From b406f5cca5c8683257fd9751bf41f6577f5e8e7e Mon Sep 17 00:00:00 2001 From: CEO Date: Sat, 15 Aug 2026 22:00:02 +0000 Subject: [PATCH 1/3] docs: highlight new Ollama (local) preset in README - Adds mention of dedicated Ollama (local) preset in provider wizard - Notes auto-detection and streamlined setup in v0.2.1 - Improves discoverability of new local model preset feature --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1b8ff1ee..94afcc18 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,8 @@ In the TUI, open the LLM tab, add a provider, and pick **Ollama (local)** (or ** Model ids are the tags the server reports, `qwen2.5:0.5b` or `llama3.2:latest` for Ollama, so use the same name you passed to `ollama pull`. The list comes from the server's own `/v1/models`, which means anything you have pulled shows up without a restart. +**New in v0.2.1**: The provider wizard now includes a dedicated **Ollama (local)** preset that auto-detects your local Ollama server and provides a streamlined setup experience. + | Preset | Endpoint | | --- | --- | | Ollama (local) | `http://localhost:11434` | From a21fd23c4056b5d3871edcbb65226c02b42f2cb3 Mon Sep 17 00:00:00 2001 From: ceo Date: Sat, 29 Aug 2026 19:31:18 +0000 Subject: [PATCH 2/3] feat: implement /max_steps slash command to adjust agent's max_steps configuration at runtime Adds a new slash command that allows users to get or set the agent's max_steps configuration without requiring a restart. The command validates input as a positive integer, updates both runtime config and persists to config.json. - Adds 'max_steps' entry to SLASH_COMMANDS registry with description - Implements dispatchMaxStepsSub function to handle command logic - Reuses existing parsePositiveInt validation from config-schema.ts - Updates getConfig().agent.maxSteps and persists via writeUserConfigFileSync - Provides clear systemMessage feedback for current value, updates, and errors - Handles persistence failures gracefully (updates runtime even if disk write fails) - Follows existing patterns from other slash commands like /theme, /model, etc. --- .gitignore | 2 +- src/tui/commands/slash-command-handler.ts | 65 +++++++++++++++++++++++ src/tui/commands/slash-commands.ts | 5 ++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4574ad0e..94fb4417 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ ARCHITECTURE.md CLAW-LOOT.md /logs/* /tmp/* -.cursor/ \ No newline at end of file +.cursor/.worktrees/ diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 7fa51b55..0ba09560 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -4,6 +4,14 @@ import { isThemeName, THEME_NAMES } from "../theme/theme.js"; import { parseSlashCommand } from "./slash-command-parser.js"; import { resolveSlashCommand, SLASH_COMMANDS } from "./slash-commands.js"; import { renderToolsOverview, renderToolsSearch } from "./tools-listing.js"; +import { getConfig } from "../../config/index.js"; +import { parsePositiveInt } from "../../config/config-schema.js"; +import { ConfigValidationError } from "../../config/config-validation-error.js"; +import { + getUserConfigPath, + ensureUserConfigFileSync, + writeUserConfigFileSync, +} from "../../config/config-file.js"; export interface SlashDispatchCallbacks { onAbort(): void; @@ -218,6 +226,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return dispatchLlmSub(parsed.args); case "model": return dispatchModelsSub(parsed.args, parsed.name); + case "max_steps": + return dispatchMaxStepsSub(parsed.args); case "tasks": return pureActions([ { type: "ui_mode_set", mode: "debug" }, @@ -739,3 +749,58 @@ function dispatchAnalyticsSub(rawArgs: string): SlashDispatchResult { systemMessage: "usage: /analytics on | off | status", }); } + +/** + * Sub-dispatcher for `/max_steps [number]`. Bare `/max_steps` shows the + * current value. `/max_steps ` sets a new positive integer value. + */ +function dispatchMaxStepsSub(rawArgs: string): SlashDispatchResult { + const args = rawArgs.trim(); + if (args.length === 0) { + // Show current value + const current = getConfig().agent.maxSteps; + return pureActions([], { + systemMessage: `current max_steps: ${current}`, + }); + } + + // Parse and validate the new value + let newValue: number; + try { + // Reuse the same validation as in config-schema.ts + newValue = parsePositiveInt(args, "max_steps"); + } catch (err) { + if (err instanceof ConfigValidationError) { + return pureActions([], { + systemMessage: err.message, + }); + } + return pureActions([], { + systemMessage: `invalid max_steps value: ${args}`, + }); + } + + // Update the runtime config + const config = getConfig(); + const oldValue = config.agent.maxSteps; + config.agent.maxSteps = newValue; + + // Persist to config.json + try { + const stateDir = getConfig().paths.stateDir; + const userConfigPath = getUserConfigPath(stateDir); + const userConfig = ensureUserConfigFileSync(userConfigPath); + userConfig.agent.maxSteps = newValue; + writeUserConfigFileSync(userConfigPath, userConfig); + } catch (err) { + // If we can't persist, return an error but still update runtime + const message = err instanceof Error ? err.message : String(err); + return pureActions([], { + systemMessage: `max_steps updated to ${newValue} (runtime only - failed to persist: ${message})`, + }); + } + + return pureActions([], { + systemMessage: `max_steps updated from ${oldValue} to ${newValue}`, + }); +} diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 779235c2..0cf9073c 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -87,6 +87,11 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ "open chat model picker · subcommands: pull | use | status | ", aliases: ["models", "local"], }, + { + name: "max_steps", + description: + "get or set the agent's max_steps configuration: `/max_steps` | `/max_steps `", + }, { name: "tasks", description: "jump to the Tasks tab (Option 4 cron + ingress UI)" }, { name: "task", From 6ba1c6c462dadd38da2082737dab26c4749f690d Mon Sep 17 00:00:00 2001 From: ceo Date: Sat, 29 Aug 2026 22:22:11 +0000 Subject: [PATCH 3/3] resolve merge conflicts + max_steps slash command --- .claude/worktrees/worktree-fix-max-steps | 1 + .github/workflows/test.yml | 130 + .../fix/telegram-approval-grant-buttons | 1 + .../telegram-approval-grant-review-followups | 1 + .worktrees/integrate-max-steps-pr | 1 + AGENTS.md | 163 +- TESTING.md | 299 + TODO.md | 49 + package-lock.json | 5 +- package.json | 3 +- pnpm-lock.yaml | 5693 +++++++++++++++++ pnpm-workspace.yaml | 4 + scripts/bundle-sea.ts | 20 + scripts/generate-logo-art.mjs | 476 ++ scripts/install.ps1 | 127 +- scripts/install.sh | 386 +- src/agent/agent-loop-steering.test.ts | 282 + src/agent/agent-loop.test.ts | 8 +- src/agent/agent-loop.ts | 171 +- src/agent/batch-executor.test.ts | 360 ++ src/agent/batch-executor.ts | 103 +- src/agent/index.ts | 1 + src/agent/loop-detector.test.ts | 150 + src/agent/loop-detector.ts | 137 +- ...tive-tool-call-execution-integrity.test.ts | 421 ++ .../parallel-tool-calls.integration.test.ts | 24 +- src/agent/plan-mode.test.ts | 79 + src/agent/plan-mode.ts | 93 + src/agent/profile-matrix.test.ts | 16 + src/agent/steer-notice.test.ts | 50 + src/agent/steer-notice.ts | 80 + src/agent/step-events.ts | 21 + src/agent/step-executor.test.ts | 500 ++ src/agent/step-executor.ts | 323 +- src/analytics/analytics-events.test.ts | 104 + src/analytics/analytics-events.ts | 75 + src/analytics/analytics-state-store.test.ts | 31 +- src/analytics/analytics-state-store.ts | 21 +- src/analytics/index.ts | 3 + src/approval/approval-gate.test.ts | 137 + src/approval/approval-gate.ts | 102 +- src/approval/dangerous-tool.ts | 35 +- src/approval/index.ts | 1 + .../telegram/approval-bridge.ts.backup | 384 ++ src/channels/telegram/approval-bridge.ts.orig | 384 ++ src/channels/telegram/inbound-handler.ts | 14 +- src/cli/bin-alias.test.ts | 43 + src/cli/config-command.test.ts | 272 +- src/cli/config-command.ts | 240 +- src/cli/config-help.ts | 57 + src/cli/debug-repl.test.ts | 40 + src/cli/debug-repl.ts | 18 +- src/cli/index.ts | 77 +- src/cli/models-command.test.ts | 100 +- src/cli/models-command.ts | 10 + src/cli/models-handlers.ts | 89 +- src/cli/models-search-command.test.ts | 236 + src/cli/models-search-command.ts | 228 + src/cli/run-agent.test.ts | 264 + src/cli/run-agent.ts | 114 +- src/cli/serve-command.ts | 3 + src/cli/skill.test.ts | 100 +- src/cli/skill.ts | 41 +- src/cli/uninstall-command.test.ts | 200 + src/cli/uninstall-command.ts | 263 + src/cli/update-command.test.ts | 202 + src/cli/update-command.ts | 211 + src/config/config-file.test.ts | 64 + src/config/config-file.ts | 105 +- src/config/config-paths.test.ts | 61 + src/config/config-paths.ts | 297 + src/config/config-schema.test.ts | 364 +- src/config/config-schema.ts | 603 +- src/config/custom-models-schema.test.ts | 108 + src/config/custom-models-schema.ts | 130 + src/config/custom-models-store.ts | 61 + src/config/index.ts | 19 + src/config/llm-config.test.ts | 303 + src/config/llm-config.ts | 336 + src/config/load-config.test.ts | 28 + src/config/load-config.ts | 35 +- src/config/provider-auth-mode.test.ts | 45 + src/config/provider-auth-mode.ts | 33 + src/error-reporting/broken-pipe.test.ts | 49 + src/error-reporting/broken-pipe.ts | 41 + src/error-reporting/error-reporter.test.ts | 16 + src/error-reporting/error-reporter.ts | 56 +- src/error-reporting/error-scrubber.test.ts | 84 + src/error-reporting/error-scrubber.ts | 76 +- src/error-reporting/index.ts | 2 + src/error-reporting/sentry-envelope.test.ts | 70 +- src/error-reporting/sentry-envelope.ts | 52 +- src/http/http-server.ts | 12 + src/http/index.ts | 6 + src/http/openai-chat-completions.test.ts | 222 + src/http/openai-chat-completions.ts | 81 + src/http/request-context.ts | 7 + src/http/route-health.test.ts | 95 + src/http/route-health.ts | 34 +- src/http/route-sessions.test.ts | 404 ++ src/http/route-sessions.ts | 236 +- src/http/route-table.ts | 18 + src/http/test-harness.ts | 23 +- src/http/undelivered-steers.test.ts | 161 + src/http/undelivered-steers.ts | 216 + src/llm/describe-llama-health-failure.test.ts | 54 + src/llm/describe-llama-health-failure.ts | 39 + src/llm/errno-code.test.ts | 50 + src/llm/errno-code.ts | 43 + src/llm/fallback/should-advance.ts | 4 +- src/llm/grammar/tool-call-grammar.ts | 9 + src/llm/index.ts | 2 + src/llm/llama-endpoint-url.test.ts | 49 + src/llm/llama-endpoint-url.ts | 32 + src/llm/llama-server-auth-probe.ts | 45 + src/llm/llama-server-client.test.ts | 36 + src/llm/llama-server-client.ts | 36 +- src/llm/llama-server-client.url.test.ts | 66 + src/llm/llama-server-health.test.ts | 81 + src/llm/llama-server-health.ts | 46 +- src/llm/model-profile.fixtures.ts | 267 + src/llm/model-profile.test.ts | 42 + src/llm/model-profile.ts | 21 + .../aimlapi/aimlapi-models-catalog.test.ts | 28 +- .../aimlapi/aimlapi-models-catalog.ts | 193 +- .../provider/aimlapi/aimlapi-provider.test.ts | 25 + src/llm/provider/aimlapi/aimlapi-provider.ts | 24 +- src/llm/provider/catalog-for-provider.test.ts | 84 + src/llm/provider/catalog-for-provider.ts | 34 + src/llm/provider/completion-types.ts | 9 + src/llm/provider/format-model-details.ts | 57 + src/llm/provider/index.ts | 6 + .../llama-server/llama-server-vision.ts | 3 +- src/llm/provider/model-catalog-entry.ts | 65 + src/llm/provider/model-search.test.ts | 251 + src/llm/provider/model-search.ts | 197 + .../openai/ascii-header-guard.test.ts | 103 + src/llm/provider/openai/ascii-header-guard.ts | 31 + .../openai/fetch-openai-compat-models.test.ts | 57 +- .../openai/fetch-openai-compat-models.ts | 19 +- .../provider/openai/merge-tool-name.test.ts | 47 + .../provider/openai/openai-auth-headers.ts | 69 + .../provider/openai/openai-build-body.test.ts | 85 + src/llm/provider/openai/openai-build-body.ts | 19 +- src/llm/provider/openai/openai-http.test.ts | 58 + src/llm/provider/openai/openai-http.ts | 62 +- src/llm/provider/openai/openai-provider.ts | 54 +- .../provider/openai/openai-stream-consumer.ts | 71 +- .../openai/openai-tool-call-adapter.test.ts | 65 + .../openai/openai-tool-call-adapter.ts | 46 +- .../fetch-openrouter-chat-catalog.test.ts | 7 +- .../fetch-openrouter-chat-catalog.ts | 28 +- .../openrouter-frontier-chat-models.ts | 168 + .../openrouter-models-catalog.test.ts | 41 +- .../openrouter/openrouter-models-catalog.ts | 252 +- .../openrouter-open-weight-chat-models.ts | 246 + .../registry/provider-registry.test.ts | 1 + src/llm/provider/registry/provider-types.ts | 25 + .../registry/register-built-in-providers.ts | 43 + .../claude-cli-adapter.test.ts | 311 + .../subscription-cli/claude-cli-adapter.ts | 273 + .../subscription-cli/claude-cli-models.ts | 41 + .../cli-adapter-descriptor.ts | 81 + .../codex-cli-adapter.test.ts | 158 + .../subscription-cli/codex-cli-adapter.ts | 202 + src/llm/provider/subscription-cli/index.ts | 41 + .../subscription-cli/register-cli-adapters.ts | 17 + .../resolve-cli-binary.test.ts | 34 + .../subscription-cli/resolve-cli-binary.ts | 38 + .../run-cli-completion.test.ts | 60 + .../subscription-cli/run-cli-completion.ts | 97 + .../stream-cli-completion.test.ts | 236 + .../subscription-cli/stream-cli-completion.ts | 177 + .../subscription-cli-errors.test.ts | 106 + .../subscription-cli-errors.ts | 127 + .../subscription-cli-provider.test.ts | 283 + .../subscription-cli-provider.ts | 287 + .../verify/classify-verify-response.test.ts | 98 + .../verify/classify-verify-response.ts | 91 + src/llm/provider/verify/index.ts | 20 + .../provider/verify/pick-probe-models.test.ts | 109 + src/llm/provider/verify/pick-probe-models.ts | 84 + .../verify/verify-provider-key.test.ts | 164 + .../provider/verify/verify-provider-key.ts | 198 + src/llm/provider/verify/verify-types.ts | 67 + src/llm/reliability/classify-failure.test.ts | 30 + src/llm/reliability/classify-failure.ts | 12 + src/llm/reliability/index.ts | 4 + src/llm/reliability/network-error.test.ts | 78 + src/llm/reliability/network-error.ts | 106 + src/local-llm/backend-installer.test.ts | 363 +- src/local-llm/backend-installer.ts | 355 +- src/local-llm/backend-staging.ts | 267 + src/local-llm/backend-version.ts | 32 +- src/local-llm/download-file.test.ts | 76 + src/local-llm/download-file.ts | 42 +- src/local-llm/ensure-latest-backend.test.ts | 214 + src/local-llm/ensure-latest-backend.ts | 110 + src/local-llm/huggingface-api.ts | 97 + src/local-llm/huggingface-fit.test.ts | 92 + src/local-llm/huggingface-fit.ts | 141 + src/local-llm/huggingface-model-def.ts | 77 + src/local-llm/huggingface-ref.test.ts | 139 + src/local-llm/huggingface-ref.ts | 126 + src/local-llm/huggingface-resolve.test.ts | 182 + src/local-llm/huggingface-resolve.ts | 132 + src/local-llm/index.ts | 40 + src/local-llm/models-catalog.test.ts | 59 +- src/local-llm/models-catalog.ts | 93 +- src/prompt/build-prompt-types.ts | 42 + src/prompt/build-prompt.test.ts | 5 + src/prompt/build-prompt.ts | 50 +- src/prompt/conversation-cap-auto.test.ts | 151 + src/prompt/default-tool-args-schemas.test.ts | 39 + src/prompt/default-tool-args-schemas.ts | 8 +- src/prompt/default-tool-descriptors-a.ts | 2 +- src/prompt/default-tool-descriptors-b.ts | 4 +- src/prompt/token-budget.test.ts | 59 + src/prompt/token-budget.ts | 33 + src/runtime/bootstrap-queued-turn.test.ts | 136 + src/runtime/bootstrap.test.ts | 224 + src/runtime/bootstrap.ts | 344 +- src/runtime/heap-guard.test.ts | 83 + src/runtime/heap-guard.ts | 111 + src/runtime/recorder-eviction.test.ts | 160 + src/runtime/steering-inbox.test.ts | 144 + src/runtime/steering-inbox.ts | 141 + src/sandbox/command-runner.test.ts | 112 + src/sandbox/command-runner.ts | 51 + src/sandbox/index.ts | 2 +- src/session/conversation-pairs.test.ts | 200 + src/session/conversation-turn.test.ts | 76 +- src/session/conversation-turn.ts | 256 +- src/session/session-exit-status.test.ts | 45 + src/session/session-state.ts | 71 +- src/sidecar/index.ts | 1 + src/sidecar/main.ts | 32 +- src/sidecar/sidecar-events.ts | 38 + src/sidecar/stdio-protocol.test.ts | 73 + src/sidecar/stdio-protocol.ts | 46 +- src/sidecar/steer-message.test.ts | 157 + src/tasks/task-runner.ts | 10 + src/tools/coerce-tool-args.test.ts | 217 + src/tools/coerce-tool-args.ts | 83 + src/tools/os/archive/tar-backend.ts.bak | 269 + src/tools/os/expand-shell-glob-args.test.ts | 84 +- src/tools/os/expand-shell-glob-args.ts | 77 +- src/tools/os/fs-glob-real.test.ts | 24 - src/tools/os/fs-grep.test.ts | 116 +- src/tools/os/fs-grep.ts | 65 +- src/tools/os/fs-require-approval.ts | 35 +- src/tools/os/fs-write-retarget.test.ts | 181 + src/tools/os/fs-write.ts | 97 +- src/tools/os/http-request-curl-meta.test.ts | 99 + src/tools/os/http-request-fetch.ts | 290 +- src/tools/os/http-request-retry.test.ts | 559 ++ src/tools/os/http-request.test.ts | 32 +- src/tools/os/http-request.ts | 2 +- src/tools/os/index.ts | 2 +- .../pdf-extractor.canvas-warnings.test.ts | 318 + .../read-document/extractors/pdf-extractor.ts | 106 +- src/tools/os/retry-after-header.test.ts | 31 + src/tools/os/retry-after-header.ts | 36 + src/tools/os/shell.ts | 9 +- src/tools/os/web-fetch-challenge.test.ts | 111 + src/tools/os/web-fetch-challenge.ts | 108 + src/tools/os/web-fetch-ssrf-guard.test.ts | 51 +- src/tools/os/web-fetch-ssrf-guard.ts | 45 +- src/tools/os/web-fetch.test.ts | 472 +- src/tools/os/web-fetch.ts | 305 +- .../providers/assert-provider-status.ts | 36 + .../os/web-search/providers/brave-provider.ts | 5 +- .../providers/duckduckgo-provider.ts | 5 +- .../os/web-search/providers/exa-provider.ts | 9 +- .../providers/search-orchestrator.test.ts | 171 +- .../providers/search-orchestrator.ts | 67 +- .../web-search/providers/searxng-provider.ts | 5 +- src/tools/os/web-search/tool/index.ts | 2 + .../tool/warn-missing-search-key.test.ts | 96 + .../tool/warn-missing-search-key.ts | 91 + .../web-search/tool/web-search-tool.test.ts | 39 + .../os/web-search/tool/web-search-tool.ts | 45 +- src/tools/os/web-search/transport/index.ts | 15 + .../transport/provider-cooldown.test.ts | 99 + .../web-search/transport/provider-cooldown.ts | 139 + .../web-search/transport/retry-after.test.ts | 67 + .../os/web-search/transport/retry-after.ts | 71 + .../web-search/transport/search-http.test.ts | 217 + .../os/web-search/transport/search-http.ts | 115 +- src/tools/os/web-search/web-search-errors.ts | 30 + src/tools/tool-registry.ts | 7 +- src/tools/vision/describe.test.ts | 82 + src/tools/vision/describe.ts | 7 +- src/tracing/agent-metrics.ts | 34 + src/tui/agent-event-reducer.test.ts | 452 +- src/tui/agent-event-reducer.ts | 249 +- src/tui/alt-screen.ts | 14 +- src/tui/app-key-bindings-selection.test.ts | 117 + src/tui/app-key-bindings.test.ts | 429 +- src/tui/app-key-bindings.ts | 884 ++- src/tui/approval-key-arbitration.test.ts | 272 + src/tui/approval-live-composer.test.tsx | 186 + src/tui/approval-modal.test.tsx | 155 +- src/tui/approval-modal.tsx | 293 +- src/tui/backdrop-dismissal.test.ts | 115 + src/tui/backdrop-dismissal.ts | 57 + src/tui/build-terminal-launch.test.ts | 170 + src/tui/build-terminal-launch.ts | 277 + src/tui/chat-loop-reducer.test.ts | 40 +- src/tui/chat-orchestrator-steering.test.ts | 422 ++ src/tui/chat-orchestrator-switch.test.ts | 410 ++ src/tui/chat-orchestrator.test.ts | 450 ++ src/tui/chat-orchestrator.ts | 804 ++- src/tui/clipboard/clipboard-context.tsx | 98 + src/tui/clipboard/copy-to-clipboard.test.ts | 212 + src/tui/clipboard/copy-to-clipboard.ts | 211 + src/tui/clipboard/index.ts | 31 + src/tui/clipboard/read-clipboard.ts | 66 + src/tui/coding-mode-menu.test.tsx | 185 + src/tui/coding-mode.test.ts | 112 + src/tui/coding-mode.ts | 154 + .../commands/slash-command-handler.test.ts | 90 +- src/tui/components/assistant-bubble.tsx | 18 +- src/tui/components/chat-copy-button.test.tsx | 294 + src/tui/components/chat-copy-button.tsx | 95 + src/tui/components/chat-log.test.tsx | 6 +- src/tui/components/chat-log.tsx | 82 +- .../components/chat-message-height.test.ts | 14 +- src/tui/components/chat-message-height.ts | 13 +- .../components/chat-try-again-button.test.tsx | 237 + src/tui/components/chat-try-again-button.tsx | 141 + src/tui/components/chip.tsx | 67 + .../cloud-provider-onboarding-mouse.test.tsx | 150 + .../cloud-provider-onboarding.test.tsx | 323 + .../components/cloud-provider-onboarding.tsx | 153 +- src/tui/components/coding-mode-chip.tsx | 78 + src/tui/components/coding-mode-popup.tsx | 258 + .../composer-overlay.mouse.test.tsx | 244 + src/tui/components/composer-overlay.test.tsx | 250 + src/tui/components/composer-overlay.tsx | 128 + src/tui/components/composer-send-button.tsx | 74 + src/tui/components/context-chip.test.tsx | 225 + src/tui/components/context-chip.tsx | 199 + src/tui/components/context-panel.test.tsx | 323 + src/tui/components/context-panel.tsx | 404 ++ src/tui/components/debug-pane-budget.test.ts | 54 + src/tui/components/debug-pane.tsx | 179 +- src/tui/components/download-chip.test.tsx | 122 + src/tui/components/download-chip.tsx | 86 + src/tui/components/fit-to-width.ts | 17 + src/tui/components/format-tokens.ts | 18 + src/tui/components/hf-pick-list.tsx | 123 + src/tui/components/hf-reference-editor.tsx | 98 + src/tui/components/hotkey-chips.ts | 320 + src/tui/components/hotkey-hint-modes.test.tsx | 106 + src/tui/components/hotkey-hint.test.tsx | 240 +- src/tui/components/hotkey-hint.tsx | 146 +- src/tui/components/llm-fallback-rows.test.tsx | 177 + src/tui/components/llm-fallback-rows.tsx | 101 +- src/tui/components/llm-health-badge.tsx | 48 +- .../components/llm-mode-rows-cloud.test.tsx | 18 + src/tui/components/llm-mode-rows.tsx | 79 +- src/tui/components/llm-panel-modals.tsx | 59 +- src/tui/components/llm-panel.test.tsx | 114 + src/tui/components/llm-panel.tsx | 58 +- .../local-models-config-wizard.test.tsx | 77 - .../components/local-models-config-wizard.tsx | 281 - src/tui/components/local-models-hf-branch.tsx | 76 + src/tui/components/local-models-panel.tsx | 58 +- src/tui/components/logo-art.generated.test.ts | 81 + src/tui/components/logo-art.ts | 184 + src/tui/components/logo-fit.test.ts | 37 + src/tui/components/logo.test.tsx | 8 +- src/tui/components/logo.tsx | 190 +- src/tui/components/mcp-add-modal.tsx | 5 + src/tui/components/mcp-list.tsx | 15 +- src/tui/components/memory-list.tsx | 13 +- src/tui/components/multi-line-editor-body.tsx | 232 +- .../components/multi-line-editor-clipboard.ts | 99 + src/tui/components/multi-line-editor-edits.ts | 75 + .../components/multi-line-editor-keys.test.ts | 197 + src/tui/components/multi-line-editor-keys.ts | 297 + .../multi-line-editor-newline.test.tsx | 66 + .../multi-line-editor-paste.test.tsx | 77 + .../components/multi-line-editor-pointer.ts | 84 + .../multi-line-editor-selection-flag.test.tsx | 111 + .../multi-line-editor-selection.test.tsx | 255 + src/tui/components/multi-line-editor.test.tsx | 46 + src/tui/components/multi-line-editor.tsx | 368 +- .../components/onboarding-atom-field.test.tsx | 77 + src/tui/components/onboarding-atom-field.tsx | 57 + src/tui/components/onboarding-choose-step.tsx | 111 + .../onboarding-download-ambient.test.tsx | 221 + .../onboarding-download-ambient.tsx | 92 + .../onboarding-download-frame.test.tsx | 198 + .../onboarding-download-progress.tsx | 109 + .../onboarding-download-step.test.tsx | 154 + .../components/onboarding-download-step.tsx | 213 + src/tui/components/onboarding-header.test.tsx | 33 + src/tui/components/onboarding-header.tsx | 100 + .../components/onboarding-hf-flow.test.tsx | 150 + src/tui/components/onboarding-hf-flow.tsx | 58 + .../components/onboarding-hf-pick-step.tsx | 59 + src/tui/components/onboarding-hf-ref-step.tsx | 45 + .../components/onboarding-hf-steps.test.tsx | 210 + .../components/onboarding-intro-step.test.tsx | 146 + src/tui/components/onboarding-intro-step.tsx | 211 + .../components/onboarding-local-pick-step.tsx | 185 + src/tui/components/onboarding-mouse.test.tsx | 417 ++ .../onboarding-propose-step.test.tsx | 37 + .../components/onboarding-propose-step.tsx | 115 + src/tui/components/onboarding-screen.test.tsx | 550 ++ src/tui/components/onboarding-screen.tsx | 285 + src/tui/components/onboarding-step-body.tsx | 161 + .../onboarding-surface-layout.test.tsx | 258 + .../components/onboarding-surface-layout.ts | 150 + src/tui/components/onboarding-url-step.tsx | 90 + .../onboarding-wait-or-jump-step.test.tsx | 142 + .../onboarding-wait-or-jump-step.tsx | 211 + src/tui/components/plan-handoff.tsx | 164 + src/tui/components/prompt-meta-bar.test.tsx | 188 + src/tui/components/prompt-meta-bar.tsx | 220 + src/tui/components/prompt-shell.test.tsx | 142 +- src/tui/components/prompt-shell.tsx | 259 +- src/tui/components/providers-panel.tsx | 60 +- .../providers-wizard-measure.test.tsx | 56 + .../components/providers-wizard-measure.ts | 102 + src/tui/components/providers-wizard.test.tsx | 202 +- src/tui/components/providers-wizard.tsx | 267 +- src/tui/components/queued-messages.test.tsx | 36 + src/tui/components/queued-messages.tsx | 61 + .../components/render-progress-bar.test.ts | 22 + src/tui/components/render-progress-bar.ts | 17 + src/tui/components/session-delete-modal.tsx | 194 + src/tui/components/session-picker.tsx | 30 +- src/tui/components/session-title.test.ts | 45 + src/tui/components/session-title.ts | 22 + src/tui/components/sidebar-fit.test.tsx | 117 + src/tui/components/sidebar.test.tsx | 121 +- src/tui/components/sidebar.tsx | 689 +- src/tui/components/skills-hub-list.tsx | 16 +- src/tui/components/skills-list.tsx | 13 +- src/tui/components/slash-palette.tsx | 26 +- src/tui/components/splash-banner.test.tsx | 120 +- src/tui/components/splash-banner.tsx | 115 +- src/tui/components/splash-fit.render.test.tsx | 87 + src/tui/components/splash-fit.test.ts | 199 + src/tui/components/splash-fit.ts | 337 + src/tui/components/status-bar.test.tsx | 69 + src/tui/components/status-bar.tsx | 185 +- src/tui/components/tasks-list.tsx | 18 +- .../components/terminal-too-small.test.tsx | 100 + src/tui/components/terminal-too-small.tsx | 94 + src/tui/components/theme-picker.tsx | 41 +- src/tui/components/tool-card.tsx | 37 +- src/tui/components/uninstall-modal.test.tsx | 93 + src/tui/components/uninstall-modal.tsx | 354 + src/tui/components/user-bubble.tsx | 18 +- src/tui/components/wizard-pick-list.test.tsx | 163 +- src/tui/components/wizard-pick-list.tsx | 307 +- src/tui/composer-ink.test.tsx | 106 + .../composer-meta-controls.test.tsx | 186 + .../composer-meta-controls.tsx | 275 + .../composer-switch-actions.ts | 23 + .../composer-switch-activate.test.ts | 184 + .../composer-switch-activate.ts | 183 + .../composer-switch-app.test.tsx | 274 + .../composer-switch-filter.test.ts | 54 + .../composer-switch/composer-switch-filter.ts | 59 + .../composer-switch-fixtures.ts | 95 + .../composer-switch-key-bindings.test.ts | 231 + .../composer-switch-key-bindings.ts | 149 + .../composer-switch-popup.test.tsx | 277 + .../composer-switch/composer-switch-popup.tsx | 310 + .../composer-switch-reducer.ts | 80 + .../composer-switch-rows.test.ts | 302 + .../composer-switch/composer-switch-rows.ts | 311 + .../composer-switch/composer-switch-state.ts | 78 + src/tui/composer-switch/index.ts | 39 + src/tui/composer-visibility.test.tsx | 114 + .../context-menu/context-menu-app.test.tsx | 327 + src/tui/context-menu/context-menu-context.tsx | 86 + src/tui/context-menu/context-menu-popup.tsx | 136 + .../context-menu/context-menu-state.test.ts | 36 + src/tui/context-menu/context-menu-state.ts | 85 + src/tui/context-menu/index.ts | 24 + src/tui/context-menu/paste-field-target.tsx | 87 + src/tui/context-pairs-selection.test.ts | 154 + src/tui/context-panel-keys.test.ts | 159 + src/tui/context-panel-keys.ts | 89 + src/tui/context-usage-from-prompt.test.ts | 97 + src/tui/context-usage-from-prompt.ts | 80 + src/tui/detached-turns.test.ts | 107 + src/tui/detached-turns.ts | 203 + src/tui/detect-kitty-keyboard.test.ts | 121 + src/tui/detect-kitty-keyboard.ts | 108 + src/tui/escape-abort-running.test.tsx | 143 + src/tui/escape-chat-editor.test.tsx | 193 + src/tui/escape-import-tab.test.tsx | 64 + src/tui/escape-observe-tabs.test.tsx | 91 + src/tui/format-agent-error-for-chat.test.ts | 30 + src/tui/format-agent-error-for-chat.ts | 22 +- src/tui/hooks/use-atom-field.ts | 63 + src/tui/hooks/use-onboarding-huggingface.ts | 121 + src/tui/hooks/use-onboarding-inputs.ts | 30 + .../hooks/use-onboarding-lifecycle.test.tsx | 88 + src/tui/hooks/use-onboarding-lifecycle.ts | 114 + src/tui/hooks/use-onboarding-url-actions.ts | 115 + .../hooks/use-rotating-placeholder.test.tsx | 55 +- src/tui/hooks/use-rotating-placeholder.ts | 16 +- src/tui/hooks/use-transfer-rate.ts | 67 + src/tui/hooks/use-transient-status.ts | 53 + src/tui/hooks/use-typewriter.test.tsx | 60 + src/tui/hooks/use-typewriter.ts | 47 + src/tui/import/import-key-bindings.ts | 6 + src/tui/index.ts | 1 + src/tui/input-history-keys.test.tsx | 104 + src/tui/layout.test.ts | 149 + src/tui/layout.ts | 234 + src/tui/llm-health/llm-health-poller.test.ts | 83 +- src/tui/llm-health/llm-health-poller.ts | 13 +- src/tui/llm-health/llm-health-state.ts | 17 +- .../fallback/fallback-key-bindings.test.ts | 64 +- .../fallback/fallback-key-bindings.ts | 45 +- .../fallback/fallback-orchestrator.test.ts | 43 +- .../fallback/fallback-orchestrator.ts | 79 +- .../fallback/fallback-panel-actions.ts | 25 +- .../fallback/fallback-panel-reducer.test.ts | 33 + .../fallback/fallback-panel-reducer.ts | 37 +- .../llm-panel/fallback/fallback-seam.test.ts | 187 + .../llm-panel/llm-panel-key-bindings.test.ts | 3 + src/tui/llm-panel/llm-panel-key-bindings.ts | 16 + .../llm-panel/llm-panel-modal-key-bindings.ts | 4 + src/tui/llm-panel/llm-panel-paste.ts | 63 + .../llm-panel/llm-panel-primary-actions.ts | 35 +- src/tui/llm-panel/llm-panel-row-builders.ts | 73 +- src/tui/llm-panel/llm-panel-selectors.test.ts | 161 +- src/tui/llm-panel/llm-panel-selectors.ts | 44 +- src/tui/local-backend-readiness.test.ts | 360 ++ src/tui/local-backend-readiness.ts | 99 + src/tui/local-models/local-models-actions.ts | 15 + src/tui/local-models/local-models-hf-keys.ts | 88 + .../local-models-key-bindings.test.ts | 247 + .../local-models/local-models-key-bindings.ts | 17 + ...al-models-orchestrator-auto-update.test.ts | 298 + .../local-models-orchestrator-pairing.test.ts | 57 +- .../local-models-orchestrator.test.ts | 55 +- .../local-models/local-models-orchestrator.ts | 350 +- .../local-models/local-models-panel-state.ts | 67 +- .../local-models/local-models-reducer.test.ts | 101 +- src/tui/local-models/local-models-reducer.ts | 104 +- src/tui/local-turn-gate.test.ts | 263 + src/tui/local-turn-gate.ts | 188 + src/tui/make-event-bus.ts | 14 +- src/tui/menu/menu-behaviour.test.ts | 170 + src/tui/menu/menu-keys.ts | 165 + src/tui/menu/menu-popup.tsx | 373 ++ src/tui/menu/menu-registry.test.ts | 329 + src/tui/menu/menu-registry.ts | 710 ++ src/tui/menu/menu-selectors.ts | 166 + src/tui/minimum-window-size.test.tsx | 108 + src/tui/mouse/index.ts | 41 + src/tui/mouse/mouse-app.test.tsx | 590 ++ src/tui/mouse/mouse-context.tsx | 130 + src/tui/mouse/mouse-event.test.ts | 48 + src/tui/mouse/mouse-event.ts | 69 + src/tui/mouse/mouse-list-row.tsx | 95 + src/tui/mouse/mouse-registry.test.ts | 224 + src/tui/mouse/mouse-registry.ts | 244 + src/tui/mouse/mouse-source.ts | 29 + src/tui/mouse/mouse-stdin.test.ts | 96 + src/tui/mouse/mouse-stdin.ts | 82 + src/tui/mouse/mouse-tracking.test.ts | 114 + src/tui/mouse/mouse-tracking.ts | 85 + src/tui/mouse/parse-mouse-events.test.ts | 107 + src/tui/mouse/parse-mouse-events.ts | 163 + src/tui/mouse/selection-passthrough.test.ts | 151 + src/tui/mouse/selection-passthrough.ts | 151 + src/tui/mouse/synthetic-key.ts | 60 + .../onboarding/atom-field-population.test.ts | 80 + src/tui/onboarding/atom-field-rows.test.ts | 121 + src/tui/onboarding/atom-field-rows.ts | 82 + src/tui/onboarding/atom-field.test.ts | 245 + src/tui/onboarding/atom-field.ts | 274 + .../centre-onboarding-block.test.ts | 124 + src/tui/onboarding/centre-onboarding-block.ts | 73 + src/tui/onboarding/index.ts | 2 + src/tui/onboarding/intro-art.test.ts | 133 + src/tui/onboarding/intro-art.ts | 127 + src/tui/onboarding/intro-input.test.ts | 60 + src/tui/onboarding/intro-input.ts | 41 + src/tui/onboarding/local-model-picks.test.ts | 135 + src/tui/onboarding/local-model-picks.ts | 137 + src/tui/onboarding/mark-clear-space.test.ts | 138 + src/tui/onboarding/mark-clear-space.ts | 70 + src/tui/onboarding/needs-onboarding.test.ts | 98 + src/tui/onboarding/needs-onboarding.ts | 36 + src/tui/onboarding/onboarding-actions.ts | 47 + src/tui/onboarding/onboarding-chrome.test.ts | 22 + src/tui/onboarding/onboarding-chrome.ts | 74 + src/tui/onboarding/onboarding-fit.test.ts | 39 + src/tui/onboarding/onboarding-fit.ts | 74 + src/tui/onboarding/onboarding-hf-keys.ts | 71 + .../onboarding-key-bindings.test.ts | 121 + src/tui/onboarding/onboarding-key-bindings.ts | 75 + src/tui/onboarding/onboarding-reducer.test.ts | 458 ++ src/tui/onboarding/onboarding-reducer.ts | 266 + src/tui/onboarding/onboarding-rows.ts | 15 + src/tui/onboarding/onboarding-state.ts | 182 + .../onboarding/onboarding-step-keys.test.ts | 234 + src/tui/onboarding/onboarding-step-keys.ts | 307 + src/tui/onboarding/orbit-field.test.ts | 61 + src/tui/onboarding/orbit-field.ts | 72 + .../onboarding/propose-second-backend.test.ts | 91 + src/tui/onboarding/propose-second-backend.ts | 62 + src/tui/onboarding/star-field.test.ts | 208 + src/tui/onboarding/star-field.ts | 266 + src/tui/onboarding/star-tiers.test.ts | 65 + src/tui/onboarding/star-tiers.ts | 72 + src/tui/onboarding/use-intro-input.test.tsx | 263 + src/tui/onboarding/use-intro-input.ts | 109 + src/tui/open-terminal-window.test.ts | 140 + src/tui/open-terminal-window.ts | 184 + src/tui/persist-conversation-max-pairs.ts | 33 + .../persist-embedding-hybrid-recall.test.ts | 4 + src/tui/persist-onboarding-state.test.ts | 64 + src/tui/persist-onboarding-state.ts | 28 + .../persist-user-local-models-config.test.ts | 88 + src/tui/persist-user-local-models-config.ts | 47 +- src/tui/persist-user-tui-config.ts | 30 + src/tui/plan-handoff-keys.test.tsx | 162 + src/tui/plan-handoff.test.tsx | 252 + src/tui/providers/describe-verify-outcome.ts | 40 + src/tui/providers/is-local-provider-url.ts | 22 + src/tui/providers/provider-presets.test.ts | 158 +- src/tui/providers/provider-presets.ts | 115 +- src/tui/providers/providers-actions.ts | 15 +- .../providers-inline-models-flow.test.ts | 53 +- .../providers/providers-key-bindings.test.ts | 140 + src/tui/providers/providers-key-bindings.ts | 29 +- src/tui/providers/providers-model-options.ts | 66 +- .../providers/providers-orchestrator.test.ts | 166 + src/tui/providers/providers-orchestrator.ts | 112 +- src/tui/providers/providers-panel-state.ts | 33 +- src/tui/providers/providers-reducer.ts | 30 +- .../providers-wizard-build-entry.test.ts | 52 + .../providers/providers-wizard-build-entry.ts | 56 +- .../providers/providers-wizard-filter.test.ts | 144 + src/tui/providers/providers-wizard-filter.ts | 62 + .../providers-wizard-key-bindings.test.ts | 294 +- .../providers-wizard-key-bindings.ts | 133 +- .../providers/providers-wizard-kind-labels.ts | 40 + .../providers-wizard-list-keys.test.ts | 215 + .../providers/providers-wizard-list-keys.ts | 104 + src/tui/providers/providers-wizard-paste.ts | 27 + src/tui/providers/providers-wizard-phases.ts | 166 +- src/tui/providers/providers-wizard-state.ts | 66 +- .../providers/providers-wizard-target.test.ts | 326 + src/tui/providers/providers-wizard-target.ts | 296 + src/tui/providers/route-wizard-key.ts | 92 + .../providers/save-provider-wizard.test.ts | 91 +- src/tui/providers/save-provider-wizard.ts | 56 +- .../verify-wizard-before-save.test.ts | 142 + .../providers/verify-wizard-before-save.ts | 51 + src/tui/rail-session-list.test.ts | 224 + src/tui/reduce-session-actions.test.ts | 111 + src/tui/reduce-ui-actions.test.ts | 177 +- src/tui/reduce-ui-actions.ts | 216 +- src/tui/reducer-helpers.ts | 51 +- .../run-local-models-config-wizard.test.ts | 79 - src/tui/run-local-models-config-wizard.ts | 102 - src/tui/select-context-usage.test.ts | 252 + src/tui/select-context-usage.ts | 252 + src/tui/session-delete-keys.test.ts | 128 + src/tui/shift-enter-support.ts | 22 + src/tui/skills/skills-actions.ts | 2 + src/tui/skills/skills-reducer.ts | 5 + src/tui/submit-handler.test.ts | 384 ++ src/tui/submit-handler.ts | 141 +- src/tui/synchronized-output.test.ts | 115 + src/tui/synchronized-output.ts | 123 + src/tui/tasks/tasks-list-fit.test.ts | 238 + src/tui/tasks/tasks-list-fit.ts | 380 ++ src/tui/terminal-restore.test.ts | 84 + src/tui/terminal-restore.ts | 103 + src/tui/test-fixtures.ts | 2 + src/tui/test-sized-render.ts | 89 + src/tui/theme/color-contrast.ts | 34 + src/tui/theme/color-luminance.ts | 14 + .../theme/detect-terminal-background.test.ts | 15 +- src/tui/theme/detect-terminal-background.ts | 46 +- src/tui/theme/github-themes.test.ts | 67 - src/tui/theme/index.ts | 6 + src/tui/theme/mix-color.test.ts | 29 + src/tui/theme/mix-color.ts | 34 + src/tui/theme/parse-hex-color.test.ts | 33 + src/tui/theme/parse-hex-color.ts | 53 + src/tui/theme/readable-foreground.test.ts | 115 + src/tui/theme/readable-foreground.ts | 31 + src/tui/theme/theme-contrast.test.ts | 188 + src/tui/theme/theme-palettes.test.ts | 140 - src/tui/theme/theme-palettes.ts | 557 +- src/tui/theme/theme.test.ts | 65 +- src/tui/theme/theme.ts | 293 +- src/tui/tui-action.ts | 115 +- src/tui/tui-app.test.tsx | 286 +- src/tui/tui-app.tsx | 1374 +++- src/tui/tui-args.help.test.ts | 20 + src/tui/tui-args.test.ts | 47 + src/tui/tui-args.ts | 56 +- src/tui/tui-command.mouse.test.ts | 254 + src/tui/tui-command.ts | 462 +- src/tui/tui-state.ts | 313 +- src/tui/uninstall-modal-focus.test.tsx | 87 + src/tui/uninstall/uninstall-actions.ts | 34 + src/tui/uninstall/uninstall-keys.test.ts | 201 + src/tui/uninstall/uninstall-orchestrator.ts | 103 + src/tui/uninstall/uninstall-reducer.test.ts | 141 + src/tui/uninstall/uninstall-reducer.ts | 58 + src/tui/uninstall/uninstall-state.ts | 73 + src/tui/usage-at-pairs.test.ts | 99 + src/uninstall/index.ts | 32 + src/uninstall/measure-uninstall-plan.ts | 81 + src/uninstall/resolve-uninstall-plan.ts | 88 + src/uninstall/run-uninstall.test.ts | 149 + src/uninstall/run-uninstall.ts | 115 + .../strip-installer-path-line.test.ts | 69 + src/uninstall/strip-installer-path-line.ts | 43 + src/uninstall/uninstall-targets.test.ts | 112 + src/uninstall/uninstall-targets.ts | 164 + src/update/run-app-update.test.ts | 40 + src/update/run-app-update.ts | 30 +- starter-skills/docker/SKILL.md | 104 +- starter-skills/ffmpeg/SKILL.md | 4 +- starter-skills/github/SKILL.md | 9 +- starter-skills/imagemagick/SKILL.md | 4 +- 736 files changed, 93218 insertions(+), 3999 deletions(-) create mode 160000 .claude/worktrees/worktree-fix-max-steps create mode 100644 .github/workflows/test.yml create mode 160000 .worktrees/fix/telegram-approval-grant-buttons create mode 160000 .worktrees/fix/telegram-approval-grant-review-followups create mode 160000 .worktrees/integrate-max-steps-pr create mode 100644 TESTING.md create mode 100644 TODO.md create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/generate-logo-art.mjs create mode 100644 src/agent/agent-loop-steering.test.ts create mode 100644 src/agent/native-tool-call-execution-integrity.test.ts create mode 100644 src/agent/plan-mode.test.ts create mode 100644 src/agent/plan-mode.ts create mode 100644 src/agent/steer-notice.test.ts create mode 100644 src/agent/steer-notice.ts create mode 100644 src/channels/telegram/approval-bridge.ts.backup create mode 100644 src/channels/telegram/approval-bridge.ts.orig create mode 100644 src/cli/bin-alias.test.ts create mode 100644 src/cli/config-help.ts create mode 100644 src/cli/debug-repl.test.ts create mode 100644 src/cli/models-search-command.test.ts create mode 100644 src/cli/models-search-command.ts create mode 100644 src/cli/run-agent.test.ts create mode 100644 src/cli/uninstall-command.test.ts create mode 100644 src/cli/uninstall-command.ts create mode 100644 src/cli/update-command.test.ts create mode 100644 src/cli/update-command.ts create mode 100644 src/config/config-paths.test.ts create mode 100644 src/config/config-paths.ts create mode 100644 src/config/custom-models-schema.test.ts create mode 100644 src/config/custom-models-schema.ts create mode 100644 src/config/custom-models-store.ts create mode 100644 src/config/provider-auth-mode.test.ts create mode 100644 src/config/provider-auth-mode.ts create mode 100644 src/error-reporting/broken-pipe.test.ts create mode 100644 src/error-reporting/broken-pipe.ts create mode 100644 src/http/route-health.test.ts create mode 100644 src/http/undelivered-steers.test.ts create mode 100644 src/http/undelivered-steers.ts create mode 100644 src/llm/describe-llama-health-failure.test.ts create mode 100644 src/llm/describe-llama-health-failure.ts create mode 100644 src/llm/errno-code.test.ts create mode 100644 src/llm/errno-code.ts create mode 100644 src/llm/llama-endpoint-url.test.ts create mode 100644 src/llm/llama-endpoint-url.ts create mode 100644 src/llm/llama-server-auth-probe.ts create mode 100644 src/llm/llama-server-client.url.test.ts create mode 100644 src/llm/provider/catalog-for-provider.test.ts create mode 100644 src/llm/provider/catalog-for-provider.ts create mode 100644 src/llm/provider/format-model-details.ts create mode 100644 src/llm/provider/model-catalog-entry.ts create mode 100644 src/llm/provider/model-search.test.ts create mode 100644 src/llm/provider/model-search.ts create mode 100644 src/llm/provider/openai/ascii-header-guard.test.ts create mode 100644 src/llm/provider/openai/ascii-header-guard.ts create mode 100644 src/llm/provider/openai/merge-tool-name.test.ts create mode 100644 src/llm/provider/openai/openai-auth-headers.ts create mode 100644 src/llm/provider/openrouter/openrouter-frontier-chat-models.ts create mode 100644 src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts create mode 100644 src/llm/provider/subscription-cli/claude-cli-adapter.test.ts create mode 100644 src/llm/provider/subscription-cli/claude-cli-adapter.ts create mode 100644 src/llm/provider/subscription-cli/claude-cli-models.ts create mode 100644 src/llm/provider/subscription-cli/cli-adapter-descriptor.ts create mode 100644 src/llm/provider/subscription-cli/codex-cli-adapter.test.ts create mode 100644 src/llm/provider/subscription-cli/codex-cli-adapter.ts create mode 100644 src/llm/provider/subscription-cli/index.ts create mode 100644 src/llm/provider/subscription-cli/register-cli-adapters.ts create mode 100644 src/llm/provider/subscription-cli/resolve-cli-binary.test.ts create mode 100644 src/llm/provider/subscription-cli/resolve-cli-binary.ts create mode 100644 src/llm/provider/subscription-cli/run-cli-completion.test.ts create mode 100644 src/llm/provider/subscription-cli/run-cli-completion.ts create mode 100644 src/llm/provider/subscription-cli/stream-cli-completion.test.ts create mode 100644 src/llm/provider/subscription-cli/stream-cli-completion.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-errors.test.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-errors.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-provider.test.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-provider.ts create mode 100644 src/llm/provider/verify/classify-verify-response.test.ts create mode 100644 src/llm/provider/verify/classify-verify-response.ts create mode 100644 src/llm/provider/verify/index.ts create mode 100644 src/llm/provider/verify/pick-probe-models.test.ts create mode 100644 src/llm/provider/verify/pick-probe-models.ts create mode 100644 src/llm/provider/verify/verify-provider-key.test.ts create mode 100644 src/llm/provider/verify/verify-provider-key.ts create mode 100644 src/llm/provider/verify/verify-types.ts create mode 100644 src/llm/reliability/network-error.test.ts create mode 100644 src/llm/reliability/network-error.ts create mode 100644 src/local-llm/backend-staging.ts create mode 100644 src/local-llm/ensure-latest-backend.test.ts create mode 100644 src/local-llm/ensure-latest-backend.ts create mode 100644 src/local-llm/huggingface-api.ts create mode 100644 src/local-llm/huggingface-fit.test.ts create mode 100644 src/local-llm/huggingface-fit.ts create mode 100644 src/local-llm/huggingface-model-def.ts create mode 100644 src/local-llm/huggingface-ref.test.ts create mode 100644 src/local-llm/huggingface-ref.ts create mode 100644 src/local-llm/huggingface-resolve.test.ts create mode 100644 src/local-llm/huggingface-resolve.ts create mode 100644 src/prompt/conversation-cap-auto.test.ts create mode 100644 src/runtime/bootstrap-queued-turn.test.ts create mode 100644 src/runtime/heap-guard.test.ts create mode 100644 src/runtime/heap-guard.ts create mode 100644 src/runtime/recorder-eviction.test.ts create mode 100644 src/runtime/steering-inbox.test.ts create mode 100644 src/runtime/steering-inbox.ts create mode 100644 src/sandbox/command-runner.test.ts create mode 100644 src/session/conversation-pairs.test.ts create mode 100644 src/session/session-exit-status.test.ts create mode 100644 src/sidecar/steer-message.test.ts create mode 100644 src/tools/coerce-tool-args.test.ts create mode 100644 src/tools/coerce-tool-args.ts create mode 100644 src/tools/os/archive/tar-backend.ts.bak delete mode 100644 src/tools/os/fs-glob-real.test.ts create mode 100644 src/tools/os/fs-write-retarget.test.ts create mode 100644 src/tools/os/http-request-curl-meta.test.ts create mode 100644 src/tools/os/http-request-retry.test.ts create mode 100644 src/tools/os/read-document/extractors/pdf-extractor.canvas-warnings.test.ts create mode 100644 src/tools/os/retry-after-header.test.ts create mode 100644 src/tools/os/retry-after-header.ts create mode 100644 src/tools/os/web-fetch-challenge.test.ts create mode 100644 src/tools/os/web-fetch-challenge.ts create mode 100644 src/tools/os/web-search/providers/assert-provider-status.ts create mode 100644 src/tools/os/web-search/tool/warn-missing-search-key.test.ts create mode 100644 src/tools/os/web-search/tool/warn-missing-search-key.ts create mode 100644 src/tools/os/web-search/transport/provider-cooldown.test.ts create mode 100644 src/tools/os/web-search/transport/provider-cooldown.ts create mode 100644 src/tools/os/web-search/transport/retry-after.test.ts create mode 100644 src/tools/os/web-search/transport/retry-after.ts create mode 100644 src/tools/os/web-search/transport/search-http.test.ts create mode 100644 src/tui/app-key-bindings-selection.test.ts create mode 100644 src/tui/approval-key-arbitration.test.ts create mode 100644 src/tui/approval-live-composer.test.tsx create mode 100644 src/tui/backdrop-dismissal.test.ts create mode 100644 src/tui/backdrop-dismissal.ts create mode 100644 src/tui/build-terminal-launch.test.ts create mode 100644 src/tui/build-terminal-launch.ts create mode 100644 src/tui/chat-orchestrator-steering.test.ts create mode 100644 src/tui/chat-orchestrator-switch.test.ts create mode 100644 src/tui/chat-orchestrator.test.ts create mode 100644 src/tui/clipboard/clipboard-context.tsx create mode 100644 src/tui/clipboard/copy-to-clipboard.test.ts create mode 100644 src/tui/clipboard/copy-to-clipboard.ts create mode 100644 src/tui/clipboard/index.ts create mode 100644 src/tui/clipboard/read-clipboard.ts create mode 100644 src/tui/coding-mode-menu.test.tsx create mode 100644 src/tui/coding-mode.test.ts create mode 100644 src/tui/coding-mode.ts create mode 100644 src/tui/components/chat-copy-button.test.tsx create mode 100644 src/tui/components/chat-copy-button.tsx create mode 100644 src/tui/components/chat-try-again-button.test.tsx create mode 100644 src/tui/components/chat-try-again-button.tsx create mode 100644 src/tui/components/chip.tsx create mode 100644 src/tui/components/cloud-provider-onboarding-mouse.test.tsx create mode 100644 src/tui/components/cloud-provider-onboarding.test.tsx create mode 100644 src/tui/components/coding-mode-chip.tsx create mode 100644 src/tui/components/coding-mode-popup.tsx create mode 100644 src/tui/components/composer-overlay.mouse.test.tsx create mode 100644 src/tui/components/composer-overlay.test.tsx create mode 100644 src/tui/components/composer-overlay.tsx create mode 100644 src/tui/components/composer-send-button.tsx create mode 100644 src/tui/components/context-chip.test.tsx create mode 100644 src/tui/components/context-chip.tsx create mode 100644 src/tui/components/context-panel.test.tsx create mode 100644 src/tui/components/context-panel.tsx create mode 100644 src/tui/components/debug-pane-budget.test.ts create mode 100644 src/tui/components/download-chip.test.tsx create mode 100644 src/tui/components/download-chip.tsx create mode 100644 src/tui/components/fit-to-width.ts create mode 100644 src/tui/components/format-tokens.ts create mode 100644 src/tui/components/hf-pick-list.tsx create mode 100644 src/tui/components/hf-reference-editor.tsx create mode 100644 src/tui/components/hotkey-chips.ts create mode 100644 src/tui/components/hotkey-hint-modes.test.tsx delete mode 100644 src/tui/components/local-models-config-wizard.test.tsx delete mode 100644 src/tui/components/local-models-config-wizard.tsx create mode 100644 src/tui/components/local-models-hf-branch.tsx create mode 100644 src/tui/components/logo-art.generated.test.ts create mode 100644 src/tui/components/logo-art.ts create mode 100644 src/tui/components/logo-fit.test.ts create mode 100644 src/tui/components/multi-line-editor-clipboard.ts create mode 100644 src/tui/components/multi-line-editor-edits.ts create mode 100644 src/tui/components/multi-line-editor-keys.test.ts create mode 100644 src/tui/components/multi-line-editor-keys.ts create mode 100644 src/tui/components/multi-line-editor-newline.test.tsx create mode 100644 src/tui/components/multi-line-editor-paste.test.tsx create mode 100644 src/tui/components/multi-line-editor-pointer.ts create mode 100644 src/tui/components/multi-line-editor-selection-flag.test.tsx create mode 100644 src/tui/components/multi-line-editor-selection.test.tsx create mode 100644 src/tui/components/multi-line-editor.test.tsx create mode 100644 src/tui/components/onboarding-atom-field.test.tsx create mode 100644 src/tui/components/onboarding-atom-field.tsx create mode 100644 src/tui/components/onboarding-choose-step.tsx create mode 100644 src/tui/components/onboarding-download-ambient.test.tsx create mode 100644 src/tui/components/onboarding-download-ambient.tsx create mode 100644 src/tui/components/onboarding-download-frame.test.tsx create mode 100644 src/tui/components/onboarding-download-progress.tsx create mode 100644 src/tui/components/onboarding-download-step.test.tsx create mode 100644 src/tui/components/onboarding-download-step.tsx create mode 100644 src/tui/components/onboarding-header.test.tsx create mode 100644 src/tui/components/onboarding-header.tsx create mode 100644 src/tui/components/onboarding-hf-flow.test.tsx create mode 100644 src/tui/components/onboarding-hf-flow.tsx create mode 100644 src/tui/components/onboarding-hf-pick-step.tsx create mode 100644 src/tui/components/onboarding-hf-ref-step.tsx create mode 100644 src/tui/components/onboarding-hf-steps.test.tsx create mode 100644 src/tui/components/onboarding-intro-step.test.tsx create mode 100644 src/tui/components/onboarding-intro-step.tsx create mode 100644 src/tui/components/onboarding-local-pick-step.tsx create mode 100644 src/tui/components/onboarding-mouse.test.tsx create mode 100644 src/tui/components/onboarding-propose-step.test.tsx create mode 100644 src/tui/components/onboarding-propose-step.tsx create mode 100644 src/tui/components/onboarding-screen.test.tsx create mode 100644 src/tui/components/onboarding-screen.tsx create mode 100644 src/tui/components/onboarding-step-body.tsx create mode 100644 src/tui/components/onboarding-surface-layout.test.tsx create mode 100644 src/tui/components/onboarding-surface-layout.ts create mode 100644 src/tui/components/onboarding-url-step.tsx create mode 100644 src/tui/components/onboarding-wait-or-jump-step.test.tsx create mode 100644 src/tui/components/onboarding-wait-or-jump-step.tsx create mode 100644 src/tui/components/plan-handoff.tsx create mode 100644 src/tui/components/prompt-meta-bar.test.tsx create mode 100644 src/tui/components/prompt-meta-bar.tsx create mode 100644 src/tui/components/providers-wizard-measure.test.tsx create mode 100644 src/tui/components/providers-wizard-measure.ts create mode 100644 src/tui/components/queued-messages.test.tsx create mode 100644 src/tui/components/queued-messages.tsx create mode 100644 src/tui/components/render-progress-bar.test.ts create mode 100644 src/tui/components/render-progress-bar.ts create mode 100644 src/tui/components/session-delete-modal.tsx create mode 100644 src/tui/components/session-title.test.ts create mode 100644 src/tui/components/session-title.ts create mode 100644 src/tui/components/sidebar-fit.test.tsx create mode 100644 src/tui/components/splash-fit.render.test.tsx create mode 100644 src/tui/components/splash-fit.test.ts create mode 100644 src/tui/components/splash-fit.ts create mode 100644 src/tui/components/status-bar.test.tsx create mode 100644 src/tui/components/terminal-too-small.test.tsx create mode 100644 src/tui/components/terminal-too-small.tsx create mode 100644 src/tui/components/uninstall-modal.test.tsx create mode 100644 src/tui/components/uninstall-modal.tsx create mode 100644 src/tui/composer-ink.test.tsx create mode 100644 src/tui/composer-switch/composer-meta-controls.test.tsx create mode 100644 src/tui/composer-switch/composer-meta-controls.tsx create mode 100644 src/tui/composer-switch/composer-switch-actions.ts create mode 100644 src/tui/composer-switch/composer-switch-activate.test.ts create mode 100644 src/tui/composer-switch/composer-switch-activate.ts create mode 100644 src/tui/composer-switch/composer-switch-app.test.tsx create mode 100644 src/tui/composer-switch/composer-switch-filter.test.ts create mode 100644 src/tui/composer-switch/composer-switch-filter.ts create mode 100644 src/tui/composer-switch/composer-switch-fixtures.ts create mode 100644 src/tui/composer-switch/composer-switch-key-bindings.test.ts create mode 100644 src/tui/composer-switch/composer-switch-key-bindings.ts create mode 100644 src/tui/composer-switch/composer-switch-popup.test.tsx create mode 100644 src/tui/composer-switch/composer-switch-popup.tsx create mode 100644 src/tui/composer-switch/composer-switch-reducer.ts create mode 100644 src/tui/composer-switch/composer-switch-rows.test.ts create mode 100644 src/tui/composer-switch/composer-switch-rows.ts create mode 100644 src/tui/composer-switch/composer-switch-state.ts create mode 100644 src/tui/composer-switch/index.ts create mode 100644 src/tui/composer-visibility.test.tsx create mode 100644 src/tui/context-menu/context-menu-app.test.tsx create mode 100644 src/tui/context-menu/context-menu-context.tsx create mode 100644 src/tui/context-menu/context-menu-popup.tsx create mode 100644 src/tui/context-menu/context-menu-state.test.ts create mode 100644 src/tui/context-menu/context-menu-state.ts create mode 100644 src/tui/context-menu/index.ts create mode 100644 src/tui/context-menu/paste-field-target.tsx create mode 100644 src/tui/context-pairs-selection.test.ts create mode 100644 src/tui/context-panel-keys.test.ts create mode 100644 src/tui/context-panel-keys.ts create mode 100644 src/tui/context-usage-from-prompt.test.ts create mode 100644 src/tui/context-usage-from-prompt.ts create mode 100644 src/tui/detached-turns.test.ts create mode 100644 src/tui/detached-turns.ts create mode 100644 src/tui/detect-kitty-keyboard.test.ts create mode 100644 src/tui/detect-kitty-keyboard.ts create mode 100644 src/tui/escape-abort-running.test.tsx create mode 100644 src/tui/escape-chat-editor.test.tsx create mode 100644 src/tui/escape-import-tab.test.tsx create mode 100644 src/tui/escape-observe-tabs.test.tsx create mode 100644 src/tui/hooks/use-atom-field.ts create mode 100644 src/tui/hooks/use-onboarding-huggingface.ts create mode 100644 src/tui/hooks/use-onboarding-inputs.ts create mode 100644 src/tui/hooks/use-onboarding-lifecycle.test.tsx create mode 100644 src/tui/hooks/use-onboarding-lifecycle.ts create mode 100644 src/tui/hooks/use-onboarding-url-actions.ts create mode 100644 src/tui/hooks/use-transfer-rate.ts create mode 100644 src/tui/hooks/use-transient-status.ts create mode 100644 src/tui/hooks/use-typewriter.test.tsx create mode 100644 src/tui/hooks/use-typewriter.ts create mode 100644 src/tui/input-history-keys.test.tsx create mode 100644 src/tui/layout.test.ts create mode 100644 src/tui/layout.ts create mode 100644 src/tui/llm-panel/fallback/fallback-seam.test.ts create mode 100644 src/tui/llm-panel/llm-panel-paste.ts create mode 100644 src/tui/local-backend-readiness.test.ts create mode 100644 src/tui/local-backend-readiness.ts create mode 100644 src/tui/local-models/local-models-hf-keys.ts create mode 100644 src/tui/local-models/local-models-orchestrator-auto-update.test.ts create mode 100644 src/tui/local-turn-gate.test.ts create mode 100644 src/tui/local-turn-gate.ts create mode 100644 src/tui/menu/menu-behaviour.test.ts create mode 100644 src/tui/menu/menu-keys.ts create mode 100644 src/tui/menu/menu-popup.tsx create mode 100644 src/tui/menu/menu-registry.test.ts create mode 100644 src/tui/menu/menu-registry.ts create mode 100644 src/tui/menu/menu-selectors.ts create mode 100644 src/tui/minimum-window-size.test.tsx create mode 100644 src/tui/mouse/index.ts create mode 100644 src/tui/mouse/mouse-app.test.tsx create mode 100644 src/tui/mouse/mouse-context.tsx create mode 100644 src/tui/mouse/mouse-event.test.ts create mode 100644 src/tui/mouse/mouse-event.ts create mode 100644 src/tui/mouse/mouse-list-row.tsx create mode 100644 src/tui/mouse/mouse-registry.test.ts create mode 100644 src/tui/mouse/mouse-registry.ts create mode 100644 src/tui/mouse/mouse-source.ts create mode 100644 src/tui/mouse/mouse-stdin.test.ts create mode 100644 src/tui/mouse/mouse-stdin.ts create mode 100644 src/tui/mouse/mouse-tracking.test.ts create mode 100644 src/tui/mouse/mouse-tracking.ts create mode 100644 src/tui/mouse/parse-mouse-events.test.ts create mode 100644 src/tui/mouse/parse-mouse-events.ts create mode 100644 src/tui/mouse/selection-passthrough.test.ts create mode 100644 src/tui/mouse/selection-passthrough.ts create mode 100644 src/tui/mouse/synthetic-key.ts create mode 100644 src/tui/onboarding/atom-field-population.test.ts create mode 100644 src/tui/onboarding/atom-field-rows.test.ts create mode 100644 src/tui/onboarding/atom-field-rows.ts create mode 100644 src/tui/onboarding/atom-field.test.ts create mode 100644 src/tui/onboarding/atom-field.ts create mode 100644 src/tui/onboarding/centre-onboarding-block.test.ts create mode 100644 src/tui/onboarding/centre-onboarding-block.ts create mode 100644 src/tui/onboarding/index.ts create mode 100644 src/tui/onboarding/intro-art.test.ts create mode 100644 src/tui/onboarding/intro-art.ts create mode 100644 src/tui/onboarding/intro-input.test.ts create mode 100644 src/tui/onboarding/intro-input.ts create mode 100644 src/tui/onboarding/local-model-picks.test.ts create mode 100644 src/tui/onboarding/local-model-picks.ts create mode 100644 src/tui/onboarding/mark-clear-space.test.ts create mode 100644 src/tui/onboarding/mark-clear-space.ts create mode 100644 src/tui/onboarding/needs-onboarding.test.ts create mode 100644 src/tui/onboarding/needs-onboarding.ts create mode 100644 src/tui/onboarding/onboarding-actions.ts create mode 100644 src/tui/onboarding/onboarding-chrome.test.ts create mode 100644 src/tui/onboarding/onboarding-chrome.ts create mode 100644 src/tui/onboarding/onboarding-fit.test.ts create mode 100644 src/tui/onboarding/onboarding-fit.ts create mode 100644 src/tui/onboarding/onboarding-hf-keys.ts create mode 100644 src/tui/onboarding/onboarding-key-bindings.test.ts create mode 100644 src/tui/onboarding/onboarding-key-bindings.ts create mode 100644 src/tui/onboarding/onboarding-reducer.test.ts create mode 100644 src/tui/onboarding/onboarding-reducer.ts create mode 100644 src/tui/onboarding/onboarding-rows.ts create mode 100644 src/tui/onboarding/onboarding-state.ts create mode 100644 src/tui/onboarding/onboarding-step-keys.test.ts create mode 100644 src/tui/onboarding/onboarding-step-keys.ts create mode 100644 src/tui/onboarding/orbit-field.test.ts create mode 100644 src/tui/onboarding/orbit-field.ts create mode 100644 src/tui/onboarding/propose-second-backend.test.ts create mode 100644 src/tui/onboarding/propose-second-backend.ts create mode 100644 src/tui/onboarding/star-field.test.ts create mode 100644 src/tui/onboarding/star-field.ts create mode 100644 src/tui/onboarding/star-tiers.test.ts create mode 100644 src/tui/onboarding/star-tiers.ts create mode 100644 src/tui/onboarding/use-intro-input.test.tsx create mode 100644 src/tui/onboarding/use-intro-input.ts create mode 100644 src/tui/open-terminal-window.test.ts create mode 100644 src/tui/open-terminal-window.ts create mode 100644 src/tui/persist-conversation-max-pairs.ts create mode 100644 src/tui/persist-onboarding-state.test.ts create mode 100644 src/tui/persist-onboarding-state.ts create mode 100644 src/tui/plan-handoff-keys.test.tsx create mode 100644 src/tui/plan-handoff.test.tsx create mode 100644 src/tui/providers/describe-verify-outcome.ts create mode 100644 src/tui/providers/is-local-provider-url.ts create mode 100644 src/tui/providers/providers-key-bindings.test.ts create mode 100644 src/tui/providers/providers-wizard-filter.test.ts create mode 100644 src/tui/providers/providers-wizard-filter.ts create mode 100644 src/tui/providers/providers-wizard-kind-labels.ts create mode 100644 src/tui/providers/providers-wizard-list-keys.test.ts create mode 100644 src/tui/providers/providers-wizard-list-keys.ts create mode 100644 src/tui/providers/providers-wizard-paste.ts create mode 100644 src/tui/providers/providers-wizard-target.test.ts create mode 100644 src/tui/providers/providers-wizard-target.ts create mode 100644 src/tui/providers/route-wizard-key.ts create mode 100644 src/tui/providers/verify-wizard-before-save.test.ts create mode 100644 src/tui/providers/verify-wizard-before-save.ts create mode 100644 src/tui/rail-session-list.test.ts delete mode 100644 src/tui/run-local-models-config-wizard.test.ts delete mode 100644 src/tui/run-local-models-config-wizard.ts create mode 100644 src/tui/select-context-usage.test.ts create mode 100644 src/tui/select-context-usage.ts create mode 100644 src/tui/session-delete-keys.test.ts create mode 100644 src/tui/shift-enter-support.ts create mode 100644 src/tui/synchronized-output.test.ts create mode 100644 src/tui/synchronized-output.ts create mode 100644 src/tui/tasks/tasks-list-fit.test.ts create mode 100644 src/tui/tasks/tasks-list-fit.ts create mode 100644 src/tui/terminal-restore.test.ts create mode 100644 src/tui/terminal-restore.ts create mode 100644 src/tui/test-sized-render.ts create mode 100644 src/tui/theme/color-contrast.ts create mode 100644 src/tui/theme/color-luminance.ts delete mode 100644 src/tui/theme/github-themes.test.ts create mode 100644 src/tui/theme/mix-color.test.ts create mode 100644 src/tui/theme/mix-color.ts create mode 100644 src/tui/theme/parse-hex-color.test.ts create mode 100644 src/tui/theme/parse-hex-color.ts create mode 100644 src/tui/theme/readable-foreground.test.ts create mode 100644 src/tui/theme/readable-foreground.ts create mode 100644 src/tui/theme/theme-contrast.test.ts delete mode 100644 src/tui/theme/theme-palettes.test.ts create mode 100644 src/tui/tui-args.help.test.ts create mode 100644 src/tui/tui-args.test.ts create mode 100644 src/tui/tui-command.mouse.test.ts create mode 100644 src/tui/uninstall-modal-focus.test.tsx create mode 100644 src/tui/uninstall/uninstall-actions.ts create mode 100644 src/tui/uninstall/uninstall-keys.test.ts create mode 100644 src/tui/uninstall/uninstall-orchestrator.ts create mode 100644 src/tui/uninstall/uninstall-reducer.test.ts create mode 100644 src/tui/uninstall/uninstall-reducer.ts create mode 100644 src/tui/uninstall/uninstall-state.ts create mode 100644 src/tui/usage-at-pairs.test.ts create mode 100644 src/uninstall/index.ts create mode 100644 src/uninstall/measure-uninstall-plan.ts create mode 100644 src/uninstall/resolve-uninstall-plan.ts create mode 100644 src/uninstall/run-uninstall.test.ts create mode 100644 src/uninstall/run-uninstall.ts create mode 100644 src/uninstall/strip-installer-path-line.test.ts create mode 100644 src/uninstall/strip-installer-path-line.ts create mode 100644 src/uninstall/uninstall-targets.test.ts create mode 100644 src/uninstall/uninstall-targets.ts diff --git a/.claude/worktrees/worktree-fix-max-steps b/.claude/worktrees/worktree-fix-max-steps new file mode 160000 index 00000000..b406f5cc --- /dev/null +++ b/.claude/worktrees/worktree-fix-max-steps @@ -0,0 +1 @@ +Subproject commit b406f5cca5c8683257fd9751bf41f6577f5e8e7e diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..ee3d1eeb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,130 @@ +# Pull-request test gate. +# +# Two dependency shapes, because they catch different bugs: +# +# - the normal install, the one contributors have locally. +# - the same install with `@napi-rs/canvas` removed. That package is an +# *optional* transitive dependency of `pdfjs-dist`, and it is absent from +# the SEA bundle (`scripts/bundle-sea.ts` externalizes only +# `better-sqlite3` and `playwright-core`) and from any install where the +# native package fails to build. Issue #117 was exactly this: PDF text +# extraction printed four "rendering may be broken" warnings that a normal +# dev install never shows. See issue #203. +# +# Note the second job does NOT use `npm ci --omit=optional`. That flag also +# drops `@rollup/rollup-linux-x64-gnu`, an optional platform binary vitest +# needs, so the job dies at startup before running a single test +# (npm/cli#4828). Removing the one package under test is both narrower and a +# closer match to what users actually hit. + +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# A new push to the same PR supersedes the previous run. +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test (${{ matrix.label }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - label: default install + # The full suite, minus the known-bad tests excluded below. + drop_canvas: "false" + scope: "" + - label: no canvas + # Reproduce the deployment issue #117 is about. NOT via + # `npm ci --omit=optional`: that also drops + # `@rollup/rollup-linux-x64-gnu`, an optional platform binary that + # *vitest itself* needs, so the runner dies at startup before + # testing anything (npm/cli#4828). Install normally, then remove + # just the one package whose absence we care about. + drop_canvas: "true" + # Optional deps are what #117 is about, and the document + # extractors are where their absence shows up. Scoped to keep this + # job cheap; widen if it earns its keep. + # + # The assertion that makes this leg mean something is + # "the real extractor stays quiet in this install" in + # pdf-extractor.canvas-warnings.test.ts. The other canvas tests + # build their own canvas-free sandbox and so answer identically in + # both legs; that one runs the real extractor against whatever is + # installed here, so it passes above (canvas present, quiet path + # skipped) and fails here if the fix regresses. Keep it in scope. + scope: src/tools/os/read-document/ + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + # Match release.yml. Node >= 25.7 is required for the SEA build; using + # the same version here means CI tests what we actually ship. + node-version: "25.7" + check-latest: true + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Remove the optional canvas package + if: matrix.drop_canvas == 'true' + run: rm -rf node_modules/@napi-rs/canvas + + - name: Confirm canvas state matches this job + # Makes the point of the matrix visible in the log, and fails loudly if + # the two jobs ever stop differing in the way they are supposed to. + run: | + if node -e "require.resolve('@napi-rs/canvas')" 2>/dev/null; then + present=true + else + present=false + fi + echo "@napi-rs/canvas present: $present" + if [ "${{ matrix.drop_canvas }}" = "true" ] && [ "$present" = "true" ]; then + echo "::error::expected @napi-rs/canvas to be absent in this job" + exit 1 + fi + if [ "${{ matrix.drop_canvas }}" = "false" ] && [ "$present" = "false" ]; then + echo "::error::expected @napi-rs/canvas to be present in this job" + exit 1 + fi + + - name: Typecheck + run: npx tsc -p tsconfig.json --noEmit + + - name: Run tests + # Two files are excluded, both because they fail on unmodified `main` + # for reasons that are not this PR's to fix. Neither is a timing flake; + # both are real and should be fixed and re-enabled. See issue #203. + # + # send-message-concurrency — the runtime makes a third + # non-reflection llamaComplete call ("m3") where the test expects + # two. A sidecar behaviour question, not a CI one. + # + # local-models-orchestrator-auto-update — reaches a real + # `spawn()` of a stub llama-server binary instead of mocking it. + # Its assertions pass, but the async throw is an *unhandled error*, + # and vitest exits non-zero on those — so it would fail the job + # while reporting "8 passed". + # + # `parallel-tool-calls` is deliberately NOT excluded: its wall-clock + # bound was replaced with a deterministic `peakInFlight` assertion in + # this PR, so it is now safe on a contended runner. + run: | + npx vitest run ${{ matrix.scope }} \ + --exclude '**/send-message-concurrency.test.ts' \ + --exclude '**/local-models-orchestrator-auto-update.test.ts' diff --git a/.worktrees/fix/telegram-approval-grant-buttons b/.worktrees/fix/telegram-approval-grant-buttons new file mode 160000 index 00000000..b406f5cc --- /dev/null +++ b/.worktrees/fix/telegram-approval-grant-buttons @@ -0,0 +1 @@ +Subproject commit b406f5cca5c8683257fd9751bf41f6577f5e8e7e diff --git a/.worktrees/fix/telegram-approval-grant-review-followups b/.worktrees/fix/telegram-approval-grant-review-followups new file mode 160000 index 00000000..ca2fa1f8 --- /dev/null +++ b/.worktrees/fix/telegram-approval-grant-review-followups @@ -0,0 +1 @@ +Subproject commit ca2fa1f83e889ef7281b5e1ada6af748a723a254 diff --git a/.worktrees/integrate-max-steps-pr b/.worktrees/integrate-max-steps-pr new file mode 160000 index 00000000..4e19a41e --- /dev/null +++ b/.worktrees/integrate-max-steps-pr @@ -0,0 +1 @@ +Subproject commit 4e19a41eee149464b9fe76f362db29b28ca6a288 diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..ab47818c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,11 +14,11 @@ This is the source-of-truth for automated contributors (LLM agents, codegen, etc ## Architectural invariants 1. **Project ≠ Prompt.** Session state, compressed tool results, and world snapshots live outside the model; the prompt is always a small slice. -2. **Stable prefix.** The prompt is `buildStablePrefix` (persona + `### rules` + skill catalog under `### skills` + `### tools` + `### capabilities` + `### instructions`) followed by a **variable tail** in mutability order: `### loaded-skills` (optional) → `### loaded-tools` (optional) → `### profile` (optional) → `### memory-index` (optional) → `### session-facts` (optional) → `### recalled` (optional) → `### world` → `### conversation` → optional `### notice` → `### respond` (+ optional reasoning prefill). Only the stable-prefix bytes must stay stable within a session for KV-cache — this is what `cache_prompt + slot_id` on `llama-server` relies on. +2. **Stable prefix.** The prompt is `buildStablePrefix` (persona + `### rules` + skill catalog under `### skills` + `### tools` + `### capabilities` + `### instructions`) followed by a **variable tail** in mutability order: `### loaded-skills` (optional) → `### loaded-tools` (optional) → `### profile` (optional) → `### memory-index` (optional) → `### session-facts` (optional) → `### recalled` (optional) → `### world` → `### conversation` → optional `### notice` (written by the no-progress loop detector and by mid-turn steering, composed in that order) → `### respond` (+ optional reasoning prefill). Only the stable-prefix bytes must stay stable within a session for KV-cache — this is what `cache_prompt + slot_id` on `llama-server` relies on. 3. **One inference per step.** No reasoning loops inside a single LLM call — the runtime drives the loop. A single inference always emits a JSON **array** of `1..N` tool calls (`[{tool, args}, ...]`); a "solo" step is just a length-1 array (`[{...}]`). `N` is capped by `agent.maxParallelToolCalls` (default 8, hard ceiling 16 in the grammar). See §"Parallel tool calls per step" for the rationale (GBNF first-token bias) and the executor pipeline. 4. **Grammar-constrained tool calls.** The sidecar sends a GBNF grammar with every completion request that must produce a tool call. The root collapsed to **array-only** (`root ::= tool-call-array`) so the model cannot fall into the single-object form via first-token bias even when it only needs one call. Reasoning-prelude profiles (`qwen-think`, `gemma4-think`) prepend a `...` / `<|channel>thought...` block to the array; the seam between the close sentinel and the leading `[` of the array routes through a dedicated **bounded** `prelude-trail-ws ::= ( [ \t\n\r] ){0,8}` rule rather than the global unbounded `ws`. This is the structural anti-degenerate-loop guard — small reasoning-capable models (Gemma 4 26B-A4B in particular) used to slide into a whitespace-only tail after a long reasoning block because the sampler could keep emitting newlines indefinitely. Pinned by [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts) "bounds the whitespace between the reasoning-close sentinel and the tool-call array". - **Reasoning-open ownership differs by profile (`reasoningOpenEmittedByModel` in [src/llm/model-profile.ts](src/llm/model-profile.ts)).** `qwen-think` **prefills** its open tag at the end of the prompt (`### respond` → ``); the grammar prelude starts after the open tag (`think-prelude ::= think-body "" …`) and `step-executor` prepends the open tag back onto the completion before parsing. `gemma4-think` is the opposite: Gemma 4's QAT template treats a prefilled `<|channel>thought\n` as the *thinking-disabled* marker and immediately closes the channel, dumping its reasoning into `reply.text`. The fix is **native turn-framing** — the profile carries `turnFraming { systemOpen: "<|turn>system\n", turnClose: "\n", assistantOpen: "<|turn>model\n" }` and `reasoningEmittedByModel: true`. `buildStablePrefix` opens the prompt with `<|turn>system\n<|think|>\n### system…` (the `<|think|>` token must sit at the very top of a real system turn to activate the channel — a one-time gemma-only stable-prefix byte change / KV invalidation), `buildPrompt` ends the tail with `…### respond\nRespond now.\n\n\n<|turn>model\n` instead of a channel prefill, and the grammar prelude **includes the open sentinel** (`channel-prelude ::= "<|channel>thought\n" channel-body "" prelude-trail-ws`) so the model emits its own `<|channel>thought` block. `step-executor.normalizeContent` does **not** prepend the open tag for `reasoningEmittedByModel` profiles (it is already in `completion.content`), the stream parser runs with `preOpenedThink: false` so it detects the open tag live, and the repair prompt strips/re-appends `\n<|turn>model` rather than the bare open tag. `checkProfilePromptAligned` asserts a turn-framed prompt ends with the model-turn opener (not the open tag). Pinned by [src/agent/profile-matrix.test.ts](src/agent/profile-matrix.test.ts), [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/prompt/build-prompt.test.ts](src/prompt/build-prompt.test.ts), [src/llm/profile-invariants.test.ts](src/llm/profile-invariants.test.ts), [src/llm/grammar/stream-parser.test.ts](src/llm/grammar/stream-parser.test.ts). + **Reasoning-open ownership differs by profile (`reasoningOpenEmittedByModel` in [src/llm/model-profile.ts](src/llm/model-profile.ts)).** `qwen-think` **prefills** its open tag at the end of the prompt (`### respond` → ``); the grammar prelude starts after the open tag (`think-prelude ::= think-body "" …`) and `step-executor` prepends the open tag back onto the completion before parsing. Nemotron 3.5 Lightning shares that ownership exactly: its template ends generation at `<|im_start|>assistant\n\n`, so `selectBaseProfile` detects it separately (the alias hint differs) but returns `qwen-think` itself rather than a duplicate profile. `gemma4-think` is the opposite: Gemma 4's QAT template treats a prefilled `<|channel>thought\n` as the *thinking-disabled* marker and immediately closes the channel, dumping its reasoning into `reply.text`. The fix is **native turn-framing** — the profile carries `turnFraming { systemOpen: "<|turn>system\n", turnClose: "\n", assistantOpen: "<|turn>model\n" }` and `reasoningEmittedByModel: true`. `buildStablePrefix` opens the prompt with `<|turn>system\n<|think|>\n### system…` (the `<|think|>` token must sit at the very top of a real system turn to activate the channel — a one-time gemma-only stable-prefix byte change / KV invalidation), `buildPrompt` ends the tail with `…### respond\nRespond now.\n\n\n<|turn>model\n` instead of a channel prefill, and the grammar prelude **includes the open sentinel** (`channel-prelude ::= "<|channel>thought\n" channel-body "" prelude-trail-ws`) so the model emits its own `<|channel>thought` block. `step-executor.normalizeContent` does **not** prepend the open tag for `reasoningEmittedByModel` profiles (it is already in `completion.content`), the stream parser runs with `preOpenedThink: false` so it detects the open tag live, and the repair prompt strips/re-appends `\n<|turn>model` rather than the bare open tag. `checkProfilePromptAligned` asserts a turn-framed prompt ends with the model-turn opener (not the open tag). Pinned by [src/agent/profile-matrix.test.ts](src/agent/profile-matrix.test.ts), [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/prompt/build-prompt.test.ts](src/prompt/build-prompt.test.ts), [src/llm/profile-invariants.test.ts](src/llm/profile-invariants.test.ts), [src/llm/grammar/stream-parser.test.ts](src/llm/grammar/stream-parser.test.ts). 5. **No global singletons.** Dependencies are passed explicitly. `getConfig()` is the only exception. 6. **Session is multi-turn chat only.** A session is a long-lived chat: `user message → 0..N tool steps → reply` is a macro-turn, multiple turns share one `SessionState.turns[]`. Two terminals exist — `reply` ends the turn, `finish` ends the whole session. All three frontends (CLI `run`, TUI, sidecar) go through `runtime.runTurn` only; there is no one-shot goal mode. @@ -147,6 +147,26 @@ Speculative batching (the runtime guessing that the model "should" have batched - Tests are colocated with source: `build-prompt.test.ts` next to `build-prompt.ts`. - Config lives in `src/config/` — read it before touching env vars. +## Mouse support + +The TUI is clickable. Ink has no mouse layer, so this is built in `src/tui/mouse/`: + +1. **Reporting** — `enableMouseTracking` writes `\x1b[?1000h\x1b[?1006h` (button events + SGR coordinates). 1002/1003 motion tracking is deliberately **not** requested: nothing in the UI hovers or drags, and motion reports are a constant wakeup stream. Paired with a `process.on("exit")` restore, like `alt-screen.ts`. +2. **Decoding** — `decodeMouseEvents` is a pure function over a stdin chunk returning `{ events, text, rest }`. It understands SGR and legacy X10, buffers a report split across two reads, and passes a lone trailing `ESC` straight through (buffering it would delay the Escape key by one keystroke). +3. **Stream split** — `createMouseStdin` reads the real TTY, hands Ink a `PassThrough` carrying only the keyboard bytes, and proxies `isTTY` / `setRawMode` / `ref` / `unref` to the real stdin. Without this the reports reach Ink's key parser and get typed into the chat buffer. +4. **Hit testing** — `MouseTargetRegistry` resolves a cell to a component. Ink exposes no absolute positions, but every node keeps its Yoga node, and `absoluteRect` sums `getComputedLeft/Top` up the parent chain — the same walk `render-node-to-output.ts` does when painting, so the rectangle is exactly where the node was drawn. Ancestors with `overflow: hidden` clip the result. Ties resolve innermost-first (higher layer, then smaller box, then later mount). +5. **Layers** — `MOUSE_LAYER_BASE` / `_PANEL` / `_MODAL`. `TuiApp` raises the registry floor to `_MODAL` whenever a modal, confirm or picker owns the keyboard (`isPanelModalOpen`, shared with `handleAppKey`), so a click cannot reach the list rendered behind a modal. + +**Navigation.** The breadcrumb in the status bar is the one clickable navigation control: clicking it opens the menu, exactly as `ctrl+p` does. An earlier draft of this layer made a Run / Observe / Manage pill strip clickable, but the menu registry replaced that strip — reinstating pills would give one job two competing controls. + +**Interaction contract.** First click selects, a second click on the selected row activates. Activation and the wheel are routed through each panel's existing `*-key-bindings.ts` handler with a synthetic Enter / arrow key (`synthetic-key.ts`), so the mouse can never disagree with the keyboard about what a row does. Clicking the prompt places the caret (`rowColToCursor`, clamped to the line length). + +**The trade-off.** While reporting is on, the terminal stops doing its own drag-to-select (Apple Terminal has no Shift-bypass). Hence `tui.mouse` (config v40, default `true`), `--mouse` / `--no-mouse`, and `/mouse on|off` at runtime; `tui-command.ts` owns the live toggle and the config write. With mouse off the previous behaviour is intact: alternate-scroll (`\x1b[?1007h`) turns the wheel into cursor keys. + +**The toggle is not a prop.** `tui-command.ts` hands `TuiApp` the `mouse` source unconditionally, whatever `tui.mouse` said at startup. The mounted tree cannot be re-parented from a plain `let` reassignment, so gating that prop on the startup value silently made `/mouse on` a no-op for the rest of the session. The live gate is the tracking controller: it decides whether the terminal reports at all, and whether decoded reports are forwarded to the source. + +**Testing.** Escape sequences, decoder and stream split are unit-tested; `mouse-app.test.tsx` drives the real Ink tree by locating a label in the rendered frame and emitting a click at those coordinates. Ink commits frames on a ~30fps throttle, so tests must wait longer than one frame before clicking a freshly rendered target. `tui-command.mouse.test.ts` covers the other end — that a runtime `/mouse on` actually reaches the source the tree subscribed to at mount. + ## Module map | Folder | Responsibility | @@ -159,14 +179,14 @@ Speculative batching (the runtime guessing that the model "should" have batched | `src/prompt/` | Prompt builder, stable prefix, token budget. See [PROMPT.md](PROMPT.md) for full anatomy of the stable prefix and variable tail. | | `src/session/` | Session state + sqlite persistence | | `src/agent/` | Agent loop + step executor + parallel batch executor (`batch-executor.ts`) + resource-class taxonomy (`tool-resource-class.ts`) + no-progress loop detector | -| `src/tools/` | Tool registry + individual tools. OS tools: `shell.run` (direct-exec by default; routes to a `sh -c` subshell when `needsShellInterpretation` sees shell metacharacters `\| & ; > < $ \`` or a pre-joined command line in `cmd` with empty `args` — the common ENOENT trap where the model puts a whole command line in `cmd`; the guard still inspects a tokenised view of the full line so hardline/dangerous rules match), `fs.read` (w/ `offset`/`limit`/`lineNumbers`), `fs.write`, `fs.list`, `fs.glob`, `fs.locate_project` (fuzzy project-name → directory over bounded sources, see §"Project path resolution"), `fs.grep` (bundled ripgrep), `fs.edit` (atomic string replace), `fs.read_document` (PDF/DOCX/XLSX/RTF/ODT/PPTX/legacy .doc → plain text via pure-JS), `fs.archive.list` / `fs.archive.read_entry` / `fs.archive.extract` (zip/tar/tar.gz/gz via pure-JS; zip-slip + bomb guards), `fs.hash` (md5/sha1/sha256/sha512 streaming), `fs.diff` (unified diff, jsdiff), `fs.patch` (dry-run default, all-or-nothing apply), `fs.watch` (chokidar one-shot, timeout-capped), `git.status` / `git.log` / `git.diff` / `git.show` / `git.blame` / `git.branch` (read-only shell-out with structured parse), `proc.list` / `proc.kill` (ps/tasklist + approval), `http.request` (curl + host allowlist + `config.http.approvalMode`), `web.search` (configured provider; keyless DuckDuckGo by default, SearXNG/Exa/Brave selectable via `web.search.*`; Exa uses `EXA_API_KEY` when present), `web.fetch` (read a known URL as markdown/text), `clipboard.*`, `window.*`, `notify`. | +| `src/tools/` | Tool registry + individual tools. OS tools: `shell.run` (direct-exec by default; routes to a `sh -c` subshell when `needsShellInterpretation` sees shell metacharacters `\| & ; > < $ \`` or a pre-joined command line in `cmd` with empty `args` — the common ENOENT trap where the model puts a whole command line in `cmd`; the guard still inspects a tokenised view of the full line so hardline/dangerous rules match), `fs.read` (w/ `offset`/`limit`/`lineNumbers`), `fs.write`, `fs.list`, `fs.glob`, `fs.locate_project` (fuzzy project-name → directory over bounded sources, see §"Project path resolution"), `fs.grep` (bundled ripgrep), `fs.edit` (atomic string replace), `fs.read_document` (PDF/DOCX/XLSX/RTF/ODT/PPTX/legacy .doc → plain text via pure-JS), `fs.archive.list` / `fs.archive.read_entry` / `fs.archive.extract` (zip/tar/tar.gz/gz via pure-JS; zip-slip + bomb guards), `fs.hash` (md5/sha1/sha256/sha512 streaming), `fs.diff` (unified diff, jsdiff), `fs.patch` (dry-run default, all-or-nothing apply), `fs.watch` (chokidar one-shot, timeout-capped), `git.status` / `git.log` / `git.diff` / `git.show` / `git.blame` / `git.branch` (read-only shell-out with structured parse), `proc.list` / `proc.kill` (ps/tasklist + approval), `http.request` (curl + host allowlist + `config.http.approvalMode`), `web.search` (configured provider; keyless Exa with a DuckDuckGo fallback by default, SearXNG/Brave selectable via `web.search.*`; Exa/Brave use an env API key when present, see §"Web search reliability"), `web.fetch` (read a known URL as markdown/text), `clipboard.*`, `window.*`, `notify`. | | `src/compressor/` | Result compressor, log summariser | | `src/sandbox/` | git worktree + sandboxed command runner | | `src/approval/` | Approval gate and event wiring | | `src/tracing/` | Structured logger + metrics + trace recorder (`src/tracing/trace/`) | | `src/replay/` | Trace-based replay: drift detection + optional LLM re-inference | | `src/memory/` | Memory fabric: ProfileStore (key/value facts, pinned + contextual) + MemoryStore (FTS5 freeform notes) + async end-of-turn reflection that writes into both. See [MEMORY.md](MEMORY.md). | -| `src/runtime/` | `bootstrap.ts` (assembles `AgentRuntime`) + `turn-controller.ts` (per-session FIFO queue + per-session event hook map; the **only** path into `AgentLoop.runTurn`). See §"Concurrency contract". | +| `src/runtime/` | `bootstrap.ts` (assembles `AgentRuntime`) + `turn-controller.ts` (per-session FIFO queue + per-session event hook map; the **only** path into `AgentLoop.runTurn`) + `steering-inbox.ts` (out-of-band per-session mailbox for messages that arrive mid-turn). See §"Concurrency contract" and §"Mid-turn steering". | | `src/tasks/` | Durable queue of deferred `runTurn` submissions: `TaskStore` (SQLite), `TaskRunner` (drain + retry/backoff), `task-backoff`, `task-schedule` (cron / interval / at resolver). See §"Durable tasks" and §"Background autonomy". | | `src/scheduler/` | One-process `Scheduler` (single `setInterval`) that polls `TaskStore.listDue` via `TaskRunner.runDue`. The **only** periodic timer in the runtime. See §"Background autonomy". | | `src/http/route-webhooks.ts` + `webhook-template.ts` + `webhook-session-store.ts` | Generic `POST /api/webhooks/:name` ingress. Always materialises into a `TaskRecord`, never calls `runTurn` directly. See §"Background autonomy". | @@ -177,6 +197,7 @@ Speculative batching (the runtime guessing that the model "should" have batched | `src/channels/telegram/` | `TelegramChannel` (lifecycle + live-control), `inbound-handler` (slash commands + dispatch into `runTurn`), `outbound-sender` (chunked replies + 429 retry), `approval-bridge` (inline-keyboard approvals with 8-min auto-deny), `pairing-mode` (60s window for first-DM owner claim), `telegram-settings` (`config.json` + `.env` persistence), `telegram-bot-factory` (grammy adapter). The **only** module that imports `grammy`. See §"Telegram remote-control channel". | | `src/tui/telegram/` | TUI "Telegram" tab: `telegram-panel-state` + `telegram-actions` + `telegram-panel-reducer` (pure UI state slice), `tui-telegram-orchestrator` (the only TUI module that touches `runtime.telegramChannel`), `telegram-key-bindings`, and the `telegram-panel` / `telegram-token-prompt` / `telegram-pairing-modal` components. See §"Telegram remote-control channel". | | `src/mcp/` | MCP (Model Context Protocol) **client** subsystem. `McpManager` (lifecycle for N `McpClient` instances), `mcp-client` (the **only** file that imports `@modelcontextprotocol/sdk` — together with `mcp-sampling-handler` for SDK type shapes), `mcp-tool-adapter` (`McpToolMeta` → `ToolDefinition`), `mcp-resource-class` (per-server trust → `ResourceClass` resolver), `mcp-descriptor-builder` (rare-tier descriptors), `mcp-grammar-builder` (dynamic `mcp-server-tool` GBNF fragment), `mcp-sampling-handler` (forwards `sampling/createMessage` to `LlamaServerClient` with `slotId: -1`), `mcp-resource-tools` + `mcp-prompt-tools` (aggregate read-only `mcp.{resource,prompt}.*` tools dispatching by `server` arg). See §"MCP client". | +| `src/tui/mouse/` | TUI mouse layer: `mouse-tracking` (1000+1006 enable/disable), `parse-mouse-events` (SGR + legacy X10 decoder), `mouse-stdin` (splits mouse bytes out of the stream Ink reads), `mouse-registry` (Yoga-based hit testing), `mouse-context` / `mouse-list-row` (React glue + the shared click-to-select-then-activate row), `synthetic-key` (wheel/second-click → the panel's own key handler). See §"Mouse support". | ## Secrets and process environment @@ -186,6 +207,69 @@ The startup read is defensive about transient locks (#59). A failing read of an There is currently **no per-tool env filtering**. `runCommand` in [src/sandbox/command-runner.ts](src/sandbox/command-runner.ts) inherits the full agent `process.env`, so every spawned subprocess (`os.shell.run`, `runSkillScript`, the managed `llama-server`, future MCP servers) sees every variable loaded from `.env`. Tightening this — per-skill `env_vars` whitelist + safe-baseline filtering (`PATH`, `HOME`, `USER`, `LANG`, `TERM`, `XDG_*`) — is tracked as a separate effort and pinned by no tests yet. Do not assume isolation when designing new skills that handle highly sensitive secrets; document the shared-env reality in the skill's `SKILL.md` instead. +## Web search reliability + +`os.web.search` defaults to `web.search.provider = "exa"` with a +`["duckduckgo"]` fallback. Exa's MCP endpoint answers **keyless** when +`EXA_API_KEY` is unset, and that keyless tier returns HTTP 429 under sustained +agent load — a GAIA validation campaign logged 1341 `Exa returned HTTP 429` +errors, 44% of all tool failures in the run (#179). Two mechanisms keep that +from silently deciding answer quality: + +1. **Retry before falling through.** [transport/retry-after.ts](src/tools/os/web-search/transport/retry-after.ts) + owns the schedule; `searchHttp` retries a 429 against the **same** provider + (default 2 retries, 500 ms doubling) before returning it. Without this, one + transient 429 permanently downgraded a session to the weakest provider in + the chain, because the orchestrator advances on any throw. A server + `Retry-After` wins over the local schedule; both are clamped to + `MAX_RETRY_AFTER_MS` (10 s) so one hostile header cannot stall a turn. The + header rides the existing `curl -w` meta line via `%header{retry-after}` + (curl >= 7.83; older curl emits the literal format string, which is read as + absent). Retries are spent, not skipped, when the limit is real — the + fallback chain remains the backstop. +2. **Name the degradation.** [tool/warn-missing-search-key.ts](src/tools/os/web-search/tool/warn-missing-search-key.ts) + emits one stderr line at tool construction when the primary provider reads + an `apiKeyEnv` that resolves to nothing. The fallback chain works as + designed, so nothing hard-fails; the run just produces weaker groundings + than configured. Warning **once at construction** (not per search) is + deliberate: a long autonomous run would drown in a per-query warning. + +`cacheTtlMinutes` stays at 15. The cache is per-process, in-memory, capped at +256 entries, and keyed on the exact query string, so a longer TTL neither +survives the per-task restarts a campaign does nor catches the near-miss +rephrasings that actually burn quota — while it would serve staler results for +time-sensitive lookups. A restart-surviving cache is the real fix and is not +built. + +Pinned by [retry-after.test.ts](src/tools/os/web-search/transport/retry-after.test.ts), +[search-http.test.ts](src/tools/os/web-search/transport/search-http.test.ts) +(retry-then-succeed, `Retry-After` precedence, give-up-after-maxRetries, +non-429 untouched, old-curl tolerance), +[warn-missing-search-key.test.ts](src/tools/os/web-search/tool/warn-missing-search-key.test.ts), +and [web-search-tool.test.ts](src/tools/os/web-search/tool/web-search-tool.test.ts) +("warns once at construction, not once per search"). +## HTTP retry contract + +`os.web.fetch` and `os.http.request` both retry transient failures +(429/502/503/504 plus curl's timeout exit 28) with exponential backoff capped +at `retryMaxDelayMs`, honouring a server-sent `Retry-After`. The RFC 9110 +value grammar lives once in [retry-after-header.ts](src/tools/os/retry-after-header.ts) +— both tools read the header off curl differently (`%{header_json}` vs +`%header{retry-after}`) but normalise it through the same parser. + +**`os.http.request` additionally guards non-idempotent methods.** Unlike +`web.fetch`, it can POST. An origin may already have processed a request whose +response never arrived, so a blind replay risks a double submit — the one +failure mode a retry layer must not introduce. A GET is replayed on any +retryable status or a timeout; a POST is replayed **only** on 429/503 that also +carries a `Retry-After`, which is an explicit "I did not process this, come +back". A bare 502/504 or a timeout on a POST is returned as-is. + +Pinned by [http-request-retry.test.ts](src/tools/os/http-request-retry.test.ts) +(retryable statuses, `Retry-After` precedence and clamping, give-up, stable-4xx +passthrough, timeout handling, and the four non-idempotent-method safety cases) +and [retry-after-header.test.ts](src/tools/os/retry-after-header.test.ts). + ## Build & test ```bash @@ -203,10 +287,14 @@ Text completion, vision, embeddings, and sub-calls route through plugin-register ### Registry and transport -- **`ProviderRegistry`** ([src/llm/provider/registry/provider-registry.ts](src/llm/provider/registry/provider-registry.ts)) — `registerProviderKind(kind, factory)` + `fromConfig(config)`. Built-in kinds self-register in [register-built-in-providers.ts](src/llm/provider/registry/register-built-in-providers.ts): `llama-server`, `openai-compatible`, `openrouter`. +- **`ProviderRegistry`** ([src/llm/provider/registry/provider-registry.ts](src/llm/provider/registry/provider-registry.ts)) — `registerProviderKind(kind, factory)` + `fromConfig(config)`. Built-in kinds self-register in [register-built-in-providers.ts](src/llm/provider/registry/register-built-in-providers.ts): `llama-server`, `openai-compatible`, `qwen-openai-compatible`, `openrouter`, `aimlapi`, `gemini`, `subscription-cli`. +- **`subscription-cli`** ([src/llm/provider/subscription-cli/](src/llm/provider/subscription-cli/)) — drives an already-signed-in vendor CLI (`claude`, `codex`) as an inference backend so a flat-rate subscription works with no API key. One kind, parameterised by `entry.subscriptionCli.cli`; every CLI-specific byte (argv builders, output parsers, hints) lives behind a `CliAdapterDescriptor`, so a new vendor CLI is a descriptor plus a `SUBSCRIPTION_CLIS` entry and never a new provider kind. It declares `native_tools` while never returning `tool_calls`: an empty `toolCalls` sends step-executor down its guarded recovery ladder, whereas `grammar` would throw out of `parseToolCalls` on any drift and pay for a second CLI invocation on the repair path. - **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `complete`, `completeStream`, `describeImage`, `health`, `close`, `capabilities`, optional `toolCallAdapter` + `streamConsumer`. - **`toolTransport`** — `grammar` (GBNF on llama-server) vs `native_tools` (OpenAI `tools` / `tool_calls`). Resolved by `resolveActiveToolTransport` from `config.llm.toolTransport` (`auto` follows the active provider). - **Name escape** — qualified tool names use `__` for dots (`os.fs.read` → `os__fs__read`) in [openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts). `reply` / `finish` are synthetic OpenAI functions alongside registry tools. +- **Vendor presets** ([src/tui/providers/provider-presets.ts](src/tui/providers/provider-presets.ts)) — 19 named cloud/local endpoints (Anthropic, Groq, Moonshot, Perplexity, Qwen/DashScope, SambaNova, …) that all resolve to the existing `openai-compatible` kind with `baseUrl` prefilled. Adding a vendor is a preset entry, not a provider kind. The one documented exception is `subscription-cli` — a subprocess backend has no baseUrl, no key and no HTTP path, so a preset cannot express it; within that kind the preset philosophy re-applies one level down (a new vendor CLI is a descriptor entry, never a new kind). Vendors that do not authenticate with `Authorization: Bearer` set `apiKeyHeader` (Anthropic: `x-api-key`) plus any mandatory static `headers` (Anthropic: `anthropic-version`); both are copied onto the saved config entry by [providers-wizard-build-entry.ts](src/tui/providers/providers-wizard-build-entry.ts) and applied to **both** request paths by the single [openai-auth-headers.ts](src/llm/provider/openai/openai-auth-headers.ts) builder, so discovery and chat cannot disagree. The bar for a new entry: probe `/v1/models` **with the headers the preset will actually send** and get either 200 with a `data` array, or a 401/403 that rejects the *credential* — a 401 whose body names a header the preset does not send (`x-api-key header is required`, `Invalid bearer token` for what is an API key) is a **failing** probe, not a passing one. Either way the same host must answer 404 for a bogus sibling path; a gateway that rejects everything before routing proves nothing. +- **Bundled catalogs** — `OPENROUTER_MODELS_CATALOG` (split across `openrouter-frontier-chat-models.ts` / `openrouter-open-weight-chat-models.ts`) and `AIMLAPI_MODELS_CATALOG` are offline snapshots regenerated from each vendor's public `/models` endpoint; the shared row builders live in [model-catalog-entry.ts](src/llm/provider/model-catalog-entry.ts). Refresh = re-pull the endpoint, remap (`context_length`, `input_modalities` → vision, `supported_parameters` → tools, price × 1e6 → USD/1M) and update the date in each file header. `scoreChat` in the OpenRouter fetcher **ranks** vendors; it must not gate them — the Anthropic/Gemini exclusions it used to carry hid ~40 served models from the picker. +- **Model search** ([src/llm/provider/model-search.ts](src/llm/provider/model-search.ts)) — one ranked, multi-term scorer over model ids plus catalog metadata (vendor, `vision`/`text`, `tools`, `cache`, context shorthand like `1m`, `free`/`cheap`/`routed`). Tag matching is exact equality, so a context window is tagged three ways — as displayed (`1.0m`), floored to the whole unit (`1m`, the bucket a window falls in rather than a `>=` filter: 1_310_720 answers to both `1m` and `1.3m`, a 2M window only to `2m`), and, when the window is an exact multiple of 1024, in binary (131_072 answers to `128k`). Add a tag rather than changing [format-model-details.ts](src/llm/provider/format-model-details.ts): the display string is what the rows render. Terms are ANDed, matches are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order so the picker does not jitter per keystroke. Used by `filterModelIds` (TUI modal picker + Cloud pane) and by `atomic-agent models search`. Row rendering is shared through [format-model-details.ts](src/llm/provider/format-model-details.ts) — do not re-implement the price/context/capability strings in a frontend. ### Bootstrap wiring @@ -218,6 +306,16 @@ Optional `config.llm` (v24) lists `providers[]`, `activeTextProvider`, `activeEm `ProviderRegistry.setActive(id)` / `swapActive(id)` closes the previous provider and switches the active text backend without process restart. TUI **Providers** tab ([src/tui/providers/](src/tui/providers/)) is the only surface that calls this seam. +### Credential check before save + +A cloud provider is verified before anything reaches disk. [src/llm/provider/verify/](src/llm/provider/verify/) is UI-free: `verifyProviderKey(target)` posts one `max_tokens: 1` completion through `openAiFetch` (deliberately not `openAiPostJson` — a key check must not spend the retry budget), and `classifyVerifyResponse` maps the answer onto `ok | invalid_key | no_balance | model_unavailable | rate_limited | unreachable | timeout | provider_error | cancelled`. Status codes alone do not settle it: prepaid services answer 401/403 once credit runs out, OpenAI sends `429 insufficient_quota`, and Gemini answers 400 for a bad key, so the body is consulted for billing/key wording first. `pickProbeModels` picks the cheapest **paid** OpenRouter model — a free model answers 200 on a key with no balance, which would make the check meaningless. + +`verifyWizardBeforeSave` ([src/tui/providers/verify-wizard-before-save.ts](src/tui/providers/verify-wizard-before-save.ts)) is the single seam; both the wizard (`ProvidersOrchestrator.completeWizard`) and first-run onboarding (`CloudProviderOnboarding`) go through it. Only `invalid_key` and `no_balance` block a save (`isBlockingVerifyStatus`); everything else saves and reports, so an offline machine stays configurable. Esc cancels a check in flight (`cancelSubmit` → `providers_wizard_verify_cancelled`), and a verdict arriving after a cancel is dropped. + +**A cancelled check is inert, at both call sites.** `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning still comes back as an ordinary verdict — never `"cancelled"`. Both callers therefore re-ask after the await whether the answer is still wanted: `completeWizard` on `abort.signal.aborted`, `CloudProviderOnboarding` on the same signal plus its mount check, at the success **and** the failure exit. A React `submitting` flag cannot carry this on its own: it is captured in the submit closure and the cancel handler resets it, so Enter after Esc — and two key events drained from stdin in one turn — read a stale `false` and started a second check racing the first to write the same provider. Re-entry is guarded on the in-flight `AbortController` ref instead, which is written before the first await and cleared only by the run that owns it or by a cancel. + +Pinned by [src/llm/provider/verify/classify-verify-response.test.ts](src/llm/provider/verify/classify-verify-response.test.ts), [verify-provider-key.test.ts](src/llm/provider/verify/verify-provider-key.test.ts), [pick-probe-models.test.ts](src/llm/provider/verify/pick-probe-models.test.ts), [src/tui/providers/verify-wizard-before-save.test.ts](src/tui/providers/verify-wizard-before-save.test.ts), [providers-wizard-target.test.ts](src/tui/providers/providers-wizard-target.test.ts), the `completeWizard` cases in [providers-orchestrator.test.ts](src/tui/providers/providers-orchestrator.test.ts), and the cancel-then-resolve cases in [src/tui/components/cloud-provider-onboarding.test.tsx](src/tui/components/cloud-provider-onboarding.test.tsx). + ### Locked invariants 1. **Local llama-server path unchanged when no cloud provider is active.** Grammar, slots, and GBNF tests remain the reference behaviour. @@ -228,6 +326,10 @@ Optional `config.llm` (v24) lists `providers[]`, `activeTextProvider`, `activeEm 6. **Every default tool ships a structured `argsJsonSchema`.** `ToolDescriptor.argsJsonSchema` is consumed exclusively by `descriptorsToOpenAiTools` ([openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts)) to populate `function.parameters` on the OpenAI `tools` payload. Without it, cloud providers fall back to `{ type: "object", additionalProperties: true }` — which is what we shipped originally and what enabled the `os.shell.run` silent-arg-drop bug (model double-serialised `args` into a JSON string, the provider accepted it, the tool coerced the non-array to `[]` without warning, the model never learned). The canonical map lives in [src/prompt/default-tool-args-schemas.ts](src/prompt/default-tool-args-schemas.ts); it is merged into `DEFAULT_TOOL_DESCRIPTORS` via `attachDefaultArgsJsonSchema`. MCP descriptors carry the server's `inputSchema` verbatim through the same field. Adding a new tool **requires** an entry in `DEFAULT_TOOL_ARGS_SCHEMAS` (pinned by [src/prompt/default-tool-args-schemas.test.ts](src/prompt/default-tool-args-schemas.test.ts) "attaches a schema to every default descriptor that has one registered"). Local llama-server with GBNF does **not** consume this field — the grammar already constrains the shape. 7. **`os.shell.run` rejects non-array `args` structurally.** A non-array, non-JSON-array-string `args` value now returns `{ status: "error" }` instead of silently dropping the operator's intent. JSON-stringified arrays (the cloud `native_tools` double-serialise pattern) are auto-coerced back to `string[]`. Pinned by [src/tools/os/os-tools.test.ts](src/tools/os/os-tools.test.ts) ("returns a structured error when `args` is an object" / "is a scalar string" / "recovers a JSON-stringified array `args`"). +8. **Subscription CLIs are driven, never impersonated.** `subscription-cli` shells out to the vendor CLI's documented headless mode (`--print`) and inherits that process's own authentication. It never reads, extracts, copies, or replays OAuth tokens or keychain entries; it never passes `--bare` (whose docs state OAuth and keychain are never read, which would defeat the feature); and it neither sets nor clears `ANTHROPIC_API_KEY`. It always disables the child's own tools as far as the CLI allows — `--tools ""` + `--strict-mcp-config` on `claude`, `-s read-only` + `--ignore-user-config` on `codex`, which confines rather than removes them — so the child agent cannot touch the filesystem or the operator's MCP servers outside atomic-agent's approval ladder, and the prompt always travels on stdin — never argv, which would `E2BIG` on a full two-zone prompt. Pinned by [claude-cli-adapter.test.ts](src/llm/provider/subscription-cli/claude-cli-adapter.test.ts) ("never passes flags that would defeat subscription auth or the approval ladder" / "never places the prompt on argv"). + +9. **A broken stdin pipe is expected, not fatal.** Because the prompt travels on stdin, every spawn site that writes it carries an `error` listener on `child.stdin` — [command-runner.ts](src/sandbox/command-runner.ts) and [stream-cli-completion.ts](src/llm/provider/subscription-cli/stream-cli-completion.ts). A CLI that rejects the request (signed out, unknown model, rate-limited) exits without draining stdin, and a prompt past the ~64 KiB pipe buffer then raises `EPIPE`; an `error` on a stream with no listener is fatal, and `installGlobalErrorHandlers` deliberately preserves that, so the operator would lose the whole session instead of seeing `SubscriptionCliAuthError`. The same fires on our own Ctrl+C, where `stop("abort")` SIGTERMs the child mid-write. Broken-pipe codes are absorbed and the child's own exit code and stderr report the failure; `CommandResult.inputTruncated` covers the CLIs that exit 0 regardless (`codex`), which would otherwise pass a half-delivered prompt off as a good completion. Every other stdin error still travels. Relatedly, `streamCliCommand`'s `finally` cancels the SIGKILL escalation **only once the child has exited** — clearing it unconditionally cancelled the timer `stop` had just armed, leaving one orphan per aborted turn behind any child that traps SIGTERM. Pinned by [command-runner.test.ts](src/sandbox/command-runner.test.ts), [run-cli-completion.test.ts](src/llm/provider/subscription-cli/run-cli-completion.test.ts) and [stream-cli-completion.test.ts](src/llm/provider/subscription-cli/stream-cli-completion.test.ts) ("force-kills a child that traps SIGTERM instead of orphaning it"). + ### Embeddings Symmetric **`EmbeddingProviderRegistry`** ([src/memory/embeddings/embedding-provider-registry.ts](src/memory/embeddings/embedding-provider-registry.ts)) with `OpenAiEmbeddingProvider` / `OpenRouterEmbeddingProvider` for `POST /v1/embeddings`. Hybrid recall degradation contract unchanged. @@ -237,7 +339,7 @@ Symmetric **`EmbeddingProviderRegistry`** ([src/memory/embeddings/embedding-prov `atomic-agent` supports two modes for the llama-server backend (`config.llama.mode`): - `external` (default) — user runs `llama-server` out-of-band; runtime reads the URL from `config.llama.url` (env fallback `ATOMIC_AGENT_LLAMA_URL`). -- `managed` — `atomic-agent` downloads the llama.cpp binary from `AtomicBot-ai/atomic-llama-cpp-turboquant` GitHub Releases into `/llamacpp/backend/` and GGUF models into `/llamacpp/models//`. The server is **not** spawned by the runtime; operators control lifecycle via `atomic-agent llama start|stop|status|update`. +- `managed` — `atomic-agent` downloads the llama.cpp binary from `AtomicBot-ai/atomic-llama-cpp-turboquant-nightly` GitHub Releases into `/llamacpp/backend/` and GGUF models into `/llamacpp/models//`. The server is **not** spawned by the runtime; operators control lifecycle via `atomic-agent llama start|stop|status|update`. Managed start auto-pulls a newer zip when `localModels.managed.autoUpdate` is true (default since config v41). A failed check or download never blocks start — the existing binary is used. The two entry points differ deliberately: **TUI auto-start** brings the daemon up first and runs the update afterwards, off the start path, so the user never faces a typeable prompt with no model behind it; that pass also refuses to stop the live daemon (`keepDaemonRunning`), so the swap lands on the next start. **CLI `models start`** is an explicit one-shot command, so it still updates before starting. Both bound the download with a timeout. **Invariant (preserved):** the agent runtime never starts a `llama-server` process. It only connects. Managed-mode lifecycle lives entirely in the `atomic-agent llama` CLI so runtime code paths stay single-mode. @@ -982,6 +1084,7 @@ Every entry point into the runtime — CLI, TUI, HTTP, sidecar, scheduler, and w | `ProfileStore` / `MemoryStore` / `SessionStore` | Anything holding a handle | **Yes** — all three use `better-sqlite3`, which is **synchronous**: there is no race window between read and write inside a single statement, so concurrent sessions are safe. **This is a load-bearing assumption.** Replacing the driver with an async one would require a redesign. | | `ReflectionRunner.pending` | Per-session `Map` | **Yes** — reflection on session A is never aborted by reflection on session B. `agent-loop.runTurn` calls `reflectionRunner.abortPending({ sessionId: state.id })` at the start of every turn so a stale reflection from the previous same-session turn cannot race the next one. `abortPending()` with no argument cancels every in-flight reflection (used at runtime shutdown). | | Trace recorder | Per-session, dispatched via `AsyncLocalStorage` | **Yes** — no global pointer to mix traces across sessions. | +| `SteeringInbox` | Per-session `Map`, drained only by the turn running on that session | **Yes** — a steer on session A is invisible to session B, and only one turn per session can drain (`TurnController` invariant 1). | ### What the scheduler / webhook paths may and may not assume @@ -991,10 +1094,38 @@ Every entry point into the runtime — CLI, TUI, HTTP, sidecar, scheduler, and w - **May not** assume exclusive browser ownership across sessions; the browser is shared at process scope (see table). - **Must not** hold a stale `SessionState` reference between `enqueue` and `run`. `executeTurn` writes its result to `sessionStore`; the correct pattern is to **re-read the latest session inside the queued callback** (see [src/sidecar/main.ts](src/sidecar/main.ts) `send_message` for the canonical example). +### Mid-turn steering + +Per-session FIFO is correct for *starting* turns and wrong for *correcting* one. An operator who watches the agent head the wrong way should not have to abort the turn or wait it out to say "no, do X instead". `SteeringInbox` ([src/runtime/steering-inbox.ts](src/runtime/steering-inbox.ts)) is the out-of-band channel for that, and it is deliberately **not** a second queue: + +- **It never starts a turn.** `runtime.steer(sessionId, text)` returns `false`, and queues nothing, unless a running turn can still pick the message up. A `false` return means "not steered" — the caller falls back to `runTurn` or to its own pending-message queue. There is still exactly one path into `AgentLoop.runTurn`. +- **Acceptance is one fact, not two.** The inbox itself owns the window: `AgentLoop.runTurn` calls `open(sessionId)` on entry and `closeAndDrain(sessionId)` on the way out, and `push` refuses whenever the window is shut. `steer()` does **not** consult `turnController.isBusy` — `isBusy` stops being true at a *different moment* than "a drain is still coming" (the loop's final drain happens inside `runTurn`, `busy.delete` later in the controller's own `finally`), and a check-then-act across those two facts loses the message in between: accepted, never delivered, and resurfacing at step 0 of some later turn under a "while you were working" notice about a turn that had already ended. Because the same call closes the window and takes what is pending, there is no window at all: a message is either delivered at a step boundary, returned on `undelivered`, or refused outright. +- **It lands at a step boundary.** `AgentLoop.runTurn` drains the inbox at the top of every step, before building that step's prompt. Effect is visible one step later at the earliest — never mid-inference, never mid-tool-call. A turn parked in a long `os.shell.run` will not react until that call returns. +- **It writes to the transcript.** Each drained message is recorded as a real `user` `ConversationTurn`. The transcript must reflect what the operator actually said; `packConversation` already guarantees the last `user` turn stays visible, and `findCurrentMacroTurnStart` treats the steer as part of the macro-turn in progress. Note this **does** count toward reflection segmentation cadence (`state.turnCount` is untouched, but the turn list grows) — a steer is a real user message, so that is the intended reading. +- **The UI sees it land.** `steer_applied` (`{ text, stepIndex }`) is emitted at the step the message was folded into. `reduceAgentEvent` ([src/tui/agent-event-reducer.ts](src/tui/agent-event-reducer.ts)) renders it inline in the turn already running — a user bubble plus a feed line naming the step — with none of the per-turn resets `user_message` triggers. That switch is exhaustiveness-checked (`const unhandled: never = event`), so the next `AgentLoopEvent` added without a case is a compile error rather than a silent no-op; it still returns `state` at runtime, because a UI reducer must not throw on an event it does not know. +- **It shares `### notice` with the loop detector.** Both write the one-shot notice slot; `composeSteerNotice` ([src/agent/steer-notice.ts](src/agent/steer-notice.ts)) appends rather than overwrites, loop-detector text first. The message text is repeated inside `### notice` even though it is already in `### conversation`: the notice sits immediately before `### respond`, which is the block small local models reliably act on. Long pastes are clipped inline and point back at the transcript copy. +- **Nothing is silently lost.** A message pushed after the loop's final drain — during the last inference, or into a turn that was cancelled before it stepped — comes back on `RunTurnResult.undelivered`. Callers MUST re-route it: `ChatOrchestrator.rerouteUndelivered` ([src/tui/chat-orchestrator.ts](src/tui/chat-orchestrator.ts)) puts it at the **head** of its pending-message queue, ahead of anything typed after `steer` started refusing. A caller that ignores `undelivered` drops a message `steer` already answered "yes" to — that is a bug, not a style choice. `shutdown()` calls `clearAll()` so a stale steer cannot resurface in a later process. +- **The caller offers, then falls back.** `ChatOrchestrator.sendMessage` is the reference shape: while a turn is in flight it calls `runtime.steer` first and only queues on its own when that returns `false`. (The editor stays live during a turn — a mid-turn submission routes through `handleEditorSubmit` as `message_queued` and reaches this path; steer first, queue on refusal.) +- **A refusal is not a demotion.** The caller's "a turn is in flight" is strictly WIDER than the window: `ChatOrchestrator` sets `currentController` before `runtime.runTurn`, and the loop's `open()` runs only once `turnController.enqueue` stops parking in `waitOrAbort` — i.e. after any out-of-band turn on the same session finishes settling. A steer aimed into that span is refused, so the fallback must keep the operator's ordering: `queueAsSteer` splices it in at the **front** of the pending queue (behind steers already re-routed for the same turn, so their typing order survives) and still emits a "steering the running turn" acknowledgement — worded so it holds whether the window was already shut, not yet open, or full. The window is deliberately NOT opened at the caller's commit point instead: it is a per-session single slot, so opening it before the submission owns the session lock aliases two turns onto one window — the turn still running would drain a message meant for the parked one, and its `closeAndDrain` would carry off the parked turn's pending steers as its own `undelivered`. And on the abort-while-parked path `run()` never executes, so nothing would close the window or hand anything back. +- **The caller reads exactly one fact.** `sendMessage` does not consult `steeringInbox.isOpen` to tell "window shut" from "inbox full" apart, and no caller should gate on `turnController.isBusy` before calling `steer`. Both are second facts read at a different moment than the one `steer` acts on — the check-then-act this mechanism exists to remove. `steer`'s return value is the whole answer. +- **Bounded.** `MAX_PENDING_STEERS` (16) per session; `push` refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. + +**TUI surface.** The editor stays live for the whole turn, so Enter has to mean something while the agent is working. `tui.whileBusySubmit` (`"steer" | "queue"`, default `"steer"`) decides which, `Ctrl+T` flips it in-app and persists the flip, and the prompt meta-row shows the live mode (`⏎ steer` / `⏎ queue`) whenever a turn is running. `/steer ` and `/queue ` land one message in the other mode without changing the default; bare `/steer` switches to steer mode, `/queue mode` to queue mode — bare `/queue` stays a side-effect-free listing, because the menu node and the `/queue N parked` chip both invite running it just to look. All three routes to the setting (Ctrl+T, bare `/steer`, `/queue mode`) go through the single `onWhileBusyModePersistRequested` callback into `persistUserWhileBusySubmit`, so the choice survives a restart and there is one place that can fail. + +**Host surfaces.** The sidecar exposes it as the `steer_message` NDJSON request (`{sessionId, text}` -> `{steered}`) plus the `steer_applied` / `steer_undelivered` events; `serve` exposes `POST /api/sessions/{id}/steer` with body `{text}` — `200 {steered:true}`, `409` when no running turn will pick the message up (it is refused, not swallowed — retry with `POST /v1/chat/completions`), `429` when the inbox is full. Neither handler goes through `turnController.enqueue`: enqueueing would park the message behind the turn it is meant to redirect. Neither pre-checks `turnController.isBusy` either — `runtime.steer` is the single authority on accept/refuse, and the HTTP route reads the inbox only *after* a refusal, to choose between the two status codes. A pre-check would be a second, staler fact that can reject a steer the runtime would have taken. +**Every host surface consumes `undelivered`.** Accepting a steer is a promise to say where it ended up, so no surface may drop `RunTurnResult.undelivered`: + +- **Sidecar.** `send_message` emits one `steer_undelivered` event per stranded message. The host owns it from there. +- **HTTP.** `POST /api/sessions/{id}/steer` and the turn that would have carried the message are different exchanges — the steer was answered long before the turn closed, and the completion response goes to whoever owns the turn, who is not necessarily whoever steered. So the route that ran the turn ([src/http/openai-chat-completions.ts](src/http/openai-chat-completions.ts)) always parks the hand-back in `UndeliveredSteerStore` ([src/http/undelivered-steers.ts](src/http/undelivered-steers.ts)), on the success, failed and threw paths alike, and additionally mirrors it onto that response where one can carry it: `undelivered_steers` on the non-stream `chat.completion` body (absent when the turn delivered everything), an `event: steer_undelivered` SSE frame for extensions-opt-in streams (a vanilla OpenAI stream stays strict). The mirrored entries carry the parked `seq`, so they are the same message, not a second copy. Hosts read parked messages with `GET /api/sessions/{id}/steer` and acknowledge with `DELETE /api/sessions/{id}/steer?through={seq}&discarded={n}` (either parameter alone is fine; at least one is required); reads are non-destructive because a retried or prefetched `GET` must not be able to lose the text, and the ack is by cursor so a steer parked between the two calls survives. `DELETE /api/sessions/{id}` drops that session's parked messages with the row. The store is per-server and in-memory (bounded by `MAX_PARKED_STEERS` per session and `MAX_PARKED_SESSIONS` sessions), matching the inbox it drains from — neither survives a restart. Two properties the cap must not break: **`discarded` is acked separately** from the entries — the discarded messages have no `seq` the host was ever shown, so the entry cursor cannot stand in for having read the loss count, and a box outlives its entries while a loss is unacknowledged (still reclaimed by the session purge and by session eviction); and **a hand-back is returned whole** — the cap evicts only entries parked by *earlier* calls, never the batch it was just handed, because that return value is what becomes `undelivered_steers` / `steer_undelivered` and trimming it would omit messages from the one payload meant to carry them. +- **Anything else that calls `runTurn` directly** (task runner, channels) inherits the same obligation. + +Pinned by [src/runtime/steering-inbox.test.ts](src/runtime/steering-inbox.test.ts), [src/agent/steer-notice.test.ts](src/agent/steer-notice.test.ts), [src/agent/agent-loop-steering.test.ts](src/agent/agent-loop-steering.test.ts) (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep), [src/tui/chat-orchestrator-steering.test.ts](src/tui/chat-orchestrator-steering.test.ts) (steer-then-queue fallback, `undelivered` re-route and its ordering, plus a real-`TurnController` harness that parks a TUI turn in `waitOrAbort` behind an out-of-band one and steers into the gap) and the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts) — including the one that stands in the window between the loop's final drain and `busy.delete`. + ### Extension points - `TurnController.isBusy(sessionId)` / `busySessionIds()` — observability hook for UI and scheduler. - `TurnController.emit(sessionId, event)` — single dispatch path for `AgentLoopEvent` to the per-session hook. +- `runtime.steer(sessionId, text)` — fold a message into the turn already running on that session. Returns `false` (and queues nothing) when no running turn can still pick it up. Deliberately not gated on `isBusy` — see §"Mid-turn steering". - `runtime.executeTurn(session, msg, opts)` — bypasses the queue. Used by sidecar from inside an already-acquired `enqueue` callback so it does not deadlock against itself. CLI / TUI / HTTP go through the public `runtime.runTurn` instead. ### Risk (acknowledged) @@ -1328,6 +1459,24 @@ Slash commands: `/memory` opens the tab; `/memory dump` keeps the legacy profile 4. **Note detail exposes link neighbours when `memory.links.enabled`.** `g` runs `linkStore.expand`; Enter on a neighbour opens that note by id. 5. **Config gates surface hints, not crashes.** Disabled channels show an empty list + `channelHint` string. +## New terminal window (Ctrl+N) + +**Ctrl+N** in the TUI (and the `/window` slash command, alias `/newwindow`) opens a **new OS terminal window** running a fresh `atomic-agent tui` in the same working directory. It is a second agent in a second process — not a second view of the current session, which the per-session runtime lock would not allow. `/new` remains the in-process "fresh session, warm runtime" reset; the two are deliberately different commands. + +The resolver is split so the platform logic is unit-reachable without opening windows: + +- [src/tui/build-terminal-launch.ts](src/tui/build-terminal-launch.ts) — **pure**. `buildTerminalLaunch({platform, execPath, argv, isSea, cwd, env, hasBinary})` → `{cmd, args, label}` or `null`. macOS drives `osascript` → `Terminal` (or `iTerm` when `TERM_PROGRAM === "iTerm.app"`); Linux probes `$ATOMIC_AGENT_TERMINAL` → `$TERMINAL` → gnome-terminal / konsole / xfce4-terminal / kitty / alacritty / wezterm / x-terminal-emulator / xterm through the injected `hasBinary`; Windows uses `wt.exe -w -1 nt` when present, else `cmd.exe /c start … cmd /k`. +- [src/tui/open-terminal-window.ts](src/tui/open-terminal-window.ts) — the effectful half: `detached: true, stdio: "ignore"` + `unref()` so the new window outlives this process, `spawn` injectable, every failure returned as `{ok: false, reason}` and never thrown into the render loop. Also owns the `isOnPath` PATH probe (no `which` shell-out). + +Two details that are easy to regress: + +1. **`argv[1]` must be dropped for a SEA build** and kept under plain node — same reasoning as the self-update relaunch in [src/tui/tui-command.ts](src/tui/tui-command.ts); `tui` is always appended explicitly. +2. **`ATOMIC_AGENT_STATE_DIR` travels inside the command line.** A spawned terminal starts a login shell and inherits nothing from us, so without the inline assignment the second window would silently attach to a different state dir. + +The POSIX command line ends with `exec "${SHELL:-sh}"` on Linux because `-e` closes the window the instant the agent exits, which would eat a startup error. macOS `do script` already leaves the shell alive, so it does not need this. + +Pinned by [src/tui/build-terminal-launch.test.ts](src/tui/build-terminal-launch.test.ts) (per-platform argv shapes, SEA split, state-dir passthrough, shell + AppleScript escaping, `null` on a headless box), [src/tui/open-terminal-window.test.ts](src/tui/open-terminal-window.test.ts) (detach/unref, error-as-value, PATH probe), [src/tui/app-key-bindings.test.ts](src/tui/app-key-bindings.test.ts) (Ctrl+N fires only outside modals / the slash palette / a pending approval) and [src/tui/commands/slash-command-handler.test.ts](src/tui/commands/slash-command-handler.test.ts) (`/window` vs `/new`). + ## Vision (multimodal input) Image recognition is an opt-in feature wired through the active **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `LlamaServerProvider` for local `/v1/chat/completions`, `OpenAiProvider` / `OpenRouterProvider` for cloud. The text agent loop is unchanged — vision lives outside the conversation transcript, exposed only via the `vision.describe` tool. @@ -1358,7 +1507,7 @@ Image recognition is an opt-in feature wired through the active **`LlmProvider`* ### Configuration (`vision.*`) -User-config block (`config.json` v6; `ensureUserConfigFileSync` actively migrates older files on bootstrap — when the on-disk `version` is below `USER_CONFIG_VERSION`, the parsed contents are atomically rewritten with the bumped version and any newly-added blocks filled from `USER_CONFIG_DEFAULTS`. Existing user values are preserved verbatim and a single `migrated config vN → vM` line is emitted to stderr for audit. Read-only call sites (`readUserConfigFileSync`) stay non-mutating): +User-config block (`config.json` v6; `ensureUserConfigFileSync` actively migrates older files on bootstrap — when the on-disk `version` is below `USER_CONFIG_VERSION`, the parsed contents are atomically rewritten with the bumped version and any newly-added blocks filled from `USER_CONFIG_DEFAULTS`. Existing user values are preserved verbatim and a single `migrated config vN → vM` line is emitted to stderr for audit. Read-only call sites (`readUserConfigFileSync`) stay non-mutating. **Migration is one-way.** A file whose `version` is *above* `USER_CONFIG_VERSION` — an install that was rolled back, or two builds sharing one state dir — is read with the running build's schema rather than rejected, keeps its own version, and is never rewritten at startup; `writeUserConfigFileSync` refuses to lower the version field, and unknown top-level blocks survive the round trip. Rejecting a newer file bricks every command, because `getConfig()` runs ahead of all of them; downgrading its version silently reverts settings, because the version gates behavioural defaults (`< 41` forces `managed.autoUpdate` on, `< 22` overrides a `memory.*` opt-out, `< 25` rewrites `http.approvalMode`)): - `vision.enabled` (default `true`) — master switch. Set to `false` to skip provider construction and tool registration entirely. - `vision.autoDetect` (default `true`) — when `true`, the provider's capabilities follow `ModelProfile.vision.supported`. When `false`, the provider trusts the operator and reports `vision: true` regardless of `/props`; useful when running a custom backend that does not expose multimodal flags. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..9b9f33a3 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,299 @@ +# atomic-agent redesign build — test plan + +Build = v0.3.7 main + PR #221 + the 8-PR onboarding stack (#220–#228) + 15 new slices. +Every item below names the exact expectation; report PASS/FAIL per item, with a screenshot +or frame excerpt for anything that fails, plus anything odd you notice beyond the list. + +## Setup + +Fresh first run (delete the state dir to see onboarding again): + +``` +rm -rf /tmp/atomic-test-state +cd ~/claudecode1/atomic-agent-onboarding && PATH=~/.local/node25/bin:$PATH \ + ATOMIC_AGENT_STATE_DIR=/tmp/atomic-test-state node dist/cli/index.js tui +``` + +Terminal at 100×30 or larger for the first pass; smaller sizes are their own section. + +## 1 · Intro splash + +- [ ] Starfield fills the screen: stars in 4 glyphs (`·` `✧` `✦` `✛`) and 4 brightnesses + (dim blue → white), clustered like sky, not evenly sprinkled +- [ ] The mark is clean inside its clear space — no star touches it or the wordmark +- [ ] Tagline "Local AI-First Agent" types itself in (~1s); cursor `▌` while typing +- [ ] ANY key finishes the reveal; a second one advances to setup +- [ ] Mouse click counts too: click once (reveal finishes), click again (advances) +- [ ] Mouse wheel also counts as input +- [ ] Ctrl+C on the splash quits the app (twice if armed-quit asks) +- [ ] Resize the window while on the splash: layout re-fits live, footer stays on the last row + +## 2 · Setup screens, general + +- [ ] Every setup screen's content block is centred horizontally AND vertically +- [ ] Rows inside the block stay left-aligned (a block, not ragged centred lines) +- [ ] Hint strip is always the last terminal row +- [ ] Below 100×30 a footer note advises widening — but nothing ever blocks + +## 3 · Small terminals + +- [ ] ~86×26: reduced tier — mark + wordmark, fewer/no stars, advisory in footer +- [ ] ~60×11: the xs mark (`▗█▄░` / `▀█▘░`) is drawn — NOT a missing icon +- [ ] Any key still advances at every size + +## 4 · Choose backend + +- [ ] Three options with cost copy (Private/free per token · needs an API key · nothing downloaded) +- [ ] 1/2/3 shortcuts, j/k, arrows, Enter, Esc=skip all work + +## 5 · Local models + +- [ ] Screen is titled "Recommended models" +- [ ] One row has ★ recommended, sized to this machine's RAM, download ≤ 8 GB +- [ ] Models too big for this machine are dimmed/warned, not hidden +- [ ] "Add a model from Hugging Face…" row exists +- [ ] HF input accepts `owner/repo` or a full URL; junk input shows a readable error on the + same screen; a repo with no GGUF is refused with a reason +- [ ] Starting a download shows: two bars (runtime, weights), %, MB, speed, ETA +- [ ] ATOMS: slow-floating atoms in the free space below the bars — appearing, disappearing, + bouncing off edges; on (rare) collision they flash toxic green; slow like a TV bouncer, + not busy +- [ ] Atoms never draw over the bars/text and stop if the download fails +- [ ] The "press c — set up a cloud model in the meantime" block is visible during a download +- [ ] Pressing `c` opens the cloud wizard; download keeps running + +## 6 · Wait or jump (after `c` + wizard finished, download still running) + +- [ ] REAL progress bar on this screen (not just a text %) +- [ ] NO "Wait here until it finishes" row +- [ ] "Add another cloud provider" row exists and re-opens the wizard, returning here after +- [ ] One skip row leads to the agent; download continues, chip in the top bar +- [ ] If the download already finished: screen says the local model is ready (no fake 0% bar); + if it failed: says so and offers Retry + +## 7 · Cloud wizard + +- [ ] Blue is bright/readable on titles and the selected row (not the old dark navy) +- [ ] Provider list: typing filters (e.g. "open" → OpenRouter/OpenAI rows), counter shows + filtered/total, empty query state says "no matches", Esc/Enter behave +- [ ] Model list (OpenRouter, 345 rows): same filter; typed query text is bright +- [ ] At 24 terminal rows the footer hint is still visible with the search line present + +## 8 · Propose-the-other-backend gating + +- [ ] Configure CLOUD only, never open the local screen → after the wizard, the "set up local + models too?" screen appears +- [ ] Fresh state, OPEN the local models screen, Esc back, then configure cloud → the propose + screen must NOT appear +- [ ] It is never shown twice (recorded in config) + +## 9 · Home screen (after setup or skip) + +- [ ] Meta row under the composer reads: backend · provider · model (provider and model + SWAPPED vs the old order), in a brighter font +- [ ] All three are buttons: mouse click AND keyboard (advertised key) open them +- [ ] Backend switch: three options — cloud / local / custom — switching actually works +- [ ] Provider switch: lists configured providers + "Add a new provider" (opens the wizard) +- [ ] Model switch: full catalog with typing filter and a (n/total) counter + +## 10 · Composer + +- [ ] Add lines (alt+enter, or shift+enter on kitty-protocol terminals — hint strip states + which): the input grows UPWARD over the content; background rows DO NOT move +- [ ] Delete the lines: the original screen returns exactly +- [ ] With a tall draft, ctrl+p menu is fully visible (composer collapses while a menu/modal + is open, re-expands after) +- [ ] Shift+arrows select text (all four directions); plain arrow collapses the selection +- [ ] Ctrl+C with selection copies to the SYSTEM clipboard (paste it somewhere to check); + without selection Ctrl+C keeps its abort/quit meaning +- [ ] Ctrl+X cuts (clipboard has it, text gone, cursor sane) + +## 11 · Controls while the agent thinks + +Send a long prompt first (any model; a failing turn is fine — controls matter, not the answer): + +- [ ] While "thinking": ctrl+p opens the menu; session picker opens; nothing is frozen +- [ ] New session mid-turn works; a notice says the old turn continues in background +- [ ] The old session's row stays in the rail; switching back shows YOUR PROMPT and progress + (not an empty pane), spinner still live if running +- [ ] Esc in the NEW session does not abort the detached turn +- [ ] Enter still steers the running turn when you are IN its session (that behaviour is + unchanged on purpose) + +## 12 · External llama.cpp (if you have a llama-server) + +- [ ] Plain http://host:port — connects, model name shown +- [ ] Save verdicts (probing…, errors) appear ON the External pane, not on a hidden tab +- [ ] Behind a reverse-proxy path (http://host/llama) — now works (path preserved) +- [ ] Server with --api-key: save is refused with a message naming + ATOMIC_AGENT_LLAMA_API_KEY; set it in the state dir's .env → connects +- [ ] Pointing it at an OpenAI-only server tells you to add it as a cloud provider instead + +## 13 · Regression spot-checks + +- [ ] Second launch after finishing/skipping setup: onboarding does NOT reappear +- [ ] Esc-skip on the choose screen lands in the agent and is remembered +- [ ] Top-bar download chip appears during a pull from anywhere in the app; sheds detail on + narrow terminals instead of wrapping the bar +- [ ] `● cloud/local` status remains legible (glyph + word, not colour alone) +- [ ] Nothing overlaps or pushes the status bar/hint strip at 80×24 + +## Round 3 + +Nine items on top of the round-2 build. Same setup as above; a fresh state dir +re-runs onboarding where an item needs it. + +### R3.1 · Mouse everywhere in onboarding + +- [ ] Choose screen: first click on an option selects it, second click activates — + same as Enter, no separate click behaviour +- [ ] Same select/activate pattern on: local model picks (incl. the pinned + "Add a model from Hugging Face…" row), the HF file list, the propose-second + screen, the wait-or-jump rows, and the cloud wizard's pick lists +- [ ] Cloud wizard rows are clickable both inside onboarding AND in the + Providers/LLM panels (clicks act on the wizard the frame drew, not a stale one) +- [ ] URL / HF reference editors: click-to-caret works +- [ ] Download screen: clicking the "press c" offer block opens the cloud wizard + (same as pressing c) +- [ ] Mouse wheel on any setup list walks the cursor; the wheel never scrolls the + invisible chat transcript behind the flow +- [ ] HF reference screen has a `[ clear ]` control below the input (click or ctrl+l) + that empties the field + +### R3.1b · Skip the download + +- [ ] The download screen shows "press s — skip, start using the agent now" below the + cloud offer (and on the failed variant, with honest copy — no "keeps running" claim) +- [ ] `s` or clicking the row lands on the home screen with the download chip in the + top bar; the pull continues +- [ ] Skipping does NOT trigger the "set up the other backend?" screen on the way out, + and does not suppress it for future runs (completedAt stamped; nothing else) +- [ ] Known limit: the "keeps running" promise is session-scoped — quitting the app + mid-download does not resume the pull on relaunch (the turn gate explains the + state if you chat before re-downloading) + +### R3.2 · Centred download screen + ambient atoms + +- [ ] The download step's text block (headline, bars, offer) is centred like every + other setup screen — it no longer hugs the full width +- [ ] The atom field is ambient: it spans the full terminal width BELOW the centred + block, in the free space, never inside/over the text +- [ ] Atoms stop when the pull fails or finishes; the "press c" offer stays clickable + inside the centred block + +### R3.3 · Local meta row — chosen-model switch + +- [ ] In local (managed) mode the composer's second control shows the CHOSEN model id + (the catalog id you picked, not the GGUF file name from /props) +- [ ] Opening it lists DOWNLOADED models only, plus a "Download more models…" row + that deep-links to Manage > LLM > Local +- [ ] On a fresh boot with models on disk the switch lists them (a one-shot refresh + fires on open; a "loading…" row may flash, never a false "nothing downloaded") +- [ ] Picking "local" as backend right after onboarding labels the backend control + "local", not "custom" — including on the home screen before the Models tab was + ever opened +- [ ] The <-/-> strip walk skips the provider switch in local mode (not drawn there); + cloud mode is unchanged + +### R3.4 · Local meta row — daemon status + RAM + +- [ ] Third control reads status word + RAM, e.g. "healthy · 4.4 GB" +- [ ] starting = daemon starting/loading or health probing; healthy = health probe OK; + down = unreachable/error; unknown renders nothing +- [ ] No RAM segment when there is no managed daemon pid (external mode, daemon down) +- [ ] Clicking the control opens the local models pane — it never switches or + downloads anything + +### R3.5 · Download chip label cap + +- [ ] A custom HF model with a very long id (80+ chars) shows an ellipsised chip label + (≤ 30 columns) — the status bar stays one row +- [ ] On narrow terminals the chip sheds to the percent-only form instead of + overflowing; actions still target the full untruncated id + +### R3.6 · Not-downloaded turn gate + +- [ ] Managed mode, active local model NOT on disk: submitting a turn is refused + up front — "local model X is not downloaded — open Models (/local) … + (message returned to the editor)" — no transport-retry burn, no bare + "fetch failed" +- [ ] While a pull is in flight the refusal shows LIVE progress + ("downloading now — 53% · 2.1 GB / 4.2 GB") +- [ ] The gate also fires for a llama-server provider saved under a custom id + (detection is by provider KIND, not the literal `local-llama` id) +- [ ] With a fallback chain of >1 link the turn RUNS (one-line notice only) and + fails over +- [ ] External mode and cloud providers never gate +- [ ] A blocked submit returns the text to the editor and does NOT create a + /history entry (a refused submit is not a run) + +### R3.7 · Right-click cut/copy/paste menu + +- [ ] Right-click on the composer opens a small menu anchored at the click cell +- [ ] Paste is always offered; cut/copy only when a selection exists +- [ ] One click acts; a click outside closes it; Esc closes (consumed); any other + key closes it and keeps its own meaning +- [ ] Cut/copy use the system clipboard; paste inserts the system clipboard through + the field's own rules (multi-line paste behaves like a bracketed paste) +- [ ] Full menu on all five multi-line editors; paste (menu right-click) also works + on the typed one-line fields: wizard api_key/base_url/model line, list + searches, filters, the external llama URL draft +- [ ] Ctrl+V / Cmd+V paste chord works in the editor (for terminals that swallow + right-click) +- [ ] The composer does NOT collapse while the menu is open (it is not a modal), + and the menu never survives under a raised modal floor + +### R3.8 · Fallback pane fixes + +- [ ] LLM tab › Fallback: `<` `>` reorder, `d` remove, `l` toggle append-local and + the add picker all PERSIST (re-open the pane / restart: the chain survives) +- [ ] An empty chain still shows the "+ add link" row (cursor never points at an + invisible row); shrinking the chain re-clamps the cursor +- [ ] Chain rows / add row / picker rows are clickable (same activation as Enter) +- [ ] `/llm fallback` deep-links to the pane AND refreshes providers on arrival — + config edited outside the app shows current, not stale, chain state +- [ ] The menu/slash description mentions the fallback pane + +### R3.9 · Hosted stub llama-server (Vercel) — External connector + +A public stub of a stock llama-server for end-to-end testing of the External +llama.cpp connector — happy path and the failure shapes that used to be silent — +from any machine, no local server needed. + +Base URL: **https://llama-stub-vercel.vercel.app** (canonical URL only; hash +deployment URLs sit behind Vercel's deployment protection). Failure shapes are +path-prefix modes — one deployment, four base URLs (query strings are stripped +by the client, and no prefix ends in `/v1` because the client drops a trailing +`/v1`). The stub always answers `stream:true` in SSE framing. + +Paste each URL into **LLM tab › External › Enter**: + +| Base URL | Imitates | Expected in the app | +| --- | --- | --- | +| `https://llama-stub-vercel.vercel.app` | stock llama-server | saved; row `[healthy]`; status bar names `qwen3-30b-a3b-q4_k_m.gguf`; a chat turn answers "stub says hi" over SSE | +| `https://llama-stub-vercel.vercel.app/llama` | same server behind a reverse-proxy path prefix | saved with the path preserved (`/llama/health` probed, not origin `/health`); chat turn works | +| `https://llama-stub-vercel.vercel.app/auth` | `llama-server --api-key` (llama.cpp's real exemptions: `/health`, `/models`, `/v1/models`, `/api/tags` stay public) | refused at save time: "http 401 — the server requires an API key (--api-key). Set ATOMIC_AGENT_LLAMA_API_KEY in the state dir's .env and retry." | +| `https://llama-stub-vercel.vercel.app/openai` | OpenAI-compatible-only runner (LM Studio / Ollama / vLLM: `/v1/*` only, no `/health`) | refused with the redirect: "answers like an OpenAI-compatible server, not llama.cpp. Add it as a cloud provider instead: LLM tab › Cloud › n › openai-compatible…" | + +The `/auth` key is `sk-stub-key` (`STUB_API_KEY` env on the Vercel project); set +`ATOMIC_AGENT_LLAMA_API_KEY=sk-stub-key` to test the accepted-key path. + +Curl smoke: + +```sh +B=https://llama-stub-vercel.vercel.app +curl $B/health # {"status":"ok"} +curl $B/props # stock body, model_path .../qwen3-30b-a3b-q4_k_m.gguf +curl -X POST $B/completion -H 'content-type: application/json' \ + -d '{"stream":true,"prompt":"x"}' # SSE: data: {...} +curl $B/llama/health # 200 +curl $B/auth/health # 200 (exempt) +curl $B/auth/props # 401 Invalid API Key +curl -H 'authorization: Bearer sk-stub-key' $B/auth/props # 200 +curl $B/openai/v1/models # 200, data[] +curl $B/openai/health # 404 +``` + +Requests are logged (`method path auth`) — `npx vercel logs +llama-stub-vercel.vercel.app` or the Vercel dashboard. Stub source: +`~/claudecode1/llama-stub-vercel` (one catch-all function, `api/stub.mjs`). diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..8e34c10c --- /dev/null +++ b/TODO.md @@ -0,0 +1,49 @@ +# /max_steps Slash Command Implementation Plan + +**Goal:** Implement a runtime `/max_steps ` slash command to adjust the agent's max_steps configuration without requiring a restart. + +**Architecture:** +- Add new slash command definition to SLASH_COMMANDS registry +- Implement sub-dispatcher function to handle /max_steps command +- Command will validate input, update runtime config, and persist to config.json +- Provide user feedback on success or validation errors + +**Tech Stack:** +- TypeScript +- Existing slash command infrastructure +- Config persistence system + +--- + +## Testing Plan + +I will add integration tests that ensure the /max_steps slash command properly updates the agent's max_steps configuration and persists it across sessions. + +I will add unit tests that verify the sub-dispatcher correctly parses arguments and returns appropriate dispatch results. + +I will add manual verification tests that confirm the command works in the TUI and affects agent behavior. + +NOTE: I will write *all* tests before I add any implementation behavior. + +--- + +## Implementation Details + +- Add "max_steps" entry to SLASH_COMMANDS in src/tui/commands/slash-commands.ts +- Implement dispatchMaxStepsSub function in src/tui/commands/slash-command-handler.ts +- Function should: + * Parse numeric argument from rawArgs + * Validate it's a positive integer + * Update getConfig().agent.maxSteps with new value + * Persist updated config to config.json using writeUserConfigFileSync + * Return systemMessage confirmation +- Handle edge cases: non-numeric input, negative numbers, zero +- Follow existing patterns from dispatchThemeSub, dispatchModelsSub, etc. + +**Question:** Should the command update only the runtime config or also persist to disk? Based on user request for "runtime" adjustment, I'll update both runtime and persist so the setting survives restarts. + +**Question:** Should I validate against any maximum value? The config schema uses parsePositiveInt which only requires >0, so I'll follow that. + +**Question:** How to provide immediate feedback? Through systemMessage in SlashDispatchResult like other commands. + +--- \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2a563a31..a6a5b992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "atomic-agent", - "version": "0.2.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "atomic-agent", - "version": "0.2.1", + "version": "0.4.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -41,6 +41,7 @@ "yaml": "^2.8.3" }, "bin": { + "atag": "dist/cli/index.js", "atomic-agent": "dist/cli/index.js", "atomic-agent-sidecar": "dist/sidecar/main.js" }, diff --git a/package.json b/package.json index 387cb3ab..4d0d8532 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "atomic-agent", - "version": "0.2.1", + "version": "0.4.2", "description": "Lightweight local operator agent (browser + OS) runtime for Tauri apps. Connects to an external llama.cpp server via HTTP and exposes a sidecar NDJSON protocol plus a debug CLI.", "license": "MIT", "type": "module", "main": "dist/sidecar/index.js", "bin": { "atomic-agent": "dist/cli/index.js", + "atag": "dist/cli/index.js", "atomic-agent-sidecar": "dist/sidecar/main.js" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..f686ed33 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5693 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.30.0(supports-color@7.2.0)(zod@3.25.76) + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 + '@types/marked': + specifier: ^5.0.2 + version: 5.0.2 + better-sqlite3: + specifier: ^12.9.0 + version: 12.11.1 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 + chromium-bidi: + specifier: ^15.0.0 + version: 15.0.0(devtools-protocol@0.0.1686980) + cli-highlight: + specifier: ^2.1.11 + version: 2.1.11 + clipboardy: + specifier: ^5.3.1 + version: 5.3.2 + cron-parser: + specifier: ^5.5.0 + version: 5.10.0 + diff: + specifier: ^9.0.0 + version: 9.0.0 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 + fast-xml-parser: + specifier: ^5.7.1 + version: 5.11.1 + fuzzysort: + specifier: ^3.1.0 + version: 3.1.0 + grammy: + specifier: ^1.42.0 + version: 1.46.0(supports-color@7.2.0) + html-to-text: + specifier: ^10.0.0 + version: 10.0.1 + ink: + specifier: ^7.0.1 + version: 7.1.1(@types/react@19.2.18)(react-devtools-core@6.1.5)(react@19.2.8) + jszip: + specifier: ^3.10.1 + version: 3.10.1 + linkedom: + specifier: ^0.18.12 + version: 0.18.13 + mammoth: + specifier: ^1.12.0 + version: 1.12.1 + marked: + specifier: ^18.0.2 + version: 18.0.11 + node-notifier: + specifier: ^10.0.1 + version: 10.0.1 + pdfjs-dist: + specifier: ^4.10.38 + version: 4.10.38 + playwright-core: + specifier: ^1.59.1 + version: 1.62.1 + posthog-node: + specifier: ^5.40.0 + version: 5.51.3 + react: + specifier: ^19.2.5 + version: 19.2.8 + react-devtools-core: + specifier: ^6.1.2 + version: 6.1.5 + tar-stream: + specifier: ^3.1.8 + version: 3.2.1 + turndown: + specifier: ^7.2.4 + version: 7.2.4 + word-extractor: + specifier: ^1.0.4 + version: 1.0.4 + yaml: + specifier: ^2.8.3 + version: 2.9.0 + devDependencies: + '@sentry/esbuild-plugin': + specifier: ^5.4.0 + version: 5.4.0(rollup@4.63.0)(supports-color@7.2.0) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/html-to-text': + specifier: ^9.0.4 + version: 9.0.4 + '@types/node': + specifier: ^24.12.2 + version: 24.13.3 + '@types/node-notifier': + specifier: ^8.0.5 + version: 8.0.5 + '@types/react': + specifier: ^19.2.14 + version: 19.2.18 + '@types/tar-stream': + specifier: ^3.1.4 + version: 3.1.4 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 + docx: + specifier: ^9.6.1 + version: 9.7.1 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + hyparquet: + specifier: ^1.26.0 + version: 1.29.2 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@19.2.18) + pdfkit: + specifier: ^0.18.0 + version: 0.18.0 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.5 + version: 2.1.9(@types/node@24.13.3)(supports-color@7.2.0) + +packages: + + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + + '@grammyjs/types@5.0.0': + resolution: {integrity: sha512-iq1Qrq1iPKkB8yAa0qSuIURMZOCuqTY5pWy5gHpCeL1oQ+GPadGhw/cDTVE8waJwuCzacUzuIjRv1sESvk7u7A==} + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mozilla/readability@0.6.0': + resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} + engines: {node: '>=14.0.0'} + + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + + '@posthog/core@1.49.0': + resolution: {integrity: sha512-+Ejf6sZ2wI9F37rOrPxoI90mydOw5O/YKjAgWMkkuuQRHGwyfXugxsD/+CVI4Yymg8LujHr0C/KXJvWDJYgwWQ==} + + '@posthog/types@1.407.0': + resolution: {integrity: sha512-7J/aFVi7JWFt/ekGVsMFvkTcADoE9MTNf1N9cpBSMUQ/SPLiBjbg386Fzk5OlgWG/u5OemBy4wlgD7YebqeppQ==} + + '@rollup/rollup-android-arm-eabi@4.63.0': + resolution: {integrity: sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.0': + resolution: {integrity: sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.0': + resolution: {integrity: sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.0': + resolution: {integrity: sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.0': + resolution: {integrity: sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.0': + resolution: {integrity: sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + resolution: {integrity: sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + resolution: {integrity: sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + resolution: {integrity: sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.0': + resolution: {integrity: sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + resolution: {integrity: sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.0': + resolution: {integrity: sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + resolution: {integrity: sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + resolution: {integrity: sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + resolution: {integrity: sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + resolution: {integrity: sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + resolution: {integrity: sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.0': + resolution: {integrity: sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.0': + resolution: {integrity: sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.0': + resolution: {integrity: sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.0': + resolution: {integrity: sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + resolution: {integrity: sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + resolution: {integrity: sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.0': + resolution: {integrity: sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.0': + resolution: {integrity: sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==} + cpu: [x64] + os: [win32] + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@selderee/plugin-htmlparser2@0.12.0': + resolution: {integrity: sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==} + peerDependencies: + selderee: ~0.12.0 + + '@sentry/bundler-plugins@10.71.0': + resolution: {integrity: sha512-cTgFyV4N8rFx+4/QMcJBotCCthYFBT/g6VoqG9tFz7CJJtoORIH3RiR+DTfpfBMe7IuVC40XP9MlD4P1LWwXCg==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=3.2.0' + webpack: '>=5.0.0' + peerDependenciesMeta: + rollup: + optional: true + webpack: + optional: true + + '@sentry/cli-darwin@2.58.6': + resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.6': + resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.6': + resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.6': + resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.6': + resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.6': + resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.6': + resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.6': + resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.6': + resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==} + engines: {node: '>= 10'} + hasBin: true + + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.71.0': + resolution: {integrity: sha512-OIjT7rzcWJjUC6r3eBT3Td1j0afDBMkbbx9jTocSD+ZSfc25eEU7hoIPS0WvfeIOTIN3y8bfQnXavwMReaNVHQ==} + engines: {node: '>=18'} + + '@sentry/esbuild-plugin@5.4.0': + resolution: {integrity: sha512-ACn/W1j1GUqVjfZvqtGfMRAhtgXAKKzJGBEgt4LrazQYoTh+rtrZy1JwfRKzGTsA3XgE+p5gXtFdU32Jc8fY7Q==} + engines: {node: '>= 18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/html-to-text@9.0.4': + resolution: {integrity: sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==} + + '@types/marked@5.0.2': + resolution: {integrity: sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==} + + '@types/node-notifier@8.0.5': + resolution: {integrity: sha512-LX7+8MtTsv6szumAp6WOy87nqMEdGhhry/Qfprjm1Ma6REjVzeF7SCyvPtp5RaF6IkXCS9V4ra8g5fwvf2ZAYg==} + + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.1: + resolution: {integrity: sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.2: + resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.19: + resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chromium-bidi@15.0.0: + resolution: {integrity: sha512-ESWZM1u85CoeSozBXXG9M73S5tH0EjkqnFJoQ6F3MHs2YGe0CLVMaRvhGxetLP6w4GVR59+/cpWvDLUpLvJXLQ==} + peerDependencies: + devtools-protocol: '*' + + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + clipboard-image@0.1.0: + resolution: {integrity: sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==} + engines: {node: '>=20'} + hasBin: true + + clipboardy@5.3.2: + resolution: {integrity: sha512-R35PENCHFCw6lsd5SjYPuAVV3Zawr74mKc7ogFNzoDPoQsmWDoJgUNNnWCk/czeqdZGZs8Y0M8zkOlVoySfHEQ==} + engines: {node: '>=20'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + cron-parser@5.10.0: + resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@4.0.0: + resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} + engines: {node: '>=12'} + + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devtools-protocol@0.0.1686980: + resolution: {integrity: sha512-NnnxGO1/cRffXL29DqGysbfgCcsYoIxprtmXgh+UDXFvhTwhRBewcRdGlEXvc3cmROi04JRmd2PqCURsijW25A==} + + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + + docx@9.7.1: + resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==} + engines: {node: '>=10'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.415: + resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.1: + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==} + hasBin: true + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammy@1.46.0: + resolution: {integrity: sha512-/8Qw+iisrUdOMk+p2mjEHouMm/BBdBEN1DHh16wiTpRUZkxDG3PxexdjCvR+wvK3LWPdrEvnQbdrwpU954sPhg==} + engines: {node: ^12.20.0 || >=14.13.1} + + growly@1.3.0: + resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hono@4.13.5: + resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + engines: {node: '>=16.9.0'} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-to-text@10.0.1: + resolution: {integrity: sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==} + engines: {node: '>=20.19.0'} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + hyparquet@1.29.2: + resolution: {integrity: sha512-2LxinZ8X0JqToST+9OXcy9t3t5Q7ZneMTkSqUx/36Hv18pCTk/N71IWZc7APTSNG1Me39l/jjEvzZSZ0R+Knbw==} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@7.1.1: + resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + + is-wayland@0.1.0: + resolution: {integrity: sha512-QkbMsWkIfkrzOPxenwye0h56iAXirZYHG9eHVPb22fO9y+wPbaX/CHacOWBa/I++4ohTcByimhM1/nyCsH8KNA==} + engines: {node: '>=20'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + is64bit@2.0.0: + resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} + engines: {node: '>=18'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + leac@0.7.0: + resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + macos-version@6.0.0: + resolution: {integrity: sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mammoth@1.12.1: + resolution: {integrity: sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==} + engines: {node: '>=12.0.0'} + hasBin: true + + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-abi@3.95.0: + resolution: {integrity: sha512-T9iGctuocf0qIWFFOTxPzjT5q0SILqaBYXt272tlBHvTKC5+3JnkMirLxNJNkXHtFyBjU2Jx+NL4Zipr0B/c6Q==} + engines: {node: '>=10'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-notifier@10.0.1: + resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==} + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + + parseley@0.13.1: + resolution: {integrity: sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + + pdfkit@0.18.0: + resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==} + + peberminta@0.10.0: + resolution: {integrity: sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + png-js@1.1.0: + resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + posthog-node@5.51.3: + resolution: {integrity: sha512-11bPNCIAec9cjVdaX6vllYTtGviIX6k/qdF3IXG5dbbmXalym91nmkFy9npalSv8DQi8jcbEkhVz3AD7qrhaww==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + + powershell-utils@0.2.0: + resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} + engines: {node: '>=20'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.63.0: + resolution: {integrity: sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-jxa@3.0.0: + resolution: {integrity: sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + selderee@0.12.0: + resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + + shellwords@0.1.1: + resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + + subsume@4.0.0: + resolution: {integrity: sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + system-architecture@0.1.0: + resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} + engines: {node: '>=18'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.1: + resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + + type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unique-string@3.0.0: + resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} + engines: {node: '>=12'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + word-extractor@1.0.4: + resolution: {integrity: sha512-PyAGZQ2gjnVA5kcZAOAxoYciCMaAvu0dbVlw/zxHphhy+3be8cDeYKHJPO8iedIM3Sx0arA/ugKTJyXhZNgo6g==} + + wrap-ansi@10.0.1: + resolution: {integrity: sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==} + engines: {node: '>=20'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@grammyjs/types@5.0.0': {} + + '@hono/node-server@2.1.1(hono@4.13.5)': + dependencies: + hono: 4.13.5 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mixmark-io/domino@2.2.0': {} + + '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@3.25.76)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.5) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) + hono: 4.13.5 + jose: 6.2.10 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mozilla/readability@0.6.0': {} + + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@noble/ciphers@1.3.0': {} + + '@noble/hashes@1.8.0': {} + + '@nodable/entities@3.0.0': {} + + '@posthog/core@1.49.0': + dependencies: + '@posthog/types': 1.407.0 + + '@posthog/types@1.407.0': {} + + '@rollup/rollup-android-arm-eabi@4.63.0': + optional: true + + '@rollup/rollup-android-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-x64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.0': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@selderee/plugin-htmlparser2@0.12.0(selderee@0.12.0)': + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + selderee: 0.12.0 + + '@sentry/bundler-plugins@10.71.0(rollup@4.63.0)(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@sentry/cli': 2.58.6(supports-color@7.2.0) + '@sentry/core': 10.71.0 + dotenv: 17.4.2 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.63.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.6': + optional: true + + '@sentry/cli-linux-arm64@2.58.6': + optional: true + + '@sentry/cli-linux-arm@2.58.6': + optional: true + + '@sentry/cli-linux-i686@2.58.6': + optional: true + + '@sentry/cli-linux-x64@2.58.6': + optional: true + + '@sentry/cli-win32-arm64@2.58.6': + optional: true + + '@sentry/cli-win32-i686@2.58.6': + optional: true + + '@sentry/cli-win32-x64@2.58.6': + optional: true + + '@sentry/cli@2.58.6(supports-color@7.2.0)': + dependencies: + https-proxy-agent: 5.0.1(supports-color@7.2.0) + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.6 + '@sentry/cli-linux-arm': 2.58.6 + '@sentry/cli-linux-arm64': 2.58.6 + '@sentry/cli-linux-i686': 2.58.6 + '@sentry/cli-linux-x64': 2.58.6 + '@sentry/cli-win32-arm64': 2.58.6 + '@sentry/cli-win32-i686': 2.58.6 + '@sentry/cli-win32-x64': 2.58.6 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.71.0': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/esbuild-plugin@5.4.0(rollup@4.63.0)(supports-color@7.2.0)': + dependencies: + '@sentry/bundler-plugins': 10.71.0(rollup@4.63.0)(supports-color@7.2.0) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + - webpack + + '@sindresorhus/merge-streams@4.0.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.13.3 + + '@types/estree@1.0.9': {} + + '@types/html-to-text@9.0.4': {} + + '@types/marked@5.0.2': {} + + '@types/node-notifier@8.0.5': + dependencies: + '@types/node': 24.13.3 + + '@types/node@14.18.63': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 24.13.3 + + '@types/turndown@5.0.6': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.13.3))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.13.3) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@xmldom/xmldom@0.8.15': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + agent-base@6.0.2(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anynum@1.0.1: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + assertion-error@2.0.1: {} + + async@3.2.6: {} + + auto-bind@5.0.1: {} + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + bare-events@2.9.2: {} + + bare-fs@4.8.1: + dependencies: + bare-events: 2.9.2 + bare-path: 3.1.1 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.8.1 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.2: + dependencies: + bare-path: 3.1.1 + + base64-js@0.0.8: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.19: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + big-integer@1.6.52: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.4.7: {} + + body-parser@2.3.0(supports-color@7.2.0): + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3(supports-color@7.2.0) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + boolbase@2.0.0: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.19 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.415 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) + + buffer-crc32@0.2.13: {} + + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001810: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + check-error@2.1.3: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + chownr@1.1.4: {} + + chromium-bidi@15.0.0(devtools-protocol@0.0.1686980): + dependencies: + devtools-protocol: 0.0.1686980 + mitt: 3.0.1 + zod: 3.25.76 + + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.2 + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.2 + + clipboard-image@0.1.0: + dependencies: + run-jxa: 3.0.0 + + clipboardy@5.3.2: + dependencies: + clipboard-image: 0.1.0 + execa: 9.6.1 + is-wayland: 0.1.0 + is-wsl: 3.1.1 + is64bit: 2.0.0 + powershell-utils: 0.2.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@2.1.2: {} + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + concat-map@0.0.1: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + convert-to-spaces@2.0.1: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + cron-parser@5.10.0: + dependencies: + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@4.0.0: + dependencies: + type-fest: 1.4.0 + + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + + css-what@8.0.0: {} + + cssom@0.5.0: {} + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + deepmerge-ts@8.0.2: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + devtools-protocol@0.0.1686980: {} + + dfa@1.2.0: {} + + diff@9.0.0: {} + + dingbat-to-unicode@1.0.1: {} + + docx@9.7.1: + dependencies: + '@types/node': 25.9.5 + hash.js: 1.1.7 + jszip: 3.10.1 + nanoid: 5.1.16 + xml: 1.0.1 + xml-js: 1.6.11 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + dotenv@17.4.2: {} + + duck@0.1.12: + dependencies: + underscore: 1.13.8 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.415: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + entities@7.0.1: {} + + entities@8.0.0: {} + + environment@1.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-toolkit@1.51.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + 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 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@5.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.23 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + expand-template@2.0.3: {} + + expect-type@1.4.0: {} + + express-rate-limit@8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + express: 5.2.1(supports-color@7.2.0) + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1(supports-color@7.2.0): + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0(supports-color@7.2.0) + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@7.2.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1(supports-color@7.2.0) + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-uri@3.1.6: {} + + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-uri-to-path@1.0.0: {} + + finalhandler@2.1.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.23 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + 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.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + github-from-package@0.0.0: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grammy@1.46.0(supports-color@7.2.0): + dependencies: + '@grammyjs/types': 5.0.0 + abort-controller: 3.0.0 + debug: 4.4.3(supports-color@7.2.0) + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + + growly@1.3.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + highlight.js@10.7.3: {} + + hono@4.13.5: {} + + html-escaper@3.0.3: {} + + html-to-text@10.0.1: + dependencies: + '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) + deepmerge-ts: 8.0.2 + dom-serializer: 2.0.0 + htmlparser2: 10.1.0 + selderee: 0.12.0 + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1(supports-color@7.2.0): + dependencies: + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + hyparquet@1.29.2: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + indent-string@5.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ink-testing-library@4.0.0(@types/react@19.2.18): + optionalDependencies: + '@types/react': 19.2.18 + + ink@7.1.1(@types/react@19.2.18)(react-devtools-core@6.1.5)(react@19.2.8): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.51.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.8 + react-reconciler: 0.33.0(react@19.2.8) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.2 + terminal-size: 4.0.1 + type-fest: 5.8.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.1 + ws: 8.21.3 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.18 + react-devtools-core: 6.1.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ip-address@10.5.0: {} + + ipaddr.js@1.9.1: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-in-ci@2.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + is-unsafe@2.0.2: {} + + is-wayland@0.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + is64bit@2.0.0: + dependencies: + system-architecture: 0.1.0 + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.10: {} + + js-md5@0.8.3: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json5@2.2.3: {} + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + leac@0.7.0: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + + listenercount@1.0.1: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.8 + + loupe@3.2.1: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + macos-version@6.0.0: + dependencies: + semver: 7.8.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mammoth@1.12.1: + dependencies: + '@xmldom/xmldom': 0.8.15 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.8 + xmlbuilder: 10.1.1 + + marked@18.0.11: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-response@3.1.0: {} + + minimalistic-assert@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + nanoid@5.1.16: {} + + napi-build-utils@2.0.0: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + node-abi@3.95.0: + dependencies: + semver: 7.8.5 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-notifier@10.0.1: + dependencies: + growly: 1.3.0 + is-wsl: 2.2.0 + semver: 7.8.5 + shellwords: 0.1.1 + uuid: 8.3.2 + which: 2.0.2 + + node-releases@2.0.54: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + option@0.2.4: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + pako@0.2.9: {} + + pako@1.0.11: {} + + parse-ms@4.0.0: {} + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + + parseley@0.13.1: + dependencies: + leac: 0.7.0 + peberminta: 0.10.0 + + parseurl@1.3.3: {} + + patch-console@2.0.0: {} + + path-exists@4.0.0: {} + + path-expression-matcher@1.6.2: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + + pdfkit@0.18.0: + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + fontkit: 2.0.4 + js-md5: 0.8.3 + linebreak: 1.1.0 + png-js: 1.1.0 + + peberminta@0.10.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + pkce-challenge@5.0.1: {} + + playwright-core@1.62.1: {} + + png-js@1.1.0: + dependencies: + browserify-zlib: 0.2.0 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + posthog-node@5.51.3: + dependencies: + '@posthog/core': 1.49.0 + + powershell-utils@0.2.0: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.95.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.10.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-reconciler@0.33.0(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + readdirp@5.1.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restructure@3.0.2: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rollup@4.63.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.0 + '@rollup/rollup-android-arm64': 4.63.0 + '@rollup/rollup-darwin-arm64': 4.63.0 + '@rollup/rollup-darwin-x64': 4.63.0 + '@rollup/rollup-freebsd-arm64': 4.63.0 + '@rollup/rollup-freebsd-x64': 4.63.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.0 + '@rollup/rollup-linux-arm-musleabihf': 4.63.0 + '@rollup/rollup-linux-arm64-gnu': 4.63.0 + '@rollup/rollup-linux-arm64-musl': 4.63.0 + '@rollup/rollup-linux-loong64-gnu': 4.63.0 + '@rollup/rollup-linux-loong64-musl': 4.63.0 + '@rollup/rollup-linux-ppc64-gnu': 4.63.0 + '@rollup/rollup-linux-ppc64-musl': 4.63.0 + '@rollup/rollup-linux-riscv64-gnu': 4.63.0 + '@rollup/rollup-linux-riscv64-musl': 4.63.0 + '@rollup/rollup-linux-s390x-gnu': 4.63.0 + '@rollup/rollup-linux-x64-gnu': 4.63.0 + '@rollup/rollup-linux-x64-musl': 4.63.0 + '@rollup/rollup-openbsd-x64': 4.63.0 + '@rollup/rollup-openharmony-arm64': 4.63.0 + '@rollup/rollup-win32-arm64-msvc': 4.63.0 + '@rollup/rollup-win32-ia32-msvc': 4.63.0 + '@rollup/rollup-win32-x64-gnu': 4.63.0 + '@rollup/rollup-win32-x64-msvc': 4.63.0 + fsevents: 2.3.3 + + router@2.2.0(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-jxa@3.0.0: + dependencies: + execa: 5.1.1 + macos-version: 6.0.0 + subsume: 4.0.0 + type-fest: 2.19.0 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sax@1.6.1: {} + + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + selderee@0.12.0: + dependencies: + parseley: 0.13.1 + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1(supports-color@7.2.0): + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.10.0: {} + + shellwords@0.1.1: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + source-map-js@1.2.1: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@2.0.1: {} + + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + + subsume@4.0.0: + dependencies: + escape-string-regexp: 5.0.0 + unique-string: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + system-architecture@0.1.0: {} + + tagged-tag@1.0.0: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.1: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + terminal-size@4.0.1: {} + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-inflate@1.0.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tmp@0.2.7: {} + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + traverse@0.3.9: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + + type-fest@1.4.0: {} + + type-fest@2.19.0: {} + + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + uhyphen@0.2.0: {} + + underscore@1.13.8: {} + + undici-types@7.18.2: {} + + undici-types@7.24.6: {} + + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + + unicorn-magic@0.3.0: {} + + unique-string@3.0.0: + dependencies: + crypto-random-string: 4.0.0 + + unpipe@1.0.0: {} + + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + update-browserslist-db@1.3.2(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + vary@1.1.2: {} + + vite-node@2.1.9(@types/node@24.13.3)(supports-color@7.2.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@7.2.0) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.13.3) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@24.13.3): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.63.0 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@24.13.3)(supports-color@7.2.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.13.3)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@7.2.0) + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.13.3) + vite-node: 2.1.9(@types/node@24.13.3)(supports-color@7.2.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@6.0.0: + dependencies: + string-width: 8.2.2 + + word-extractor@1.0.4: + dependencies: + saxes: 5.0.1 + yauzl: 2.10.0 + + wrap-ansi@10.0.1: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@7.5.13: {} + + ws@8.21.3: {} + + xml-js@1.6.11: + dependencies: + sax: 1.6.1 + + xml-naming@0.3.0: {} + + xml@1.0.1: {} + + xmlbuilder@10.1.1: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + yargs-parser@20.2.9: {} + + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + yoctocolors@2.2.0: {} + + yoga-layout@3.2.1: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..2c852380 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + '@sentry/cli': true + better-sqlite3: true + esbuild: true diff --git a/scripts/bundle-sea.ts b/scripts/bundle-sea.ts index 089bde91..b2aad6a0 100644 --- a/scripts/bundle-sea.ts +++ b/scripts/bundle-sea.ts @@ -82,6 +82,26 @@ async function main(): Promise { // (see src/version.ts) resolves without a shipped package.json. define: { __ATOMIC_AGENT_VERSION__: JSON.stringify(version), + // React ships as two builds behind a runtime + // `process.env.NODE_ENV === "production" ? prod : dev` switch. A + // SEA has no build-time env, so without this define BOTH builds + // land in the bundle and the *development* reconciler is what + // actually runs on any machine whose shell does not export + // NODE_ENV — which is every machine. + // + // That is not merely slow. React 19's development build carries + // the Component Performance Track, which calls + // `performance.measure()` for every component render; Node keeps + // every user-timing entry alive for the life of the process. The + // TUI redraws on a timer even while idle (~114 measures/s), so an + // open session grew the heap without bound and aborted with + // `FATAL ERROR: JavaScript heap out of memory` after six to seven + // hours — twice, on the same laptop, before anyone connected the + // crash to a missing define. + // + // Inlining the constant also lets esbuild drop the dev build as + // dead code, so the binary gets smaller as a side effect. + "process.env.NODE_ENV": JSON.stringify("production"), }, loader: { ".node": "file" }, plugins: sentryAuthToken diff --git a/scripts/generate-logo-art.mjs b/scripts/generate-logo-art.mjs new file mode 100644 index 00000000..066f93ba --- /dev/null +++ b/scripts/generate-logo-art.mjs @@ -0,0 +1,476 @@ +/** + * Regenerates `src/tui/components/logo-art.ts` from `assets/logo.svg`. + * + * node scripts/generate-logo-art.mjs [--check] + * + * `--check` re-derives the art and exits non-zero if the checked-in file + * has drifted, which is what `logo-art.generated.test.ts` runs. + * + * The mark is rasterised from the actual bezier path — flattened to a + * polygon, then point-in-polygon with a supersampled area average — so + * the drawing can never drift from the source asset the way a hand copy + * does. Everything below is geometry; see the header of the generated + * file for the design rules it encodes. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SVG = join(ROOT, "assets", "logo.svg"); +const OUT = join(ROOT, "src", "tui", "components", "logo-art.ts"); + +/** Terminal cell height ÷ width. Real fonts run 2.05–2.4. */ +const ASPECT = 2.2; +/** Supersample factor per axis when measuring cell coverage. */ +const SS = 4; +/** The arms occupy the middle quarter of the bounding box. */ +const LO = 0.375; +const HI = 0.625; + +// ---------------------------------------------------------------- path + +function flatten(d, steps = 64) { + const toks = d.match(/[MCLZmclz]|-?\d*\.?\d+/g) ?? []; + const pts = []; + let i = 0; + let cur = [0, 0]; + let start = [0, 0]; + const num = () => Number(toks[i++]); + while (i < toks.length) { + const c = toks[i++]; + if (c === "M") { + cur = [num(), num()]; + start = cur; + pts.push(cur); + } else if (c === "L") { + cur = [num(), num()]; + pts.push(cur); + } else if (c === "C") { + const p1 = [num(), num()]; + const p2 = [num(), num()]; + const p3 = [num(), num()]; + for (let k = 1; k <= steps; k += 1) { + const t = k / steps; + const u = 1 - t; + pts.push([ + u * u * u * cur[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0], + u * u * u * cur[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1], + ]); + } + cur = p3; + } else if (c === "Z" || c === "z") { + pts.push(start); + } + } + return pts; +} + +const svg = readFileSync(SVG, "utf8"); +const pathData = / in ${SVG}`); +const PTS = flatten(pathData); +const X0 = Math.min(...PTS.map((p) => p[0])); +const X1 = Math.max(...PTS.map((p) => p[0])); +const Y0 = Math.min(...PTS.map((p) => p[1])); +const Y1 = Math.max(...PTS.map((p) => p[1])); +const BW = X1 - X0; +const BH = Y1 - Y0; + +/** Point-in-polygon over the flattened outline. `ux`/`uy` in [0,1], y down. */ +function inside(ux, uy) { + const x = X0 + ux * BW; + const y = Y0 + uy * BH; + let hit = false; + for (let k = 0, j = PTS.length - 1; k < PTS.length; j = k, k += 1) { + const [xi, yi] = PTS[k]; + const [xj, yj] = PTS[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; + } + return hit; +} + +// ------------------------------------------------------------- hinting +// The arm edges (0.375 / 0.625) land mid-pixel at small sizes and the +// arms come out ragged, so warp the sampling coordinate piecewise- +// linearly and pin both edges to exact pixel boundaries — font hinting. + +function band(n, thick) { + const a = Math.floor((n - thick) / 2); + return [a, a + thick]; +} + +function warp(t, a, b, n) { + const loT = a / n; + const hiT = b / n; + if (t <= loT) return loT > 0 ? (t * LO) / loT : LO; + if (t >= hiT) return hiT < 1 ? HI + ((t - hiT) * (1 - HI)) / (1 - hiT) : HI; + return LO + ((t - loT) * (HI - LO)) / (hiT - loT); +} + +function coverage(px, py, W, H, bands) { + const [ax, bx, ay, by] = bands; + let hits = 0; + for (let i = 0; i < SS; i += 1) { + for (let j = 0; j < SS; j += 1) { + if ( + inside( + warp((px + (i + 0.5) / SS) / W, ax, bx, W), + warp((py + (j + 0.5) / SS) / H, ay, by, H), + ) + ) { + hits += 1; + } + } + } + return hits / (SS * SS); +} + +// --------------------------------------------------------------- grids +// The box is 4× the arm, and the arm sits centred — so the leftover +// padding is 3×arm, which is ODD when the arm is odd and lands the mark +// off-centre by a column. Widen the box by one in that case. + +/** One cell = one pixel. A cell is `aspect` times taller than it is wide. */ +function fullGrid(cols, aspect = ASPECT) { + const ah = Math.max(1, Math.round(Math.round(cols / 4) / aspect)); + const av = Math.max(1, Math.round(ah * aspect)); + const W = 4 * av + (av % 2); + const H = 4 * ah + (ah % 2); + const bands = [...band(W, av), ...band(H, ah)]; + const g = []; + for (let r = 0; r < H; r += 1) { + const row = []; + for (let c = 0; c < W; c += 1) row.push(coverage(c, r, W, H, bands) >= 0.5); + g.push(row); + } + return { g, W, H, av, ah }; +} + +// ---------------------------------------------------------------- 3-D +// Depth sweeps bottom-right at a true 45° ON SCREEN. A cell is `aspect` +// times taller than wide, so that is ~2.2 columns per row — stepping one +// column per row would lean at ~65° and read as a shear. Offsets are +// enumerated by column so every intermediate column is covered and the +// side walls come out solid rather than dashed. + +function sweep(face, dcols, aspect) { + const out = new Set(); + for (const dc of dcols) { + const dr = Math.round(dc / aspect); + for (const key of face) { + const [r, c] = key.split(",").map(Number); + out.add(`${r + dr},${c + dc}`); + } + } + return out; +} + +function paint(layers) { + const all = new Set(); + for (const [set] of layers) for (const k of set) all.add(k); + const rs = [...all].map((k) => Number(k.split(",")[0])); + const cs = [...all].map((k) => Number(k.split(",")[1])); + const r0 = Math.min(...rs); + const r1 = Math.max(...rs); + const c0 = Math.min(...cs); + const c1 = Math.max(...cs); + const rows = []; + for (let r = r0; r <= r1; r += 1) { + let line = ""; + for (let c = c0; c <= c1; c += 1) { + let ch = " "; + for (const [set, glyph] of layers) if (set.has(`${r},${c}`)) ch = glyph; + line += ch; + } + rows.push(line.replace(/\s+$/, "")); + } + return rows; +} + +function faceSet(g, W, H) { + const s = new Set(); + for (let r = 0; r < H; r += 1) { + for (let c = 0; c < W; c += 1) if (g[r][c]) s.add(`${r},${c}`); + } + return s; +} + +const STROKES = { + block: { face: "█", wall: "▓", shade: "░" }, + ascii: { face: "#", wall: "+", shade: "." }, +}; + +/** LG: face + extruded walls + a contact shadow. */ +function renderBoth(cols, ch, aspect = ASPECT) { + const { g, W, H, av } = fullGrid(cols, aspect); + const face = faceSet(g, W, H); + const dcol = Math.max(2, Math.round(av / 3)); + const gap = Math.max(1, Math.round(dcol * 0.6)); + const body = sweep(face, range(1, dcol), aspect); + const shade = sweep(face, [dcol + gap], aspect); + for (const k of face) { + body.delete(k); + shade.delete(k); + } + for (const k of body) shade.delete(k); + return paint([[shade, ch.shade], [body, ch.wall], [face, ch.face]]); +} + +/** MD: face + extruded walls, no contact shadow. */ +function renderExtrude(cols, ch, wallGlyph, aspect = ASPECT) { + const { g, W, H, av } = fullGrid(cols, aspect); + const face = faceSet(g, W, H); + const dcol = Math.max(2, Math.round(av / 3)); + const body = sweep(face, range(1, dcol), aspect); + for (const k of face) body.delete(k); + return paint([[body, wallGlyph], [face, ch.face]]); +} + +/** + * SM: three rows, one-cell arms, a sub-cell fillet in each concave + * corner and a one-column right bevel. + * + * ▗█░ + * █████░ + * █▘░ + * + * **This one is constructed, not rasterised.** Every other size samples + * the bezier path; this one cannot. The arm is a quarter of the box, so + * a one-column arm implies a five-row box at a 2.2:1 cell — there is no + * sampling of the path that yields three rows with arms still on it. + * `fullGrid(5)` rounds straight back up to a two-column arm. So the + * geometry is written out here instead: arm 1 cell, bar 4×arm + 1 for + * centring, which is the same proportion the other sizes obey, quantised + * to the smallest grid that can still carry it. + * + * It is a *sign* at this size rather than a reproduction, and that is + * the point: it sits inline beside text — the rail lockup, the setup + * headers — where five rows of logo out-shout the words next to them. + * + * **The fillets.** The concave diagonal (top-left, bottom-right) is what + * distinguishes this mark from a plain cross, and at one cell per arm + * there is no room to draw it in whole cells. A quadrant block puts the + * ink in the corner it belongs to at half the size — the only sub-cell + * tool a terminal offers. The hard 90° corners (top-right, bottom-left) + * stay empty; filleting all four would make the mark 4-fold symmetric, + * which is a different logo. + * + * ASCII has no quadrant glyphs, so that stroke keeps plain cells. The + * charset is pinned by `logo-art.generated.test.ts`. + */ +function renderSmall(ch, stroke) { + const arm = 1; + const width = 4 * arm + 1; + const armCol = Math.floor(width / 2); + const face = new Set([`0,${armCol}`, `2,${armCol}`]); + for (let c = 0; c < width; c += 1) face.add(`1,${c}`); + // Top-left and bottom-right only — the 180°-symmetric pair. + const fillets = new Map(); + if (stroke === "block") { + fillets.set(`0,${armCol - 1}`, "▗"); + fillets.set(`2,${armCol + 1}`, "▘"); + } + const ink = new Set([...face, ...fillets.keys()]); + const shade = new Set(); + for (const k of ink) { + const [r, c] = k.split(",").map(Number); + const key = `${r},${c + 1}`; + if (!ink.has(key)) shade.add(key); + } + const layers = [[shade, ch.shade], [face, ch.face]]; + for (const [key, glyph] of fillets) layers.push([new Set([key]), glyph]); + return paint(layers); +} + +/** + * XS: two rows, a half-cell cross for terminals where even the SM sign + * is too tall — the minimal onboarding tier, a splash pane a few rows + * high. + * + * ▗█▄░ + * ▀█▘░ + * + * Constructed like SM — there is no grid this small the sampler can + * land on. The horizontal bar is drawn in half-cells so it can sit + * *between* the two rows: `▄` (bottom half) and `▀` (top half) fuse + * across the row seam into a three-cell bar vertically centred on the + * full-cell arm running through the middle column. + * + * The identity survives in the bar's corners: `▗` pulls the top-left + * tip in and `▘` the bottom-right — the same concave pair SM fillets — + * so the sign stays 180°-symmetric rather than 4-fold, which is the + * one property separating this mark from a generic plus. + * + * ASCII has no sub-cell glyphs (charset pinned by the generated test), + * so that stroke degrades to a one-cell stub over a bar: the same thin + * plus its SM already draws, one row shorter. + */ +function renderTiny(ch, stroke) { + const width = 3; + const armCol = 1; + const face = new Set(); + const partials = new Map(); + if (stroke === "block") { + face.add(`0,${armCol}`).add(`1,${armCol}`); + partials.set(`0,${armCol - 1}`, "▗"); + partials.set(`0,${armCol + 1}`, "▄"); + partials.set(`1,${armCol - 1}`, "▀"); + partials.set(`1,${armCol + 1}`, "▘"); + } else { + face.add(`0,${armCol}`); + for (let c = 0; c < width; c += 1) face.add(`1,${c}`); + } + const ink = new Set([...face, ...partials.keys()]); + const shade = new Set(); + for (const k of ink) { + const [r, c] = k.split(",").map(Number); + const key = `${r},${c + 1}`; + if (!ink.has(key)) shade.add(key); + } + const layers = [[shade, ch.shade], [face, ch.face]]; + for (const [key, glyph] of partials) layers.push([new Set([key]), glyph]); + return paint(layers); +} + +/** Kept for reference: the five-row bevelled mark the SM size replaced. */ +function renderBevel(cols, ch, aspect = ASPECT) { + const { g, W, H } = fullGrid(cols, aspect); + const face = faceSet(g, W, H); + const shade = new Set(); + for (const k of face) { + const [r, c] = k.split(",").map(Number); + const key = `${r},${c + 1}`; + if (!face.has(key)) shade.add(key); + } + return paint([[shade, ch.shade], [face, ch.face]]); +} + +function range(a, b) { + const out = []; + for (let i = a; i <= b; i += 1) out.push(i); + return out; +} + +// -------------------------------------------------------------- emit + +// `sm` and `xs` take no nominal width: they are constructed, not sampled. +const SCALES = { lg: 45, md: 29 }; + +function art(scale, stroke) { + const ch = STROKES[stroke]; + if (scale === "lg") return renderBoth(SCALES.lg, ch); + if (scale === "sm") return renderSmall(ch, stroke); + if (scale === "xs") return renderTiny(ch, stroke); + // MD/block draws its walls in the light `░` so it matches the rail + // mark's tone; the ASCII ramp is already low-contrast and would lose + // the depth entirely if it dropped to `.`. + return renderExtrude(SCALES.md, ch, stroke === "block" ? ch.shade : ch.wall); +} + +function lit(rows, indent) { + const pad = " ".repeat(indent); + return rows.map((r) => `${pad}${JSON.stringify(r)},`).join("\n"); +} + +function block(stroke) { + return ["lg", "md", "sm", "xs"] + .map((scale) => { + const rows = art(scale, stroke); + const w = Math.max(...rows.map((r) => r.length)); + return ` // ${w} x ${rows.length}\n ${scale}: [\n${lit(rows, 4)}\n ],`; + }) + .join("\n"); +} + +const out = `/** + * Brand-mark artwork: the Atomic cross at four scales, in two stroke + * systems, plus a dedicated rail mark. + * + * GENERATED FROM \`assets/logo.svg\` by \`scripts/generate-logo-art.mjs\`. + * Do not hand-edit — redraw the SVG and regenerate. + * \`logo-art.generated.test.ts\` fails if this file drifts from the source. + * + * **Why separate drawings instead of one scaled at runtime.** These + * marks carry depth in up to three tones — face, extruded wall, cast + * shadow. The rasteriser this replaced scaled one drawing by first + * flattening it to a boolean ink mask, in which every non-space glyph + * counts as ink; run these through it and \`#\`, \`+\` and \`.\` collapse + * into one solid blob with the depth gone. Tone has to be re-decided per + * size, not resampled. + * + * The ladder is quantized rather than continuous anyway: the arm is + * exactly a quarter of the bounding box and must be a whole number of + * cells, so the usable sizes are fixed points with nothing to + * interpolate between. + * + * Geometry rules the artwork obeys, should the SVG ever be redrawn: + * + * - The concave fillet is in the **top-left** and **bottom-right** + * quadrants only. Top-right and bottom-left are straight segments + * meeting at a hard 90°. The mark is 180°-symmetric, not 4-fold, so + * mirroring or v-flipping it yields a *different* logo. + * - The fillets leave each arm edge tangentially: the arms stay + * parallel-sided near the tips and flare only toward the centre. + * - Depth sweeps bottom-right (observer there, light from the top-left) + * at a true 45° *on screen* — which at a ~2.2:1 cell aspect means + * ~2.2 columns per row, not one. + */ + +/** Which drawing to use. A bigger scale is not a scaled-up smaller one. */ +export type MarkScale = "lg" | "md" | "sm" | "xs"; + +/** + * Glyph system. \`block\` uses Unicode block elements; \`ascii\` stays in + * plain ASCII so it survives \`TERM=dumb\`, CI log scrapes and non-UTF-8 + * locales. + */ +export type MarkStroke = "block" | "ascii"; + +export type MarkArt = Readonly>; + +/** + * Glyphs that draw a mark's front plane, sub-cell face ink included — + * SM's fillets, XS's half-cell bar. Everything else in the art is + * depth (extruded wall, cast shadow) or blank. Exported from here so + * every renderer colours the same glyphs as face instead of keeping a + * private copy that drifts when the art gains a glyph. + */ +export const FACE_GLYPHS: ReadonlySet = new Set([ + "#", + "\\u2588", // █ full block + "\\u2597", // ▗ SM/XS concave fillet, top-left + "\\u2598", // ▘ SM/XS concave fillet, bottom-right + "\\u2584", // ▄ lower half block — XS bar, top row + "\\u2580", // ▀ upper half block — XS bar, bottom row +]); + +/** \`█\` face, \`▓\` wall, \`░\` shadow. */ +const BLOCK: MarkArt = { +${block("block")} +}; + +/** \`#\` face, \`+\` wall, \`.\` shadow. */ +const ASCII: MarkArt = { +${block("ascii")} +}; + +export const CROSS_MARKS: Readonly> = { + block: BLOCK, + ascii: ASCII, +}; +`; + +if (process.argv.includes("--check")) { + const current = readFileSync(OUT, "utf8"); + if (current !== out) { + console.error( + `${OUT} is stale.\nRun: node scripts/generate-logo-art.mjs`, + ); + process.exit(1); + } + console.log("logo-art.ts is in sync with assets/logo.svg"); +} else { + writeFileSync(OUT, out); + console.log(`wrote ${OUT}`); +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index db417b83..24d4c077 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -50,6 +50,110 @@ function Write-Info($msg) { Write-Host $msg } # turns the exception into a single readable line and exits 1. function Fail($msg) { throw $msg } +# SHA256 of a file, as a lowercase hex string. +# +# Get-FileHash is NOT assumed: it ships in the Microsoft.PowerShell.Utility +# module from PowerShell 4.0 on, so it is missing under a 2.0 engine +# (`-Version 2`) and absent whenever a trimmed image or an overridden +# PSModulePath keeps that module from loading. Users hit exactly that during +# in-app self-update and the install aborted with "'Get-FileHash' is not +# recognized" (issue #174). +# +# So hash through .NET, which needs no module and exists wherever PowerShell +# runs at all, and keep Get-FileHash / certutil only as fallbacks. Verifying +# the download is not optional — a checksum that cannot be computed is a +# failure, never a skip. +function Get-Sha256($path) { + $full = (Resolve-Path -LiteralPath $path).ProviderPath + + try { + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $stream = [System.IO.File]::OpenRead($full) + try { + return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace("-", "").ToLower() + } finally { + $stream.Dispose() + } + } finally { + $sha.Dispose() + } + } catch { + # Fall through to the external implementations below. + } + + if (Get-Command Get-FileHash -ErrorAction SilentlyContinue) { + # Guard the result: Get-FileHash can return nothing (a directory, an + # unreadable path) and a bare .Hash on $null throws an error that says + # nothing about hashing. Fall through instead. + $result = Get-FileHash -LiteralPath $full -Algorithm SHA256 -ErrorAction SilentlyContinue + if ($result -and $result.Hash) { return $result.Hash.ToLower() } + } + + # certutil is present on every supported Windows version. Its output is a + # banner, the hex digest (spaced on older builds), then a status line. + try { + $out = & certutil.exe -hashfile $full SHA256 2>$null + if ($LASTEXITCODE -eq 0 -and $out) { + $digest = ($out | Where-Object { $_ -match '^[0-9a-fA-F ]+$' } | + ForEach-Object { $_ -replace '\s', '' } | + Where-Object { $_.Length -eq 64 } | Select-Object -First 1) + if ($digest) { return $digest.ToLower() } + } + } catch { + # Not on PATH, or refused the file — report it as a hashing failure below + # rather than leaking "certutil.exe is not recognized" to the user. + } + + Fail "cannot compute SHA256: no usable hash implementation (.NET, Get-FileHash and certutil all failed)" +} + +# Extract a zip into an existing directory. +# +# Expand-Archive has the same availability problem as Get-FileHash — it lives +# in Microsoft.PowerShell.Archive and only from PowerShell 5.0 — so an install +# that got past the checksum would still fail here on the same machines. Use +# .NET first for the same reason. +function Expand-Zip($zipPath, $destination) { + $zipFull = (Resolve-Path -LiteralPath $zipPath).ProviderPath + $destFull = (Resolve-Path -LiteralPath $destination).ProviderPath + + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + + # Walk the entries instead of calling ExtractToDirectory: on .NET + # Framework (Windows PowerShell 5.1) that helper throws when the + # destination already exists, and install.ps1 always creates the staging + # dir first. Entry-by-entry also lets a re-run overwrite cleanly. + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipFull) + try { + foreach ($entry in $zip.Entries) { + $target = Join-Path $destFull $entry.FullName + # Directory entries have an empty Name; create and move on. + if (-not $entry.Name) { + New-Item -ItemType Directory -Path $target -Force | Out-Null + continue + } + $parent = Split-Path -Parent $target + if ($parent) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) + } + } finally { + $zip.Dispose() + } + return + } catch { + # Fall through to Expand-Archive below. + } + + if (Get-Command Expand-Archive -ErrorAction SilentlyContinue) { + Expand-Archive -LiteralPath $zipFull -DestinationPath $destFull -Force + return + } + + Fail "cannot extract $(Split-Path -Leaf $zipFull): no usable zip implementation (.NET and Expand-Archive both failed)" +} + # Invoke-WebRequest's exception names the status code but not the URL, so a bare # "404 (Not Found)" during self-update does not say whether the tag, the repo or # the asset name was wrong. Attribute it. @@ -134,9 +238,7 @@ function Test-SameFile($left, $right) { if ((Get-Item -LiteralPath $left).Length -ne (Get-Item -LiteralPath $right).Length) { return $false } - $leftHash = (Get-FileHash -LiteralPath $left -Algorithm SHA256).Hash - $rightHash = (Get-FileHash -LiteralPath $right -Algorithm SHA256).Hash - return $leftHash -eq $rightHash + return (Get-Sha256 $left) -eq (Get-Sha256 $right) } # Apply a staged tree onto the install dir as a single all-or-nothing @@ -237,7 +339,7 @@ try { if (-not $expected) { Fail "could not read expected checksum from $ArchiveName.sha256" } - $actual = (Get-FileHash -Path $ZipPath -Algorithm SHA256).Hash.ToLower() + $actual = Get-Sha256 $ZipPath if ($actual -ne $expected) { Fail "checksum mismatch for $ArchiveName`n expected: $expected`n actual: $actual" } @@ -247,7 +349,7 @@ try { # (no top-level / wrapper), unlike the Unix tarball. $Stage = Join-Path $Work "stage" New-Item -ItemType Directory -Path $Stage -Force | Out-Null - Expand-Archive -Path $ZipPath -DestinationPath $Stage -Force + Expand-Zip $ZipPath $Stage $BinaryPath = Join-Path $Stage "atomic-agent.exe" if (-not (Test-Path $BinaryPath)) { @@ -264,8 +366,20 @@ try { Remove-StaleBackups $InstallDir Copy-TreeTransactional $Stage $InstallDir + # Short alias: `atag` is the same CLI under a shorter name. A .cmd shim + # rather than a copy of the (large) SEA binary, and rather than a symlink, + # which needs an elevated shell or Developer Mode. %~dp0 resolves to the + # directory of the shim, so it always launches the atomic-agent.exe sitting + # next to it and asset resolution is unaffected. Rewritten on every install, + # so it self-heals and needs no place in the transactional copy. + Set-Content -LiteralPath (Join-Path $InstallDir "atag.cmd") -Encoding ASCII -Value @( + "@echo off", + "`"%~dp0atomic-agent.exe`" %*" + ) + Write-Info "" Write-Info "installed atomic-agent to $InstallDir\atomic-agent.exe" + Write-Info "(plus the short alias 'atag' next to it)" } catch { # A bare PowerShell error record is unreadable when the in-app updater @@ -313,15 +427,18 @@ switch ($script:PathStatus) { "present" { Write-Info "to run:" Write-Info " atomic-agent" + Write-Info " atag # same thing, shorter" } "manual" { Write-Info "atomic-agent is NOT on your PATH yet." Write-Info "add $InstallDir to your PATH, then run:" Write-Info " atomic-agent" + Write-Info " atag # same thing, shorter" } default { Write-Info "atomic-agent was added to your PATH." Write-Info "it works in THIS terminal now; open a NEW terminal elsewhere, then run:" Write-Info " atomic-agent" + Write-Info " atag # same thing, shorter" } } diff --git a/scripts/install.sh b/scripts/install.sh index 7f304218..58398b0b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,6 +9,8 @@ # ATOMIC_AGENT_VERSION=v0.1.0 (optional: pin a tag; default: latest) # ATOMIC_AGENT_INSTALL_DIR=path (default: $HOME/.local/bin) # ATOMIC_AGENT_NO_PATH=1 (optional: skip rc-file PATH update) +# ATOMIC_AGENT_VERIFY_TIMEOUT=20 (optional: seconds to allow the macOS +# signature check; 0 skips it) set -eu @@ -52,17 +54,314 @@ elif [ "$OS_NAME" = "Linux" ]; then ARCHIVE_EXT="tar.gz" fi +have() { + command -v "$1" >/dev/null 2>&1 +} + +# Progress UI --------------------------------------------------------------- +# +# curl's default meter paints a three-line table (two header rows plus the +# data row) per transfer, so a plain install scrolls six lines of numbers. +# Both fetchers are silenced below and progress is drawn here instead: a +# single line, redrawn in place, terminated by exactly one newline. +# +# Degrades in this order: no TTY (CI logs, `| tee`) prints one plain line and +# no bar; NO_COLOR or TERM=dumb keeps the bar but drops the colour; a +# non-UTF-8 locale swaps the block glyphs for ASCII. + +UI_TTY=0 +UI_COLOUR=0 +[ -t 1 ] && UI_TTY=1 + +# Keep a handle on the real stdout. Inside a command substitution fd 1 is the +# capture pipe, so terminal queries made there must go through this instead. +exec 3>&1 +if [ "$UI_TTY" = "1" ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then + UI_COLOUR=1 +fi + +if [ "$UI_COLOUR" = "1" ]; then + # Atomic blue (#0b63f6), 24-bit where the terminal advertises it. + case "${COLORTERM:-}" in + truecolor|24bit) C_ACCENT="$(printf '\033[38;2;11;99;246m')" ;; + *) C_ACCENT="$(printf '\033[38;5;33m')" ;; + esac + C_TRACK="$(printf '\033[38;5;239m')" + C_DIM="$(printf '\033[2m')" + C_OFF="$(printf '\033[0m')" +else + C_ACCENT="" + C_TRACK="" + C_DIM="" + C_OFF="" +fi + +case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in + *[Uu][Tt][Ff]8* | *[Uu][Tt][Ff]-8*) + BAR_FULL="█" + BAR_EMPTY="░" + ;; + *) + BAR_FULL="#" + BAR_EMPTY="-" + ;; +esac + +# Terminal width. Both obvious approaches are wrong inside the command +# substitution that captures this value: fd 1 is the pipe, not the terminal, +# so `stty size` sees nothing, and `tput cols` falls back to the terminfo +# default of 80 regardless of the real window. fd 3 (duped from stdout above) +# still refers to the terminal, so ask through that. +term_cols() { + _tc="${COLUMNS:-}" + case "$_tc" in + '' | *[!0-9]*) _tc="" ;; + esac + if [ -z "$_tc" ] && have stty; then + _tc="$(stty size <&3 2>/dev/null | awk '{ print $2 }')" + case "$_tc" in + '' | *[!0-9]*) _tc="" ;; + esac + fi + if [ -z "$_tc" ] && have tput; then + _tc="$(tput cols 2>/dev/null || echo '')" + case "$_tc" in + '' | *[!0-9]*) _tc="" ;; + esac + fi + [ -n "$_tc" ] || _tc=80 + printf '%s' "$_tc" +} + +# Line budget: the label, percentage and byte counter take ~52 columns; the +# bar gets what is left, so a narrow window still renders on one line. +BAR_WIDTH=16 +if [ "$UI_TTY" = "1" ]; then + _cols="$(term_cols)" + if [ "$_cols" -ge 100 ]; then + BAR_WIDTH=24 + elif [ "$_cols" -lt 78 ]; then + BAR_WIDTH=8 + fi +fi + +file_size() { + _fs=0 + if [ -f "$1" ]; then + _fs="$(wc -c < "$1" 2>/dev/null | tr -d ' \t' || echo 0)" + fi + case "$_fs" in + '' | *[!0-9]*) _fs=0 ;; + esac + printf '%s' "$_fs" +} + +# Total transfer size, or 0 when the server does not say. Redirects are +# followed so this reports the length of the object, not of the 302. +content_length() { + have curl || { printf '0'; return 0; } + curl -fsIL --retry 2 "$1" 2>/dev/null | awk ' + { if (tolower($1) == "content-length:") { v = $2; gsub(/\r/, "", v) } } + END { print (v == "" ? 0 : v) } + ' +} + +render_progress() { + # $1 label, $2 bytes so far, $3 total bytes (0 when unknown) + _bar="$(awk -v label="$1" -v got="$2" -v total="$3" -v w="$BAR_WIDTH" \ + -v full="$BAR_FULL" -v empty="$BAR_EMPTY" \ + -v a="$C_ACCENT" -v t="$C_TRACK" -v d="$C_DIM" -v o="$C_OFF" ' + function human(b) { + if (b < 1024) return sprintf("%d B", b) + if (b < 1048576) return sprintf("%.0f KB", b / 1024) + return sprintf("%.1f MB", b / 1048576) + } + BEGIN { + if (total <= 0) { + printf "%s %s%s%s", label, d, human(got), o + exit + } + frac = got / total + if (frac > 1) frac = 1 + n = int(frac * w + 0.5) + done = ""; left = "" + for (i = 0; i < n; i++) done = done full + for (i = n; i < w; i++) left = left empty + printf "%s %s%s%s%s%s%s %3d%% %s%s of %s%s", \ + label, a, done, o, t, left, o, int(frac * 100 + 0.5), d, human(got), human(total), o + } + ')" + printf '\r%s\033[K' "$_bar" +} + +fetch() { + # Silent transfer; the caller owns all output. + if have curl; then + curl -fsS -L --retry 3 -o "$2" "$1" + else + wget -q -O "$2" "$1" + fi +} + download() { + # $1 url, $2 destination, $3 label (omit for a silent transfer) _url="$1" _out="$2" - if command -v curl >/dev/null 2>&1; then - curl -fL --retry 3 -o "$_out" "$_url" - elif command -v wget >/dev/null 2>&1; then - wget -q -O "$_out" "$_url" - else + _label="${3:-}" + + if ! have curl && ! have wget; then echo "install curl or wget" >&2 exit 1 fi + + # Small side files (checksums) and non-interactive runs get no bar. + if [ -z "$_label" ]; then + fetch "$_url" "$_out" + return 0 + fi + if [ "$UI_TTY" != "1" ]; then + printf '%s\n' "$_label" + fetch "$_url" "$_out" + return 0 + fi + + _total="$(content_length "$_url")" + : > "$_out" + + fetch "$_url" "$_out" & + _dl_pid=$! + + while kill -0 "$_dl_pid" 2>/dev/null; do + render_progress "$_label" "$(file_size "$_out")" "$_total" + sleep 0.2 + done + + if wait "$_dl_pid"; then + render_progress "$_label" "$(file_size "$_out")" "$_total" + printf '\n' + else + _rc=$? + printf '\r\033[K' + echo "download failed: $_url" >&2 + exit "$_rc" + fi +} + +# Signature check ----------------------------------------------------------- +# +# macOS runs a first-sight Gatekeeper/XProtect scan of a newly written +# executable the first time anything asks about its signature, and codesign +# blocks -- at ~0% CPU, so it does not even look busy -- until that scan +# lands. On a 140 MB SEA binary that is routinely minutes: measured 4m59s on +# an idle M-series laptop, against 0.2s for a codesign of the very same bytes +# at a path the scanner has already seen. +# +# This check used to run inline and silently, so a perfectly healthy install +# printed the checksum line and then sat there with a bare cursor. People read +# that as a hang and pressed Ctrl-C -- which left them with no atomic-agent at +# all. That is the failure this bounds: show the wait, cap it, and never let +# it be the reason an install ends with nothing installed. +# +# A timeout is not a verification failure. The sha256 compared above already +# proves these bytes are the ones the release published; codesign is a second +# opinion on the same question. So a timeout warns and proceeds, while a +# codesign that actually returns non-zero still aborts -- a binary whose pages +# do not match its signature is SIGKILLed by the kernel on launch, and saying +# so here beats letting the user discover it. +VERIFY_TIMEOUT="${ATOMIC_AGENT_VERIFY_TIMEOUT:-20}" +case "$VERIFY_TIMEOUT" in + '' | *[!0-9]*) VERIFY_TIMEOUT=20 ;; +esac + +render_wait() { + # $1 label, $2 elapsed seconds, $3 budget seconds + _wb="$(awk -v label="$1" -v got="$2" -v total="$3" -v w="$BAR_WIDTH" \ + -v full="$BAR_FULL" -v empty="$BAR_EMPTY" \ + -v a="$C_ACCENT" -v t="$C_TRACK" -v d="$C_DIM" -v o="$C_OFF" ' + BEGIN { + frac = (total <= 0 ? 0 : got / total) + if (frac > 1) frac = 1 + n = int(frac * w + 0.5) + done = ""; left = "" + for (i = 0; i < n; i++) done = done full + for (i = n; i < w; i++) left = left empty + printf "%s %s%s%s%s%s%s %s%ds%s", \ + label, a, done, o, t, left, o, d, got, o + } + ')" + printf '\r%s\033[K' "$_wb" +} + +verify_signature() { + # $1 path to the extracted binary. Returns 0 when the install should + # continue; exits only on a definite signature failure. + _vs_file="$1" + if [ "$OS_NAME" != "Darwin" ]; then + return 0 + fi + if ! have codesign; then + return 0 + fi + if [ "$VERIFY_TIMEOUT" -le 0 ]; then + return 0 + fi + + _vs_label="checking signature" + _vs_rc_file="$WORK/codesign.rc" + rm -f "$_vs_rc_file" + + # The exit status of a killed background job is not recoverable from + # `wait`, so the subshell writes codesign's own status where the parent + # can read it. An absent file therefore means "killed", not "passed". + # + # `|| _rc=$?` is load-bearing: the subshell inherits `set -e`, so a bare + # failing codesign would kill it on the spot and the status line would + # never be written -- which reads to the parent exactly like a pass. + ( + _rc=0 + codesign --verify --strict "$_vs_file" >/dev/null 2>&1 || _rc=$? + echo "$_rc" > "$_vs_rc_file" + ) & + _vs_pid=$! + + if [ "$UI_TTY" != "1" ]; then + printf '%s\n' "$_vs_label" + fi + + _vs_waited=0 + while kill -0 "$_vs_pid" 2>/dev/null; do + if [ "$_vs_waited" -ge "$VERIFY_TIMEOUT" ]; then + kill "$_vs_pid" 2>/dev/null || true + wait "$_vs_pid" 2>/dev/null || true + if [ "$UI_TTY" = "1" ]; then + printf '\r\033[K' + fi + echo "signature check exceeded ${VERIFY_TIMEOUT}s and was skipped." + echo " (macOS scans a newly written 140 MB executable the first time it is asked;" + echo " the sha256 checksum above already verified this download.)" + return 0 + fi + if [ "$UI_TTY" = "1" ]; then + render_wait "$_vs_label" "$_vs_waited" "$VERIFY_TIMEOUT" + fi + sleep 1 + _vs_waited=$((_vs_waited + 1)) + done + wait "$_vs_pid" 2>/dev/null || true + + _vs_rc="$(cat "$_vs_rc_file" 2>/dev/null || echo 0)" + case "$_vs_rc" in + '' | *[!0-9]*) _vs_rc=0 ;; + esac + if [ "$UI_TTY" = "1" ]; then + render_wait "$_vs_label" "$_vs_waited" "$VERIFY_TIMEOUT" + printf '\n' + fi + if [ "$_vs_rc" -ne 0 ]; then + echo "error: downloaded binary failed 'codesign --verify --strict'; aborting" >&2 + exit 1 + fi + return 0 } BASE="https://github.com/${REPO}" @@ -76,14 +375,25 @@ else SHA_URL="${BASE}/releases/latest/download/${TAR_NAME}.sha256" fi -echo "downloading ${TAR_NAME} from ${REPO} …" - TMPDIR="${TMPDIR:-/tmp}" WORK="$(mktemp -d "$TMPDIR/atomic-agent-install.XXXXXX")" -# shellcheck disable=SC2064 -trap 'rm -rf "$WORK"' EXIT +TMP_BIN="" + +# Ctrl-C used to leave a 140 MB .atomic-agent.tmp.NNN orphan in the install +# dir, because only the work dir was cleaned and only on a normal exit. POSIX +# sh does not run the EXIT trap for an uncaught signal, so INT/TERM are wired +# up explicitly. cleanup is idempotent; the re-entry from `exit` is harmless. +cleanup() { + rm -rf "$WORK" + if [ -n "${TMP_BIN:-}" ]; then + rm -f "$TMP_BIN" + fi +} +trap 'cleanup' EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM -download "$TAR_URL" "$WORK/${TAR_NAME}" +download "$TAR_URL" "$WORK/${TAR_NAME}" "downloading atomic-agent" download "$SHA_URL" "$WORK/${TAR_NAME}.sha256" if command -v shasum >/dev/null 2>&1; then @@ -106,6 +416,11 @@ fi mkdir -p "$INSTALL_DIR" +# Sweep orphans left by installs interrupted before the cleanup trap above +# existed. Each one is a full copy of the binary -- 140 MB a piece. +rm -f "$INSTALL_DIR"/.atomic-agent.tmp.* 2>/dev/null || true +rm -f "$INSTALL_DIR"/.atomic-agent.exe.tmp.* 2>/dev/null || true + # Atomically replace a directory next to the binary. Copies the fresh tree # into a temp sibling, removes the old tree (unlinked inodes survive for any # running process that still maps them), then rename(2)s the new tree in. @@ -132,29 +447,42 @@ replace_dir() { # have been modified)" / "Invalid Page"). A self-update never restarts the # process, so the live binary MUST keep its own inode. if [ -f "$STAGE/atomic-agent" ]; then - _tmp_bin="$INSTALL_DIR/.atomic-agent.tmp.$$" - cp -f "$STAGE/atomic-agent" "$_tmp_bin" - chmod 755 "$_tmp_bin" 2>/dev/null || true - # Verify the signed binary before swapping it in (macOS). A failed --strict - # check means the downloaded bytes do not match the embedded signature, so - # launching it would SIGKILL anyway — abort instead of installing it. - if [ "$OS_NAME" = "Darwin" ] && command -v codesign >/dev/null 2>&1; then - if ! codesign --verify --strict "$_tmp_bin" 2>/dev/null; then - echo "error: downloaded binary failed 'codesign --verify --strict'; aborting" >&2 - rm -f "$_tmp_bin" - exit 1 - fi - fi - mv -f "$_tmp_bin" "$INSTALL_DIR/atomic-agent" + # Verify the archive copy, before a single byte is written into the install + # dir: a check that fails (or is interrupted) then leaves whatever is + # already installed exactly as it was. + verify_signature "$STAGE/atomic-agent" + TMP_BIN="$INSTALL_DIR/.atomic-agent.tmp.$$" + cp -f "$STAGE/atomic-agent" "$TMP_BIN" + chmod 755 "$TMP_BIN" 2>/dev/null || true + mv -f "$TMP_BIN" "$INSTALL_DIR/atomic-agent" + TMP_BIN="" elif [ -f "$STAGE/atomic-agent.exe" ]; then - _tmp_bin="$INSTALL_DIR/.atomic-agent.exe.tmp.$$" - cp -f "$STAGE/atomic-agent.exe" "$_tmp_bin" - mv -f "$_tmp_bin" "$INSTALL_DIR/atomic-agent.exe" + TMP_BIN="$INSTALL_DIR/.atomic-agent.exe.tmp.$$" + cp -f "$STAGE/atomic-agent.exe" "$TMP_BIN" + mv -f "$TMP_BIN" "$INSTALL_DIR/atomic-agent.exe" + TMP_BIN="" else echo "binary not found in archive under $STAGE" >&2 exit 1 fi +# Short alias: `atag` is the same binary under a shorter name. A relative +# symlink keeps the install dir movable, and because it points at a sibling +# the runtime still resolves grammars/, starter-skills/, vendor/ and +# node_modules/ next to the binary (dirname(process.execPath)) — execPath +# reports the resolved target, not the link. Falls back to a copy on +# filesystems without symlinks. +link_alias() { + # $1 target file name (sibling), $2 alias path + ln -sfn "$1" "$2" 2>/dev/null || cp -f "$INSTALL_DIR/$1" "$2" +} + +if [ -f "$INSTALL_DIR/atomic-agent" ]; then + link_alias atomic-agent "$INSTALL_DIR/atag" +elif [ -f "$INSTALL_DIR/atomic-agent.exe" ]; then + link_alias atomic-agent.exe "$INSTALL_DIR/atag.exe" +fi + replace_dir "$STAGE/grammars" "$INSTALL_DIR/grammars" # Built-in starter skills. The runtime resolves them next to the binary # (see resolveStarterSkillsSourceDir / seedStarterSkillsIfMissing) and @@ -251,20 +579,24 @@ fi echo echo "installed atomic-agent to ${INSTALL_DIR}/atomic-agent" +echo "(plus the short alias 'atag' next to it)" case "${PATH_STATUS:-added}" in present) echo "to run:" echo " atomic-agent" + echo " atag # same thing, shorter" ;; manual) echo "atomic-agent is NOT on your PATH yet." echo "add ${INSTALL_DIR} to your PATH, then run:" echo " atomic-agent" + echo " atag # same thing, shorter" ;; *) echo "atomic-agent was added to your PATH." echo "open a NEW terminal, then run:" echo " atomic-agent" + echo " atag # same thing, shorter" if [ -n "${RC_FILE:-}" ]; then echo "(to use it in THIS terminal, first reload your shell config: ${RC_FILE})" fi diff --git a/src/agent/agent-loop-steering.test.ts b/src/agent/agent-loop-steering.test.ts new file mode 100644 index 00000000..2d2a0457 --- /dev/null +++ b/src/agent/agent-loop-steering.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AgentLoop, type AgentLoopEvent } from "./agent-loop.js"; +import { buildDefaultToolRegistry } from "../tools/index.js"; +import { SlotManager } from "../llm/slot-manager.js"; +import { createEmptySessionState } from "../session/session-state.js"; +import { SteeringInbox } from "../runtime/steering-inbox.js"; +import type { CompletionResult } from "../llm/llama-server-client.js"; +import type { + CapabilitiesSummary, + SkillCatalogEntry, + ToolDescriptor, +} from "../prompt/stable-prefix.js"; + +/** + * Mid-turn steering. Pins: + * - A message pushed while the turn is running reaches the NEXT + * step's prompt as a `### notice` block — never the step already + * in flight, and never a later turn. + * - It is also recorded as a real `user` turn, so the transcript + * does not lie about what the operator said. + * - The loop-detector's own one-shot notice is composed with, not + * clobbered by, a steer landing in the same step. + * - Nothing is ever silently lost: a message that arrives too late + * to be drained comes back on `RunTurnResult.undelivered`, on the + * normal path and on the cancelled path alike. + * - Without a `steeringInbox` dep the loop behaves exactly as before. + */ + +function makeCompletion(content: string): CompletionResult { + return { + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 10, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; +} + +const TOOLS: ToolDescriptor[] = [ + { name: "finish", summary: "Finish the session.", argsSchema: '{"summary": string}' }, +]; + +const CAPS: CapabilitiesSummary = { + platform: "darwin", + arch: "arm64", + browserChannel: "chrome", + workingDir: "/work", + hasClipboard: true, + hasWmctrl: false, + hasNotifications: true, +}; + +const SKILLS: SkillCatalogEntry[] = []; + +const NOOP = JSON.stringify({ tool: "noop", args: {} }); +const REPLY = JSON.stringify({ tool: "reply", args: { text: "done" } }); + +interface Harness { + loop: AgentLoop; + tails: string[]; + events: AgentLoopEvent[]; +} + +function buildLoop(opts: { + inbox?: SteeringInbox; + onStep?: (stepIndex: number) => void; + steps?: number; +}): Harness { + const tails: string[] = []; + const events: AgentLoopEvent[] = []; + const totalSteps = opts.steps ?? 2; + let calls = 0; + const registry = buildDefaultToolRegistry(); + // A trivial non-terminal tool so the turn takes more than one step — + // steering only exists between step boundaries, so a one-step turn + // could not exercise it. + registry.register({ + name: "noop", + description: "does nothing", + readonly: true, + run: async () => ({ + tool: "noop", + status: "ok" as const, + summary: "noop", + details: {}, + truncated: false, + }), + }); + const loop = new AgentLoop({ + registry, + slotManager: new SlotManager(2), + grammar: 'root ::= "ok"', + llmComplete: async () => { + calls += 1; + opts.onStep?.(calls - 1); + return makeCompletion(calls < totalSteps ? NOOP : REPLY); + }, + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + ...(opts.inbox ? { steeringInbox: opts.inbox } : {}), + onEvent: (event) => { + events.push(event); + if (event.type === "llm_event" && event.event.type === "prompt_captured") { + tails.push(event.event.tail); + } + }, + }); + return { loop, tails, events }; +} + +describe("AgentLoop mid-turn steering", () => { + let workingDir: string; + + beforeEach(() => { + workingDir = mkdtempSync(join(tmpdir(), "atomic-agent-steer-")); + }); + + afterEach(() => { + rmSync(workingDir, { recursive: true, force: true }); + }); + + it("folds a message sent during step 0 into step 1's prompt", async () => { + const inbox = new SteeringInbox(); + const { loop, tails } = buildLoop({ + inbox, + // Pushed while step 0's inference is in flight — the realistic + // shape of "the operator typed while the agent was working". + onStep: (step) => { + if (step === 0) inbox.push("s-steer", "actually, check the logs first"); + }, + }); + const session = createEmptySessionState({ id: "s-steer", workingDir }); + await loop.runTurn(session, { + userMessage: "do the thing", + maxSteps: 4, + signal: new AbortController().signal, + }); + + expect(tails).toHaveLength(2); + // Step 0 was already committed when the message arrived. + expect(tails[0]).not.toContain("actually, check the logs first"); + expect(tails[1]).toContain("### notice"); + expect(tails[1]).toContain("actually, check the logs first"); + }); + + it("does not leak the notice into the step after that", async () => { + const inbox = new SteeringInbox(); + const { loop, tails } = buildLoop({ + inbox, + steps: 3, + onStep: (step) => { + if (step === 0) inbox.push("s-once", "one-shot please"); + }, + }); + await loop.runTurn(createEmptySessionState({ id: "s-once", workingDir }), { + userMessage: "go", + maxSteps: 4, + signal: new AbortController().signal, + }); + // The NOTICE is one-shot. The message itself stays visible in + // `### conversation` forever — it is a real user turn, and that is + // the point — so assert on the notice framing, not on the text. + expect(tails[1]).toContain("### notice"); + expect(tails[1]).toMatch(/Take it into account before your next action/); + expect(tails[2]).not.toMatch(/Take it into account before your next action/); + expect(tails[2]).toContain("one-shot please"); + }); + + it("records the steer as a real user turn and emits steer_applied", async () => { + const inbox = new SteeringInbox(); + const { loop, events } = buildLoop({ + inbox, + onStep: (step) => { + if (step === 0) inbox.push("s-turn", "and use the staging db"); + }, + }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-turn", workingDir }), + { userMessage: "deploy", maxSteps: 4, signal: new AbortController().signal }, + ); + + const userTurns = result.session.turns.filter((t) => t.kind === "user"); + expect(userTurns.map((t) => (t as { text: string }).text)).toEqual([ + "deploy", + "and use the staging db", + ]); + expect(events).toContainEqual({ + type: "steer_applied", + text: "and use the staging db", + stepIndex: 1, + }); + }); + + it("delivers several messages queued between two steps in one notice", async () => { + const inbox = new SteeringInbox(); + const { loop, tails } = buildLoop({ + inbox, + onStep: (step) => { + if (step === 0) { + inbox.push("s-multi", "first correction"); + inbox.push("s-multi", "second correction"); + } + }, + }); + await loop.runTurn(createEmptySessionState({ id: "s-multi", workingDir }), { + userMessage: "go", + maxSteps: 4, + signal: new AbortController().signal, + }); + expect(tails[1]).toContain("first correction"); + expect(tails[1]).toContain("second correction"); + expect(tails[1]).toContain("2 new messages"); + }); + + it("hands back a message that arrived too late to be drained", async () => { + const inbox = new SteeringInbox(); + const { loop } = buildLoop({ + inbox, + // Pushed during the FINAL inference: the loop terminates on this + // step's `reply`, so no further step boundary exists to drain it. + onStep: (step) => { + if (step === 1) inbox.push("s-late", "too late to steer"); + }, + }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-late", workingDir }), + { userMessage: "go", maxSteps: 4, signal: new AbortController().signal }, + ); + expect(result.reason).toBe("reply"); + expect(result.undelivered).toEqual(["too late to steer"]); + // And it really is gone from the inbox — it is the caller's now. + expect(inbox.peek("s-late")).toEqual([]); + }); + + it("hands back pending messages when the turn is cancelled", async () => { + const inbox = new SteeringInbox(); + const controller = new AbortController(); + const { loop } = buildLoop({ + inbox, + steps: 5, + onStep: (step) => { + if (step === 0) { + inbox.push("s-cancel", "never delivered"); + controller.abort(); + } + }, + }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-cancel", workingDir }), + { userMessage: "go", maxSteps: 4, signal: controller.signal }, + ); + expect(result.undelivered).toEqual(["never delivered"]); + }); + + it("returns no undelivered messages on an ordinary turn", async () => { + const { loop } = buildLoop({ inbox: new SteeringInbox() }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-plain", workingDir }), + { userMessage: "go", maxSteps: 4, signal: new AbortController().signal }, + ); + expect(result.undelivered).toEqual([]); + }); + + it("behaves exactly as before when no inbox is wired in", async () => { + const { loop, tails } = buildLoop({}); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-none", workingDir }), + { userMessage: "go", maxSteps: 4, signal: new AbortController().signal }, + ); + expect(result.reason).toBe("reply"); + expect(result.undelivered).toEqual([]); + for (const tail of tails) expect(tail).not.toContain("### notice"); + }); +}); diff --git a/src/agent/agent-loop.test.ts b/src/agent/agent-loop.test.ts index 4728e1a6..86d68c9a 100644 --- a/src/agent/agent-loop.test.ts +++ b/src/agent/agent-loop.test.ts @@ -787,7 +787,7 @@ describe("AgentLoop end-to-end with mock LLM", () => { expect(loopFailedCategory).toBe("model"); }); - it("classifies an empty completion as ModelError and skips parse retry", async () => { + it("repairs an empty completion once, then classifies it as ModelError", async () => { const registry = buildDefaultToolRegistry(); let llmCalls = 0; const stepErrors: Array<{ category: string }> = []; @@ -816,7 +816,11 @@ describe("AgentLoop end-to-end with mock LLM", () => { }); expect(result.reason).toBe("failed"); expect(result.session.status).toBe("failed"); - expect(llmCalls).toBe(1); + // An empty grammar body now goes through the one-shot repair (a + // rebuilt prompt, not a replay) before the turn is written off — two + // calls, never three. A second empty completion is still terminal + // and still classifies as `model`. + expect(llmCalls).toBe(2); expect(stepErrors[0]?.category).toBe("model"); }); diff --git a/src/agent/agent-loop.ts b/src/agent/agent-loop.ts index d4367270..b8036043 100644 --- a/src/agent/agent-loop.ts +++ b/src/agent/agent-loop.ts @@ -48,12 +48,20 @@ import { formatForcedLoopReply, } from "./loop-detector.js"; import type { BatchLoopSignal } from "./batch-executor.js"; +import { composeSteerNotice } from "./steer-notice.js"; import { getConfig } from "../config/index.js"; import type { AgentMetrics } from "../tracing/agent-metrics.js"; import type { StructuredLogger } from "../tracing/structured-logger.js"; export interface AgentLoopDependencies { registry: ToolRegistry; + /** + * Plan mode, read per call. A getter rather than a boolean so a mode + * the operator flips mid-session is observed by the next tool call + * rather than by the next process — the same reasoning the approval + * gate uses for `approvalRequired`. + */ + isPlanMode?: () => boolean; slotManager: SlotManager; grammar: string; llmComplete: (params: LlmStreamParams) => Promise; @@ -71,10 +79,23 @@ export interface AgentLoopDependencies { capabilities: CapabilitiesSummary; /** Model-specific reasoning behaviour derived from llama-server /props. */ profile?: ModelProfile; + /** + * Context window resolved from the model catalogue, for providers with + * no `/props` probe. Read per step so a mid-session model swap is + * reflected without restarting the loop. + */ + contextWindow?: () => number | null; /** Defaults to `grammar` when omitted (test / legacy wiring). */ toolTransport?: ToolCallTransport; toolCallAdapter?: ToolCallAdapter | null; supportsSlotAffinity?: boolean; + /** + * Whether the active native-tools provider can emit parallel tool + * calls. Defaults to `true` when omitted (legacy / grammar-only + * wiring). Combined with `agent.maxParallelToolCalls` to decide the + * `parallel_tool_calls` wire flag (issue #104). + */ + supportsParallelTools?: boolean; /** * Optional hot-swap supervisor. When provided, the loop re-probes * `/props` at the start of every turn and inspects the `modelId` of @@ -164,6 +185,14 @@ export interface AgentLoopDependencies { */ lessonLifecycle?: LessonLifecycleHook; onEvent?: (event: AgentLoopEvent) => void; + /** + * Out-of-band channel for user messages that arrive while this turn is + * already running (`SteeringInbox`). Drained at the top of every step + * and folded into that step's `### notice`; see §"Mid-turn steering" + * in AGENTS.md. Absent in tests and in surfaces that do not offer + * steering, in which case the loop behaves exactly as before. + */ + steeringInbox?: SteeringChannel; metrics?: AgentMetrics; logger?: StructuredLogger; } @@ -247,6 +276,23 @@ export interface LessonLifecycleHook { }): void; } +/** + * The turn's side of the steering inbox. Declared structurally (like + * {@link MemoryContextProvider}) so `src/agent/` does not import from + * `src/runtime/`, which imports it. + * + * The loop owns the window in which steering is accepted: `open` when + * the turn starts, `drain` at every step boundary, `closeAndDrain` + * exactly once on the way out. `closeAndDrain` is what makes "the turn + * can still pick messages up" and "the last drain has happened" the + * same fact — see the comment on `SteeringInbox.accepting`. + */ +export interface SteeringChannel { + open(sessionId: string): void; + drain(sessionId: string): readonly string[]; + closeAndDrain(sessionId: string): readonly string[]; +} + export interface RunTurnOptions { maxSteps: number; signal: AbortSignal; @@ -264,6 +310,13 @@ export type AgentLoopReason = export type AgentLoopEvent = | { type: "user_message"; text: string } + /** + * A message the user sent mid-turn was folded into the prompt for + * step `stepIndex`. Distinct from `user_message`, which marks the + * message that *started* the turn — UIs render this one inline in the + * running turn rather than as the opening of a new one. + */ + | { type: "steer_applied"; text: string; stepIndex: number } | { type: "turn_started"; turnIndex: number } | { type: "turn_finished"; @@ -326,6 +379,14 @@ export interface RunTurnResult { session: SessionState; reason: AgentLoopReason; stepCount: number; + /** + * Steering messages that were pushed but never reached a step — the + * turn ended (or was cancelled) before the loop could drain them. + * Callers MUST re-route these, normally onto their own message queue, + * otherwise a message the user watched being accepted vanishes. Empty + * on every ordinary turn. + */ + undelivered?: readonly string[]; } export class AgentLoop { @@ -342,10 +403,40 @@ export class AgentLoop { * - On `finish`: returns with `reason: "finish"`, session marked completed. * - On `max_steps`: synthesises a fallback assistant reply so the user * is never left without a turn closing. + * + * The wrapper owns the mid-turn steering window: it is open for + * exactly the lifetime of this call, and it closes in the same + * indivisible step as the loop's final drain (see `flushSteering`). + * A `steer()` that lands after that is refused, not stranded. */ async runTurn( session: SessionState, options: RunTurnOptions, + ): Promise { + this.deps.steeringInbox?.open(session.id); + try { + return await this.runTurnInner(session, options); + } finally { + // Every ordinary exit already closed the window through + // `flushSteering` — a `return` expression is evaluated before + // this block runs, so `undelivered` is unaffected and this call + // is a no-op. What it catches is the throw path (a programming + // bug escaping the classified-error handling above): without it + // the session would stay open forever and every later `steer()` + // would be accepted into an inbox nobody drains. + const stranded = this.deps.steeringInbox?.closeAndDrain(session.id) ?? []; + if (stranded.length > 0) { + this.deps.logger?.warn("mid-turn steering stranded by a failed turn", { + sessionId: session.id, + count: stranded.length, + }); + } + } + } + + private async runTurnInner( + session: SessionState, + options: RunTurnOptions, ): Promise { let state = session; @@ -451,6 +542,27 @@ export class AgentLoop { } this.deps.onEvent?.({ type: "step_started", stepIndex: i }); const started = Date.now(); + // Mid-turn steering: anything the user sent since the previous + // step boundary joins this step's prompt. It is recorded as a + // real `user` turn (the transcript must reflect what was said, + // and `packConversation` always keeps the last user turn visible) + // AND repeated in `### notice`, which is the tail-most block the + // model reads before `### respond`. `composeSteerNotice` appends + // to whatever the loop detector already left in `pendingNotice` + // rather than overwriting it — both nudges matter. + const steered = this.deps.steeringInbox?.drain(state.id) ?? []; + for (const text of steered) { + state = recordTurn(state, userTurn(text)); + this.deps.onEvent?.({ type: "steer_applied", text, stepIndex: i }); + } + if (steered.length > 0) { + pendingNotice = composeSteerNotice(pendingNotice, steered); + this.deps.logger?.info("mid-turn steering applied", { + sessionId: state.id, + stepIndex: i, + count: steered.length, + }); + } const noticeForThisStep = pendingNotice; pendingNotice = undefined; try { @@ -479,12 +591,19 @@ export class AgentLoop { }, { registry: this.deps.registry, + ...(this.deps.isPlanMode + ? { isPlanMode: this.deps.isPlanMode } + : {}), slotManager: this.deps.slotManager, grammar: activeGrammar, profile: activeProfile, + ...(this.deps.contextWindow + ? { contextWindow: this.deps.contextWindow() } + : {}), toolTransport: this.deps.toolTransport ?? "grammar", toolCallAdapter: this.deps.toolCallAdapter ?? null, supportsSlotAffinity: this.deps.supportsSlotAffinity ?? true, + supportsParallelTools: this.deps.supportsParallelTools ?? true, llmComplete: this.deps.llmComplete, ...(this.deps.llmCompleteStream ? { llmCompleteStream: this.deps.llmCompleteStream } @@ -554,9 +673,14 @@ export class AgentLoop { // loop-signal path below may overwrite this with a repeat // notice — that is intentional: a loop hint outranks a trim // hint since the loop indicates the model failed to make - // progress over multiple steps. + // progress over multiple steps. A wave-split step (issue #111) + // seeds its notice the same way — nothing was dropped, but the + // model should know its oversized read array ran in bounded + // waves. if (outcome.trimmedBatchNotice !== undefined) { pendingNotice = outcome.trimmedBatchNotice; + } else if (outcome.waveSplitNotice !== undefined) { + pendingNotice = outcome.waveSplitNotice; } // The synchronous batch gate (inside `executeStep`) already @@ -708,7 +832,12 @@ export class AgentLoop { stepCount: stepsTaken, durationMs, }); - return { session: state, reason: "cancelled", stepCount: stepsTaken }; + return { + session: state, + reason: "cancelled", + stepCount: stepsTaken, + undelivered: this.flushSteering(state.id), + }; } // Symmetric with the cancelled path above: set terminal state, // emit `loop_completed` + `turn_finished`, increment turnCount, @@ -739,7 +868,12 @@ export class AgentLoop { // returned earlier without calling the hook (cancellation // carries neither success nor failure signal). invokeLessonLifecycle(this.deps, state.id, surfacedLessonIds, "failure"); - return { session: state, reason: "failed", stepCount: stepsTaken }; + return { + session: state, + reason: "failed", + stepCount: stepsTaken, + undelivered: this.flushSteering(state.id), + }; } } @@ -890,7 +1024,30 @@ export class AgentLoop { } } - return { session: state, reason, stepCount: stepsTaken }; + return { + session: state, + reason, + stepCount: stepsTaken, + undelivered: this.flushSteering(state.id), + }; + } + + /** + * Close the steering window and empty the inbox on the way out of a + * turn — one indivisible step, which is the whole point. + * + * A message pushed after the loop's last drain — during the final + * inference, or at any point in a turn that was cancelled before it + * stepped — would otherwise sit in the inbox until some unrelated + * later turn happened to pick it up, out of order and out of context. + * What is already pending is handed back to the caller as + * `undelivered`; what arrives from here on is refused at `push`, so + * the sender learns immediately that it was not steered. Together + * that keeps "the message you sent always goes somewhere" true on + * every exit path, with no window in between. + */ + private flushSteering(sessionId: string): readonly string[] { + return this.deps.steeringInbox?.closeAndDrain(sessionId) ?? []; } } @@ -1034,7 +1191,11 @@ function collectLastUserAssistantPairs( for (const turn of state.turns) { if (!turn) continue; if (turn.kind === "user") { - pendingUser = turn.text; + // Consecutive user rows exist since mid-turn steering: the steer + // must not REPLACE the founding message in the reflection pair — + // memory extraction would then attribute the whole turn to the + // correction alone. Join them in order instead. + pendingUser = pendingUser === null ? turn.text : `${pendingUser}\n\n${turn.text}`; } else if (turn.kind === "assistant_reply" && pendingUser !== null) { pairs.push({ user: pendingUser, assistant: turn.text }); pendingUser = null; diff --git a/src/agent/batch-executor.test.ts b/src/agent/batch-executor.test.ts index a72b5772..479e7cd7 100644 --- a/src/agent/batch-executor.test.ts +++ b/src/agent/batch-executor.test.ts @@ -111,6 +111,109 @@ describe("executeBatch", () => { expect(elapsed).toBeLessThan(250); }); + it("chunks pure_read fan-out into bounded waves when maxWaveSize is set", async () => { + // 5 reads with a wave size of 2 → waves of [0,1], [2,3], [4]. Track + // peak concurrency: it must never exceed 2, and all 5 must run. + let inflight = 0; + let peak = 0; + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.read", + description: "read", + readonly: true, + run: async () => { + inflight += 1; + peak = Math.max(peak, inflight); + await new Promise((r) => setTimeout(r, 30)); + inflight -= 1; + return okResult("os.fs.read"); + }, + }); + const inputs = toBatchInputs( + [0, 1, 2, 3, 4].map((i) => ({ + tool: "os.fs.read", + args: { path: String(i) }, + })), + ); + const out = await executeBatch(inputs, registry, { + ...ctx(new AbortController().signal), + maxWaveSize: 2, + }); + expect(out.results).toHaveLength(5); + expect(out.results.every((r) => r.compressed?.status === "ok")).toBe(true); + expect(out.cancelled).toBe(false); + expect(peak).toBeLessThanOrEqual(2); + // Result order still matches the original batch-index order. + expect(out.results.map((r) => r.batchIndex)).toEqual([0, 1, 2, 3, 4]); + }); + + it("runs a single wave when maxWaveSize covers the whole group", async () => { + let inflight = 0; + let peak = 0; + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.read", + description: "read", + readonly: true, + run: async () => { + inflight += 1; + peak = Math.max(peak, inflight); + await new Promise((r) => setTimeout(r, 30)); + inflight -= 1; + return okResult("os.fs.read"); + }, + }); + const inputs = toBatchInputs( + [0, 1, 2].map((i) => ({ + tool: "os.fs.read", + args: { path: String(i) }, + })), + ); + const out = await executeBatch(inputs, registry, { + ...ctx(new AbortController().signal), + maxWaveSize: 10, + }); + expect(out.results).toHaveLength(3); + expect(out.results.every((r) => r.compressed?.status === "ok")).toBe(true); + // All three ran concurrently — a single wave. + expect(peak).toBe(3); + }); + + it("preserves batch-index correlation across waves", async () => { + const order: number[] = []; + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.read", + description: "read", + readonly: true, + run: async (args) => { + await new Promise((r) => setTimeout(r, 10)); + order.push(args.path as number); + return okResult("os.fs.read", `read ${args.path}`); + }, + }); + const inputs = toBatchInputs( + [3, 1, 4, 0, 2].map((p) => ({ + tool: "os.fs.read", + args: { path: p }, + })), + ); + const out = await executeBatch(inputs, registry, { + ...ctx(new AbortController().signal), + maxWaveSize: 2, + }); + // `results[i]` must correspond to `inputs[i]` regardless of wave + // execution order. + expect(out.results.map((r) => r.batchIndex)).toEqual([0, 1, 2, 3, 4]); + expect(out.results.map((r) => r.compressed?.summary)).toEqual([ + "read 3", + "read 1", + "read 4", + "read 0", + "read 2", + ]); + }); + it("serialises browser calls in batch-index order", async () => { const order: number[] = []; const make = (idx: number) => @@ -497,6 +600,131 @@ describe("executeBatch", () => { expect(out.loopSignals[0]!.detector).toBe("wandering"); }); + // Issue #186: the veto body must name the invariant that held across + // the blocked attempts and offer a concrete alternative. + it("veto body names the repeated host and offers the search-first alternative", async () => { + const registry = buildRegistry({ "os.web.fetch": async () => okResult("os.web.fetch") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + const args = { url: "https://web.archive.org/web/2020/https://x.test/a?k=SECRET" }; + seedCriticalStreak(tracker, "os.web.fetch", args, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.web.fetch", args }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("web.archive.org"); + expect(body).toContain("`os.web.search`"); + // The full URL — path, query, secret — must NOT reach model context. + expect(body).not.toContain("SECRET"); + expect(body).not.toContain("/web/2020/"); + }); + + it("veto body names the command for a shell loop", async () => { + const registry = buildRegistry({ "os.shell.run": async () => okResult("os.shell.run") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + const args = { command: "curl -s https://x.test --header 'Authorization: Bearer SECRET'" }; + seedCriticalStreak(tracker, "os.shell.run", args, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.shell.run", args }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("`curl`"); + expect(body).not.toContain("SECRET"); + }); + + it("veto body degrades to generic wording when args carry no extractable target", async () => { + const registry = buildRegistry({ "os.fs.read": async () => okResult("os.fs.read") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + seedCriticalStreak(tracker, "os.fs.read", { path: "a" }, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.fs.read", args: { path: "a" } }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("BLOCKED"); + expect(body).toContain("2 consecutive calls returned the same no-progress outcome"); + expect(body).not.toContain("undefined"); + }); + + // The wandering spread is a property of the history window, so it stays + // above the threshold once the model stops varying its argument. Reporting + // a verbatim repeat as "N different attempts" is the same false statement + // the wandering wording exists to avoid, in the mirror case. + it("stops claiming different attempts once a wandering model settles on one url", async () => { + const registry = buildRegistry({ + "os.web.fetch": async () => okResult("os.web.fetch"), + }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 3, + wanderingThreshold: 3, + wanderingEscalation: 4, + }); + // Wander first: four distinct URLs on one host crosses the escalation. + for (const path of ["a", "b", "c", "d"]) { + const wandered = { url: `https://web.archive.org/${path}` }; + tracker.check("os.web.fetch", wandered); + tracker.recordCall("os.web.fetch", wandered); + tracker.recordOutcome( + "os.web.fetch", + wandered, + okResult("os.web.fetch", path), + ); + } + // Then settle: the same URL, twice, so the second call is a repeat. + const settled = { url: "https://web.archive.org/same" }; + tracker.check("os.web.fetch", settled); + tracker.recordCall("os.web.fetch", settled); + tracker.recordOutcome( + "os.web.fetch", + settled, + okResult("os.web.fetch", "same"), + ); + + const out = await executeBatch( + toBatchInputs([{ tool: "os.web.fetch", args: settled }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("BLOCKED"); + expect(body).toContain("web.archive.org"); + expect(body).not.toContain("different attempts"); + // A count the verdict cannot substantiate must not be quoted either. + expect(body).not.toContain("0 consecutive"); + }); + + it("does not throw and stays generic when args are malformed", async () => { + const registry = buildRegistry({ "os.web.fetch": async () => okResult("os.web.fetch") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + const args = { url: "://not a url" }; + seedCriticalStreak(tracker, "os.web.fetch", args, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.web.fetch", args }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("BLOCKED"); + expect(body).not.toContain("undefined"); + }); + it("marks tail calls as cancelled when the signal aborts mid-serialised-group", async () => { const ctrl = new AbortController(); const registry = new ToolRegistry(); @@ -602,3 +830,135 @@ describe("executeBatch — skill.view short-circuit", () => { expect(tracker.check("skill.view", { name: "exa" }).level).toBe("critical"); }); }); + +/** + * Plan mode at the seam that matters: not "does the predicate say no", + * which `plan-mode.test.ts` covers, but "did the tool actually not run". + */ +describe("executeBatch under plan mode", () => { + it("never dispatches a mutating tool", async () => { + const write = vi.fn(async () => okResult("os.fs.write")); + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.write", + description: "write", + readonly: false, + run: write, + }); + const inputs = toBatchInputs([ + { tool: "os.fs.write", args: { path: "a", content: "x" } }, + ]); + const out = await executeBatch(inputs, registry, { + ...ctx(new AbortController().signal), + isPlanMode: () => true, + }); + expect(write).not.toHaveBeenCalled(); + expect(out.results[0]!.compressed?.status).toBe("error"); + expect(out.results[0]!.compressed?.summary).toContain("plan mode is on"); + }); + + it("still runs the read-only calls in the same batch", async () => { + const read = vi.fn(async () => okResult("os.fs.read")); + const write = vi.fn(async () => okResult("os.fs.write")); + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.read", + description: "read", + readonly: true, + run: read, + }); + registry.register({ + name: "os.fs.write", + description: "write", + readonly: false, + run: write, + }); + const inputs = toBatchInputs([ + { tool: "os.fs.read", args: { path: "a" } }, + { tool: "os.fs.write", args: { path: "b", content: "x" } }, + { tool: "os.fs.read", args: { path: "c" } }, + ]); + const out = await executeBatch(inputs, registry, { + ...ctx(new AbortController().signal), + isPlanMode: () => true, + }); + expect(read).toHaveBeenCalledTimes(2); + expect(write).not.toHaveBeenCalled(); + expect(out.results[0]!.compressed?.status).toBe("ok"); + expect(out.results[1]!.compressed?.status).toBe("error"); + expect(out.results[2]!.compressed?.status).toBe("ok"); + }); + + it("does not feed a refused call to the loop detector", async () => { + // A refused call that was recorded would let a retried tool trip the + // loop breaker and end the turn — over an argument the model was + // never allowed to try in the first place. + const write = vi.fn(async () => okResult("os.fs.write")); + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.write", + description: "write", + readonly: false, + run: write, + }); + const tracker = new ToolLoopTracker(); + for (let i = 0; i < 12; i++) { + const out = await executeBatch( + toBatchInputs([{ tool: "os.fs.write", args: { path: "a" } }]), + registry, + { + ...ctx(new AbortController().signal), + tracker, + isPlanMode: () => true, + }, + ); + expect(out.results[0]!.compressed?.summary).toContain("plan mode is on"); + } + expect(out2LoopSignals(tracker)).toBe(0); + }); + + it("runs everything again the moment plan mode goes off", async () => { + const write = vi.fn(async () => okResult("os.fs.write")); + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.write", + description: "write", + readonly: false, + run: write, + }); + let planning = true; + const inputs = toBatchInputs([ + { tool: "os.fs.write", args: { path: "a", content: "x" } }, + ]); + const base = { ...ctx(new AbortController().signal), isPlanMode: () => planning }; + await executeBatch(inputs, registry, base); + expect(write).not.toHaveBeenCalled(); + // The getter is read per call, so the flip is observed by the next + // tool call rather than by the next process. + planning = false; + await executeBatch(inputs, registry, base); + expect(write).toHaveBeenCalledTimes(1); + }); + + it("is inert when no getter is supplied", async () => { + const write = vi.fn(async () => okResult("os.fs.write")); + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.write", + description: "write", + readonly: false, + run: write, + }); + await executeBatch( + toBatchInputs([{ tool: "os.fs.write", args: { path: "a" } }]), + registry, + ctx(new AbortController().signal), + ); + expect(write).toHaveBeenCalledTimes(1); + }); +}); + +/** The tracker never saw a call, so it has nothing to complain about. */ +function out2LoopSignals(tracker: ToolLoopTracker): number { + return tracker.check("os.fs.write", { path: "a" }).count; +} diff --git a/src/agent/batch-executor.ts b/src/agent/batch-executor.ts index 14f0427c..a936fc18 100644 --- a/src/agent/batch-executor.ts +++ b/src/agent/batch-executor.ts @@ -1,3 +1,4 @@ +import { checkPlanMode } from "./plan-mode.js"; import type { ToolCallPayload } from "../llm/grammar/tool-call-grammar.js"; import { compressToolResult, @@ -11,6 +12,7 @@ import { type ResourceClass, } from "./tool-resource-class.js"; import { + extractLoopTarget, formatVetoInstruction, LOOP_VETO_DENIED_REASON, type LoopCheckVerdict, @@ -74,6 +76,15 @@ export interface BatchExecutionContext { * for this step (legacy behaviour). */ tracker?: ToolLoopTracker; + /** + * Plan mode, read at dispatch time rather than passed as a boolean. + * + * A getter for the same reason `dangerous.approvalRequired` is one + * (see `bootstrap.ts`): a value copied at construction freezes + * whatever was true at boot, and the whole point of a mode is that + * the operator flips it mid-session. Absent ⇒ plan mode is off. + */ + isPlanMode?: () => boolean; /** * Names of skills already present in `SessionState.loadedSkills`. A * `skill.view` call targeting one of these is short-circuited with a @@ -82,6 +93,15 @@ export interface BatchExecutionContext { * is never invoked for such calls. Absent ⇒ no short-circuit. */ loadedSkillNames?: ReadonlySet; + /** + * When set, the `pure_read` group fans out in bounded waves of at most + * this many concurrent calls instead of launching the whole group at + * once (issue #111). Each wave is awaited before the next starts, so + * waves execute in original order; the per-input `batchIndex` preserves + * global result correlation across waves. Other groups are unaffected. + * Absent ⇒ legacy single-wave fan-out. + */ + maxWaveSize?: number; } export interface BatchExecutionResult { @@ -153,10 +173,10 @@ export function planBatch( * a `CompressedToolResult{status:"error"}` and continues. * - Abort: if `signal.aborted` flips while a serialised group is * iterating, the remaining calls in that group are marked - * `cancelled` and skipped. `pure_read` calls are launched all at - * once before the loop checks the signal again — those that already - * started run to completion (their tool implementations honour the - * signal cooperatively). + * `cancelled` and skipped. `pure_read` calls launch per wave (or all + * at once when `maxWaveSize` is unset) before the loop checks the + * signal again — those that already started run to completion (their + * tool implementations honour the signal cooperatively). * - Terminal-tail barrier: when the batch contains a `terminal` call * (the validator guarantees it is at the last position), every * non-terminal call completes first; the terminal call then runs @@ -206,6 +226,26 @@ export async function executeBatch( slots[input.batchIndex] = { ...slots[input.batchIndex]!, cancelled: true }; continue; } + // Plan mode first: a call that is not going to run should not spend + // a slot in the loop tracker's history either. Recording it would + // let a refused-and-retried tool trip the loop breaker, and end the + // turn over an argument the model was never allowed to try. + const plan = runPlanModeGate(input, registry, ctx); + if (!plan.proceed && plan.vetoResult) { + ctx.onCallStarted?.({ batchIndex: input.batchIndex, batchSize }); + slots[input.batchIndex] = { + ...slots[input.batchIndex]!, + compressed: plan.vetoResult, + durationMs: 0, + }; + ctx.onCallFinished?.({ + batchIndex: input.batchIndex, + batchSize, + result: plan.vetoResult, + durationMs: 0, + }); + continue; + } const gate = runSyncLoopGate(input, ctx, loopSignals); if (!gate.proceed && gate.vetoResult) { ctx.onCallStarted?.({ batchIndex: input.batchIndex, batchSize }); @@ -315,12 +355,17 @@ export async function executeBatch( const groupTasks: Array> = []; for (const [cls, calls] of groups) { if (isParallelWithinGroup(cls)) { - // Pure-read fan-out. Launch every call immediately. Any that - // already started keep running on cooperative signal — already - // matches the legacy single-call path. + // Pure-read fan-out, bounded to waves of `maxWaveSize` when set + // (issue #111). Each wave is awaited before the next starts, so + // waves execute in original order; the per-input `batchIndex` + // keeps global result correlation intact. Absent ⇒ legacy + // single-wave fan-out (the whole group at once). + const waveSize = ctx.maxWaveSize ?? calls.length; groupTasks.push( (async (): Promise => { - await Promise.allSettled(calls.map(invokeOne)); + for (let i = 0; i < calls.length; i += waveSize) { + await Promise.allSettled(calls.slice(i, i + waveSize).map(invokeOne)); + } })(), ); continue; @@ -434,6 +479,25 @@ function skillAlreadyLoadedResult( * no-progress streak (the streak then plateaus at `criticalThreshold`). * Terminal verbs and tracker-less steps always proceed unchanged. */ +/** + * Refuse a mutating call while plan mode is on. + * + * Sits beside `runSyncLoopGate` and shares its shape — a synchronous + * verdict that either lets the call through or fills its slot — because + * both answer the same kind of question: is this call going to run at + * all, decided before anything is dispatched. + */ +function runPlanModeGate( + input: BatchCallInput, + registry: ToolRegistry, + ctx: BatchExecutionContext, +): { proceed: boolean; vetoResult?: CompressedToolResult } { + if (!ctx.isPlanMode?.()) return { proceed: true }; + const verdict = checkPlanMode(input.call.tool, registry); + if (verdict.allowed) return { proceed: true }; + return { proceed: false, vetoResult: verdict.refusal! }; +} + function runSyncLoopGate( input: BatchCallInput, ctx: BatchExecutionContext, @@ -456,14 +520,31 @@ function runSyncLoopGate( const count = breakerTripped ? Math.max(verdict.count, ctx.tracker.breakerThreshold) : verdict.count; + // Name the invariant that held across the blocked attempts (host for + // web/HTTP, command name for shell) so the message says WHAT stayed + // the same instead of only that something did. + const target = extractLoopTarget(tool, args); + // A wandering escalation rides this same veto path but its `count` is + // a spread of DISTINCT arguments; pass the detector so the wording + // does not claim they were identical. + // + // The verdict decides, not the escalation flag. `isWanderingEscalated` + // answers for the whole history window, so it stays true after the model + // stops wandering and settles on repeating one argument -- and borrowing + // it there would announce "N different attempts" about a verbatim + // repeat, quoting a count the verdict never established. + const detector = + wanderingEscalated && verdict.detector === "wandering" + ? "wandering" + : verdict.detector; const vetoResult = compressToolResult({ tool, status: "error", - output: formatVetoInstruction({ tool, count }), + output: formatVetoInstruction({ tool, count, target, detector }), details: { deniedReason: LOOP_VETO_DENIED_REASON, loopCount: count, - detector: verdict.detector, + detector, }, }); ctx.tracker.recordOutcome(tool, args, vetoResult); @@ -471,7 +552,7 @@ function runSyncLoopGate( kind: forceBreaker ? "breaker" : "critical", tool, count, - detector: verdict.detector, + detector, warningKey: verdict.warningKey, }); return { proceed: false, vetoResult }; diff --git a/src/agent/index.ts b/src/agent/index.ts index 696f40f1..6abc67d3 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -21,6 +21,7 @@ export { formatRepeatNotice, formatVetoInstruction, formatForcedLoopReply, + extractLoopTarget, BATCH_LOOP_LABEL, LOOP_VETO_DENIED_REASON, LOOP_WARNING_BUCKET_SIZE, diff --git a/src/agent/loop-detector.test.ts b/src/agent/loop-detector.test.ts index e392c585..bfce6ee1 100644 --- a/src/agent/loop-detector.test.ts +++ b/src/agent/loop-detector.test.ts @@ -4,6 +4,7 @@ import { BATCH_LOOP_LABEL, LOOP_VETO_DENIED_REASON, ToolLoopTracker, + extractLoopTarget, formatForcedLoopReply, formatRepeatNotice, formatVetoInstruction, @@ -312,6 +313,155 @@ describe("loop notice formatters", () => { }); }); +describe("extractLoopTarget", () => { + it("reduces a web fetch URL to its host, dropping path and query", () => { + expect( + extractLoopTarget("os.web.fetch", { + url: "https://web.archive.org/web/2020/https://x.test/a?token=SECRET", + }), + ).toBe("web.archive.org"); + }); + + it("handles os.http.request and schemeless URLs", () => { + expect( + extractLoopTarget("os.http.request", { + url: "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed", + }), + ).toBe("eutils.ncbi.nlm.nih.gov"); + expect(extractLoopTarget("os.web.fetch", { url: "en.wikipedia.org/wiki/X" })).toBe( + "en.wikipedia.org", + ); + }); + + it("reduces a shell command to the executable name only", () => { + expect( + extractLoopTarget("os.shell.run", { + command: "curl -s https://x.test/a --header 'Authorization: Bearer SECRET'", + }), + ).toBe("curl"); + }); + + it("returns undefined for unextractable or malformed args", () => { + expect(extractLoopTarget("os.web.fetch", {})).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", { url: "" })).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", { url: 42 })).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", null)).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", undefined)).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", "not-an-object")).toBeUndefined(); + expect(extractLoopTarget("os.shell.run", { command: " " })).toBeUndefined(); + expect(extractLoopTarget("browser.click", { selector: "#a" })).toBeUndefined(); + }); + + it("never throws on hostile or malformed URL values", () => { + for (const url of ["http://", "://", "%%%", "h ttp://a b", ""]) { + expect(() => extractLoopTarget("os.web.fetch", { url })).not.toThrow(); + } + }); +}); + +describe("veto message names the invariant and an alternative (issue #186)", () => { + it("names the repeated host for a fetch loop", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "web.archive.org", + detector: "no_progress", + }); + expect(veto).toContain("BLOCKED"); + expect(veto).toContain("web.archive.org"); + expect(veto).toContain("5 consecutive calls"); + expect(veto).toContain("same no-progress outcome"); + }); + + it("offers the search-first alternative naming a different host", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "web.archive.org", + detector: "no_progress", + }); + expect(veto).toContain("`os.web.search`"); + expect(veto).toContain("DIFFERENT host"); + expect(veto.toLowerCase()).toContain("do not repeat"); + }); + + it("names the command for a shell loop", () => { + const veto = formatVetoInstruction({ + tool: "os.shell.run", + count: 5, + target: "curl", + detector: "no_progress", + }); + expect(veto).toContain("`curl`"); + expect(veto).toContain("5 consecutive calls"); + expect(veto).toContain("change the arguments or path"); + }); + + it("does not claim identical outcomes on a wandering escalation", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 13, + target: "web.archive.org", + detector: "wandering", + }); + expect(veto).toContain("13 different attempts"); + expect(veto).toContain("web.archive.org"); + expect(veto).not.toContain("identical"); + expect(veto).not.toContain("consecutive calls"); + // Wandering means many DIFFERENT URLs, so the hint says stop guessing + // rather than "stop retrying" (which would imply identical calls). + expect(veto).toContain("Stop guessing URLs"); + expect(veto).toContain("`os.web.search`"); + expect(veto).not.toContain("stop retrying"); + }); + + it("degrades to the generic wording when no target can be extracted", () => { + const veto = formatVetoInstruction({ tool: "noop", count: 5 }); + expect(veto).toContain("BLOCKED"); + expect(veto).toContain("`noop`"); + expect(veto).toContain("5 consecutive calls returned the same no-progress outcome"); + expect(veto).not.toContain("undefined"); + expect(veto.toLowerCase()).toContain("do not repeat"); + }); + + it("sanitizes a hostile target: no backticks or newlines leak through", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "evil`\n## injected heading\n`x", + detector: "no_progress", + }); + expect(veto).not.toContain("## injected heading\n"); + expect(veto.split("\n")[0]).toContain("evil"); + // Header stays a single line. + expect(veto.split("\n")[0]).not.toContain("injected heading\n"); + }); + + it("truncates an over-long target", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "a".repeat(200), + detector: "no_progress", + }); + expect(veto).toContain("..."); + // The 200-char target is capped at 60 chars, not echoed in full. + expect(veto).not.toContain("a".repeat(61)); + expect(veto.split("\n")[0]!.length).toBeLessThan(160); + }); + + it("stays short — the message is injected on every veto", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "web.archive.org", + detector: "no_progress", + }); + expect(veto.split("\n").length).toBeLessThanOrEqual(5); + expect(veto.length).toBeLessThan(600); + }); +}); + describe("ToolLoopTracker wandering detector", () => { it("flags a wandering loop on distinct web fetches", () => { const tracker = new ToolLoopTracker({ diff --git a/src/agent/loop-detector.ts b/src/agent/loop-detector.ts index db6e72d9..30a98cb9 100644 --- a/src/agent/loop-detector.ts +++ b/src/agent/loop-detector.ts @@ -168,7 +168,16 @@ export class ToolLoopTracker { } if (isWanderingProneTool(tool)) { const spread = this.effectiveSpread(tool, argsHash); - if (spread >= this.wanderingThreshold) { + // The spread is a property of the whole window, so it stays above the + // threshold after the model stops varying its argument and settles on + // repeating one. Classifying THIS call as wandering would then tell it + // "N different attempts" about a call that is a verbatim repeat -- the + // same kind of false statement the wandering wording exists to avoid. + // A repeat falls through to the repeat detector, which describes it + // accurately. + const repeatsEarlierCall = + getRepeatCount(this.history, tool, argsHash) > 0; + if (spread >= this.wanderingThreshold && !repeatsEarlierCall) { return { level: "warn", count: spread, @@ -571,19 +580,29 @@ function canonicalJson(value: unknown): string { export function formatRepeatNotice(verdict: { count: number; tool: string; + target?: string; }): string { - return formatLoopGuidance(verdict.tool, verdict.count, "notice"); + return formatLoopGuidance(verdict.tool, verdict.count, "notice", verdict); } /** * Body of the synthetic veto tool result (critical). Same class-aware * guidance as the notice, plus an explicit "do not repeat" instruction. + * + * `target` names the invariant that stayed the same across the blocked + * attempts (host for web/HTTP calls, command name for shell). `detector` + * distinguishes a true no-progress repeat from a `wandering` escalation + * riding the same veto path — the two need opposite wording, because a + * wandering `count` is a spread of DISTINCT arguments, not a run of + * identical outcomes. */ export function formatVetoInstruction(verdict: { count: number; tool: string; + target?: string; + detector?: LoopCheckVerdict["detector"]; }): string { - return formatLoopGuidance(verdict.tool, verdict.count, "veto"); + return formatLoopGuidance(verdict.tool, verdict.count, "veto", verdict); } /** @@ -629,27 +648,65 @@ function formatLoopGuidance( tool: string, count: number, mode: "notice" | "veto", + context: { + target?: string; + detector?: LoopCheckVerdict["detector"]; + } = {}, ): string { - const header = - mode === "veto" - ? `BLOCKED: \`${tool}\` was vetoed as a no-progress loop (${count} identical no-progress outcomes).` + const target = sanitizeLoopTarget(context.target); + const wandering = context.detector === "wandering"; + + let header: string; + if (mode === "veto" && wandering) { + // Wandering: `count` is a spread of DISTINCT arguments, so calling + // these "identical outcomes" would be flatly wrong. + header = target + ? `BLOCKED: \`${tool}\` — ${count} different attempts against \`${target}\` and still no answer.` + : `BLOCKED: \`${tool}\` — ${count} different attempts and still no answer.`; + } else if (mode === "veto" && count > 1) { + header = target + ? `BLOCKED: \`${tool}\` — ${count} consecutive calls to \`${target}\` returned the same no-progress outcome.` + : `BLOCKED: \`${tool}\` — ${count} consecutive calls returned the same no-progress outcome.`; + } else if (mode === "veto") { + // The breaker can fire on a verdict that carries no streak of its own + // (a wandering episode the model ended by settling on one argument). + // State only what is certainly true rather than quoting a count that + // would read as "0 consecutive calls". + header = target + ? `BLOCKED: \`${tool}\` — repeated calls to \`${target}\` are not making progress.` + : `BLOCKED: \`${tool}\` — repeated calls are not making progress.`; + } else { + header = target + ? `You called \`${tool}\` on \`${target}\` ${count} times with the same arguments and neither the result nor the world snapshot changed.` : `You called \`${tool}\` with the same arguments ${count} times and neither the result nor the world snapshot changed.`; + } - const webHint = - tool === "os.web.fetch" || tool === "os.http.request" - ? "- The URL may be dead or returning an HTTP error — read the status in the tool result and try a different source, endpoint, or search query." - : null; + // Actionable alternative, modelled on the wandering redirect: name the + // next move, do not restate the failure mode. + let webHint: string | null = null; + if (tool === "os.web.fetch" || tool === "os.http.request") { + if (wandering && target) { + webHint = `- Stop guessing URLs on \`${target}\`. Run \`os.web.search\` for the fact you need and fetch a result from a DIFFERENT host.`; + } else if (target) { + webHint = `- Run \`os.web.search\` for the fact you need and fetch a result from a DIFFERENT host — stop retrying \`${target}\`. The URL may be dead or returning an HTTP error; read the status in the tool result.`; + } else { + webHint = + "- Run `os.web.search` for the fact you need, then fetch one URL from the results — do not keep guessing URLs. The URL may be dead or returning an HTTP error; read the status in the tool result."; + } + } const browserHint = tool.startsWith("browser.") ? "- Re-read `### world` — the answer may already be on the page. Try `browser.scroll`, a different element, or `browser.navigate` to a more direct URL. An `[expanded]` element is already open." : null; const shellHint = tool.startsWith("os.shell.") || tool.startsWith("os.fs.") - ? "- Change the command, path, or arguments — repeating the same invocation will not produce a different result." + ? target + ? `- \`${target}\` will not behave differently on a re-run — change the arguments or path, or use a different command entirely.` + : "- Change the command, path, or arguments — repeating the same invocation will not produce a different result." : null; const lines = [ header, - "This is a no-progress loop. Change strategy BEFORE calling any tool again:", + "Change strategy BEFORE calling any tool again:", webHint, browserHint, shellHint, @@ -661,3 +718,59 @@ function formatLoopGuidance( return lines.join("\n"); } + +/** + * Defensive cleanup for a caller-supplied invariant label before it is + * echoed into model context: single line, no backticks (they would break + * the surrounding code span), length-capped. Returns `undefined` for + * anything empty so callers degrade to the generic wording. + */ +function sanitizeLoopTarget(raw: string | undefined): string | undefined { + if (typeof raw !== "string") return undefined; + const cleaned = raw.replace(/[`\r\n]+/g, " ").trim(); + if (cleaned.length === 0) return undefined; + return cleaned.length > 60 ? `${cleaned.slice(0, 57)}...` : cleaned; +} + +/** + * Extract the invariant that stayed the same across a loop's blocked + * attempts, for use as the `target` label in guidance messages. + * + * Deliberately coarse: web/HTTP calls collapse to the URL's HOST and + * shell calls to the leading command word, so no query parameters, + * credentials, paths, or other potentially sensitive argument content + * reaches the model context. Returns `undefined` when nothing meaningful + * can be extracted, so the caller falls back to the generic wording. + * Never throws on malformed args. + */ +export function extractLoopTarget( + tool: string, + args: unknown, +): string | undefined { + if (args === null || typeof args !== "object") return undefined; + const record = args as Record; + + if (tool === "os.web.fetch" || tool === "os.http.request") { + const raw = record.url ?? record.uri ?? record.endpoint; + if (typeof raw !== "string" || raw.length === 0) return undefined; + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) + ? raw + : `https://${raw}`; + try { + const host = new URL(candidate).hostname; + return host.length > 0 ? host : undefined; + } catch { + return undefined; + } + } + + if (tool === "os.shell.run") { + const raw = record.command ?? record.cmd; + if (typeof raw !== "string") return undefined; + // Leading word only: the executable name, never the full argv. + const name = raw.trim().split(/\s+/)[0]; + return name !== undefined && name.length > 0 ? name : undefined; + } + + return undefined; +} diff --git a/src/agent/native-tool-call-execution-integrity.test.ts b/src/agent/native-tool-call-execution-integrity.test.ts new file mode 100644 index 00000000..1c68d8d9 --- /dev/null +++ b/src/agent/native-tool-call-execution-integrity.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { OpenAiProvider } from "../llm/provider/openai/openai-provider.js"; +import { openAiToolCallAdapter } from "../llm/provider/openai/openai-tool-call-adapter.js"; +import type { CompletionRequest, CompletionResult } from "../llm/provider/completion-types.js"; +import { executeStep, type StepDependencies } from "./step-executor.js"; +import { ToolRegistry } from "../tools/tool-registry.js"; +import { SlotManager } from "../llm/slot-manager.js"; +import { PLAIN_INSTRUCT_PROFILE } from "../llm/model-profile.js"; +import { buildGrammar } from "../llm/grammar/build-grammar.js"; +import { createEmptySessionState } from "../session/session-state.js"; +import { DEFAULT_TOOL_DESCRIPTORS } from "../prompt/tool-descriptors.js"; +import { compressToolResult } from "../compressor/result-compressor.js"; +import type { CapabilitiesSummary, SkillCatalogEntry } from "../prompt/stable-prefix.js"; + +/** + * Execution-integrity regression suite for the native OpenAI-compatible + * tool-call path. + * + * Drives the real `OpenAiProvider` (SSE parsing) and the real + * `executeStep()` (tool dispatch) with an instrumented no-op tool that + * only counts invocations — never a real filesystem/network/shell effect. + * + * Proves, at the actual dispatch boundary, that: + * - a malformed or ambiguously-terminated tool call is never invoked, + * - a healthy call still executes exactly once, + * - an explicit `finish_reason: "length"` still fails closed. + */ + +const tools: NonNullable = [ + { type: "function", function: { name: "os__fs__delete", parameters: { type: "object", properties: {} } } }, +]; + +const parallelTools: NonNullable = [ + { type: "function", function: { name: "os__fs__read", parameters: { type: "object", properties: {} } } }, + { type: "function", function: { name: "os__fs__grep", parameters: { type: "object", properties: {} } } }, +]; + +function sseFrame(obj: Record): string { + return `data: ${JSON.stringify(obj)}\n\n`; +} + +function sseTail(obj: Record): string { + return `data: ${JSON.stringify(obj)}`; +} + +/** Streams one tool call's arguments, then the connection just ends — no + * finish_reason chunk, no `[DONE]`. */ +function eofBody(toolCallArgs: string, toolName = "os__fs__delete"): string { + return ( + sseFrame({ + choices: [ + { + index: 0, + delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: toolName, arguments: "" } }] }, + finish_reason: null, + }, + ], + }) + + sseFrame({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: toolCallArgs } }] }, finish_reason: null }] }) + ); +} + +function parallelEofBody(): string { + return ( + sseFrame({ + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { index: 0, id: "call_a", type: "function", function: { name: "os__fs__read", arguments: "" } }, + { index: 1, id: "call_b", type: "function", function: { name: "os__fs__grep", arguments: "" } }, + ], + }, + finish_reason: null, + }, + ], + }) + + sseFrame({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { index: 0, function: { arguments: '{"path":"a.txt"}' } }, + { index: 1, function: { arguments: '{"path":"b.txt' } }, + ], + }, + finish_reason: null, + }, + ], + }) + ); +} + +function qwenTaggedBody(finishReason: string | null = null): string { + return sseFrame({ + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: "", + }, + finish_reason: finishReason, + }, + ], + }); +} + +function healthyBody(toolCallArgs: string, toolName = "os__fs__delete"): string { + return ( + eofBody(toolCallArgs, toolName) + + sseFrame({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }) + + "data: [DONE]\n\n" + ); +} + +function lengthTerminatedBody(toolCallArgs: string, toolName = "os__fs__delete"): string { + return ( + eofBody(toolCallArgs, toolName) + + sseFrame({ choices: [{ index: 0, delta: {}, finish_reason: "length" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }) + + "data: [DONE]\n\n" + ); +} + +function fetchReturning(body: string) { + return (async () => new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } })) as unknown as typeof fetch; +} + +function fetchErroringMidStream(prefixBody: string) { + return (async () => { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(prefixBody)); + controller.error(new Error("simulated transport read error")); + }, + }); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); + }) as unknown as typeof fetch; +} + +async function drainCompleteStream( + fetchImpl: typeof fetch, + options?: { + taggedToolCompatibility?: "qwen"; + requestTools?: NonNullable; + }, +): Promise { + const provider = new OpenAiProvider({ + id: "test", + baseUrl: "https://example.invalid", + apiKey: "", + defaultChatModel: "m", + fetchImpl, + ...(options?.taggedToolCompatibility + ? { taggedToolCompatibility: options.taggedToolCompatibility } + : {}), + }); + const gen = provider.completeStream({ + prompt: "delete widget.txt", + tools: options?.requestTools ?? tools, + }); + let next = await gen.next(); + while (!next.done) next = await gen.next(); + return next.value; +} + +function makeCaps(): CapabilitiesSummary { + return { platform: "linux", arch: "x64", browserChannel: "chrome", workingDir: "/work", hasClipboard: false, hasWmctrl: false, hasNotifications: false }; +} + +async function runStepThroughLlmComplete( + llmComplete: StepDependencies["llmComplete"], + registerTools: (registry: ToolRegistry) => void, +): Promise<{ outcomeOrError: unknown }> { + const registry = new ToolRegistry(); + registerTools(registry); + registry.register({ + name: "reply", + description: "reply", + readonly: true, + async run(args: Record) { + return compressToolResult({ tool: "reply", status: "ok", output: String(args.text ?? "") }); + }, + }); + + const grammarsDir = join(process.cwd(), "grammars"); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + const session = createEmptySessionState({ id: "s-integrity", workingDir: "/w" }); + const deps: StepDependencies = { + registry, + slotManager: new SlotManager(2), + llmComplete, + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: openAiToolCallAdapter, + supportsSlotAffinity: false, + }; + + let outcomeOrError: unknown; + try { + outcomeOrError = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: makeCaps(), + skillCatalog: [] as SkillCatalogEntry[], + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "delete the widget", + }, + deps, + ); + } catch (err) { + outcomeOrError = err; + } + return { outcomeOrError }; +} + +/** End-to-end: drives the real SSE parser via a fake fetch, then feeds + * the resulting real CompletionResult into the real step executor. */ +async function runStepFromFetch( + fetchImpl: typeof fetch, + registerTools: (registry: ToolRegistry) => void, + options?: { + taggedToolCompatibility?: "qwen"; + requestTools?: NonNullable; + }, +): Promise<{ outcomeOrError: unknown; completion: CompletionResult }> { + const completion = await drainCompleteStream(fetchImpl, options); + const { outcomeOrError } = await runStepThroughLlmComplete(async () => completion, registerTools); + return { outcomeOrError, completion }; +} + +function countingTool(name: string) { + let count = 0; + return { + executions: () => count, + register: (registry: ToolRegistry) => { + registry.register({ + name, + description: "instrumented no-op test tool", + readonly: false, + async run(args: Record) { + count += 1; + return compressToolResult({ tool: name, status: "ok", output: `noop args=${JSON.stringify(args)}` }); + }, + }); + }, + }; +} + +describe("native tool-call execution integrity", () => { + it("1. healthy valid tool call: executions = 1", async () => { + const tool = countingTool("os.fs.delete"); + const { outcomeOrError } = await runStepFromFetch(fetchReturning(healthyBody('{"path":"widget.txt"}')), tool.register); + expect(tool.executions()).toBe(1); + expect(outcomeOrError).not.toBeInstanceOf(Error); + }); + + it("2. malformed JSON + clean terminal (finish_reason: tool_calls): executions = 0", async () => { + const tool = countingTool("os.fs.delete"); + const { outcomeOrError } = await runStepFromFetch(fetchReturning(healthyBody('{"path":"widget.txt')), tool.register); + expect(tool.executions()).toBe(0); + // Routed through the existing one-shot repair path, not a silent {} execute. + expect(outcomeOrError).toBeInstanceOf(Error); + }); + + it("3. malformed JSON + abrupt EOF (no finish_reason, no [DONE]): executions = 0", async () => { + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch(fetchReturning(eofBody('{"path":"widget.txt')), tool.register); + expect(completion.finishReason).toBeNull(); + expect(completion.truncated).toBe(true); // now correctly flagged + expect(tool.executions()).toBe(0); + expect(outcomeOrError).toBeInstanceOf(Error); + }); + + it("4. container-level truncated JSON + abrupt EOF: executions = 0", async () => { + const tool = countingTool("os.shell.run"); + const { outcomeOrError } = await runStepFromFetch( + fetchReturning(eofBody('{"commands":["npm install","npm test"', "os__shell__run")), + tool.register, + ); + expect(tool.executions()).toBe(0); + expect(outcomeOrError).toBeInstanceOf(Error); + }); + + it("5. syntactically COMPLETE JSON but ambiguous EOF: executions = 0", async () => { + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch(fetchReturning(eofBody('{"path":"widget.txt"}')), tool.register); + expect(completion.finishReason).toBeNull(); + expect(completion.truncated).toBe(true); + expect(tool.executions()).toBe(0); + expect(outcomeOrError).toBeInstanceOf(Error); + }); + + it("6. explicit finish_reason: length: executions = 0 (control, unchanged)", async () => { + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch(fetchReturning(lengthTerminatedBody('{"path":"widget.txt"}')), tool.register); + expect(completion.finishReason).toBe("length"); + expect(completion.truncated).toBe(true); + expect(tool.executions()).toBe(0); + expect(outcomeOrError).toBeInstanceOf(Error); + }); + + it("7. parallel calls: provider derives ambiguous EOF and neither call executes", async () => { + const toolA = countingTool("os.fs.read"); + const toolB = countingTool("os.fs.grep"); + const { outcomeOrError, completion } = await runStepFromFetch( + fetchReturning(parallelEofBody()), + (registry) => { + toolA.register(registry); + toolB.register(registry); + }, + { requestTools: parallelTools }, + ); + expect(completion.toolCalls).toHaveLength(2); + expect(completion.finishReason).toBeNull(); + expect(completion.truncated).toBe(true); + expect(completion.stop).toBe(false); + expect(toolA.executions()).toBe(0); + expect(toolB.executions()).toBe(0); + expect(outcomeOrError).toBeInstanceOf(Error); + }); + + it("8. stream read error mid-stream: executions = 0", async () => { + const tool = countingTool("os.fs.delete"); + let threw = false; + try { + await drainCompleteStream(fetchErroringMidStream(eofBody('{"path":"widget.txt'))); + } catch { + threw = true; + } + expect(threw).toBe(true); + expect(tool.executions()).toBe(0); + }); + + it("9. compatibility: finish_reason sent but connection ends without [DONE] must still be trusted as a clean completion", async () => { + // Some OpenAI-compatible providers omit the [DONE] sentinel entirely + // but do send a real finish_reason on the last data chunk. That is a + // trustworthy terminal signal on its own and must NOT be treated as + // ambiguous just because [DONE] never arrived. + const bodyWithFinishReasonButNoDone = + eofBody('{"path":"widget.txt"}') + + sseFrame({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + // deliberately no "data: [DONE]\n\n" appended + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch(fetchReturning(bodyWithFinishReasonButNoDone), tool.register); + expect(completion.finishReason).toBe("tool_calls"); + expect(completion.truncated).toBe(false); + expect(tool.executions()).toBe(1); + expect(outcomeOrError).not.toBeInstanceOf(Error); + }); + + it("10. compatibility: plain text-only response with ambiguous EOF is unaffected by the tool-call fix", async () => { + const textOnlyEofBody = sseFrame({ choices: [{ index: 0, delta: { role: "assistant", content: "hello" }, finish_reason: null }] }); + const completion = await drainCompleteStream(fetchReturning(textOnlyEofBody)); + expect(completion.toolCalls).toBeUndefined(); + expect(completion.truncated).toBe(false); + expect(completion.stop).toBe(true); + }); + + it("11. qwen tagged tool call with ambiguous EOF is fail-closed after adaptation", async () => { + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch( + fetchReturning(qwenTaggedBody()), + tool.register, + { taggedToolCompatibility: "qwen" }, + ); + expect(completion.toolCalls).toHaveLength(1); + expect(completion.finishReason).toBe("tool_calls"); + expect(completion.truncated).toBe(true); + expect(completion.stop).toBe(false); + expect(tool.executions()).toBe(0); + expect(outcomeOrError).toBeInstanceOf(Error); + }); + + it("12. qwen tagged tool call with explicit terminal finish reason still executes", async () => { + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch( + fetchReturning(qwenTaggedBody("stop")), + tool.register, + { taggedToolCompatibility: "qwen" }, + ); + expect(completion.toolCalls).toHaveLength(1); + expect(completion.finishReason).toBe("tool_calls"); + expect(completion.truncated).toBe(false); + expect(completion.stop).toBe(true); + expect(tool.executions()).toBe(1); + expect(outcomeOrError).not.toBeInstanceOf(Error); + }); + + it("13. final finish_reason event without trailing blank line is flushed at EOF", async () => { + const body = + eofBody('{"path":"widget.txt"}') + + sseTail({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch(fetchReturning(body), tool.register); + expect(completion.finishReason).toBe("tool_calls"); + expect(completion.truncated).toBe(false); + expect(completion.stop).toBe(true); + expect(tool.executions()).toBe(1); + expect(outcomeOrError).not.toBeInstanceOf(Error); + }); + + it("14. final [DONE] event without trailing blank line is flushed at EOF", async () => { + const body = eofBody('{"path":"widget.txt"}') + "data: [DONE]"; + const tool = countingTool("os.fs.delete"); + const { outcomeOrError, completion } = await runStepFromFetch(fetchReturning(body), tool.register); + expect(completion.finishReason).toBeNull(); + expect(completion.truncated).toBe(false); + expect(completion.stop).toBe(true); + expect(tool.executions()).toBe(1); + expect(outcomeOrError).not.toBeInstanceOf(Error); + }); +}); diff --git a/src/agent/parallel-tool-calls.integration.test.ts b/src/agent/parallel-tool-calls.integration.test.ts index c9312582..6bbc79da 100644 --- a/src/agent/parallel-tool-calls.integration.test.ts +++ b/src/agent/parallel-tool-calls.integration.test.ts @@ -126,15 +126,23 @@ describe("parallel tool calls — wall-time speedup", () => { "read f3.csv", ]); - // Concrete proof of parallelism: peakInFlight must be > 1. - expect(peakInFlight).toBeGreaterThan(1); - // Soft wall-time bound: a sequential run would take BATCH_SIZE * - // PER_CALL_LATENCY_MS ≥ 320ms. Parallel pure_read fan-out should - // come in well under that. We use 2x the per-call latency as a - // generous CI-friendly threshold (real measured walls are ~85–110ms - // for BATCH_SIZE=4). + // Concrete, timing-independent proof of parallelism: all BATCH_SIZE calls + // were in flight at once. This is the real assertion — it holds no matter + // how slow or contended the machine is. + expect(peakInFlight).toBe(BATCH_SIZE); + + // Wall time is deliberately NOT asserted. A shared CI runner can stall a + // timer for hundreds of milliseconds, which made this test fail for + // reasons unrelated to the code under test. `peakInFlight` above already + // proves the batch ran concurrently, so a wall-clock bound would add no + // signal — only flakiness. Logged instead, to keep the number visible. const sequentialWall = BATCH_SIZE * PER_CALL_LATENCY_MS; - expect(elapsedMs).toBeLessThan(sequentialWall / 2); + if (elapsedMs >= sequentialWall / 2) { + console.warn( + `[perf] parallel batch took ${elapsedMs}ms (sequential would be ` + + `~${sequentialWall}ms) — slow machine, not a correctness failure`, + ); + } }); it("appends N tool_result turns in batch-index order", async () => { diff --git a/src/agent/plan-mode.test.ts b/src/agent/plan-mode.test.ts new file mode 100644 index 00000000..a51442d1 --- /dev/null +++ b/src/agent/plan-mode.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { checkPlanMode } from "./plan-mode.js"; +import { ToolRegistry, type ToolDefinition } from "../tools/tool-registry.js"; + +function tool(name: string, readonly: boolean): ToolDefinition { + return { + name, + description: name, + readonly, + async run() { + return { + tool: name, + status: "ok" as const, + summary: "", + details: {}, + truncated: false, + }; + }, + }; +} + +function registryWith(...tools: ToolDefinition[]): ToolRegistry { + const registry = new ToolRegistry(); + for (const definition of tools) registry.register(definition); + return registry; +} + +describe("checkPlanMode", () => { + const registry = registryWith( + tool("os.fs.read", true), + tool("os.fs.grep", true), + tool("os.web.search", true), + tool("os.fs.write", false), + tool("os.shell.run", false), + tool("os.fs.trash", false), + ); + + it("lets every read-only tool through", () => { + // Most of what planning *is*: the agent has to read the code before + // it can say what it would change. + for (const name of ["os.fs.read", "os.fs.grep", "os.web.search"]) { + expect(checkPlanMode(name, registry).allowed, name).toBe(true); + } + }); + + it("refuses every mutating tool", () => { + for (const name of ["os.fs.write", "os.shell.run", "os.fs.trash"]) { + expect(checkPlanMode(name, registry).allowed, name).toBe(false); + } + }); + + it("never touches the terminal verbs", () => { + // `reply` and `finish` are how the plan reaches the operator. A mode + // whose purpose is to produce a plan cannot block the sentence that + // delivers it — and vetoing a terminal verb vetoes the turn's exit. + const bare = new ToolRegistry(); + for (const name of ["reply", "finish"]) { + expect(checkPlanMode(name, bare).allowed, name).toBe(true); + } + }); + + it("lets an unknown tool through to the executor's own error", () => { + // Answering "not in plan mode" to a typo would send the model + // hunting for a mode switch instead of a spelling mistake. + expect(checkPlanMode("os.fs.raed", registry).allowed).toBe(true); + }); + + it("tells the model what to do instead of just saying no", () => { + // The part that decides whether plan mode works at all. A bare "not + // permitted" reads as a broken tool, and a model that thinks its + // tools are broken retries them. + const refusal = checkPlanMode("os.fs.write", registry).refusal; + expect(refusal?.status).toBe("error"); + expect(refusal?.summary).toContain("plan mode is on"); + expect(refusal?.summary).toContain("os.fs.write"); + expect(refusal?.summary).toContain("reply with the plan"); + expect(refusal?.details).toMatchObject({ plan_mode: true }); + }); +}); diff --git a/src/agent/plan-mode.ts b/src/agent/plan-mode.ts new file mode 100644 index 00000000..29f4beec --- /dev/null +++ b/src/agent/plan-mode.ts @@ -0,0 +1,93 @@ +import type { CompressedToolResult } from "../compressor/result-compressor.js"; +import type { ToolRegistry } from "../tools/tool-registry.js"; + +/** + * Plan mode: the agent may look, but not touch. + * + * Every tool already declares whether it mutates anything — + * `ToolDefinition.readonly` — and until now that flag was surfaced over + * HTTP (`route-capabilities.ts`) and enforced nowhere. This is the + * enforcement. + * + * **Why a gate and not an approval level.** The ladder answers "does + * this need to ask first", and its top and bottom are both wrong for + * planning: level 1 asks about every mutation, which is a stream of + * prompts for work the operator has explicitly said they do not want + * done yet, and answering them all with "no" teaches the model nothing + * except that its tools are broken. Plan mode is a different question — + * "is this the kind of thing we are doing right now" — and the honest + * answer is a refusal the model can read and act on, not a prompt. + * + * **What still runs.** Every read-only tool: the whole `os.fs.read` / + * `grep` / `glob` / `git.*` surface, web search and fetch, memory + * recall, `skill.view`, `tool.view`. That is deliberate and is most of + * what planning *is* — the agent needs to read the code before it can + * say what it would change. + * + * **What never gets gated.** Terminal verbs. `reply` and `finish` are + * how the plan reaches the operator, and a mode whose purpose is to + * produce a plan cannot be allowed to block the sentence that delivers + * it. They are also the only tools whose refusal would strand a turn: + * the loop ends when a terminal verb runs, so vetoing one is vetoing + * the exit. + */ + +/** + * Terminal verbs, which plan mode never touches. Duplicated from the + * executor's own notion of `resourceClass: "terminal"` rather than + * imported, because this module is consulted before a call is + * classified — and because the list being short and explicit is worth + * more here than the indirection would be. + */ +const TERMINAL_TOOLS: ReadonlySet = new Set(["reply", "finish"]); + +export interface PlanModeVerdict { + /** False when the call must not reach the registry. */ + allowed: boolean; + /** The result to fill the call's slot with. Present iff `allowed` is false. */ + refusal?: CompressedToolResult; +} + +/** + * Decide whether `tool` may run while plan mode is on. + * + * A tool the registry does not know is allowed through untouched: the + * step executor has its own unknown-tool path with a better message, + * and answering "not in plan mode" to a typo would send the model + * looking for a mode switch instead of a spelling mistake. + */ +export function checkPlanMode( + tool: string, + registry: Pick, +): PlanModeVerdict { + if (TERMINAL_TOOLS.has(tool)) return { allowed: true }; + // `has` before `get`, because `get` throws for an unknown name. + if (!registry.has(tool)) return { allowed: true }; + if (registry.get(tool).readonly) return { allowed: true }; + return { allowed: false, refusal: refusalFor(tool) }; +} + +/** + * What the model is told. + * + * Three things, in the order they are useful: that the call did not + * happen, why, and what to do instead. The last one is the part that + * decides whether plan mode works — a bare "not permitted" reads as a + * broken tool, and a model that thinks its tools are broken retries + * them. Naming the exit ("say what you would do, then stop") turns the + * refusal into an instruction. + */ +export function refusalFor(tool: string): CompressedToolResult { + return { + tool, + status: "error", + summary: + `plan mode is on, so \`${tool}\` was not run — nothing is being ` + + `changed yet. Keep reading (every read-only tool still works) and ` + + `then reply with the plan: what you would change, where, and in ` + + `what order. The operator switches out of plan mode to let you ` + + `carry it out.`, + details: { plan_mode: true, tool }, + truncated: false, + }; +} diff --git a/src/agent/profile-matrix.test.ts b/src/agent/profile-matrix.test.ts index 823e9590..f53cf338 100644 --- a/src/agent/profile-matrix.test.ts +++ b/src/agent/profile-matrix.test.ts @@ -6,6 +6,7 @@ import { GEMMA4_PROPS, GPT_OSS_PROPS, LLAMA3_PROPS, + NEMOTRON_PROPS, QWEN3_PROPS, } from "../llm/model-profile.fixtures.js"; import { startTestHarness } from "../http/test-harness.js"; @@ -36,6 +37,21 @@ describe("profile matrix", () => { }); }); + it("streams nemotron inline reasoning through the full agent loop", async () => { + // Nemotron shares the qwen think-tags profile (its alias is what needs a + // dedicated detector, not its behaviour): the prompt prefills ``, + // the stream starts mid-body and closes with `` before the array. + // Without the nemotron detection branch this props payload falls through + // to plain-instruct and emits no reasoning at all. + await expectScenario({ + props: NEMOTRON_PROPS, + chunks: [ + 'inner thought[{"tool":"reply","args":{"text":"ok"}}]', + ], + expectReasoning: true, + }); + }); + it("streams gemma 4 channel reasoning through the full agent loop", async () => { // Turn-framed gemma: the model emits its OWN `<|channel>thought\n` opener // (the prompt no longer prefills it), reasons, then closes with diff --git a/src/agent/steer-notice.test.ts b/src/agent/steer-notice.test.ts new file mode 100644 index 00000000..7c2682bc --- /dev/null +++ b/src/agent/steer-notice.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { composeSteerNotice, formatSteerNotice } from "./steer-notice.js"; + +describe("formatSteerNotice", () => { + it("carries the message text verbatim", () => { + const out = formatSteerNotice(["stop and just summarise"]); + expect(out).toContain("stop and just summarise"); + }); + + it("tells the model the message may cancel what it was doing", () => { + const out = formatSteerNotice(["never mind"]); + expect(out).toMatch(/change or cancel/); + }); + + it("pluralises when several arrived in one step", () => { + const out = formatSteerNotice(["one", "two"]); + expect(out).toContain("2 new messages"); + expect(out).toContain("- one"); + expect(out).toContain("- two"); + }); + + it("clips a huge paste and points at the full copy in the transcript", () => { + const out = formatSteerNotice(["x".repeat(5000)]); + expect(out.length).toBeLessThan(1000); + expect(out).toContain("### conversation"); + }); + + it("returns empty for no messages", () => { + expect(formatSteerNotice([])).toBe(""); + }); +}); + +describe("composeSteerNotice", () => { + it("keeps an existing loop-detector notice and appends the steer below it", () => { + const out = composeSteerNotice("### repeat detected: os.fs.read", ["stop"]); + expect(out).toContain("### repeat detected: os.fs.read"); + expect(out).toContain("stop"); + expect(out!.indexOf("repeat detected")).toBeLessThan(out!.indexOf("stop")); + }); + + it("passes the existing notice through untouched when nothing was steered", () => { + expect(composeSteerNotice("loop!", [])).toBe("loop!"); + expect(composeSteerNotice(undefined, [])).toBeUndefined(); + }); + + it("is just the steer block when there was no prior notice", () => { + const out = composeSteerNotice(undefined, ["go left"]); + expect(out).toBe(formatSteerNotice(["go left"])); + }); +}); diff --git a/src/agent/steer-notice.ts b/src/agent/steer-notice.ts new file mode 100644 index 00000000..0b4034fa --- /dev/null +++ b/src/agent/steer-notice.ts @@ -0,0 +1,80 @@ +/** + * Renders mid-turn user messages into the `### notice` block of the next + * step's prompt. + * + * The block is deliberately imperative and deliberately redundant: the + * same text also lands in `### conversation` as a real `user` turn (the + * transcript must not lie about what the operator said), but + * `### conversation` is a long scroll and the models this runtime + * targets are small. `### notice` sits immediately before + * `### respond`, which is the one place a 30B local model reliably + * reads, so the message is repeated there with an instruction attached. + */ + +/** + * Per-message inline cap. A pasted stack trace should not evict the rest + * of the tail from the token budget — past this the model is pointed at + * the full copy in `### conversation`. + */ +const MAX_INLINE_CHARS = 600; + +/** + * Aggregate cap across the whole block. Sixteen backlogged messages at + * the per-message cap would put ~10KB immediately before `### respond` + * and squeeze the conversation out of the token budget; past this the + * remaining messages are counted, not inlined — they are all real user + * turns in `### conversation` either way. + */ +const MAX_BLOCK_CHARS = 2400; + +/** + * Fold `messages` into an existing one-shot notice (the loop detector + * writes to the same slot). The loop-detector text comes first: it + * describes what the model just did wrong, which is context for how to + * act on the new instruction. + */ +export function composeSteerNotice( + existing: string | undefined, + messages: readonly string[], +): string | undefined { + if (messages.length === 0) return existing; + const block = formatSteerNotice(messages); + if (existing === undefined || existing.length === 0) return block; + return `${existing}\n\n${block}`; +} + +/** The steering block on its own, without the loop-detector prefix. */ +export function formatSteerNotice(messages: readonly string[]): string { + if (messages.length === 0) return ""; + const header = + messages.length === 1 + ? "The user sent a new message while you were working. Take it into account before your next action — it may change or cancel what you were doing:" + : `The user sent ${messages.length} new messages while you were working. Take them into account before your next action — they may change or cancel what you were doing:`; + const lines: string[] = []; + let used = 0; + let elided = 0; + for (const m of messages) { + const line = `- ${clip(m)}`; + if (used + line.length > MAX_BLOCK_CHARS && lines.length > 0) { + elided += 1; + continue; + } + used += line.length; + lines.push(line); + } + if (elided > 0) { + lines.push( + `- …and ${elided} more (all shown in full as the latest user turns in ### conversation)`, + ); + } + return `${header}\n${lines.join("\n")}`; +} + +function clip(text: string): string { + const flat = text.trim(); + if (flat.length <= MAX_INLINE_CHARS) return flat; + // Code-point slice, not a UTF-16 slice: a cut through a surrogate + // pair would put mojibake into the prompt. + const points = [...flat].slice(0, MAX_INLINE_CHARS).join(""); + return `${points}… (full text is the last user turn in ### conversation)`; +} diff --git a/src/agent/step-events.ts b/src/agent/step-events.ts index 9cbbcad0..11a9c6be 100644 --- a/src/agent/step-events.ts +++ b/src/agent/step-events.ts @@ -141,6 +141,27 @@ export type StepEvent = /** Canonical trim cause. New reasons may be added over time. */ reason: "approval-gated-batched"; } + /** + * A model-emitted batch larger than `agent.maxParallelToolCalls` was + * mechanically split into bounded waves because every call was + * preflight-validated as registered, argument-schema-valid, and + * classified `pure_read`. No LLM repair round-trip happened — the + * batch executes deterministically in waves of at most `cap`. An + * oversized batch containing any non-`pure_read` call bypasses this + * (and approval trimming) and routes to `parse_retry` instead. + */ + | { + type: "batch_wave_split"; + stepIndex: number; + /** Original batch size the model emitted. Always > cap. */ + originalSize: number; + /** Wave size cap (== agent.maxParallelToolCalls). */ + cap: number; + /** Number of waves: ceil(originalSize / cap). */ + waveCount: number; + /** Start index of each wave in the original call array. */ + boundaries: number[]; + } /** * Terminal error for this step. The `category` follows the canonical * LLM failure taxonomy (see `src/llm/reliability/`) and is always set diff --git a/src/agent/step-executor.test.ts b/src/agent/step-executor.test.ts index f0ef8620..3bb7fde1 100644 --- a/src/agent/step-executor.test.ts +++ b/src/agent/step-executor.test.ts @@ -13,6 +13,7 @@ import { buildGrammar } from "../llm/grammar/build-grammar.js"; import { createEmptySessionState } from "../session/session-state.js"; import { DEFAULT_TOOL_DESCRIPTORS } from "../prompt/tool-descriptors.js"; import { replyTool } from "../tools/conversation/reply.js"; +import { resetConfigCache } from "../config/index.js"; import type { CapabilitiesSummary, SkillCatalogEntry, @@ -1097,6 +1098,250 @@ describe("executeStep batch handling", () => { }); }); +describe("executeStep pure-read wave splitting (#111)", () => { + let grammarsDir: string; + + beforeEach(() => { + grammarsDir = join(process.cwd(), "grammars"); + }); + + // `makeRegistry` in the batch-handling describe is lexically scoped + // there; this describe needs its own registry with the same tools. + function makeRegistry() { + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.read", + description: "read", + readonly: true, + async run(args) { + return compressToolResult({ + tool: "os.fs.read", + status: "ok", + output: `read ${args.path}`, + }); + }, + }); + registry.register({ + name: "os.fs.write", + description: "write", + readonly: false, + async run(args) { + return compressToolResult({ + tool: "os.fs.write", + status: "ok", + output: `wrote ${args.path}`, + }); + }, + }); + registry.register({ + name: "os.fs.edit", + description: "edit", + readonly: false, + async run(args) { + return compressToolResult({ + tool: "os.fs.edit", + status: "ok", + output: `edited ${args.path}`, + }); + }, + }); + registry.register({ + name: "reply", + description: "reply", + readonly: true, + async run(args) { + return compressToolResult({ + tool: "reply", + status: "ok", + output: String(args.text ?? ""), + }); + }, + }); + return registry; + } + + // Default cap is 8 (ENV_DEFAULTS.MAX_PARALLEL_TOOL_CALLS). 14 reads + // is the issue's canonical oversized case → waves of 8 and 6. + function reads(n: number): Array<{ tool: string; args: Record }> { + return Array.from({ length: n }, (_, i) => ({ + tool: "os.fs.read", + args: { path: `f${i}` }, + })); + } + + async function runWithBody( + body: string, + opts?: { + envCap?: number; + repairBody?: string; + extraRegistry?: (reg: ToolRegistry) => void; + }, + ) { + const registry = makeRegistry(); + opts?.extraRegistry?.(registry); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + const session = createEmptySessionState({ id: "s-wave", workingDir: "/w" }); + const events: Array<{ type: string; [k: string]: unknown }> = []; + let llmCalls = 0; + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "x", + }, + { + registry, + slotManager: new SlotManager(2), + llmComplete: async () => { + llmCalls += 1; + return { + content: llmCalls > 1 && opts?.repairBody ? opts.repairBody : body, + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; + }, + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + onEvent: (ev) => { + if ( + ev.type === "batch_wave_split" || + ev.type === "parse_retry" || + ev.type === "batch_trimmed" + ) { + events.push(ev as { type: string; [k: string]: unknown }); + } + }, + }, + ); + return { outcome, events, llmCalls }; + } + + it("wave-splits a 14-read oversized pure-read batch without an LLM repair", async () => { + const { outcome, events, llmCalls } = await runWithBody( + JSON.stringify(reads(14)), + ); + // All 14 executed, correlated by original batch index. + expect(outcome.toolCalls).toHaveLength(14); + expect(outcome.toolResults).toHaveLength(14); + expect(outcome.toolResults.map((r) => r.summary)).toEqual( + Array.from({ length: 14 }, (_, i) => `read f${i}`), + ); + // One `batch_wave_split` event with the full plan; no repair. + const waves = events.filter((e) => e.type === "batch_wave_split"); + const retries = events.filter((e) => e.type === "parse_retry"); + expect(waves).toHaveLength(1); + expect(retries).toHaveLength(0); + expect(llmCalls).toBe(1); + expect(waves[0]).toMatchObject({ + originalSize: 14, + cap: 8, + waveCount: 2, + boundaries: [0, 8], + }); + }); + + it("executes an exact-cap batch in a single wave (no split, no repair)", async () => { + const { outcome, events, llmCalls } = await runWithBody( + JSON.stringify(reads(8)), + ); + expect(outcome.toolResults).toHaveLength(8); + expect(llmCalls).toBe(1); + expect(events.filter((e) => e.type === "batch_wave_split")).toHaveLength(0); + expect(events.filter((e) => e.type === "parse_retry")).toHaveLength(0); + }); + + it("wave-splits into 14 single-call waves when the cap is 1", async () => { + process.env.ATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS = "1"; + resetConfigCache(); + try { + const { outcome, events, llmCalls } = await runWithBody( + JSON.stringify(reads(14)), + ); + expect(outcome.toolResults).toHaveLength(14); + expect(llmCalls).toBe(1); + const waves = events.filter((e) => e.type === "batch_wave_split"); + expect(waves).toHaveLength(1); + expect(waves[0]).toMatchObject({ + originalSize: 14, + cap: 1, + waveCount: 14, + boundaries: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }); + } finally { + delete process.env.ATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS; + resetConfigCache(); + } + }); + + it("routes a schema-invalid oversized pure-read batch to repair (no wave split)", async () => { + // One read carries args that fail the `os.fs.read` JSON schema + // (`path` is required and must be a string). The batch is not + // wave-splittable — preflight fails — so it goes through repair. + const calls = reads(13); + calls.push({ tool: "os.fs.read", args: { path: 123 } }); + const { outcome, events, llmCalls } = await runWithBody( + JSON.stringify(calls), + { repairBody: JSON.stringify(reads(1)) }, + ); + expect(events.filter((e) => e.type === "batch_wave_split")).toHaveLength(0); + expect(events.filter((e) => e.type === "parse_retry")).toHaveLength(1); + expect(llmCalls).toBe(2); + // The repaired response ran; the original 14 never dispatched. + expect(outcome.toolCalls).toHaveLength(1); + expect(outcome.toolResults[0]!.summary).toBe("read f0"); + }); + + it("routes an oversized [approval_gated, pure_read, ...] batch to repair, bypassing approval trim", async () => { + // Explicit issue case: an oversized batch whose first call is + // approval-gated must bypass BOTH wave splitting AND approval + // trimming — parse_retry, no original call dispatched. + const calls = [{ tool: "os.fs.write", args: { path: "a.ts", content: "x" } }]; + calls.push(...reads(13)); + const { outcome, events, llmCalls } = await runWithBody( + JSON.stringify(calls), + { repairBody: JSON.stringify(reads(1)) }, + ); + expect(events.filter((e) => e.type === "batch_wave_split")).toHaveLength(0); + expect(events.filter((e) => e.type === "parse_retry")).toHaveLength(1); + expect(events.filter((e) => e.type === "batch_trimmed")).toHaveLength(0); + expect(llmCalls).toBe(2); + expect(outcome.toolCalls).toHaveLength(1); + expect(outcome.toolCalls[0]!.tool).toBe("os.fs.read"); + }); + + it.each([ + ["terminal mid-batch", [{ tool: "reply", args: { text: "hi" } }], 13], + ["unknown class", [{ tool: "mystery.tool", args: {} }], 13], + ] as const)( + "routes an oversized batch containing %s to repair (no wave split, no trim)", + async (_label, first, rest) => { + const calls = [...first, ...reads(rest)]; + const { outcome, events, llmCalls } = await runWithBody( + JSON.stringify(calls), + { repairBody: JSON.stringify(reads(1)) }, + ); + expect(events.filter((e) => e.type === "batch_wave_split")).toHaveLength(0); + expect(events.filter((e) => e.type === "parse_retry")).toHaveLength(1); + expect(llmCalls).toBe(2); + expect(outcome.toolCalls).toHaveLength(1); + }, + ); +}); + describe("executeStep streaming reasoning accumulator", () => { // Regression for the Fix B side of the "degenerate-loop + empty // reasoningContent" investigation. Before the fix `consumeStream` only @@ -1363,3 +1608,258 @@ describe("executeStep unparseable-completion fallback", () => { ).rejects.toThrow(/tool-call/); }); }); + +describe("parallelToolCalls derivation (issue #104)", () => { + const originalEnv = process.env.ATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS; + + beforeEach(() => { + process.env.ATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS = originalEnv; + resetConfigCache(); + }); + + /** Minimal registry with a single `os.fs.read` tool. */ + function makeRegistry() { + const registry = new ToolRegistry(); + registry.register({ + name: "os.fs.read", + description: "read a file", + readonly: true, + async run(args) { + return compressToolResult({ + tool: "os.fs.read", + status: "ok", + output: `read ${String(args.path)}`, + }); + }, + }); + return registry; + } + + /** + * Run one native_tools step and capture the `LlmStreamParams` the + * executor passes to `llmComplete`. The model emits a single + * `os.fs.read` tool call so the request carries the tools payload. + */ + async function captureStreamParams(deps?: { + supportsParallelTools?: boolean; + maxParallelToolCallsEnv?: string; + }) { + if (deps?.maxParallelToolCallsEnv !== undefined) { + process.env.ATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS = + deps.maxParallelToolCallsEnv; + resetConfigCache(); + } + const registry = makeRegistry(); + const session = createEmptySessionState({ + id: "s-parallel-flag", + workingDir: "/w", + }); + let captured: { parallelToolCalls?: boolean } | null = null; + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "read the file", + }, + { + registry, + slotManager: new SlotManager(2), + llmComplete: async (params) => { + captured = { parallelToolCalls: params.parallelToolCalls }; + return { + content: JSON.stringify([ + { + tool: "os.fs.read", + args: { path: "/w/a.txt" }, + }, + ]), + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: -1, + modelId: "mock", + }; + }, + grammar: "", + profile: PLAIN_INSTRUCT_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + ...(deps?.supportsParallelTools !== undefined + ? { supportsParallelTools: deps.supportsParallelTools } + : {}), + }, + ); + expect(outcome.toolResults[0]?.status).toBe("ok"); + expect(captured).not.toBeNull(); + return captured!; + } + + it("defaults to parallelToolCalls true when the cap > 1 and the provider is capable", async () => { + const captured = await captureStreamParams(); + expect(captured.parallelToolCalls).toBe(true); + }); + + it("sends parallelToolCalls false when maxParallelToolCalls is 1", async () => { + const captured = await captureStreamParams({ + maxParallelToolCallsEnv: "1", + }); + expect(captured.parallelToolCalls).toBe(false); + }); + + it("sends parallelToolCalls false when the provider reports supportsParallelTools false, regardless of cap", async () => { + const captured = await captureStreamParams({ + supportsParallelTools: false, + }); + expect(captured.parallelToolCalls).toBe(false); + }); + + it("keeps parallelToolCalls true when cap > 1 and the provider is capable", async () => { + const captured = await captureStreamParams({ + supportsParallelTools: true, + maxParallelToolCallsEnv: "8", + }); + expect(captured.parallelToolCalls).toBe(true); + }); +}); + + +describe("executeStep raw-network-failure classification", () => { + const grammarsDir = join(process.cwd(), "grammars"); + + async function runFailingStep(thrown: unknown) { + const registry = new ToolRegistry(); + registry.register(replyTool); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + return executeStep( + { + session: createEmptySessionState({ id: "s-net", workingDir: "/w" }), + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry, + slotManager: new SlotManager(2), + llmComplete: async () => { + throw thrown; + }, + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + }, + ); + } + + it("surfaces undici's `fetch failed` as TransportError, not ToolExecutionError", async () => { + // A surface that does not wrap its own errors (MCP streamable-http, + // embeddings, a vendor SDK with its own fetch) throws this shape. + // Filing it as a tool failure both mislabels the turn and stops the + // provider fallback chain from advancing. + const inner = Object.assign( + new Error("connect ECONNREFUSED 127.0.0.1:19091"), + { code: "ECONNREFUSED" }, + ); + const thrown = Object.assign(new TypeError("fetch failed"), { + cause: inner, + }); + await expect(runFailingStep(thrown)).rejects.toMatchObject({ + name: "TransportError", + category: "transport", + }); + }); + + it("still reports a genuine runtime bug as a tool failure", async () => { + await expect( + runFailingStep(new TypeError("x.map is not a function")), + ).rejects.toMatchObject({ name: "ToolExecutionError", category: "tool" }); + }); +}); + + +describe("executeStep empty-completion repair", () => { + const grammarsDir = join(process.cwd(), "grammars"); + + /** + * Runs one grammar-transport step over a scripted list of completion + * bodies: the first is the initial call, the second the repair. + */ + async function runGrammarStep(bodies: string[]) { + const registry = new ToolRegistry(); + registry.register(replyTool); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + let calls = 0; + const outcome = await executeStep( + { + session: createEmptySessionState({ id: "s-empty", workingDir: "/w" }), + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry, + slotManager: new SlotManager(2), + llmComplete: async () => { + const content = bodies[calls] ?? ""; + calls += 1; + return { + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: content.length, + }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; + }, + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + }, + ); + return { outcome, calls }; + } + + it("repairs an empty body instead of ending the turn", async () => { + // ModelError(reason=empty) is the largest failure bucket in + // production (Sentry CLI-2W/2X/2Z/5J/4R, ~500 events). An empty + // grammar body is exactly what the one-shot repair recovers for + // every other malformed completion. + const { outcome, calls } = await runGrammarStep([ + "", + JSON.stringify([{ tool: "reply", args: { text: "recovered" } }]), + ]); + expect(calls).toBe(2); + expect(outcome.toolCalls).toHaveLength(1); + expect(outcome.toolCalls[0]!.tool).toBe("reply"); + expect(outcome.toolResults[0]!.status).toBe("ok"); + }); + + it("still fails with ModelError when the repair is empty too", async () => { + await expect(runGrammarStep(["", ""])).rejects.toMatchObject({ + name: "ModelError", + reason: "empty", + }); + }); +}); diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index 321f6066..98636f3d 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -38,6 +38,8 @@ import { getToolDescriptorByName, isRareToolName, } from "../prompt/tool-descriptors.js"; +import { getDefaultArgsJsonSchema } from "../prompt/default-tool-args-schemas.js"; +import { validateJsonSchemaValue } from "../llm/provider/openai/coerce-json-schema-value.js"; import { buildPrompt } from "../prompt/build-prompt.js"; import type { BuiltPrompt } from "../prompt/build-prompt.js"; import { formatCurrentDate } from "../prompt/current-date.js"; @@ -127,6 +129,11 @@ export type LlmCompleteStream = ( export interface StepDependencies { registry: ToolRegistry; + /** + * Plan mode, read per call rather than captured once — same contract + * as `BatchExecutionContext.isPlanMode`. Absent ⇒ off. + */ + isPlanMode?: () => boolean; slotManager: SlotManager; llmComplete: (params: LlmStreamParams) => Promise; /** @@ -139,12 +146,32 @@ export interface StepDependencies { llmCompleteStream?: LlmCompleteStream; grammar: string; profile: ModelProfile; + /** + * The model's context window when the profile probe cannot supply it. + * + * `profile.contextWindow` comes from llama-server `/props`, so on a + * cloud provider the budget had no window and every window-relative + * decision fell back to a fixed number. Resolved from the model + * catalogue instead — and only when the catalogue actually knows, + * never from a nominal default, because a budget computed against a + * guessed window is worse than one that admits it has none. + */ + contextWindow?: number | null; /** Effective transport for this runtime (grammar vs native OpenAI tools). */ toolTransport: ToolCallTransport; /** Adapter for native_tools; null when grammar-only. */ toolCallAdapter: ToolCallAdapter | null; /** When false, completions use slotId -1 (cloud providers). */ supportsSlotAffinity: boolean; + /** + * Provider capability: whether the active native-tools provider can + * generate parallel tool calls in one response. When false (or the + * configured `agent.maxParallelToolCalls` is 1), the executor asks + * the provider for a single tool call per response by sending + * `parallel_tool_calls: false`. Defaults to `true` for legacy / + * grammar-only wiring. + */ + supportsParallelTools?: boolean; /** * Invoked after every LLM completion (initial call and one-shot parse * retry alike). Used by the agent loop to feed the served `modelId` @@ -239,8 +266,25 @@ export interface StepOutcome { * happened for the trim. */ trimmedBatchNotice?: string; + /** + * Notice text injected into the NEXT step's `transientNotice` when + * `executeStepInner` mechanically split an oversized pure-read batch + * into bounded waves (issue #111). Same lifecycle as + * `trimmedBatchNotice`: set only when the split fires, left undefined + * otherwise so the agent loop does not overwrite a higher-priority + * pending notice. + */ + waveSplitNotice?: string; } +/** + * Most tool calls one emission may run after a wave split. Generous + * enough for any honest fan-out (a repo-wide read, a batch of searches) + * and small enough that a hallucinated array goes back to the model + * instead of hitting the network 120 times. + */ +const MAX_WAVE_SPLIT_CALLS = 32; + /** Validation failure for a multi-call batch (forbidden tool / oversized / unknown). */ export class BatchValidationError extends Error { constructor( @@ -292,6 +336,9 @@ async function executeStepInner( skillCatalog: ctx.skillCatalog, currentDate: formatCurrentDate(new Date()), profile: deps.profile, + ...(deps.contextWindow !== undefined + ? { contextWindow: deps.contextWindow } + : {}), ...(ctx.transientNotice !== undefined ? { transientNotice: ctx.transientNotice } : {}), @@ -379,23 +426,40 @@ async function executeStepInner( // the model may have thought but failed to emit a required tool call, and // the existing repair path can recover with a stricter one-shot prompt. const initialModelFailure = detectModelFailure(completion); - if ( - initialModelFailure !== null && - !isNativeToolsEmptyCompletionHandledByParser( - parseDepsFor(completion, deps), + if (initialModelFailure !== null) { + const initialParseDeps = parseDepsFor(completion, deps); + const repairable = isGrammarEmptyCompletionWorthRepairing( + initialParseDeps, initialModelFailure.reason, - completion, - ) - ) { - deps.logger?.warn("model-side completion defect", { - sessionId: ctx.session.id, - stepIndex: ctx.stepIndex, - reason: initialModelFailure.reason, - }); - throw new ModelError( - initialModelFailure.reason, - initialModelFailure.message, ); + if ( + !repairable && + !isNativeToolsEmptyCompletionHandledByParser( + initialParseDeps, + initialModelFailure.reason, + completion, + ) + ) { + deps.logger?.warn("model-side completion defect", { + sessionId: ctx.session.id, + stepIndex: ctx.stepIndex, + reason: initialModelFailure.reason, + }); + throw new ModelError( + initialModelFailure.reason, + initialModelFailure.message, + ); + } + if (repairable) { + // Fall through to the parser: an empty body fails to parse, which + // routes into the one-shot repair below. The repair's own + // `detectModelFailure` still throws `ModelError` if the second + // completion is empty too, so "twice empty" remains terminal. + deps.logger?.warn("empty completion, repairing once", { + sessionId: ctx.session.id, + stepIndex: ctx.stepIndex, + }); + } } // Notice text injected into the NEXT step's `transientNotice` when @@ -404,6 +468,12 @@ async function executeStepInner( // overwrite a higher-priority pending notice (loop-detector hint). let trimmedBatchNotice: string | undefined; + // Same lifecycle for the wave-split path: when an oversized pure-read + // batch was mechanically split (issue #111), tell the model on the + // next step so it understands its array ran in bounded waves rather + // than all-at-once. + let waveSplitNotice: string | undefined; + /** * Inline helper: if a `BatchValidationError` is purely about * approval-gated tools batched together, trim the batch to the first @@ -417,6 +487,14 @@ async function executeStepInner( batch: ToolCallBatch, error: BatchValidationError, ): { ok: true; batch: ToolCallBatch } | null => { + // An oversized batch is never trim-eligible, even when its only + // per-call reason is approval-gated (e.g. `[os.fs.write, 13 reads]` + // with a cap of 8). Trimming would keep the write solo and silently + // drop the 13 reads the model asked for; the oversized case must go + // through the LLM repair path (or the wave split below) instead. + if (batch.calls.length > getConfig().agent.maxParallelToolCalls) { + return null; + } if (!isApprovalGatedOnlyFailure(error)) return null; const trim = trimBatchToFirstApprovalGated(batch); if (trim === null) return null; @@ -448,6 +526,74 @@ async function executeStepInner( }; }; + /** + * Inline helper: mechanically split an oversized pure-read batch into + * bounded waves (issue #111). Eligibility is strict — the batch must + * be larger than `agent.maxParallelToolCalls` AND every call must + * preflight as registered, argument-schema-valid, and classified + * `pure_read`. A single non-`pure_read` call (approval-gated, + * terminal, unknown class) or a schema-invalid arg kicks the batch + * back to the LLM repair path, because wave-splitting would execute + * calls the runtime is not allowed to batch (consent / ordering / + * semantic intent the model is better placed to reconcile). + */ + const trySplitPureReadWaves = ( + batch: ToolCallBatch, + ): { ok: true; batch: ToolCallBatch } | null => { + const cap = getConfig().agent.maxParallelToolCalls; + const calls = batch.calls; + if (calls.length <= cap) return null; + // A ceiling, because "run it in waves" is not a licence to execute + // an arbitrary array. A model that derails and emits 120 searches + // would otherwise have every one run — 120 live requests and 240 + // transcript turns out of a single hallucinated emission — and the + // loop detector cannot intervene: its gate runs once, before the + // first call of the batch. Past the ceiling the batch goes back to + // the model, which is what an oversized batch did before waves + // existed. + // + // The ceiling counts CALLS, not waves: with a cap of 1 a fan-out of + // fourteen reads is fourteen waves and perfectly reasonable, while + // with a cap of 8 the same wave count would be 112 live requests. + // What matters is how much work one emission can start. + if (calls.length > MAX_WAVE_SPLIT_CALLS) return null; + for (const call of calls) { + if (resourceClassFor(call.tool) !== "pure_read") return null; + if (!callArgsSchemaValid(call, ctx.toolDescriptors)) return null; + } + const waveCount = Math.ceil(calls.length / cap); + const boundaries = Array.from( + { length: waveCount }, + (_, i) => i * cap, + ); + waveSplitNotice = formatWaveSplitNotice(calls.length, cap, waveCount); + deps.onEvent?.({ + type: "batch_wave_split", + stepIndex: ctx.stepIndex, + originalSize: calls.length, + cap, + waveCount, + boundaries, + }); + deps.metrics?.recordBatchWaveSplit({ + sessionId: ctx.session.id, + originalSize: calls.length, + cap, + waveCount, + }); + deps.logger?.info("oversized pure-read batch split into bounded waves", { + sessionId: ctx.session.id, + stepIndex: ctx.stepIndex, + originalSize: calls.length, + cap, + waveCount, + }); + return { + ok: true, + batch: { ...batch, maxWaveSize: cap }, + }; + }; + let parsed = tryParseToolCalls( completion, deps.profile, @@ -456,17 +602,26 @@ async function executeStepInner( if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); if (!validation.ok) { - // Try the cheap mechanical fix first. If the only reason the - // batch failed is "approval-gated tools must be solo", we trim - // to the first approval-gated call and proceed — no LLM repair - // round-trip, no `parse_retry`. Anything else (terminal verbs in - // a batch, oversized, unknown resource class) still routes - // through the model so it can re-plan. - const trimmed = tryTrimApprovalGated(parsed.batch, validation.error); - if (trimmed !== null) { - parsed = trimmed; + // Try the cheap mechanical fixes first, in order: + // 1. Wave split (issue #111): an oversized batch whose calls + // are ALL `pure_read` and schema-valid runs deterministically + // in bounded waves — no LLM repair round-trip. + // 2. Approval-gated trim: a batch whose only failure is + // "approval-gated tools must be solo" (and is NOT oversized) + // trims to the first approval-gated call. + // Anything else (terminal verbs in a batch, oversized mixed + // batches, unknown resource class) still routes through the model + // so it can re-plan. + const split = trySplitPureReadWaves(parsed.batch); + if (split !== null) { + parsed = split; } else { - parsed = { ok: false, error: validation.error }; + const trimmed = tryTrimApprovalGated(parsed.batch, validation.error); + if (trimmed !== null) { + parsed = trimmed; + } else { + parsed = { ok: false, error: validation.error }; + } } } } @@ -573,15 +728,21 @@ async function executeStepInner( if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); if (!validation.ok) { - // Same trim shortcut for the post-repair attempt: if the model - // came back from repair with another approval-gated batch, - // mechanically split it instead of escalating to `GrammarError`. - // Surfaces in the same `batch_trimmed` event/metric. - const trimmed = tryTrimApprovalGated(parsed.batch, validation.error); - if (trimmed !== null) { - parsed = trimmed; + // Same mechanical-fix shortcuts for the post-repair attempt: + // if the model came back from repair with another oversized + // pure-read batch (wave split) or another approval-gated batch + // (trim), fix it mechanically instead of escalating to + // `GrammarError`. Surfaces in the same event/metric pair. + const split = trySplitPureReadWaves(parsed.batch); + if (split !== null) { + parsed = split; } else { - parsed = { ok: false, error: validation.error }; + const trimmed = tryTrimApprovalGated(parsed.batch, validation.error); + if (trimmed !== null) { + parsed = trimmed; + } else { + parsed = { ok: false, error: validation.error }; + } } } } @@ -659,6 +820,8 @@ async function executeStepInner( stepIndex: ctx.stepIndex, signal: ctx.signal, ...(deps.tracker ? { tracker: deps.tracker } : {}), + ...(deps.isPlanMode ? { isPlanMode: deps.isPlanMode } : {}), + ...(batch.maxWaveSize !== undefined ? { maxWaveSize: batch.maxWaveSize } : {}), ...(loadedSkillNames.size > 0 ? { loadedSkillNames } : {}), onCallFinished: ({ batchIndex, result, durationMs }) => { deps.onEvent?.({ @@ -792,6 +955,7 @@ async function executeStepInner( terminal, loopSignals: batchOutcome.loopSignals, ...(trimmedBatchNotice !== undefined ? { trimmedBatchNotice } : {}), + ...(waveSplitNotice !== undefined ? { waveSplitNotice } : {}), }; } @@ -900,6 +1064,36 @@ function isNativeToolsEmptyCompletionHandledByParser( return reasoning.length > 0; } +/** + * Is this an empty completion the one-shot repair should get a crack at? + * + * On the grammar transports an empty body is not the dead end + * `detectModelFailure`'s doc assumes. The prompt is not replayed + * verbatim: the repair path rebuilds it through + * `buildToolCallRepairPrompt` with a corrective notice and a bounded + * token cap, which is a materially different request — and the same + * machinery already recovers every *other* unparseable body (a truncated + * array, a stray prelude, prose where JSON belongs). Only "the model + * emitted literally nothing" was singled out to end the turn outright, + * and that is the single largest failure bucket in production. + * + * `native_tools` is deliberately excluded: that transport has its own + * salvage path (`isNativeToolsEmptyCompletionHandledByParser`), and a + * native completion with nothing in any channel routes through + * `ModelError` by design — see `step-executor.test.ts`, "native_tools: + * routes 'no tool_calls and no content' through ModelError". + * + * `truncated` and `no_stop` are excluded too, and for the original + * reason: the model already spent its budget on this prefix, so a second + * pass hits the same wall. + */ +function isGrammarEmptyCompletionWorthRepairing( + deps: Pick, + reason: string, +): boolean { + return reason === "empty" && deps.toolTransport !== "native_tools"; +} + /** * Effective transport for *parsing a response*. Prefers the transport of * the provider that actually served the completion (`servedTransport`, @@ -1112,7 +1306,7 @@ function buildLlmStreamParams(args: { promptText: string; deps: Pick< StepDependencies, - "grammar" | "toolTransport" | "toolCallAdapter" + "grammar" | "toolTransport" | "toolCallAdapter" | "supportsParallelTools" >; slotId: number; sessionId: string; @@ -1154,7 +1348,16 @@ function buildLlmStreamParams(args: { // that content (see invariant comment there), so the // one-inference-per-step contract is preserved. toolChoice: "auto", - parallelToolCalls: true, + // Ask the provider for a single tool call per response unless the + // executor cap allows more AND the provider reports it can emit + // parallel calls. With `maxParallelToolCalls=1` this is the + // provider-compatibility control: some OpenAI-compatible streams + // (Gemini) lack stable indices for parallel calls, so the setting + // must reach the wire, not just the executor's batch planner + // (issue #104). + parallelToolCalls: + getConfig().agent.maxParallelToolCalls > 1 && + (args.deps.supportsParallelTools ?? true), }; } @@ -1327,6 +1530,47 @@ export function formatBatchTrimNotice(trim: BatchTrimResult): string { ].join(" "); } +/** + * Render the `### notice` text the model sees on the next step after an + * oversized pure-read batch was mechanically split into bounded waves. + * Unlike the trim notice, nothing was dropped — every call ran, just in + * waves of at most `cap` instead of one all-at-once fan-out. The notice + * exists so the model understands the array was honoured in full and + * does not re-emit the calls. + */ +export function formatWaveSplitNotice( + originalSize: number, + cap: number, + waveCount: number, +): string { + return `Your previous emission contained ${originalSize} reads that exceeded the parallel-call cap of ${cap}. The runtime executed all of them in ${waveCount} bounded wave${waveCount === 1 ? "" : "s"} — nothing was dropped. Do not re-emit those calls.`; +} + +/** + * Preflight for wave splitting (issue #111): does the call's `args` + * satisfy the tool's registered JSON schema? The schema is taken from + * the effective descriptor list first (covers dynamic MCP descriptors + * carrying server-supplied `inputSchema`), falling back to the static + * default-args map. A tool with no registered schema passes — there is + * nothing to validate against. An unsupported schema construct fails + * closed (no wave split) so we never execute a call the runtime cannot + * vouch for. + */ +export function callArgsSchemaValid( + call: ToolCallPayload, + descriptors: readonly ToolDescriptor[], +): boolean { + const descriptor = descriptors.find((d) => d.name === call.tool); + const schema = + descriptor?.argsJsonSchema ?? getDefaultArgsJsonSchema(call.tool); + if (!schema) return true; + try { + return validateJsonSchemaValue(call.args, schema); + } catch { + return false; + } +} + /** * Trim a raw completion body to the short preview attached to every * `GrammarError` so postmortems can tell grammar misconfiguration apart @@ -1498,6 +1742,15 @@ function toLlmFailure(err: unknown, ctx: StepContext): LlmFailure { if (categorised === "cancelled") { return new CancelledError(wrapped.message, { cause: err }); } + // A raw socket failure from a surface that does not wrap its own + // errors (MCP streamable-http, embeddings, a vendor SDK carrying its + // own `fetch`) reaches here as a bare `TypeError: fetch failed`. It is + // a provider-boundary problem, not a tool bug: wrapping it as + // `ToolExecutionError("unknown", …)` both mislabels the turn for the + // user and blocks the fallback chain from advancing. + if (categorised === "transport") { + return new TransportError(wrapped.message, null, "", { cause: err }); + } return new ToolExecutionError("unknown", wrapped.message, { cause: err }); } diff --git a/src/analytics/analytics-events.test.ts b/src/analytics/analytics-events.test.ts index 8c5c85c5..466de4eb 100644 --- a/src/analytics/analytics-events.test.ts +++ b/src/analytics/analytics-events.test.ts @@ -4,7 +4,10 @@ import type { AnalyticsClient } from "./analytics-client.js"; import { ANALYTICS_EVENTS, captureAppInstalled, + captureAppOpened, captureMessageSent, + captureModelConfigured, + captureOnboardingStep, } from "./analytics-events.js"; import type { AnalyticsStateStore } from "./analytics-state-store.js"; @@ -18,19 +21,25 @@ function fakeStore(overrides: Partial> = {}) { const state = { appInstalled: overrides.appInstalled ?? false, firstMessage: overrides.firstMessage ?? false, + modelConfigured: overrides.modelConfigured ?? false, }; return { isAppInstalledSent: () => state.appInstalled, isFirstMessageSent: () => state.firstMessage, + isModelConfiguredSent: () => state.modelConfigured, markAppInstalledSent: vi.fn(() => { state.appInstalled = true; }), markFirstMessageSent: vi.fn(() => { state.firstMessage = true; }), + markModelConfiguredSent: vi.fn(() => { + state.modelConfigured = true; + }), } as unknown as AnalyticsStateStore & { markAppInstalledSent: ReturnType; markFirstMessageSent: ReturnType; + markModelConfiguredSent: ReturnType; }; } @@ -200,3 +209,98 @@ describe("captureMessageSent", () => { expect(store.markFirstMessageSent).not.toHaveBeenCalled(); }); }); + +describe("captureAppOpened", () => { + it("fires on every call — it is per-launch, not per-install", () => { + const client = fakeClient(); + captureAppOpened(client); + captureAppOpened(client); + expect(client.capture).toHaveBeenCalledTimes(2); + expect(client.capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.appOpened); + }); + + it("no-ops when analytics is disabled (null client)", () => { + expect(() => captureAppOpened(null)).not.toThrow(); + }); + + it("carries no properties — platform and app_version come from the client", () => { + const client = fakeClient(); + captureAppOpened(client); + expect(client.capture.mock.calls[0]).toHaveLength(1); + }); +}); + +describe("captureOnboardingStep", () => { + it("sends the step name", () => { + const client = fakeClient(); + captureOnboardingStep(client, "choose"); + expect(client.capture).toHaveBeenCalledWith( + ANALYTICS_EVENTS.onboardingStep, + { step: "choose" }, + ); + }); + + it("attaches outcome when given", () => { + const client = fakeClient(); + captureOnboardingStep(client, "finished", "cloud"); + expect(client.capture).toHaveBeenCalledWith( + ANALYTICS_EVENTS.onboardingStep, + { step: "finished", outcome: "cloud" }, + ); + }); + + it("omits the outcome key entirely when not given", () => { + const client = fakeClient(); + captureOnboardingStep(client, "intro"); + const payload = client.capture.mock.calls[0][1]; + expect(payload).not.toHaveProperty("outcome"); + }); + + it("no-ops when analytics is disabled (null client)", () => { + expect(() => captureOnboardingStep(null, "intro")).not.toThrow(); + }); +}); + +describe("captureModelConfigured", () => { + const ctx = { provider: "openrouter", kind: "cloud" } as const; + + it("fires once and marks the flag", () => { + const client = fakeClient(); + const store = fakeStore(); + captureModelConfigured(client, store, ctx); + expect(client.capture).toHaveBeenCalledWith( + ANALYTICS_EVENTS.modelConfigured, + { provider: "openrouter", kind: "cloud" }, + ); + expect(store.markModelConfiguredSent).toHaveBeenCalledTimes(1); + }); + + it("no-ops on a reconfiguration — it marks the transition, not a count", () => { + const client = fakeClient(); + const store = fakeStore(); + captureModelConfigured(client, store, ctx); + captureModelConfigured(client, store, { provider: "llama.cpp", kind: "local" }); + expect(client.capture).toHaveBeenCalledTimes(1); + }); + + it("no-ops when already sent", () => { + const client = fakeClient(); + const store = fakeStore({ modelConfigured: true }); + captureModelConfigured(client, store, ctx); + expect(client.capture).not.toHaveBeenCalled(); + }); + + it("carries no model id, key, or url — only provider and kind", () => { + const client = fakeClient(); + const store = fakeStore(); + captureModelConfigured(client, store, { provider: "llama.cpp", kind: "local" }); + const payload = client.capture.mock.calls[0][1]; + expect(Object.keys(payload).sort()).toEqual(["kind", "provider"]); + }); + + it("no-ops when analytics is disabled (null client)", () => { + const store = fakeStore(); + expect(() => captureModelConfigured(null, store, ctx)).not.toThrow(); + expect(store.markModelConfiguredSent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/analytics/analytics-events.ts b/src/analytics/analytics-events.ts index ea8b128b..5a744bb4 100644 --- a/src/analytics/analytics-events.ts +++ b/src/analytics/analytics-events.ts @@ -4,6 +4,9 @@ import type { AnalyticsStateStore } from "./analytics-state-store.js"; /** Canonical PostHog event names emitted by the runtime. */ export const ANALYTICS_EVENTS = { appInstalled: "app_installed", + appOpened: "app_opened", + onboardingStep: "onboarding_step", + modelConfigured: "model_configured", messageSent: "message_sent", firstMessageSent: "first_message_sent", } as const; @@ -60,6 +63,78 @@ export function captureAppInstalled( store.markAppInstalledSent(); } +/** + * Emit `app_opened` — once per process start, on every launch. + * + * The counterpart to `app_installed`, which fires once per install and + * never again. Without this event an install that was downloaded and + * never launched is indistinguishable from one that launched and got + * stuck, so the two failure modes collapse into a single "never sent a + * message" number that no dashboard can take apart. + * + * Deliberately carries no properties of its own — the client already + * stamps `platform` and `app_version` on every event, which is the + * whole dimension set this event needs. + */ +export function captureAppOpened(client: AnalyticsClient | null): void { + if (!client) return; + client.capture(ANALYTICS_EVENTS.appOpened); +} + +/** + * Emit `onboarding_step` when the first-run flow arrives at `step`. + * + * `step` is the `OnboardingStep` union from the TUI onboarding state + * (`intro` / `choose` / `local_pick` / `cloud` / `finished` / …) — a + * closed vocabulary of screen names, never free text and never + * anything the operator typed. `outcome` is set only on the terminal + * step and reports how the flow ended (`local` / `cloud` / `custom` / + * `skipped`). + * + * This is what turns "55% never activated" into a funnel with a named + * step where people leave. + */ +export function captureOnboardingStep( + client: AnalyticsClient | null, + step: string, + outcome?: string, +): void { + if (!client) return; + client.capture(ANALYTICS_EVENTS.onboardingStep, { + step, + ...(outcome !== undefined ? { outcome } : {}), + }); +} + +/** + * Emit the one-time `model_configured` event: this install has a + * working LLM backend for the first time. + * + * Fires on the first *verified* provider setup — the point where a + * backend answered a probe, not where a key was merely typed in. Guarded + * by the state store so it marks the transition rather than counting + * reconfigurations; a user who later swaps providers does not re-fire it. + * + * Carries `{ provider, kind }` only: the provider id (`openrouter`, + * `llama.cpp`, …) and whether the backend is `local` or `cloud`. Never + * the key, the base URL, or a host — a self-hosted endpoint is part of + * the operator's private infrastructure, so only the shape of the + * choice leaves the machine. + */ +export function captureModelConfigured( + client: AnalyticsClient | null, + store: AnalyticsStateStore, + context: { provider: string; kind: "local" | "cloud" }, +): void { + if (!client) return; + if (store.isModelConfiguredSent()) return; + client.capture(ANALYTICS_EVENTS.modelConfigured, { + provider: context.provider, + kind: context.kind, + }); + store.markModelConfiguredSent(); +} + /** * Emit `message_sent` for every human-originated turn, plus the * one-time `first_message_sent` on the very first message this install diff --git a/src/analytics/analytics-state-store.test.ts b/src/analytics/analytics-state-store.test.ts index 92d497dc..3e65bac3 100644 --- a/src/analytics/analytics-state-store.test.ts +++ b/src/analytics/analytics-state-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -18,11 +18,12 @@ describe("AnalyticsStateStore", () => { rmSync(dir, { recursive: true, force: true }); }); - it("mints an anonymous install id and clears both flags on a fresh install", () => { + it("mints an anonymous install id and clears every flag on a fresh install", () => { const store = new AnalyticsStateStore(file); expect(store.getInstallId()).toMatch(/[0-9a-f-]{36}/); expect(store.isAppInstalledSent()).toBe(false); expect(store.isFirstMessageSent()).toBe(false); + expect(store.isModelConfiguredSent()).toBe(false); }); it("persists the install id across reloads", () => { @@ -36,12 +37,16 @@ describe("AnalyticsStateStore", () => { store.markAppInstalledSent(); store.markAppInstalledSent(); store.markFirstMessageSent(); + store.markModelConfiguredSent(); + store.markModelConfiguredSent(); expect(store.isAppInstalledSent()).toBe(true); expect(store.isFirstMessageSent()).toBe(true); + expect(store.isModelConfiguredSent()).toBe(true); const reloaded = new AnalyticsStateStore(file); expect(reloaded.isAppInstalledSent()).toBe(true); expect(reloaded.isFirstMessageSent()).toBe(true); + expect(reloaded.isModelConfiguredSent()).toBe(true); }); it("stores no machine-derived data — only id + boolean flags", () => { @@ -52,6 +57,28 @@ describe("AnalyticsStateStore", () => { "appInstalledSent", "firstMessageSent", "installId", + "modelConfiguredSent", ]); }); + + it("reads a pre-existing file that predates modelConfiguredSent", () => { + // A file written before `model_configured` existed: the id and the + // two original flags must survive, and the new flag starts false so + // an already-set-up install still reports its next verified save. + const legacyId = "11111111-2222-3333-4444-555555555555"; + writeFileSync( + file, + JSON.stringify({ + installId: legacyId, + appInstalledSent: true, + firstMessageSent: true, + }), + "utf8", + ); + const store = new AnalyticsStateStore(file); + expect(store.getInstallId()).toBe(legacyId); + expect(store.isAppInstalledSent()).toBe(true); + expect(store.isFirstMessageSent()).toBe(true); + expect(store.isModelConfiguredSent()).toBe(false); + }); }); diff --git a/src/analytics/analytics-state-store.ts b/src/analytics/analytics-state-store.ts index 73a3540e..35fd1087 100644 --- a/src/analytics/analytics-state-store.ts +++ b/src/analytics/analytics-state-store.ts @@ -4,7 +4,7 @@ import { dirname } from "node:path"; /** * Persistent, on-disk analytics state kept in `/analytics.json`. - * Holds only an anonymous, randomly-generated install id and two + * Holds only an anonymous, randomly-generated install id and three * "fire once" flags. No IP, hostname, username, or any machine-derived * value is stored here — the id is a bare UUID with no link back to the * user or the device. @@ -16,6 +16,8 @@ export interface AnalyticsState { appInstalledSent: boolean; /** Whether the one-time `first_message_sent` event was already sent. */ firstMessageSent: boolean; + /** Whether the one-time `model_configured` event was already sent. */ + modelConfiguredSent: boolean; } /** @@ -44,6 +46,10 @@ export class AnalyticsStateStore { return this.state.firstMessageSent; } + isModelConfiguredSent(): boolean { + return this.state.modelConfiguredSent; + } + markAppInstalledSent(): void { if (this.state.appInstalledSent) return; this.state.appInstalledSent = true; @@ -56,6 +62,12 @@ export class AnalyticsStateStore { this.persist(); } + markModelConfiguredSent(): void { + if (this.state.modelConfiguredSent) return; + this.state.modelConfiguredSent = true; + this.persist(); + } + private load(): AnalyticsState { try { const raw = readFileSync(this.filePath, "utf8"); @@ -65,6 +77,12 @@ export class AnalyticsStateStore { installId: parsed.installId, appInstalledSent: parsed.appInstalledSent === true, firstMessageSent: parsed.firstMessageSent === true, + // Absent in files written before `model_configured` existed. + // Defaulting to `false` lets an install that is already set up + // emit the event once on its next verified provider save, + // rather than never — the flag means "already reported", and + // an old file has genuinely never reported it. + modelConfiguredSent: parsed.modelConfiguredSent === true, }; } } catch { @@ -74,6 +92,7 @@ export class AnalyticsStateStore { installId: randomUUID(), appInstalledSent: false, firstMessageSent: false, + modelConfiguredSent: false, }; this.state = fresh; this.persist(); diff --git a/src/analytics/index.ts b/src/analytics/index.ts index 3c1f1d0b..ebbba831 100644 --- a/src/analytics/index.ts +++ b/src/analytics/index.ts @@ -13,7 +13,10 @@ export type { export { ANALYTICS_EVENTS, captureAppInstalled, + captureAppOpened, captureMessageSent, + captureModelConfigured, + captureOnboardingStep, } from "./analytics-events.js"; export type { MessageEventContext } from "./analytics-events.js"; export { TurnUsageMeter } from "./turn-usage-meter.js"; diff --git a/src/approval/approval-gate.test.ts b/src/approval/approval-gate.test.ts index a9007608..3fed274f 100644 --- a/src/approval/approval-gate.test.ts +++ b/src/approval/approval-gate.test.ts @@ -6,6 +6,55 @@ import { } from "./approval-gate.js"; describe("ApprovalGate", () => { + it("denyPendingForSession denies only that session's requests, with the reason", async () => { + const emittedIds: string[] = []; + const gate = new ApprovalGate({ + emit: (req) => emittedIds.push(req.approvalId), + }); + const mine = gate.request({ + sessionId: "s-leaving", + tool: "t", + category: "shell", + reason: "r", + }); + const other = gate.request({ + sessionId: "s-staying", + tool: "t", + category: "shell", + reason: "r", + }); + const denied = gate.denyPendingForSession("s-leaving", "operator switched away"); + expect(denied).toBe(1); + const decision = await mine; + expect(decision.approved).toBe(false); + expect(decision.reason).toBe("operator switched away"); + // The other session's request is untouched and still answerable. + expect(gate.pendingCount()).toBe(1); + const stayingId = emittedIds[1] ?? ""; + expect(gate.resolve({ approvalId: stayingId, approved: true })).toBe(true); + await expect(other).resolves.toMatchObject({ approved: true }); + }); + + it("pendingRequestForSession returns that session's parked request only", () => { + const gate = new ApprovalGate({ emit: () => undefined }); + void gate.request({ + sessionId: "s-owner", + tool: "os.fs.write", + category: "fs_write_workspace", + reason: "r", + }); + expect(gate.pendingRequestForSession("s-owner")?.tool).toBe("os.fs.write"); + // No cross-session leak, and no request means null. + expect(gate.pendingRequestForSession("s-other")).toBeNull(); + gate.denyPendingForSession("s-owner", "cleared"); + expect(gate.pendingRequestForSession("s-owner")).toBeNull(); + }); + + it("denyPendingForSession with nothing pending is a counted no-op", () => { + const gate = new ApprovalGate({ emit: () => undefined }); + expect(gate.denyPendingForSession("s-any", "reason")).toBe(0); + }); + it("emits a request and resolves with the host decision", async () => { let capturedId = ""; const gate = new ApprovalGate({ @@ -403,3 +452,91 @@ describe("ApprovalGate", () => { expect(canGrantShape(httpReq)).toBe(false); }); }); + +// Regression: issue #121 — `request()` subscribed to the caller's abort +// signal with `{ once: true }` and never detached. `once` only removes the +// listener when the event actually fires, and on the normal approve/deny +// path it never does. The signal is the turn-lifetime one threaded into +// every gated tool, so a turn with N gated calls left N listeners (each +// closing over its request, including the shell command preview) attached +// until the whole turn was torn down. +describe("ApprovalGate abort-listener lifecycle (issue #121)", () => { + /** An AbortSignal wrapper that counts currently-attached listeners. */ + function countingSignal(): { signal: AbortSignal; live: () => number; abort: () => void } { + const controller = new AbortController(); + const real = controller.signal; + let live = 0; + const proxy = { + get aborted() { + return real.aborted; + }, + addEventListener(type: string, fn: EventListener, opts?: AddEventListenerOptions) { + live += 1; + real.addEventListener(type, fn, opts); + }, + removeEventListener(type: string, fn: EventListener) { + live -= 1; + real.removeEventListener(type, fn); + }, + } as unknown as AbortSignal; + return { signal: proxy, live: () => live, abort: () => controller.abort() }; + } + + it("detaches the abort listener after an approval, across many calls on one signal", async () => { + const { signal, live } = countingSignal(); + const gate = new ApprovalGate({ + emit: (req) => + setImmediate(() => gate.resolve({ approvalId: req.approvalId, approved: true })), + }); + // One turn-lifetime signal, many gated tool calls. + for (let i = 0; i < 20; i += 1) { + const decision = await gate.request( + { sessionId: "s", tool: "os.shell.run", category: "shell", reason: "r", preview: `cmd ${i}` }, + { signal }, + ); + expect(decision.approved).toBe(true); + } + expect(live()).toBe(0); + expect(gate.pendingCount()).toBe(0); + }); + + it("detaches the abort listener after a denial too", async () => { + const { signal, live } = countingSignal(); + const gate = new ApprovalGate({ + emit: (req) => setImmediate(() => gate.reject(req.approvalId, "nope")), + }); + for (let i = 0; i < 5; i += 1) { + const decision = await gate.request( + { sessionId: "s", tool: "os.shell.run", category: "shell", reason: "r" }, + { signal }, + ); + expect(decision.approved).toBe(false); + } + expect(live()).toBe(0); + }); + + it("still rejects when the signal aborts while a request is pending", async () => { + const { signal, abort } = countingSignal(); + const gate = new ApprovalGate({ emit: () => setImmediate(abort) }); + await expect( + gate.request( + { sessionId: "s", tool: "os.shell.run", category: "shell", reason: "r" }, + { signal }, + ), + ).rejects.toThrow(/aborted/); + expect(gate.pendingCount()).toBe(0); + }); + + it("rejects immediately when handed an already-aborted signal", async () => { + const { signal, abort } = countingSignal(); + abort(); + const gate = new ApprovalGate({ emit: () => {} }); + await expect( + gate.request( + { sessionId: "s", tool: "os.shell.run", category: "shell", reason: "r" }, + { signal }, + ), + ).rejects.toThrow(/aborted/); + expect(gate.pendingCount()).toBe(0); + }); +}); diff --git a/src/approval/approval-gate.ts b/src/approval/approval-gate.ts index beba404e..f895b21f 100644 --- a/src/approval/approval-gate.ts +++ b/src/approval/approval-gate.ts @@ -30,6 +30,14 @@ export interface ApprovalRequest { * the shape option is not offered. */ commandShape?: string; + /** + * Absolute path this request would write, when the host may offer to + * retarget it before approving (`[e]` in the TUI). Set only by tools + * whose target is a free choice rather than a file the model just + * read — currently `os.fs.write`. Absent means "no redirect offered", + * and a host that ignores the field behaves exactly as before. + */ + redirectablePath?: string; } /** @@ -49,6 +57,15 @@ export interface ApprovalDecision { reason?: string; /** Session grant to record alongside an approval. Ignored when denied. */ grant?: ApprovalGrantScope; + /** + * Operator-supplied replacement for the request's `redirectablePath`, + * approved along with the call. The gate passes it through untouched: + * it is the *tool* that resolves it, re-categorises it, and decides + * whether the new target needs another prompt — the gate never lets a + * decision widen the scope it was asked about. Ignored when denied, + * and by every tool that did not set `redirectablePath`. + */ + pathOverride?: string; } export type ApprovalEmitter = (request: ApprovalRequest) => void; @@ -72,6 +89,15 @@ export class ApprovalGateError extends Error { interface PendingEntry { resolve: (decision: ApprovalDecision) => void; request: ApprovalRequest; + /** + * Detaches the caller's abort listener. `{ once: true }` only fires — + * and so only self-removes — when the signal actually aborts, which + * never happens on the normal approve/deny path. Without this the + * listener stays attached to a signal that lives for the whole turn, + * so every gated tool call in a turn leaks one listener plus the + * closure over its `request` (which carries the command preview). + */ + detach: () => void; } /** @@ -177,20 +203,26 @@ export class ApprovalGate { const auto = this.autoApproval(request); if (auto) return Promise.resolve({ approvalId, approved: true, reason: auto }); return new Promise((resolve, reject) => { - this.pending.set(approvalId, { resolve, request }); - signal?.addEventListener( - "abort", - () => { - this.pending.delete(approvalId); - reject( - new ApprovalGateError( - "approval aborted before a decision was made", - approvalId, - ), - ); - }, - { once: true }, - ); + const onAbort = (): void => { + this.pending.delete(approvalId); + reject( + new ApprovalGateError( + "approval aborted before a decision was made", + approvalId, + ), + ); + }; + const detach = (): void => { + signal?.removeEventListener("abort", onAbort); + }; + this.pending.set(approvalId, { resolve, request, detach }); + // An already-aborted signal never fires `abort`, so check before + // subscribing rather than hanging until the turn is torn down. + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); this.emitter(request); }); } @@ -227,6 +259,7 @@ export class ApprovalGate { const entry = this.pending.get(decision.approvalId); if (!entry) return false; this.pending.delete(decision.approvalId); + entry.detach(); if (decision.approved && decision.grant) { this.recordGrant(entry.request, decision.grant); } @@ -273,6 +306,47 @@ export class ApprovalGate { return this.resolve({ approvalId, approved: false, reason }); } + /** + * Deny every request `sessionId` has pending, with `reason` as the + * decision reason the blocked tool call reports back to the model. + * + * For hosts whose approval surface stops watching a session while a + * turn keeps running on it — the TUI switching threads mid-turn is the + * case in point. An unresolved request would park that turn forever on + * `await request()`: nobody is left to answer, and the per-session + * FIFO would hold the session busy until process exit. Denying with an + * explicit reason is the same shape as the Telegram bridge's auto-deny + * timeout: the turn continues, the transcript says why. + * + * Returns how many requests were denied so the caller can tell the + * operator what switching away did. + */ + denyPendingForSession(sessionId: string, reason: string): number { + const ids: string[] = []; + for (const [approvalId, entry] of this.pending) { + if (entry.request.sessionId === sessionId) ids.push(approvalId); + } + for (const approvalId of ids) this.reject(approvalId, reason); + return ids.length; + } + + /** + * The request `sessionId` is currently parked on, if any. + * + * For hosts whose approval surface follows the visible session: a + * request raised by an off-screen session is only *pointed at* there + * (answering keys must never act across sessions), so when the + * operator navigates into the owning session the host needs the + * original request back to re-raise the prompt. At most one request + * is pending per session by design, so first match is the match. + */ + pendingRequestForSession(sessionId: string): ApprovalRequest | null { + for (const entry of this.pending.values()) { + if (entry.request.sessionId === sessionId) return entry.request; + } + return null; + } + pendingCount(): number { return this.pending.size; } diff --git a/src/approval/dangerous-tool.ts b/src/approval/dangerous-tool.ts index e6299d5a..cea7bee0 100644 --- a/src/approval/dangerous-tool.ts +++ b/src/approval/dangerous-tool.ts @@ -22,6 +22,26 @@ export interface ApprovalPrompt { affectedResources?: string[]; /** Command binary (argv[0]) for shell requests; unit of a shape grant. */ commandShape?: string; + /** + * Absolute path the host may offer to retarget before approving. See + * `ApprovalRequest.redirectablePath` — set only by `os.fs.write`. + */ + redirectablePath?: string; +} + +/** + * What a survived approval tells the caller. Today that is only the + * operator's retarget, if they used it; a denial throws rather than + * returning, so reaching this value means "approved". + */ +export interface ApprovalOutcome { + /** + * Raw replacement path as typed by the operator, or `undefined` when + * they approved the call as proposed. Unresolved and unvalidated on + * purpose: only the tool knows the working directory to resolve it + * against and what re-categorising it means. + */ + pathOverride?: string; } export class ApprovalDeniedError extends Error { @@ -44,8 +64,8 @@ export async function requireApproval( options: DangerousToolOptions, prompt: ApprovalPrompt, signal: AbortSignal, -): Promise { - if (!options.approvalRequired) return; +): Promise { + if (!options.approvalRequired) return {}; const decision = await options.approvals.request( { sessionId: prompt.sessionId, @@ -59,10 +79,21 @@ export async function requireApproval( ...(prompt.commandShape !== undefined ? { commandShape: prompt.commandShape } : {}), + ...(prompt.redirectablePath !== undefined + ? { redirectablePath: prompt.redirectablePath } + : {}), }, { signal }, ); if (!decision.approved) { throw new ApprovalDeniedError(prompt.tool, decision.reason); } + // A retarget is only meaningful for a request that offered one. A host + // that returns `pathOverride` for a call with no `redirectablePath` is + // answering a question nobody asked, so it is dropped here rather than + // handed to a tool that would not know what to do with it. + if (prompt.redirectablePath === undefined || decision.pathOverride === undefined) { + return {}; + } + return { pathOverride: decision.pathOverride }; } diff --git a/src/approval/index.ts b/src/approval/index.ts index 944964a9..75231b91 100644 --- a/src/approval/index.ts +++ b/src/approval/index.ts @@ -33,4 +33,5 @@ export { export type { DangerousToolOptions, ApprovalPrompt, + ApprovalOutcome, } from "./dangerous-tool.js"; diff --git a/src/channels/telegram/approval-bridge.ts.backup b/src/channels/telegram/approval-bridge.ts.backup new file mode 100644 index 00000000..35e8bced --- /dev/null +++ b/src/channels/telegram/approval-bridge.ts.backup @@ -0,0 +1,384 @@ +import type { ApprovalGate, ApprovalGrantScope, ApprovalRequest } from "../../approval/index.js"; +import { canGrantCategory, canGrantShape, formatApprovalCategory } from "../../approval/index.js"; +import type { StructuredLogger } from "../../tracing/structured-logger.js"; + +import type { TelegramApi } from "./outbound-sender.js"; + +/** + * Default auto-deny window for an approval delivered to Telegram. + * The design memo (`atomic-telegram.md` §6) calls out 8 minutes as + * the only away-from-keyboard policy. Tests inject a smaller value + * via `ApprovalBridgeDeps.timeoutMs` to keep wall-clock cheap. + */ +const APPROVAL_TIMEOUT_MS_DEFAULT = 8 * 60 * 1000; + +/** + * Inline-keyboard `callback_data` prefix. Keeps the wire format + * compact (Telegram caps `callback_data` at 64 bytes) and + * unambiguous: any data string that does not start with this prefix + * belongs to a different feature and is dropped on receipt. + */ +const CALLBACK_PREFIX = "appr:"; + +/** + * Narrow shape of a `callback_query` update consumed by the bridge. + * The grammy adapter projects the real grammy `Context` onto this + * shape; tests fabricate updates directly so they never need to + * import grammy's type tree. + */ +export interface InboundCallbackUpdate { + /** Telegram's id for the callback_query — passed back to `answerCallbackQuery`. */ + id: string; + /** Sender. Used for the owner check. */ + from?: { id: number }; + /** Original message that hosted the inline keyboard. */ + message?: { chat: { id: number }; message_id: number }; + /** Verbatim `callback_data` payload from the inline button. */ + data?: string; +} + +export interface ApprovalBridgeDeps { + api: TelegramApi; + /** + * The bridge only ever resolves the gate; it never requests a new + * approval. `Pick` makes that explicit and + * keeps the test-time mock surface small. + */ + approvals: Pick; + /** + * Owner check applied to every callback_query. `null` is a fail- + * closed configuration: every callback is dropped silently. This + * mirrors the inbound text owner check. + */ + ownerUserId: number | null; + logger: StructuredLogger; + /** Test seam — replaces `setTimeout` with a controllable scheduler. */ + schedule?: (cb: () => void, ms: number) => () => void; + /** Test seam — override the default 8-minute auto-deny window. */ + timeoutMs?: number; +} + +interface PendingState { + chatId: number; + messageId: number; + cancelTimer: () => void; +} + +/** + * Per-channel bridge that turns an `ApprovalRequest` into a 2-button + * inline-keyboard message and routes the operator's reply back to + * `ApprovalGate.resolve`. The bridge owns: + * + * - one outbound message per pending approval (sent via `dispatch`); + * - one auto-deny timer per pending approval (default 8 min); + * - the `callback_query` parser that decodes `appr::y|n|s|a`. + * + * Locked invariants — pinned by the colocated test file: + * + * 1. **Owner-only callbacks.** Non-owner `callback_query` updates are + * dropped silently — no `answerCallbackQuery`, no `resolve`. This + * avoids leaking pairing-shaped signals to a stranger who clicked + * a forwarded button. + * 2. **Single resolution per approvalId.** Whichever path completes + * first (button vs timeout) cancels the other before calling + * `resolve`. A late-arriving stale button is acknowledged ("Already + * resolved.") but never resolves a second time. + * 3. **Send failure auto-denies immediately.** If `sendMessage` + * rejects, the operator cannot reply; the bridge resolves with + * `approved: false, reason: "telegram delivery failed"` so the + * gated turn does not block forever. + * 4. **`cancelAll()` does not resolve the gate.** Channel shutdown + * cancels every timer and clears local state — gate resolution for + * in-flight approvals comes from the caller's own abort signal + * (`runtime.shutdown()` aborts every in-flight turn, which aborts + * the gate via `ApprovalGate`'s `signal` parameter). + * + * Known UX gap (non-functional, deferred to slice 3): + * + * When a turn is aborted *externally* while an approval is pending + * — the canonical case is `/cancel` typed by the operator, which + * triggers the per-turn `AbortController` — the gate clears its own + * `pending` entry via its `signal` listener and rejects the request + * promise. The agent loop unwinds normally. **The bridge, however, + * never sees the abort**: it has no back-channel from the gate. So + * the `pending` entry on this bridge stays alive, the 8-min timer + * keeps running, and the inline-keyboard message lingers in the + * chat until the timer naturally fires. When it does, `gate.resolve` + * returns `false` (gate already cleared), `editFinal` is skipped on + * purpose, and bridge state is cleaned up. So the lingering keyboard + * is a UX wart — *not* a leak (timer eventually fires, frees state), + * *not* a double-resolve (gate dedupes), *not* a state desync (bridge + * tears itself down on fire). If the operator clicks a stale button + * in that window, the spinner toast says "Approved" / "Denied" even + * though the action did not run — also a UX wart. + * + * Fixing it requires a back-channel from `ApprovalGate` to its + * per-session handlers (an "external resolve" subscriber list) or + * per-dispatch abort signals plumbed into the bridge. ~30 LOC, + * non-invasive, scheduled for slice 3 alongside the live-control + * surface that is already going to touch this file. + */ +export class ApprovalBridge { + private readonly deps: ApprovalBridgeDeps; + private readonly pending = new Map(); + private readonly timeoutMs: number; + + constructor(deps: ApprovalBridgeDeps) { + this.deps = deps; + this.timeoutMs = deps.timeoutMs ?? APPROVAL_TIMEOUT_MS_DEFAULT; + } + + /** + * Send the approval prompt for `request` to `chatId`. Caller (the + * inbound handler) is responsible for picking the correct chat — + * the bridge does not own a chat reference itself because a future + * Slack-style channel would resolve the same way (channel id, not + * chat id) and the dispatch shape stays clean. + */ + async dispatch(request: ApprovalRequest, chatId: number): Promise { + if (this.pending.has(request.approvalId)) { + // Defensive: the gate enforces single-pending-per-session, so a + // duplicate approvalId would be a runtime bug. Log and ignore. + this.deps.logger.warn("telegram: duplicate approval dispatch", { + approvalId: request.approvalId, + }); + return; + } + let messageId: number | null = null; + try { + const sent = await this.deps.api.sendMessage( + chatId, + formatApprovalText(request), + { reply_markup: buildKeyboard(request) }, + ); + const id = (sent as { message_id?: number } | null)?.message_id; + if (typeof id !== "number") { + this.deps.logger.warn("telegram: approval message missing message_id", { + approvalId: request.approvalId, + }); + this.deps.approvals.resolve({ + approvalId: request.approvalId, + approved: false, + reason: "telegram delivery failed", + }); + return; + } + messageId = id; + } catch (err) { + this.deps.logger.warn("telegram: failed to send approval prompt", { + approvalId: request.approvalId, + error: err instanceof Error ? err.message : String(err), + }); + this.deps.approvals.resolve({ + approvalId: request.approvalId, + approved: false, + reason: "telegram delivery failed", + }); + return; + } + const cancelTimer = this.scheduleTimeout( + request.approvalId, + chatId, + messageId, + ); + this.pending.set(request.approvalId, { chatId, messageId, cancelTimer }); + } + + /** + * Process one `callback_query` update. Validates owner, parses the + * payload, resolves the gate and edits the original message to + * reflect the decision. Drops malformed or unauthorised callbacks + * silently. + */ + async handleCallback(update: InboundCallbackUpdate): Promise { + const fromId = update.from?.id; + if (typeof fromId !== "number") return; + if (this.deps.ownerUserId === null || fromId !== this.deps.ownerUserId) { + this.deps.logger.warn("telegram: dropping non-owner callback_query", { + fromId, + }); + return; + } + const data = update.data; + if (typeof data !== "string" || !data.startsWith(CALLBACK_PREFIX)) return; + const parts = data.slice(CALLBACK_PREFIX.length).split(":"); + if (parts.length !== 2) return; + const [approvalId, kind] = parts; + if (!approvalId || (kind !== "y" && kind !== "n" && kind !== "s" && kind !== "a")) return; + let approved: boolean; + let grant: ApprovalGrantScope | undefined; + if (kind === "y") { + approved = true; + } else if (kind === "n") { + approved = false; + } else { + // grant buttons: always approve, scope depends on kind + approved = true; + grant = kind === "s" ? "category" : "shape"; + } + + const pending = this.pending.get(approvalId); + if (!pending) { + // Stale button — the timer fired (or the operator clicked twice + // in quick succession). Acknowledge so the spinner dismisses. + await this.acknowledge(update.id, "Already resolved"); + return; + } + + pending.cancelTimer(); + this.pending.delete(approvalId); + + const resolved = this.deps.approvals.resolve({ + approvalId, + approved, + grant, + reason: "telegram", + }); + + const grantLabel = grant === "category" ? " (category granted for session)" : grant === "shape" ? " (shape granted for session)" : ""; + await this.acknowledge(update.id, approved ? `Approved${grantLabel}` : "Denied"); + if (resolved) { + await this.editFinal( + pending.chatId, + pending.messageId, + approved ? "✅ approved" : "❌ denied", + ); + } + } + + /** + * Cancel every outstanding timer and forget every pending state. + * Does NOT call `approvals.resolve` — gate resolution for any + * still-pending approval comes from the caller's own abort signal. + */ + cancelAll(): void { + for (const state of this.pending.values()) { + state.cancelTimer(); + } + this.pending.clear(); + } + + pendingCount(): number { + return this.pending.size; + } + + private scheduleTimeout( + approvalId: string, + chatId: number, + messageId: number, + ): () => void { + const fire = (): void => { + // Re-check pending — a button click may have run between + // schedule and timer fire on a stalled event loop. + if (!this.pending.has(approvalId)) return; + this.pending.delete(approvalId); + const resolved = this.deps.approvals.resolve({ + approvalId, + approved: false, + reason: "timeout", + }); + if (resolved) { + void this.editFinal( + chatId, + messageId, + "⏱ timed out — auto-denied", + ); + } + }; + if (this.deps.schedule) return this.deps.schedule(fire, this.timeoutMs); + const handle = setTimeout(fire, this.timeoutMs); + return () => clearTimeout(handle); + } + + private async acknowledge( + callbackQueryId: string, + text: string, + ): Promise { + if (!this.deps.api.answerCallbackQuery) return; + try { + await this.deps.api.answerCallbackQuery(callbackQueryId, { text }); + } catch (err) { + this.deps.logger.warn("telegram: answerCallbackQuery failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + private async editFinal( + chatId: number, + messageId: number, + text: string, + ): Promise { + if (!this.deps.api.editMessageText) return; + try { + await this.deps.api.editMessageText(chatId, messageId, text, { + reply_markup: { inline_keyboard: [] }, + }); + } catch (err) { + this.deps.logger.warn("telegram: editMessageText failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +function buildKeyboard(request: ApprovalRequest): { + inline_keyboard: Array>; +} { + const rows: Array> = [ + [ + { + text: "✅ Approve", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:y`, + }, + { + text: "❌ Deny", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:n`, + }, + ], + ]; + if (canGrantCategory(request)) { + rows.push([ + { + text: "🔓 Grant category for session", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:s`, + }, + ]); + } + if (canGrantShape(request)) { + rows.push([ + { + text: `🔓 Grant "${request.commandShape}" for session`, + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:a`, + }, + ]); + } + return { inline_keyboard: rows }; +} + +/** + * Render an `ApprovalRequest` as plain text. We deliberately do not + * use Telegram's MarkdownV2 — escaping rules around backticks / + * underscores in tool names and previews are easy to get wrong, and + * a corrupted Markdown payload breaks the prompt. Plain text is + * unambiguous; it just renders without a fixed-width preview block. + */ +function formatApprovalText(req: ApprovalRequest): string { + const lines: string[] = [ + "Approval requested", + `tool: ${req.tool}`, + `kind: ${formatApprovalCategory(req.category)}`, + ]; + if (req.reason) lines.push(`reason: ${req.reason}`); + if (req.preview) { + lines.push("preview:"); + for (const previewLine of req.preview.split("\n")) { + lines.push(` ${previewLine}`); + } + } + if (req.affectedResources && req.affectedResources.length > 0) { + lines.push("resources:"); + for (const r of req.affectedResources) lines.push(`- ${r}`); + } + return lines.join("\n"); +} diff --git a/src/channels/telegram/approval-bridge.ts.orig b/src/channels/telegram/approval-bridge.ts.orig new file mode 100644 index 00000000..35e8bced --- /dev/null +++ b/src/channels/telegram/approval-bridge.ts.orig @@ -0,0 +1,384 @@ +import type { ApprovalGate, ApprovalGrantScope, ApprovalRequest } from "../../approval/index.js"; +import { canGrantCategory, canGrantShape, formatApprovalCategory } from "../../approval/index.js"; +import type { StructuredLogger } from "../../tracing/structured-logger.js"; + +import type { TelegramApi } from "./outbound-sender.js"; + +/** + * Default auto-deny window for an approval delivered to Telegram. + * The design memo (`atomic-telegram.md` §6) calls out 8 minutes as + * the only away-from-keyboard policy. Tests inject a smaller value + * via `ApprovalBridgeDeps.timeoutMs` to keep wall-clock cheap. + */ +const APPROVAL_TIMEOUT_MS_DEFAULT = 8 * 60 * 1000; + +/** + * Inline-keyboard `callback_data` prefix. Keeps the wire format + * compact (Telegram caps `callback_data` at 64 bytes) and + * unambiguous: any data string that does not start with this prefix + * belongs to a different feature and is dropped on receipt. + */ +const CALLBACK_PREFIX = "appr:"; + +/** + * Narrow shape of a `callback_query` update consumed by the bridge. + * The grammy adapter projects the real grammy `Context` onto this + * shape; tests fabricate updates directly so they never need to + * import grammy's type tree. + */ +export interface InboundCallbackUpdate { + /** Telegram's id for the callback_query — passed back to `answerCallbackQuery`. */ + id: string; + /** Sender. Used for the owner check. */ + from?: { id: number }; + /** Original message that hosted the inline keyboard. */ + message?: { chat: { id: number }; message_id: number }; + /** Verbatim `callback_data` payload from the inline button. */ + data?: string; +} + +export interface ApprovalBridgeDeps { + api: TelegramApi; + /** + * The bridge only ever resolves the gate; it never requests a new + * approval. `Pick` makes that explicit and + * keeps the test-time mock surface small. + */ + approvals: Pick; + /** + * Owner check applied to every callback_query. `null` is a fail- + * closed configuration: every callback is dropped silently. This + * mirrors the inbound text owner check. + */ + ownerUserId: number | null; + logger: StructuredLogger; + /** Test seam — replaces `setTimeout` with a controllable scheduler. */ + schedule?: (cb: () => void, ms: number) => () => void; + /** Test seam — override the default 8-minute auto-deny window. */ + timeoutMs?: number; +} + +interface PendingState { + chatId: number; + messageId: number; + cancelTimer: () => void; +} + +/** + * Per-channel bridge that turns an `ApprovalRequest` into a 2-button + * inline-keyboard message and routes the operator's reply back to + * `ApprovalGate.resolve`. The bridge owns: + * + * - one outbound message per pending approval (sent via `dispatch`); + * - one auto-deny timer per pending approval (default 8 min); + * - the `callback_query` parser that decodes `appr::y|n|s|a`. + * + * Locked invariants — pinned by the colocated test file: + * + * 1. **Owner-only callbacks.** Non-owner `callback_query` updates are + * dropped silently — no `answerCallbackQuery`, no `resolve`. This + * avoids leaking pairing-shaped signals to a stranger who clicked + * a forwarded button. + * 2. **Single resolution per approvalId.** Whichever path completes + * first (button vs timeout) cancels the other before calling + * `resolve`. A late-arriving stale button is acknowledged ("Already + * resolved.") but never resolves a second time. + * 3. **Send failure auto-denies immediately.** If `sendMessage` + * rejects, the operator cannot reply; the bridge resolves with + * `approved: false, reason: "telegram delivery failed"` so the + * gated turn does not block forever. + * 4. **`cancelAll()` does not resolve the gate.** Channel shutdown + * cancels every timer and clears local state — gate resolution for + * in-flight approvals comes from the caller's own abort signal + * (`runtime.shutdown()` aborts every in-flight turn, which aborts + * the gate via `ApprovalGate`'s `signal` parameter). + * + * Known UX gap (non-functional, deferred to slice 3): + * + * When a turn is aborted *externally* while an approval is pending + * — the canonical case is `/cancel` typed by the operator, which + * triggers the per-turn `AbortController` — the gate clears its own + * `pending` entry via its `signal` listener and rejects the request + * promise. The agent loop unwinds normally. **The bridge, however, + * never sees the abort**: it has no back-channel from the gate. So + * the `pending` entry on this bridge stays alive, the 8-min timer + * keeps running, and the inline-keyboard message lingers in the + * chat until the timer naturally fires. When it does, `gate.resolve` + * returns `false` (gate already cleared), `editFinal` is skipped on + * purpose, and bridge state is cleaned up. So the lingering keyboard + * is a UX wart — *not* a leak (timer eventually fires, frees state), + * *not* a double-resolve (gate dedupes), *not* a state desync (bridge + * tears itself down on fire). If the operator clicks a stale button + * in that window, the spinner toast says "Approved" / "Denied" even + * though the action did not run — also a UX wart. + * + * Fixing it requires a back-channel from `ApprovalGate` to its + * per-session handlers (an "external resolve" subscriber list) or + * per-dispatch abort signals plumbed into the bridge. ~30 LOC, + * non-invasive, scheduled for slice 3 alongside the live-control + * surface that is already going to touch this file. + */ +export class ApprovalBridge { + private readonly deps: ApprovalBridgeDeps; + private readonly pending = new Map(); + private readonly timeoutMs: number; + + constructor(deps: ApprovalBridgeDeps) { + this.deps = deps; + this.timeoutMs = deps.timeoutMs ?? APPROVAL_TIMEOUT_MS_DEFAULT; + } + + /** + * Send the approval prompt for `request` to `chatId`. Caller (the + * inbound handler) is responsible for picking the correct chat — + * the bridge does not own a chat reference itself because a future + * Slack-style channel would resolve the same way (channel id, not + * chat id) and the dispatch shape stays clean. + */ + async dispatch(request: ApprovalRequest, chatId: number): Promise { + if (this.pending.has(request.approvalId)) { + // Defensive: the gate enforces single-pending-per-session, so a + // duplicate approvalId would be a runtime bug. Log and ignore. + this.deps.logger.warn("telegram: duplicate approval dispatch", { + approvalId: request.approvalId, + }); + return; + } + let messageId: number | null = null; + try { + const sent = await this.deps.api.sendMessage( + chatId, + formatApprovalText(request), + { reply_markup: buildKeyboard(request) }, + ); + const id = (sent as { message_id?: number } | null)?.message_id; + if (typeof id !== "number") { + this.deps.logger.warn("telegram: approval message missing message_id", { + approvalId: request.approvalId, + }); + this.deps.approvals.resolve({ + approvalId: request.approvalId, + approved: false, + reason: "telegram delivery failed", + }); + return; + } + messageId = id; + } catch (err) { + this.deps.logger.warn("telegram: failed to send approval prompt", { + approvalId: request.approvalId, + error: err instanceof Error ? err.message : String(err), + }); + this.deps.approvals.resolve({ + approvalId: request.approvalId, + approved: false, + reason: "telegram delivery failed", + }); + return; + } + const cancelTimer = this.scheduleTimeout( + request.approvalId, + chatId, + messageId, + ); + this.pending.set(request.approvalId, { chatId, messageId, cancelTimer }); + } + + /** + * Process one `callback_query` update. Validates owner, parses the + * payload, resolves the gate and edits the original message to + * reflect the decision. Drops malformed or unauthorised callbacks + * silently. + */ + async handleCallback(update: InboundCallbackUpdate): Promise { + const fromId = update.from?.id; + if (typeof fromId !== "number") return; + if (this.deps.ownerUserId === null || fromId !== this.deps.ownerUserId) { + this.deps.logger.warn("telegram: dropping non-owner callback_query", { + fromId, + }); + return; + } + const data = update.data; + if (typeof data !== "string" || !data.startsWith(CALLBACK_PREFIX)) return; + const parts = data.slice(CALLBACK_PREFIX.length).split(":"); + if (parts.length !== 2) return; + const [approvalId, kind] = parts; + if (!approvalId || (kind !== "y" && kind !== "n" && kind !== "s" && kind !== "a")) return; + let approved: boolean; + let grant: ApprovalGrantScope | undefined; + if (kind === "y") { + approved = true; + } else if (kind === "n") { + approved = false; + } else { + // grant buttons: always approve, scope depends on kind + approved = true; + grant = kind === "s" ? "category" : "shape"; + } + + const pending = this.pending.get(approvalId); + if (!pending) { + // Stale button — the timer fired (or the operator clicked twice + // in quick succession). Acknowledge so the spinner dismisses. + await this.acknowledge(update.id, "Already resolved"); + return; + } + + pending.cancelTimer(); + this.pending.delete(approvalId); + + const resolved = this.deps.approvals.resolve({ + approvalId, + approved, + grant, + reason: "telegram", + }); + + const grantLabel = grant === "category" ? " (category granted for session)" : grant === "shape" ? " (shape granted for session)" : ""; + await this.acknowledge(update.id, approved ? `Approved${grantLabel}` : "Denied"); + if (resolved) { + await this.editFinal( + pending.chatId, + pending.messageId, + approved ? "✅ approved" : "❌ denied", + ); + } + } + + /** + * Cancel every outstanding timer and forget every pending state. + * Does NOT call `approvals.resolve` — gate resolution for any + * still-pending approval comes from the caller's own abort signal. + */ + cancelAll(): void { + for (const state of this.pending.values()) { + state.cancelTimer(); + } + this.pending.clear(); + } + + pendingCount(): number { + return this.pending.size; + } + + private scheduleTimeout( + approvalId: string, + chatId: number, + messageId: number, + ): () => void { + const fire = (): void => { + // Re-check pending — a button click may have run between + // schedule and timer fire on a stalled event loop. + if (!this.pending.has(approvalId)) return; + this.pending.delete(approvalId); + const resolved = this.deps.approvals.resolve({ + approvalId, + approved: false, + reason: "timeout", + }); + if (resolved) { + void this.editFinal( + chatId, + messageId, + "⏱ timed out — auto-denied", + ); + } + }; + if (this.deps.schedule) return this.deps.schedule(fire, this.timeoutMs); + const handle = setTimeout(fire, this.timeoutMs); + return () => clearTimeout(handle); + } + + private async acknowledge( + callbackQueryId: string, + text: string, + ): Promise { + if (!this.deps.api.answerCallbackQuery) return; + try { + await this.deps.api.answerCallbackQuery(callbackQueryId, { text }); + } catch (err) { + this.deps.logger.warn("telegram: answerCallbackQuery failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + private async editFinal( + chatId: number, + messageId: number, + text: string, + ): Promise { + if (!this.deps.api.editMessageText) return; + try { + await this.deps.api.editMessageText(chatId, messageId, text, { + reply_markup: { inline_keyboard: [] }, + }); + } catch (err) { + this.deps.logger.warn("telegram: editMessageText failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +function buildKeyboard(request: ApprovalRequest): { + inline_keyboard: Array>; +} { + const rows: Array> = [ + [ + { + text: "✅ Approve", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:y`, + }, + { + text: "❌ Deny", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:n`, + }, + ], + ]; + if (canGrantCategory(request)) { + rows.push([ + { + text: "🔓 Grant category for session", + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:s`, + }, + ]); + } + if (canGrantShape(request)) { + rows.push([ + { + text: `🔓 Grant "${request.commandShape}" for session`, + callback_data: `${CALLBACK_PREFIX}${request.approvalId}:a`, + }, + ]); + } + return { inline_keyboard: rows }; +} + +/** + * Render an `ApprovalRequest` as plain text. We deliberately do not + * use Telegram's MarkdownV2 — escaping rules around backticks / + * underscores in tool names and previews are easy to get wrong, and + * a corrupted Markdown payload breaks the prompt. Plain text is + * unambiguous; it just renders without a fixed-width preview block. + */ +function formatApprovalText(req: ApprovalRequest): string { + const lines: string[] = [ + "Approval requested", + `tool: ${req.tool}`, + `kind: ${formatApprovalCategory(req.category)}`, + ]; + if (req.reason) lines.push(`reason: ${req.reason}`); + if (req.preview) { + lines.push("preview:"); + for (const previewLine of req.preview.split("\n")) { + lines.push(` ${previewLine}`); + } + } + if (req.affectedResources && req.affectedResources.length > 0) { + lines.push("resources:"); + for (const r of req.affectedResources) lines.push(`- ${r}`); + } + return lines.join("\n"); +} diff --git a/src/channels/telegram/inbound-handler.ts b/src/channels/telegram/inbound-handler.ts index dfcbace5..a5cbf34a 100644 --- a/src/channels/telegram/inbound-handler.ts +++ b/src/channels/telegram/inbound-handler.ts @@ -256,11 +256,23 @@ async function dispatchToRuntime( progress?.start("🤔 Thinking…"); const stopKeepalive = startTypingKeepalive(ctx, chatId); try { - await ctx.runtime.runTurn(session, text, { + const result = await ctx.runtime.runTurn(session, text, { origin: "telegram", signal: controller.signal, eventHook, }); + // A steer accepted for this turn but never delivered: the chat is + // the host here, so tell it rather than dropping the text silently. + if (result.undelivered !== undefined && result.undelivered.length > 0) { + const lines = result.undelivered + .map((t) => `• ${t.length > 120 ? `${t.slice(0, 119)}…` : t}`) + .join("\n"); + await sendText( + ctx, + chatId, + `A message arrived too late for that turn and was not applied:\n${lines}`, + ); + } } catch (err) { failure = { error: err instanceof Error ? err : new Error(String(err)), diff --git a/src/cli/bin-alias.test.ts b/src/cli/bin-alias.test.ts new file mode 100644 index 00000000..71c806ab --- /dev/null +++ b/src/cli/bin-alias.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * `atag` is the short alias for the CLI. It has to be created by every + * install channel, and each channel owns its own file — so dropping it from + * one while editing another is an easy mistake. These assertions are the + * guard against a partially-installed alias. + */ +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function read(relative: string): string { + return readFileSync(resolve(repoRoot, relative), "utf8"); +} + +describe("atag alias", () => { + it("npm exposes it as a bin entry pointing at the CLI entrypoint", () => { + const manifest = JSON.parse(read("package.json")) as { + bin: Record; + }; + expect(manifest.bin.atag).toBe(manifest.bin["atomic-agent"]); + }); + + it("the POSIX installer links it next to the binary", () => { + const script = read("scripts/install.sh"); + expect(script).toContain('link_alias atomic-agent "$INSTALL_DIR/atag"'); + // Relative link target, so moving the install dir does not break it. + expect(script).toContain('ln -sfn "$1" "$2"'); + }); + + it("the Windows installer writes an atag.cmd shim", () => { + const script = read("scripts/install.ps1"); + expect(script).toContain('Join-Path $InstallDir "atag.cmd"'); + // %~dp0 keeps the shim pointed at the binary beside it. + expect(script).toContain('"`"%~dp0atomic-agent.exe`" %*"'); + }); + + it("is documented in the CLI help", () => { + expect(read("src/cli/index.ts")).toContain("atag [options]"); + }); +}); diff --git a/src/cli/config-command.test.ts b/src/cli/config-command.test.ts index 851aee8e..cfe91744 100644 --- a/src/cli/config-command.test.ts +++ b/src/cli/config-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -155,4 +155,274 @@ describe("configCommand", () => { expect(code).toBe(1); expect(stderr).toContain("unknown subcommand: wat"); }); + + it("the set example in --help runs through the real set path", async () => { + // Rot-proofing: extract the example payload straight out of the rendered + // help text and feed it to `config set` for real. If the schema moves and + // the example is left behind (as happened with `"version":1` + `llama`), + // this test fails instead of a user's paste. + await configCommand(["--help"]); + const match = stdout.match(/config set '(\{.*\})'/); + expect(match).not.toBeNull(); + stdout = ""; + const code = await configCommand(["set", match![1]]); + expect(code).toBe(0); + const onDisk = JSON.parse(readFileSync(join(stateDir, "config.json"), "utf8")); + expect(onDisk.version).toBe(USER_CONFIG_VERSION); + expect(onDisk.localModels.url).toBe("http://127.0.0.1:19091"); + }); + + describe("point edits", () => { + /** Seed a hand-written sparse config, as a user who edited the file would have. */ + function seedSparseConfig(tree: Record): void { + writeFileSync( + join(stateDir, "config.json"), + JSON.stringify({ version: USER_CONFIG_VERSION, ...tree }, null, 2), + ); + resetConfigCache(); + } + + it("set writes one key and leaves the rest of the file alone", async () => { + seedSparseConfig({ agent: { maxSteps: 7 } }); + const code = await configCommand(["set", "log.level", "debug"]); + expect(code).toBe(0); + const onDisk = JSON.parse(readFileSync(join(stateDir, "config.json"), "utf8")); + expect(onDisk.log.level).toBe("debug"); + // The pre-existing user value survives untouched... + expect(onDisk.agent.maxSteps).toBe(7); + // ...and the file is NOT expanded with every default in the schema. + // Writing back the defaulted parse output would freeze today's + // defaults into the user's file; only touched keys may appear. + expect(Object.keys(onDisk).sort()).toEqual(["agent", "log", "version"]); + expect(stdout.trim()).toBe('log.level = "debug"'); + }); + + it("set coerces the raw string through the schema rather than guessing types", async () => { + // The command never inspects the value: "false" becomes a boolean and + // "19099" a number purely because the schema's parsers coerce them. + // This is what keeps CLI typing from drifting from the schema. + expect(await configCommand(["set", "localModels.managed.autoUpdate", "false"])).toBe(0); + expect(await configCommand(["set", "localModels.managed.port", "19099"])).toBe(0); + const onDisk = JSON.parse(readFileSync(join(stateDir, "config.json"), "utf8")); + expect(onDisk.localModels.managed.autoUpdate).toBe(false); + expect(onDisk.localModels.managed.port).toBe(19099); + }); + + it("set rejects an unknown key instead of silently writing nothing", async () => { + // The load-bearing test. `parseUserConfigFile` IGNORES unknown keys, + // so without our own check this typo would validate, write a file + // with no such setting, and report success — the command would lie. + seedSparseConfig({ agent: { maxSteps: 7 } }); + const before = readFileSync(join(stateDir, "config.json"), "utf8"); + const code = await configCommand([ + "set", + "localModels.managed.autoUpdte", + "false", + ]); + expect(code).toBe(1); + expect(stderr).toContain("unknown key localModels.managed.autoUpdte"); + expect(stderr).toContain("did you mean localModels.managed.autoUpdate?"); + expect(readFileSync(join(stateDir, "config.json"), "utf8")).toBe(before); + }); + + it("set suggests nothing when no key is close enough", async () => { + // A wrong suggestion points the user at a real but unintended + // setting, so the threshold stays conservative. + const code = await configCommand(["set", "totally.bogus.nonsense", "1"]); + expect(code).toBe(1); + expect(stderr).toContain("unknown key"); + expect(stderr).not.toContain("did you mean"); + }); + + it("set rejects a value the schema refuses, leaving the file untouched", async () => { + seedSparseConfig({ agent: { maxSteps: 7 } }); + const before = readFileSync(join(stateDir, "config.json"), "utf8"); + const code = await configCommand(["set", "agent.maxSteps", "0"]); + expect(code).toBe(1); + // The dotted path comes from ConfigValidationError, not from us. + expect(stderr).toContain("agent.maxSteps"); + expect(readFileSync(join(stateDir, "config.json"), "utf8")).toBe(before); + }); + + it("set rejects a value that is not a complete number", async () => { + // `Number.parseInt` stops at the first character it cannot read, so each + // of these would become a plausible-looking number and be written as a + // success — `60s` silently becoming a 60ms timeout, `1,000` becoming 1. + for (const bad of ["100_000", "60s", "1,000", "10.9", "8080x", "0x10"]) { + seedSparseConfig({ agent: { maxSteps: 7 } }); + const before = readFileSync(join(stateDir, "config.json"), "utf8"); + stderr = ""; + const code = await configCommand(["set", "agent.tokenBudget", bad]); + expect(code, `${bad} should be rejected`).toBe(1); + expect(stderr).toContain("agent.tokenBudget"); + expect(readFileSync(join(stateDir, "config.json"), "utf8")).toBe( + before, + ); + } + }); + + it("set accepts any complete literal whose value is a whole number", async () => { + // The check is on the value, not the spelling. `10.0` and `1e3` are both + // complete literals that name an integer, and `parseInt` already handled + // `10.0` correctly — rejecting them would break configs that worked, and + // this parser runs at every startup. + for (const [input, want] of [ + [" 1000 ", "1000"], + ["10.0", "10"], + ["1e3", "1000"], + ["+5", "5"], + ] as const) { + seedSparseConfig({ agent: { maxSteps: 7 } }); + stdout = ""; + const code = await configCommand(["set", "agent.tokenBudget", input]); + expect(code, `${input} should be accepted`).toBe(0); + expect(stdout).toContain(`agent.tokenBudget = ${want}`); + } + }); + + it("set writes the approval ladder, the one key that matters most", async () => { + // `config set` hands the schema the raw argv string on purpose — + // guessing the type at the CLI would be a second source of truth + // that drifts the moment a field changes type. `parseApprovalLevel` + // took numbers only, which made `agent.approvalLevel` the single + // key the dotted-key editor could not write, and it said so in a + // message that asked for exactly what it had been given: + // `expected an integer between 1 and 5, got "3"`. + for (const level of ["1", "3", "5"] as const) { + seedSparseConfig({ agent: { maxSteps: 7 } }); + stdout = ""; + const code = await configCommand(["set", "agent.approvalLevel", level]); + expect(code, `level ${level} should be accepted`).toBe(0); + expect(stdout).toContain(`agent.approvalLevel = ${level}`); + } + }); + + it("set still refuses an approval level outside the ladder", async () => { + seedSparseConfig({ agent: { maxSteps: 7 } }); + for (const bad of ["0", "6", "2.5", "high"] as const) { + stderr = ""; + const code = await configCommand(["set", "agent.approvalLevel", bad]); + expect(code, `${bad} should be rejected`).toBe(1); + expect(stderr).toContain("agent.approvalLevel"); + } + }); + + it("set rejects an integer too large to round-trip", async () => { + // Past 2^53 the literal is silently stored as a different number. + seedSparseConfig({ agent: { maxSteps: 7 } }); + const code = await configCommand([ + "set", + "agent.tokenBudget", + "9007199254740993", + ]); + expect(code).toBe(1); + expect(stderr).toContain("agent.tokenBudget"); + }); + + it("set rejects a partly-numeric fractional value", async () => { + seedSparseConfig({ agent: { maxSteps: 7 } }); + const code = await configCommand([ + "set", + "memory.retrieve.fts5Threshold", + "0.85xyz", + ]); + expect(code).toBe(1); + expect(stderr).toContain("fts5Threshold"); + }); + + it("set rejects version, which the schema's migration owns", async () => { + const code = await configCommand(["set", "version", "12"]); + expect(code).toBe(1); + expect(stderr).toContain("managed by the config schema"); + }); + + it("set rejects a branch and a list rather than inventing syntax", async () => { + expect(await configCommand(["set", "localModels.managed", "x"])).toBe(1); + expect(stderr).toContain("unknown key localModels.managed"); + stderr = ""; + expect(await configCommand(["set", "projects.roots", "/a"])).toBe(1); + expect(stderr).toContain("is a list"); + }); + + it("set with a key but no value is a usage error, not a get", async () => { + const code = await configCommand(["set", "agent.maxSteps"]); + expect(code).toBe(1); + expect(stderr).toContain("no value given for agent.maxSteps"); + }); + + it("unset restores a key to its default and prunes the emptied block", async () => { + seedSparseConfig({ agent: { maxSteps: 7 } }); + const code = await configCommand(["unset", "agent.maxSteps"]); + expect(code).toBe(0); + expect(stdout.trim()).toBe("agent.maxSteps \u2192 25 (default)"); + const onDisk = JSON.parse(readFileSync(join(stateDir, "config.json"), "utf8")); + // The now-empty `agent` block is pruned rather than left as a husk. + expect(onDisk.agent).toBeUndefined(); + }); + + it("unset works on list keys, which set refuses", async () => { + seedSparseConfig({ projects: { roots: ["/tmp/x"] } }); + expect(await configCommand(["unset", "projects.roots"])).toBe(0); + const onDisk = JSON.parse(readFileSync(join(stateDir, "config.json"), "utf8")); + expect(onDisk.projects).toBeUndefined(); + }); + + it("unset rejects an unknown key", async () => { + expect(await configCommand(["unset", "agent.maxStep"])).toBe(1); + expect(stderr).toContain("unknown key agent.maxStep"); + }); + + it("get prints a single value and rejects unknown keys", async () => { + seedSparseConfig({ agent: { maxSteps: 7 } }); + expect(await configCommand(["get", "agent.maxSteps"])).toBe(0); + expect(stdout.trim()).toBe("7"); + stdout = ""; + expect(await configCommand(["get", "localModels.managed"])).toBe(0); + expect(JSON.parse(stdout).port).toBe(19091); + stdout = ""; + expect(await configCommand(["get", "nope.nope"])).toBe(1); + expect(stderr).toContain("unknown key nope.nope"); + }); + + it("list marks non-default values and masks credential-shaped strings", async () => { + seedSparseConfig({ agent: { maxSteps: 42 } }); + const code = await configCommand(["list"]); + expect(code).toBe(0); + const lines = stdout.split("\n"); + const maxSteps = lines.find((line) => line.startsWith("agent.maxSteps ")); + expect(maxSteps).toContain("= 42"); + expect(maxSteps).toContain("(default 25)"); + // A key left at its default carries no annotation. + expect(lines.find((line) => line.startsWith("log.level "))).not.toContain( + "(default", + ); + // `apiKeyEnv` holds an env var name today, but anything + // credential-shaped is masked before it reaches a terminal. + expect( + lines.find((line) => line.startsWith("web.search.exa.apiKeyEnv ")), + ).toContain("***"); + // A token *count* is not a credential and must stay readable. + expect( + lines.find((line) => line.startsWith("agent.tokenBudget ")), + ).toContain("= 3000"); + }); + + it("path prints the config file location", async () => { + expect(await configCommand(["path"])).toBe(0); + expect(stdout.trim()).toBe(join(stateDir, "config.json")); + }); + + it("the key/value example in --help runs through the real set path", async () => { + // Same rot-proofing as the JSON example above: the documented + // ` ` line is executed, so a renamed key fails here + // rather than in a user's terminal. + await configCommand(["--help"]); + const match = stdout.match(/config set ([a-zA-Z.]+) (\S+)\n/); + expect(match).not.toBeNull(); + stdout = ""; + const code = await configCommand(["set", match![1], match![2]]); + expect(code).toBe(0); + expect(stdout.trim()).toBe(`${match![1]} = ${match![2]}`); + }); + }); }); diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 8c8a020d..4635ba0d 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -1,30 +1,27 @@ +import { existsSync, readFileSync } from "node:fs"; + import { ConfigValidationError, ensureUserConfigFileSync, getConfig, parseUserConfigFile, resetConfigCache, + USER_CONFIG_VERSION, writeUserConfigFileSync, } from "../config/index.js"; - -const HELP = - [ - "atomic-agent config — manage the user config file", - "", - "Location: /config.json (stateDir comes from ATOMIC_AGENT_STATE_DIR", - "or defaults to ~/.atomic-agent).", - "", - "Subcommands:", - " get Print the whole config file as JSON", - " set '' Replace the whole config file with a JSON payload", - "", - "Example:", - " atomic-agent config get", - " atomic-agent config set '{\"version\":1,\"llama\":{\"url\":\"http://127.0.0.1:19091\"},", - " \"log\":{\"level\":\"info\"},", - " \"agent\":{\"tokenBudget\":3000,\"maxSteps\":25,", - " \"toolTimeoutMs\":60000,\"approvalLevel\":1}}'", - ].join("\n") + "\n"; +import { HELP } from "./config-help.js"; +import { + deleteConfigPath, + findConfigLeaf, + formatConfigValue, + isConfigBranch, + isReadOnlyConfigKey, + listConfigLeaves, + readConfigPath, + suggestConfigKey, + writeConfigPath, + writeRawUserConfigFileSync, +} from "../config/config-paths.js"; export async function configCommand(args: string[]): Promise { const sub = args[0]; @@ -35,9 +32,16 @@ export async function configCommand(args: string[]): Promise { try { switch (sub) { case "get": - return handleGet(); + return handleGet(args.slice(1)); case "set": return handleSet(args.slice(1)); + case "unset": + return handleUnset(args.slice(1)); + case "list": + return handleList(); + case "path": + process.stdout.write(`${getConfig().paths.userConfigFile}\n`); + return 0; default: process.stderr.write(`unknown subcommand: ${sub}\n`); process.stderr.write(HELP); @@ -54,19 +58,56 @@ export async function configCommand(args: string[]): Promise { } } -function handleGet(): number { +function handleGet(args: string[]): number { const path = getConfig().paths.userConfigFile; const file = ensureUserConfigFileSync(path); - process.stdout.write(`${JSON.stringify(file, null, 2)}\n`); + if (args.length === 0) { + process.stdout.write(`${JSON.stringify(file, null, 2)}\n`); + return 0; + } + const key = args[0]!; + if (isConfigBranch(key)) { + // A branch has no single value; print the subtree rather than + // refusing, since that is unambiguously what was asked for. + process.stdout.write( + `${JSON.stringify(readConfigPath(file, key), null, 2)}\n`, + ); + return 0; + } + if (!findConfigLeaf(key)) return rejectUnknownKey("get", key); + process.stdout.write(`${formatConfigValue(key, readConfigPath(file, key))}\n`); return 0; } function handleSet(args: string[]): number { if (args.length === 0) { - process.stderr.write("usage: atomic-agent config set ''\n"); + process.stderr.write( + "usage: atomic-agent config set \n" + + " or: atomic-agent config set ''\n", + ); + return 1; + } + // Form discrimination. A leading `{` means the whole-file JSON payload, + // including the case where the shell split one JSON argument across + // several argv entries (`set { "version":40, ... }`), which is why the + // test for that keeps passing. + const first = args[0]!; + if (args.length >= 2 && !first.startsWith("{")) { + return setOneKey(first, args.slice(1).join(" ")); + } + if (!first.startsWith("{")) { + // Single non-JSON argument: a key with no value. Treating this as + // `get` would silently do something other than what was typed. + process.stderr.write( + `config set failed: no value given for ${first}\n` + + `usage: atomic-agent config set ${first} \n`, + ); return 1; } - const raw = args.join(" "); + return setWholeFile(args.join(" ")); +} + +function setWholeFile(raw: string): number { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -75,6 +116,20 @@ function handleSet(args: string[]): number { process.stderr.write(`config set failed: invalid JSON: ${message}\n`); return 1; } + // Reading a version from the future is right for a file on disk — some + // newer build wrote it. Typing one here is not: nothing understands it, + // the write path will not let a later `config set` lower it again, and + // the file would sit permanently above every future schema, skipping + // every migration. + if ( + typeof (parsed as { version?: unknown } | null)?.version === "number" && + (parsed as { version: number }).version > USER_CONFIG_VERSION + ) { + process.stderr.write( + `config set failed: version ${(parsed as { version: number }).version} is newer than this build understands (${USER_CONFIG_VERSION})\n`, + ); + return 1; + } const next = parseUserConfigFile(parsed); const path = getConfig().paths.userConfigFile; writeUserConfigFileSync(path, next); @@ -82,3 +137,140 @@ function handleSet(args: string[]): number { process.stdout.write(`wrote ${path}\n`); return 0; } + +function setOneKey(key: string, value: string): number { + const leaf = findConfigLeaf(key); + if (!leaf) return rejectUnknownKey("set", key); + if (isReadOnlyConfigKey(key)) { + process.stderr.write( + `config set failed: ${key} is managed by the config schema and cannot be set by hand\n`, + ); + return 1; + } + if (leaf.isArray) { + process.stderr.write( + `config set failed: ${key} is a list; set it with the whole-file JSON form\n` + + ` atomic-agent config set '{"version":${USER_CONFIG_VERSION}, ...}'\n`, + ); + return 1; + } + const path = getConfig().paths.userConfigFile; + const tree = readRawConfigTree(path); + // The raw string goes in as-is: `parseUserConfigFile` coerces it to the + // declared type ("false" → false, "40" → 40) and enforces bounds and + // enums. Guessing the type here would be a second source of truth that + // drifts from the schema the moment a field changes type. + writeConfigPath(tree, key, value); + // Validate first, write second: `parseUserConfigFile` throws on a bad + // value, so a rejected `set` leaves the file untouched. + const next = parseUserConfigFile(tree); + // Store the *coerced* value the schema produced ("false" → false, + // "40" → 40) rather than the raw string. Both reload identically, since + // the schema coerces on every read, but a config file should hold + // JSON-typed values — anything else is a surprise to whoever opens it + // next. Only this one key is taken from the parse output; the rest of + // the tree stays exactly as it was on disk, so the file does not get + // expanded with every default (see `writeRawUserConfigFileSync`). + writeConfigPath(tree, key, readConfigPath(next, key)); + writeRawUserConfigFileSync(path, tree); + resetConfigCache(); + process.stdout.write( + `${key} = ${formatConfigValue(key, readConfigPath(next, key))}\n`, + ); + return 0; +} + +function handleUnset(args: string[]): number { + if (args.length === 0) { + process.stderr.write("usage: atomic-agent config unset \n"); + return 1; + } + const key = args[0]!; + const leaf = findConfigLeaf(key); + if (!leaf) return rejectUnknownKey("unset", key); + if (isReadOnlyConfigKey(key)) { + process.stderr.write( + `config unset failed: ${key} is managed by the config schema\n`, + ); + return 1; + } + const path = getConfig().paths.userConfigFile; + const tree = readRawConfigTree(path); + deleteConfigPath(tree, key); + const next = parseUserConfigFile(tree); + writeRawUserConfigFileSync(path, tree); + resetConfigCache(); + process.stdout.write( + `${key} → ${formatConfigValue(key, readConfigPath(next, key))} (default)\n`, + ); + return 0; +} + +function handleList(): number { + const file = ensureUserConfigFileSync(getConfig().paths.userConfigFile); + const rows = listConfigLeaves().map((leaf) => { + const actual = readConfigPath(file, leaf.key); + const rendered = `${leaf.key} = ${formatConfigValue(leaf.key, actual)}`; + const isDefault = + JSON.stringify(actual) === JSON.stringify(leaf.defaultValue); + return { rendered, isDefault, leaf }; + }); + // Align the `(default …)` notes with each other, not with all 139 rows: + // padding to the widest key in the whole config would strand the notes + // far off to the right of the handful of lines that carry them. + const annotated = rows.filter((row) => !row.isDefault); + const width = annotated.length + ? Math.max(...annotated.map((row) => row.rendered.length)) + : 0; + for (const row of rows) { + if (row.isDefault) { + process.stdout.write(`${row.rendered}\n`); + continue; + } + const shown = formatConfigValue(row.leaf.key, row.leaf.defaultValue); + process.stdout.write( + `${row.rendered.padEnd(width)} (default ${shown})\n`, + ); + } + return 0; +} + +/** + * Reject a key the schema does not define. + * + * This check is the whole reason `set` does not simply hand the tree to + * the schema: `parseUserConfigFile` *ignores* unknown keys, so a typo + * would validate, write a file without the setting, and report success. + */ +function rejectUnknownKey(sub: string, key: string): number { + const suggestion = suggestConfigKey(key); + const hint = suggestion ? ` (did you mean ${suggestion}?)` : ""; + process.stderr.write(`config ${sub} failed: unknown key ${key}${hint}\n`); + process.stderr.write("run `atomic-agent config list` to see every key\n"); + return 1; +} + +/** + * Read the config file as a plain JSON tree, without filling in defaults. + * + * Deliberately not `ensureUserConfigFileSync`: that returns the fully + * defaulted config, so writing it back would freeze today's 139 defaults + * into the user's file and silently pin them against future schema + * changes. A point edit must leave the rest of the file byte-for-byte + * alone, which means starting from what is actually on disk. + */ +function readRawConfigTree(path: string): Record { + if (!existsSync(path)) return {}; + const text = readFileSync(path, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new ConfigValidationError("", `${path} is not valid JSON: ${message}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ConfigValidationError("", `${path} is not a JSON object`); + } + return parsed as Record; +} diff --git a/src/cli/config-help.ts b/src/cli/config-help.ts new file mode 100644 index 00000000..afacb663 --- /dev/null +++ b/src/cli/config-help.ts @@ -0,0 +1,57 @@ +import { USER_CONFIG_VERSION } from "../config/index.js"; + +/** + * Help text for `atomic-agent config`, kept beside the command rather + * than inside it so `config-command.ts` stays within the 300-line limit + * and holds only command behaviour. + */ + +/** + * Copy-pasteable `config set` payload, assembled from the live schema + * constants so the help text cannot drift from what `parseUserConfigFile` + * accepts (the previous hand-written example carried `"version":1` and a + * `llama` key, both long dead). `config-command.test.ts` extracts this + * exact line from the rendered help and runs it through the real `set` + * path. + */ +const CONFIG_SET_EXAMPLE = JSON.stringify({ + version: USER_CONFIG_VERSION, + localModels: { url: "http://127.0.0.1:19091" }, + log: { level: "info" }, +}); + +/** + * Key/value example, also extracted and executed by the test suite for + * the same rot-proofing reason as `CONFIG_SET_EXAMPLE`. + */ +const CONFIG_SET_KEY_EXAMPLE = "agent.maxSteps 40"; + +export const HELP = + [ + "atomic-agent config — manage the user config file", + "", + "Location: /config.json (stateDir comes from ATOMIC_AGENT_STATE_DIR", + "or defaults to ~/.atomic-agent).", + "", + "Subcommands:", + " get Print the whole config file as JSON", + " get Print one value by dotted key", + " set Set one value, leaving the rest of the file alone", + " set '' Replace the whole config file with a JSON payload", + " unset Restore one key to its default", + " list Print every key as `key = value`", + " path Print the path to the config file", + "", + "Values are typed by the config schema, so `false`, `40` and `info` are", + "written as boolean, number and string respectively; bounds and enums are", + "enforced before anything is written. Keys left out of a whole-file JSON", + "payload are filled with their defaults.", + "", + "List-valued keys (for example projects.roots) have no single-value spelling —", + "set those with the whole-file JSON form. `unset` works on them.", + "", + "Example:", + " atomic-agent config get", + ` atomic-agent config set ${CONFIG_SET_KEY_EXAMPLE}`, + ` atomic-agent config set '${CONFIG_SET_EXAMPLE}'`, + ].join("\n") + "\n"; diff --git a/src/cli/debug-repl.test.ts b/src/cli/debug-repl.test.ts new file mode 100644 index 00000000..327d3c8c --- /dev/null +++ b/src/cli/debug-repl.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; + +import { debugReplCommand, REPL_HELP } from "./debug-repl.js"; + +describe("debugReplCommand --help", () => { + it("prints usage and resolves without opening the interactive prompt", async () => { + // Under vitest stdin is a pipe that never closes, so if --help fell + // through to the readline loop (as it did before) this await would + // hang until the test timeout instead of resolving. + let stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + return true; + }); + try { + const code = await debugReplCommand(["--help"]); + expect(code).toBe(0); + expect(stdout).toBe(REPL_HELP); + expect(stdout).toContain("not yet implemented"); + expect(stdout).not.toContain("atomic-agent> "); + } finally { + vi.restoreAllMocks(); + } + }); + + it("-h behaves the same as --help", async () => { + let stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdout += typeof chunk === "string" ? chunk : String(chunk); + return true; + }); + try { + const code = await debugReplCommand(["-h"]); + expect(code).toBe(0); + expect(stdout).toBe(REPL_HELP); + } finally { + vi.restoreAllMocks(); + } + }); +}); diff --git a/src/cli/debug-repl.ts b/src/cli/debug-repl.ts index daa67b3b..a4df8604 100644 --- a/src/cli/debug-repl.ts +++ b/src/cli/debug-repl.ts @@ -1,11 +1,27 @@ import { createInterface } from "node:readline"; +export const REPL_HELP = + [ + "atomic-agent repl — interactive debug scaffold (not yet implemented)", + "", + "Currently a stub: only 'help' and 'quit' work inside. The real", + "step-the-agent-manually REPL lands with a later milestone, and the", + "command is hidden from `atomic-agent --help` until then.", + ].join("\n") + "\n"; + /** * Interactive REPL to step the agent manually. The real implementation is * wired up once the agent loop (M4) and retrieval (M6) land. For M1 we * provide a minimal line-reader so the binary has a stable command surface. */ -export async function debugReplCommand(_args: string[]): Promise { +export async function debugReplCommand(args: string[]): Promise { + // Answer --help before touching readline: opening the interface grabs + // stdin, so a help request used to drop the user into the (empty) + // interactive prompt instead of printing anything. + if (args.includes("--help") || args.includes("-h")) { + process.stdout.write(REPL_HELP); + return 0; + } const rl = createInterface({ input: process.stdin, output: process.stdout }); rl.setPrompt("atomic-agent> "); process.stdout.write( diff --git a/src/cli/index.ts b/src/cli/index.ts index 2001b9d1..e706f2eb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,12 +10,46 @@ import { traceCommand } from "./trace-command.js"; import { taskCommand } from "./task-command.js"; import { modelsCommand } from "./models-command.js"; import { importCommand } from "./import-command.js"; +import { uninstallCommand } from "./uninstall-command.js"; +import { updateCommand } from "./update-command.js"; import { tuiCommand } from "../tui/index.js"; +import { getAppVersion } from "../version.js"; interface CommandDescriptor { name: string; summary: string; + /** + * Resolves to the process exit code: + * + * 0 success + * 1 operational failure — the command was invoked correctly and the + * work did not succeed. A lookup miss ("no such skill") is a + * failure, not a usage error. + * 2 usage error — unknown command or subcommand, missing required + * argument, argument of the wrong kind. Nothing was attempted. + * + * `run`, `skill` and the dispatcher below implement this split. The + * rest of the table does not, and a caller must not read their codes + * through it: + * + * - `config`, `serve`, `trace`, `task`, `models`, `import` predate + * the split and return `1` for usage errors too, so their `1` does + * not mean "the work failed". + * - `trace replay` returns `2` for "stable-prefix drift detected", a + * diff-style result code rather than a usage error. + * - `tui` reports `0` or `1` from its own session, and when it + * relaunches itself it passes the child process's status straight + * through, so any code is possible (130 on SIGINT, say). + * - `repl` is a scaffold and always returns `0`. + * + * Widening the split to those commands is a separate change. + */ run: (args: string[]) => Promise; + /** + * Omit the command from `--help` while keeping it dispatchable when + * typed. Used for scaffolds that are not ready to be advertised. + */ + hidden?: boolean; } const COMMANDS: CommandDescriptor[] = [ @@ -38,6 +72,9 @@ const COMMANDS: CommandDescriptor[] = [ name: "repl", summary: "Interactive debug REPL: step the agent manually", run: debugReplCommand, + // Still a stub (help/quit only) — dispatchable if typed, but not + // advertised until the real implementation lands. + hidden: true, }, { name: "tui", @@ -62,7 +99,7 @@ const COMMANDS: CommandDescriptor[] = [ { name: "models", summary: - "Manage the local-LLM runtime + GGUF models (list|pull|use|status|start|stop|update|remove)", + "Manage the local-LLM runtime + GGUF models (list|pull|use|status|...) and search cloud models (search)", run: modelsCommand, }, { @@ -70,6 +107,20 @@ const COMMANDS: CommandDescriptor[] = [ summary: "Import conversation history + cron jobs from another agent (hermes)", run: importCommand, }, + { + name: "update", + summary: "Self-update the installed binary from GitHub Releases (--check to probe only)", + run: updateCommand, + }, + { + // Last, and last on purpose: the help listing is read top to bottom, + // and the one entry that destroys data belongs at the bottom of it + // rather than next to `update`, which it otherwise rhymes with. + name: "uninstall", + summary: + "Remove atomic-agent and all of its data from this machine (--dry-run to preview)", + run: uninstallCommand, + }, ]; function printHelp(): void { @@ -78,9 +129,12 @@ function printHelp(): void { "", "Usage:", " atomic-agent [options]", + " atag [options] (short alias, same binary)", "", "Commands:", - ...COMMANDS.map((c) => ` ${c.name.padEnd(8)} ${c.summary}`), + ...COMMANDS.filter((c) => !c.hidden).map( + (c) => ` ${c.name.padEnd(9)} ${c.summary}`, + ), "", "User config (edit via `atomic-agent config`):", " /config.json localModels.url, localModels.mode, log.level, agent.{tokenBudget,maxSteps,toolTimeoutMs,approvalLevel}", @@ -121,6 +175,25 @@ async function main(): Promise { printHelp(); return 0; } + if (command === "-v" || command === "--version" || command === "version") { + process.stdout.write(`atomic-agent ${getAppVersion()}\n`); + return 0; + } + // `help ` reads as naturally as ` --help`; alias one to the other. + if (command === "help") { + const target = rest[0]; + if (!target) { + printHelp(); + return 0; + } + const aliased = COMMANDS.find((c) => c.name === target); + if (!aliased) { + process.stderr.write(`unknown command: ${target}\n`); + printHelp(); + return 2; + } + return aliased.run(["--help"]); + } if (!command) { return tuiCommand([]); } diff --git a/src/cli/models-command.test.ts b/src/cli/models-command.test.ts index ad44a087..1c2f27f7 100644 --- a/src/cli/models-command.test.ts +++ b/src/cli/models-command.test.ts @@ -8,8 +8,32 @@ import { getUserConfigPath, writeUserConfigFileSync } from "../config/config-fil import { resetConfigCache, getConfig } from "../config/index.js"; import { USER_CONFIG_DEFAULTS } from "../config/config-schema.js"; +import type { LocalModelDef } from "../local-llm/index.js"; + import { modelsCommand } from "./models-command.js"; +/** + * A complete def (not the four-line hand-written shape) because + * `writeUserConfigFileSync` takes the parsed type; the values mirror + * what `buildCustomModelDef` mints from a real repo. + */ +const CUSTOM_DEF: LocalModelDef = { + id: "custom-unsloth-qwen3-0.6b-gguf-qwen3-0.6b-ud-q4_k_xl", + name: "unsloth/Qwen3-0.6B-GGUF · Qwen3-0.6B-UD-Q4_K_XL.gguf", + filename: "Qwen3-0.6B-UD-Q4_K_XL.gguf", + huggingFaceUrl: + "https://huggingface.co/unsloth/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-UD-Q4_K_XL.gguf", + fileSizeGb: 0.37, + sizeLabel: "378 MB", + description: "Added from huggingface.co/unsloth/Qwen3-0.6B-GGUF", + maxContextLength: 0, + contextLabel: "auto", + minRamGb: 1, + recommendedRamGb: 3, + family: "custom", + supportsVision: false, +}; + describe("modelsCommand", () => { let stateDir: string; let stdoutChunks: string[]; @@ -53,10 +77,78 @@ describe("modelsCommand", () => { const code = await modelsCommand(["list"]); expect(code).toBe(0); const out = stdout(); - // One row per curated model in LOCAL_MODELS_CATALOG: 4 gemma + 6 qwen. - expect(out.split("\n").filter((l) => l.includes("qwen-") || l.includes("gemma-"))).toHaveLength( - 10, - ); + // One row per curated model in LOCAL_MODELS_CATALOG. + expect( + out + .split("\n") + .filter( + (l) => + l.includes("qwen-") || + l.includes("gemma-") || + l.includes("nemotron-") || + l.includes("muse-"), + ), + ).toHaveLength(12); + }); + + describe("models the operator added from Hugging Face", () => { + function seedCustomModel(): void { + writeUserConfigFileSync(getUserConfigPath(stateDir), { + ...USER_CONFIG_DEFAULTS, + localModels: { + ...USER_CONFIG_DEFAULTS.localModels, + mode: "managed", + customModels: [CUSTOM_DEF], + managed: { + ...USER_CONFIG_DEFAULTS.localModels.managed, + modelId: CUSTOM_DEF.id, + }, + }, + }); + resetConfigCache(); + } + + it("list shows the added model on the same list, marked active", async () => { + seedCustomModel(); + const code = await modelsCommand(["list"]); + expect(code).toBe(0); + const row = stdout() + .split("\n") + .find((l) => l.includes(CUSTOM_DEF.id)); + expect(row).toBeDefined(); + expect(row).toContain("custom"); + expect(row?.trimEnd().endsWith("*")).toBe(true); + }); + + it("names the added model among the valid ids on a typo", async () => { + seedCustomModel(); + const code = await modelsCommand(["pull", "nope"]); + expect(code).toBe(1); + expect(stderrChunks.join("")).toContain(CUSTOM_DEF.id); + }); + + // Deleting a custom model undoes the add: unlike a curated row the + // entry has no life outside the operator's config, and a row that + // cannot be dropped would haunt `models list` forever. + it("remove drops the files, the row and the active mark", async () => { + seedCustomModel(); + const code = await modelsCommand(["remove", CUSTOM_DEF.id]); + expect(code).toBe(0); + const raw = JSON.parse( + readFileSync(getUserConfigPath(stateDir), "utf8"), + ) as { + localModels: { + customModels: unknown[]; + managed: { modelId: string | null }; + }; + }; + expect(raw.localModels.customModels).toEqual([]); + expect(raw.localModels.managed.modelId).toBeNull(); + stdoutChunks.length = 0; + resetConfigCache(); + expect(await modelsCommand(["list"])).toBe(0); + expect(stdout()).not.toContain(CUSTOM_DEF.id); + }); }); it("pull with bad id exits 1", async () => { diff --git a/src/cli/models-command.ts b/src/cli/models-command.ts index 7d25b8c7..f1258172 100644 --- a/src/cli/models-command.ts +++ b/src/cli/models-command.ts @@ -14,6 +14,7 @@ import { runLocalModelsUseDevice, runLocalModelsUseEmbedding, } from "./models-handlers.js"; +import { runModelsSearch } from "./models-search-command.js"; const HELP = [ @@ -32,6 +33,12 @@ const HELP = " (stops daemon first; does not auto-restart)", " remove Delete a downloaded model (refuses if active + daemon running)", "", + "Cloud subcommands (no local runtime needed):", + " search Search configured cloud providers' models by id,", + " vendor and capability (`claude vision`, `free tools`,", + " `1m cache`). Flags: --provider --limit ", + " --json --refresh (pull live lists first)", + "", "GPU subcommands:", " devices List GPU devices (llama-server --list-devices); active marked with *", " use-device Set the managed daemon's GPU (auto-picks best discrete by default)", @@ -45,6 +52,7 @@ const HELP = "", "Examples:", " atomic-agent models list", + " atomic-agent models search claude vision", " atomic-agent models pull qwen-3.5-4b", " atomic-agent models use qwen-3.5-4b", " atomic-agent models pull-embedding nomic-embed-text-v1.5", @@ -63,6 +71,8 @@ export async function modelsCommand(args: string[]): Promise { } try { switch (sub) { + case "search": + return await runModelsSearch(args.slice(1)); case "list": return runLocalModelsList(); case "pull": diff --git a/src/cli/models-handlers.ts b/src/cli/models-handlers.ts index 80be86d0..540d6408 100644 --- a/src/cli/models-handlers.ts +++ b/src/cli/models-handlers.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { getConfig, resetConfigCache } from "../config/index.js"; import { ensureUserConfigFileSync, writeUserConfigFileSync } from "../config/config-file.js"; +import { removeCustomModel } from "../config/custom-models-store.js"; import type { UserConfigFile } from "../config/config-schema.js"; import { checkForBackendUpdate, @@ -18,8 +19,9 @@ import { isKnownLocalModelId, isMmprojDownloaded, isModelDownloaded, + listLocalModels, listVulkanDevices, - LOCAL_MODELS_CATALOG, + maybeAutoUpdateBackend, readBackendVersion, removeModel, resolveChatTemplatePath, @@ -62,14 +64,17 @@ export async function runLocalModelsList(): Promise { const cfg = getConfig(); const dataDir = cfg.paths.localModelsDataDir; process.stdout.write( - "ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n", + "ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n", ); - for (const m of LOCAL_MODELS_CATALOG) { + // Curated catalog plus the operator's own Hugging Face additions — + // a model added on first run must show up (and be markable active) + // in the same list as the curated ones. + for (const m of listLocalModels()) { const dl = isModelDownloaded(dataDir, m) ? "yes" : "no"; const active = cfg.localModels.managed.modelId === m.id && cfg.localModels.mode === "managed" ? "*" : " "; process.stdout.write( - `${m.id.padEnd(19)} | ${m.family.padEnd(6)} | ${m.sizeLabel.padEnd(6)} | ${m.contextLabel.padEnd(7)} | ${dl.padEnd(3)} | ${active}\n`, + `${m.id.padEnd(20)} | ${m.family.padEnd(8)} | ${m.sizeLabel.padEnd(6)} | ${m.contextLabel.padEnd(7)} | ${dl.padEnd(3)} | ${active}\n`, ); } return 0; @@ -78,7 +83,7 @@ export async function runLocalModelsList(): Promise { export async function runLocalModelsPull(idArg: string | undefined): Promise { if (!idArg || !isKnownLocalModelId(idArg)) { process.stderr.write( - `unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`, + `unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`, ); return 1; } @@ -119,7 +124,7 @@ export async function runLocalModelsPull(idArg: string | undefined): Promise { if (!idArg || !isKnownLocalModelId(idArg)) { process.stderr.write( - `unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`, + `unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`, ); return 1; } @@ -218,6 +223,54 @@ export async function runLocalModelsStart(): Promise { return 1; } const dataDir = cfg.paths.localModelsDataDir; + try { + const auto = await maybeAutoUpdateBackend(dataDir, { + enabled: cfg.localModels.managed.autoUpdate, + // Unlike the TUI, `models start` is an explicit one-shot command: + // updating before the daemon comes up is what the operator asked + // for. It still needs a deadline — a stalled-open connection would + // otherwise pin the command forever with a progress bar at 12%. + signal: AbortSignal.timeout(BACKEND_DOWNLOAD_TIMEOUT_MS), + onProgress: (p: number, t: number, tot: number) => { + const line = renderPullProgress("backend zip", p, t, tot); + if (process.stderr.isTTY) process.stderr.write(`\r${line.padEnd(79)}`); + else if (p % 5 === 0 || p === 100) process.stderr.write(`${line}\n`); + }, + }); + if (auto.action === "updated") { + if (process.stderr.isTTY) process.stderr.write("\n"); + process.stdout.write( + `backend: updated ${auto.from ?? "none"} → ${auto.to}\n`, + ); + } else if (auto.action === "check_failed") { + process.stderr.write( + `note: backend update check failed — starting current binary (${auto.error})\n`, + ); + } else if (auto.action === "deferred") { + process.stderr.write( + "note: backend update deferred — another session is using the current binary\n", + ); + } else if (auto.action === "update_failed") { + if (process.stderr.isTTY) process.stderr.write("\n"); + if (!auto.backendUsable) { + // The daemon was stopped for the update and there is no binary + // left to fall back to — nothing can be started. + process.stderr.write( + `backend auto-update failed and no usable backend remains: ${auto.error}\n` + + `run 'atomic-agent models update' once connectivity is back.\n`, + ); + return 1; + } + process.stderr.write( + `note: backend update failed — starting current binary (${auto.error})\n`, + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`backend auto-update failed: ${msg}\n`); + return 1; + } + const m = getLocalModelDef(mid); const tpl = resolveChatTemplatePath(m) ?? undefined; const mmprojFile = @@ -337,6 +390,13 @@ function describeDeviceChoice( return resolved ?? configured; } +/** + * Deadline for the backend asset download. The zip is 27-39 MB, so this + * is generous for any working link; it exists because a stalled-but-open + * TCP connection never resolves on its own. + */ +const BACKEND_DOWNLOAD_TIMEOUT_MS = 10 * 60_000; + const DEVICE_ID_RE = /^[A-Za-z]+\d+$/; /** @@ -580,7 +640,14 @@ export async function runLocalModelsUpdate(): Promise { try { const { updateAvailable, latestTag, currentTag } = await checkForBackendUpdate(dataDir); if (!updateAvailable) { - process.stdout.write(`backend up to date (${latestTag})\n`); + // `latestTag` is null when no scanned release ships this + // platform's asset — nothing to compare against, so the install + // on disk stands. + process.stdout.write( + latestTag === null + ? `backend unchanged (no published release for this platform)\n` + : `backend up to date (${latestTag})\n`, + ); return 0; } process.stdout.write(`current: ${currentTag ?? "none"} → latest: ${latestTag}\n`); @@ -606,7 +673,7 @@ export async function runLocalModelsUpdate(): Promise { export async function runLocalModelsRemove(idArg: string | undefined): Promise { if (!idArg || !isKnownLocalModelId(idArg)) { process.stderr.write( - `unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`, + `unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`, ); return 1; } @@ -624,7 +691,13 @@ export async function runLocalModelsRemove(idArg: string | undefined): Promise { + it("joins bare words into one query and reads the flags", () => { + const parsed = parseModelsSearchArgs([ + "claude", + "vision", + "--limit", + "5", + "--json", + "--provider", + "or", + ]); + expect(parsed).toEqual({ + query: "claude vision", + provider: "or", + limit: 5, + json: true, + refresh: false, + }); + }); + + it("rejects a non-positive limit and unknown flags", () => { + expect(() => parseModelsSearchArgs(["x", "--limit", "0"])).toThrow(/--limit/); + expect(() => parseModelsSearchArgs(["x", "--nope"])).toThrow(/unknown flag/); + }); +}); + +describe("runModelsSearch", () => { + let stateDir: string; + let out: string[]; + let err: string[]; + + function writeConfig(): void { + writeFileSync( + getUserConfigPath(stateDir), + JSON.stringify({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "or", + activeEmbeddingProvider: "or", + toolTransport: "auto", + providers: [ + { id: "or", kind: "openrouter", defaultChatModel: "openrouter/auto" }, + { + id: "vllm", + kind: "openai-compatible", + baseUrl: "http://127.0.0.1:8000", + defaultChatModel: "local/mistral", + }, + ], + }, + }), + "utf8", + ); + resetConfigCache(); + } + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-models-search-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + resetConfigCache(); + out = []; + err = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + out.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + err.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + resetConfigCache(); + vi.restoreAllMocks(); + }); + + it("finds catalog models by id and prints provider, context, price and caps", async () => { + writeConfig(); + const code = await runModelsSearch(["qwen"]); + expect(code).toBe(0); + expect(out.join("")).toMatch(/^or\s+qwen\//m); + expect(out.join("")).toMatch(/tools/); + }); + + it("ANDs terms across id and capability tags", async () => { + writeConfig(); + // The old TUI filter answered this with nothing: "qwen vision" is not + // a substring of any id. + expect(await runModelsSearch(["qwen", "vision", "--json"])).toBe(0); + const rows = JSON.parse(out.join("")) as { + id: string; + supportsVision: boolean; + }[]; + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.id).toMatch(/qwen/); + expect(row.supportsVision).toBe(true); + } + }); + + it("`1m` returns every million-token row, whatever its rendered size reads", async () => { + writeConfig(); + // The README advertises this query. The bundled OpenRouter catalog + // holds 1_000_000, 1_048_576 and 1_050_000 windows, which render as + // `1m`, `1.0m` and `1.1m`; only the first used to answer to `1m`. + expect(await runModelsSearch(["1m", "--json"])).toBe(0); + const rows = JSON.parse(out.join("")) as { + id: string; + contextWindow: number; + }[]; + const windows = new Set(rows.map((row) => row.contextWindow)); + expect(windows).toEqual(new Set([1_000_000, 1_048_576, 1_050_000])); + for (const row of rows) { + expect(row.contextWindow).toBeGreaterThanOrEqual(1_000_000); + // `openrouter/auto` is 2M and belongs to `2m`, not to `1m`. + expect(row.contextWindow).toBeLessThan(2_000_000); + } + + // Same normalisation one unit down: 262_144 renders as `262k` and is + // sold as 256k. + out.length = 0; + expect(await runModelsSearch(["256k", "--json"])).toBe(0); + const kilo = JSON.parse(out.join("")) as { contextWindow: number }[]; + expect(kilo.length).toBeGreaterThan(0); + for (const row of kilo) expect(row.contextWindow).toBe(262_144); + }); + + it("includes models an entry carries under userModels", async () => { + // Read straight off the entry: `parseLlmProviderEntry` currently + // drops `userModels` on the way out of config.json, so this path + // cannot be reached through a config fixture. + const hits = await collectHits( + [ + { + id: "vllm", + kind: "openai-compatible", + baseUrl: "http://127.0.0.1:8000", + userModels: [ + { + id: "local/mistral", + kind: "chat", + contextWindow: 32_000, + }, + ], + }, + ], + false, + ); + expect(hits).toEqual([{ providerId: "vllm", id: "local/mistral" }]); + }); + + it("narrows to one provider entry and caps the result count", async () => { + writeConfig(); + // `vllm` ships no bundled catalog, so restricting to it finds nothing + // to search rather than silently falling back to the other provider. + expect(await runModelsSearch(["--provider", "vllm", "qwen"])).toBe(1); + expect(err.join("")).toMatch(/no searchable cloud models/); + + out.length = 0; + expect(await runModelsSearch(["qwen", "--limit", "1"])).toBe(0); + expect(out.join("").trimEnd().split("\n")).toHaveLength(1); + }); + + it("exits 1 with one line — never a stack trace — when nothing matches", async () => { + writeConfig(); + expect(await runModelsSearch(["definitely-not-a-model"])).toBe(1); + expect(out.join("")).toBe(""); + expect(err.join("")).toMatch(/no model matches/); + }); + + it("exits 1 on a missing query or an unknown provider id", async () => { + writeConfig(); + expect(await runModelsSearch([])).toBe(1); + expect(err.join("")).toMatch(/expects a query/); + + err.length = 0; + expect(await runModelsSearch(["--provider", "nope", "qwen"])).toBe(1); + expect(err.join("")).toMatch(/no configured provider/); + }); + + it("says so instead of printing nothing when no provider ships a catalog", async () => { + // Default config: one local llama-server entry, no cloud catalog. + expect(await runModelsSearch(["qwen"])).toBe(1); + expect(err.join("")).toMatch(/no searchable cloud models/); + }); + + // Last in the file on purpose: a live refresh writes the fetcher's + // module-global pick cache, which outlives this test. + it("--refresh searches the live catalog, not just the bundled snapshot", async () => { + writeConfig(); + expect(await runModelsSearch(["brand-new-model"])).toBe(1); + + out.length = 0; + err.length = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [ + { + id: "vendor/brand-new-model", + name: "Brand New", + context_length: 256_000, + pricing: { prompt: "0.000001", completion: "0.000004" }, + supported_parameters: ["tools"], + architecture: { input_modalities: ["text"] }, + }, + ], + }), + })), + ); + expect(await runModelsSearch(["brand-new-model", "--refresh"])).toBe(0); + expect(out.join("")).toContain("vendor/brand-new-model"); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/cli/models-search-command.ts b/src/cli/models-search-command.ts new file mode 100644 index 00000000..46bffca4 --- /dev/null +++ b/src/cli/models-search-command.ts @@ -0,0 +1,228 @@ +import { getConfig } from "../config/index.js"; +import { catalogForProvider } from "../llm/provider/catalog-for-provider.js"; +import { + formatCapabilitySummary, + formatContextWindow, + formatTokenPrice, +} from "../llm/provider/format-model-details.js"; +import type { ModelCatalogEntry } from "../llm/provider/model-resolver.js"; +import { searchModels } from "../llm/provider/model-search.js"; +import { fetchOpenAiCompatModels } from "../llm/provider/openai/fetch-openai-compat-models.js"; +import { + listAimlapiChatPicks, + refreshAimlapiChatCatalogFromApi, +} from "../llm/provider/aimlapi/fetch-aimlapi-chat-catalog.js"; +import { + listOpenRouterChatPicks, + refreshOpenRouterChatCatalogFromApi, +} from "../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; +import { resolveLlmConfig } from "../llm/provider/registry/provider-types.js"; +import type { LlmProviderConfigEntry } from "../llm/provider/registry/provider-types.js"; + +/** + * `atomic-agent models search ` — the cloud half of `models`. + * + * The rest of this command group manages local GGUF weights. Cloud + * models were only ever searchable from inside the TUI, which is no + * help when picking a `defaultChatModel` for a config file or checking + * what a provider charges. Same scorer as the TUI picker + * (`searchModels`), same rendering (`format-model-details`), so a query + * that works in one surface works in the other. + */ + +export type ModelSearchHit = { + providerId: string; + id: string; + entry?: ModelCatalogEntry | undefined; +}; + +export type ModelsSearchOptions = { + query: string; + provider: string | null; + limit: number; + json: boolean; + refresh: boolean; +}; + +const DEFAULT_LIMIT = 30; + +export function parseModelsSearchArgs(args: readonly string[]): ModelsSearchOptions { + const terms: string[] = []; + let provider: string | null = null; + let limit = DEFAULT_LIMIT; + let json = false; + let refresh = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === "--json") json = true; + else if (arg === "--refresh") refresh = true; + else if (arg === "--provider") provider = args[++i] ?? null; + else if (arg === "--limit") { + const raw = Number.parseInt(args[++i] ?? "", 10); + if (!Number.isFinite(raw) || raw <= 0) { + throw new Error("--limit expects a positive integer"); + } + limit = raw; + } else if (arg.startsWith("--")) { + throw new Error(`unknown flag: ${arg}`); + } else terms.push(arg); + } + if (provider !== null && provider.length === 0) { + throw new Error("--provider expects a provider id"); + } + return { query: terms.join(" "), provider, limit, json, refresh }; +} + +/** + * Every model this machine could reach, tagged with the provider entry + * it came from: the bundled catalog for curated kinds, plus whatever the + * entry carries under `userModels`. + * + * Note that `userModels` cannot currently arrive from `config.json` — + * `parseLlmProviderEntry` drops the field even though the schema, the + * `LlmProviderConfigEntry` type and `resolveModel` all support it. This + * reads whatever the entry actually holds rather than assuming the + * config parser is the only way one gets populated. + */ +export async function collectHits( + entries: readonly LlmProviderConfigEntry[], + refresh: boolean, +): Promise { + const hits: ModelSearchHit[] = []; + for (const entry of entries) { + if (refresh) await refreshCatalog(entry); + const seen = new Set(); + const add = (id: string, catalogEntry?: ModelCatalogEntry): void => { + if (seen.has(id)) return; + seen.add(id); + hits.push({ providerId: entry.id, id, entry: catalogEntry }); + }; + // Bundled snapshot first: it is curated, ordered, and the only + // source that carries embedding rows. + for (const [id, catalogEntry] of catalogForProvider(entry)) add(id, catalogEntry); + // Then whatever the live picker cache holds. `listXChatPicks` falls + // back to the same snapshot when nothing has been fetched, so this + // only ever adds ids — after `--refresh` it is the fresh catalog. + for (const pick of livePicks(entry)) add(pick.id, pick.entry); + for (const model of entry.userModels ?? []) add(model.id); + if (refresh) for (const id of await liveCompatModels(entry)) add(id); + } + return hits; +} + +/** + * A live refresh writes into each fetcher's module cache, which is what + * `catalogForProvider` reads through for curated kinds. Failures are + * silent on purpose: the bundled snapshot is still a useful answer, and + * a search should not fail because a vendor endpoint is down. + */ +async function refreshCatalog(entry: LlmProviderConfigEntry): Promise { + try { + if (entry.kind === "openrouter") await refreshOpenRouterChatCatalogFromApi(); + else if (entry.kind === "aimlapi") await refreshAimlapiChatCatalogFromApi(); + } catch { + /* keep the bundled snapshot */ + } +} + +function livePicks( + entry: LlmProviderConfigEntry, +): readonly { id: string; entry: ModelCatalogEntry }[] { + if (entry.kind === "openrouter") return listOpenRouterChatPicks(); + if (entry.kind === "aimlapi") return listAimlapiChatPicks(); + return []; +} + +async function liveCompatModels( + entry: LlmProviderConfigEntry, +): Promise { + if (!entry.baseUrl) return []; + if (entry.kind !== "openai-compatible" && entry.kind !== "qwen-openai-compatible") { + return []; + } + try { + return await fetchOpenAiCompatModels(entry.baseUrl, entry.apiKey); + } catch { + return []; + } +} + +function formatHit(hit: ModelSearchHit): string { + const entry = hit.entry; + const details = entry + ? [ + formatContextWindow(entry.contextWindow), + formatTokenPrice(hit.id, entry.pricing), + formatCapabilitySummary(entry), + ].join(" · ") + : "metadata unavailable"; + return `${hit.providerId.padEnd(14)} ${hit.id.padEnd(42)} ${details}`; +} + +export async function runModelsSearch(args: readonly string[]): Promise { + let options: ModelsSearchOptions; + try { + options = parseModelsSearchArgs(args); + } catch (err) { + process.stderr.write(`${(err as Error).message}\n`); + return 1; + } + if (options.query.length === 0) { + process.stderr.write( + "models search expects a query, e.g. `models search claude vision`\n", + ); + return 1; + } + + const resolved = resolveLlmConfig(getConfig()); + const entries = resolved.providers.filter((entry) => + options.provider === null ? true : entry.id === options.provider, + ); + if (options.provider !== null && entries.length === 0) { + process.stderr.write(`no configured provider with id "${options.provider}"\n`); + return 1; + } + + const hits = await collectHits(entries, options.refresh); + if (hits.length === 0) { + process.stderr.write( + "no searchable cloud models: the configured providers ship no catalog. " + + "Add an openrouter or aimlapi provider, or re-run with --refresh to " + + "pull a live /v1/models list.\n", + ); + return 1; + } + + const matches = searchModels(hits, options.query).slice(0, options.limit); + if (matches.length === 0) { + process.stderr.write(`no model matches ${JSON.stringify(options.query)}\n`); + return 1; + } + + if (options.json) { + process.stdout.write( + `${JSON.stringify( + matches.map((hit) => ({ + provider: hit.providerId, + id: hit.id, + ...(hit.entry + ? { + kind: hit.entry.kind, + contextWindow: hit.entry.contextWindow, + supportsVision: hit.entry.supportsVision, + supportsTools: hit.entry.supportsTools, + supportsPromptCache: hit.entry.supportsPromptCache, + ...(hit.entry.pricing ? { pricing: hit.entry.pricing } : {}), + } + : {}), + })), + null, + 2, + )}\n`, + ); + return 0; + } + + process.stdout.write(`${matches.map(formatHit).join("\n")}\n`); + return 0; +} diff --git a/src/cli/run-agent.test.ts b/src/cli/run-agent.test.ts new file mode 100644 index 00000000..a027e9d1 --- /dev/null +++ b/src/cli/run-agent.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; + +import type { CompletionResult } from "../llm/llama-server-client.js"; +import { resetConfigCache } from "../config/index.js"; + +import { formatLlamaUnreachableHint } from "../llm/llama-server-health.js"; +import { formatAgentEvent } from "./run-agent.js"; + +const HINT = formatLlamaUnreachableHint("http://127.0.0.1:8080"); + +function transportError(message: string) { + return { + type: "llm_event" as const, + event: { + type: "step_error" as const, + error: new Error(message), + category: "transport" as const, + }, + }; +} + +describe("formatAgentEvent llama hint", () => { + it("turns a bare transport failure into something actionable on the local route", () => { + const line = formatAgentEvent(transportError("fetch failed"), { + llamaHint: HINT, + hintShown: { value: false }, + }); + expect(line).toContain("! [transport] fetch failed"); + expect(line).toContain("llama-server is not reachable at http://127.0.0.1:8080"); + expect(line).toContain("atomic-agent models start"); + expect(line).toContain("config set localModels.url"); + }); + + it("prints the hint once, not on every retry", () => { + const hintShown = { value: false }; + const first = formatAgentEvent(transportError("fetch failed"), { + llamaHint: HINT, + hintShown, + }); + const second = formatAgentEvent(transportError("fetch failed"), { + llamaHint: HINT, + hintShown, + }); + expect(first).toContain("llama-server is not reachable"); + expect(second).toBe(" ! [transport] fetch failed"); + }); + + it("stays out of the way on a cloud route", () => { + // No hint is computed when the active text provider is not local — + // a transport failure there points at the provider, not at llama. + const line = formatAgentEvent(transportError("fetch failed"), { + llamaHint: null, + hintShown: { value: false }, + }); + expect(line).toBe(" ! [transport] fetch failed"); + }); + + it("stays out of the way for non-transport failures", () => { + const line = formatAgentEvent( + { + type: "llm_event", + event: { + type: "step_error", + error: new Error("grammar rejected the completion"), + category: "model" as never, + }, + }, + { llamaHint: HINT, hintShown: { value: false } }, + ); + expect(line).toBe(" ! [model] grammar rejected the completion"); + }); + + it("decorates loop_failed the same way", () => { + const line = formatAgentEvent( + { + type: "loop_failed", + error: new Error("fetch failed"), + category: "transport" as never, + }, + { llamaHint: HINT, hintShown: { value: false } }, + ); + expect(line).toContain("» loop failed [transport]: fetch failed"); + expect(line).toContain("llama-server is not reachable"); + }); +}); + +describe("formatLlamaUnreachableHint", () => { + it("names the URL, the start command and the config key", () => { + const hint = formatLlamaUnreachableHint("http://10.0.0.4:9090"); + expect(hint).toContain("http://10.0.0.4:9090"); + expect(hint).toContain("atomic-agent models start"); + expect(hint).toContain("localModels.url"); + }); +}); + +/** Raw model output the stubbed llama-server replays on every step. */ +const model = vi.hoisted(() => ({ emits: "" })); + +// `runAgentCommand` boots the real runtime and takes no injection seam of +// its own, so the bootstrap module is wrapped to supply the same +// `overrides` the HTTP harness uses. Everything else — tool registry, +// agent loop, session status transitions, the exit-code branch under +// test — stays on the production path. Nothing may be imported from +// `../http/test-harness.js` in here: that module imports the very module +// being mocked, so awaiting it inside the factory re-enters the mock and +// deadlocks. +vi.mock("../runtime/bootstrap.js", async (importOriginal) => { + const actual = + await importOriginal(); + const completion = (content: string): CompletionResult => ({ + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 0, + predictedMs: 0, + promptTokens: 5, + predictedTokens: 3, + }, + cacheHitTokens: 0, + slotId: 0, + modelId: null, + }); + const complete = async (params: { + sessionId: string; + }): Promise => { + // Post-turn reflection shares the completion seam but expects prose, + // not a tool call; feeding it the step payload would have it parse + // the fixture as notes. + if (params.sessionId.startsWith("reflection:")) return completion(""); + return completion(model.emits); + }; + return { + ...actual, + createAgentRuntime: ( + options: Parameters[0], + ) => + actual.createAgentRuntime({ + ...options, + // No browser override: `PlaywrightBackend` launches lazily and + // these turns never touch a browser tool, so nothing spawns. + overrides: { + skipLlamaHealthCheck: true, + disableStreaming: true, + llamaComplete: complete, + }, + }), + }; +}); + +const { runAgentCommand } = await import("./run-agent.js"); + +describe("runAgentCommand exit codes", () => { + let stateDir: string; + let workingDir: string; + let stderr = ""; + const realStdin = process.stdin; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-cli-run-state-")); + workingDir = mkdtempSync(join(tmpdir(), "atomic-cli-run-cwd-")); + mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true }); + writeFileSync(join(workingDir, "note.txt"), "hello\n", "utf8"); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars"); + resetConfigCache(); + model.emits = ""; + stderr = ""; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderr += typeof chunk === "string" ? chunk : String(chunk); + return true; + }); + }); + + afterEach(() => { + Object.defineProperty(process, "stdin", { + value: realStdin, + configurable: true, + }); + rmSync(stateDir, { recursive: true, force: true }); + rmSync(workingDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + resetConfigCache(); + vi.restoreAllMocks(); + }); + + function feedStdin(lines: string[]): void { + Object.defineProperty(process, "stdin", { + value: Readable.from(lines), + configurable: true, + }); + } + + it("exits 2 when --cwd points at a path that does not exist", async () => { + const missing = join(workingDir, "definitely-not-here"); + const code = await runAgentCommand(["--cwd", missing]); + expect(code).toBe(2); + expect(stderr).toContain(`--cwd is not a directory: ${missing}`); + }); + + it("exits 2 when --working-dir points at a file rather than a directory", async () => { + const file = join(workingDir, "note.txt"); + const code = await runAgentCommand(["--working-dir", file]); + expect(code).toBe(2); + expect(stderr).toContain(`--working-dir is not a directory: ${file}`); + }); + + it("exits 2 on an unknown flag", async () => { + const code = await runAgentCommand(["--nope"]); + expect(code).toBe(2); + expect(stderr).toContain("unknown flag: --nope"); + }); + + it( + "exits 1 when the turn exhausts the step budget and the session stalls", + async () => { + // A non-terminal tool on every step: the loop never reaches `reply` + // or `finish`, so it runs the budget out and lands on `stalled`. + model.emits = JSON.stringify({ + tool: "os.fs.read", + args: { path: "note.txt" }, + }); + feedStdin(["read the note\n"]); + const code = await runAgentCommand([ + "--cwd", + workingDir, + "--max-steps", + "2", + "--no-approval", + ]); + expect(stderr).toContain('"status": "stalled"'); + expect(code).toBe(1); + }, + 60_000, + ); + + it( + "still exits 0 when the same turn ends on a reply", + async () => { + model.emits = JSON.stringify({ + tool: "reply", + args: { text: "the note says hello" }, + }); + feedStdin(["read the note\n"]); + const code = await runAgentCommand([ + "--cwd", + workingDir, + "--max-steps", + "2", + "--no-approval", + ]); + expect(stderr).not.toContain('"status": "stalled"'); + expect(code).toBe(0); + }, + 60_000, + ); +}); diff --git a/src/cli/run-agent.ts b/src/cli/run-agent.ts index c93c2977..8b2dc1a3 100644 --- a/src/cli/run-agent.ts +++ b/src/cli/run-agent.ts @@ -1,3 +1,4 @@ +import { statSync } from "node:fs"; import { resolve } from "node:path"; import { createInterface } from "node:readline"; import type { Interface as ReadlineInterface } from "node:readline"; @@ -8,6 +9,8 @@ import { resolveBootApprovalLevel, } from "../approval/approval-level.js"; import { getConfig } from "../config/index.js"; +import { formatLlamaUnreachableHint } from "../llm/llama-server-health.js"; +import { resolveLlmConfig } from "../llm/provider/registry/provider-types.js"; import { createAgentRuntime } from "../runtime/bootstrap.js"; import type { AgentRuntime } from "../runtime/bootstrap.js"; import type { AgentLoopEvent } from "../agent/agent-loop.js"; @@ -18,7 +21,10 @@ import { type ApprovalRequest, } from "../approval/approval-gate.js"; import { stderrSink } from "../tracing/structured-logger.js"; -import type { SessionState } from "../session/session-state.js"; +import { + isFailedSessionStatus, + type SessionState, +} from "../session/session-state.js"; interface RunArgs { workingDir: string; @@ -26,18 +32,53 @@ interface RunArgs { noApproval: boolean; } -function parseArgs(args: string[]): RunArgs | { error: string } { +const HELP = + [ + "atomic-agent run — chat with the agent over stdin", + "", + "Usage:", + " atomic-agent run [options] interactive: one message per line", + " echo \"\" | atomic-agent run one-shot: answer on stdout, logs on stderr", + "", + "Options:", + " --cwd Working directory for OS tools (default: current directory)", + " --working-dir Alias for --cwd", + " --max-steps Step budget for one turn (default: agent.maxSteps from config)", + " --no-approval Force approval level 5: auto-approve every dangerous tool call", + "", + "In-session: /quit exits · /abort cancels the current turn", + "Exit codes: 0 replied · 1 failed · 2 usage error", + ].join("\n") + "\n"; + +function parseArgs(args: string[]): RunArgs | { error: string } | { help: true } { let workingDir: string | null = null; let maxSteps: number | null = null; let noApproval = false; for (let i = 0; i < args.length; i += 1) { const flag = args[i]; switch (flag) { + case "--help": + case "-h": + return { help: true }; case "--cwd": case "--working-dir": { const value = args[++i]; if (!value) return { error: `${flag} requires a value` }; - workingDir = resolve(value); + const resolved = resolve(value); + // A typo'd path used to sail through: the run booted, printed the + // bogus directory in its banner as though healthy, ENOENT'd on + // every filesystem tool until the step budget ran out — and then + // exited 0. Catch it before anything boots. + let isDirectory = false; + try { + isDirectory = statSync(resolved).isDirectory(); + } catch { + isDirectory = false; + } + if (!isDirectory) { + return { error: `${flag} is not a directory: ${resolved}` }; + } + workingDir = resolved; break; } case "--max-steps": { @@ -127,7 +168,40 @@ async function promptApproval( } } -function formatAgentEvent(event: AgentLoopEvent): string | null { +/** Transport failures that mean "nothing answered at the configured URL". */ +const TRANSPORT_NO_ANSWER = /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|timeout/i; + +function withLlamaHint( + base: string, + category: string, + message: string, + ctx?: { llamaHint?: string | null; hintShown?: { value: boolean } }, +): string { + if (!ctx?.llamaHint || ctx.hintShown?.value) return base; + if (category !== "transport" || !TRANSPORT_NO_ANSWER.test(message)) return base; + if (ctx.hintShown) ctx.hintShown.value = true; + const indented = ctx.llamaHint + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + return `${base}\n${indented}`; +} + +/** + * Render one agent-loop event as a diagnostic stderr line, or null for + * events other surfaces own. Exported for tests. + * + * `ctx.llamaHint` carries the actionable llama-server message when the + * active text route is the local server: a transport failure there is + * almost always "llama-server is not running", and the raw undici string + * ("fetch failed") tells the operator none of URL / cause / fix. The hint + * is appended once per process — every retry repeating three lines of + * advice would bury the log. + */ +export function formatAgentEvent( + event: AgentLoopEvent, + ctx?: { llamaHint?: string | null; hintShown?: { value: boolean } }, +): string | null { switch (event.type) { case "user_message": return null; @@ -148,15 +222,18 @@ function formatAgentEvent(event: AgentLoopEvent): string | null { return ` ← ${inner.result.tool} ${inner.result.status}: ${inner.result.summary}${inner.result.truncated ? " (truncated)" : ""}`; } if (inner.type === "step_error") { - return ` ! [${inner.category}] ${inner.error.message}`; + const base = ` ! [${inner.category}] ${inner.error.message}`; + return withLlamaHint(base, inner.category, inner.error.message, ctx); } // assistant_reply / reasoning are emitted to stdout from the chat loop instead. return null; } case "loop_completed": return null; - case "loop_failed": - return `» loop failed [${event.category}]: ${event.error.message}`; + case "loop_failed": { + const base = `» loop failed [${event.category}]: ${event.error.message}`; + return withLlamaHint(base, event.category, event.error.message, ctx); + } default: return null; } @@ -286,6 +363,10 @@ async function runChatLoop(opts: ChatLoopOptions): Promise { */ export async function runAgentCommand(args: string[]): Promise { const parsed = parseArgs(args); + if ("help" in parsed) { + process.stdout.write(HELP); + return 0; + } if ("error" in parsed) { process.stderr.write(`${parsed.error}\n`); return 2; @@ -298,6 +379,15 @@ export async function runAgentCommand(args: string[]): Promise { let approvalChain: Promise = Promise.resolve(); + // The hint only applies when a transport failure means "local llama is + // down" — i.e. the active text route IS the local server. On a cloud + // route the same category points at the provider, not at llama. + const llamaHint = + resolveLlmConfig(config).activeTextProvider === "local-llama" + ? formatLlamaUnreachableHint(config.localModels.url) + : null; + const hintShown = { value: false }; + const runtime = await createAgentRuntime({ workingDir: parsed.workingDir, approvalLevel, @@ -308,7 +398,7 @@ export async function runAgentCommand(args: string[]): Promise { // `eventHook` argument of `runTurn` (see `driveTurn`). This // global handler only feeds the diagnostic stderr stream so // the operator can watch the macro-turn lifecycle. - const line = formatAgentEvent(event); + const line = formatAgentEvent(event, { llamaHint, hintShown }); if (line) process.stderr.write(`${line}\n`); }, onApprovalRequest: (request) => { @@ -371,7 +461,13 @@ export async function runAgentCommand(args: string[]): Promise { 2, )}\n`, ); - if (finalSession.status === "failed") exitCode = 1; + // `stalled` means the step budget ran out with nothing produced — + // that is not success, and a CI job watching this exit code must not + // read it as one. Only `completed` (and a clean EOF on an idle + // session) count as 0. + if (isFailedSessionStatus(finalSession.status)) { + exitCode = 1; + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`fatal: ${msg}\n`); diff --git a/src/cli/serve-command.ts b/src/cli/serve-command.ts index 5ea3ca66..44fbf3dc 100644 --- a/src/cli/serve-command.ts +++ b/src/cli/serve-command.ts @@ -47,6 +47,9 @@ const HELP = " GET /api/skills, GET /api/skills/{name} List or inspect installed skills", " POST /api/skills/install, /uninstall Manage installed skills", " GET /api/sessions, GET /api/sessions/{id}, DELETE /api/sessions/{id}", + " POST /api/sessions/{id}/steer Fold a message into the turn already running", + " GET /api/sessions/{id}/steer Steers a turn accepted but never delivered", + " DELETE /api/sessions/{id}/steer Acknowledge those: ?through={seq} and/or ?discarded={n}", " POST /api/approval/resolve Resolve a pending approval", " GET /api/events SSE stream of pending approval requests", ].join("\n") + "\n"; diff --git a/src/cli/skill.test.ts b/src/cli/skill.test.ts index 9633f5b6..f34aa2e9 100644 --- a/src/cli/skill.test.ts +++ b/src/cli/skill.test.ts @@ -14,6 +14,21 @@ import { writeUserConfigFileSync, } from "../config/index.js"; +// Only `browseHub`/`searchHub` are stubbed; everything else in the hub +// module (identifier parsing, installer, scan summary) stays real so the +// install and tap tests below are unaffected. +vi.mock("../skills/hub/index.js", async (importOriginal) => ({ + ...(await importOriginal()), + browseHub: vi.fn(async () => ({ + entries: [], + errors: [{ repo: "owner/repo", error: "boom" }], + })), + searchHub: vi.fn(async () => ({ + entries: [], + errors: [{ repo: "owner/repo", error: "boom" }], + })), +})); + function writeSkill(globalDir: string, name: string): void { const dir = join(globalDir, name); mkdirSync(dir, { recursive: true }); @@ -182,12 +197,93 @@ describe("skillCommand", () => { expect(file?.skills.disabled).toEqual(["beta"]); }); - it("uninstall on a skill not installed globally returns 2", async () => { + it("uninstall on a skill not installed globally returns 1", async () => { const code = await skillCommand(["uninstall", "ghost-skill"]); - expect(code).toBe(2); + expect(code).toBe(1); expect(stderr).toContain("not installed globally: ghost-skill"); }); + it("show on a skill that is not installed returns 1", async () => { + writeSkill(globalSkillsDir, "alpha"); + const code = await skillCommand(["show", "ghost-skill"]); + expect(code).toBe(1); + expect(stderr).toContain("skill not installed: ghost-skill"); + }); + + it("install over an existing skill returns 1", async () => { + const sourceDir = mkdtempSync(join(tmpdir(), "atomic-cli-skill-src-")); + try { + writeSkill(sourceDir, "alpha"); + expect(await skillCommand(["install", join(sourceDir, "alpha")])).toBe(0); + const code = await skillCommand(["install", join(sourceDir, "alpha")]); + expect(code).toBe(1); + expect(stderr).toContain("already installed"); + } finally { + rmSync(sourceDir, { recursive: true, force: true }); + } + }); + + it("returns 2 for a missing required argument", async () => { + expect(await skillCommand(["show"])).toBe(2); + expect(await skillCommand(["uninstall"])).toBe(2); + expect(await skillCommand(["enable"])).toBe(2); + expect(await skillCommand(["disable"])).toBe(2); + expect(stderr).toContain("usage: atomic-agent skill show "); + expect(stderr).toContain("usage: atomic-agent skill uninstall "); + }); + + it("returns 2 for an unknown subcommand", async () => { + const code = await skillCommand(["frobnicate"]); + expect(code).toBe(2); + expect(stderr).toContain("unknown subcommand: frobnicate"); + }); + + it("returns 2 for a tap repo argument of the wrong shape", async () => { + const code = await skillCommand(["tap", "add", "not-a-repo"]); + expect(code).toBe(2); + expect(stderr).toContain("not-a-repo"); + }); + + it("returns 2 for the remaining argument-shape errors", async () => { + // The rest of the usage surface, which reaches its branch without any + // network: install with no source, browse with a valueless --source, + // search with an empty query, tap with a missing repo or a verb that + // does not exist. + expect(await skillCommand(["install"])).toBe(2); + expect(stderr).toContain("usage: atomic-agent skill install"); + + expect(await skillCommand(["browse", "--source"])).toBe(2); + expect(stderr).toContain("usage: atomic-agent skill browse"); + + expect(await skillCommand(["search"])).toBe(2); + expect(stderr).toContain("usage: atomic-agent skill search"); + + expect(await skillCommand(["tap", "add"])).toBe(2); + expect(await skillCommand(["tap", "remove"])).toBe(2); + expect(stderr).toContain("usage: atomic-agent skill tap add "); + + expect(await skillCommand(["tap", "frobnicate"])).toBe(2); + expect(stderr).toContain("usage: atomic-agent skill tap list"); + }); + + it("browse and search return 1 when every source failed and nothing was found", async () => { + // ClawHub off so the GitHub tap is the only source; it errors, so the + // command found nothing and every source it had failed. + writeUserConfigFileSync(getUserConfigPath(stateDir), { + ...USER_CONFIG_DEFAULTS, + skills: { + taps: ["owner/repo"], + clawhub: { ...USER_CONFIG_DEFAULTS.skills.clawhub, enabled: false }, + }, + }); + resetConfigCache(); + + expect(await skillCommand(["browse"])).toBe(1); + expect(await skillCommand(["search", "anything"])).toBe(1); + expect(stderr).toContain("WARN: owner/repo: boom"); + expect(stdout).toContain("(no skills found)"); + }); + it("enable/disable is idempotent across repeated invocations", async () => { writeSkill(globalSkillsDir, "alpha"); await skillCommand(["disable", "alpha"]); diff --git a/src/cli/skill.ts b/src/cli/skill.ts index 1ca16a43..20733e0d 100644 --- a/src/cli/skill.ts +++ b/src/cli/skill.ts @@ -79,7 +79,7 @@ export async function skillCommand(args: string[]): Promise { default: process.stderr.write(`unknown subcommand: ${sub}\n`); process.stderr.write(HELP); - return 1; + return 2; } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -94,7 +94,7 @@ async function handleInstall(args: string[]): Promise { process.stderr.write( "usage: atomic-agent skill install [--force] [--acknowledge-risk]\n", ); - return 1; + return 2; } const force = args.includes("--force"); const acknowledgeRisk = args.includes("--acknowledge-risk"); @@ -132,7 +132,7 @@ async function handleInstall(args: string[]): Promise { } catch (err) { if (err instanceof SkillInstallError && err.code === "already_installed") { process.stderr.write(`${err.message}\n`); - return 2; + return 1; } throw err; } @@ -158,11 +158,11 @@ async function handleHubInstall( } catch (err) { if (err instanceof SkillInstallError && err.code === "already_installed") { process.stderr.write(`${err.message}\n`); - return 2; + return 1; } const message = err instanceof Error ? err.message : String(err); process.stderr.write(`${message}\n`); - return 2; + return 1; } } @@ -189,11 +189,11 @@ async function handleClawHubInstall( } catch (err) { if (err instanceof SkillInstallError && err.code === "already_installed") { process.stderr.write(`${err.message}\n`); - return 2; + return 1; } const message = err instanceof Error ? err.message : String(err); process.stderr.write(`${message}\n`); - return 2; + return 1; } } @@ -201,13 +201,13 @@ async function handleUninstall(args: string[]): Promise { const name = args[0]; if (!name) { process.stderr.write("usage: atomic-agent skill uninstall \n"); - return 1; + return 2; } const config = getConfig(); const result = await uninstallSkill(config.paths.globalSkillsDir, name); if (!result.removed) { process.stderr.write(`skill not installed globally: ${name}\n`); - return 2; + return 1; } // Drop any stale disable entry so config.json does not accumulate // dangling names for skills that no longer exist on disk. @@ -260,7 +260,7 @@ async function handleShow(args: string[]): Promise { const name = args[0]; if (!name) { process.stderr.write("usage: atomic-agent skill show \n"); - return 1; + return 2; } const config = getConfig(); const projectDir = resolve(process.cwd(), config.paths.projectSkillsDirName); @@ -271,7 +271,7 @@ async function handleShow(args: string[]): Promise { const record = skills.find((s) => s.manifest.name === name); if (!record) { process.stderr.write(`skill not installed: ${name}\n`); - return 2; + return 1; } process.stdout.write(`# path: ${record.manifestPath}\n`); process.stdout.write(`# source: ${record.source}\n\n`); @@ -285,7 +285,7 @@ async function handleEnable(args: string[]): Promise { const name = args[0]; if (!name) { process.stderr.write("usage: atomic-agent skill enable \n"); - return 1; + return 2; } const result = mutateDisabledList((current) => { if (!current.includes(name)) return null; @@ -303,7 +303,7 @@ async function handleDisable(args: string[]): Promise { const name = args[0]; if (!name) { process.stderr.write("usage: atomic-agent skill disable \n"); - return 1; + return 2; } const result = mutateDisabledList((current) => { if (current.includes(name)) return null; @@ -343,7 +343,7 @@ async function handleBrowse(args: string[]): Promise { sourceIdx !== -1 ? args[sourceIdx + 1]?.trim() : undefined; if (sourceIdx !== -1 && !source) { process.stderr.write("usage: atomic-agent skill browse [--source owner/repo]\n"); - return 1; + return 2; } // ClawHub is the primary catalog; `--source owner/repo` narrows to a // single GitHub tap and skips ClawHub (the operator asked for a repo). @@ -357,7 +357,7 @@ async function handleSearch(args: string[]): Promise { const query = args.filter((a) => !a.startsWith("--")).join(" ").trim(); if (query.length === 0) { process.stderr.write("usage: atomic-agent skill search \n"); - return 1; + return 2; } const clawEntries = await browseClawHubSafe(query); const client = new GithubSkillClient(); @@ -421,7 +421,10 @@ function printHubEntries( for (const e of errors) { process.stderr.write(`WARN: ${e.repo}: ${e.error}\n`); } - return 0; + // Partial results still succeed — one dead tap should not fail a browse + // that found skills elsewhere. Nothing found and every source failing is + // an operational failure, not an empty catalog. + return entries.length === 0 && errors.length > 0 ? 1 : 0; } async function handleTap(args: string[]): Promise { @@ -439,14 +442,14 @@ async function handleTap(args: string[]): Promise { const repo = args[1]?.trim(); if (!repo) { process.stderr.write(`usage: atomic-agent skill tap ${verb} \n`); - return 1; + return 2; } try { parseTapRepo(repo); } catch (err) { const message = err instanceof Error ? err.message : String(err); process.stderr.write(`${message}\n`); - return 1; + return 2; } const result = mutateTapsList((current) => { if (verb === "add") { @@ -468,7 +471,7 @@ async function handleTap(args: string[]): Promise { process.stderr.write( "usage: atomic-agent skill tap list | add | remove \n", ); - return 1; + return 2; } /** diff --git a/src/cli/uninstall-command.test.ts b/src/cli/uninstall-command.test.ts new file mode 100644 index 00000000..1f4112be --- /dev/null +++ b/src/cli/uninstall-command.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ResolvedUninstallPlan } from "../uninstall/index.js"; +import { + uninstallCommand, + type UninstallCommandDeps, +} from "./uninstall-command.js"; + +function makePlan( + overrides: Partial = {}, +): ResolvedUninstallPlan { + const targets = [ + { + path: "/Users/op/.atomic-agent", + label: "config, memory, sessions", + group: "data" as const, + }, + { + path: "/Users/op/.local/bin/atomic-agent", + label: "the binary", + group: "program" as const, + }, + ]; + return { + targets, + measured: { + targets: targets.map((t) => ({ ...t, exists: true, bytes: 1024 })), + totalBytes: 2048, + }, + devCheckout: false, + installDir: "/Users/op/.local/bin", + ...overrides, + }; +} + +describe("atomic-agent uninstall", () => { + let out: string[]; + let err: string[]; + let deps: UninstallCommandDeps; + const run = vi.fn(); + const resolvePlan = vi.fn(); + const ask = vi.fn(); + + beforeEach(() => { + out = []; + err = []; + run.mockReset(); + resolvePlan.mockReset(); + ask.mockReset(); + run.mockResolvedValue({ removed: [], rcFilesEdited: [], complete: true }); + resolvePlan.mockResolvedValue(makePlan()); + ask.mockResolvedValue("uninstall"); + deps = { + resolvePlan: resolvePlan as unknown as UninstallCommandDeps["resolvePlan"], + run: run as unknown as UninstallCommandDeps["run"], + getStateDir: () => "/Users/op/.atomic-agent", + isTTY: () => true, + ask, + write: (text) => void out.push(text), + writeErr: (text) => void err.push(text), + }; + }); + + const stdout = (): string => out.join(""); + const stderr = (): string => err.join(""); + + it("prints the plan with sizes before asking anything", async () => { + await uninstallCommand([], deps); + expect(stdout()).toContain("/Users/op/.atomic-agent"); + expect(stdout()).toContain("1 KB"); + expect(stdout()).toContain("total: 2 KB"); + }); + + it("warns that the removal is permanent", async () => { + await uninstallCommand([], deps); + expect(stdout()).toContain("THIS CANNOT BE UNDONE"); + }); + + it("requires the word, not a y", async () => { + ask.mockResolvedValue("y"); + const code = await uninstallCommand([], deps); + expect(code).toBe(0); + expect(run).not.toHaveBeenCalled(); + expect(stdout()).toContain("cancelled"); + }); + + it("removes once the word is typed", async () => { + const code = await uninstallCommand([], deps); + expect(code).toBe(0); + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]?.[0].targets).toHaveLength(2); + expect(stdout()).toContain("uninstalled"); + }); + + it("accepts the word with stray case and whitespace", async () => { + ask.mockResolvedValue(" UNINSTALL \n"); + await uninstallCommand([], deps); + expect(run).toHaveBeenCalledOnce(); + }); + + it("removes nothing under --dry-run and never asks", async () => { + const code = await uninstallCommand(["--dry-run"], deps); + expect(code).toBe(0); + expect(ask).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(stdout()).toContain("dry run"); + }); + + it("refuses to run unattended without --yes", async () => { + deps.isTTY = () => false; + const code = await uninstallCommand([], deps); + expect(code).toBe(2); + expect(run).not.toHaveBeenCalled(); + expect(stderr()).toContain("--yes"); + }); + + it("runs unattended with --yes", async () => { + deps.isTTY = () => false; + const code = await uninstallCommand(["--yes"], deps); + expect(code).toBe(0); + expect(ask).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledOnce(); + }); + + it("passes --keep-data and --keep-binary through to the planner", async () => { + await uninstallCommand(["--keep-data", "--yes"], deps); + expect(resolvePlan.mock.calls[0]?.[0]).toMatchObject({ keepData: true }); + resolvePlan.mockClear(); + await uninstallCommand(["--keep-binary", "--yes"], deps); + expect(resolvePlan.mock.calls[0]?.[0]).toMatchObject({ keepBinary: true }); + }); + + it("rejects the two --keep flags together", async () => { + const code = await uninstallCommand(["--keep-data", "--keep-binary"], deps); + expect(code).toBe(2); + expect(stderr()).toContain("would remove nothing"); + }); + + it("skips the permanence warning under --keep-data", async () => { + await uninstallCommand(["--keep-data", "--yes"], deps); + expect(stdout()).not.toContain("THIS CANNOT BE UNDONE"); + }); + + it("rejects an unknown flag", async () => { + const code = await uninstallCommand(["--force"], deps); + expect(code).toBe(2); + expect(stderr()).toContain("unknown option: --force"); + }); + + it("says so and exits 0 when nothing is installed", async () => { + resolvePlan.mockResolvedValue( + makePlan({ targets: [], measured: { targets: [], totalBytes: 0 } }), + ); + const code = await uninstallCommand([], deps); + expect(code).toBe(0); + expect(stdout()).toContain("not installed here"); + expect(run).not.toHaveBeenCalled(); + }); + + it("names the dev checkout instead of pretending to remove a binary", async () => { + resolvePlan.mockResolvedValue(makePlan({ devCheckout: true })); + await uninstallCommand(["--dry-run"], deps); + expect(stdout()).toContain("dev checkout"); + expect(stdout()).toContain("/Users/op/.local/bin"); + }); + + it("reports every failed target and exits 1", async () => { + run.mockResolvedValue({ + removed: [ + { path: "/a", ok: true }, + { path: "/b", ok: false, error: "EPERM" }, + ], + rcFilesEdited: [], + complete: false, + }); + const code = await uninstallCommand(["--yes"], deps); + expect(code).toBe(1); + expect(stderr()).toContain("could not remove /b: EPERM"); + expect(stderr()).toContain("1 of 2 targets"); + }); + + it("tells the operator their PATH needs a fresh shell", async () => { + run.mockResolvedValue({ + removed: [], + rcFilesEdited: ["/Users/op/.zshrc"], + complete: true, + }); + await uninstallCommand(["--yes"], deps); + expect(stdout()).toContain("/Users/op/.zshrc"); + expect(stdout()).toContain("new shell"); + }); + + it("documents itself under --help without touching anything", async () => { + const code = await uninstallCommand(["--help"], deps); + expect(code).toBe(0); + expect(resolvePlan).not.toHaveBeenCalled(); + expect(stdout()).toContain("--dry-run"); + expect(stdout()).toContain("cannot be undone"); + }); +}); diff --git a/src/cli/uninstall-command.ts b/src/cli/uninstall-command.ts new file mode 100644 index 00000000..f7482b1d --- /dev/null +++ b/src/cli/uninstall-command.ts @@ -0,0 +1,263 @@ +import { createInterface } from "node:readline/promises"; + +import { getConfig } from "../config/index.js"; +import { + formatBytes, + resolveUninstallPlan, + runUninstall, + type ResolvedUninstallPlan, +} from "../uninstall/index.js"; + +/** + * The word the operator has to type out. Not `y`: every other confirm + * in this CLI is a `[y/N]`, and muscle memory answers those without + * reading. A word that has to be spelled is the cheapest way to make + * the last keystroke a decision rather than a reflex. + */ +const CONFIRM_WORD = "uninstall"; + +export interface UninstallCommandDeps { + resolvePlan?: typeof resolveUninstallPlan; + run?: typeof runUninstall; + getStateDir?: () => string; + /** Defaults to stdin *and* stdout being a TTY — see `update-command.ts`. */ + isTTY?: () => boolean; + /** Free-text prompt. Defaults to a readline question. */ + ask?: (prompt: string) => Promise; + write?: (text: string) => void; + writeErr?: (text: string) => void; +} + +const HELP = [ + "atomic-agent uninstall — remove atomic-agent and its data from this machine", + "", + "Deletes the state directory (config, memory, sessions, tasks, traces and any", + "downloaded GGUF models), the installed binary and its `atag` alias, the asset", + "directories the installer put beside them, and the PATH line install.sh added", + "to your shell rc file.", + "", + "This cannot be undone. There is no backup. Nothing is uploaded anywhere, and", + "nothing is kept — after this the only trace of atomic-agent on the machine is", + "whatever you copied out yourself.", + "", + "Interactive runs print the full list with sizes and then ask you to type the", + `word \`${CONFIRM_WORD}\`. Non-interactive runs must pass --yes.`, + "", + "Flags:", + " --dry-run Print exactly what would be removed, remove nothing", + " --keep-data Keep the state directory; remove only the program", + " --keep-binary Keep the binary; remove only the data", + " --keep-path Leave the installer's PATH line in your rc file", + " -y, --yes Skip the typed confirmation (for scripts)", + " -h, --help Show this help", + "", + "Exit codes:", + " 0 success (removed, or --dry-run printed the plan, or you declined)", + " 1 operational failure (something could not be removed)", + " 2 usage error (unknown flag, or no TTY and no --yes)", + "", + "Examples:", + " atomic-agent uninstall --dry-run", + " atomic-agent uninstall", + " atomic-agent uninstall --keep-data # reinstall later, keep your memory", +].join("\n") + "\n"; + +interface UninstallFlags { + dryRun: boolean; + keepData: boolean; + keepBinary: boolean; + keepPath: boolean; + yes: boolean; + help: boolean; +} + +function parseArgs( + args: string[], +): { ok: true; flags: UninstallFlags } | { ok: false; error: string } { + const flags: UninstallFlags = { + dryRun: false, + keepData: false, + keepBinary: false, + keepPath: false, + yes: false, + help: false, + }; + for (const arg of args) { + switch (arg) { + case "-h": + case "--help": + flags.help = true; + break; + case "--dry-run": + flags.dryRun = true; + break; + case "--keep-data": + flags.keepData = true; + break; + case "--keep-binary": + flags.keepBinary = true; + break; + case "--keep-path": + flags.keepPath = true; + break; + case "-y": + case "--yes": + flags.yes = true; + break; + default: + return { ok: false, error: `unknown option: ${arg}` }; + } + } + if (flags.keepData && flags.keepBinary) { + return { + ok: false, + error: "--keep-data and --keep-binary together would remove nothing", + }; + } + return { ok: true, flags }; +} + +async function defaultAsk(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return await rl.question(prompt); + } finally { + rl.close(); + } +} + +/** + * `atomic-agent uninstall` — the one command that undoes the install, + * so nobody has to reconstruct an `rm -rf` from the README and get the + * paths wrong in either direction. + * + * The warnings are deliberately stacked and deliberately unpleasant: + * the plan with real sizes, then the sentence saying it is permanent, + * then a word to type. Each one is cheap for someone who means it and + * expensive for someone who does not. + */ +export async function uninstallCommand( + args: string[], + deps: UninstallCommandDeps = {}, +): Promise { + const resolvePlan = deps.resolvePlan ?? resolveUninstallPlan; + const run = deps.run ?? runUninstall; + const getStateDir = deps.getStateDir ?? (() => getConfig().paths.stateDir); + const isTTY = + deps.isTTY ?? (() => process.stdin.isTTY === true && process.stdout.isTTY === true); + const ask = deps.ask ?? defaultAsk; + const write = deps.write ?? ((text: string) => void process.stdout.write(text)); + const writeErr = + deps.writeErr ?? ((text: string) => void process.stderr.write(text)); + + const parsed = parseArgs(args); + if (!parsed.ok) { + writeErr(`${parsed.error}\n`); + return 2; + } + if (parsed.flags.help) { + write(HELP); + return 0; + } + const flags = parsed.flags; + + let plan: ResolvedUninstallPlan; + try { + plan = await resolvePlan({ + stateDir: getStateDir(), + keepData: flags.keepData, + keepBinary: flags.keepBinary, + }); + } catch (err) { + writeErr(`uninstall failed: ${message(err)}\n`); + return 1; + } + + write(renderPlan(plan, flags)); + + if (plan.measured.targets.length === 0) { + write("nothing to remove — atomic-agent is not installed here\n"); + return 0; + } + if (flags.dryRun) { + write("dry run: nothing was removed\n"); + return 0; + } + + if (!flags.yes) { + if (!isTTY()) { + writeErr( + "uninstall needs a terminal to confirm; pass --yes to run unattended\n", + ); + return 2; + } + const answer = await ask(`type ${CONFIRM_WORD} to confirm: `); + if (answer.trim().toLowerCase() !== CONFIRM_WORD) { + write("cancelled — nothing was removed\n"); + return 0; + } + } + + const result = await run({ + targets: plan.targets, + keepPathEntry: flags.keepPath, + onProgress: (line) => write(` ${line}\n`), + }); + + for (const rc of result.rcFilesEdited) { + write(`edited ${rc} — open a new shell for PATH to catch up\n`); + } + const failures = result.removed.filter((entry) => !entry.ok); + if (failures.length > 0) { + for (const failure of failures) { + writeErr(`could not remove ${failure.path}: ${failure.error}\n`); + } + writeErr( + `${failures.length} of ${result.removed.length} targets could not be removed — ` + + "remove them by hand to finish\n", + ); + return 1; + } + write("atomic-agent is uninstalled. Thanks for trying it.\n"); + return 0; +} + +/** The plan, with sizes, as the operator sees it before deciding. */ +function renderPlan(plan: ResolvedUninstallPlan, flags: UninstallFlags): string { + const lines: string[] = ["", "atomic-agent uninstall will remove:", ""]; + for (const target of plan.measured.targets) { + lines.push( + ` ${target.path} (${formatBytes(target.bytes)})`, + ` ${target.label}`, + ); + } + if (plan.measured.targets.length > 0) { + lines.push("", ` total: ${formatBytes(plan.measured.totalBytes)}`); + } + if (plan.devCheckout) { + lines.push( + "", + "no installed binary found — this looks like a dev checkout, so only data", + `is listed. The program itself is removed with git. (looked in ${plan.installDir})`, + ); + } + if (!flags.keepPath) { + lines.push( + "", + "the PATH line install.sh added to your shell rc file will also be removed.", + ); + } + if (!flags.keepData) { + lines.push( + "", + "THIS CANNOT BE UNDONE. There is no backup and no undo: your memory fabric,", + "your session history and any downloaded model weights go with it.", + ); + } + lines.push(""); + return `${lines.join("\n")}\n`; +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/cli/update-command.test.ts b/src/cli/update-command.test.ts new file mode 100644 index 00000000..4aa1b5b5 --- /dev/null +++ b/src/cli/update-command.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + AppUpdateCheckError, + AppUpdateError, + type AppUpdateCheckResult, +} from "../update/index.js"; +import { + updateCommand, + type UpdateCommandDeps, +} from "./update-command.js"; + +function makeResult( + overrides: Partial = {}, +): AppUpdateCheckResult { + return { + updateAvailable: true, + currentVersion: "0.3.1", + latestTag: "v0.3.2", + latestVersion: "0.3.2", + ...overrides, + }; +} + +describe("atomic-agent update", () => { + let stdoutChunks: string[]; + let stderrChunks: string[]; + let deps: Required; + + const runInstaller = vi.fn(); + const check = vi.fn(); + const canSelfUpdate = vi.fn(); + + beforeEach(() => { + stdoutChunks = []; + stderrChunks = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + runInstaller.mockReset(); + check.mockReset(); + canSelfUpdate.mockReset(); + deps = { + checkForAppUpdate: check, + runAppUpdate: runInstaller, + canSelfUpdate, + getRepo: () => "AtomicBot-ai/atomic-agent", + isTTY: () => false, + confirm: async () => true, + }; + check.mockResolvedValue(makeResult()); + canSelfUpdate.mockReturnValue(true); + runInstaller.mockResolvedValue({ ok: true, installDir: "/tmp/install" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function stdout(): string { + return stdoutChunks.join(""); + } + + function stderr(): string { + return stderrChunks.join(""); + } + + it("prints help and exits 0 for -h and --help", async () => { + expect(await updateCommand(["-h"], deps)).toBe(0); + expect(await updateCommand(["--help"], deps)).toBe(0); + expect(stdout()).toMatch(/atomic-agent update/); + expect(stdout()).toMatch(/--check/); + expect(check).not.toHaveBeenCalled(); + }); + + it("exits 2 for an unknown flag", async () => { + expect(await updateCommand(["--bogus"], deps)).toBe(2); + expect(stderr()).toMatch(/unknown option: --bogus/); + expect(check).not.toHaveBeenCalled(); + }); + + it("exits 2 for --version without a value", async () => { + expect(await updateCommand(["--version"], deps)).toBe(2); + expect(stderr()).toMatch(/--version requires a tag/); + }); + + it("exits 2 when --check is combined with --version", async () => { + expect(await updateCommand(["--check", "--version", "v0.3.2"], deps)).toBe( + 2, + ); + expect(stderr()).toMatch(/--check and --version are mutually exclusive/); + }); + + it("--check reports up to date and exits 0 without installing", async () => { + check.mockResolvedValue( + makeResult({ updateAvailable: false, latestTag: "v0.3.1", latestVersion: "0.3.1" }), + ); + expect(await updateCommand(["--check"], deps)).toBe(0); + expect(stdout()).toMatch(/up to date \(0\.3\.1\)/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("--check reports the newer version and exits 0 without installing", async () => { + expect(await updateCommand(["--check"], deps)).toBe(0); + expect(stdout()).toMatch(/update available: 0\.3\.1 → 0\.3\.2/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("--check exits 1 when the check itself fails", async () => { + check.mockRejectedValue(new AppUpdateCheckError("HTTP 403", 403)); + expect(await updateCommand(["--check"], deps)).toBe(1); + expect(stderr()).toMatch(/HTTP 403/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("refuses to self-update in a dev build, exiting 1", async () => { + canSelfUpdate.mockReturnValue(false); + expect(await updateCommand([], deps)).toBe(1); + expect(stderr()).toMatch(/installed binary/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("reports up to date and exits 0 without installing when current", async () => { + check.mockResolvedValue( + makeResult({ updateAvailable: false, latestTag: "v0.3.1", latestVersion: "0.3.1" }), + ); + expect(await updateCommand([], deps)).toBe(0); + expect(stdout()).toMatch(/up to date \(0\.3\.1\)/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("updates in place when a newer version exists (non-interactive)", async () => { + expect(await updateCommand([], deps)).toBe(0); + expect(stdout()).toMatch(/current: 0\.3\.1 → latest: 0\.3\.2/); + expect(runInstaller).toHaveBeenCalledTimes(1); + expect(runInstaller).toHaveBeenCalledWith( + expect.objectContaining({ + repo: "AtomicBot-ai/atomic-agent", + version: undefined, + }), + ); + expect(stdout()).toMatch(/updated to 0\.3\.2/); + }); + + it("streams installer lines prefixed with [update]", async () => { + runInstaller.mockImplementation( + async (opts?: { onLine?: (line: string) => void }) => { + opts?.onLine?.("downloading atomic-agent"); + opts?.onLine?.("installed atomic-agent to /tmp/install"); + return { ok: true, installDir: "/tmp/install" }; + }, + ); + expect(await updateCommand([], deps)).toBe(0); + expect(stdout()).toMatch(/\[update\] downloading atomic-agent/); + expect(stdout()).toMatch(/\[update\] installed atomic-agent/); + }); + + it("prompts in an interactive terminal and cancels on 'no'", async () => { + const confirm = vi.fn().mockResolvedValue(false); + expect(await updateCommand([], { ...deps, isTTY: () => true, confirm })).toBe( + 0, + ); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledWith("update to 0.3.2? [y/N] "); + expect(stdout()).toMatch(/update cancelled/); + expect(runInstaller).not.toHaveBeenCalled(); + }); + + it("proceeds when the interactive prompt is accepted", async () => { + const confirm = vi.fn().mockResolvedValue(true); + expect(await updateCommand([], { ...deps, isTTY: () => true, confirm })).toBe( + 0, + ); + expect(confirm).toHaveBeenCalledTimes(1); + expect(runInstaller).toHaveBeenCalledTimes(1); + }); + + it("exits 1 and reports the installer failure", async () => { + runInstaller.mockRejectedValue( + new AppUpdateError("install script exited with code 7"), + ); + expect(await updateCommand([], deps)).toBe(1); + expect(stderr()).toMatch(/install script exited with code 7/); + expect(stdout()).not.toMatch(/updated to/); + }); + + it("--version pins a specific tag even when the running version is newer", async () => { + check.mockResolvedValue( + makeResult({ updateAvailable: false, latestTag: "v0.3.1", latestVersion: "0.3.1" }), + ); + expect(await updateCommand(["--version", "v0.3.2"], deps)).toBe(0); + expect(stdout()).toMatch(/installing v0\.3\.2/); + expect(runInstaller).toHaveBeenCalledWith( + expect.objectContaining({ version: "v0.3.2" }), + ); + }); +}); diff --git a/src/cli/update-command.ts b/src/cli/update-command.ts new file mode 100644 index 00000000..e3b7c854 --- /dev/null +++ b/src/cli/update-command.ts @@ -0,0 +1,211 @@ +import { createInterface } from "node:readline/promises"; + +import { getConfig } from "../config/index.js"; +import { + checkForAppUpdate, + runAppUpdate, + canSelfUpdate, +} from "../update/index.js"; + +/** + * Dependency seam for `updateCommand`. Defaults to the real + * `src/update/` functions; tests inject stubs so nothing spawns a + * process or hits the network. All deps are optional. + */ +export interface UpdateCommandDeps { + checkForAppUpdate?: typeof checkForAppUpdate; + runAppUpdate?: typeof runAppUpdate; + canSelfUpdate?: typeof canSelfUpdate; + /** Resolves the `update.repo` config value. Defaults to `getConfig().update.repo`. */ + getRepo?: () => string; + /** + * Whether an interactive answer is possible. Defaults to **stdin** + * being a TTY, not stdout: the prompt is printed to stdout but the + * answer is read from stdin, and `atomic-agent update < /dev/null` + * (or any wrapper that gives stdout a pty and stdin a pipe) would + * otherwise print the question and hang on a stream already at EOF. + */ + isTTY?: () => boolean; + /** Interactive y/n confirmation. Defaults to a readline prompt. */ + confirm?: (prompt: string) => Promise; +} + +const HELP = [ + "atomic-agent update — self-update the installed binary from GitHub Releases", + "", + "Checks GitHub Releases for a newer published version and re-runs the", + "canonical installer (install.sh / install.ps1) in place, exactly like the", + "TUI's in-app update. Only meaningful for the installed SEA binary — a dev", + "checkout is updated via git. The running process is not restarted; the", + "next launch picks up the new binary.", + "", + "Flags:", + " --check Check only: report current vs latest, install nothing", + " --version Install a specific release tag (e.g. v0.3.2) instead of latest", + " -h, --help Show this help", + "", + "Exit codes:", + " 0 success (up to date, updated, or --check ran fine)", + " 1 operational failure (check failed, not self-updatable, installer failed)", + " 2 usage error (unknown flag, missing --version value, conflicting flags)", + "", + "Examples:", + " atomic-agent update", + " atomic-agent update --check", + " atomic-agent update --version v0.3.2", +].join("\n") + "\n"; + +/** Parse flags into a discriminated plan; returns a usage error string on bad input. */ +function parseArgs( + args: string[], +): { ok: true; checkOnly: boolean; version?: string } | { ok: false; error: string } { + let checkOnly = false; + let version: string | undefined; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "-h" || arg === "--help") { + return { ok: true, checkOnly: false }; + } + if (arg === "--check") { + checkOnly = true; + continue; + } + if (arg === "--version") { + const value = args[i + 1]; + if (!value || value.startsWith("-")) { + return { ok: false, error: "--version requires a tag (e.g. v0.3.2)" }; + } + version = value; + i += 1; + continue; + } + return { ok: false, error: `unknown option: ${arg}` }; + } + if (checkOnly && version) { + return { + ok: false, + error: "--check and --version are mutually exclusive", + }; + } + return { ok: true, checkOnly, version }; +} + +async function defaultConfirm(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(prompt); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +/** + * `atomic-agent update` — check GitHub Releases and re-run the canonical + * installer in place when a newer version exists. Mirrors the TUI's + * in-app update for headless / `run` / sidecar users who never see it. + * + * Exit codes follow the documented CLI contract: 0 success, 1 operational + * failure, 2 usage error. + */ +export async function updateCommand( + args: string[], + deps: UpdateCommandDeps = {}, +): Promise { + const check = deps.checkForAppUpdate ?? checkForAppUpdate; + const run = deps.runAppUpdate ?? runAppUpdate; + const canSelf = deps.canSelfUpdate ?? canSelfUpdate; + const getRepo = deps.getRepo ?? (() => getConfig().update.repo); + // stdin, not stdout: the answer comes from stdin, so that is the + // stream whose interactivity decides whether asking is possible. + const isTTY = + deps.isTTY ?? (() => process.stdin.isTTY === true && process.stdout.isTTY === true); + const confirm = deps.confirm ?? defaultConfirm; + const repo = getRepo(); + + const parsed = parseArgs(args); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n`); + return 2; + } + if (args.includes("-h") || args.includes("--help")) { + process.stdout.write(HELP); + return 0; + } + + try { + // --version installs a pinned tag regardless of what "latest" says; + // it still refuses dev builds and still confirms interactively. + if (parsed.version) { + if (!canSelf()) { + process.stderr.write( + "self-update is only supported for the installed binary; " + + "update via git in development\n", + ); + return 1; + } + process.stdout.write(`installing ${parsed.version}…\n`); + if (isTTY()) { + const ok = await confirm(`update to ${parsed.version}? [y/N] `); + if (!ok) { + process.stdout.write("update cancelled\n"); + return 0; + } + } + await run({ repo, version: parsed.version, onLine: streamUpdateLine }); + process.stdout.write(`updated to ${parsed.version}\n`); + return 0; + } + + const result = await check({ repo }); + if (parsed.checkOnly) { + if (!result.updateAvailable) { + process.stdout.write(`up to date (${result.currentVersion})\n`); + } else { + process.stdout.write( + `update available: ${result.currentVersion} → ${result.latestVersion}\n`, + ); + } + return 0; + } + + if (!result.updateAvailable) { + process.stdout.write(`up to date (${result.currentVersion})\n`); + return 0; + } + if (!canSelf()) { + process.stderr.write( + "self-update is only supported for the installed binary; " + + "update via git in development\n", + ); + return 1; + } + + process.stdout.write( + `current: ${result.currentVersion} → latest: ${result.latestVersion}\n`, + ); + if (isTTY()) { + const ok = await confirm(`update to ${result.latestVersion}? [y/N] `); + if (!ok) { + process.stdout.write("update cancelled\n"); + return 0; + } + } + await run({ + repo, + version: undefined, + onLine: streamUpdateLine, + }); + process.stdout.write(`updated to ${result.latestVersion}\n`); + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`update failed: ${message}\n`); + return 1; + } +} + +/** Stream installer stdout/stderr lines to the terminal, one per line. */ +function streamUpdateLine(line: string): void { + process.stdout.write(`[update] ${line}\n`); +} diff --git a/src/config/config-file.test.ts b/src/config/config-file.test.ts index fcb676ee..10d4b956 100644 --- a/src/config/config-file.test.ts +++ b/src/config/config-file.test.ts @@ -541,6 +541,70 @@ describe("user config file IO", () => { expect(() => readUserConfigFileSync(path)).toThrow(ConfigValidationError); }); + // The rollback trap: 0.3.3 migrated config.json to v41, then the + // published release went back to 0.3.2, whose supported-version list + // stopped at 40 — every command died at boot. These four pin the + // behaviour that makes a rollback survivable. + describe("a config written by a newer build", () => { + const future = { + ...USER_CONFIG_DEFAULTS, + version: USER_CONFIG_VERSION + 1, + blockFromTheFuture: { keep: "me" }, + }; + + it("is read rather than rejected", () => { + const path = getUserConfigPath(dir); + writeFileSync(path, JSON.stringify(future, null, 2) + "\n", "utf8"); + const loaded = readUserConfigFileSync(path); + expect(loaded?.version).toBe(USER_CONFIG_VERSION + 1); + expect((loaded as unknown as Record).blockFromTheFuture).toEqual( + { keep: "me" }, + ); + }); + + it("is not rewritten at startup", () => { + const path = getUserConfigPath(dir); + const text = JSON.stringify(future, null, 2) + "\n"; + writeFileSync(path, text, "utf8"); + + const warn = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const result = ensureUserConfigFileSync(path); + expect(result.version).toBe(USER_CONFIG_VERSION + 1); + // No "migrated config v43 → v42" line, and the bytes are untouched. + expect(warn).not.toHaveBeenCalled(); + expect(readFileSync(path, "utf8")).toBe(text); + warn.mockRestore(); + }); + + // Guards the ~15 call sites that spread a config object and write it + // straight back, several of which never re-validate. + it("cannot be downgraded by a later write", () => { + const path = getUserConfigPath(dir); + writeFileSync(path, JSON.stringify(future, null, 2) + "\n", "utf8"); + + writeUserConfigFileSync(path, USER_CONFIG_DEFAULTS); + + const onDisk = JSON.parse(readFileSync(path, "utf8")) as { + version: number; + }; + expect(onDisk.version).toBe(USER_CONFIG_VERSION + 1); + }); + + it("still lets an older file be migrated up", () => { + const path = getUserConfigPath(dir); + writeFileSync(path, JSON.stringify({ version: 39 }, null, 2) + "\n", "utf8"); + + const warn = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const result = ensureUserConfigFileSync(path); + expect(result.version).toBe(USER_CONFIG_VERSION); + expect(warn).toHaveBeenCalledOnce(); + expect( + (JSON.parse(readFileSync(path, "utf8")) as { version: number }).version, + ).toBe(USER_CONFIG_VERSION); + warn.mockRestore(); + }); + }); + it("ensureUserConfigFileSync leaves an up-to-date file untouched on disk", () => { const path = getUserConfigPath(dir); writeUserConfigFileSync(path, USER_CONFIG_DEFAULTS); diff --git a/src/config/config-file.ts b/src/config/config-file.ts index 197db5dc..f64826ee 100644 --- a/src/config/config-file.ts +++ b/src/config/config-file.ts @@ -60,10 +60,32 @@ export function readUserConfigFileSync(path: string): UserConfigFile | null { /** * Atomically write the user config file: tmp file + rename. Creates * the parent directory as needed. + * + * The written `version` is never lower than the one already on disk. A + * dozen call sites build their payload by spreading a config object and + * writing it back, and several skip `parseUserConfigFile` entirely, so + * the guard lives here rather than in the parse: this is the only + * function in the tree that writes `config.json`. Without it, an older + * build that opens a newer file relabels it on the first settings + * toggle, and the version field is load-bearing — `version < 41` forces + * `localModels.managed.autoUpdate` back on, `version < 22` overrides an + * explicit `memory.*` opt-out, `version < 25` rewrites + * `http.approvalMode`. Downgrading the label silently reverts choices + * the user made. */ export function writeUserConfigFileSync(path: string, data: UserConfigFile): void { mkdirSync(dirname(path), { recursive: true }); - const payload = JSON.stringify(data, null, 2) + "\n"; + const onDisk = readVersionFieldFromFileSync(path); + const raised = onDisk !== null && onDisk > data.version; + const version = raised ? (onDisk as number) : data.version; + if (raised) { + process.stderr.write( + `[atomic-agent] kept config version ${onDisk} (this build writes v${data.version}) at ${path}\n`, + ); + } + const payload = + JSON.stringify(version === data.version ? data : { ...data, version }, null, 2) + + "\n"; const tmp = `${path}.tmp-${process.pid}`; writeFileSync(tmp, payload, "utf8"); renameSync(tmp, path); @@ -82,28 +104,80 @@ export function writeUserConfigFileSync(path: string, data: UserConfigFile): voi * blocks (e.g. `vision` in v6) are filled with defaults. * - File present at the current `version`: parse and return without * touching the file on disk. + * - File present at a NEWER `version` (an install that was rolled back, + * or two builds sharing one state dir): parse with this build's + * schema and return without touching the file. Unknown top-level + * blocks ride along untouched; the newer `version` is kept. * * The return value is always the validated, normalised contents. */ +/** + * Where "created default config …" and "migrated config …" go. + * + * They are diagnostics, not output, and the TUI is the one caller that + * cannot afford them on stderr: it prints them *before* the alternate + * screen is entered, so a first run opens with two raw lines above the + * interface — a file path and a warning, as the first thing a new user + * reads. A sink lets that caller collect them and replay them inside the + * UI instead. Every other caller (the CLI, the sidecar) keeps stderr, + * which is the default. + */ +export type ConfigNoticeSink = (line: string) => void; + +let configNoticeSink: ConfigNoticeSink | null = null; + +export function setConfigNoticeSink(sink: ConfigNoticeSink | null): void { + configNoticeSink = sink; +} + +function emitConfigNotice(line: string): void { + if (configNoticeSink) { + configNoticeSink(line); + return; + } + process.stderr.write(`${line}\n`); +} + export function ensureUserConfigFileSync(path: string): UserConfigFile { const raw = readRawUserConfigFileSync(path); if (!raw) { writeUserConfigFileSync(path, USER_CONFIG_DEFAULTS); - process.stderr.write( - `[atomic-agent] created default config at ${path}\n`, - ); + emitConfigNotice(`[atomic-agent] created default config at ${path}`); return USER_CONFIG_DEFAULTS; } - const parsed = parseUserConfigFile(raw.parsed); - if (raw.originalVersion !== USER_CONFIG_VERSION) { + const parsed = withConfigPathInError(path, () => parseUserConfigFile(raw.parsed)); + // Migrate upward only. `!==` would treat "written by a newer build" as + // "needs migrating" and rewrite the file down to this build's schema at + // startup, before the user has touched anything — the exact move that + // turns a rollback into data loss. + if (raw.originalVersion === null || raw.originalVersion < USER_CONFIG_VERSION) { writeUserConfigFileSync(path, parsed); - process.stderr.write( - `[atomic-agent] migrated config v${raw.originalVersion} → v${USER_CONFIG_VERSION} at ${path}\n`, + emitConfigNotice( + `[atomic-agent] migrated config v${raw.originalVersion} → v${USER_CONFIG_VERSION} at ${path}`, ); } return parsed; } +/** + * Name the file and a way out when the config is unusable. `getConfig()` + * runs ahead of every command, so a validation failure here is the first + * thing a user sees, and the bare `invalid config: version: …` it used to + * print names neither the file nor a remedy — leaving nothing to do but + * search the source. + */ +function withConfigPathInError(path: string, read: () => T): T { + try { + return read(); + } catch (err) { + if (!(err instanceof ConfigValidationError)) throw err; + throw new ConfigValidationError( + err.field, + `${err.reason} (in ${path}) — edit or delete that file to start from defaults, or point ATOMIC_AGENT_STATE_DIR elsewhere`, + ); + } +} + interface RawUserConfigFile { /** Untouched parsed JSON tree, ready for `parseUserConfigFile`. */ parsed: unknown; @@ -134,6 +208,21 @@ function readRawUserConfigFileSync(path: string): RawUserConfigFile | null { return { parsed, originalVersion: readVersionField(parsed) }; } +/** + * The `version` currently on disk, or `null` when the file is absent, + * unreadable, not JSON, or carries no numeric version. Deliberately + * total: this runs on the write path, where a malformed existing file + * must not stop the caller from replacing it. + */ +function readVersionFieldFromFileSync(path: string): number | null { + if (!existsSync(path)) return null; + try { + return readVersionField(JSON.parse(readFileSync(path, "utf8"))); + } catch { + return null; + } +} + function readVersionField(parsed: unknown): number | null { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; const value = (parsed as Record).version; diff --git a/src/config/config-paths.test.ts b/src/config/config-paths.test.ts new file mode 100644 index 00000000..09fdd9b4 --- /dev/null +++ b/src/config/config-paths.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + deleteConfigPath, + isSafeConfigPath, + readConfigPath, + writeConfigPath, +} from "./config-paths.js"; + +describe("config path helpers reject prototype-reaching keys", () => { + it("writeConfigPath throws rather than polluting Object.prototype", () => { + const tree: Record = {}; + expect(() => writeConfigPath(tree, "__proto__.polluted", "yes")).toThrow( + /unsafe path/, + ); + expect(() => + writeConfigPath(tree, "constructor.prototype.polluted", "yes"), + ).toThrow(/unsafe path/); + + // The real assertion: nothing leaked onto every object in the process. + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.prototype).not.toHaveProperty("polluted"); + }); + + it("writeConfigPath still writes ordinary nested keys", () => { + const tree: Record = {}; + writeConfigPath(tree, "localModels.managed.autoUpdate", false); + expect(tree).toEqual({ + localModels: { managed: { autoUpdate: false } }, + }); + }); + + it("readConfigPath returns undefined for inherited properties", () => { + // A bare `node[segment]` would hand back Object.prototype.constructor + // here, reporting a value the config file does not contain. + expect(readConfigPath({}, "constructor")).toBeUndefined(); + expect(readConfigPath({}, "toString")).toBeUndefined(); + expect(readConfigPath({ a: { b: 1 } }, "a.b")).toBe(1); + expect(readConfigPath({ a: { b: 0 } }, "a.b")).toBe(0); + expect(readConfigPath({ a: { b: false } }, "a.b")).toBe(false); + }); + + it("deleteConfigPath refuses unsafe paths and inherited keys", () => { + expect(deleteConfigPath({}, "__proto__.x")).toBe(false); + expect(deleteConfigPath({}, "constructor")).toBe(false); + expect(Object.prototype).not.toHaveProperty("x"); + }); + + it("deleteConfigPath still prunes a real emptied branch", () => { + const tree: Record = { a: { b: { c: 1 } }, keep: 2 }; + expect(deleteConfigPath(tree, "a.b.c")).toBe(true); + expect(tree).toEqual({ keep: 2 }); + }); + + it("isSafeConfigPath names the three dangerous segments", () => { + expect(isSafeConfigPath("agent.maxSteps")).toBe(true); + expect(isSafeConfigPath("__proto__")).toBe(false); + expect(isSafeConfigPath("a.constructor.b")).toBe(false); + expect(isSafeConfigPath("a.prototype")).toBe(false); + }); +}); diff --git a/src/config/config-paths.ts b/src/config/config-paths.ts new file mode 100644 index 00000000..1ccd391d --- /dev/null +++ b/src/config/config-paths.ts @@ -0,0 +1,297 @@ +import { mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { USER_CONFIG_DEFAULTS } from "./config-schema.js"; + +/** + * Dotted-key addressing for `atomic-agent config get|set|unset|list`. + * + * The shape of the config is derived from `USER_CONFIG_DEFAULTS` at + * module load rather than hand-listed, so a new block in the schema + * becomes addressable without touching this file. + * + * Deliberately absent: any notion of a *type* per key. `parseUserConfigFile` + * already coerces strings ("false" → false, "19099" → 19099 via `parseBool` + * / the numeric parsers), enforces bounds and enums, and reports failures + * as `ConfigValidationError` with the dotted path already in the message. + * A type table here would be a second source of truth that silently drifts + * from the schema; instead `set` substitutes the raw string and lets the + * schema decide. See `config-command.ts`. + */ + +/** A leaf that `config set` can address, and its default value. */ +export interface ConfigLeaf { + /** Dotted path, e.g. `localModels.managed.autoUpdate`. */ + readonly key: string; + /** Default value from `USER_CONFIG_DEFAULTS`. */ + readonly defaultValue: unknown; + /** + * True when the default is an array. Arrays have no single-token + * spelling that would not be an invented mini-language (indices, + * append syntax, separator escaping), so `set` refuses them and points + * at the whole-file JSON form. `unset` still works — restoring the + * default needs no syntax. + */ + readonly isArray: boolean; +} + +function buildIndex(): { + leaves: Map; + branches: Set; +} { + const leaves = new Map(); + const branches = new Set(); + const walk = (node: Record, prefix: string[]): void => { + for (const [name, value] of Object.entries(node)) { + const path = [...prefix, name]; + const key = path.join("."); + // Arrays and null are leaves: `null` is a real value in this schema + // (tri-state toggles, "no override"), not an empty branch to descend. + if (value !== null && !Array.isArray(value) && typeof value === "object") { + branches.add(key); + walk(value as Record, path); + continue; + } + leaves.set(key, { key, defaultValue: value, isArray: Array.isArray(value) }); + } + }; + walk(USER_CONFIG_DEFAULTS as unknown as Record, []); + return { leaves, branches }; +} + +const INDEX = buildIndex(); + +/** + * `version` is owned by the schema's migration path + * (`ensureUserConfigFileSync` bumps it); a user pinning it by hand + * produces a file the migrator will disagree with, so it is not settable. + */ +const READ_ONLY_KEYS = new Set(["version"]); + +/** Every addressable leaf, in declaration order. */ +export function listConfigLeaves(): readonly ConfigLeaf[] { + return [...INDEX.leaves.values()]; +} + +/** Look up a leaf by dotted key, or `undefined` if it is not one. */ +export function findConfigLeaf(key: string): ConfigLeaf | undefined { + return INDEX.leaves.get(key); +} + +/** True when `key` names an object node (e.g. `localModels.managed`). */ +export function isConfigBranch(key: string): boolean { + return INDEX.branches.has(key); +} + +/** True when `key` exists but the schema, not the user, owns its value. */ +export function isReadOnlyConfigKey(key: string): boolean { + return READ_ONLY_KEYS.has(key); +} + +/** + * Nearest known leaf to a typo, or `null` when nothing is close enough. + * + * The threshold is deliberately tight (edit distance <= 2, and never more + * than a third of the key's length): a wrong suggestion sends the user + * to edit a real but unintended setting, which is worse than no + * suggestion at all. + */ +export function suggestConfigKey(key: string): string | null { + const target = key.toLowerCase(); + let best: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const leaf of INDEX.leaves.keys()) { + const distance = editDistance(target, leaf.toLowerCase()); + if (distance < bestDistance) { + bestDistance = distance; + best = leaf; + } + } + if (best === null) return null; + const ceiling = Math.min(2, Math.floor(key.length / 3)); + return bestDistance <= ceiling ? best : null; +} + +/** Levenshtein distance, two-row rolling table. */ +function editDistance(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + let previous = Array.from({ length: b.length + 1 }, (_, i) => i); + let current = new Array(b.length + 1).fill(0); + for (let i = 1; i <= a.length; i += 1) { + current[0] = i; + for (let j = 1; j <= b.length; j += 1) { + const substitution = previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1); + current[j] = Math.min(previous[j]! + 1, current[j - 1]! + 1, substitution); + } + [previous, current] = [current, previous]; + } + return previous[b.length]!; +} + +/** + * Leaf names whose *values* would be secrets if this schema ever held + * one. Today the config file stores only env var *names* + * (`web.search.exa.apiKeyEnv`), with the secrets themselves in `.env` — + * but `config get`/`list` print to a terminal that gets pasted into bug + * reports, so the masking rule is in place before a real secret lands in + * the schema and quietly gets printed. + * + * The name is matched on whole camelCase words of the last path segment, + * in any position, so both `apiKeyEnv` and a future `authToken` are + * caught. Name alone is not enough, though: `agent.tokenBudget` and + * `memory.profile.maxTokens` are step counters that happen to contain + * "token". The value's type is the tiebreaker — a number is a budget, a + * non-empty string is the thing worth hiding — so masking is decided in + * `formatConfigValue`, where the value is in hand. + */ +const SECRET_NAMES = new Set(["secret", "token", "apikey", "password"]); + +/** + * True when a leaf's name suggests its value is a credential. Callers + * that print values should use {@link formatConfigValue}, which also + * applies the value-type check described above. + */ +export function isSecretConfigKey(key: string): boolean { + const last = key.slice(key.lastIndexOf(".") + 1); + const words = last.split(/(?=[A-Z])/).map((word) => word.toLowerCase()); + if (words.some((word) => SECRET_NAMES.has(word))) return true; + // `apiKey`/`apiKeyEnv`: the secret noun spans two camelCase words. + return words.some( + (word, i) => SECRET_NAMES.has(word + (words[i + 1] ?? "")), + ); +} + +/** Render a value for `get`/`list`, masking anything secret-shaped. */ +export function formatConfigValue(key: string, value: unknown): string { + if (isSecretConfigKey(key) && typeof value === "string" && value.length > 0) { + return "***"; + } + return JSON.stringify(value); +} + +/** Read a dotted path out of a config tree, or `undefined` if absent. */ +export function readConfigPath(tree: unknown, key: string): unknown { + let node: unknown = tree; + for (const segment of key.split(".")) { + if (node === null || typeof node !== "object" || Array.isArray(node)) { + return undefined; + } + // Own properties only: a bare `[segment]` would happily return + // `Object.prototype.constructor` for a key named "constructor", + // reporting a value the config file does not contain. + if (!Object.hasOwn(node, segment)) return undefined; + node = (node as Record)[segment]; + } + return node; +} + +/** + * Segments that must never be walked or assigned through. Writing to + * `__proto__` mutates `Object.prototype` for the whole process, and + * `constructor.prototype` reaches it the long way round. + */ +const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); + +/** Whether a dotted key is safe to walk. Exported for the callers' guards. */ +export function isSafeConfigPath(key: string): boolean { + return key.split(".").every((s) => !UNSAFE_PATH_SEGMENTS.has(s)); +} + +/** + * Write `value` at a dotted path in a raw (on-disk) config tree, + * creating intermediate objects as needed. Mutates `tree`. + * + * A non-object sitting where a branch must go (including an array, which + * `typeof` calls "object") is replaced: it cannot be a valid parent, and + * `parseUserConfigFile` will reject the result anyway if the shape is + * wrong — nothing is written until it passes. + * + * Throws on a path containing `__proto__`, `constructor` or `prototype`. + * Callers today filter keys through the schema allowlist first, so this is + * unreachable from the CLI — but the guard lives here, next to the + * assignment, rather than depending on every future caller validating as + * strictly. + */ +export function writeConfigPath( + tree: Record, + key: string, + value: unknown, +): void { + if (!isSafeConfigPath(key)) { + throw new Error(`config: refusing to write unsafe path ${key}`); + } + const segments = key.split("."); + let node = tree; + for (const segment of segments.slice(0, -1)) { + const child = node[segment]; + if ( + child === null || + typeof child !== "object" || + Array.isArray(child) || + !Object.hasOwn(node, segment) + ) { + node[segment] = {}; + } + node = node[segment] as Record; + } + node[segments[segments.length - 1]!] = value; +} + +/** + * Atomically write a *sparse* config tree — only the keys the user has + * actually set — using the same tmp + rename discipline as + * `writeUserConfigFileSync`. + * + * Separate from `writeUserConfigFileSync` on purpose: that one takes a + * fully-defaulted `UserConfigFile`, which is right for the whole-file + * `set` and for migration, but wrong for a point edit. Writing the + * defaulted tree back would expand a hand-written four-line config into + * every key in the schema and freeze today's defaults into the user's + * file, so a later change to a default would silently not reach them. + * The caller validates the tree through `parseUserConfigFile` first and + * only reaches this function once that succeeds. + */ +export function writeRawUserConfigFileSync( + path: string, + tree: Record, +): void { + mkdirSync(dirname(path), { recursive: true }); + const payload = JSON.stringify(tree, null, 2) + "\n"; + const tmp = `${path}.tmp-${process.pid}`; + writeFileSync(tmp, payload, "utf8"); + renameSync(tmp, path); +} + +/** + * Delete a dotted path from a raw config tree, pruning any intermediate + * objects the deletion leaves empty so `unset` does not accumulate + * `{"memory":{"links":{}}}` husks. Returns true when something was removed. + */ +export function deleteConfigPath( + tree: Record, + key: string, +): boolean { + if (!isSafeConfigPath(key)) return false; + const segments = key.split("."); + const chain: Record[] = [tree]; + let node: Record = tree; + for (const segment of segments.slice(0, -1)) { + const child = Object.hasOwn(node, segment) ? node[segment] : undefined; + if (child === null || typeof child !== "object" || Array.isArray(child)) { + return false; + } + node = child as Record; + chain.push(node); + } + const last = segments[segments.length - 1]!; + // `in` walks the prototype chain; only an own key is really present. + if (!Object.hasOwn(node, last)) return false; + delete node[last]; + for (let i = chain.length - 1; i > 0; i -= 1) { + if (Object.keys(chain[i]!).length > 0) break; + delete chain[i - 1]![segments[i - 1]!]; + } + return true; +} diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 2f72bcf5..b3af5574 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -7,6 +7,80 @@ import { parseUserConfigFile, } from "./config-schema.js"; +describe("tui.onboarding (config v43, extended in v45)", () => { + it("defaults every stamp to null on a file that predates the block", () => { + const parsed = parseUserConfigFile({ version: 42, tui: { theme: "nord" } }); + expect(parsed.tui.onboarding).toEqual({ + completedAt: null, + introSeenAt: null, + skippedAt: null, + proposedSecondBackendAt: null, + localSetupSeenAt: null, + }); + expect(parsed.tui.theme).toBe("nord"); + }); + + it("still accepts a v44 file — the customModels release — as input", () => { + const parsed = parseUserConfigFile({ version: 44, tui: { theme: "nord" } }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(parsed.tui.onboarding.localSetupSeenAt).toBeNull(); + }); + + it("reads a v43 file as never having opened the local list", () => { + const stamp = "2026-08-21T18:04:05.000Z"; + const parsed = parseUserConfigFile({ + version: 43, + tui: { onboarding: { completedAt: stamp } }, + }); + expect(parsed.tui.onboarding.completedAt).toBe(stamp); + expect(parsed.tui.onboarding.localSetupSeenAt).toBeNull(); + }); + + it("round-trips localSetupSeenAt", () => { + const stamp = "2026-08-22T07:15:00.000Z"; + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { onboarding: { localSetupSeenAt: stamp } }, + }); + expect(parsed.tui.onboarding.localSetupSeenAt).toBe(stamp); + }); + + it("rejects a localSetupSeenAt that is not a date", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { onboarding: { localSetupSeenAt: "earlier" } }, + }), + ).toThrow(ConfigValidationError); + }); + + it("round-trips ISO stamps", () => { + const stamp = "2026-08-21T18:04:05.000Z"; + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { onboarding: { completedAt: stamp, introSeenAt: stamp } }, + }); + expect(parsed.tui.onboarding.completedAt).toBe(stamp); + expect(parsed.tui.onboarding.introSeenAt).toBe(stamp); + expect(parsed.tui.onboarding.skippedAt).toBeNull(); + }); + + it("rejects a stamp that is not a date", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { onboarding: { completedAt: "soon" } }, + }), + ).toThrow(ConfigValidationError); + }); + + it("rejects a non-object onboarding block", () => { + expect(() => + parseUserConfigFile({ version: USER_CONFIG_VERSION, tui: { onboarding: true } }), + ).toThrow(ConfigValidationError); + }); +}); + describe("parseUserConfigFile", () => { it("returns defaults when all fields are missing", () => { const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); @@ -66,8 +140,33 @@ describe("parseUserConfigFile", () => { expect(parsed.agent.approvalLevel).toBe(3); }); + it("takes a numeric string, like every other number in this file", () => { + // `config set ` hands the schema the raw argv string on + // purpose — guessing the type at the CLI would be a second source of + // truth that drifts the moment a field changes type — and every + // other numeric key coerces accordingly. `approvalLevel` was the one + // exception, which made the ladder the only key the dotted-key + // editor could not write, and it said so with a message that asked + // for exactly what it had been given. + for (const [input, want] of [ + ["3", 3], + [" 5 ", 5], + ["1", 1], + ] as const) { + expect( + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + agent: { approvalLevel: input }, + }).agent.approvalLevel, + `${JSON.stringify(input)} should be accepted`, + ).toBe(want); + } + }); + it("rejects out-of-range or non-integer agent.approvalLevel", () => { - for (const bad of [0, 6, 2.5, "3", true]) { + // A string that names a number is fine; a string that does not, a + // fractional level, and anything off the 1..5 ladder are not. + for (const bad of [0, 6, 2.5, "2.5", "high", "", true]) { expect(() => parseUserConfigFile({ version: USER_CONFIG_VERSION, @@ -84,12 +183,97 @@ describe("parseUserConfigFile", () => { ).toBe(1); }); - it("rejects unsupported version", () => { - expect(() => parseUserConfigFile({ version: 99 })).toThrow( + it("rejects a non-numeric version", () => { + expect(() => parseUserConfigFile({ version: "41" })).toThrow( + ConfigValidationError, + ); + expect(() => parseUserConfigFile({ version: Number.NaN })).toThrow( ConfigValidationError, ); }); + // A build that is rolled back, or a second install sharing one state + // dir, meets a file from the future. Throwing here bricks every command + // — `getConfig()` runs before all of them — so a newer file is read + // with this build's schema instead. + it("reads a config written by a newer build instead of rejecting it", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION + 1, + agent: { approvalLevel: 3 }, + }); + expect(parsed.agent.approvalLevel).toBe(3); + }); + + it("keeps a newer version rather than stamping its own", () => { + expect(parseUserConfigFile({ version: USER_CONFIG_VERSION + 7 }).version).toBe( + USER_CONFIG_VERSION + 7, + ); + }); + + // Every version-gated rule in the parse is a `<` comparison against the + // input version, so a newer file must take its stored values verbatim + // rather than having the pre-v41/v22/v25 defaults forced back on. + it("does not apply migration overrides to a newer file", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION + 1, + localModels: { managed: { autoUpdate: false } }, + memory: { links: { enabled: false } }, + }); + expect(parsed.localModels.managed.autoUpdate).toBe(false); + expect(parsed.memory.links.enabled).toBe(false); + }); + + // The parse rebuilds a fixed literal, so a block added by a newer + // schema only survives if it is carried across explicitly. + // Deliberately absent from `UserConfigFile` — the keys ride along on the + // object without widening the type, so the test has to reach past it. + it("carries unknown top-level keys through the parse", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION + 1, + somethingFromTheFuture: { nested: [1, 2, 3] }, + }) as unknown as Record; + expect(parsed.somethingFromTheFuture).toEqual({ nested: [1, 2, 3] }); + }); + + it("retires the legacy telemetry alias instead of carrying it forward", () => { + const parsed = parseUserConfigFile({ + version: 20, + telemetry: { trace: { enabled: true } }, + }) as unknown as Record; + expect(parsed.telemetry).toBeUndefined(); + expect(parsed.tracing).toBeDefined(); + }); + + it("rejects a version that is not a plausible whole number", () => { + for (const version of [42.5, 1e308, 0, -1]) { + expect(() => parseUserConfigFile({ version })).toThrow( + ConfigValidationError, + ); + } + }); + + // JSON.parse — unlike an object literal — makes `__proto__` a real own + // key, which is the only way this reaches the carry-through spread. + it("drops a __proto__ key instead of carrying it through", () => { + const raw: unknown = JSON.parse( + `{"version": ${USER_CONFIG_VERSION}, "__proto__": {"polluted": true}}`, + ); + expect(Object.hasOwn(raw as object, "__proto__")).toBe(true); + + const parsed = parseUserConfigFile(raw); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(Object.hasOwn(parsed, "__proto__")).toBe(false); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it("never lets an unknown key shadow a parsed one", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + agent: { approvalLevel: 2 }, + }); + expect(parsed.agent.approvalLevel).toBe(2); + }); + it("rejects legacy v1/v2/v3/v4 input — migration is not supported", () => { expect(() => parseUserConfigFile({ version: 1 })).toThrow( ConfigValidationError, @@ -148,7 +332,7 @@ describe("parseUserConfigFile", () => { it("fills cacheTtlMinutes/fallback defaults when migrating from v27", () => { const parsed = parseUserConfigFile({ version: 27 }); expect(parsed.version).toBe(USER_CONFIG_VERSION); - expect(parsed.web.search.cacheTtlMinutes).toBe(15); + expect(parsed.web.search.cacheTtlMinutes).toBe(60); expect(parsed.web.search.provider).toBe("exa"); expect(parsed.web.search.fallback).toEqual(["duckduckgo"]); }); @@ -159,6 +343,27 @@ describe("parseUserConfigFile", () => { expect(parsed.tui.theme).toBe("auto"); }); + it("fills web.fetch defaults when migrating from v37", () => { + const parsed = parseUserConfigFile({ + version: 37, + web: { search: { provider: "exa", timeoutMs: 15_000 } }, + }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(parsed.web.fetch.timeoutMs).toBe(30_000); + expect(parsed.web.fetch.connectTimeoutMs).toBe(10_000); + expect(parsed.web.fetch.maxRetries).toBe(2); + // The version bump must not drop the settings a v37 file already carried. + expect(parsed.web.search.timeoutMs).toBe(15_000); + }); + + it("accepts every version between the oldest supported and the current one", () => { + // A bump that forgets to append the outgoing version to the supported + // list locks out everyone whose config is still on it. + for (let version = 5; version <= USER_CONFIG_VERSION; version += 1) { + expect(() => parseUserConfigFile({ version })).not.toThrow(); + } + }); + it("preserves an explicit tui.theme name", () => { const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION, @@ -175,6 +380,37 @@ describe("parseUserConfigFile", () => { expect(parsed.tui.theme).toBe("auto"); }); + it("enables tui.mouse by default when migrating from v37", () => { + const parsed = parseUserConfigFile({ version: 37 }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(parsed.tui.mouse).toBe(true); + }); + + it("preserves tui.mouse: false so an operator's opt-out survives", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "auto", mouse: false }, + }); + expect(parsed.tui.mouse).toBe(false); + }); + + it("accepts the string forms parseBool understands for tui.mouse", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { mouse: "off" }, + }); + expect(parsed.tui.mouse).toBe(false); + }); + + it("rejects a non-boolean tui.mouse", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { mouse: 42 }, + }), + ).toThrow(/tui.mouse/); + }); + it("rejects a non-string tui.theme", () => { expect(() => parseUserConfigFile({ @@ -353,11 +589,22 @@ describe("parseUserConfigFile", () => { expect(parsed.agent.worldSnapshotMaxTokens).toBe(4_000); }); - it("rejects non-positive conversationMaxTokens", () => { + it("accepts conversationMaxTokens: 0 as the auto sentinel", () => { + // `0` is not a request for a zero-token transcript: it is "let the + // window decide", the same sentinel `localModels.managed.contextSize` + // uses. It has to survive the parser to mean anything. + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + agent: { conversationMaxTokens: 0 }, + }); + expect(parsed.agent?.conversationMaxTokens).toBe(0); + }); + + it("rejects a negative conversationMaxTokens", () => { expect(() => parseUserConfigFile({ version: USER_CONFIG_VERSION, - agent: { conversationMaxTokens: 0 }, + agent: { conversationMaxTokens: -1 }, }), ).toThrow(/agent.conversationMaxTokens/); }); @@ -510,6 +757,38 @@ describe("parseUserConfigFile", () => { expect(parsed.localModels.managed.autoUpdate).toBe(true); }); + it("defaults localModels.managed.autoUpdate to true", () => { + const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); + expect(parsed.localModels.managed.autoUpdate).toBe(true); + }); + + it("migrates a pre-v41 autoUpdate:false (unused default) to true", () => { + const parsed = parseUserConfigFile({ + version: 37, + localModels: { managed: { autoUpdate: false } }, + }); + expect(parsed.localModels.managed.autoUpdate).toBe(true); + }); + + // The migration gate is the LAST version that stored the dead default. + // v40 is the boundary: it must still migrate, v41 must be honoured. + // Without this pair the gate can drift off USER_CONFIG_VERSION unnoticed. + it("migrates a v40 autoUpdate:false (still the unused default) to true", () => { + const parsed = parseUserConfigFile({ + version: 40, + localModels: { managed: { autoUpdate: false } }, + }); + expect(parsed.localModels.managed.autoUpdate).toBe(true); + }); + + it("preserves an explicit localModels.managed.autoUpdate=false on v41+", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { autoUpdate: false } }, + }); + expect(parsed.localModels.managed.autoUpdate).toBe(false); + }); + it("preserves an explicit localModels.managed.device override", () => { const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION, @@ -993,3 +1272,76 @@ describe("parseUserConfigFile", () => { ).toThrow(/timeoutMs/); }); }); + +describe("tui.whileBusySubmit", () => { + it("defaults to steer for a config file that predates the key", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "auto" }, + }); + expect(parsed.tui.whileBusySubmit).toBe("steer"); + }); + + it("round-trips an explicit queue preference", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "nord", whileBusySubmit: "queue" }, + }); + expect(parsed.tui.whileBusySubmit).toBe("queue"); + expect(parsed.tui.theme).toBe("nord"); + }); + + it("rejects an unknown mode instead of silently defaulting", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "auto", whileBusySubmit: "interrupt" }, + }), + ).toThrow(/whileBusySubmit/); + }); +}); + + +describe("numeric coercion of string config values", () => { + const base = { version: USER_CONFIG_VERSION }; + + it("loads a config whose integer was written as a decimal string", () => { + // Regression: a stricter `/^\d+$/` test rejected these, and because this + // parser runs on every startup a config.json holding "8080.0" made the + // whole CLI unbootable — with no way to repair it from inside the tool, + // since `config set` loads the config first. `parseInt` had converted + // them correctly all along, so they must keep working. + for (const raw of ["10.0", "25.0", "1e3", "+7"]) { + expect(() => + parseUserConfigFile({ ...base, agent: { maxSteps: raw } }), + ).not.toThrow(); + } + expect( + parseUserConfigFile({ ...base, agent: { maxSteps: "10.0" } }).agent + .maxSteps, + ).toBe(10); + expect( + parseUserConfigFile({ ...base, agent: { tokenBudget: "1e3" } }).agent + .tokenBudget, + ).toBe(1000); + }); + + it("still rejects a value that is not a complete number", () => { + for (const raw of ["60s", "100_000", "1,000", "10.9", "0x10", "abc"]) { + expect(() => + parseUserConfigFile({ ...base, agent: { maxSteps: raw } }), + ).toThrow(/maxSteps/); + } + }); + + it("rejects an integer past the safe range instead of rounding it", () => { + // "9007199254740993" would be stored as ...992 — the same quiet + // corruption the strict parsing exists to prevent. + expect(() => + parseUserConfigFile({ + ...base, + agent: { tokenBudget: "9007199254740993" }, + }), + ).toThrow(/tokenBudget/); + }); +}); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index ebb079e8..cf9403c7 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -7,7 +7,11 @@ import { export type { ApprovalLevel } from "../approval/approval-level.js"; import type { DotenvLoadResult } from "./load-dotenv.js"; -import { isKnownLocalModelId } from "../local-llm/models-catalog.js"; +import { + isKnownLocalModelId, + type LocalModelDef, +} from "../local-llm/models-catalog.js"; +import { parseCustomLocalModels } from "./custom-models-schema.js"; import { MCP_SERVER_NAME_MAX_LENGTH, MCP_SERVER_NAME_RE, @@ -31,6 +35,50 @@ export type BrowserChannel = "chrome" | "msedge" | "chromium"; export type WebSearchProviderName = "duckduckgo" | "searxng" | "exa" | "brave"; +/** + * Tunables for `os.web.fetch` (config v38). Before v38 the tool hard-coded a + * 30s overall budget with no connect timeout and no retries, so a single + * unreachable host burned 30s of the task budget and a transient 503 (the + * bulk of them from `web.archive.org`, which serves the same URL seconds + * later) ended the fetch outright. + */ +export interface WebFetchConfig { + /** + * Overall per-attempt budget in milliseconds, passed to curl `--max-time`. + * Kept at the historical 30_000 so unconfigured installs behave exactly as + * before. A per-call `timeoutMs` tool argument overrides it. + */ + timeoutMs: number; + /** + * TCP/TLS connect budget in milliseconds, passed to curl `--connect-timeout`. + * Much smaller than `timeoutMs` because a host that has not completed a + * handshake in 10s is almost never merely slow — it is firewalled, dead, or + * blackholing packets, and waiting the full overall budget for it is pure + * loss. A slow but reachable server still gets the whole `timeoutMs` to + * stream its body, since `--connect-timeout` only covers the handshake. + */ + connectTimeoutMs: number; + /** + * Extra attempts after the first for retryable failures (429/502/503/504 and + * curl exit 28 "operation timed out"). `0` disables retrying. Deliberately + * small: `os.web.fetch` is GET-only, so retries are always safe, but each one + * spends task budget. + */ + maxRetries: number; + /** + * Base delay in milliseconds for exponential backoff between retries + * (attempt N waits `retryBaseDelayMs * 2^(N-1)`). A server-sent `Retry-After` + * header wins over the computed delay when it is shorter than the cap. + */ + retryBaseDelayMs: number; + /** + * Upper bound in milliseconds on any single backoff wait, including one + * derived from `Retry-After`. Stops a hostile or overloaded origin from + * parking the agent for minutes on a header value. + */ + retryMaxDelayMs: number; +} + export interface WebSearchConfig { enabled: boolean; provider: WebSearchProviderName; @@ -39,6 +87,12 @@ export interface WebSearchConfig { /** * Per-runtime result cache TTL in minutes. `0` disables caching. The cache * is the primary defence against provider rate-limiting on repeated queries. + * + * An hour, raised from fifteen minutes. An agent re-issues near-identical + * queries across the steps of one task and across tasks in one run, and + * every expiry inside that window spends quota to re-fetch a result it + * already had (#179). Search results for the factual lookups this is + * mostly used for do not turn over in an hour; a rate limit does. */ cacheTtlMinutes: number; /** @@ -175,8 +229,29 @@ export interface AtomicAgentConfig { * Safety-net ceiling for the `### conversation` section of the prompt. * Typical sessions stay well under this cap — it exists to prevent * pathological growth, not to be a regular truncation mechanism. + * + * **`0` means auto:** the transcript takes whatever the model's + * window leaves after the scaffold, the memory sections and the + * reply reservation, with no fixed ceiling above it. Same sentinel + * and same reasoning as `localModels.managed.contextSize` — the + * useful value is a function of hardware this file cannot see. + * + * The default stays at 32k rather than becoming auto, and + * deliberately: on a local server the tokens are free, but on a + * metered cloud model with a 200k window "auto" would multiply the + * per-step bill without anyone asking for it. Operators who size + * their own `llama-server` are exactly the people who should set + * this to `0`, and the context panel now tells them so when their + * ceiling is what is holding the transcript below their window. */ conversationMaxTokens: number; + /** + * Macro-turns of history the prompt carries — one per task you sent, + * each carrying everything the agent did answering it. The knob an + * operator actually reaches for; `conversationMaxTokens` remains the + * ceiling underneath it. + */ + conversationMaxPairs: number; /** * Safety-net ceiling for the `### world` section. ARIA snapshots are * already compressed at the browser layer; this cap guards against @@ -293,6 +368,7 @@ export interface AtomicAgentConfig { }; web: { search: WebSearchConfig; + fetch: WebFetchConfig; }; /** * User-declared project root directories consumed by @@ -662,12 +738,16 @@ export interface AtomicAgentConfig { maxImagesPerCall: number; }; /** - * TUI appearance. Mirrors `UserConfigFile.tui`. `theme` is `"auto"` - * (OSC 11 autodetect) or a registered theme name. Consumed by the TUI - * startup path; the rest of the runtime ignores it. + * TUI appearance and input. Mirrors `UserConfigFile.tui`. `theme` is + * `"auto"` (OSC 11 autodetect) or a registered theme name; `mouse` + * toggles terminal mouse reporting. Consumed by the TUI startup path; + * the rest of the runtime ignores it. */ tui: { theme: string; + whileBusySubmit: WhileBusySubmitMode; + mouse: boolean; + onboarding: OnboardingState; }; /** * Anonymous product analytics (PostHog). Mirrors @@ -714,11 +794,35 @@ export interface AtomicAgentConfig { defaultChatModel?: string; defaultEmbeddingModel?: string; headers?: Record; + /** + * Header carrying this entry's API key for services that do not + * accept `Authorization: Bearer` (Anthropic wants `x-api-key`). + */ + apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; requestTimeoutMs?: number; promptCache?: "auto" | "off" | "explicit-markers"; providerPreferences?: Record; + /** + * Vendor-specific fields merged into the OpenAI-compatible chat + * body. Reserved keys (`model`, `messages`, `stream`, `tools`) + * are re-applied after the merge and cannot be overridden. + */ + extraBody?: Record; + /** + * Settings for a `subscription-cli` provider: which already + * signed-in vendor CLI to drive (`claude`, `codex`) and how to + * invoke it. There is no API key on these entries — the CLI + * authenticates from its own session. + */ + subscriptionCli?: { + cli: "claude" | "codex"; + binPath?: string; + extraArgs?: string[]; + streaming?: boolean; + maxBudgetUsd?: number; + }; userModels?: ReadonlyArray<{ id: string; kind: "chat" | "embedding"; @@ -822,6 +926,14 @@ export interface UserManagedLocalLlmConfig { modelId: string | null; port: number; dataDirOverride: string | null; + /** + * When true, managed-mode start (TUI auto-start / `s`, CLI + * `models start`) checks GitHub Releases and replaces the llama.cpp + * zip if a newer tag (or a stale Windows variant) is available. + * Default `true` since config v41. Older files stored an unused + * `false` default — those migrate to `true`. Set `false` to pin the + * installed backend. + */ autoUpdate: boolean; /** * Compute-device preference for the managed llama.cpp daemon: @@ -886,7 +998,17 @@ export interface UserManagedEmbeddingLlmConfig { * changes and add a migration step in `parseUserConfigFile`. */ export interface UserConfigFile { - version: typeof USER_CONFIG_VERSION; + /** + * The on-disk schema version. Usually `USER_CONFIG_VERSION`, but a file + * written by a NEWER build keeps its own (higher) number all the way + * through parse and write-back, so an older build can never label a + * newer file with its own version. See `parseUserConfigFile`. + */ + version: number; + // NB: keys from a newer schema ride along on the object at runtime (see + // `unknownTopLevelKeys`) but are deliberately absent from this type. An + // index signature here would make every property name valid on the type + // the whole tree writes back — `{ ...file, viison: … }` would compile. localModels: { url: string; mode: LocalLlmMode; @@ -905,6 +1027,14 @@ export interface UserConfigFile { * `{ enabled: false, modelId: null, port: 19092 }`. */ embeddings: UserManagedEmbeddingLlmConfig; + /** + * GGUF models the operator added from an arbitrary Hugging Face repo + * (config v44). Each entry is a whole `LocalModelDef` with a + * `custom-` prefixed id; `loadConfig()` publishes them to the catalog + * registry so curated and added models resolve through one lookup. + * Older files inherit `[]`. + */ + customModels: LocalModelDef[]; }; log: { level: LogLevel }; agent: { @@ -918,6 +1048,20 @@ export interface UserConfigFile { */ approvalLevel: ApprovalLevel; conversationMaxTokens: number; + /** + * How many macro-turns of history the prompt carries — one "pair" + * being a task you sent plus everything the agent did answering it. + * + * This is the knob to reach for. Tokens are the wrong unit to steer + * with: nobody thinks in tokens, they think in how many of their + * last tasks the agent should still know about. + * {@link UserConfigFile.agent.conversationMaxTokens} stays underneath + * as the ceiling, because a pair has no bounded size — one task can + * run `maxSteps` tool calls and a fresh `os.http.request` body is + * rendered uncapped — so N pairs can exceed any window. Whichever + * limit bites first wins. + */ + conversationMaxPairs: number; worldSnapshotMaxTokens: number; }; http: { @@ -929,6 +1073,7 @@ export interface UserConfigFile { }; web: { search: WebSearchConfig; + fetch: WebFetchConfig; }; /** * Project path resolution (config v36). `roots` lists directories @@ -1341,12 +1486,24 @@ export interface UserConfigFile { /** * TUI appearance. Added in config v29. `theme` is either the literal * `"auto"` (default — detect the terminal background via OSC 11 and pick - * the matching GitHub theme) or a registered theme name (e.g. `dracula`, - * `nord`). Persisted from the in-app `/theme` picker. Older files are + * the matching classic theme) or a registered theme name (e.g. + * `khorne-red`, `moon-yellow`). Names the registry used to carry are + * rehomed to the nearest surviving palette by `resolveThemeName`, so + * an older file never loses its theme silently. Persisted from the + * in-app `/theme` picker. Older files are * transparently upgraded with `tui: { theme: "auto" }`. + * + * `mouse` (config v38, default `true`) turns terminal mouse reporting + * on: clicking panels, list rows, the nav bar and the prompt, plus + * wheel scrolling. Turning it off restores the terminal's own + * drag-to-select, which mouse reporting takes over — see `/mouse` and + * `--no-mouse`. Older files are upgraded with `mouse: true`. */ tui: { theme: string; + whileBusySubmit: WhileBusySubmitMode; + mouse: boolean; + onboarding: OnboardingState; }; /** * Anonymous product analytics (PostHog). Added in config v33. Older @@ -1406,7 +1563,43 @@ export interface UserConfigFile { // is absent, a legacy `approvalRequired: false` maps to level 5 and // `true`/absent maps to level 1 — both preserve the old behaviour // exactly. The legacy key is never written back. -export const USER_CONFIG_VERSION = 37 as const; +// v39: new `web.fetch` block (`timeoutMs`, `connectTimeoutMs`, `maxRetries`, +// `retryBaseDelayMs`, `retryMaxDelayMs`) making `os.web.fetch` timeouts and +// retry/backoff configurable. Older files transparently inherit the defaults, +// and `timeoutMs` keeps its historical 30_000 value, so the migration does not +// change behaviour for anyone who does not opt in. +// v40: new `tui.mouse` flag gating the mouse layer. Defaults to true, so an +// older file inherits mouse support on upgrade; `--no-mouse` and `/mouse off` +// override it without rewriting the file. +// v41: `localModels.managed.autoUpdate` is wired to managed start +// (TUI auto-start / CLI `models start`) and defaults to `true`. Pre-v41 +// files stored an unused `false` default — those migrate to `true` so +// existing installs pick up newer llama.cpp zips. Explicit `false` on +// a v41+ file is honoured. +// v43: new `tui.onboarding` block — four nullable ISO timestamps recording +// that the first-run flow was seen, skipped, completed, and that the +// "configure the other backend too" screen was already offered. Additive: +// an older file parses with all four `null`, which reads as "never +// onboarded" and opens the flow exactly once, instead of re-deriving the +// answer from a health probe on every launch. +// v42: no schema change. The bump exists to carry the forward-compat +// rules below: a file whose `version` is *newer* than this constant is +// now read instead of rejected, its version is preserved rather than +// stamped down, and unknown top-level keys survive the round trip. See +// `parseUserConfigFile` and `ensureUserConfigFileSync`. +// v44: localModels gains `customModels` — GGUF models the operator pointed +// at on Hugging Face, stored as full catalog entries. Older files inherit +// `[]`, which is exactly the behaviour they had before the key existed. +// v45: a fifth stamp in `tui.onboarding` — `localSetupSeenAt`, written +// when the first run reaches the local model list rather than when a +// model comes out of it. It is what stops the "set up local models too" +// screen being pitched to an operator who already walked through that +// list and walked back out. Additive: a v43 file parses with it `null`, +// which reads as "never opened", the same answer that file has always +// implied. (It was drafted as a second v44, but v44 was already spent on +// `customModels` in the same release — the stamp ships as v45 so the two +// additive changes keep distinct numbers.) +export const USER_CONFIG_VERSION = 45; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1416,6 +1609,9 @@ export const USER_CONFIG_VERSION = 37 as const; */ const MEMORY_V2_OPT_IN_DEFAULTS_VERSION = 22; +/** Pre-v41 `autoUpdate: false` was a dead default; force the live default. */ +const MANAGED_AUTO_UPDATE_DEFAULTS_VERSION = 41; + export type RewriterGateMode = "heuristic" | "embedding" | "always"; /** @@ -1485,6 +1681,12 @@ export type RewriterGateMode = "heuristic" | "embedding" | "always"; * v32→v33 added the optional `analytics.*` block (anonymous PostHog * product analytics — opt-out via `analytics.enabled: false`). Older * files inherit `analytics: { enabled: true }` transparently. + * v40→v41 wired `localModels.managed.autoUpdate` (default `true`): + * managed start pulls a newer llama.cpp zip from GitHub Releases when + * one exists. Pre-v41 the field was stored but never read, so a `false` + * there carried no meaning and is migrated to `true` — including one a + * user set deliberately, since the two are indistinguishable on disk. + * An explicit `false` on a v41+ file is honoured. * Older files are transparently upgraded by filling missing * blocks/fields from `USER_CONFIG_DEFAULTS`. Anything older than v5 * is not migrated: this is active development, callers delete their @@ -1523,6 +1725,14 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 34, 35, 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, USER_CONFIG_VERSION, ]; @@ -1536,7 +1746,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { modelId: null, port: 19091, dataDirOverride: null, - autoUpdate: false, + autoUpdate: true, stopOnExit: true, device: "auto", contextSize: 0, @@ -1547,6 +1757,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { port: 19092, url: "http://127.0.0.1:19092", }, + customModels: [], }, log: { level: "info" }, agent: { @@ -1555,6 +1766,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { toolTimeoutMs: 60_000, approvalLevel: 1, conversationMaxTokens: 32_000, + conversationMaxPairs: 20, worldSnapshotMaxTokens: 8_000, }, http: { @@ -1570,7 +1782,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { provider: "exa", maxResults: 8, timeoutMs: 15_000, - cacheTtlMinutes: 15, + cacheTtlMinutes: 60, fallback: ["duckduckgo"], searxng: { instanceUrl: null, @@ -1584,6 +1796,13 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { apiKeyEnv: "BRAVE_SEARCH_API_KEY", }, }, + fetch: { + timeoutMs: 30_000, + connectTimeoutMs: 10_000, + maxRetries: 2, + retryBaseDelayMs: 500, + retryMaxDelayMs: 5_000, + }, }, projects: { roots: [], @@ -1761,6 +1980,15 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { }, tui: { theme: "auto", + whileBusySubmit: "steer", + mouse: true, + onboarding: { + completedAt: null, + introSeenAt: null, + localSetupSeenAt: null, + proposedSecondBackendAt: null, + skippedAt: null, + }, }, analytics: { enabled: true, @@ -1858,10 +2086,18 @@ export function parseLocalLlmMode(raw: unknown, field: string): LocalLlmMode { ); } -function parseOptionalManagedModelId(raw: unknown, field: string): string | null { +function parseOptionalManagedModelId( + raw: unknown, + field: string, + customModels: readonly LocalModelDef[], +): string | null { if (raw === null || raw === undefined) return null; const s = parseNonEmptyString(raw, field); - if (!isKnownLocalModelId(s)) { + // The added models come out of the same file, so they are checked + // against that array rather than the module registry: a config that + // adds a model and selects it in one write has to validate before + // anything has had the chance to publish it. + if (!isKnownLocalModelId(s) && !customModels.some((m) => m.id === s)) { throw new ConfigValidationError( field, `unknown managed local model id: ${JSON.stringify(s)}`, @@ -1919,13 +2155,55 @@ export function parseWebSearchFallback( return out; } +/** + * Coerce a raw config value to a number for validation. + * + * A string must be a *complete* numeric literal. `Number.parseInt` stops at + * the first character it cannot read, so it turns `"1e3"` into `1`, `"60s"` + * into `60` and `"100_000"` into `100` — a typo silently becomes a valid + * setting. That is reachable from `config set `, where every + * value arrives as a string, so the whole token is parsed here and anything + * that is not a complete numeric literal is rejected as `NaN`. + * + * The test is on the *whole token*, not on its shape: `"10.0"` and `"1e3"` + * are both complete literals, and `parseInt` converted the first correctly + * (to `10`) while truncating the second. Rejecting every non-`\d+` string + * would therefore also reject values that already worked — and since this + * parser runs on `loadConfig` at every startup, a `config.json` holding + * `"8080.0"` would make the whole CLI unbootable with no way to fix it from + * inside the tool. So the value is what decides: parse it in full, then + * require it to be an exact integer. `"10.0"` passes, `"1e3"` (1000) passes + * as the thousand the user asked for, `"60s"` and `"10.9"` do not. + */ +function coerceIntLike(raw: unknown): number { + if (typeof raw === "number") return raw; + if (typeof raw !== "string") return NaN; + const value = coerceFloatLike(raw); + // `Number.isInteger` also rejects NaN and the infinities. + if (!Number.isInteger(value)) return NaN; + // Past 2^53 the literal no longer round-trips: "9007199254740993" would be + // silently stored as ...992, which is the same class of quiet corruption + // this function exists to stop. + return Number.isSafeInteger(value) ? value : NaN; +} + +/** + * The float counterpart of {@link coerceIntLike}. Accepts the forms JSON + * does — `1.5`, `-0.25`, `1e3` — and rejects trailing garbage like + * `"0.85xyz"` or a second dot, which `Number.parseFloat` would truncate. + */ +function coerceFloatLike(raw: unknown): number { + if (typeof raw === "number") return raw; + if (typeof raw !== "string") return NaN; + const text = raw.trim(); + if (text.length === 0) return NaN; + return /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text) + ? Number(text) + : NaN; +} + export function parsePositiveInt(raw: unknown, field: string): number { - const value = - typeof raw === "number" - ? raw - : typeof raw === "string" - ? Number.parseInt(raw, 10) - : NaN; + const value = coerceIntLike(raw); if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { throw new ConfigValidationError( field, @@ -1963,12 +2241,7 @@ export function parseBoundedPositiveInt( * accept `0` as "feature disabled", e.g. `memory.reflection.maxNotesPerCall`. */ export function parseNonNegativeInt(raw: unknown, field: string): number { - const value = - typeof raw === "number" - ? raw - : typeof raw === "string" - ? Number.parseInt(raw, 10) - : NaN; + const value = coerceIntLike(raw); if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { throw new ConfigValidationError( field, @@ -2008,12 +2281,7 @@ export function parseNonNegativeBoundedInt( * re-deriving the clamp. */ export function parseUnitInterval(raw: unknown, field: string): number { - const value = - typeof raw === "number" - ? raw - : typeof raw === "string" - ? Number.parseFloat(raw) - : NaN; + const value = coerceFloatLike(raw); if (!Number.isFinite(value) || value < 0 || value > 1) { throw new ConfigValidationError( field, @@ -2034,12 +2302,7 @@ export function parseHalfOpenUnitInterval( raw: unknown, field: string, ): number { - const value = - typeof raw === "number" - ? raw - : typeof raw === "string" - ? Number.parseFloat(raw) - : NaN; + const value = coerceFloatLike(raw); if (!Number.isFinite(value) || value <= 0 || value > 1) { throw new ConfigValidationError( field, @@ -2075,6 +2338,16 @@ function parseMemoryV2FeatureEnabled( return parseBool(raw ?? defaultEnabled, field); } +function resolveManagedAutoUpdate(inputVersion: number, raw: unknown): boolean { + if (inputVersion < MANAGED_AUTO_UPDATE_DEFAULTS_VERSION) { + return true; + } + return parseBool( + raw ?? USER_CONFIG_DEFAULTS.localModels.managed.autoUpdate, + "localModels.managed.autoUpdate", + ); +} + function resolveEmbeddingModelId( _inputVersion: number, raw: unknown, @@ -2133,13 +2406,19 @@ function resolveHttpApprovalMode( } export function parseApprovalLevel(raw: unknown, field: string): ApprovalLevel { - if ( - typeof raw === "number" && - Number.isInteger(raw) && - raw >= 1 && - raw <= 5 - ) { - return raw as ApprovalLevel; + // `coerceIntLike`, like every other numeric parser in this file, and + // not a bare `typeof raw === "number"`. `config set ` + // hands the schema the raw argv string on purpose — guessing the type + // at the CLI would be a second source of truth — so a number-only + // check made `agent.approvalLevel` the single key the dotted-key + // editor could never write. It is also the one safety-critical + // setting in the file, and it failed with a message that asked for + // exactly what it had just been given: `config set + // agent.approvalLevel 3` answered `expected an integer between 1 and + // 5, got "3"`. + const value = coerceIntLike(raw); + if (Number.isInteger(value) && value >= 1 && value <= 5) { + return value as ApprovalLevel; } throw new ConfigValidationError( field, @@ -2628,11 +2907,51 @@ export function parseMcpServers( return out; } +/** + * Every top-level key this build knows about. Derived from the defaults + * so it cannot drift when a block is added, plus `llm`, which the parse + * emits conditionally rather than defaulting. + */ +const KNOWN_TOP_LEVEL_KEYS: ReadonlySet = new Set([ + ...Object.keys(USER_CONFIG_DEFAULTS), + // Emitted conditionally rather than defaulted. + "llm", + // Read as the legacy alias of `tracing` and intentionally not emitted; + // treating it as unknown would carry a retired block forward for ever. + "telemetry", +]); + +/** + * The keys of `obj` this build does not recognise — in practice, a block + * written by a newer schema. `parseUserConfigFile` rebuilds its result as + * a fixed literal, so without carrying these through explicitly the first + * write from an older build would delete them. `__proto__` is dropped + * rather than carried: `JSON.parse` can produce it as an own key and it + * has no business in a config file. + */ +function unknownTopLevelKeys( + obj: Record, +): Record { + // Null prototype so `extras[key] = value` is always a plain data write. + // On a normal object, `extras["__proto__"] = …` hits the inherited setter + // and silently reparents `extras` instead of storing anything. + const extras = Object.create(null) as Record; + for (const [key, value] of Object.entries(obj)) { + // With the null prototype above this would otherwise become a real own + // key, get spread into the result, and be written back out to disk. + if (key === "__proto__") continue; + if (KNOWN_TOP_LEVEL_KEYS.has(key)) continue; + extras[key] = value; + } + return extras; +} + /** * Validate and normalise a raw JSON payload into a `UserConfigFile`. * Missing sub-keys are filled with defaults — this lets us add new * fields without breaking existing installations. Unknown top-level - * keys are preserved silently (forward compat). + * keys are carried through verbatim (forward compat); unknown keys + * *inside* a known block are still dropped. */ export function parseUserConfigFile(raw: unknown): UserConfigFile { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { @@ -2640,10 +2959,23 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { } const obj = raw as Record; const version = obj.version ?? USER_CONFIG_VERSION; - if ( - typeof version !== "number" || - !SUPPORTED_INPUT_VERSIONS.includes(version) - ) { + if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) { + throw new ConfigValidationError( + "version", + `unsupported config version ${JSON.stringify(version)}; expected a positive whole number`, + ); + } + // A version we have never heard of is only fatal when it is OLDER than + // the oldest we can migrate. A NEWER one is read with this build's + // schema instead of throwing: every version-gated rule below is a `<` + // comparison, so a higher number makes all of them fall through to + // "take the file at its word", which is the correct reading of a file + // written by a build that knew more than we do. Rejecting it instead + // bricks every command in the CLI — `getConfig()` runs before all of + // them — and turns any rollback to an older build into a dead install. + // The newer number is preserved in the result (and unknown top-level + // keys with it) so writing the file back cannot downgrade it. + if (!SUPPORTED_INPUT_VERSIONS.includes(version) && version < USER_CONFIG_VERSION) { throw new ConfigValidationError( "version", `unsupported config version ${JSON.stringify(version)}; expected one of ${SUPPORTED_INPUT_VERSIONS.join(", ")}`, @@ -2658,6 +2990,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { const web = (obj.web as Record | undefined) ?? {}; const projects = (obj.projects as Record | undefined) ?? {}; const webSearch = (web.search as Record | undefined) ?? {}; + const webFetch = (web.fetch as Record | undefined) ?? {}; const webSearchProvider = parseWebSearchProviderName( webSearch.provider ?? USER_CONFIG_DEFAULTS.web.search.provider, "web.search.provider", @@ -2729,12 +3062,20 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { (obj.analytics as Record | undefined) ?? {}; const mcp = (obj.mcp as Record | undefined) ?? {}; + // Parsed before `managed.modelId` so a file that adds a model and + // activates it in one write validates. + const customModels = parseCustomLocalModels( + localModels.customModels, + "localModels.customModels", + ); + const rawManaged = (localModels.managed as Record | undefined) ?? {}; const managed: UserManagedLocalLlmConfig = { modelId: parseOptionalManagedModelId( rawManaged.modelId, "localModels.managed.modelId", + customModels, ), port: parsePositiveInt( rawManaged.port ?? USER_CONFIG_DEFAULTS.localModels.managed.port, @@ -2747,10 +3088,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { rawManaged.dataDirOverride, "localModels.managed.dataDirOverride", ), - autoUpdate: parseBool( - rawManaged.autoUpdate ?? USER_CONFIG_DEFAULTS.localModels.managed.autoUpdate, - "localModels.managed.autoUpdate", - ), + autoUpdate: resolveManagedAutoUpdate(version, rawManaged.autoUpdate), stopOnExit: parseBool( rawManaged.stopOnExit ?? USER_CONFIG_DEFAULTS.localModels.managed.stopOnExit, @@ -2816,7 +3154,11 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { }); return { - version: USER_CONFIG_VERSION, + // Spread first so a known key can never be shadowed by a stray one. + ...unknownTopLevelKeys(obj), + // A file from a newer build keeps its own version. Stamping ours here + // is what turns "read a newer file" into "silently downgrade it". + version: Math.max(version, USER_CONFIG_VERSION), localModels: { url: localModelsUrl, mode: localModelsMode, @@ -2829,6 +3171,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { ), managed, embeddings: embeddingsDaemon, + customModels, }, log: { level: parseLogLevel(log.level ?? USER_CONFIG_DEFAULTS.log.level, "log.level"), @@ -2850,11 +3193,25 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { agent.approvalLevel, agent.approvalRequired, ), - conversationMaxTokens: parsePositiveInt( + // Non-negative rather than positive: `0` is the "auto" sentinel + // (`CONVERSATION_CAP_AUTO`), not a request for a zero-token + // transcript. + conversationMaxTokens: parseNonNegativeInt( agent.conversationMaxTokens ?? USER_CONFIG_DEFAULTS.agent.conversationMaxTokens, "agent.conversationMaxTokens", ), + // Bounded, unlike the token cap: there is no "auto" here. `1` + // means the agent sees only the task in front of it; the upper + // bound keeps a fat-fingered `1000` from turning into a prompt + // nobody meant to pay for. + conversationMaxPairs: parseBoundedPositiveInt( + agent.conversationMaxPairs ?? + USER_CONFIG_DEFAULTS.agent.conversationMaxPairs, + "agent.conversationMaxPairs", + 1, + 100, + ), worldSnapshotMaxTokens: parsePositiveInt( agent.worldSnapshotMaxTokens ?? USER_CONFIG_DEFAULTS.agent.worldSnapshotMaxTokens, @@ -2946,6 +3303,33 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { ), }, }, + fetch: { + timeoutMs: parsePositiveInt( + webFetch.timeoutMs ?? USER_CONFIG_DEFAULTS.web.fetch.timeoutMs, + "web.fetch.timeoutMs", + ), + connectTimeoutMs: parsePositiveInt( + webFetch.connectTimeoutMs ?? + USER_CONFIG_DEFAULTS.web.fetch.connectTimeoutMs, + "web.fetch.connectTimeoutMs", + ), + maxRetries: parseNonNegativeBoundedInt( + webFetch.maxRetries ?? USER_CONFIG_DEFAULTS.web.fetch.maxRetries, + "web.fetch.maxRetries", + 0, + 5, + ), + retryBaseDelayMs: parsePositiveInt( + webFetch.retryBaseDelayMs ?? + USER_CONFIG_DEFAULTS.web.fetch.retryBaseDelayMs, + "web.fetch.retryBaseDelayMs", + ), + retryMaxDelayMs: parsePositiveInt( + webFetch.retryMaxDelayMs ?? + USER_CONFIG_DEFAULTS.web.fetch.retryMaxDelayMs, + "web.fetch.retryMaxDelayMs", + ), + }, }, projects: { roots: @@ -3415,6 +3799,12 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { tui.theme ?? USER_CONFIG_DEFAULTS.tui.theme, "tui.theme", ), + whileBusySubmit: parseWhileBusySubmit( + tui.whileBusySubmit ?? USER_CONFIG_DEFAULTS.tui.whileBusySubmit, + "tui.whileBusySubmit", + ), + mouse: parseBool(tui.mouse ?? USER_CONFIG_DEFAULTS.tui.mouse, "tui.mouse"), + onboarding: parseOnboardingState(tui.onboarding), }, analytics: { enabled: parseBool( @@ -3455,6 +3845,111 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { * only enforces the string shape — an unknown name falls back to the * autodetect path at startup, never crashes. Anything non-string throws. */ +/** + * What Enter does in the TUI while a turn is already running. + * + * `steer` folds the message into the turn in flight (it reaches the + * model at the next step boundary); `queue` parks it and runs it as its + * own turn once the current one closes. Default is `steer` — an + * operator who types *while* the agent is working is usually reacting + * to what they see it doing. + */ +export type WhileBusySubmitMode = "steer" | "queue"; + +/** + * Parse `tui.whileBusySubmit` (added in config v38). Older config files predate the key and + * are transparently upgraded to the `steer` default by the `??` at the + * call site, so there is no migration step. + */ +export function parseWhileBusySubmit( + raw: unknown, + field: string, +): WhileBusySubmitMode { + if (raw === "steer" || raw === "queue") return raw; + throw new ConfigValidationError( + field, + `expected "steer" or "queue", got ${JSON.stringify(raw)}`, + ); +} + +/** + * First-run flow state (config v43, extended in v44). Five nullable + * ISO-8601 timestamps, not booleans: knowing *when* a run was completed + * or skipped is what lets a later release decide whether an install + * predates a flow it would like to show again, and it costs the same + * byte budget. + * + * - `introSeenAt` — the splash was dismissed at least once. + * - `completedAt` — a backend was configured and the flow handed over to + * the agent. Set for the "custom endpoint" branch too. + * - `skippedAt` — the operator escaped out. The flow does not reopen by + * itself afterwards; before v43 nothing was written here, which is why + * an escaped setup used to reappear on every single launch. + * - `proposedSecondBackendAt` — the "you have one, want the other too?" + * screen was already offered, so it is never offered twice. + * - `localSetupSeenAt` — the local model list was reached, whether or + * not a model came out of it. Recorded rather than derived because + * backing out of that list leaves no trace anywhere else, and it + * survives a launch: an interrupted first run is exactly the case + * where an operator would otherwise be shown it twice. + */ +export interface OnboardingState { + completedAt: string | null; + introSeenAt: string | null; + skippedAt: string | null; + proposedSecondBackendAt: string | null; + localSetupSeenAt: string | null; +} + +/** + * Parse `tui.onboarding`. Absent, `null`, or an empty object all mean a + * fresh install, so every field falls back to `null` rather than + * throwing — an older config file must never fail to load because it + * predates the block. + */ +export function parseOnboardingState(raw: unknown): OnboardingState { + const defaults = USER_CONFIG_DEFAULTS.tui.onboarding; + if (raw === undefined || raw === null) return { ...defaults }; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError( + "tui.onboarding", + `expected object, got ${JSON.stringify(raw)}`, + ); + } + const obj = raw as Record; + return { + completedAt: parseTimestampOrNull(obj.completedAt, "tui.onboarding.completedAt"), + introSeenAt: parseTimestampOrNull(obj.introSeenAt, "tui.onboarding.introSeenAt"), + skippedAt: parseTimestampOrNull(obj.skippedAt, "tui.onboarding.skippedAt"), + proposedSecondBackendAt: parseTimestampOrNull( + obj.proposedSecondBackendAt, + "tui.onboarding.proposedSecondBackendAt", + ), + localSetupSeenAt: parseTimestampOrNull( + obj.localSetupSeenAt, + "tui.onboarding.localSetupSeenAt", + ), + }; +} + +/** + * An ISO-8601 instant or `null`. Validated through `Date.parse` rather + * than a regex so a hand-edited file with a plausible-but-unparseable + * stamp is rejected at load instead of producing an `Invalid Date` + * somewhere far away. + */ +export function parseTimestampOrNull(raw: unknown, field: string): string | null { + if (raw === undefined || raw === null) return null; + const s = parseNonEmptyString(raw, field); + if (Number.isNaN(Date.parse(s))) { + throw new ConfigValidationError( + field, + `expected an ISO-8601 timestamp, got ${JSON.stringify(s)}`, + ); + } + return s; +} + export function parseThemeName(raw: unknown, field: string): string { if (typeof raw !== "string") { throw new ConfigValidationError( diff --git a/src/config/custom-models-schema.test.ts b/src/config/custom-models-schema.test.ts new file mode 100644 index 00000000..18bedae6 --- /dev/null +++ b/src/config/custom-models-schema.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { parseCustomLocalModel, parseCustomLocalModels } from "./custom-models-schema.js"; +import { USER_CONFIG_DEFAULTS, parseUserConfigFile } from "./config-schema.js"; + +const MINIMAL = { + id: "custom-unsloth-qwen3-4b-gguf-qwen3-4b-ud-q4_k_xl", + filename: "Qwen3-4B-UD-Q4_K_XL.gguf", + huggingFaceUrl: + "https://huggingface.co/unsloth/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-UD-Q4_K_XL.gguf", +}; + +describe("parseCustomLocalModel", () => { + it("fills in everything cosmetic so a hand-written entry can stay short", () => { + const def = parseCustomLocalModel(MINIMAL, "e"); + expect(def.name).toBe(MINIMAL.id); + expect(def.family).toBe("custom"); + expect(def.contextLabel).toBe("auto"); + expect(def.maxContextLength).toBe(0); + expect(def.supportsVision).toBe(false); + }); + + const bad: { name: string; entry: unknown; field: RegExp }[] = [ + { name: "a missing id", entry: { ...MINIMAL, id: undefined }, field: /e\.id/ }, + // The id becomes a directory name under `/models/`. + { name: "an id with a path separator", entry: { ...MINIMAL, id: "custom-a/b" }, field: /e\.id/ }, + { name: "an id without the prefix", entry: { ...MINIMAL, id: "qwen-3.5-4b" }, field: /e\.id/ }, + // Filenames land in a path join under the model's own directory. + { name: "a filename with a path separator", entry: { ...MINIMAL, filename: "a/b.gguf" }, field: /e\.filename/ }, + { name: "a filename that climbs out", entry: { ...MINIMAL, filename: "../../../x.gguf" }, field: /e\.filename/ }, + { name: "a backslashed filename", entry: { ...MINIMAL, filename: "..\\x.gguf" }, field: /e\.filename/ }, + { name: "an unparseable URL", entry: { ...MINIMAL, huggingFaceUrl: "nope" }, field: /huggingFaceUrl/ }, + { name: "a negative size", entry: { ...MINIMAL, fileSizeGb: -1 }, field: /fileSizeGb/ }, + { name: "a bare string", entry: "custom-x", field: /^invalid config: e/ }, + ]; + + for (const row of bad) { + it(`rejects ${row.name}`, () => { + expect(() => parseCustomLocalModel(row.entry, "e")).toThrow(row.field); + }); + } + + it("holds the projector filename to the same rule as the weights'", () => { + expect(() => + parseCustomLocalModel( + { + ...MINIMAL, + supportsVision: true, + mmprojUrl: "https://huggingface.co/u/r/resolve/main/mmproj.gguf", + mmprojFilename: "../mmproj.gguf", + }, + "e", + ), + ).toThrow(/e\.mmprojFilename/); + }); + + it("demands the projector fields once vision is claimed", () => { + expect(() => + parseCustomLocalModel({ ...MINIMAL, supportsVision: true }, "e"), + ).toThrow(/mmprojUrl/); + }); +}); + +describe("parseCustomLocalModels", () => { + it("reads a missing block as no added models", () => { + expect(parseCustomLocalModels(undefined, "f")).toEqual([]); + }); + + it("refuses two entries under one id", () => { + expect(() => parseCustomLocalModels([MINIMAL, MINIMAL], "f")).toThrow(/duplicate/); + }); +}); + +describe("localModels.customModels in a whole config file", () => { + it("lets an added model be the active one in the same write", () => { + const parsed = parseUserConfigFile({ + ...USER_CONFIG_DEFAULTS, + localModels: { + ...USER_CONFIG_DEFAULTS.localModels, + customModels: [MINIMAL], + managed: { ...USER_CONFIG_DEFAULTS.localModels.managed, modelId: MINIMAL.id }, + }, + }); + expect(parsed.localModels.managed.modelId).toBe(MINIMAL.id); + expect(parsed.localModels.customModels).toHaveLength(1); + }); + + it("still refuses an active id that names nothing", () => { + expect(() => + parseUserConfigFile({ + ...USER_CONFIG_DEFAULTS, + localModels: { + ...USER_CONFIG_DEFAULTS.localModels, + customModels: [], + managed: { ...USER_CONFIG_DEFAULTS.localModels.managed, modelId: MINIMAL.id }, + }, + }), + ).toThrow(/unknown managed local model id/); + }); + + // The key is additive, so a file written before it existed has to read + // exactly as it did then. + it("upgrades a file from the previous version with an empty list", () => { + const previous = { ...USER_CONFIG_DEFAULTS, version: 43 } as Record; + delete (previous.localModels as Record).customModels; + expect(parseUserConfigFile(previous).localModels.customModels).toEqual([]); + }); +}); diff --git a/src/config/custom-models-schema.ts b/src/config/custom-models-schema.ts new file mode 100644 index 00000000..54c6eac7 --- /dev/null +++ b/src/config/custom-models-schema.ts @@ -0,0 +1,130 @@ +/** + * Validation for `localModels.customModels`, the block that holds the + * models an operator added from Hugging Face. Ported from PR #38 by + * sachin-detrax and kept out of `config-schema.ts`, which is long enough + * already; it depends only on `ConfigValidationError` so the two files + * do not form a cycle. + * + * Only the fields the runtime reads are enforced. The rest are cosmetic + * and defaulted, so a hand-written entry can stay to four lines. + */ + +import { ConfigValidationError } from "./config-validation-error.js"; +import type { LocalModelDef } from "../local-llm/models-catalog.js"; + +/** The id becomes a directory name under `/models/`. */ +const CUSTOM_ID_RE = /^custom-[a-z0-9._-]+$/; + +function requireString(raw: unknown, field: string): string { + if (typeof raw !== "string" || raw.trim().length === 0) { + throw new ConfigValidationError(field, `expected a non-empty string`); + } + return raw; +} + +/** + * Filenames land in a path join under `/models//` (see + * `backend-paths.ts`), so they get the same treatment the id gets for + * becoming a directory name: no separators, no dot-dot, nothing that + * can climb out of the model's own directory. + */ +function requireSafeFilename(raw: unknown, field: string): string { + const str = requireString(raw, field); + if (str.includes("/") || str.includes("\\") || str.startsWith(".")) { + throw new ConfigValidationError( + field, + `expected a bare filename (no path separators, no leading dot), got ${JSON.stringify(str)}`, + ); + } + return str; +} + +function requireUrl(raw: unknown, field: string): string { + const str = requireString(raw, field); + try { + new URL(str); + } catch { + throw new ConfigValidationError(field, `expected a valid URL, got ${JSON.stringify(raw)}`); + } + return str; +} + +function optionalNumber(raw: unknown, field: string, fallback: number): number { + if (raw === undefined || raw === null) return fallback; + const value = typeof raw === "number" ? raw : Number.NaN; + if (!Number.isFinite(value) || value < 0) { + throw new ConfigValidationError( + field, + `expected a non-negative number, got ${JSON.stringify(raw)}`, + ); + } + return value; +} + +function optionalString(raw: unknown, fallback: string): string { + return typeof raw === "string" && raw.length > 0 ? raw : fallback; +} + +export function parseCustomLocalModel(raw: unknown, field: string): LocalModelDef { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected an object"); + } + const entry = raw as Record; + const id = requireString(entry.id, `${field}.id`); + if (!CUSTOM_ID_RE.test(id)) { + throw new ConfigValidationError( + `${field}.id`, + `expected /^custom-[a-z0-9._-]+$/, got ${JSON.stringify(id)}`, + ); + } + const fileSizeGb = optionalNumber(entry.fileSizeGb, `${field}.fileSizeGb`, 0); + const supportsVision = entry.supportsVision === true; + const def: LocalModelDef = { + id: id as LocalModelDef["id"], + name: optionalString(entry.name, id), + filename: requireSafeFilename(entry.filename, `${field}.filename`), + huggingFaceUrl: requireUrl(entry.huggingFaceUrl, `${field}.huggingFaceUrl`), + fileSizeGb, + sizeLabel: optionalString(entry.sizeLabel, `${fileSizeGb.toFixed(1)} GB`), + description: optionalString(entry.description, "Custom model"), + maxContextLength: optionalNumber( + entry.maxContextLength, + `${field}.maxContextLength`, + 0, + ), + contextLabel: optionalString(entry.contextLabel, "auto"), + minRamGb: optionalNumber(entry.minRamGb, `${field}.minRamGb`, 1), + recommendedRamGb: optionalNumber( + entry.recommendedRamGb, + `${field}.recommendedRamGb`, + 2, + ), + family: "custom", + supportsVision, + }; + if (!supportsVision) return def; + return { + ...def, + mmprojUrl: requireUrl(entry.mmprojUrl, `${field}.mmprojUrl`), + mmprojFilename: requireSafeFilename(entry.mmprojFilename, `${field}.mmprojFilename`), + mmprojFileSizeGb: optionalNumber( + entry.mmprojFileSizeGb, + `${field}.mmprojFileSizeGb`, + 0, + ), + }; +} + +export function parseCustomLocalModels(raw: unknown, field: string): LocalModelDef[] { + if (raw === undefined || raw === null) return []; + if (!Array.isArray(raw)) throw new ConfigValidationError(field, "expected an array"); + const parsed = raw.map((entry, i) => parseCustomLocalModel(entry, `${field}[${i}]`)); + const seen = new Set(); + for (const def of parsed) { + if (seen.has(def.id)) { + throw new ConfigValidationError(field, `duplicate custom model id: ${def.id}`); + } + seen.add(def.id); + } + return parsed; +} diff --git a/src/config/custom-models-store.ts b/src/config/custom-models-store.ts new file mode 100644 index 00000000..0e1832d9 --- /dev/null +++ b/src/config/custom-models-store.ts @@ -0,0 +1,61 @@ +/** + * Write the operator's own Hugging Face models into the user config and + * keep the catalog registry in step. Ported from PR #38 by + * sachin-detrax; the ordering below (registry first, cache second) is + * its observation, and it matters — the first-run flow adds a model and + * asks the orchestrator to pull it in the same tick. + */ + +import { ensureUserConfigFileSync, writeUserConfigFileSync } from "./config-file.js"; +import { parseUserConfigFile } from "./config-schema.js"; +import { getConfig, resetConfigCache } from "./config-cache.js"; +import { setCustomLocalModels } from "../local-llm/models-catalog.js"; +import type { LocalModelDef } from "../local-llm/models-catalog.js"; + +function writeCustomModels(defs: readonly LocalModelDef[]): void { + const path = getConfig().paths.userConfigFile; + const previous = ensureUserConfigFileSync(path); + // Dropping the model that is currently active would leave + // `managed.modelId` dangling, and the file would then fail its own + // validation on the next read. + const activeId = previous.localModels.managed.modelId; + const activeSurvives = + activeId === null || + !activeId.startsWith("custom-") || + defs.some((def) => def.id === activeId); + const validated = parseUserConfigFile({ + ...previous, + localModels: { + ...previous.localModels, + customModels: [...defs], + managed: { + ...previous.localModels.managed, + modelId: activeSurvives ? activeId : null, + }, + }, + }); + writeUserConfigFileSync(path, validated); + setCustomLocalModels(validated.localModels.customModels); + resetConfigCache(); +} + +/** + * Persist `def`, replacing any entry with the same id — re-adding the + * same repo and file is a refresh, not a duplicate. + */ +export function addCustomModel(def: LocalModelDef): void { + const path = getConfig().paths.userConfigFile; + const previous = ensureUserConfigFileSync(path); + const kept = previous.localModels.customModels.filter((m) => m.id !== def.id); + writeCustomModels([...kept, def]); +} + +/** Drop one. Returns `false` when there was nothing by that id. */ +export function removeCustomModel(id: string): boolean { + const path = getConfig().paths.userConfigFile; + const previous = ensureUserConfigFileSync(path); + const kept = previous.localModels.customModels.filter((m) => m.id !== id); + if (kept.length === previous.localModels.customModels.length) return false; + writeCustomModels(kept); + return true; +} diff --git a/src/config/index.ts b/src/config/index.ts index 312d7bcb..dd6ea2c2 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -4,22 +4,29 @@ export type { HttpApprovalMode, LocalLlmMode, LogLevel, + OnboardingState, TelegramConfig, TelegramParseMode, UserConfigFile, UserManagedLocalLlmConfig, + WebFetchConfig, WebSearchConfig, WebSearchProviderName, WebhookConfig, + WhileBusySubmitMode, } from "./config-schema.js"; export { ConfigValidationError, USER_CONFIG_DEFAULTS, USER_CONFIG_VERSION, + parseOnboardingState, parseUserConfigFile, + parseWhileBusySubmit, } from "./config-schema.js"; +export type { ConfigNoticeSink } from "./config-file.js"; export { ensureUserConfigFileSync, + setConfigNoticeSink, getDotenvPath, getTrustConfigPaths, getUserConfigPath, @@ -40,10 +47,22 @@ export { type UserLlmFileConfig, type UserLlmFallbackConfig, type UserLlmProviderEntry, + type UserSubscriptionCliOptions, + type SubscriptionCliName, + SUBSCRIPTION_CLIS, } from "./llm-config.js"; +export { + SUBSCRIPTION_CLI_KIND, + usesExternalCliAuth, +} from "./provider-auth-mode.js"; export type { DotenvLoadResult, DotenvReadFailure, } from "./load-dotenv.js"; export { DotenvWriterError, setDotenvKey } from "./dotenv-writer.js"; export type { SetDotenvKeyResult } from "./dotenv-writer.js"; +export { addCustomModel, removeCustomModel } from "./custom-models-store.js"; +export { + parseCustomLocalModel, + parseCustomLocalModels, +} from "./custom-models-schema.js"; diff --git a/src/config/llm-config.test.ts b/src/config/llm-config.test.ts index ee01b842..ee0aad96 100644 --- a/src/config/llm-config.test.ts +++ b/src/config/llm-config.test.ts @@ -202,4 +202,307 @@ describe("llm-config", () => { }); expect(parsed.llm?.fallback).toBeUndefined(); }); + + it("parses extraBody on an openai-compatible provider entry", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "model-studio", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { + id: "local-llama", + kind: "llama-server", + url: "http://127.0.0.1:19091", + }, + { + id: "model-studio", + kind: "qwen-openai-compatible", + baseUrl: "https://example.invalid/compatible-mode", + defaultChatModel: "qwen3.8-27b", + extraBody: { chat_template_kwargs: { enable_thinking: false } }, + }, + ], + }, + }); + expect(parsed.llm?.providers[1]?.extraBody).toEqual({ + chat_template_kwargs: { enable_thinking: false }, + }); + }); + + const withProviderField = (extra: Record) => ({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "openrouter", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto" as const, + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + { id: "openrouter", kind: "openrouter", defaultChatModel: "gpt", ...extra }, + ], + }, + }); + + it("round-trips promptCache and providerPreferences on a provider entry", () => { + const parsed = parseUserConfigFile( + withProviderField({ + promptCache: "explicit-markers", + providerPreferences: { order: ["anthropic"], allow_fallbacks: false }, + }), + ); + expect(parsed.llm?.providers[1]).toMatchObject({ + promptCache: "explicit-markers", + providerPreferences: { order: ["anthropic"], allow_fallbacks: false }, + }); + }); + + it("rejects an unknown promptCache mode", () => { + expect(() => + parseUserConfigFile(withProviderField({ promptCache: "always" })), + ).toThrow(/llm\.providers\[1\]\.promptCache/); + }); + + it("rejects a non-object providerPreferences", () => { + expect(() => + parseUserConfigFile(withProviderField({ providerPreferences: ["anthropic"] })), + ).toThrow(/llm\.providers\[1\]\.providerPreferences/); + }); + + const withUserModels = (userModels: unknown) => ({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "model-studio", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto" as const, + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + { + id: "model-studio", + kind: "qwen-openai-compatible", + baseUrl: "https://example.invalid/compatible-mode", + defaultChatModel: "qwen3.8-27b", + userModels, + }, + ], + }, + }); + + it("round-trips userModels on a provider entry", () => { + const parsed = parseUserConfigFile( + withUserModels([ + { + id: "qwen3.8-27b", + kind: "chat", + contextWindow: 262144, + supportsVision: true, + supportsTools: "strict", + supportsPromptCache: true, + reasoningFormat: "delta_reasoning_content", + pricing: { input: 0.0004, output: 0.0012, cacheRead: 0 }, + }, + { id: "text-embedding-v4", kind: "embedding", dim: 1024 }, + ]), + ); + + // resolveModel reads userModels as its highest-priority source, so + // the parser dropping these rows is the difference between a + // hand-configured model and the 128k/no-pricing defaults. + expect(parsed.llm?.providers[1]?.userModels).toEqual([ + { + id: "qwen3.8-27b", + kind: "chat", + contextWindow: 262144, + dim: undefined, + supportsVision: true, + supportsTools: "strict", + supportsPromptCache: true, + reasoningFormat: "delta_reasoning_content", + pricing: { input: 0.0004, output: 0.0012, cacheRead: 0 }, + }, + { + id: "text-embedding-v4", + kind: "embedding", + contextWindow: undefined, + dim: 1024, + supportsVision: undefined, + supportsTools: undefined, + supportsPromptCache: undefined, + reasoningFormat: undefined, + pricing: undefined, + }, + ]); + }); + + it("omits userModels when the entry does not configure any", () => { + const parsed = parseUserConfigFile(withUserModels(undefined)); + expect(parsed.llm?.providers[1]?.userModels).toBeUndefined(); + }); + + it("rejects a userModels row with an unknown kind", () => { + expect(() => + parseUserConfigFile( + withUserModels([{ id: "qwen3.8-27b", kind: "completion" }]), + ), + ).toThrow(/llm\.providers\[1\]\.userModels\[0\]\.kind/); + }); + + it("rejects a userModels row with a malformed contextWindow", () => { + expect(() => + parseUserConfigFile( + withUserModels([ + { id: "a", kind: "chat" }, + { id: "b", kind: "chat", contextWindow: "262144" }, + ]), + ), + ).toThrow(/llm\.providers\[1\]\.userModels\[1\]\.contextWindow/); + }); + + it("rejects userModels pricing that is missing a rate", () => { + expect(() => + parseUserConfigFile( + withUserModels([ + { id: "a", kind: "chat", pricing: { input: 0.0004 } }, + ]), + ), + ).toThrow(/llm\.providers\[1\]\.userModels\[0\]\.pricing\.output/); + }); + + it("rejects duplicate model ids within one provider's userModels", () => { + expect(() => + parseUserConfigFile( + withUserModels([ + { id: "a", kind: "chat" }, + { id: "a", kind: "chat" }, + ]), + ), + ).toThrow(/userModels\[1\]\.id/); + }); + + it("rejects a non-array userModels", () => { + expect(() => + parseUserConfigFile(withUserModels({ "qwen3.8-27b": { kind: "chat" } })), + ).toThrow(/userModels/); + }); + + it("rejects a non-object extraBody", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "model-studio", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { + id: "local-llama", + kind: "llama-server", + url: "http://127.0.0.1:19091", + }, + { + id: "model-studio", + kind: "qwen-openai-compatible", + baseUrl: "https://example.invalid/compatible-mode", + defaultChatModel: "qwen3.8-27b", + extraBody: "enable_thinking=false", + }, + ], + }, + }), + ).toThrow(/extraBody/); + }); + it("accepts subscription-cli entries and round-trips subscriptionCli", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { + id: "local-llama", + kind: "llama-server", + url: "http://127.0.0.1:19091", + }, + { + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "sonnet", + subscriptionCli: { + cli: "claude", + binPath: "/opt/homebrew/bin/claude", + extraArgs: ["--effort", "high"], + streaming: false, + maxBudgetUsd: 5, + }, + }, + ], + }, + }); + const entry = parsed.llm?.providers.find((p) => p.id === "claude-cli"); + // parseLlmProviderEntry is a whitelist that rebuilds the entry from + // known keys, so an unparsed field would be silently dropped on the + // next config rewrite. Pin the whole block, not just `cli`. + expect(entry?.subscriptionCli).toEqual({ + cli: "claude", + binPath: "/opt/homebrew/bin/claude", + extraArgs: ["--effort", "high"], + streaming: false, + maxBudgetUsd: 5, + }); + }); + + it("rejects a subscription-cli entry with no subscriptionCli block", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "claude-cli", + toolTransport: "auto", + providers: [{ id: "claude-cli", kind: "subscription-cli" }], + }, + }), + ).toThrow(/subscriptionCli/); + }); + + it("rejects an unknown cli name", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "gemini-cli", + activeEmbeddingProvider: "gemini-cli", + toolTransport: "auto", + providers: [ + { + id: "gemini-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "gemini" }, + }, + ], + }, + }), + ).toThrow(/subscriptionCli\.cli/); + }); + + it("rejects non-string extraArgs", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "claude-cli", + toolTransport: "auto", + providers: [ + { + id: "claude-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "claude", extraArgs: ["--effort", 3] }, + }, + ], + }, + }), + ).toThrow(/extraArgs\[1\]/); + }); }); diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts index 34d2b361..bc00e644 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -1,7 +1,34 @@ import { ConfigValidationError } from "./config-validation-error.js"; +import { SUBSCRIPTION_CLI_KIND } from "./provider-auth-mode.js"; export type UserLlmToolTransport = "auto" | "grammar" | "native_tools"; +/** Vendor CLIs a `subscription-cli` provider knows how to drive. */ +export const SUBSCRIPTION_CLIS = ["claude", "codex"] as const; +export type SubscriptionCliName = (typeof SUBSCRIPTION_CLIS)[number]; + +/** + * Settings for a provider backed by an already-signed-in vendor CLI. + * The CLI authenticates itself from its own session, so there is no + * `apiKey` / `apiKeyEnvVar` anywhere in this block. + */ +export type UserSubscriptionCliOptions = { + /** Which CLI to drive. Required when `kind` is `subscription-cli`. */ + cli: SubscriptionCliName; + /** Absolute path to the binary. Omit to resolve it from `PATH`. */ + binPath?: string; + /** + * Extra argv appended verbatim to every invocation. The escape hatch + * for flags we do not model (`--effort high`) and for correcting a + * vendor CLI whose interface moved, without waiting for a release. + */ + extraArgs?: string[]; + /** Opt out of the streaming path and always buffer. */ + streaming?: boolean; + /** Passed through as the CLI's own spend ceiling where it has one. */ + maxBudgetUsd?: number; +}; + export type UserLlmProviderEntry = { id: string; kind: string; @@ -19,11 +46,85 @@ export type UserLlmProviderEntry = { defaultChatModel?: string; defaultEmbeddingModel?: string; headers?: Record; + /** + * Header that carries this entry's API key. Set for known-service + * presets whose endpoint does not accept `Authorization: Bearer` + * (Anthropic wants `x-api-key`). Absent keeps the OpenAI convention. + * Stored on the entry rather than looked up from the preset table at + * request time, so a saved provider keeps authenticating after a + * restart and a hand-written entry can express the same thing. + */ + apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; requestTimeoutMs?: number; + /** + * Prompt-caching policy for this provider. Declared in the config + * schema and on `LlmProviderConfigEntry`; no provider reads it yet, + * so today it only has to survive the round-trip through config. + */ + promptCache?: "auto" | "off" | "explicit-markers"; + /** + * Vendor routing preferences (e.g. OpenRouter's `provider` block). + * Same status as `promptCache`: carried through config, not yet read + * by any provider. + */ + providerPreferences?: Record; + /** + * Vendor-specific fields merged into the OpenAI-compatible chat body + * for `openai-compatible` / `qwen-openai-compatible` providers. Lets a + * deployment reach vendor extensions outside the OpenAI schema, e.g. + * Alibaba Model Studio thinking control: + * + * ```json + * { "chat_template_kwargs": { "enable_thinking": false } } + * ``` + * + * Reserved keys (`model`, `messages`, `stream`, `tools`) are re-applied + * after the merge and cannot be overridden from config. + */ + extraBody?: Record; + /** + * Hand-written model metadata for this provider. `resolveModel` + * reads it as its highest-priority source (userModels > bundled + * catalog > defaults), so it is the documented way to teach the + * runtime about a model the bundled catalog does not know: context + * window, capabilities and pricing. + */ + userModels?: ReadonlyArray; + /** Present only on `subscription-cli` entries. */ + subscriptionCli?: UserSubscriptionCliOptions; }; +/** + * One hand-configured model on a provider entry. Mirrors + * `UserModelConfigEntry` in the provider registry — the shape + * `resolveModel` merges over the bundled catalog. + * + * Note `supportsTools` here is a support *level*, not the boolean of + * the same name on the provider entry: a model can advertise strict or + * parallel tool calling independently of whether the transport does. + */ +export type UserModelEntry = { + id: string; + kind: "chat" | "embedding"; + contextWindow?: number; + dim?: number; + supportsVision?: boolean; + supportsTools?: "none" | "basic" | "parallel" | "strict"; + supportsPromptCache?: boolean; + reasoningFormat?: + | "none" + | "delta_reasoning" + | "delta_thinking" + | "delta_reasoning_content"; + pricing?: { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + };}; + export type UserLlmFallbackConfig = { chain?: string[]; appendLocal?: boolean; @@ -49,6 +150,7 @@ const PROVIDER_KINDS = new Set([ "openrouter", "aimlapi", "gemini", + SUBSCRIPTION_CLI_KIND, ]); function parseProviderId(raw: unknown, field: string): string { @@ -106,6 +208,18 @@ export function parseLlmProviderEntry( `expected one of ${[...PROVIDER_KINDS].join(", ")}`, ); } + const subscriptionCli = parseSubscriptionCliOptions( + obj.subscriptionCli, + `${field}.subscriptionCli`, + ); + // A `subscription-cli` entry without a `cli` has no binary to drive, so + // fail at load rather than at the first inference an hour into a run. + if (kind === SUBSCRIPTION_CLI_KIND && !subscriptionCli) { + throw new ConfigValidationError( + `${field}.subscriptionCli`, + `required when kind is ${SUBSCRIPTION_CLI_KIND}`, + ); + } return { id, kind, @@ -123,6 +237,10 @@ export function parseLlmProviderEntry( `${field}.defaultEmbeddingModel`, ), headers: parseOptionalHeaders(obj.headers, `${field}.headers`), + apiKeyHeader: parseOptionalString( + obj.apiKeyHeader, + `${field}.apiKeyHeader`, + ), supportsTools: obj.supportsTools === undefined ? undefined @@ -158,9 +276,227 @@ export function parseLlmProviderEntry( "expected positive number", ); })(), + promptCache: parseOptionalEnum< + NonNullable + >(obj.promptCache, `${field}.promptCache`, PROMPT_CACHE_MODES), + providerPreferences: parseOptionalPlainObject( + obj.providerPreferences, + `${field}.providerPreferences`, + ), + extraBody: parseOptionalPlainObject(obj.extraBody, `${field}.extraBody`), + userModels: parseOptionalUserModels(obj.userModels, `${field}.userModels`), + subscriptionCli: parseSubscriptionCliOptions( + obj.subscriptionCli, + `${field}.subscriptionCli`, + ), }; } +function parseSubscriptionCliOptions( + raw: unknown, + field: string, +): UserSubscriptionCliOptions | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const cli = obj.cli; + if ( + typeof cli !== "string" || + !(SUBSCRIPTION_CLIS as readonly string[]).includes(cli) + ) { + throw new ConfigValidationError( + `${field}.cli`, + `expected one of ${SUBSCRIPTION_CLIS.join(", ")}`, + ); + } + const out: UserSubscriptionCliOptions = { cli: cli as SubscriptionCliName }; + const binPath = parseOptionalString(obj.binPath, `${field}.binPath`); + if (binPath !== undefined) out.binPath = binPath; + if (obj.extraArgs !== undefined && obj.extraArgs !== null) { + if (!Array.isArray(obj.extraArgs)) { + throw new ConfigValidationError( + `${field}.extraArgs`, + "expected array of strings", + ); + } + out.extraArgs = obj.extraArgs.map((value, i) => { + if (typeof value !== "string") { + throw new ConfigValidationError( + `${field}.extraArgs[${i}]`, + "expected string", + ); + } + return value; + }); + } + if (obj.streaming !== undefined && obj.streaming !== null) { + if (typeof obj.streaming !== "boolean") { + throw new ConfigValidationError(`${field}.streaming`, "expected boolean"); + } + out.streaming = obj.streaming; + } + if (obj.maxBudgetUsd !== undefined && obj.maxBudgetUsd !== null) { + if ( + typeof obj.maxBudgetUsd !== "number" || + !Number.isFinite(obj.maxBudgetUsd) || + obj.maxBudgetUsd <= 0 + ) { + throw new ConfigValidationError( + `${field}.maxBudgetUsd`, + "expected positive number", + ); + } + out.maxBudgetUsd = obj.maxBudgetUsd; + } + return out; +} + +function parseOptionalPlainObject( + raw: unknown, + field: string, +): Record | undefined { + if (raw === undefined) return undefined; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + return { ...(raw as Record) }; +} + +const PROMPT_CACHE_MODES = new Set(["auto", "off", "explicit-markers"]); +const TOOLS_SUPPORT_LEVELS = new Set(["none", "basic", "parallel", "strict"]); +const REASONING_FORMATS = new Set([ + "none", + "delta_reasoning", + "delta_thinking", + "delta_reasoning_content", +]); + +function parseOptionalBoolean( + raw: unknown, + field: string, +): boolean | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "boolean") { + throw new ConfigValidationError(field, "expected boolean"); + } + return raw; +} + +function parseOptionalEnum( + raw: unknown, + field: string, + allowed: ReadonlySet, +): T | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "string" || !allowed.has(raw)) { + throw new ConfigValidationError(field, `expected ${[...allowed].join("|")}`); + } + return raw as T; +} + +/** + * Prices are per-token rates, so 0 is legal (free tiers) but negative + * or non-finite is not — a NaN rate would poison every cost estimate + * downstream rather than fail loudly. + */ +function parseRate(raw: unknown, field: string): number { + if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) { + throw new ConfigValidationError(field, "expected a non-negative number"); + } + return raw; +} + +function parseUserModelPricing( + raw: unknown, + field: string, +): UserModelEntry["pricing"] | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const pricing: NonNullable = { + input: parseRate(obj.input, `${field}.input`), + output: parseRate(obj.output, `${field}.output`), + }; + if (obj.cacheRead !== undefined && obj.cacheRead !== null) { + pricing.cacheRead = parseRate(obj.cacheRead, `${field}.cacheRead`); + } + if (obj.cacheWrite !== undefined && obj.cacheWrite !== null) { + pricing.cacheWrite = parseRate(obj.cacheWrite, `${field}.cacheWrite`); + } + return pricing; +} + +function parseUserModelEntry(raw: unknown, field: string): UserModelEntry { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + if (typeof obj.id !== "string" || obj.id.length === 0) { + throw new ConfigValidationError(`${field}.id`, "expected non-empty string"); + } + if (obj.kind !== "chat" && obj.kind !== "embedding") { + throw new ConfigValidationError(`${field}.kind`, "expected chat|embedding"); + } + return { + id: obj.id, + kind: obj.kind, + contextWindow: + obj.contextWindow === undefined || obj.contextWindow === null + ? undefined + : parsePositiveInt(obj.contextWindow, `${field}.contextWindow`), + dim: + obj.dim === undefined || obj.dim === null + ? undefined + : parsePositiveInt(obj.dim, `${field}.dim`), + supportsVision: parseOptionalBoolean( + obj.supportsVision, + `${field}.supportsVision`, + ), + supportsTools: parseOptionalEnum< + NonNullable + >(obj.supportsTools, `${field}.supportsTools`, TOOLS_SUPPORT_LEVELS), + supportsPromptCache: parseOptionalBoolean( + obj.supportsPromptCache, + `${field}.supportsPromptCache`, + ), + reasoningFormat: parseOptionalEnum< + NonNullable + >(obj.reasoningFormat, `${field}.reasoningFormat`, REASONING_FORMATS), + pricing: parseUserModelPricing(obj.pricing, `${field}.pricing`), + }; +} + +function parseOptionalUserModels( + raw: unknown, + field: string, +): UserModelEntry[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected array"); + } + // `resolveModel` looks a model up by id with `.find`, so a duplicate + // id would silently shadow the later row. Reject it at parse time + // instead of serving whichever copy happens to come first. + const seen = new Set(); + const out: UserModelEntry[] = []; + for (let i = 0; i < raw.length; i++) { + const entry = parseUserModelEntry(raw[i], `${field}[${i}]`); + if (seen.has(entry.id)) { + throw new ConfigValidationError( + `${field}[${i}].id`, + `duplicate model id ${JSON.stringify(entry.id)}`, + ); + } + seen.add(entry.id); + out.push(entry); + } + return out; +} + export function parseLlmProviders( raw: unknown, field: string, diff --git a/src/config/load-config.test.ts b/src/config/load-config.test.ts index 96cf2e85..25f0f996 100644 --- a/src/config/load-config.test.ts +++ b/src/config/load-config.test.ts @@ -31,6 +31,7 @@ describe("loadConfig", () => { delete process.env.ATOMIC_AGENT_LLAMA_API_KEY; delete process.env.ATOMIC_AGENT_LLAMA_MAX_TOKENS; delete process.env.ATOMIC_AGENT_BROWSER_CHANNEL; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; delete process.env.ATOMIC_LOADCONFIG_TEST_KEY; resetConfigCache(); vi.restoreAllMocks(); @@ -212,4 +213,31 @@ describe("loadConfig", () => { resetConfigCache(); expect(loadConfig().paths.localModelsDataDir).toBe(override); }); + + it("resolves grammarsDir without consulting the working directory", () => { + // The Ctrl+N "new terminal window" spawn starts the agent by absolute + // path from the operator's home, so cwd holds no `grammars/` and the + // old cwd-relative default died on ENOENT tool-call.gbnf. Standing in + // an empty temp dir reproduces exactly that shape. + const elsewhere = mkdtempSync(join(tmpdir(), "atomic-cwd-")); + const originalCwd = process.cwd(); + try { + process.chdir(elsewhere); + resetConfigCache(); + const grammarsDir = loadConfig().paths.grammarsDir; + expect(grammarsDir.startsWith(elsewhere)).toBe(false); + expect(existsSync(join(grammarsDir, "tool-call.gbnf"))).toBe(true); + } finally { + process.chdir(originalCwd); + rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + it("still lets ATOMIC_AGENT_GRAMMARS_DIR win over the packaged copy", () => { + const override = join(stateDir, "custom-grammars"); + mkdirSync(override); + process.env.ATOMIC_AGENT_GRAMMARS_DIR = override; + resetConfigCache(); + expect(loadConfig().paths.grammarsDir).toBe(override); + }); }); diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 49f9a958..f8b750c2 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { ENV_DEFAULTS, @@ -12,6 +13,7 @@ import { ensureUserConfigFileSync, getUserConfigPath, } from "./config-file.js"; +import { setCustomLocalModels } from "../local-llm/models-catalog.js"; import { loadDotenvFromStateDir } from "./load-dotenv.js"; import { resolveLlmProviderApiKey } from "./resolve-llm-api-key.js"; import type { UserLlmFileConfig } from "./llm-config.js"; @@ -64,8 +66,19 @@ function resolvePath(raw: string | undefined, fallback: string): string { // Asset directories (e.g. `grammars/`) ship next to the Node SEA binary in // installed layouts but live under the project root during dev. Env overrides -// win first; otherwise prefer the binary-adjacent copy and fall back to -// `/` so `npm run`-style dev invocations still work. +// win first; otherwise prefer the binary-adjacent copy, then the copy that +// ships alongside this module, and only then `/`. +// +// The module-relative step is what makes `node /abs/path/dist/cli/index.js` +// work from an unrelated directory — exactly what the Ctrl+N "new terminal +// window" spawn does, which used to die on `ENOENT .../grammars/tool-call.gbnf` +// because cwd was the operator's home rather than the install root. Two levels +// up from this file is the tree root in both layouts: `dist/config/` under a +// build, `src/config/` under tsx. +// +// cwd stays last rather than being dropped: a checkout whose `dist/` was +// copied elsewhere, or any layout we have not thought of, still resolves as it +// always did when run from the project root. function resolveAssetDir(envKey: string, relativeDefault: string): string { const raw = readEnv(envKey); if (raw) { @@ -75,6 +88,15 @@ function resolveAssetDir(envKey: string, relativeDefault: string): string { if (existsSync(nextToBinary)) { return nextToBinary; } + const nextToModule = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + relativeDefault, + ); + if (existsSync(nextToModule)) { + return nextToModule; + } return resolve(process.cwd(), relativeDefault); } @@ -92,6 +114,10 @@ export function loadConfig(): AtomicAgentConfig { const dotenv = loadDotenvFromStateDir(stateDir); const userConfigFile = getUserConfigPath(stateDir); const user = ensureUserConfigFileSync(userConfigFile); + // Publish the operator's own models to the catalog registry, so that + // `getLocalModelDef` and `isKnownLocalModelId` resolve them everywhere + // a curated id already works. + setCustomLocalModels(user.localModels.customModels); const grammarsDir = resolveAssetDir("ATOMIC_AGENT_GRAMMARS_DIR", "grammars"); const browserChannel: BrowserChannel = readBrowserChannel( @@ -177,6 +203,7 @@ export function loadConfig(): AtomicAgentConfig { readEnv("ATOMIC_AGENT_STABLE_PREFIX_SALT") ?? ENV_DEFAULTS.STABLE_PREFIX_SALT, conversationMaxTokens: user.agent.conversationMaxTokens, + conversationMaxPairs: user.agent.conversationMaxPairs, worldSnapshotMaxTokens: user.agent.worldSnapshotMaxTokens, loadedToolsCap: readBoundedPositiveInt( "ATOMIC_AGENT_LOADED_TOOLS_CAP", @@ -282,6 +309,7 @@ export function loadConfig(): AtomicAgentConfig { }, web: { search: { ...user.web.search }, + fetch: { ...user.web.fetch }, }, projects: { roots: [...user.projects.roots], @@ -468,6 +496,9 @@ export function loadConfig(): AtomicAgentConfig { }, tui: { theme: user.tui.theme, + whileBusySubmit: user.tui.whileBusySubmit, + mouse: user.tui.mouse, + onboarding: { ...user.tui.onboarding }, }, analytics: { enabled: user.analytics.enabled, diff --git a/src/config/provider-auth-mode.test.ts b/src/config/provider-auth-mode.test.ts new file mode 100644 index 00000000..f3c26b02 --- /dev/null +++ b/src/config/provider-auth-mode.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + SUBSCRIPTION_CLI_KIND, + usesExternalCliAuth, +} from "./provider-auth-mode.js"; + +describe("usesExternalCliAuth", () => { + it("is true for a subscription-cli entry that names a cli", () => { + expect( + usesExternalCliAuth({ + kind: SUBSCRIPTION_CLI_KIND, + subscriptionCli: { cli: "claude" }, + }), + ).toBe(true); + expect( + usesExternalCliAuth({ + kind: SUBSCRIPTION_CLI_KIND, + subscriptionCli: { cli: "codex" }, + }), + ).toBe(true); + }); + + it("is false for the kind without a cli block", () => { + expect(usesExternalCliAuth({ kind: SUBSCRIPTION_CLI_KIND })).toBe(false); + }); + + it("is false for every key-carrying kind", () => { + for (const kind of [ + "llama-server", + "openai-compatible", + "qwen-openai-compatible", + "openrouter", + "aimlapi", + "gemini", + ]) { + expect(usesExternalCliAuth({ kind })).toBe(false); + // Even a hand-edited config that bolts the block onto another kind + // must not be treated as CLI-authenticated. + expect(usesExternalCliAuth({ kind, subscriptionCli: { cli: "claude" } })).toBe( + false, + ); + } + }); +}); diff --git a/src/config/provider-auth-mode.ts b/src/config/provider-auth-mode.ts new file mode 100644 index 00000000..3cd1fce1 --- /dev/null +++ b/src/config/provider-auth-mode.ts @@ -0,0 +1,33 @@ +import type { UserLlmProviderEntry } from "./llm-config.js"; + +/** + * Provider kind that authenticates by delegating to an already-signed-in + * vendor CLI (`claude`, `codex`) instead of carrying an API key. Lives + * here rather than in the provider folder so the config and TUI layers + * can classify an entry without importing the provider implementation. + */ +export const SUBSCRIPTION_CLI_KIND = "subscription-cli"; + +/** + * Whether this entry gets its credentials from an external CLI's own + * session rather than from an API key we resolve. + * + * Callers use it wherever "has no API key" would otherwise be read as + * "not configured": the TUI startup gate and the providers panel both + * treat a keyless entry as unusable, which is right for every kind that + * existed before subscription CLIs and wrong for this one. + * + * Deliberately does NOT probe the binary. Both call sites are on + * synchronous hot paths (startup, panel refresh), spawning the CLI there + * would add ~800ms per TUI launch, and a transient PATH problem would + * bounce the user into the local-model setup wizard. A missing binary + * surfaces on the first completion and through `health()` instead. + */ +export function usesExternalCliAuth( + entry: Pick, +): boolean { + return ( + entry.kind === SUBSCRIPTION_CLI_KIND && + Boolean(entry.subscriptionCli?.cli) + ); +} diff --git a/src/error-reporting/broken-pipe.test.ts b/src/error-reporting/broken-pipe.test.ts new file mode 100644 index 00000000..588af111 --- /dev/null +++ b/src/error-reporting/broken-pipe.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { isBrokenPipeError } from "./broken-pipe.js"; + +describe("isBrokenPipeError", () => { + it("recognises a closed host pipe", () => { + const err = Object.assign(new Error("write EPIPE"), { + code: "EPIPE", + syscall: "write", + }); + expect(isBrokenPipeError(err)).toBe(true); + }); + + it("recognises a tty that went away", () => { + const err = Object.assign(new Error("write EIO"), { code: "EIO" }); + expect(isBrokenPipeError(err)).toBe(true); + }); + + it("recognises a stream torn down under us", () => { + for (const code of ["ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END"]) { + expect(isBrokenPipeError(Object.assign(new Error(code), { code }))).toBe( + true, + ); + } + }); + + it("reads the code out of a cause chain", () => { + const inner = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + const outer = Object.assign(new Error("failed to emit event"), { + cause: inner, + }); + expect(isBrokenPipeError(outer)).toBe(true); + }); + + it("does not claim ordinary failures", () => { + expect(isBrokenPipeError(new Error("boom"))).toBe(false); + expect( + isBrokenPipeError(Object.assign(new Error("x"), { code: "ECONNRESET" })), + ).toBe(false); + expect(isBrokenPipeError("EPIPE")).toBe(false); + expect(isBrokenPipeError(null)).toBe(false); + }); + + it("survives a self-referential cause chain", () => { + const err = new Error("loop") as Error & { cause?: unknown }; + err.cause = err; + expect(isBrokenPipeError(err)).toBe(false); + }); +}); diff --git a/src/error-reporting/broken-pipe.ts b/src/error-reporting/broken-pipe.ts new file mode 100644 index 00000000..37d7321a --- /dev/null +++ b/src/error-reporting/broken-pipe.ts @@ -0,0 +1,41 @@ +/** + * "The other end of our stdio went away." + * + * Two shapes reach us in production, both as *uncaught exceptions* from + * an asynchronous write: + * + * - `EPIPE` — the sidecar's host (the desktop app) exited, so the next + * NDJSON event write hits a closed pipe; + * - `EIO` — the controlling tty is gone (terminal window closed, + * SIGHUP, a detached tmux pane), so the TUI's frame write fails. + * + * Neither is a defect in the agent. Left unhandled they kill a healthy + * process with a raw stack trace and ship a Sentry event for a peer that + * is simply no longer there. + */ +const BROKEN_PIPE_CODES = new Set([ + "EPIPE", + "EIO", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_WRITE_AFTER_END", +]); + +/** Depth cap on the `cause` walk — a longer chain is a cycle. */ +const MAX_CAUSE_DEPTH = 5; + +/** + * True when `err` (or anything in its `cause` chain) is a write failure + * against a stdio stream whose far end has closed. + */ +export function isBrokenPipeError(err: unknown): boolean { + let current: unknown = err; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (typeof current !== "object" || current === null) return false; + const code = (current as { code?: unknown }).code; + if (typeof code === "string" && BROKEN_PIPE_CODES.has(code)) return true; + const next = (current as { cause?: unknown }).cause; + if (next === current) return false; + current = next; + } + return false; +} diff --git a/src/error-reporting/error-reporter.test.ts b/src/error-reporting/error-reporter.test.ts index 7e49595e..b0be4866 100644 --- a/src/error-reporting/error-reporter.test.ts +++ b/src/error-reporting/error-reporter.test.ts @@ -51,4 +51,20 @@ describe("captureError", () => { expect(captured[0].errorType).toBe("NonError"); expect(captured[0].message).toBeUndefined(); }); + it("drops a process-global broken pipe — the reader left, nothing broke", () => { + const { client, captured } = fakeClient(); + const err = Object.assign(new Error("write EPIPE"), { + code: "EPIPE", + syscall: "write", + }); + captureError(client, err, { source: "uncaughtException" }); + expect(captured).toHaveLength(0); + }); + + it("still reports an EPIPE raised at a call site, where it may be ours", () => { + const { client, captured } = fakeClient(); + const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + captureError(client, err, { source: "tool_exec" }); + expect(captured).toHaveLength(1); + }); }); diff --git a/src/error-reporting/error-reporter.ts b/src/error-reporting/error-reporter.ts index d194fc0a..27904efa 100644 --- a/src/error-reporting/error-reporter.ts +++ b/src/error-reporting/error-reporter.ts @@ -1,7 +1,9 @@ +import { isBrokenPipeError } from "./broken-pipe.js"; import { scrubError } from "./error-scrubber.js"; import type { SentryClient } from "./sentry-client.js"; let globalHandlersInstalled = false; +let stdioGuardsInstalled = false; /** * Capture an error through the (possibly `null`) client. No-ops when @@ -17,6 +19,11 @@ export function captureError( ): void { if (!client) return; if (opts.category === "cancelled") return; + // A process-global EPIPE/EIO is the host pipe or the tty going away, + // not a defect: nothing in the agent misbehaved, its reader left. Only + // the global sources are filtered — an EPIPE surfaced by a tool still + // reports, because there it may well be a bug in ours. + if (isGlobalSource(opts.source) && isBrokenPipeError(err)) return; const error = err instanceof Error ? err @@ -53,9 +60,18 @@ export function installGlobalErrorHandlers( const soleUncaughtHandler = process.listenerCount("uncaughtException") === 0; + installStdioErrorGuards(soleUncaughtHandler); + process.on("uncaughtException", (err) => { const client = getClient(); captureError(client, err, { source: "uncaughtException" }); + // Belt-and-braces for the synchronous path the stream guards below + // cannot intercept: a dead pipe is not a crash, so it neither prints + // a stack (there is nowhere to print it) nor exits non-zero. + if (isBrokenPipeError(err)) { + if (soleUncaughtHandler) process.exit(0); + return; + } if (soleUncaughtHandler) { // Preserve Node's default fatal behavior: flush best-effort (only // when a client is present), print the stack to local stderr, then @@ -79,7 +95,45 @@ export function installGlobalErrorHandlers( }); } -/** Test-only reset of the idempotency guard. */ +/** + * Attach `error` listeners to `process.stdout` / `process.stderr`. + * + * Without a listener, a write failure on either stream is thrown as an + * uncaught exception — which is how a closed host pipe (`EPIPE`) or a + * terminal that went away (`EIO`, e.g. SIGHUP or a detached tmux pane) + * takes down an otherwise healthy agent, stack trace and all. These are + * the two loudest crashes in production and neither is a defect. + * + * `ownsProcess` mirrors the `uncaughtException` policy: when this + * runtime is the top-level process we shut down cleanly (exit 0 — the + * pipe closed, same as `head` hanging up), and when a host embeds us we + * only swallow the error and let the host decide. A non-broken-pipe + * stream error is re-thrown so genuine bugs stay visible. + */ +export function installStdioErrorGuards(ownsProcess: boolean): void { + if (stdioGuardsInstalled) return; + stdioGuardsInstalled = true; + + for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err: unknown) => { + if (!isBrokenPipeError(err)) { + queueMicrotask(() => { + throw err; + }); + return; + } + if (ownsProcess) process.exit(0); + }); + } +} + +/** Test-only reset of the idempotency guards. */ export function resetGlobalErrorHandlersForTests(): void { globalHandlersInstalled = false; + stdioGuardsInstalled = false; +} + +/** Sources that report a process-global failure rather than a call site. */ +function isGlobalSource(source: string): boolean { + return source === "uncaughtException" || source === "unhandledRejection"; } diff --git a/src/error-reporting/error-scrubber.test.ts b/src/error-reporting/error-scrubber.test.ts index 79436fa6..7d07c443 100644 --- a/src/error-reporting/error-scrubber.test.ts +++ b/src/error-reporting/error-scrubber.test.ts @@ -50,6 +50,53 @@ describe("extractSafeCode", () => { expect(extractSafeCode({ code: "failed to read /home/x" })).toEqual({}); expect(extractSafeCode(null)).toEqual({}); }); + + it("reads status and errno through the wrapper chain", () => { + // What actually reaches the scrubber: TransportError wrapping a + // LlamaServerError wrapping undici's TypeError. Only the innermost + // link knows it was a refused connection. + const undici = Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }), + }); + const llama = Object.assign(new Error("network"), { + name: "LlamaServerError", + status: null, + cause: undici, + }); + const transport = Object.assign(new Error("network"), { + name: "TransportError", + cause: llama, + }); + expect(extractSafeCode(transport)).toEqual({ code: "ECONNREFUSED" }); + }); + + it("prefers a status the wrapper carries over its cause's", () => { + const cause = Object.assign(new Error("inner"), { status: 500 }); + const wrapper = Object.assign(new Error("outer"), { status: 404, cause }); + expect(extractSafeCode(wrapper)).toEqual({ httpStatus: 404 }); + }); + + it("back-fills the status a GrammarError wrapper does not carry", () => { + // GrammarError has no status field at all, which is why not one + // grammar issue in Sentry has an `http_status` tag today. + const llama = Object.assign(new Error("http 501"), { + name: "LlamaServerError", + status: 501, + }); + const grammar = Object.assign(new Error("rejected"), { + name: "GrammarError", + cause: llama, + }); + expect(extractSafeCode(grammar)).toEqual({ httpStatus: 501 }); + }); + + it("survives a self-referential cause chain", () => { + const err = new Error("loop") as Error & { cause?: unknown }; + err.cause = err; + expect(extractSafeCode(err)).toEqual({}); + }); }); describe("extractSafeReason", () => { @@ -199,4 +246,41 @@ describe("scrubError", () => { const ev = scrubError(err, { source: "uncaughtException" }); expect(ev.causeType).toBeUndefined(); }); + + it("falls back to the wrapper's frames when the cause has none", () => { + // A cause with an unparseable stack used to take the wrapper's + // frames down with it and ship an event with NO stack at all — how + // a 108-event issue ended up undiagnosable. + const cause = new Error("fetch failed"); + cause.stack = "TypeError: fetch failed"; + const wrapper = Object.assign(new Error("wrapped"), { + name: "ToolExecutionError", + cause, + }); + wrapper.stack = [ + "ToolExecutionError: wrapped", + " at toLlmFailure (/app/step-executor.js:1561:10)", + " at executeStep (/app/step-executor.js:303:20)", + ].join("\n"); + const ev = scrubError(wrapper, { source: "llm_failure" }); + expect(ev.frames.map((f) => f.filename)).toEqual([ + "step-executor.js", + "step-executor.js", + ]); + }); + + it("still prefers the cause's frames when it has them", () => { + const cause = new Error("boom"); + cause.stack = [ + "Error: boom", + " at realThrowSite (/app/prime-stream.js:24:3)", + ].join("\n"); + const wrapper = Object.assign(new Error("wrapped"), { cause }); + wrapper.stack = [ + "Error: wrapped", + " at wrapIt (/app/step-executor.js:1561:10)", + ].join("\n"); + const ev = scrubError(wrapper, { source: "llm_failure" }); + expect(ev.frames.map((f) => f.filename)).toEqual(["prime-stream.js"]); + }); }); diff --git a/src/error-reporting/error-scrubber.ts b/src/error-reporting/error-scrubber.ts index 69243c83..144b1a8d 100644 --- a/src/error-reporting/error-scrubber.ts +++ b/src/error-reporting/error-scrubber.ts @@ -90,6 +90,16 @@ const SAFE_IDENTIFIER_RE = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,63}$/; const MAX_FRAMES = 30; +/** + * Enum shape for an errno-style code. A value that does not match could + * be freeform text (and therefore user data), so it is dropped rather + * than sent. + */ +const SAFE_CODE_RE = /^[A-Z][A-Z0-9_]*$/; + +/** Depth cap on every `cause` walk in this module — longer is a cycle. */ +const MAX_CAUSE_DEPTH = 5; + // ` at fn (/abs/path/file.js:12:34)` or ` at /abs/path/file.js:12:34` const FRAME_RE = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?\s*$/; @@ -139,18 +149,44 @@ export function sanitizeStack(stack: string | undefined): SentryStackFrame[] { return frames; } -/** Extract only safe, enum-like scalar codes from an error. */ +/** + * Extract only safe, enum-like scalar codes from an error. + * + * The walk continues into `err.cause` because the error that reaches + * this function is usually a wrapper: `TransportError` carries no status + * of its own on the network path, and `GrammarError` carries none at all + * — the HTTP status lives on the `LlamaServerError` underneath, and the + * errno one level below that. Reading only the top object is why the + * largest issue in error reporting has neither an `http_status` nor a + * `code` tag on a single event. + * + * Each field is taken from the outermost link that has it, so a wrapper + * that DOES carry a status still wins over its cause. + */ export function extractSafeCode(err: unknown): { httpStatus?: number; code?: string; } { - if (typeof err !== "object" || err === null) return {}; const out: { httpStatus?: number; code?: string } = {}; - const status = (err as { status?: unknown }).status; - if (typeof status === "number") out.httpStatus = status; - const code = (err as { code?: unknown }).code; - if (typeof code === "string" && /^[A-Z][A-Z0-9_]*$/.test(code)) { - out.code = code; + let current: unknown = err; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (typeof current !== "object" || current === null) break; + const status = (current as { status?: unknown }).status; + if (out.httpStatus === undefined && typeof status === "number") { + out.httpStatus = status; + } + const code = (current as { code?: unknown }).code; + if ( + out.code === undefined && + typeof code === "string" && + SAFE_CODE_RE.test(code) + ) { + out.code = code; + } + if (out.httpStatus !== undefined && out.code !== undefined) break; + const next = (current as { cause?: unknown }).cause; + if (next === current) break; + current = next; } return out; } @@ -193,6 +229,30 @@ export function extractSafeTransportHost(err: unknown): string | undefined { } } +/** + * Choose the frames to report: the cause's, when it has any, else the + * wrapper's own. + * + * Preferring the cause is right — a generic wrapper's `.stack` points at + * the `new ToolExecutionError(...)` call site, not the throw site. But + * preferring it *unconditionally* meant that a cause with no parseable + * frames took the wrapper's frames down with it, and the event shipped + * with an empty stack. That is not hypothetical: it is how a + * 108-event issue ended up with no stack trace at all and no way to + * tell where it came from. Some causes genuinely have nothing — a + * `DOMException` from an abort, an error rebuilt from a serialized + * worker message, anything constructed without `Error.captureStackTrace`. + * A wrapper frame is worth strictly more than nothing. + */ +function pickFrames( + err: Error, + causeError: Error | undefined, +): SentryStackFrame[] { + const causeFrames = causeError ? sanitizeStack(causeError.stack) : []; + if (causeFrames.length > 0) return causeFrames; + return sanitizeStack(err.stack); +} + /** * Read the underlying `Error` off `err.cause`, when present. Only an * `Error` instance is returned — a non-Error cause carries no `.stack` / @@ -236,7 +296,7 @@ export function scrubError( const event: ScrubbedErrorEvent = { errorType, source: opts.source, - frames: sanitizeStack(causeError?.stack ?? err.stack), + frames: pickFrames(err, causeError), }; if (causeType) event.causeType = causeType; if (category) event.category = category; diff --git a/src/error-reporting/index.ts b/src/error-reporting/index.ts index 408a2b2c..4653c636 100644 --- a/src/error-reporting/index.ts +++ b/src/error-reporting/index.ts @@ -29,5 +29,7 @@ export type { export { captureError, installGlobalErrorHandlers, + installStdioErrorGuards, resetGlobalErrorHandlersForTests, } from "./error-reporter.js"; +export { isBrokenPipeError } from "./broken-pipe.js"; diff --git a/src/error-reporting/sentry-envelope.test.ts b/src/error-reporting/sentry-envelope.test.ts index cb007d46..06766b18 100644 --- a/src/error-reporting/sentry-envelope.test.ts +++ b/src/error-reporting/sentry-envelope.test.ts @@ -10,7 +10,15 @@ const META = { installId: "install-1", release: "1.2.3", platform: "darwin" }; function parseEventPayload(body: string): { tags: Record; fingerprint: string[]; - exception: { values: Array<{ type: string; value: string }> }; + exception: { + values: Array<{ + type: string; + value: string; + stacktrace: { + frames: Array<{ filename: string; lineno?: number; in_app: boolean }>; + }; + }>; + }; } { const lines = body.trim().split("\n"); return JSON.parse(lines[2]!); @@ -81,4 +89,64 @@ describe("buildEnvelope", () => { // fingerprint just because they share the generic "unknown" tool tag. expect(payloadA.fingerprint).not.toEqual(payloadB.fingerprint); }); + + it("sends frames oldest-first, the way Sentry reads a stack", () => { + // V8 puts the throw site at index 0; Sentry's protocol wants it + // LAST. Sending V8 order rendered every stack upside-down and made + // Sentry read the culprit off the outermost caller — which is why + // the issue list was a wall of `async run` / `async runOneTurn`. + const ev: ScrubbedErrorEvent = { + errorType: "TransportError", + source: "llm_failure", + frames: [ + { filename: "prime-stream.ts", lineno: 24, function: "primeStream" }, + { filename: "llm-fallback-seam.ts", lineno: 160 }, + { filename: "step-executor.ts", lineno: 272 }, + ], + }; + const payload = parseEventPayload(buildEnvelope(DSN, ev, META).body); + const frames = payload.exception.values[0]!.stacktrace.frames; + expect(frames.map((f) => f.filename)).toEqual([ + "step-executor.ts", + "llm-fallback-seam.ts", + "prime-stream.ts", + ]); + // The crash site is the last frame — that is what Sentry names as + // the culprit. + expect(frames.at(-1)!.filename).toBe("prime-stream.ts"); + }); + + it("marks our own frames in_app and node internals not", () => { + const ev: ScrubbedErrorEvent = { + errorType: "Error", + source: "uncaughtException", + frames: [ + { filename: "node:internal/streams/writable", lineno: 1 }, + { filename: "stdio-protocol.js", lineno: 114 }, + ], + }; + const payload = parseEventPayload(buildEnvelope(DSN, ev, META).body); + const frames = payload.exception.values[0]!.stacktrace.frames; + expect(frames.map((f) => [f.filename, f.in_app])).toEqual([ + ["stdio-protocol.js", true], + ["node:internal/streams/writable", false], + ]); + }); + + it("does not reorder the caller's array (the fingerprint reads frames[0])", () => { + const frames = [ + { filename: "inner.ts", lineno: 1 }, + { filename: "outer.ts", lineno: 2 }, + ]; + const ev: ScrubbedErrorEvent = { + errorType: "TransportError", + source: "llm_failure", + frames, + }; + const payload = parseEventPayload(buildEnvelope(DSN, ev, META).body); + // Reversing in place would flip the fingerprint's topFrame and + // re-group every existing issue. + expect(frames[0]!.filename).toBe("inner.ts"); + expect(payload.fingerprint.at(-1)).toBe("inner.ts"); + }); }); diff --git a/src/error-reporting/sentry-envelope.ts b/src/error-reporting/sentry-envelope.ts index 1abade98..7d79a98a 100644 --- a/src/error-reporting/sentry-envelope.ts +++ b/src/error-reporting/sentry-envelope.ts @@ -1,7 +1,10 @@ import { randomUUID } from "node:crypto"; import type { ParsedSentryDsn } from "./sentry-config.js"; -import type { ScrubbedErrorEvent } from "./error-scrubber.js"; +import type { + ScrubbedErrorEvent, + SentryStackFrame, +} from "./error-scrubber.js"; /** Constant context stamped on every envelope. */ export interface EnvelopeMeta { @@ -88,7 +91,9 @@ export function buildEnvelope( { type: ev.errorType, value: ev.message ?? ev.errorType, - stacktrace: { frames: ev.frames }, + // `ev.frames` stays innermost-first for the fingerprint above; + // the wire format wants the opposite. See `toSentryFrameOrder`. + stacktrace: { frames: toSentryFrameOrder(ev.frames) }, }, ], }, @@ -105,6 +110,49 @@ export function buildEnvelope( return { eventId, body }; } +/** A stack frame as Sentry's ingest protocol wants it. */ +interface WireStackFrame { + function?: string; + filename: string; + lineno?: number; + colno?: number; + in_app: boolean; +} + +/** + * Reorder and annotate frames for the wire. + * + * Two protocol details, both of which we were getting wrong: + * + * - **Order.** Sentry lists a stack oldest frame FIRST, so the crash + * site is the LAST entry — the opposite of V8, which puts the throw + * site at index 0. Sending V8 order rendered every stack upside-down + * in the UI and, worse, made Sentry read the culprit off the wrong + * end: that is why the issue list is a wall of `async run`, + * `async executeStep`, `async runOneTurn` — the outermost caller of + * unrelated bugs — instead of the frame that actually threw. + * + * - **`in_app`.** Nothing ever set it, so every frame was a system + * frame (`in_app_frame_mix: "system-only"` on all of our issues) and + * Sentry's culprit/grouping heuristics had nothing of ours to + * prefer. Anything that is not a `node:` internal is ours — the + * scrubber has already reduced paths to basenames, and the bundle + * is a single file, so there is no dependency directory left to + * distinguish. + * + * `ev.frames` is left untouched (a copy is reversed): the fingerprint + * reads `frames[0]` as the innermost frame, and flipping that would + * re-group every existing issue. + */ +function toSentryFrameOrder(frames: SentryStackFrame[]): WireStackFrame[] { + return frames + .map((frame) => ({ + ...frame, + in_app: !frame.filename.startsWith("node:"), + })) + .reverse(); +} + /** Build the `X-Sentry-Auth` header value for an envelope POST. */ export function buildSentryAuthHeader( dsn: ParsedSentryDsn, diff --git a/src/http/http-server.ts b/src/http/http-server.ts index c5d8410e..e1c86056 100644 --- a/src/http/http-server.ts +++ b/src/http/http-server.ts @@ -4,6 +4,7 @@ import type { AgentRuntime } from "../runtime/bootstrap.js"; import { ApprovalBus } from "./approval-bus.js"; import { CompletionRegistry } from "./completion-registry.js"; import { openaiError } from "./openai-errors.js"; +import { UndeliveredSteerStore } from "./undelivered-steers.js"; import { BodyParseError, BodyTooLargeError, @@ -40,6 +41,7 @@ export interface HttpServerOptions { routes: RouteDefinition[]; approvalBus?: ApprovalBus; completionRegistry?: CompletionRegistry; + undeliveredSteers?: UndeliveredSteerStore; } export interface HttpServerHandle { @@ -48,6 +50,12 @@ export interface HttpServerHandle { port: number; approvalBus: ApprovalBus; completionRegistry: CompletionRegistry; + /** + * Steers the runtime accepted but no turn ever delivered. Exposed on + * the handle so an embedder can drain or inspect them the same way it + * can inspect pending approvals. + */ + undeliveredSteers: UndeliveredSteerStore; close: () => Promise; } @@ -116,6 +124,8 @@ export function createHttpServer( const approvalBus = options.approvalBus ?? new ApprovalBus(); const completionRegistry = options.completionRegistry ?? new CompletionRegistry(); + const undeliveredSteers = + options.undeliveredSteers ?? new UndeliveredSteerStore(); const compiled = options.routes.map(compileRoute); const server = createServer(async (req, res) => { @@ -125,6 +135,7 @@ export function createHttpServer( apiKey: options.apiKey, approvalBus, completionRegistry, + undeliveredSteers, }); } catch (err) { handleRouteError(res, err); @@ -153,6 +164,7 @@ export function createHttpServer( port: resolvedPort, approvalBus, completionRegistry, + undeliveredSteers, close: () => closeServer(server), }); }; diff --git a/src/http/index.ts b/src/http/index.ts index 00b173dc..3c58600c 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -4,6 +4,12 @@ export type { ApprovalListener } from "./approval-bus.js"; export { CompletionRegistry } from "./completion-registry.js"; export type { CompletionEntry } from "./completion-registry.js"; +export { + MAX_PARKED_STEERS, + UndeliveredSteerStore, +} from "./undelivered-steers.js"; +export type { UndeliveredSteer } from "./undelivered-steers.js"; + export { createHttpServer, } from "./http-server.js"; diff --git a/src/http/openai-chat-completions.test.ts b/src/http/openai-chat-completions.test.ts index 4eb6954d..f6344d5f 100644 --- a/src/http/openai-chat-completions.test.ts +++ b/src/http/openai-chat-completions.test.ts @@ -195,10 +195,20 @@ describe("POST /v1/chat/completions concurrency contract", () => { let userCallCount = 0; const llamaComplete = async (params: { sessionId: string; + prompt: string; }): Promise => { if (params.sessionId.startsWith("reflection:")) { return instantReply("nope"); } + // Only the agent loop's own step blocks on the gate. The memory + // machinery makes further completions under the same session id — + // the recall query rewriter fires on the second turn now that the + // queue re-reads the stored session at run time and turn 2 really + // sees turn 1's history — and gating those would deadlock the test + // against calls it never planned to release. + if (!params.prompt.includes("You are atomic-agent")) { + return instantReply("aside"); + } userCallCount += 1; const tag = `c${userCallCount}`; userEnters.push(tag); @@ -479,6 +489,218 @@ describe("POST /v1/chat/completions (streaming)", () => { }); }); +/** + * A steer accepted mid-turn but never shown to the model must not + * evaporate when the turn closes. `runTurn` hands it back on + * `RunTurnResult.undelivered`; these pin that this route consumes it — + * on the response where one can be carried, and in the undelivered + * store always, because the host that sent the steer is generally not + * the one holding this response. + */ +describe("POST /v1/chat/completions undelivered steers", () => { + function instantReply(text: string): CompletionResult { + return { + content: JSON.stringify({ tool: "reply", args: { text } }), + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 0, + predictedMs: 0, + promptTokens: 1, + predictedTokens: 1, + }, + cacheHitTokens: 0, + slotId: 0, + modelId: null, + }; + } + + /** + * Steer from inside the final inference. The loop drains at the top + * of a step and this turn replies on the step already running, so no + * later boundary exists to drain it. `### respond` identifies the + * agent step — the recall/reflection helper prompts run on the same + * session id before the loop's first drain. + */ + function steeringLlama(sessionIdRef: { current: string | null }, text: string) { + let steered = false; + return async (params: { + sessionId: string; + prompt: string; + steer?: (sessionId: string, text: string) => boolean; + }): Promise => { + if ( + !steered && + params.sessionId === sessionIdRef.current && + params.prompt.includes("### respond") + ) { + steered = true; + params.steer?.(params.sessionId, text); + } + return instantReply("done"); + }; + } + + it("reports the stranded steer on the completion body and parks it", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + const base = steeringLlama(sessionIdRef, "stop and summarise"); + const harness = await startTestHarness({ + llamaComplete: (params) => + base({ ...params, ...(steerFn ? { steer: steerFn } : {}) }), + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat(harness.baseUrl, { + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + undelivered_steers?: Array<{ seq: number; text: string; parked_at: number }>; + }; + expect(body.undelivered_steers).toEqual([ + { + seq: expect.any(Number) as unknown as number, + text: "stop and summarise", + parked_at: expect.any(Number) as unknown as number, + }, + ]); + // The same entry, not a second copy of the message: acking the + // seq the body reported clears exactly this one. + const parked = harness.handle.undeliveredSteers.list(session.id); + expect(parked.map((e) => e.seq)).toEqual( + body.undelivered_steers?.map((e) => e.seq), + ); + expect(harness.handle.undeliveredSteers.ack(session.id, parked[0]!.seq)).toBe(1); + } finally { + await harness.cleanup(); + } + }); + + it("omits the field entirely when the turn delivered everything", async () => { + const harness = await startTestHarness({ + llamaComplete: scriptedLlama(["hi back"]), + }); + try { + const response = await postChat(harness.baseUrl, { + messages: [{ role: "user", content: "hello" }], + }); + const body = (await response.json()) as Record; + expect("undelivered_steers" in body).toBe(false); + } finally { + await harness.cleanup(); + } + }); + + it("parks it even when the turn fails and the body is an error envelope", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + let failed = false; + const harness = await startTestHarness({ + llamaComplete: async (params) => { + if ( + params.sessionId === sessionIdRef.current && + params.prompt.includes("### respond") + ) { + steerFn?.(params.sessionId, "stop, the branch is wrong"); + failed = true; + throw new Error("llama backend exploded"); + } + return instantReply("done"); + }, + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat(harness.baseUrl, { + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }); + expect(failed).toBe(true); + expect(response.status).toBe(500); + // Nothing on the wire could carry it, so the store is the only + // place it can be — and it is there. + expect( + harness.handle.undeliveredSteers.list(session.id).map((e) => e.text), + ).toEqual(["stop, the branch is wrong"]); + expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([]); + } finally { + await harness.cleanup(); + } + }); + + it("emits a steer_undelivered SSE event to extension clients", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + const base = steeringLlama(sessionIdRef, "abort the deploy"); + const harness = await startTestHarness({ + llamaComplete: (params) => + base({ ...params, ...(steerFn ? { steer: steerFn } : {}) }), + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat( + harness.baseUrl, + { + stream: true, + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }, + { [EXTENSIONS_HEADER]: "1" }, + ); + const text = await readAllText(response); + expect(text).toMatch(/event: steer_undelivered\n/); + expect(text).toMatch(/"text":"abort the deploy"/); + expect(text).toMatch(/data: \[DONE\]/); + expect( + harness.handle.undeliveredSteers.list(session.id).map((e) => e.text), + ).toEqual(["abort the deploy"]); + } finally { + await harness.cleanup(); + } + }); + + it("keeps the vanilla stream clean and leaves the message to be polled", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + const base = steeringLlama(sessionIdRef, "abort the deploy"); + const harness = await startTestHarness({ + llamaComplete: (params) => + base({ ...params, ...(steerFn ? { steer: steerFn } : {}) }), + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat(harness.baseUrl, { + stream: true, + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }); + const text = await readAllText(response); + expect(text).not.toMatch(/event: steer_undelivered\n/); + // Not on this stream, but not lost either: the host reads it off + // `GET /api/sessions/{id}/steer`. + const listed = await fetch( + `${harness.baseUrl}/api/sessions/${session.id}/steer`, + ); + const body = (await listed.json()) as { + undelivered: Array<{ text: string }>; + }; + expect(body.undelivered.map((e) => e.text)).toEqual(["abort the deploy"]); + } finally { + await harness.cleanup(); + } + }); +}); + async function readAllText(response: Response): Promise { if (!response.body) return ""; const reader = response.body.getReader(); diff --git a/src/http/openai-chat-completions.ts b/src/http/openai-chat-completions.ts index 148d9b72..d8f83112 100644 --- a/src/http/openai-chat-completions.ts +++ b/src/http/openai-chat-completions.ts @@ -20,6 +20,7 @@ import { type SseWriter, } from "./request-context.js"; import { deriveChatSessionId } from "./openai-session-id.js"; +import type { UndeliveredSteer } from "./undelivered-steers.js"; import { buildFinalAssistantPayload, buildStreamChunk, @@ -127,6 +128,7 @@ async function handleNonStream( // `transport`/`grammar`/`model`/`tool`/`cancelled`) come back as // `result.session.status === "failed"` instead of throwing — see // the next block. + parkUndelivered(ctx, env.session.id, null); const message = err instanceof Error ? err.message : String(err); sendError( res, @@ -135,10 +137,13 @@ async function handleNonStream( ); return; } + const parked = parkUndelivered(ctx, result.session.id, result); if (result.session.status === "failed") { // Surface classified LLM failures as HTTP 500 (matches the legacy // `runTurn`-throws contract that OpenAI clients depend on; an empty // body with finish_reason="stop" would silently strand the caller). + // The error envelope has no room for the parked steers; they stay + // in the store for `GET /api/sessions/{id}/steer`. sendError( res, 500, @@ -165,6 +170,14 @@ async function handleNonStream( }, ], usage, + // Present only when this turn stranded a steer, so an ordinary + // completion stays byte-identical for OpenAI clients. These are the + // parked entries, not copies of them: same `seq`, so acting on this + // list and acking it at `DELETE /api/sessions/{id}/steer?through=` + // is acting on one message, not two. + ...(parked.length > 0 + ? { undelivered_steers: parked.map(toWirePayload) } + : {}), }; sendJson(res, 200, payload, { [SESSION_ID_HEADER]: result.session.id, @@ -236,6 +249,11 @@ async function handleStream( ctx.completionRegistry.unregister(env.completionId); } + // Park before anything can end the stream: on the error paths below, + // and whenever the client is gone, the store is the only place a + // stranded steer can still be found. + const parked = parkUndelivered(ctx, env.session.id, result); + if (!error && result?.session.status === "failed") { error = new Error( `Agent loop failed: ${result.session.lastError ?? "unknown error"}`, @@ -260,6 +278,20 @@ async function handleStream( const usage = buildUsagePayload(result!); const final = buildFinalAssistantPayload(result!); + if (parked.length > 0 && env.request.extensionsEnabled) { + // Same name and meaning as the sidecar's `steer_undelivered` + // event. Extensions-off clients get nothing here — the stream stays + // strict OpenAI — and read the parked entries off + // `GET /api/sessions/{id}/steer` instead. + sse.writeEvent("steer_undelivered", { + id: env.completionId, + object: "chat.completion.steer_undelivered", + created: env.created, + model: env.request.model, + session_id: result!.session.id, + undelivered: parked.map(toWirePayload), + }); + } if (env.request.extensionsEnabled) { sse.writeEvent("usage", { id: env.completionId, @@ -361,6 +393,18 @@ function buildStreamEventHook( } return; } + if (event.type === "steer_applied") { + // Hosts that can observe failure (`steer_undelivered`) deserve the + // success signal too, or they can never render a steer inline. + if (env.request.extensionsEnabled) { + sse.writeEvent(null, { + object: "atomic.steer_applied", + text: event.text, + step_index: event.stepIndex, + }); + } + return; + } if (event.type === "loop_failed") { emitStreamError(sse, env, event.error.message, event.category); } @@ -398,6 +442,43 @@ function emitStreamError( ); } +/** + * Consume `RunTurnResult.undelivered` — the steers this turn accepted + * but never showed the model — and park them where the host can find + * them. + * + * Both halves matter. The steer arrived on its own `POST + * .../steer` exchange, which answered `200 {steered:true}` long before + * the turn ended, so this response is the first chance to say anything + * about it at all; and this response goes to whoever owns the turn, + * which is not necessarily whoever sent the steer. Parking is therefore + * unconditional and the response payload is a fast path on top of it, + * carrying the very entries that were parked rather than a second copy. + * + * `result` is `null` when `runTurn` threw. The inbox is deliberately NOT + * touched then: on the window core the loop's own `finally` already + * closed this turn's window and logged anything stranded — and a request + * that failed BEFORE acquiring the session lock (a queued submission + * whose client disconnected) never owned the window at all, so a drain + * here would steal steers accepted for the turn still running. + */ +function parkUndelivered( + ctx: HandlerContext, + sessionId: string, + result: RunTurnResult | null, +): UndeliveredSteer[] { + const texts = result ? (result.undelivered ?? []) : []; + return ctx.undeliveredSteers.park(sessionId, texts); +} + +function toWirePayload(entry: UndeliveredSteer): { + seq: number; + text: string; + parked_at: number; +} { + return { seq: entry.seq, text: entry.text, parked_at: entry.parkedAt }; +} + function safeStringify(value: unknown): string { try { return JSON.stringify(value); diff --git a/src/http/request-context.ts b/src/http/request-context.ts index 289a9ccc..93f3b009 100644 --- a/src/http/request-context.ts +++ b/src/http/request-context.ts @@ -3,6 +3,7 @@ import { openaiError, type OpenAiErrorPayload } from "./openai-errors.js"; import type { AgentRuntime } from "../runtime/bootstrap.js"; import type { ApprovalBus } from "./approval-bus.js"; import type { CompletionRegistry } from "./completion-registry.js"; +import type { UndeliveredSteerStore } from "./undelivered-steers.js"; /** * Small, dependency-free helpers that every HTTP route needs. Kept in @@ -22,6 +23,12 @@ export interface HandlerContext { params: Record; approvalBus: ApprovalBus; completionRegistry: CompletionRegistry; + /** + * Where a steer that the runtime accepted but never delivered ends + * up. Written by whichever route ran the turn, read by + * `GET /api/sessions/{id}/steer`. + */ + undeliveredSteers: UndeliveredSteerStore; } export type HttpHandler = ( diff --git a/src/http/route-health.test.ts b/src/http/route-health.test.ts new file mode 100644 index 00000000..057de819 --- /dev/null +++ b/src/http/route-health.test.ts @@ -0,0 +1,95 @@ +import { createServer, type Server } from "node:http"; +import { createServer as createTcpServer } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; + +import { startTestHarness, type Harness } from "./test-harness.js"; + +/** A port that was just bound and released — as closed as a port gets. */ +async function closedPort(): Promise { + return new Promise((resolve) => { + const srv = createTcpServer(); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + const port = typeof address === "object" && address ? address.port : 0; + srv.close(() => resolve(port)); + }); + }); +} + +/** Minimal llama-server imitation: answers `/health` the way llama.cpp does. */ +async function startFakeLlama(): Promise<{ url: string; stop: () => Promise }> { + const srv: Server = createServer((req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const address = srv.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}`, + stop: () => new Promise((resolve) => srv.close(() => resolve())), + }; +} + +describe("GET /health", () => { + let harness: Harness | null = null; + let fakeLlama: { url: string; stop: () => Promise } | null = null; + + afterEach(async () => { + if (harness) await harness.cleanup(); + harness = null; + if (fakeLlama) await fakeLlama.stop(); + fakeLlama = null; + }); + + it("answers fast and says degraded when llama is down", async () => { + harness = await startTestHarness({ + localModelsUrl: `http://127.0.0.1:${await closedPort()}`, + }); + + const started = Date.now(); + const res = await fetch(`${harness.baseUrl}/health`); + const elapsed = Date.now() - started; + const body = (await res.json()) as { + status: string; + llama: { reachable: boolean; error: string | null }; + }; + + // One probe, no retry ladder: the full ladder was 15.5 s of backoff, + // far beyond any orchestrator's patience. A refused connection fails + // in milliseconds; five seconds is a generous ceiling. + expect(elapsed).toBeLessThan(5000); + // Sidecar alive → 200 by default; the body tells the truth about llama. + expect(res.status).toBe(200); + expect(body.status).toBe("degraded"); + expect(body.llama.reachable).toBe(false); + }); + + it("returns 503 for ?strict=1 when llama is down", async () => { + harness = await startTestHarness({ + localModelsUrl: `http://127.0.0.1:${await closedPort()}`, + }); + + const res = await fetch(`${harness.baseUrl}/health?strict=1`); + expect(res.status).toBe(503); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe("degraded"); + }); + + it("reports ok — strict or not — when llama answers", async () => { + fakeLlama = await startFakeLlama(); + harness = await startTestHarness({ localModelsUrl: fakeLlama.url }); + + const plain = await fetch(`${harness.baseUrl}/health`); + expect(plain.status).toBe(200); + expect(((await plain.json()) as { status: string }).status).toBe("ok"); + + const strict = await fetch(`${harness.baseUrl}/health?strict=1`); + expect(strict.status).toBe(200); + expect(((await strict.json()) as { status: string }).status).toBe("ok"); + }); +}); diff --git a/src/http/route-health.ts b/src/http/route-health.ts index 4db3b34d..425dd21c 100644 --- a/src/http/route-health.ts +++ b/src/http/route-health.ts @@ -3,16 +3,34 @@ import { sendJson, type HttpHandler } from "./request-context.js"; /** * `GET /health` — liveness probe. Reports the sidecar's own status plus - * a passthrough summary of the external llama-server reachability. We - * intentionally return 200 even when llama is down so orchestrators - * can tell the sidecar apart from the LLM runtime (which may legally - * be absent during indexing/degraded mode). + * a passthrough summary of the external llama-server reachability. + * + * The llama probe is a single attempt on purpose. This route used to + * inherit `checkLlamaServer`'s full retry ladder (5 attempts with + * exponential backoff — 15.5 s worst case), which is the one budget a + * liveness endpoint does not have: orchestrators typically allow 1–10 s + * before declaring the process dead, so the slow answer read as a hang + * and restart-looped the sidecar exactly when llama was down. Retrying + * inside one probe buys nothing anyway — the orchestrator's next poll + * IS the retry. + * + * Status contract: + * - HTTP 200, `status: "ok"` — sidecar up, llama reachable. + * - HTTP 200, `status: "degraded"` — sidecar up, llama down. Still 200 + * by default: the LLM runtime may legally be absent (indexing, + * degraded mode), and restarting the sidecar would not revive llama. + * - `?strict=1` turns the degraded case into HTTP 503 for orchestrators + * that do want restart-on-down semantics. */ export function createHealthHandler(): HttpHandler { - return async (_req, res, ctx) => { - const llama = await checkLlamaServer(); - sendJson(res, 200, { - status: "ok", + return async (req, res, ctx) => { + const llama = await checkLlamaServer({ retries: 0 }); + const strict = + new URL(req.url ?? "/", "http://localhost").searchParams.get("strict") === + "1"; + const degraded = !llama.reachable; + sendJson(res, degraded && strict ? 503 : 200, { + status: degraded ? "degraded" : "ok", runtime: "atomic-agent", workingDir: ctx.runtime.capabilities.workingDir, llama: { diff --git a/src/http/route-sessions.test.ts b/src/http/route-sessions.test.ts index 82424173..b7478c89 100644 --- a/src/http/route-sessions.test.ts +++ b/src/http/route-sessions.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { CompletionResult } from "../llm/llama-server-client.js"; + import { startTestHarness, type Harness } from "./test-harness.js"; +import { MAX_PARKED_STEERS } from "./undelivered-steers.js"; describe("/api/sessions", () => { let harness: Harness; @@ -59,3 +62,404 @@ describe("/api/sessions", () => { expect(second.status).toBe(200); }); }); + +describe("POST /api/sessions/{id}/steer", () => { + let harness: Harness; + + beforeEach(async () => { + harness = await startTestHarness(); + }); + + afterEach(async () => { + await harness.cleanup(); + }); + + async function steer( + sessionId: string, + body: unknown, + ): Promise { + return fetch(`${harness.baseUrl}/api/sessions/${sessionId}/steer`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + /** Hold the session lock so `turnController.isBusy` is true. */ + async function whileBusy( + sessionId: string, + fn: () => Promise, + ): Promise { + let release!: () => void; + const held = new Promise((res) => { + release = res; + }); + let result!: T; + const turn = harness.runtime.turnController.enqueue({ + sessionId, + origin: "http", + run: async () => { + // A real turn opens the steering window on entry; on the current + // core the raw session lock alone does not make a session + // steerable — the window is the one acceptance fact. + harness.runtime.steeringInbox.open(sessionId); + result = await fn(); + release(); + await held; + return null; + }, + }); + await turn; + return result; + } + + it("accepts a steer while the session has a turn in flight", async () => { + const session = harness.runtime.createSession(); + const response = await whileBusy(session.id, () => + steer(session.id, { text: "actually, stop and summarise" }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + steered: true, + sessionId: session.id, + }); + expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([ + "actually, stop and summarise", + ]); + }); + + it("409s on an idle session instead of silently swallowing the message", async () => { + const session = harness.runtime.createSession(); + const response = await steer(session.id, { text: "anyone home?" }); + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { message: string } }; + expect(body.error.message).toContain("/v1/chat/completions"); + expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([]); + }); + + it("409s for a session id that never existed", async () => { + const response = await steer("s-nope", { text: "hello" }); + expect(response.status).toBe(409); + }); + + it("rejects a missing or blank text", async () => { + const session = harness.runtime.createSession(); + expect((await steer(session.id, {})).status).toBe(400); + expect((await steer(session.id, { text: " " })).status).toBe(400); + expect((await steer(session.id, { text: 42 })).status).toBe(400); + }); + + it("lets runtime.steer decide instead of pre-checking isBusy", async () => { + const session = harness.runtime.createSession(); + // Idle by every reading the controller can offer — a + // `turnController.isBusy` gate in the route would 409 here without + // ever asking the runtime. `isBusy` and "a step boundary is still + // coming" are different facts that expire at different moments, so + // the runtime's answer is the only one worth acting on. + expect(harness.runtime.turnController.isBusy(session.id)).toBe(false); + const seen: Array<[string, string]> = []; + harness.runtime.steer = (id, text) => { + seen.push([id, text]); + return true; + }; + const response = await steer(session.id, { text: "the runtime says yes" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + steered: true, + sessionId: session.id, + }); + expect(seen).toEqual([[session.id, "the runtime says yes"]]); + }); + + it("429s once the per-session inbox is full", async () => { + const session = harness.runtime.createSession(); + const statuses = await whileBusy(session.id, async () => { + const out: number[] = []; + // 16 fit (MAX_PENDING_STEERS); the 17th must be refused rather + // than evicting one the operator already saw accepted. + for (let i = 0; i < 17; i += 1) { + out.push((await steer(session.id, { text: `m${i}` })).status); + } + return out; + }); + expect(statuses.slice(0, 16).every((s) => s === 200)).toBe(true); + expect(statuses[16]).toBe(429); + }); +}); + + +/** + * The other half of the steering promise: `200 {steered:true}` is + * acceptance, not delivery, and the surface has to say so when the turn + * ends without ever reading the message. Every steer here goes in + * through the real `POST /api/sessions/{id}/steer` route while a real + * turn holds the session lock — the loss this pins is the one a host + * actually hits. + */ +describe("GET|DELETE /api/sessions/{id}/steer (undelivered)", () => { + let harness: Harness; + let sessionId: string; + let steerStatus: number | null; + /** What the stub steers from inside the final inference, in order. */ + let steerTexts: string[]; + + function replyCompletion(text: string): CompletionResult { + return { + content: JSON.stringify({ tool: "reply", args: { text } }), + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 0, predictedMs: 0, promptTokens: 4, predictedTokens: 2 }, + cacheHitTokens: 0, + slotId: 0, + modelId: null, + }; + } + + beforeEach(async () => { + steerStatus = null; + steerTexts = ["stop, summarise what you have instead"]; + harness = await startTestHarness({ + // Steer from inside the FINAL inference. The loop drains at the + // top of a step; this turn replies on the step already running, + // so no later boundary exists to drain it and the message comes + // back on `RunTurnResult.undelivered`. + llamaComplete: async ({ sessionId: turnSession, prompt }) => { + // `### respond` marks a real agent step; the recall / reflection + // helper prompts run on the same session id BEFORE the loop's + // first drain, and steering from one of those would be + // delivered normally instead of stranded. + const agentStep = prompt.includes("### respond"); + if (turnSession === sessionId && agentStep && steerStatus === null) { + for (const text of steerTexts) { + const accepted = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text }), + }, + ); + steerStatus = accepted.status; + } + } + return replyCompletion("done"); + }, + }); + sessionId = harness.runtime.createSession({ + metadata: { source: "undelivered" }, + }).id; + }); + + afterEach(async () => { + await harness.cleanup(); + }); + + /** Run one turn that strands the steer, and assert it really did. */ + async function runStrandingTurn(): Promise { + const completion = await fetch(`${harness.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + messages: [{ role: "user", content: "go" }], + }), + }); + expect(completion.status).toBe(200); + await completion.json(); + // The POST really was accepted — this is the `200 {steered:true}` + // whose message used to be able to vanish. + expect(steerStatus).toBe(200); + // And the inbox is empty: `flushSteering` swept it on the way out, + // so the text exists nowhere but the undelivered store. + expect(harness.runtime.steeringInbox.peek(sessionId)).toEqual([]); + } + + async function listUndelivered(): Promise<{ + sessionId: string; + undelivered: Array<{ seq: number; text: string; parkedAt: number }>; + discarded: number; + }> { + const response = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer`, + ); + expect(response.status).toBe(200); + return (await response.json()) as { + sessionId: string; + undelivered: Array<{ seq: number; text: string; parkedAt: number }>; + discarded: number; + }; + } + + it("surfaces a steer the turn accepted but never delivered", async () => { + await runStrandingTurn(); + const body = await listUndelivered(); + expect(body.sessionId).toBe(sessionId); + expect(body.undelivered.map((e) => e.text)).toEqual(steerTexts); + expect(body.undelivered[0]?.seq).toBeGreaterThan(0); + expect(body.discarded).toBe(0); + }); + + it("returns nothing for a session whose turns delivered everything", async () => { + const other = harness.runtime.createSession(); + const response = await fetch( + `${harness.baseUrl}/api/sessions/${other.id}/steer`, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + sessionId: other.id, + undelivered: [], + discarded: 0, + }); + }); + + it("does not consume on read — a retried GET still finds the message", async () => { + await runStrandingTurn(); + const first = await listUndelivered(); + const second = await listUndelivered(); + expect(second.undelivered).toEqual(first.undelivered); + }); + + it("lets the host resend the message and then ack it", async () => { + await runStrandingTurn(); + const parked = await listUndelivered(); + const entry = parked.undelivered[0]!; + + // The resend is an ordinary completion carrying the parked text. + steerStatus = -1; // stop the stub steering the resend turn as well + const resend = await fetch(`${harness.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + messages: [{ role: "user", content: entry.text }], + }), + }); + expect(resend.status).toBe(200); + const transcript = harness.runtime.sessionStore.load(sessionId); + expect( + transcript?.turns.some( + (turn) => turn.kind === "user" && turn.text === entry.text, + ), + ).toBe(true); + + const acked = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?through=${entry.seq}`, + { method: "DELETE" }, + ); + expect(acked.status).toBe(200); + expect(await acked.json()).toEqual({ + sessionId, + acked: 1, + remaining: 0, + discardsAcked: 0, + discarded: 0, + }); + expect((await listUndelivered()).undelivered).toEqual([]); + }); + + it("acks by cursor, so a steer parked after the read survives", async () => { + await runStrandingTurn(); + const seen = (await listUndelivered()).undelivered[0]!; + // A second turn strands another message between the read and the + // ack. A bare "clear" would swallow it unseen. + steerStatus = null; + steerTexts = ["and cancel the deploy"]; + await runStrandingTurn(); + + const acked = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?through=${seen.seq}`, + { method: "DELETE" }, + ); + expect((await acked.json()) as unknown).toEqual({ + sessionId, + acked: 1, + remaining: 1, + discardsAcked: 0, + discarded: 0, + }); + const left = await listUndelivered(); + expect(left.undelivered.map((e) => e.text)).toEqual([ + "and cancel the deploy", + ]); + }); + + it("keeps the discard notice when the host acks the entries it was shown", async () => { + // Fill the parking lot in one turn, then strand one more so the + // per-session cap genuinely has to throw a message away. + steerTexts = Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`); + await runStrandingTurn(); + steerStatus = null; + steerTexts = ["one too many"]; + await runStrandingTurn(); + + const listed = await listUndelivered(); + expect(listed.undelivered).toHaveLength(MAX_PARKED_STEERS); + expect(listed.discarded).toBe(1); + + // The host acks the highest seq it was given — which is all it can + // do about the entries, and says nothing about the loss count. + const acked = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?through=${listed.undelivered.at(-1)!.seq}`, + { method: "DELETE" }, + ); + expect((await acked.json()) as unknown).toEqual({ + sessionId, + acked: MAX_PARKED_STEERS, + remaining: 0, + discardsAcked: 0, + discarded: 1, + }); + + // ...and is still told a message was genuinely lost, rather than + // being shown `discarded: 0` for a session that dropped one. + const after = await listUndelivered(); + expect(after.undelivered).toEqual([]); + expect(after.discarded).toBe(1); + + // The counter clears only through its own ack. + const ackedDiscard = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?discarded=1`, + { method: "DELETE" }, + ); + expect((await ackedDiscard.json()) as unknown).toEqual({ + sessionId, + acked: 0, + remaining: 0, + discardsAcked: 1, + discarded: 0, + }); + expect((await listUndelivered()).discarded).toBe(0); + }); + + it("rejects an ack without a usable cursor", async () => { + await runStrandingTurn(); + for (const query of [ + "", + "?through=", + "?through=abc", + "?through=-1", + "?discarded=", + "?discarded=abc", + "?discarded=-1", + ]) { + const response = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer${query}`, + { method: "DELETE" }, + ); + expect(response.status).toBe(400); + } + expect((await listUndelivered()).undelivered).toHaveLength(1); + }); + + it("drops parked steers when the session itself is purged", async () => { + await runStrandingTurn(); + expect((await listUndelivered()).undelivered).toHaveLength(1); + const deleted = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}`, + { method: "DELETE" }, + ); + expect(deleted.status).toBe(200); + expect((await listUndelivered()).undelivered).toEqual([]); + }); +}); diff --git a/src/http/route-sessions.ts b/src/http/route-sessions.ts index ad3ddca7..709ae1a6 100644 --- a/src/http/route-sessions.ts +++ b/src/http/route-sessions.ts @@ -1,5 +1,11 @@ +import { MAX_PENDING_STEERS } from "../runtime/steering-inbox.js"; import { openaiError } from "./openai-errors.js"; -import { sendError, sendJson, type HttpHandler } from "./request-context.js"; +import { + readJsonBody, + sendError, + sendJson, + type HttpHandler, +} from "./request-context.js"; /** * `GET /api/sessions` — list recent sessions in the current working @@ -61,6 +67,230 @@ export function createGetSessionHandler(): HttpHandler { }; } +/** + * `POST /api/sessions/{id}/steer` — fold `{ text }` into the turn + * already running on that session. + * + * This is NOT a way to send a message: it never starts a turn and never + * queues behind one (see §"Mid-turn steering" in AGENTS.md). When no + * running turn will pick the message up there is nothing to steer, and + * the caller is told so with `409` rather than having the message + * silently disappear — the correct follow-up is + * `POST /v1/chat/completions`. `429` means the per-session steering + * inbox is full; the turn has not read any of them yet, so piling on + * more would only bloat one prompt. + * + * `runtime.steer` decides, and this route only translates. There is no + * `turnController.isBusy` pre-check: "busy" and "a step boundary is + * still coming" stop being true at different moments (the loop's final + * drain happens inside `runTurn`, `busy.delete` later in the + * controller's `finally`), so gating on it would reject steers the + * runtime would have accepted. The inbox is consulted only after a + * refusal, to choose between 409 and 429. + * + * `200 {steered:true}` means accepted, **not** delivered: the loop + * drains the inbox at step boundaries, and a turn can end before the + * next one. Anything left over is parked, not dropped — the turn hands + * it back on `RunTurnResult.undelivered` and the route that ran the + * turn puts it in the undelivered store, where + * `GET /api/sessions/{id}/steer` finds it. That is the HTTP half of the + * same promise the sidecar keeps with its `steer_undelivered` event. + */ +export function createSteerSessionHandler(): HttpHandler { + return async (req, res, ctx) => { + const id = ctx.params.id; + if (!id) { + sendError(res, 400, openaiError("session id is required")); + return; + } + let body: Record; + try { + body = await readJsonBody>(req); + } catch (err) { + sendError( + res, + 400, + openaiError(err instanceof Error ? err.message : "invalid body"), + ); + return; + } + const text = body.text; + if (typeof text !== "string" || text.trim().length === 0) { + sendError(res, 400, openaiError("text must be a non-empty string")); + return; + } + if (!ctx.runtime.steer(id, text)) { + // `steer()` is the only authority on whether the message landed, + // and it already refused. The inbox read below only *names* the + // refusal for the status code — it never gates the attempt, so a + // stale read here can at worst mislabel a message that was + // definitively not queued, where a pre-check could have rejected + // one the runtime would have taken. + const inboxFull = + ctx.runtime.steeringInbox.peek(id).length >= MAX_PENDING_STEERS; + if (inboxFull) { + sendError( + res, + 429, + openaiError( + `steering inbox for session ${id} is full — the running turn has not consumed the pending messages yet`, + ), + ); + return; + } + sendError( + res, + 409, + openaiError( + `session ${id} has no turn accepting steers — send the message with POST /v1/chat/completions instead`, + ), + ); + return; + } + sendJson(res, 200, { steered: true, sessionId: id }); + }; +} + +/** + * `GET /api/sessions/{id}/steer` — the messages this session's turns + * accepted for steering but never delivered. + * + * A steer that arrives during the final inference, or into a turn that + * is cancelled before its next step, is handed back when the turn ends; + * by then the `POST` that accepted it has long since answered, so the + * server parks it here. Polling this endpoint is how a host that only + * ever spoke to `POST .../steer` detects the loss; re-sending is a + * normal `POST /v1/chat/completions`. + * + * Reads do not consume. A retried or prefetched `GET` must not be able + * to lose a message — that is the bug this whole path exists to + * prevent. Acknowledge with `DELETE /api/sessions/{id}/steer?through=` + * once the text is safely somewhere else. + * + * `discarded` counts messages this session lost to the per-session cap + * (`MAX_PARKED_STEERS`) because nobody acked in time. Non-zero means + * text is genuinely gone, and the host is told rather than left to + * assume the list is complete. It has its own ack + * (`DELETE ...?discarded={n}`): acking the listed entries leaves it + * standing, so a host that acks first and reads later still sees the + * loss. + */ +export function createGetUndeliveredSteersHandler(): HttpHandler { + return async (_req, res, ctx) => { + const id = ctx.params.id; + if (!id) { + sendError(res, 400, openaiError("session id is required")); + return; + } + const undelivered = ctx.undeliveredSteers.list(id); + sendJson(res, 200, { + sessionId: id, + undelivered: undelivered.map((entry) => ({ + seq: entry.seq, + text: entry.text, + parkedAt: entry.parkedAt, + })), + discarded: ctx.undeliveredSteers.discarded(id), + }); + }; +} + +/** + * `DELETE /api/sessions/{id}/steer?through={seq}&discarded={n}` — + * acknowledge what a prior `GET` reported. Both parameters are + * optional individually; at least one must be present. + * + * `through` acks parked steers up to and including `seq`. The cursor is + * mandatory rather than a bare "clear it" because a bare clear would + * also drop whatever was parked between the caller's `GET` and this + * call, which is a message the host never saw. Anything parked since + * carries a higher `seq` and survives. + * + * `discarded` acks up to `n` of the messages the per-session cap threw + * away. It is a **separate** ack, and for the same reason the cursor + * exists: those messages have no `seq` the host was ever shown, so the + * entry cursor cannot stand in for having read the loss count. Acking + * the entries alone leaves `discarded` reporting the loss on the next + * `GET` instead of quietly resetting it to zero. Counting rather than + * clearing keeps discards that happened since the host's `GET` + * outstanding. + * + * Idempotent — re-acking an already-acked cursor or count reports `0`. + * The response repeats the loss still outstanding as `discarded`, so a + * host that only ever calls `DELETE` still learns about it. + */ +export function createAckUndeliveredSteersHandler(): HttpHandler { + return async (req, res, ctx) => { + const id = ctx.params.id; + if (!id) { + sendError(res, 400, openaiError("session id is required")); + return; + } + const url = new URL(req.url ?? "/", "http://localhost"); + const rawThrough = url.searchParams.get("through"); + const rawDiscarded = url.searchParams.get("discarded"); + if (rawThrough === null && rawDiscarded === null) { + sendError( + res, + 400, + openaiError( + "through and/or discarded is required — use the highest seq and the discarded count returned by GET /api/sessions/{id}/steer", + ), + ); + return; + } + const through = parseCount(rawThrough); + if (through === null) { + sendError( + res, + 400, + openaiError( + "through must be a non-negative integer — use the highest seq returned by GET /api/sessions/{id}/steer", + ), + ); + return; + } + const discarded = parseCount(rawDiscarded); + if (discarded === null) { + sendError( + res, + 400, + openaiError( + "discarded must be a non-negative integer — use the discarded count returned by GET /api/sessions/{id}/steer", + ), + ); + return; + } + const acked = + through === undefined ? 0 : ctx.undeliveredSteers.ack(id, through); + const discardsAcked = + discarded === undefined + ? 0 + : ctx.undeliveredSteers.ackDiscarded(id, discarded); + sendJson(res, 200, { + sessionId: id, + acked, + remaining: ctx.undeliveredSteers.list(id).length, + discardsAcked, + discarded: ctx.undeliveredSteers.discarded(id), + }); + }; +} + +/** + * `undefined` when the parameter was absent, `null` when it was present + * but not a non-negative integer (the caller turns that into a 400). + */ +function parseCount(raw: string | null): number | undefined | null { + if (raw === null) return undefined; + // Whole-string digits only: `parseInt` would silently truncate + // `12abc` to 12 and `1e9` to 1, acking through the wrong cursor. + if (!/^\d+$/.test(raw)) return null; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return parsed; +} + /** * `DELETE /api/sessions/{id}` — purge the session row. Idempotent: * returns 200 whether or not the row existed so orchestrators can @@ -74,6 +304,10 @@ export function createDeleteSessionHandler(): HttpHandler { return; } ctx.runtime.sessionStore.delete(id); + // Purging the session takes its parked steers with it: they are + // messages for a conversation the caller just said it is done with, + // and leaving them would strand rows nobody will ever ack. + ctx.undeliveredSteers.clear(id); sendJson(res, 200, { deleted: true, id }); }; } diff --git a/src/http/route-table.ts b/src/http/route-table.ts index 93e9cf1f..c0beffe0 100644 --- a/src/http/route-table.ts +++ b/src/http/route-table.ts @@ -16,9 +16,12 @@ import { createUninstallSkillHandler, } from "./route-skills.js"; import { + createAckUndeliveredSteersHandler, createDeleteSessionHandler, createGetSessionHandler, + createGetUndeliveredSteersHandler, createListSessionsHandler, + createSteerSessionHandler, } from "./route-sessions.js"; import { createApprovalEventsHandler, @@ -61,6 +64,21 @@ export function buildRouteTable(): RouteDefinition[] { { method: "GET", path: "/api/sessions", handler: createListSessionsHandler() }, { method: "GET", path: "/api/sessions/{id}", handler: createGetSessionHandler() }, { method: "DELETE", path: "/api/sessions/{id}", handler: createDeleteSessionHandler() }, + { + method: "POST", + path: "/api/sessions/{id}/steer", + handler: createSteerSessionHandler(), + }, + { + method: "GET", + path: "/api/sessions/{id}/steer", + handler: createGetUndeliveredSteersHandler(), + }, + { + method: "DELETE", + path: "/api/sessions/{id}/steer", + handler: createAckUndeliveredSteersHandler(), + }, { method: "POST", path: "/api/approval/resolve", handler: createResolveApprovalHandler() }, { method: "GET", path: "/api/events", handler: createApprovalEventsHandler() }, { method: "POST", path: "/api/tasks", handler: createCreateTaskHandler() }, diff --git a/src/http/test-harness.ts b/src/http/test-harness.ts index a544cd16..a71b91f6 100644 --- a/src/http/test-harness.ts +++ b/src/http/test-harness.ts @@ -78,6 +78,14 @@ export class FakeBrowserBackend implements BrowserBackend { export interface HarnessOptions { /** When set, the HTTP server requires this bearer token. */ apiKey?: string | null; + /** + * Point `localModels.url` at a specific server so llama health probes + * are deterministic — e.g. a stub that answers `/health`, or a port + * that is known to be closed. Without this the probe hits the config + * default (127.0.0.1:8080), which may or may not be occupied on the + * machine running the tests. + */ + localModelsUrl?: string; /** Replace the llama completion implementation. Default: one `reply` turn. */ llamaComplete?: (params: { prompt: string; @@ -142,11 +150,22 @@ export async function startTestHarness( mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true }); process.env.ATOMIC_AGENT_STATE_DIR = stateDir; process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars"); - if (options.webhooks) { + if (options.webhooks || options.localModelsUrl) { writeFileSync( join(stateDir, "config.json"), JSON.stringify( - { ...USER_CONFIG_DEFAULTS, webhooks: options.webhooks }, + { + ...USER_CONFIG_DEFAULTS, + ...(options.webhooks ? { webhooks: options.webhooks } : {}), + ...(options.localModelsUrl + ? { + localModels: { + ...USER_CONFIG_DEFAULTS.localModels, + url: options.localModelsUrl, + }, + } + : {}), + }, null, 2, ), diff --git a/src/http/undelivered-steers.test.ts b/src/http/undelivered-steers.test.ts new file mode 100644 index 00000000..99525ff4 --- /dev/null +++ b/src/http/undelivered-steers.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_PARKED_SESSIONS, + MAX_PARKED_STEERS, + UndeliveredSteerStore, +} from "./undelivered-steers.js"; + +/** + * The store behind `GET /api/sessions/{id}/steer`. Pins the properties + * the route promises: reading never consumes, acking is by cursor so a + * message parked between the read and the ack cannot be swallowed + * unseen, the loss counter is not collateral damage of that ack, and a + * hand-back is returned whole. + */ +describe("UndeliveredSteerStore", () => { + it("keeps parked messages until they are acked", () => { + const store = new UndeliveredSteerStore(); + const parked = store.park("s1", ["stop", "do X instead"]); + expect(parked.map((e) => e.text)).toEqual(["stop", "do X instead"]); + expect(store.list("s1")).toHaveLength(2); + // Reading twice returns the same rows — a retried GET is safe. + expect(store.list("s1")).toHaveLength(2); + expect(store.ack("s1", parked[1]!.seq)).toBe(2); + expect(store.list("s1")).toEqual([]); + }); + + it("isolates sessions", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", ["for one"]); + store.park("s2", ["for two"]); + store.ack("s1", Number.MAX_SAFE_INTEGER); + expect(store.list("s1")).toEqual([]); + expect(store.list("s2").map((e) => e.text)).toEqual(["for two"]); + }); + + it("acks by cursor, so anything parked after the read survives", () => { + const store = new UndeliveredSteerStore(); + const seen = store.park("s1", ["first"]); + const later = store.park("s1", ["arrived after the GET"]); + expect(store.ack("s1", seen[0]!.seq)).toBe(1); + expect(store.list("s1").map((e) => e.seq)).toEqual([later[0]!.seq]); + }); + + it("is a no-op for an empty hand-back and for an unknown session", () => { + const store = new UndeliveredSteerStore(); + expect(store.park("s1", [])).toEqual([]); + expect(store.list("s1")).toEqual([]); + expect(store.ack("nope", 10)).toBe(0); + expect(store.discarded("nope")).toBe(0); + }); + + it("counts what the per-session cap discards instead of quietly shortening the list", () => { + const store = new UndeliveredSteerStore(); + // The cap bites across hand-backs: fill it, then strand three more. + const first = Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`); + store.park("s1", first); + const second = store.park("s1", ["late-1", "late-2", "late-3"]); + expect(store.list("s1")).toHaveLength(MAX_PARKED_STEERS); + expect(store.discarded("s1")).toBe(3); + // The three oldest went; what the latest caller is told it can + // retrieve matches what is actually retrievable. + expect(second.map((e) => e.text)).toEqual(["late-1", "late-2", "late-3"]); + expect(store.list("s1")[0]?.text).toBe("m3"); + expect(store.list("s1").at(-1)?.text).toBe("late-3"); + }); + + it("hands a batch back whole even when it alone exceeds the cap", () => { + const store = new UndeliveredSteerStore(); + const texts = Array.from({ length: MAX_PARKED_STEERS + 3 }, (_, i) => `m${i}`); + const parked = store.park("s1", texts); + // The return value IS the hand-back — it becomes + // `undelivered_steers` on the response — so trimming it would drop + // the oldest messages out of the one payload meant to carry them. + expect(parked.map((e) => e.text)).toEqual(texts); + // And everything returned is retrievable, so a host that only reads + // `GET .../steer` sees the same set. + expect(store.list("s1").map((e) => e.text)).toEqual(texts); + expect(store.discarded("s1")).toBe(0); + }); + + it("evicts earlier entries, never the batch it was just handed", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", ["old-1", "old-2"]); + const oversized = Array.from( + { length: MAX_PARKED_STEERS + 1 }, + (_, i) => `n${i}`, + ); + const parked = store.park("s1", oversized); + expect(parked.map((e) => e.text)).toEqual(oversized); + expect(store.list("s1").map((e) => e.text)).toEqual(oversized); + expect(store.discarded("s1")).toBe(2); + }); + + it("keeps the loss counter when the host acks the entries it was shown", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`)); + store.park("s1", ["late-1", "late-2", "late-3"]); + expect(store.discarded("s1")).toBe(3); + // Acking the highest seq in the listing is what a host does first; + // the discarded messages were never in that listing and have no + // seq it could point at, so this must not clear them. + const listed = store.list("s1"); + store.ack("s1", listed.at(-1)!.seq); + expect(store.list("s1")).toEqual([]); + expect(store.discarded("s1")).toBe(3); + // The box survives the entry ack precisely so the counter can. + expect(store.trackedSessions).toBe(1); + }); + + it("clears the loss counter only by its own ack, and reclaims the box then", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`)); + store.park("s1", ["late-1", "late-2", "late-3"]); + store.ack("s1", store.list("s1").at(-1)!.seq); + // By count, not by flag: a partial ack leaves the rest outstanding, + // so discards that happened after the host's GET are not cleared + // unseen. + expect(store.ackDiscarded("s1", 1)).toBe(1); + expect(store.discarded("s1")).toBe(2); + expect(store.trackedSessions).toBe(1); + // Over-acking clamps rather than going negative, and is idempotent. + expect(store.ackDiscarded("s1", 99)).toBe(2); + expect(store.ackDiscarded("s1", 99)).toBe(0); + expect(store.discarded("s1")).toBe(0); + expect(store.trackedSessions).toBe(0); + }); + + it("still reclaims a discard-only box on purge and on session eviction", () => { + const store = new UndeliveredSteerStore(); + const overflow = (id: string): void => { + store.park(id, Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`)); + store.park(id, ["one too many"]); + store.ack(id, Number.MAX_SAFE_INTEGER); + }; + overflow("purged"); + expect(store.discarded("purged")).toBe(1); + store.clear("purged"); + expect(store.discarded("purged")).toBe(0); + expect(store.trackedSessions).toBe(0); + + // A host that never acks anything cannot pin boxes open forever: + // the session cap still evicts the oldest. + overflow("stale"); + for (let i = 0; i < MAX_PARKED_SESSIONS; i += 1) { + store.park(`s${i}`, ["x"]); + } + expect(store.trackedSessions).toBe(MAX_PARKED_SESSIONS); + expect(store.discarded("stale")).toBe(0); + }); + + it("forgets a session on clear", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", ["gone with the session"]); + store.clear("s1"); + expect(store.list("s1")).toEqual([]); + store.park("s2", ["x"]); + store.clearAll(); + expect(store.list("s2")).toEqual([]); + }); +}); diff --git a/src/http/undelivered-steers.ts b/src/http/undelivered-steers.ts new file mode 100644 index 00000000..ac1349a9 --- /dev/null +++ b/src/http/undelivered-steers.ts @@ -0,0 +1,216 @@ +import { MAX_PENDING_STEERS } from "../runtime/steering-inbox.js"; + +/** + * Parking lot for steering messages a turn handed back on + * `RunTurnResult.undelivered`. + * + * `POST /api/sessions/{id}/steer` answers `200 {steered:true}` as soon + * as the message is in the inbox, but acceptance is not delivery: a + * steer that lands during the final inference — or into a turn that is + * cancelled before its next step — comes back undelivered when the turn + * closes, and `AgentLoop.flushSteering` empties the inbox as it reads. + * The steer was its own HTTP exchange whose response was written long + * before that, so there is nowhere to hand the text back to unless the + * server keeps it. This store is that "somewhere": it is what makes the + * HTTP surface hold the same invariant as the sidecar's + * `steer_undelivered` event — the message you sent always goes + * somewhere the host can see. + * + * Retrieval is deliberately **non-destructive**. `GET` lists, `DELETE` + * acks by sequence number. A consuming read would lose the message to + * any retried or prefetched request, which is the exact failure mode + * this store exists to prevent; and because the ack carries a cursor + * taken from the listing, a steer parked between the two calls has a + * higher `seq` and survives the ack. + * + * The same reasoning applies to the loss counter. `discarded` is the + * "N messages are gone" signal, and it is **not** covered by the entry + * cursor — the discarded messages have no `seq` the host ever saw. It + * therefore has its own ack ({@link UndeliveredSteerStore.ackDiscarded}) + * and keeps a session's box alive on its own, so acking the entries + * cannot silently take the loss notice with them. + * + * Single-process, in-memory, one instance per HTTP server. Parked + * messages do not survive a restart — neither does the inbox they came + * from (`shutdown()` calls `SteeringInbox.clearAll`). + */ +export interface UndeliveredSteer { + /** Monotonic within one store. The ack cursor for `DELETE`. */ + seq: number; + text: string; + /** Epoch ms at which the turn handed the message back. */ + parkedAt: number; +} + +/** + * Per-session cap on **accumulation**, not on one hand-back. The inbox + * refuses past `MAX_PENDING_STEERS`, so a single turn cannot strand + * more than that; the cap bites when several turns strand messages and + * nobody ever acks. Past it the oldest entries go — and `discarded` + * counts them, so a host that comes back late learns it lost some + * instead of quietly seeing a short list. + * + * A single `park` batch is never trimmed, even if it alone exceeds this + * number: those entries are being handed back on a live response, and + * dropping them there would omit them from the one message that was + * supposed to carry them. See {@link UndeliveredSteerStore.park}. + */ +export const MAX_PARKED_STEERS = MAX_PENDING_STEERS; + +/** + * Cap on tracked sessions. Long-lived servers see unboundedly many + * session ids; the oldest box is evicted first (Map insertion order). + */ +export const MAX_PARKED_SESSIONS = 256; + +interface Box { + entries: UndeliveredSteer[]; + /** + * Messages lost to {@link MAX_PARKED_STEERS} that the host has not + * acknowledged yet. Counts down through `ackDiscarded`, never through + * the entry cursor: the two are separate acks because they carry + * separate information. + */ + discarded: number; +} + +export class UndeliveredSteerStore { + private nextSeq = 1; + private readonly bySession = new Map(); + + /** + * Take ownership of everything a turn could not deliver. Returns + * **the whole batch** — the same objects, with the same `seq`, that + * `list` will report — so the caller can mirror it onto a live + * response without that becoming a second copy of the message. + * + * Every entry returned here is retrievable until it is acked. That + * matters because the return value *is* the hand-back: it becomes + * `undelivered_steers` on the completion body and the + * `steer_undelivered` SSE frame. Returning only the survivors of the + * cap would omit the oldest messages from the very response that + * exists to give them back, leaving nothing behind but a counter — + * so the cap never trims the batch it was just handed. It evicts only + * entries parked by *earlier* calls, which the host has already been + * told about once and can still see on `GET`. + */ + park(sessionId: string, texts: readonly string[]): UndeliveredSteer[] { + if (texts.length === 0) return []; + const box = this.bySession.get(sessionId) ?? { entries: [], discarded: 0 }; + const parkedAt = Date.now(); + const parked = texts.map((text) => ({ + seq: this.nextSeq++, + text, + parkedAt, + })); + box.entries.push(...parked); + // `capacity >= parked.length`, so `overflow` can never reach into + // the batch that was just pushed — only into what was already here. + // One turn cannot hand back more than `MAX_PENDING_STEERS` anyway + // (the inbox refuses past it), so the wider capacity is a bound the + // caller has to breach deliberately, not a hole in the cap. + const capacity = Math.max(MAX_PARKED_STEERS, parked.length); + const overflow = box.entries.length - capacity; + if (overflow > 0) { + box.discarded += overflow; + box.entries.splice(0, overflow); + } + this.bySession.set(sessionId, box); + this.evictOldestSessions(); + return parked; + } + + /** Non-destructive listing, oldest first. */ + list(sessionId: string): readonly UndeliveredSteer[] { + return this.bySession.get(sessionId)?.entries ?? []; + } + + /** + * How many messages this session lost to `MAX_PARKED_STEERS` and has + * not been acknowledged for. Survives `ack` — see `ackDiscarded`. + */ + discarded(sessionId: string): number { + return this.bySession.get(sessionId)?.discarded ?? 0; + } + + /** + * Drop everything with `seq <= through` and report how many went. + * The cursor comes from a prior `list`, so a message parked in + * between carries a higher `seq` and is not swallowed by the ack. + * + * Deliberately does **not** touch `discarded`. The cursor covers the + * entries the host was shown; the discarded messages were never in + * that listing and have no `seq` the host could point at, so nothing + * about acking the entries proves the loss notice was read. + */ + ack(sessionId: string, through: number): number { + const box = this.bySession.get(sessionId); + if (!box) return 0; + const before = box.entries.length; + box.entries = box.entries.filter((entry) => entry.seq > through); + const acked = before - box.entries.length; + this.reapIfEmpty(sessionId, box); + return acked; + } + + /** + * Acknowledge up to `count` discarded messages and report how many + * that actually cleared. + * + * Separate from `ack` on purpose. A host that acks the highest `seq` + * it was given — before, or in the same pass as, reading `discarded` + * — must not thereby erase the "N messages were dropped" signal and + * be told on its next `GET` that nothing was lost. And it is a count, + * not a flag, so discards that happen between the host's `GET` and + * this call stay outstanding rather than being cleared unseen: the + * same cursor discipline as the entries, applied to a counter. + */ + ackDiscarded(sessionId: string, count: number): number { + const box = this.bySession.get(sessionId); + if (!box) return 0; + const cleared = Math.min(Math.max(count, 0), box.discarded); + box.discarded -= cleared; + this.reapIfEmpty(sessionId, box); + return cleared; + } + + /** Forget one session's parked messages (session purge). */ + clear(sessionId: string): void { + this.bySession.delete(sessionId); + } + + /** Forget everything (server shutdown / tests). */ + clearAll(): void { + this.bySession.clear(); + } + + /** + * How many sessions currently hold a box. Introspection only — the + * seam that lets a test assert a box outlives its entries while a + * loss is unacknowledged, and is reclaimed once it is not. + */ + get trackedSessions(): number { + return this.bySession.size; + } + + /** + * Drop a box that has nothing left to say — no entries and no + * unacknowledged loss — so an idle server does not hold rows for + * sessions nobody is asking about. A box kept alive only by + * `discarded` is still reclaimed by `clear` (session purge) and by + * `MAX_PARKED_SESSIONS` eviction, so this cannot grow without bound. + */ + private reapIfEmpty(sessionId: string, box: Box): void { + if (box.entries.length === 0 && box.discarded === 0) { + this.bySession.delete(sessionId); + } + } + + private evictOldestSessions(): void { + while (this.bySession.size > MAX_PARKED_SESSIONS) { + const oldest = this.bySession.keys().next(); + if (oldest.done) return; + this.bySession.delete(oldest.value); + } + } +} diff --git a/src/llm/describe-llama-health-failure.test.ts b/src/llm/describe-llama-health-failure.test.ts new file mode 100644 index 00000000..31498a7b --- /dev/null +++ b/src/llm/describe-llama-health-failure.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { describeLlamaHealthFailure } from "./describe-llama-health-failure.js"; +import type { HealthResult } from "./llama-server-health.js"; + +function result(partial: Partial): HealthResult { + return { + reachable: false, + status: null, + kind: "unknown", + error: null, + latencyMs: 0, + ...partial, + }; +} + +describe("describeLlamaHealthFailure", () => { + it("steers an openai-compatible server to the cloud provider flow", () => { + const line = describeLlamaHealthFailure( + result({ kind: "openai-compat", status: 404, error: "http 404" }), + "http://127.0.0.1:1234", + ); + expect(line).toContain("OpenAI-compatible"); + expect(line).toContain("openai-compatible, base URL http://127.0.0.1:1234"); + }); + + it("says wait, not reconfigure, while the model is loading", () => { + const line = describeLlamaHealthFailure( + result({ kind: "llama-loading", status: 503 }), + "http://127.0.0.1:8080", + ); + expect(line).toContain("still loading"); + }); + + it("names the key env var for a --api-key server", () => { + const line = describeLlamaHealthFailure( + result({ + kind: "llama-auth", + status: 401, + error: "http 401 — the server requires an API key (--api-key)", + }), + "http://127.0.0.1:8080", + ); + expect(line).toContain("ATOMIC_AGENT_LLAMA_API_KEY"); + expect(line).toContain("http 401"); + }); + + it("falls back to the raw error with the probed URL", () => { + const line = describeLlamaHealthFailure( + result({ kind: "unknown", error: "fetch failed" }), + "http://10.0.0.7:8080", + ); + expect(line).toBe("local-llm /health failed at http://10.0.0.7:8080: fetch failed"); + }); +}); diff --git a/src/llm/describe-llama-health-failure.ts b/src/llm/describe-llama-health-failure.ts new file mode 100644 index 00000000..2c0686b6 --- /dev/null +++ b/src/llm/describe-llama-health-failure.ts @@ -0,0 +1,39 @@ +import type { HealthResult } from "./llama-server-health.js"; + +/** + * One operator-actionable line per probe verdict, shared by every + * surface that saves an external llama.cpp URL (LLM tab External pane, + * first-run wizard). Stub-verified failure shapes each map to what the + * operator must actually do — a bare "http 404" or "fetch failed" told + * them nothing at the exact moment they were ready to act. + */ +export function describeLlamaHealthFailure( + health: HealthResult, + url: string, +): string { + switch (health.kind) { + case "openai-compat": + // A real server, wrong route: KoboldCpp / LM Studio / Ollama / + // vLLM speak /v1/* but not llama.cpp's native endpoints. + return ( + `${url} answers like an OpenAI-compatible server, not llama.cpp. ` + + `Add it as a cloud provider instead: LLM tab › Cloud › n › ` + + `openai-compatible, base URL ${url}.` + ); + case "llama-loading": + return ( + `${url} is a llama.cpp server still loading its model. ` + + `Give it a minute and save the URL again.` + ); + case "llama-auth": + // /health is exempt from --api-key, so this is the first moment + // the key problem is even visible. Name the env var: there is no + // UI field for it. + return ( + `${url}: ${health.error ?? "http 401 — API key required"}. ` + + `Set ATOMIC_AGENT_LLAMA_API_KEY in the state dir's .env and retry.` + ); + default: + return `local-llm /health failed at ${url}: ${health.error ?? "unknown"}`; + } +} diff --git a/src/llm/errno-code.test.ts b/src/llm/errno-code.test.ts new file mode 100644 index 00000000..1c963a88 --- /dev/null +++ b/src/llm/errno-code.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { readErrnoCode } from "./errno-code.js"; + +describe("readErrnoCode", () => { + it("reads the errno off the error itself", () => { + const err = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:19091"), { + code: "ECONNREFUSED", + }); + expect(readErrnoCode(err)).toBe("ECONNREFUSED"); + }); + + it("digs the errno out from under undici's `fetch failed`", () => { + const inner = Object.assign(new Error("read ECONNRESET"), { + code: "ECONNRESET", + }); + const outer = Object.assign(new TypeError("fetch failed"), { + cause: inner, + }); + expect(readErrnoCode(outer)).toBe("ECONNRESET"); + }); + + it("accepts undici's own UND_ERR_* codes", () => { + const inner = Object.assign(new Error("other side closed"), { + code: "UND_ERR_SOCKET", + }); + expect( + readErrnoCode(Object.assign(new TypeError("fetch failed"), { cause: inner })), + ).toBe("UND_ERR_SOCKET"); + }); + + it("drops a freeform code — it could carry user data", () => { + const err = Object.assign(new Error("x"), { + code: "failed to read /home/alex/notes.md", + }); + expect(readErrnoCode(err)).toBeUndefined(); + }); + + it("returns undefined for errors with no code and for non-objects", () => { + expect(readErrnoCode(new Error("plain"))).toBeUndefined(); + expect(readErrnoCode("ECONNREFUSED")).toBeUndefined(); + expect(readErrnoCode(null)).toBeUndefined(); + }); + + it("survives a self-referential cause chain", () => { + const err = new Error("loop") as Error & { cause?: unknown }; + err.cause = err; + expect(readErrnoCode(err)).toBeUndefined(); + }); +}); diff --git a/src/llm/errno-code.ts b/src/llm/errno-code.ts new file mode 100644 index 00000000..fd8ca9fe --- /dev/null +++ b/src/llm/errno-code.ts @@ -0,0 +1,43 @@ +/** + * The errno a failed request left behind. + * + * Node reports connection-level failures as an errno on the thrown + * error (`ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, …) — and `undici` + * buries it one level down, under the generic `TypeError: fetch failed` + * it hands to `fetch` callers. Both HTTP clients here rebuild the + * failure into their own typed error, which is where that errno used to + * be dropped: `LlamaServerError(message, null, url)` says only "the + * network failed", never *how*. + * + * That distinction is the whole diagnosis. `ECONNREFUSED` is "the daemon + * was never started" — a setup problem. `ECONNRESET` mid-generation is + * "the daemon died under us" — an OOM or a crash. `EAI_AGAIN` is DNS, + * `ETIMEDOUT` is a hung box or a proxy. They need opposite fixes and + * they all currently arrive looking identical. + */ + +/** Node errnos are `E`-prefixed screaming snake; undici uses `UND_ERR_*`. */ +const ERRNO_SHAPE = /^[A-Z][A-Z0-9_]*$/; + +/** Depth cap on the `cause` walk — a longer chain is a cycle. */ +const MAX_CAUSE_DEPTH = 5; + +/** + * The first errno-shaped `code` on `err` or in its `cause` chain. + * + * The shape check is not cosmetic: an arbitrary `code` field could be + * freeform text, and this value is destined for an error report where + * only enum-like scalars are allowed to travel. + */ +export function readErrnoCode(err: unknown): string | undefined { + let current: unknown = err; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (typeof current !== "object" || current === null) return undefined; + const code = (current as { code?: unknown }).code; + if (typeof code === "string" && ERRNO_SHAPE.test(code)) return code; + const next = (current as { cause?: unknown }).cause; + if (next === current) return undefined; + current = next; + } + return undefined; +} diff --git a/src/llm/fallback/should-advance.ts b/src/llm/fallback/should-advance.ts index b7092d16..e7cf240c 100644 --- a/src/llm/fallback/should-advance.ts +++ b/src/llm/fallback/should-advance.ts @@ -29,7 +29,9 @@ const NO: AdvanceDecision = { advance: false, immediate: false }; * - `transport` → advance (provider unreachable). Note every cloud * `OpenAiHttpError` classifies as `transport` regardless of status, so * a 404 model-not-found or a 401 dead key advances too — a different - * link may have the model or a working key. + * link may have the model or a working key. Untyped socket failures + * (undici's `TypeError: fetch failed` and friends, from surfaces that + * do not wrap their own errors) land here too — see `isNetworkError`. * - `model` → advance. This is a *defective completion* from a reachable * provider (truncated / empty / no_stop), not "model not found"; the * same prompt would reproduce it here, so another link is worth a try. diff --git a/src/llm/grammar/tool-call-grammar.ts b/src/llm/grammar/tool-call-grammar.ts index 0e72c4f0..ddf4b535 100644 --- a/src/llm/grammar/tool-call-grammar.ts +++ b/src/llm/grammar/tool-call-grammar.ts @@ -60,6 +60,15 @@ export interface ToolCallBatch { kind: "single" | "batch"; calls: ToolCallPayload[]; reasoning?: string; + /** + * Set by the step executor when an oversized pure-read batch is + * mechanically split into bounded waves (issue #111). Absent ⇒ the + * batch executes under the default fan-out rules. `executeBatch` + * reads this and runs `pure_read` groups in waves of at most this + * many calls instead of one `Promise.allSettled` over the whole + * group. + */ + maxWaveSize?: number; } export interface ReasoningTagOptions { diff --git a/src/llm/index.ts b/src/llm/index.ts index 775baf9a..53d7fd0d 100644 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -12,6 +12,8 @@ export type { StreamChunk, } from "./llama-server-client.js"; export { checkLlamaServer } from "./llama-server-health.js"; +export { llamaEndpointUrl } from "./llama-endpoint-url.js"; +export { describeLlamaHealthFailure } from "./describe-llama-health-failure.js"; export type { HealthCheckOptions, HealthResult } from "./llama-server-health.js"; export { SlotManager, hashPrefix, DEFAULT_SLOT_COUNT } from "./slot-manager.js"; export type { SlotAssignment } from "./slot-manager.js"; diff --git a/src/llm/llama-endpoint-url.test.ts b/src/llm/llama-endpoint-url.test.ts new file mode 100644 index 00000000..5a889bd1 --- /dev/null +++ b/src/llm/llama-endpoint-url.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { llamaEndpointUrl } from "./llama-endpoint-url.js"; + +describe("llamaEndpointUrl", () => { + // The shapes most operators type are pure origins; for those the fix + // must be a byte-for-byte no-op against the old `new URL(path, base)` + // construction, so nothing that worked before can regress. + it("matches the legacy origin-resolving join for prefix-free bases", () => { + const bases = [ + "http://127.0.0.1:8080", + "http://192.168.1.50:8080", + "http://192.168.1.50:8080/", + ]; + const paths = ["/health", "/props", "/completion", "/v1/models"]; + for (const base of bases) { + for (const path of paths) { + expect(llamaEndpointUrl(base, path)).toBe(new URL(path, base).toString()); + } + } + }); + + it("keeps a reverse-proxy path prefix", () => { + expect(llamaEndpointUrl("https://box.example/llama", "/health")).toBe( + "https://box.example/llama/health", + ); + expect(llamaEndpointUrl("https://box.example/llama/", "/completion")).toBe( + "https://box.example/llama/completion", + ); + expect(llamaEndpointUrl("https://box.example/llama", "/v1/models")).toBe( + "https://box.example/llama/v1/models", + ); + }); + + it("drops a trailing /v1 pasted from the openai-compatible field", () => { + expect(llamaEndpointUrl("http://192.168.1.50:8080/v1", "/health")).toBe( + "http://192.168.1.50:8080/health", + ); + // ... including under a proxy prefix, where both conventions stack. + expect(llamaEndpointUrl("https://box.example/llama/v1", "/props")).toBe( + "https://box.example/llama/props", + ); + }); + + it("strips query and fragment from the base", () => { + expect(llamaEndpointUrl("http://127.0.0.1:8080/?x=1#frag", "/health")).toBe( + "http://127.0.0.1:8080/health", + ); + }); +}); diff --git a/src/llm/llama-endpoint-url.ts b/src/llm/llama-endpoint-url.ts new file mode 100644 index 00000000..19cc4242 --- /dev/null +++ b/src/llm/llama-endpoint-url.ts @@ -0,0 +1,32 @@ +/** + * Join a llama-server endpoint path onto an operator-supplied base URL + * without discarding the base's own path. + * + * Every llama.cpp call site used to build URLs with + * `new URL("/health", base)`. A leading-slash path resolves against the + * ORIGIN, so a server published behind a reverse-proxy path prefix + * (`https://box/llama`) was probed at `https://box/health`, answered 404, + * and the External pane reported the server as missing. The + * OpenAI-compatible client concatenates strings instead, which is why the + * exact same base URL works when pasted into that provider's field — the + * asymmetry operators actually hit (stub-verified: the compat route logs + * `GET /llama/v1/models`, the llama route logged `GET /health`). + * + * A trailing `/v1` is dropped first: operators paste the URL they already + * gave the OpenAI-compatible provider, whose convention bakes `/v1` into + * the base. llama.cpp's native endpoints live beside `/v1`, not under it, + * mirroring what `normalizeOpenAiBaseUrl` does in the other direction. + */ +export function llamaEndpointUrl(base: string, endpointPath: string): string { + const parsed = new URL(base); + let basePath = parsed.pathname.replace(/\/+$/, ""); + if (basePath.toLowerCase().endsWith("/v1")) { + basePath = basePath.slice(0, -"/v1".length); + } + // Query/fragment on a base URL are operator typos for our purposes; + // carrying them into every endpoint would break llama.cpp routing. + parsed.search = ""; + parsed.hash = ""; + parsed.pathname = `${basePath}${endpointPath}`; + return parsed.toString(); +} diff --git a/src/llm/llama-server-auth-probe.ts b/src/llm/llama-server-auth-probe.ts new file mode 100644 index 00000000..3fd88360 --- /dev/null +++ b/src/llm/llama-server-auth-probe.ts @@ -0,0 +1,45 @@ +import type { HealthResult } from "./llama-server-health.js"; +import { llamaEndpointUrl } from "./llama-endpoint-url.js"; + +/** + * Second-stage probe for `verifyAuth`: ask the key-guarded `/props` with + * whatever key we would use for real requests. Only an explicit 401/403 + * flips the verdict — any other answer (older build without /props, + * transient network blip) keeps the passing `/health` result, because + * "reachable but degraded" must never read as "server missing". + */ +export async function verifyGuardedEndpoint( + passed: HealthResult, + base: string, + timeoutMs: number, + apiKey: string | null | undefined, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(llamaEndpointUrl(base, "/props"), { + method: "GET", + headers: { + accept: "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), + }, + signal: controller.signal, + }); + if (response.status === 401 || response.status === 403) { + return { + ...passed, + reachable: false, + status: response.status, + kind: "llama-auth", + error: apiKey + ? `http ${response.status} — the server rejected the configured API key` + : `http ${response.status} — the server requires an API key (--api-key)`, + }; + } + return passed; + } catch { + return passed; + } finally { + clearTimeout(timer); + } +} diff --git a/src/llm/llama-server-client.test.ts b/src/llm/llama-server-client.test.ts index 192b2102..0bfcc8d8 100644 --- a/src/llm/llama-server-client.test.ts +++ b/src/llm/llama-server-client.test.ts @@ -113,6 +113,42 @@ describe("LlamaServerClient.complete", () => { }); }); + it("keeps the errno and the original error on a network failure", async () => { + // Without these, every unreachable-daemon failure is indistinguishable + // from every died-mid-generation one: same name, same null status, + // same empty message in an error report. + const cause = Object.assign( + new Error("connect ECONNREFUSED 127.0.0.1:9999"), + { code: "ECONNREFUSED" }, + ); + const client = new LlamaServerClient({ + baseUrl: "http://127.0.0.1:9999", + fetchImpl: createMockFetch(async () => { + throw Object.assign(new TypeError("fetch failed"), { cause }); + }), + completionRetries: 1, + }); + await expect(client.complete({ prompt: "x" })).rejects.toMatchObject({ + name: "LlamaServerError", + status: null, + code: "ECONNREFUSED", + }); + }); + + it("leaves `code` undefined when the transport left no errno", async () => { + const client = new LlamaServerClient({ + baseUrl: "http://127.0.0.1:9999", + fetchImpl: createMockFetch(async () => { + throw new Error("something opaque"); + }), + completionRetries: 1, + }); + await expect(client.complete({ prompt: "x" })).rejects.toMatchObject({ + name: "LlamaServerError", + code: undefined, + }); + }); + it("retries transient 5xx responses and eventually succeeds", async () => { let calls = 0; const client = new LlamaServerClient({ diff --git a/src/llm/llama-server-client.ts b/src/llm/llama-server-client.ts index d2dfa016..3abe229a 100644 --- a/src/llm/llama-server-client.ts +++ b/src/llm/llama-server-client.ts @@ -1,4 +1,6 @@ import { getConfig } from "../config/index.js"; +import { llamaEndpointUrl } from "./llama-endpoint-url.js"; +import { readErrnoCode } from "./errno-code.js"; import type { CompletionRequest, CompletionResult, @@ -51,9 +53,21 @@ export class LlamaServerError extends Error { * `isRetryableLlamaError`. */ public readonly timedOut = false, + /** + * Errno of the underlying failure (`ECONNREFUSED`, `ECONNRESET`, + * `ETIMEDOUT`, `UND_ERR_*`, …) when the transport left one behind. + * This is the difference between "the daemon was never started" and + * "the daemon died under us" — two problems with opposite fixes + * that both surface as `status === null`. + */ + public readonly code: string | undefined = undefined, + options?: { cause?: unknown }, ) { super(message); this.name = "LlamaServerError"; + if (options?.cause !== undefined) { + (this as { cause?: unknown }).cause = options.cause; + } } } @@ -172,7 +186,7 @@ export class LlamaServerClient { async fetchProps(): Promise { const config = getConfig(); const base = this.baseUrlOverride ?? config.localModels.url; - const url = new URL("/props", base).toString(); + const url = llamaEndpointUrl(base, "/props"); const controller = new AbortController(); const timer = setTimeout( () => controller.abort(), @@ -191,7 +205,9 @@ export class LlamaServerClient { } catch (err) { if (err instanceof LlamaServerError) throw err; const message = err instanceof Error ? err.message : String(err); - throw new LlamaServerError(message, null, url); + throw new LlamaServerError(message, null, url, false, readErrnoCode(err), { + cause: err, + }); } finally { clearTimeout(timer); } @@ -267,7 +283,9 @@ export class LlamaServerClient { } catch (err) { if (err instanceof LlamaServerError) throw err; const message = err instanceof Error ? err.message : String(err); - throw new LlamaServerError(message, null, url); + throw new LlamaServerError(message, null, url, false, readErrnoCode(err), { + cause: err, + }); } const { response, cleanup, timedOut } = opened; let finalResult: CompletionResult = { @@ -400,10 +418,18 @@ export class LlamaServerClient { null, url, true, + undefined, + { cause: err }, ); } const message = err instanceof Error ? err.message : String(err); - return new LlamaServerError(message, null, url); + // Keep the errno and the original error. Rebuilding the failure + // without them is what left the biggest bucket in error reporting + // undiagnosable: ~1,900 events that say "the network failed" and + // nothing about how. + return new LlamaServerError(message, null, url, false, readErrnoCode(err), { + cause: err, + }); } private prepareRequest( @@ -412,7 +438,7 @@ export class LlamaServerClient { ): { url: string; headers: Record; body: string } { const config = getConfig(); const base = this.baseUrlOverride ?? config.localModels.url; - const url = new URL(config.localModels.completionPath, base).toString(); + const url = llamaEndpointUrl(base, config.localModels.completionPath); const headers = this.buildHeaders(stream); const payload: Record = { prompt: request.prompt, diff --git a/src/llm/llama-server-client.url.test.ts b/src/llm/llama-server-client.url.test.ts new file mode 100644 index 00000000..8d05863c --- /dev/null +++ b/src/llm/llama-server-client.url.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { LlamaServerClient } from "./llama-server-client.js"; + +// URL-construction cases live in their own file: the main client suite +// is already past the size budget, and these tests share one concern — +// the base URL's own path must survive endpoint joins (the reverse-proxy +// shape the OpenAI-compatible route already handled). + +function completionResponse(): Response { + return new Response( + JSON.stringify({ + content: "ok", + stop: true, + truncated: false, + timings: { prompt_ms: 1, predicted_ms: 1, prompt_n: 1, predicted_n: 1 }, + tokens_cached: 0, + slot_id: 0, + model: "m", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +describe("LlamaServerClient endpoint URLs", () => { + it("keeps a reverse-proxy path prefix on /completion", async () => { + const urls: string[] = []; + const client = new LlamaServerClient({ + baseUrl: "https://box.example/llama", + fetchImpl: (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return completionResponse(); + }) as typeof fetch, + }); + await client.complete({ prompt: "hi" }); + expect(urls).toEqual(["https://box.example/llama/completion"]); + }); + + it("keeps the prefix on /props", async () => { + const urls: string[] = []; + const client = new LlamaServerClient({ + baseUrl: "https://box.example/llama", + fetchImpl: (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return new Response(JSON.stringify({ total_slots: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch, + }); + await client.fetchProps(); + expect(urls).toEqual(["https://box.example/llama/props"]); + }); + + it("drops a trailing /v1 pasted from the openai-compatible field", async () => { + const urls: string[] = []; + const client = new LlamaServerClient({ + baseUrl: "http://192.168.1.50:8080/v1", + fetchImpl: (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return completionResponse(); + }) as typeof fetch, + }); + await client.complete({ prompt: "hi" }); + expect(urls).toEqual(["http://192.168.1.50:8080/completion"]); + }); +}); diff --git a/src/llm/llama-server-health.test.ts b/src/llm/llama-server-health.test.ts index d7edd765..c583250a 100644 --- a/src/llm/llama-server-health.test.ts +++ b/src/llm/llama-server-health.test.ts @@ -194,4 +194,85 @@ describe("checkLlamaServer", () => { const urls = fetchMock.mock.calls.map((c) => String(c[0])); expect(urls.some((u) => u.endsWith("/v1/models"))).toBe(false); }); + + it("probes /health under a reverse-proxy path prefix", async () => { + // The exact "works via openai-compatible, dead via external" split: + // the compat client concatenates and reaches /llama/v1/models, while + // this probe used to resolve "/health" against the origin and 404. + const fetchMock = vi.fn(async () => jsonResponse({ status: "ok" })); + vi.stubGlobal("fetch", fetchMock); + const result = await checkLlamaServer({ + url: "https://box.example/llama", + retries: 0, + }); + expect(result.reachable).toBe(true); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://box.example/llama/health", + ); + }); + + it("keeps the prefix on the openai-compat detection probe too", async () => { + // Behind a prefix, an LM Studio-style box must still be recognized + // and steered — otherwise the operator just sees "http 404". + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).endsWith("/v1/models")) { + return jsonResponse({ object: "list", data: [] }); + } + return jsonResponse({ error: "no health here" }, false, 404); + }); + vi.stubGlobal("fetch", fetchMock); + const result = await checkLlamaServer({ + url: "https://box.example/lmstudio", + retries: 0, + }); + expect(result.kind).toBe("openai-compat"); + const urls = fetchMock.mock.calls.map((c) => String(c[0])); + expect(urls).toContain("https://box.example/lmstudio/v1/models"); + }); + + it("verifyAuth reports a --api-key server as llama-auth", async () => { + // llama.cpp exempts /health from --api-key, so the plain probe + // passes and the row claims healthy while every completion 401s. + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).endsWith("/health")) return jsonResponse({ status: "ok" }); + return jsonResponse({ error: { code: 401, message: "Invalid API Key" } }, false, 401); + }); + vi.stubGlobal("fetch", fetchMock); + const result = await checkLlamaServer({ + url: "http://127.0.0.1:8080", + retries: 0, + verifyAuth: true, + }); + expect(result.reachable).toBe(false); + expect(result.kind).toBe("llama-auth"); + expect(result.error).toContain("requires an API key"); + }); + + it("verifyAuth stays off by default so the poller costs one request", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ status: "ok" })); + vi.stubGlobal("fetch", fetchMock); + const result = await checkLlamaServer({ + url: "http://127.0.0.1:8080", + retries: 0, + }); + expect(result.reachable).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("verifyAuth keeps a passing verdict when /props merely errors", async () => { + // An old build without /props (404) is still a llama-server; + // only an explicit 401/403 may flip the verdict. + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).endsWith("/health")) return jsonResponse({ status: "ok" }); + return jsonResponse({ error: "not found" }, false, 404); + }); + vi.stubGlobal("fetch", fetchMock); + const result = await checkLlamaServer({ + url: "http://127.0.0.1:8080", + retries: 0, + verifyAuth: true, + }); + expect(result.reachable).toBe(true); + expect(result.kind).toBe("llama-server"); + }); }); diff --git a/src/llm/llama-server-health.ts b/src/llm/llama-server-health.ts index 50a75251..49324127 100644 --- a/src/llm/llama-server-health.ts +++ b/src/llm/llama-server-health.ts @@ -1,4 +1,6 @@ import { getConfig } from "../config/index.js"; +import { llamaEndpointUrl } from "./llama-endpoint-url.js"; +import { verifyGuardedEndpoint } from "./llama-server-auth-probe.js"; export interface HealthResult { reachable: boolean; @@ -16,6 +18,13 @@ export interface HealthResult { * answered like an OpenAI-compatible server (KoboldCpp, LM Studio, * vLLM). The external llama.cpp route cannot drive these; callers * should steer the operator to the openai-compatible provider. + * - `"llama-auth"`: `/health` passed but the guarded `/props` endpoint + * answered 401/403 (only reported when `verifyAuth` is set). + * llama.cpp exempts exactly /health, /models, /v1/models and + * /api/tags from `--api-key`, so a key-protected server sails + * through the probe and then rejects every actual request — the + * "row says healthy, first turn 401s" trap. Callers should name the + * key env var. * - `"unknown"`: nothing recognizable answered. * * A bare HTTP 200 is deliberately NOT enough for `"llama-server"`: @@ -23,7 +32,7 @@ export interface HealthResult { * the probe pass falsely and let the chat route switch onto a server * the llama.cpp client then hangs against (#65, #66). */ - kind: "llama-server" | "llama-loading" | "openai-compat" | "unknown"; + kind: "llama-server" | "llama-loading" | "openai-compat" | "llama-auth" | "unknown"; error: string | null; latencyMs: number; } @@ -34,6 +43,13 @@ export interface HealthCheckOptions { retries?: number; backoffMs?: number; apiKey?: string | null; + /** + * Also GET the key-guarded `/props` after a passing `/health`, so a + * `--api-key` server is caught at save time instead of on the first + * completion. Off by default: it costs a second round trip, and the + * boot probe plus the 3s footer poller must stay at one request each. + */ + verifyAuth?: boolean; } function buildHeaders(apiKey: string | null | undefined): Record { @@ -156,7 +172,7 @@ async function probeOpenAiCompat( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const url = new URL("/v1/models", base).toString(); + const url = llamaEndpointUrl(base, "/v1/models"); const response = await fetch(url, { method: "GET", headers: buildHeaders(apiKey), @@ -180,6 +196,7 @@ function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + /** * Pings the external llama-server `/health` endpoint with exponential backoff. * Returns the first successful probe or the last failure after all retries. @@ -189,7 +206,7 @@ export async function checkLlamaServer( ): Promise { const config = getConfig(); const base = options.url ?? config.localModels.url; - const url = new URL(config.localModels.healthPath, base).toString(); + const url = llamaEndpointUrl(base, config.localModels.healthPath); const timeoutMs = options.timeoutMs ?? config.localModels.healthTimeoutMs; const retries = options.retries ?? config.localModels.healthRetries; const backoffMs = options.backoffMs ?? config.localModels.healthRetryBackoffMs; @@ -198,7 +215,10 @@ export async function checkLlamaServer( let last: HealthResult | null = null; for (let attempt = 0; attempt <= retries; attempt += 1) { last = await pingOnce(url, timeoutMs, apiKey); - if (last.reachable) return last; + if (last.reachable) { + if (!options.verifyAuth) return last; + return await verifyGuardedEndpoint(last, base, timeoutMs, apiKey); + } // A 200 with a non-llama body is deterministic: the same wrong // server (KoboldCpp web UI) will answer the same way on every // retry, so burning the whole backoff budget changes nothing. @@ -231,3 +251,21 @@ export async function checkLlamaServer( } return failed; } + +/** + * The message a human can act on when llama-server does not answer. + * + * "fetch failed" is undici's transport error verbatim: it names neither the + * URL that was tried, nor the fact that the missing piece is llama-server, + * nor what to do about it — and it is the single most common failure a new + * local-model user sees. Every surface that reports an unreachable llama + * should say this instead. + */ +export function formatLlamaUnreachableHint(url: string): string { + return [ + `llama-server is not reachable at ${url}`, + " start it with: atomic-agent models start", + ` or point elsewhere: atomic-agent config set localModels.url `, + ].join("\n"); +} + diff --git a/src/llm/model-profile.fixtures.ts b/src/llm/model-profile.fixtures.ts index d1403a09..f12c5e08 100644 --- a/src/llm/model-profile.fixtures.ts +++ b/src/llm/model-profile.fixtures.ts @@ -444,3 +444,270 @@ export const GEMMA4_PROPS = { supports_preserve_reasoning: true, }, }; + +export const NEMOTRON_PROPS = { + model_alias: "nvidia-nemotron-3.5-lightning-30b-a3b", + chat_template: `{% macro render_extra_keys(json_dict, handled_keys) %} + {%- if json_dict is mapping %} + {%- for json_key in json_dict if json_key not in handled_keys %} + {%- if json_dict[json_key] is mapping or (json_dict[json_key] is sequence and json_dict[json_key] is not string) %} + {{- '\\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | tojson | safe) ~ '' }} + {%- else %} + {{-'\\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | string) ~ '' }} + {%- endif %} + {%- endfor %} + {%- endif %} +{% endmacro %} +{%- set enable_thinking = enable_thinking if enable_thinking is defined else True %} +{%- set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %} +{%- set ns = namespace(last_user_idx = -1) %} +{%- set loop_messages = messages %} +{%- for m in loop_messages %} + {%- if m["role"] == "user" %} + {%- set ns.last_user_idx = loop.index0 %} + {%- endif %} +{%- endfor %} +{%- if messages[0]["role"] == "system" %} + {%- set system_message = messages[0]["content"] %} + {%- set loop_messages = messages[1:] %} +{%- else %} + {%- set system_message = "" %} + {%- set loop_messages = messages %} +{%- endif %} +{%- if not tools is defined %} + {%- set tools = [] %} +{%- endif %} +{%- set ns = namespace(last_user_idx = -1) %} +{%- for m in loop_messages %} + {%- if m["role"] == "user" %} + {%- set ns.last_user_idx = loop.index0 %} + {%- endif %} +{%- endfor %} +{%- if system_message is defined %} + {{- "<|im_start|>system\\n" + system_message }} +{%- else %} + {%- if tools is iterable and tools | length > 0 %} + {{- "<|im_start|>system\\n" }} + {%- endif %} +{%- endif %} +{%- if tools is iterable and tools | length > 0 %} + {%- if system_message is defined and system_message | length > 0 %} + {{- "\\n\\n" }} + {%- endif %} + {{- "# Tools\\n\\nYou have access to the following functions:\\n\\n" }} + {{- "" }} + {%- for tool in tools %} + {%- if tool.function is defined %} + {%- set tool = tool.function %} + {%- endif %} + {{- "\\n\\n" ~ tool.name ~ "" }} + {%- if tool.description is defined %} + {{- '\\n' ~ (tool.description | trim) ~ '' }} + {%- endif %} + {{- '\\n' }} + {%- if tool.parameters is defined and tool.parameters is mapping and tool.parameters.properties is defined and tool.parameters.properties is mapping %} + {%- for param_name, param_fields in tool.parameters.properties|items %} + {{- '\\n' }} + {{- '\\n' ~ param_name ~ '' }} + {%- if param_fields.type is defined %} + {{- '\\n' ~ (param_fields.type | string) ~ '' }} + {%- endif %} + {%- if param_fields.description is defined %} + {{- '\\n' ~ (param_fields.description | trim) ~ '' }} + {%- endif %} + {%- if param_fields.enum is defined %} + {{- '\\n' ~ (param_fields.enum | tojson | safe) ~ '' }} + {%- endif %} + {%- set handled_keys = ['name', 'type', 'description', 'enum'] %} + {{- render_extra_keys(param_fields, handled_keys) }} + {{- '\\n' }} + {%- endfor %} + {%- endif %} + {% set handled_keys = ['type', 'properties', 'required'] %} + {{- render_extra_keys(tool.parameters, handled_keys) }} + {%- if tool.parameters is defined and tool.parameters.required is defined %} + {{- '\\n' ~ (tool.parameters.required | tojson | safe) ~ '' }} + {%- endif %} + {{- '\\n' }} + {%- set handled_keys = ['type', 'name', 'description', 'parameters'] %} + {{- render_extra_keys(tool, handled_keys) }} + {{- '\\n' }} + {%- endfor %} + {{- "\\n" }} + {{- '\\n\\nIf you choose to call a function ONLY reply in the following format with NO suffix:\\n\\n\\n\\n\\nvalue_1\\n\\n\\nThis is the value for the second parameter\\nthat can span\\nmultiple lines\\n\\n\\n\\n\\n\\nReminder:\\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\\n- Required parameters MUST be specified\\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\\n' }} +{%- endif %} +{%- if system_message is defined %} + {{- '<|im_end|>\\n' }} +{%- else %} + {%- if tools is iterable and tools | length > 0 %} + {{- '<|im_end|>\\n' }} + {%- endif %} +{%- endif %} +{%- for message in loop_messages %} + {%- if message.role == "assistant" %} + {%- if message.reasoning_content is defined and message.reasoning_content is string and message.reasoning_content | trim | length > 0 %} + {%- set content = "\\n" ~ message.reasoning_content ~ "" ~ (message.content | default('', true)) %} + {%- else %} + {%- set content = message.content | default('', true) %} + {%- if content is string -%} + {%- if '' not in content and '' not in content -%} + {%- set content = "" ~ content -%} + {%- endif -%} + {%- else -%} + {%- set content = content -%} + {%- endif -%} + {%- endif %} + {%- if message.tool_calls is defined and message.tool_calls is iterable and message.tool_calls | length > 0 %} + {{- '<|im_start|>assistant\\n' }} + {%- set include_content = not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %} + {%- if content is string and content | trim | length > 0 %} + {%- if include_content %} + {{- (content | trim) ~ '\\n' -}} + {%- else %} + {%- set c = (content | string) %} + {%- if '' in c %} + {%- set c = c.split('')[-1] %} + {%- elif '' in c %} + {%- set c = c.split('')[0] %} + {%- endif %} + {%- set c = "" ~ c %} + {%- if c | length > 0 %} + {{- c ~ '\\n' -}} + {%- endif %} + {%- endif %} + {%- else %} + {{- "" -}} + {%- endif %} + {%- for tool_call in message.tool_calls %} + {%- if tool_call.function is defined %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {{- '\\n\\n' -}} + {%- if tool_call.arguments is defined %} + {%- for args_name, args_value in tool_call.arguments|items %} + {{- '\\n' -}} + {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %} + {{- args_value ~ '\\n\\n' -}} + {%- endfor %} + {%- endif %} + {{- '\\n\\n' -}} + {%- endfor %} + {{- '<|im_end|>\\n' }} + {%- else %} + {%- if not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %} + {{- '<|im_start|>assistant\\n' ~ (content | default('', true) | string | trim) ~ '<|im_end|>\\n' }} + {%- else %} + {%- set c = (content | default('', true) | string) %} + {%- if '' in c and '' in c %} + {%- set c = "" ~ c.split('')[-1] %} + {%- endif %} + {%- set c = c | trim %} + {%- if c | length > 0 %} + {{- '<|im_start|>assistant\\n' ~ c ~ '<|im_end|>\\n' }} + {%- else %} + {{- '<|im_start|>assistant\\n<|im_end|>\\n' }} + {%- endif %} + {%- endif %} + {%- endif %} + {%- elif message.role == "user" or message.role == "system" %} + {{- '<|im_start|>' + message.role + '\\n' }} + {%- set content = message.content | string %} + {{- content }} + {{- '<|im_end|>\\n' }} + {%- elif message.role == "tool" %} + {%- if loop.previtem and loop.previtem.role != "tool" %} + {{- '<|im_start|>user\\n' }} + {%- endif %} + {{- '\\n' }} + {{- message.content }} + {{- '\\n\\n' }} + {%- if not loop.last and loop.nextitem.role != "tool" %} + {{- '<|im_end|>\\n' }} + {%- elif loop.last %} + {{- '<|im_end|>\\n' }} + {%- endif %} + {%- else %} + {{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>\\n' }} + {%- endif %} +{%- endfor %} +{%- if add_generation_prompt %} + {%- if enable_thinking %} + {{- '<|im_start|>assistant\\n\\n' }} + {%- else %} + {{- '<|im_start|>assistant\\n' }} + {%- endif %} +{%- endif %}`, + chat_template_caps: { + supports_preserve_reasoning: true, + }, +}; + +/** + * Meta Muse Glimmer 30B as llama-server reports it. The alias is the + * catalog id verbatim — `daemon-lifecycle.ts` passes `model.id` to `-a`. + * + * The template is Harmony/ATEM channel framing, deliberately kept rich + * rather than stubbed: it carries `<|channel|>analysis` reasoning markers + * and native `<|start|>`/`<|end|>` tool framing. That is the point of the + * fixture — detection still falls through to `plain-instruct`, and it does + * so because the alias `muse-glimmer-30b` matches no hint in + * `selectBaseProfile` (not because the template is empty). A stub template + * would pass the same assertion for the wrong reason. + */ +export const MUSE_PROPS = { + model_alias: "muse-glimmer-30b", + chat_template: `{%- if messages[0].role == 'system' %} + {{- '<|start|>system<|message|>' + messages[0].content + '<|end|>' }} + {%- set loop_messages = messages[1:] %} +{%- else %} + {%- set loop_messages = messages %} +{%- endif %} +{%- if tools is defined and tools | length > 0 %} + {{- '<|start|>developer<|message|># Tools\\n\\n' }} + {{- '## functions\\n\\nnamespace functions {\\n\\n' }} + {%- for tool in tools %} + {%- if tool.function is defined %} + {%- set tool = tool.function %} + {%- endif %} + {%- if tool.description is defined %} + {{- '// ' ~ (tool.description | trim) ~ '\\n' }} + {%- endif %} + {{- 'type ' ~ tool.name ~ ' = (_: ' }} + {{- (tool.parameters | tojson | safe) ~ ') => any;\\n\\n' }} + {%- endfor %} + {{- '} // namespace functions<|end|>' }} +{%- endif %} +{%- for message in loop_messages %} + {%- if message.role == 'assistant' %} + {%- if message.tool_calls is defined and message.tool_calls %} + {%- for tool_call in message.tool_calls %} + {%- if tool_call.function is defined %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {{- '<|start|>assistant to=functions.' ~ tool_call.name }} + {{- '<|channel|>commentary json<|message|>' }} + {{- (tool_call.arguments | tojson | safe) ~ '<|call|>' }} + {%- endfor %} + {%- else %} + {%- set content = message.content | default('', true) | string %} + {%- if '<|channel|>analysis<|message|>' in content %} + {%- set content = content.split('<|end|>')[-1] %} + {%- endif %} + {{- '<|start|>assistant<|channel|>final<|message|>' }} + {{- content | trim ~ '<|end|>' }} + {%- endif %} + {%- elif message.role == 'tool' %} + {{- '<|start|>functions.' ~ message.name ~ ' to=assistant' }} + {{- '<|channel|>commentary<|message|>' ~ message.content ~ '<|end|>' }} + {%- else %} + {{- '<|start|>' ~ message.role ~ '<|message|>' }} + {{- (message.content | string) ~ '<|end|>' }} + {%- endif %} +{%- endfor %} +{%- if add_generation_prompt %} + {{- '<|start|>assistant<|channel|>analysis<|message|>' }} +{%- endif %}`, + chat_template_caps: { + supports_preserve_reasoning: true, + }, +}; diff --git a/src/llm/model-profile.test.ts b/src/llm/model-profile.test.ts index 172306d2..04210571 100644 --- a/src/llm/model-profile.test.ts +++ b/src/llm/model-profile.test.ts @@ -12,6 +12,7 @@ import { GEMMA4_PROPS, GPT_OSS_PROPS, LLAMA3_PROPS, + NEMOTRON_PROPS, QWEN3_PROPS, } from "./model-profile.fixtures.js"; @@ -49,6 +50,47 @@ describe("detectModelProfile", () => { expect(detectModelProfile(GEMMA4_PROPS)).toEqual(GEMMA4_THINK_PROFILE); }); + // Nemotron has no profile of its own: its ChatML template is qwen-shaped, + // so the dedicated detector deliberately maps onto QWEN_THINK_PROFILE. The + // contract under test is that the Nemotron template yields the think-tags + // reasoning profile at all — deleting the branch drops it to plain-instruct + // and silently kills the reasoning channel. + it("maps the nemotron ChatML + enable_thinking template onto the think-tags profile", () => { + const profile = detectModelProfile(NEMOTRON_PROPS); + expect(profile).toEqual(QWEN_THINK_PROFILE); + expect(profile.reasoningStyle).toBe("think-tags"); + }); + + // Pins the alias gate: the Nemotron detector must require a `nemotron` + // alias, not fire on the template markers alone. An alias carrying none of + // the qwen/qwq/deepseek-r1/nemotron hints must fall through to plain even + // though the template is a full ChatML + + enable_thinking match. + it("requires a nemotron alias — template markers alone do not classify", () => { + expect( + detectModelProfile({ + ...NEMOTRON_PROPS, + model_alias: "some-other-chatml-think-model", + }), + ).toEqual(PLAIN_INSTRUCT_PROFILE); + }); + + // Pins branch ordering. This alias satisfies BOTH gates (it contains + // "qwen" and "nemotron"), which is the only input where the order of the + // two branches is observable: whichever runs first decides. The qwen + // branch runs first, so the qwen gate must win. Both branches currently + // yield QWEN_THINK_PROFILE, so this is pinned on the gate that fired + // rather than on the returned object. + it("lets the qwen gate win when an alias matches both the qwen and nemotron hints", () => { + const alias = "qwen-nemotron-hybrid-think"; + // Guard: the alias really does trip both gates, so the assertion below + // is about ordering and not about one gate quietly failing to match. + expect(alias).toContain("qwen"); + expect(alias).toContain("nemotron"); + expect(detectModelProfile({ ...NEMOTRON_PROPS, model_alias: alias })).toEqual( + QWEN_THINK_PROFILE, + ); + }); + it("falls back to plain profile for gpt-oss style templates", () => { expect(detectModelProfile(GPT_OSS_PROPS)).toEqual(PLAIN_INSTRUCT_PROFILE); }); diff --git a/src/llm/model-profile.ts b/src/llm/model-profile.ts index 1829f6ac..aa3059a7 100644 --- a/src/llm/model-profile.ts +++ b/src/llm/model-profile.ts @@ -225,6 +225,16 @@ function selectBaseProfile( if (looksLikeQwenThinkModel(modelAlias, templateLower, supportsPreserveReasoning)) { return QWEN_THINK_PROFILE; } + // Nemotron needs its own detector but not its own profile. Its ChatML + // template is qwen-shaped — ``/`` prefilled at the + // generation point, same open/close ownership, no turn framing — so the + // runtime behaviour is byte-for-byte `QWEN_THINK_PROFILE`. Only the alias + // gate differs: `looksLikeQwenThinkModel` requires a qwen/qwq/deepseek-r1 + // alias, which Nemotron's does not match, so without this branch it would + // fall through to `plain-instruct` and lose its reasoning channel. + if (looksLikeNemotronThinkModel(modelAlias, templateLower)) { + return QWEN_THINK_PROFILE; + } if (looksLikeGemma4ThinkModel(modelAlias, templateLower)) { return GEMMA4_THINK_PROFILE; } @@ -290,6 +300,17 @@ function looksLikeQwenThinkModel( return aliasHint && templateHint; } +function looksLikeNemotronThinkModel( + modelAlias: string, + templateLower: string, +): boolean { + const aliasHint = modelAlias.includes("nemotron"); + const templateHint = + templateLower.includes("") && + templateLower.includes("enable_thinking"); + return aliasHint && templateHint; +} + function looksLikeGemma4ThinkModel(modelAlias: string, templateLower: string): boolean { const aliasHint = modelAlias.includes("gemma"); const templateHint = diff --git a/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts b/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts index b4cf05d3..008e9877 100644 --- a/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts +++ b/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts @@ -48,7 +48,33 @@ describe("AIMLAPI_MODELS_CATALOG", () => { } }); - it("retires all legacy Anthropic Claude and Google Gemini ids", () => { + it("lists the vendor-prefixed Claude and Gemini ids aimlapi serves today", () => { + // The catalog used to carry no Claude and no Gemini row at all. Both + // are on aimlapi's `openai/chat-completions` surface under + // vendor-prefixed ids, so the provider can reach them; only the old + // unprefixed spellings below are actually gone. + for (const id of [ + "anthropic/claude-opus-5", + "anthropic/claude-sonnet-5", + "google/gemini-3.7-flash", + "google/gemini-3.5-flash", + ]) { + expect(AIMLAPI_MODELS_CATALOG.get(id)?.kind).toBe("chat"); + expect(AIMLAPI_CHAT_MODEL_ORDER).toContain(id); + } + }); + + it("keeps every chat row on the picker order, without duplicates", () => { + expect(new Set(AIMLAPI_CHAT_MODEL_ORDER).size).toBe( + AIMLAPI_CHAT_MODEL_ORDER.length, + ); + const chatIds = [...AIMLAPI_MODELS_CATALOG] + .filter(([, entry]) => entry.kind === "chat") + .map(([id]) => id); + expect([...AIMLAPI_CHAT_MODEL_ORDER].sort()).toEqual([...chatIds].sort()); + }); + + it("keeps the retired unprefixed Claude and Gemini ids out", () => { const retired = [ "claude-opus-4-8", "claude-sonnet-4-6", diff --git a/src/llm/provider/aimlapi/aimlapi-models-catalog.ts b/src/llm/provider/aimlapi/aimlapi-models-catalog.ts index d53468e5..375c34dc 100644 --- a/src/llm/provider/aimlapi/aimlapi-models-catalog.ts +++ b/src/llm/provider/aimlapi/aimlapi-models-catalog.ts @@ -1,51 +1,5 @@ import type { ModelCatalogEntry } from "../model-resolver.js"; - -type ChatModelSpec = { - id: string; - contextWindow: number; - supportsVision: boolean; - supportsTools?: "basic" | "parallel"; - supportsPromptCache?: boolean; -}; - -type EmbeddingModelSpec = { - id: string; - contextWindow: number; - dim?: number; -}; - -function chatModel(spec: ChatModelSpec): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "chat", - contextWindow: spec.contextWindow, - supportsVision: spec.supportsVision, - supportsTools: spec.supportsTools ?? "parallel", - supportsPromptCache: spec.supportsPromptCache ?? false, - reasoningFormat: "none", - }, - ]; -} - -function embeddingModel( - spec: EmbeddingModelSpec, -): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "embedding", - contextWindow: spec.contextWindow, - ...(spec.dim !== undefined ? { dim: spec.dim } : {}), - supportsVision: false, - supportsTools: "none", - supportsPromptCache: false, - reasoningFormat: "none", - }, - ]; -} +import { chatModel, embeddingModel } from "../model-catalog-entry.js"; /** * Static fallback catalog for aimlapi.com. @@ -57,13 +11,20 @@ function embeddingModel( * `contextWindow` / `supportsVision` / `supportsTools` for ids that * have been hand-verified against the live API. * - * Curated down to current-generation chat models only — legacy OpenAI - * (gpt-4o / gpt-4.1 / o-series), all Anthropic Claude, and all Google - * Gemini ids were retired. Every id here was verified against - * `https://api.aimlapi.com/v1/models` with `type === "chat-completion"`. - * Models that only expose `type: "responses"` (`openai/gpt-5-pro`, - * `openai/gpt-5-3-codex`, etc.) are intentionally excluded — they 404 - * on `/v1/chat/completions`. + * Curated down to current-generation chat models only: legacy OpenAI + * (gpt-4o / gpt-4.1 / o-series) and the unprefixed `claude-*` / + * `google/gemini-2.x` ids stay retired because aimlapi no longer serves + * them. Claude and Gemini themselves are back — aimlapi lists them under + * vendor-prefixed ids (`anthropic/claude-opus-5`, + * `google/gemini-3.7-flash`) on the `openai/chat-completions` surface, + * so they work through this provider like any other row. + * + * Every id here was re-verified on 2026-08-19 against + * `https://api.aimlapi.com/v1/models` with `type === + * "openai/chat-completions"`. Models that only expose `type: + * "responses"` (`openai/gpt-5-pro`, `openai/gpt-5-3-codex`) or only + * `anthropic/messages` are intentionally excluded — they 404 on + * `/v1/chat/completions`. */ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = new Map([ @@ -112,6 +73,11 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = contextWindow: 2_000_000, supportsVision: true, }), + chatModel({ + id: "x-ai/grok-4-6", + contextWindow: 500_000, + supportsVision: true, + }), // DeepSeek chatModel({ id: "deepseek/deepseek-v4-flash", @@ -131,6 +97,11 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = contextWindow: 262_144, supportsVision: false, }), + chatModel({ + id: "moonshot/kimi-k3", + contextWindow: 1_048_576, + supportsVision: false, + }), // ByteDance Seed chatModel({ id: "bytedance/dola-seed-2-0-pro", @@ -143,6 +114,97 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = contextWindow: 524_288, supportsVision: false, }), + // Anthropic Claude (verified `openai/chat-completions`, not the + // `anthropic/messages` surface that 404s on /v1/chat/completions) + chatModel({ + id: "anthropic/claude-opus-5", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-sonnet-5", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-fable-5", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-opus-4-8", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-haiku-4.5", + contextWindow: 200_000, + supportsVision: true, + }), + // Google Gemini + chatModel({ + id: "google/gemini-3.7-flash", + contextWindow: 1_048_576, + supportsVision: true, + }), + chatModel({ + id: "google/gemini-3.5-flash", + contextWindow: 1_048_576, + supportsVision: true, + }), + chatModel({ + id: "google/gemini-3.5-flash-lite", + contextWindow: 1_048_576, + supportsVision: true, + }), + chatModel({ + id: "google/gemini-3.1-pro-preview", + contextWindow: 1_000_000, + supportsVision: true, + }), + // Alibaba Qwen + chatModel({ + id: "alibaba/qwen3.8-max", + contextWindow: 1_000_000, + supportsVision: false, + }), + chatModel({ + id: "alibaba/qwen3.7-max", + contextWindow: 1_000_000, + supportsVision: false, + }), + chatModel({ + id: "alibaba/qwen3.6-flash", + contextWindow: 1_000_000, + supportsVision: false, + }), + chatModel({ + id: "alibaba/qwen3-vl-plus", + contextWindow: 262_144, + supportsVision: true, + }), + // Zhipu GLM + chatModel({ + id: "zhipu/glm-5-3", + contextWindow: 1_024_000, + supportsVision: false, + }), + chatModel({ + id: "zhipu/glm-5.2", + contextWindow: 1_000_000, + supportsVision: false, + }), + // Mistral + chatModel({ + id: "mistralai/mistral-large-2512", + contextWindow: 262_144, + supportsVision: false, + }), + chatModel({ + id: "mistralai/mistral-medium-3-5", + contextWindow: 262_144, + supportsVision: false, + }), // Embeddings (verified against `/v1/models`) embeddingModel({ id: "text-embedding-3-small", @@ -169,7 +231,9 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = }), ]); -/** TUI chat-picker order when offline. Verified ids only. */ +/** + * TUI chat-picker order when offline: catalog order, verified ids only. + */ export const AIMLAPI_CHAT_MODEL_ORDER: readonly string[] = [ "openai/gpt-5.5-2026-04-23", "openai/gpt-5.4-2026-03-05", @@ -179,11 +243,30 @@ export const AIMLAPI_CHAT_MODEL_ORDER: readonly string[] = [ "openai/gpt-oss-20b", "x-ai/grok-4-3", "x-ai/grok-4-fast-reasoning", + "x-ai/grok-4-6", "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", "moonshot/kimi-k2-7-code", + "moonshot/kimi-k3", "bytedance/dola-seed-2-0-pro", "minimax/minimax-m3", + "anthropic/claude-opus-5", + "anthropic/claude-sonnet-5", + "anthropic/claude-fable-5", + "anthropic/claude-opus-4-8", + "anthropic/claude-haiku-4.5", + "google/gemini-3.7-flash", + "google/gemini-3.5-flash", + "google/gemini-3.5-flash-lite", + "google/gemini-3.1-pro-preview", + "alibaba/qwen3.8-max", + "alibaba/qwen3.7-max", + "alibaba/qwen3.6-flash", + "alibaba/qwen3-vl-plus", + "zhipu/glm-5-3", + "zhipu/glm-5.2", + "mistralai/mistral-large-2512", + "mistralai/mistral-medium-3-5", ]; /** diff --git a/src/llm/provider/aimlapi/aimlapi-provider.test.ts b/src/llm/provider/aimlapi/aimlapi-provider.test.ts index 9c19c0d6..40218cd2 100644 --- a/src/llm/provider/aimlapi/aimlapi-provider.test.ts +++ b/src/llm/provider/aimlapi/aimlapi-provider.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { AimlapiProvider, + buildAimlapiAttributionHeaders, DEFAULT_AIMLAPI_BASE, normalizeAimlapiBaseUrl, } from "./aimlapi-provider.js"; @@ -28,6 +29,10 @@ describe("AimlapiProvider", () => { expect(headers.get("authorization")).toBe("Bearer test-key"); expect(headers.get("http-referer")).toBeNull(); expect(headers.get("x-title")).toBeNull(); + expect(headers.get("x-aimlapi-source")).toBe("agent/atomic-agent"); + // No partner / referral id: the operator's own key must not carry + // an attribution tag they never chose. + expect(headers.get("x-aimlapi-partner-id")).toBeNull(); return new Response( JSON.stringify({ id: "gen-1", @@ -61,6 +66,26 @@ describe("AimlapiProvider", () => { expect(fetchImpl).toHaveBeenCalledOnce(); }); + it("sends no partner id, even when AIMLAPI_PARTNER_ID is set", () => { + // The env var was the override for a default that no longer exists. + // Honouring it would keep the tracker alive for anyone who had + // already set it, which is the one case where it is most likely to + // still be pointing somewhere. + const previous = process.env.AIMLAPI_PARTNER_ID; + process.env.AIMLAPI_PARTNER_ID = "part_test123"; + try { + const headers = buildAimlapiAttributionHeaders(); + expect(headers["X-AIMLAPI-Partner-ID"]).toBeUndefined(); + expect(headers).toEqual({ "X-AIMLAPI-Source": "agent/atomic-agent" }); + } finally { + if (previous === undefined) { + delete process.env.AIMLAPI_PARTNER_ID; + } else { + process.env.AIMLAPI_PARTNER_ID = previous; + } + } + }); + it("normalises a base URL with a trailing /v1 to avoid /v1/v1", async () => { const fetchImpl = vi.fn(async (url: string) => { expect(url).toBe("https://api.aimlapi.com/v1/chat/completions"); diff --git a/src/llm/provider/aimlapi/aimlapi-provider.ts b/src/llm/provider/aimlapi/aimlapi-provider.ts index bf568441..b67a4711 100644 --- a/src/llm/provider/aimlapi/aimlapi-provider.ts +++ b/src/llm/provider/aimlapi/aimlapi-provider.ts @@ -6,14 +6,33 @@ export const DEFAULT_AIMLAPI_BASE = "https://api.aimlapi.com"; /** Strips a trailing `/v1` so paths are not doubled (`/v1/v1/...`). */ export { normalizeOpenAiBaseUrl as normalizeAimlapiBaseUrl } from "../openai/normalize-openai-base-url.js"; +/** + * Identifies the client, and nothing else. + * + * There was a `X-AIMLAPI-Partner-ID` beside this, defaulting to a + * hardcoded partner id so that every request from every install was + * credited to one account's rebate program. That is a revenue-attribution + * tracker riding on the operator's own API key, switched on by default, + * for a party the operator never chose — and an agent that ships one + * without asking has spent trust it will need later for things that + * matter more. + * + * What stays is the product name. `X-AIMLAPI-Source` is the same thing a + * User-Agent is: it tells the service which client is calling so the + * service can debug it, and it says nothing about who should be paid. + */ +export function buildAimlapiAttributionHeaders(): Record { + return { "X-AIMLAPI-Source": "agent/atomic-agent" }; +} + export type AimlapiProviderOptions = Omit & { baseUrl?: string; }; /** * AI/ML API (aimlapi.com) is OpenAI-compatible; this thin wrapper sets - * the default base URL. Unlike OpenRouter, no attribution headers are - * required by the service. + * the default base URL and identifies the client. No partner or + * referral id is attached — see `buildAimlapiAttributionHeaders`. */ export class AimlapiProvider extends OpenAiProvider { constructor(options: AimlapiProviderOptions) { @@ -23,6 +42,7 @@ export class AimlapiProvider extends OpenAiProvider { // OpenAiProvider normalizes the base URL. baseUrl: options.baseUrl ?? DEFAULT_AIMLAPI_BASE, defaultChatModel: options.defaultChatModel, + headers: { ...buildAimlapiAttributionHeaders(), ...options.headers }, }); } } diff --git a/src/llm/provider/catalog-for-provider.test.ts b/src/llm/provider/catalog-for-provider.test.ts new file mode 100644 index 00000000..b2589f66 --- /dev/null +++ b/src/llm/provider/catalog-for-provider.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { catalogForProvider } from "./catalog-for-provider.js"; +import { resolveModel } from "./model-resolver.js"; +import type { LlmProviderConfigEntry } from "./registry/provider-types.js"; + +const entry = ( + kind: string, + extra: Partial = {}, +): LlmProviderConfigEntry => ({ id: kind, kind, ...extra }); + +describe("catalogForProvider", () => { + it("returns a populated catalog for the aggregators", () => { + expect(catalogForProvider(entry("openrouter")).size).toBeGreaterThan(0); + expect(catalogForProvider(entry("aimlapi")).size).toBeGreaterThan(0); + }); + + it("returns an empty catalog for providers that ship none", () => { + for (const kind of ["llama-server", "openai-compatible", "gemini"]) { + expect(catalogForProvider(entry(kind)).size).toBe(0); + } + }); + + it("returns an empty catalog for an unknown kind", () => { + expect(catalogForProvider(entry("some-future-provider")).size).toBe(0); + }); +}); + +describe("resolveModel with a provider catalog", () => { + const openrouter = entry("openrouter"); + const [catalogId] = [...catalogForProvider(openrouter).keys()]; + + it("prices a catalog model that carries no userModels entry", () => { + const resolved = resolveModel( + openrouter, + catalogId, + catalogForProvider(openrouter), + ); + + expect(resolved.source).toBe("catalog"); + expect(resolved.pricing).toBeDefined(); + }); + + it("reports no pricing for the same model without the catalog", () => { + // The pre-fix call shape: `resolveModel` defaults to an empty map, so + // an unpriced cloud model fell through to DEFAULT_CHAT and `cost_usd` + // was never emitted. + const resolved = resolveModel(openrouter, catalogId); + + expect(resolved.source).not.toBe("catalog"); + expect(resolved.pricing).toBeUndefined(); + }); + + it("keeps hand-configured pricing ahead of the catalog", () => { + const priced = entry("openrouter", { + userModels: [ + { + id: catalogId, + kind: "chat", + pricing: { input: 1.25, output: 4.5 }, + }, + ], + }); + + const resolved = resolveModel( + priced, + catalogId, + catalogForProvider(priced), + ); + + expect(resolved.source).toBe("user"); + expect(resolved.pricing).toEqual({ input: 1.25, output: 4.5 }); + }); + + it("leaves local runners unpriced", () => { + const local = entry("llama-server"); + const resolved = resolveModel( + local, + "some-local.gguf", + catalogForProvider(local), + ); + + expect(resolved.pricing).toBeUndefined(); + }); +}); diff --git a/src/llm/provider/catalog-for-provider.ts b/src/llm/provider/catalog-for-provider.ts new file mode 100644 index 00000000..f5a9ac93 --- /dev/null +++ b/src/llm/provider/catalog-for-provider.ts @@ -0,0 +1,34 @@ +import type { LlmProviderConfigEntry } from "./registry/provider-types.js"; +import { AIMLAPI_MODELS_CATALOG } from "./aimlapi/aimlapi-models-catalog.js"; +import type { ModelCatalogEntry } from "./model-resolver.js"; +import { OPENROUTER_MODELS_CATALOG } from "./openrouter/openrouter-models-catalog.js"; + +const EMPTY: ReadonlyMap = new Map(); + +/** + * The bundled model catalog for a provider, or an empty map when the + * provider ships none. + * + * Only the two aggregators carry one. `llama-server` runs local weights + * that have no list price, and `openai-compatible` / `gemini` point at + * whatever endpoint the operator configured, so neither has a catalog to + * look a model up in. An empty map is the honest answer for those: it + * leaves `resolveModel` on its `userModels` -> defaults path, which is + * where a hand-configured price would live. + * + * These are the static snapshots. `refreshOpenRouterChatCatalogFromApi` + * and its aimlapi counterpart fetch live rows, including current prices, + * but only the TUI model picker holds that cache today. + */ +export function catalogForProvider( + entry: LlmProviderConfigEntry, +): ReadonlyMap { + switch (entry.kind) { + case "openrouter": + return OPENROUTER_MODELS_CATALOG; + case "aimlapi": + return AIMLAPI_MODELS_CATALOG; + default: + return EMPTY; + } +} diff --git a/src/llm/provider/completion-types.ts b/src/llm/provider/completion-types.ts index d6cd5644..6acc50d2 100644 --- a/src/llm/provider/completion-types.ts +++ b/src/llm/provider/completion-types.ts @@ -122,4 +122,13 @@ export interface StreamFinalResult { finishReason?: string | null; usage?: CompletionUsage; modelId?: string | null; + /** + * Whether the underlying transport actually delivered a trustworthy + * terminal signal — an explicit provider `finish_reason` on any chunk, + * or a parser-recognized terminal event (e.g. `[DONE]`) — before the + * stream ended. `false` (or absent) means the connection just closed + * (bare EOF / read error) without either: not asserted, so callers must + * not treat an absent value as confirmation of a clean completion. + */ + terminalObserved?: boolean; } diff --git a/src/llm/provider/format-model-details.ts b/src/llm/provider/format-model-details.ts new file mode 100644 index 00000000..daa4cbcf --- /dev/null +++ b/src/llm/provider/format-model-details.ts @@ -0,0 +1,57 @@ +import type { ModelCatalogEntry } from "./model-resolver.js"; + +/** + * How a catalog row is described to a human: context window, price per + * 1M tokens, capability summary. + * + * Lifted out of `src/tui/providers/providers-model-options.ts` so the + * `models search` CLI prints the same strings as the TUI picker without + * a CLI -> TUI import. `src/llm/` is the layer both frontends already + * depend on. + */ + +export function formatContextWindow(tokens: number): string { + if (tokens >= 1_000_000) { + const millions = tokens / 1_000_000; + return `${formatCompactNumber(millions)}M`; + } + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return `${tokens}`; +} + +export function formatTokenPrice( + modelId: string, + pricing: ModelCatalogEntry["pricing"], +): string { + if (!pricing) return "price unknown"; + if (modelId === "openrouter/auto") return "routed"; + if (pricing.input === 0 && pricing.output === 0) return "free"; + return `$${formatPrice(pricing.input)}/$${formatPrice(pricing.output)}`; +} + +export function formatEmbeddingTokenPrice( + pricing: ModelCatalogEntry["pricing"], +): string { + if (!pricing) return "$?"; + if (pricing.input === 0) return "free"; + return `$${formatPrice(pricing.input)}`; +} + +export function formatCapabilitySummary(entry: ModelCatalogEntry): string { + const modality = entry.supportsVision ? "vision" : "text"; + const tools = entry.supportsTools === "none" ? null : "tools"; + const cache = entry.supportsPromptCache ? "cache" : null; + return [modality, tools, cache].filter(Boolean).join(" · "); +} + +function formatCompactNumber(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + +export function formatPrice(value: number): string { + if (value === 0) return "0"; + if (value < 1) return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); + return Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); +} diff --git a/src/llm/provider/index.ts b/src/llm/provider/index.ts index dd57217b..2b7c1b7b 100644 --- a/src/llm/provider/index.ts +++ b/src/llm/provider/index.ts @@ -29,6 +29,12 @@ export { resolveModel, type ResolvedModel } from "./model-resolver.js"; export { CostAccumulator, type CostAccumulatorSnapshot } from "./cost-accumulator.js"; export { OpenAiProvider, type OpenAiProviderOptions } from "./openai/index.js"; export { OpenRouterProvider } from "./openrouter/index.js"; +export { + CLAUDE_CLI_CHAT_MODELS, + CLAUDE_CLI_DEFAULT_CHAT_MODEL, + SubscriptionCliProvider, + type SubscriptionCliProviderOptions, +} from "./subscription-cli/index.js"; export { GeminiProvider, type GeminiProviderOptions, diff --git a/src/llm/provider/llama-server/llama-server-vision.ts b/src/llm/provider/llama-server/llama-server-vision.ts index 4ca5e258..c4ebd9a8 100644 --- a/src/llm/provider/llama-server/llama-server-vision.ts +++ b/src/llm/provider/llama-server/llama-server-vision.ts @@ -1,4 +1,5 @@ import { getConfig } from "../../../config/index.js"; +import { llamaEndpointUrl } from "../../llama-endpoint-url.js"; import type { ModelProfile } from "../../model-profile.js"; import type { ProviderCapabilities } from "../llm-provider.js"; import type { VisionRequest, VisionResult } from "../llm-provider.js"; @@ -94,7 +95,7 @@ export async function describeImageViaLlamaServer(opts: { } const config = getConfig(); - const url = new URL("/v1/chat/completions", opts.baseUrl).toString(); + const url = llamaEndpointUrl(opts.baseUrl, "/v1/chat/completions"); const userContent: Array< | { type: "image_url"; image_url: { url: string } } diff --git a/src/llm/provider/model-catalog-entry.ts b/src/llm/provider/model-catalog-entry.ts new file mode 100644 index 00000000..8ed65410 --- /dev/null +++ b/src/llm/provider/model-catalog-entry.ts @@ -0,0 +1,65 @@ +import type { ModelCatalogEntry } from "./model-resolver.js"; + +/** + * Row builders shared by the bundled provider catalogs. + * + * OpenRouter and aimlapi both ship a static `ReadonlyMap` and both used to declare their own private + * `chatModel` / `embeddingModel` helpers. The two copies had already + * drifted — one defaulted `supportsTools` to `"parallel"`, the other + * hard-coded it — so the shared version keeps every field explicit and + * lets each catalog omit what its API genuinely does not publish + * (`pricing` is absent from the aimlapi payload, so aimlapi rows carry + * no price rather than a made-up one). + */ + +export type ChatModelSpec = { + readonly id: string; + readonly contextWindow: number; + readonly supportsVision: boolean; + readonly supportsTools?: "none" | "basic" | "parallel" | "strict"; + readonly supportsPromptCache?: boolean; + readonly pricing?: { readonly input: number; readonly output: number }; +}; + +export type EmbeddingModelSpec = { + readonly id: string; + readonly contextWindow: number; + readonly dim?: number; + readonly pricing?: { readonly input: number; readonly output: number }; +}; + +export type CatalogRow = readonly [string, ModelCatalogEntry]; + +export function chatModel(spec: ChatModelSpec): CatalogRow { + return [ + spec.id, + { + id: spec.id, + kind: "chat", + contextWindow: spec.contextWindow, + supportsVision: spec.supportsVision, + supportsTools: spec.supportsTools ?? "parallel", + supportsPromptCache: spec.supportsPromptCache ?? false, + reasoningFormat: "none", + ...(spec.pricing ? { pricing: spec.pricing } : {}), + }, + ]; +} + +export function embeddingModel(spec: EmbeddingModelSpec): CatalogRow { + return [ + spec.id, + { + id: spec.id, + kind: "embedding", + contextWindow: spec.contextWindow, + ...(spec.dim !== undefined ? { dim: spec.dim } : {}), + supportsVision: false, + supportsTools: "none", + supportsPromptCache: false, + reasoningFormat: "none", + ...(spec.pricing ? { pricing: spec.pricing } : {}), + }, + ]; +} diff --git a/src/llm/provider/model-search.test.ts b/src/llm/provider/model-search.test.ts new file mode 100644 index 00000000..0c4130c2 --- /dev/null +++ b/src/llm/provider/model-search.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; + +import type { ModelCatalogEntry } from "./model-resolver.js"; +import { + modelSearchTags, + searchModelIds, + searchModels, + splitQueryTerms, +} from "./model-search.js"; + +function entry(over: Partial = {}): ModelCatalogEntry { + return { + id: over.id ?? "x", + kind: "chat", + contextWindow: 128_000, + supportsVision: false, + supportsTools: "parallel", + supportsPromptCache: false, + reasoningFormat: "none", + ...over, + } as ModelCatalogEntry; +} + +const CATALOG: readonly { id: string; entry: ModelCatalogEntry }[] = [ + { + id: "anthropic/claude-opus-5", + entry: entry({ + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 25 }, + }), + }, + { + id: "anthropic/claude-haiku-4.5", + entry: entry({ + contextWindow: 200_000, + supportsVision: true, + pricing: { input: 0.8, output: 4 }, + }), + }, + { + id: "qwen/qwen3.6-flash", + entry: entry({ contextWindow: 1_000_000, pricing: { input: 0.19, output: 1.13 } }), + }, + { + id: "openai/gpt-oss-20b", + entry: entry({ contextWindow: 131_072, pricing: { input: 0, output: 0 } }), + }, +]; + +const ids = (rows: readonly { id: string }[]): readonly string[] => + rows.map((row) => row.id); + +describe("splitQueryTerms", () => { + it("lowercases, trims and drops empty terms", () => { + expect(splitQueryTerms(" Claude VISION ")).toEqual(["claude", "vision"]); + expect(splitQueryTerms(" ")).toEqual([]); + }); +}); + +describe("searchModels", () => { + it("returns everything, in order, for an empty query", () => { + expect(searchModels(CATALOG, "")).toBe(CATALOG); + expect(searchModels(CATALOG, " ")).toBe(CATALOG); + }); + + it("keeps the old substring behaviour for a single term", () => { + expect(ids(searchModels(CATALOG, "claude"))).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + expect(ids(searchModels(CATALOG, "OPUS"))).toEqual(["anthropic/claude-opus-5"]); + }); + + it("ANDs multiple terms instead of matching the raw string", () => { + // "claude vision" is not a substring of any id — this is the query + // the old single-`includes` filter answered with an empty list. + expect(ids(searchModels(CATALOG, "claude vision"))).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + expect(ids(searchModels(CATALOG, "claude 1m"))).toEqual([ + "anthropic/claude-opus-5", + ]); + expect(searchModels(CATALOG, "claude qwen")).toEqual([]); + }); + + it("matches capability and price tags off the catalog entry", () => { + expect(ids(searchModels(CATALOG, "free"))).toEqual(["openai/gpt-oss-20b"]); + // The tag follows the rendered price, so a router row is "routed", + // never "free", and never "cheap" either. + const auto = [ + { id: "openrouter/auto", entry: entry({ pricing: { input: 0, output: 0 } }) }, + ]; + expect(ids(searchModels(auto, "routed"))).toEqual(["openrouter/auto"]); + expect(searchModels(auto, "free")).toEqual([]); + expect(searchModels(auto, "cheap")).toEqual([]); + expect(ids(searchModels(CATALOG, "cache"))).toEqual(["anthropic/claude-opus-5"]); + expect(ids(searchModels(CATALOG, "cheap"))).toEqual([ + "anthropic/claude-haiku-4.5", + "qwen/qwen3.6-flash", + ]); + }); + + it("matches the vendor prefix", () => { + expect(ids(searchModels(CATALOG, "anthropic"))).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + }); + + it("ranks exact ids and prefixes above buried substrings", () => { + const rows = [ + { id: "vendor/needs-opus-handling", entry: entry() }, + { id: "opus", entry: entry() }, + { id: "opus-mini", entry: entry() }, + ]; + expect(ids(searchModels(rows, "opus"))).toEqual([ + "opus", + "opus-mini", + "vendor/needs-opus-handling", + ]); + }); + + it("keeps input order between equally ranked rows", () => { + // The catalogs are hand-ordered and this runs on every keystroke, so + // equal matches must not shuffle under the cursor. + const rows = [ + { id: "a/model-one", entry: entry() }, + { id: "a/model-two", entry: entry() }, + { id: "a/model-three", entry: entry() }, + ]; + expect(ids(searchModels(rows, "model"))).toEqual([ + "a/model-one", + "a/model-two", + "a/model-three", + ]); + }); + + it("falls back to a subsequence match, ranked last", () => { + const rows = [ + { id: "openai/gpt-oss-20b", entry: entry() }, + { id: "vendor/gpt", entry: entry() }, + ]; + // "gpto" is nobody's substring; it is a subsequence of the first id. + expect(ids(searchModels(rows, "gpto"))).toEqual(["openai/gpt-oss-20b"]); + }); + + it("still matches ids with no catalog entry, on the id alone", () => { + const rows = [{ id: "some-local-model" }, { id: "other" }]; + expect(ids(searchModels(rows, "local"))).toEqual(["some-local-model"]); + // No entry means no tags, so a capability term cannot match. + expect(searchModels(rows, "vision")).toEqual([]); + }); +}); + +describe("context window terms", () => { + // Ids are deliberately digit-free: a row must match on its window, not + // because "1m" happens to be a substring or subsequence of its id. + const WINDOWS: readonly { id: string; entry: ModelCatalogEntry }[] = [ + { id: "vendor/alpha", entry: entry({ contextWindow: 1_000_000 }) }, + { id: "vendor/bravo", entry: entry({ contextWindow: 1_048_576 }) }, + { id: "vendor/charlie", entry: entry({ contextWindow: 1_050_000 }) }, + { id: "vendor/delta", entry: entry({ contextWindow: 1_310_720 }) }, + { id: "vendor/echo", entry: entry({ contextWindow: 2_000_000 }) }, + { id: "vendor/foxtrot", entry: entry({ contextWindow: 131_072 }) }, + { id: "vendor/golf", entry: entry({ contextWindow: 204_800 }) }, + ]; + + it("`1m` finds every roughly-1M window, not only the ones rendered as 1m", () => { + // The bug this pins: the tag used to be the display string alone, so + // only the exactly-1_000_000 row answered to `1m` and an operator + // concluded the 1.0m/1.1m/1.3m rows had no million-token variant. + expect(ids(searchModels(WINDOWS, "1m"))).toEqual([ + "vendor/alpha", + "vendor/bravo", + "vendor/charlie", + "vendor/delta", + ]); + // Floor, not round, and a bucket rather than a `>=` filter: nothing + // under 1M leaks in and the 2M row answers to `2m` alone. This is the + // boundary the README documents, so pin it by name. + expect(ids(searchModels(WINDOWS, "1m"))).not.toContain("vendor/echo"); + expect(ids(searchModels(WINDOWS, "2m"))).toEqual(["vendor/echo"]); + }); + + it("keeps answering to the string the row displays", () => { + expect(ids(searchModels(WINDOWS, "1.0m"))).toEqual(["vendor/bravo"]); + expect(ids(searchModels(WINDOWS, "1.1m"))).toEqual(["vendor/charlie"]); + expect(ids(searchModels(WINDOWS, "1.3m"))).toEqual(["vendor/delta"]); + expect(ids(searchModels(WINDOWS, "131k"))).toEqual(["vendor/foxtrot"]); + expect(ids(searchModels(WINDOWS, "205k"))).toEqual(["vendor/golf"]); + }); + + it("answers to the binary reading of a power-of-two window", () => { + // 131_072 is sold as 128k and 204_800 as 200k; decimal rounding is + // what hid them. + expect(ids(searchModels(WINDOWS, "128k"))).toEqual(["vendor/foxtrot"]); + expect(ids(searchModels(WINDOWS, "200k"))).toEqual(["vendor/golf"]); + // A window that was never binary keeps only its decimal reading. + const decimal = [{ id: "vendor/hotel", entry: entry({ contextWindow: 200_000 }) }]; + expect(ids(searchModels(decimal, "200k"))).toEqual(["vendor/hotel"]); + expect(searchModels(decimal, "195k")).toEqual([]); + }); +}); + +describe("modelSearchTags", () => { + it("derives tags from the entry and nothing else", () => { + expect(modelSearchTags(undefined)).toEqual([]); + expect( + modelSearchTags( + entry({ + contextWindow: 200_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0, output: 0 }, + }), + ), + ).toEqual(["chat", "vision", "tools", "cache", "200k", "free"]); + }); + + it("tags a window as displayed, floored to the whole unit, and in binary", () => { + // The first three tags are kind / modality / tools; window forms follow. + const windowTags = (contextWindow: number): readonly string[] => + modelSearchTags(entry({ contextWindow })).slice(3); + expect(windowTags(1_000_000)).toEqual(["1m"]); + expect(windowTags(1_048_576)).toEqual(["1.0m", "1m"]); + expect(windowTags(1_310_720)).toEqual(["1.3m", "1m"]); + expect(windowTags(131_072)).toEqual(["131k", "128k"]); + expect(windowTags(204_800)).toEqual(["205k", "204k", "200k"]); + expect(windowTags(200_000)).toEqual(["200k"]); + // Below a thousand there is no shorthand to normalise. + expect(windowTags(512)).toEqual(["512"]); + }); +}); + +describe("searchModelIds", () => { + it("searches plain ids and uses the lookup for metadata when given", () => { + const all = CATALOG.map((row) => row.id); + const lookup = (id: string): ModelCatalogEntry | undefined => + CATALOG.find((row) => row.id === id)?.entry; + expect(searchModelIds(all, "vision", lookup)).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + // Without the lookup the same query has no metadata to match on. + expect(searchModelIds(all, "vision")).toEqual([]); + expect(searchModelIds(all, "")).toBe(all); + }); +}); diff --git a/src/llm/provider/model-search.ts b/src/llm/provider/model-search.ts new file mode 100644 index 00000000..12095748 --- /dev/null +++ b/src/llm/provider/model-search.ts @@ -0,0 +1,197 @@ +import { + formatContextWindow, + formatTokenPrice, +} from "./format-model-details.js"; +import type { ModelCatalogEntry } from "./model-resolver.js"; + +/** + * Ranked, multi-term search over model ids and their catalog metadata. + * + * The picker used to filter with one case-insensitive `includes` over + * the id, which is fine for 18 rows and useless for the 300-400 the + * live OpenRouter catalog returns: "the cheap Claude with vision" is + * not a substring of anything. Here a query is split into terms, every + * term has to match (AND), and a term may match the id, the vendor, or + * a capability tag derived from the catalog entry — so `claude vision`, + * `1m cache` and `free tools` all narrow the list. + * + * Matches are ranked, best first, and equal ranks keep input order: the + * bundled catalogs are hand-ordered and the picker re-runs this on + * every keystroke, so rows must not jitter between presses. + */ + +export type ModelSearchItem = { + readonly id: string; + readonly entry?: ModelCatalogEntry | undefined; +}; + +/** Metadata lookup for callers that hold ids and a catalog separately. */ +export type ModelEntryLookup = (id: string) => ModelCatalogEntry | undefined; + +/** + * Per-term match strength. Summed across terms into the row score, so a + * row matching one term exactly and another loosely still outranks a row + * that matches both loosely. + */ +const RANK = { + exactId: 6, + idPrefix: 5, + vendor: 4, + wordStart: 3, + substring: 2, + tag: 2, + subsequence: 1, + none: 0, +} as const; + +export function splitQueryTerms(query: string): readonly string[] { + return query.trim().toLowerCase().split(/\s+/).filter(Boolean); +} + +/** + * Searchable tags for a row: what an operator would type that is not + * part of the id. Everything here is derived from the catalog entry, so + * a row without metadata simply has fewer ways to be found. + */ +export function modelSearchTags( + entry: ModelCatalogEntry | undefined, + modelId?: string, +): readonly string[] { + if (!entry) return []; + const tags: string[] = [entry.kind]; + tags.push(entry.supportsVision ? "vision" : "text"); + if (entry.supportsTools !== "none") tags.push("tools"); + if (entry.supportsPromptCache) tags.push("cache"); + if (entry.contextWindow > 0) tags.push(...contextWindowTags(entry.contextWindow)); + // Price tags mirror what the row displays, so searching for what you + // can see works: `openrouter/auto` renders as "routed", not "free", + // even though its list price is zero. + const priceLabel = formatTokenPrice(modelId ?? entry.id, entry.pricing); + if (priceLabel === "free" || priceLabel === "routed") tags.push(priceLabel); + else if (entry.pricing && entry.pricing.input > 0 && entry.pricing.input < 1) { + tags.push("cheap"); + } + return tags; +} + +/** + * Every shorthand an operator would type for one context window. + * + * The display string alone is not enough, because it is a rounded + * decimal rendering and tag matching is exact: `formatContextWindow` + * writes 1_048_576 as "1.0m" and 131_072 as "131k", so `1m` and `128k` — + * the numbers those vendors actually advertise — would drop the row. + * The formatter stays as it is; the extra forms ride alongside it. + * + * Three forms per window, deduped: + * + * 1. the display string, so searching for what the row shows works; + * 2. the whole-unit **floor** in that same unit — 1_310_720 -> `1m`, + * 1_050_000 -> `1m`, 202_752 -> `202k`. Floor rather than round, + * because a size term names the bucket a window falls in: `1m` means + * "a window in the millions", so it must find every row from 1M up to + * 2M — a 2M row answers to `2m`, not to `1m` — and must not find a + * 950k row that would round up to it; + * 3. the **binary** reading, when the window is an exact multiple of + * 1024 (1024² above a million) — 131_072 -> `128k`, 204_800 -> + * `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`. Those windows are + * power-of-two sized and are sold by the binary number; decimal + * rounding is what hides it. The exact-multiple guard keeps the + * reading off windows that were never binary (200_000 stays `200k`). + * + * Raw token counts (`131072`) are deliberately not tags: no surface + * renders one, so nobody reads it off a row to type it back. + */ +function contextWindowTags(tokens: number): readonly string[] { + const tags = [formatContextWindow(tokens).toLowerCase()]; + const add = (tag: string): void => { + if (!tags.includes(tag)) tags.push(tag); + }; + if (tokens >= 1_000_000) { + add(`${Math.floor(tokens / 1_000_000)}m`); + if (tokens % 1_048_576 === 0) add(`${tokens / 1_048_576}m`); + } else if (tokens >= 1_000) { + add(`${Math.floor(tokens / 1_000)}k`); + if (tokens % 1_024 === 0) add(`${tokens / 1_024}k`); + } + return tags; +} + +function rankTerm( + term: string, + id: string, + vendor: string, + tags: readonly string[], +): number { + if (id === term) return RANK.exactId; + if (id.startsWith(term)) return RANK.idPrefix; + if (vendor === term || vendor.startsWith(term)) return RANK.vendor; + const at = id.indexOf(term); + if (at >= 0) { + // A term that starts a word ("opus" in "claude-opus-5") is a better + // hit than one buried mid-token ("pus"). + const before = at === 0 ? "" : id[at - 1]!; + return at === 0 || /[^a-z0-9]/.test(before) ? RANK.wordStart : RANK.substring; + } + if (tags.includes(term)) return RANK.tag; + return isSubsequence(term, id) ? RANK.subsequence : RANK.none; +} + +/** Typo tolerance: every character of `term`, in order, somewhere in `id`. */ +function isSubsequence(term: string, id: string): boolean { + let i = 0; + for (const ch of id) { + if (ch === term[i]) i += 1; + if (i === term.length) return true; + } + return term.length === 0; +} + +export function scoreModel( + item: ModelSearchItem, + terms: readonly string[], +): number { + const id = item.id.toLowerCase(); + const slash = id.indexOf("/"); + const vendor = slash > 0 ? id.slice(0, slash) : ""; + const tags = modelSearchTags(item.entry, item.id); + let total = 0; + for (const term of terms) { + const rank = rankTerm(term, id, vendor, tags); + // AND semantics: one unmatched term drops the row entirely. + if (rank === RANK.none) return RANK.none; + total += rank; + } + return total; +} + +/** + * Rows matching `query`, best match first. An empty query returns + * `items` untouched — the caller renders the full catalog. + */ +export function searchModels( + items: readonly T[], + query: string, +): readonly T[] { + const terms = splitQueryTerms(query); + if (terms.length === 0) return items; + const scored: { item: T; score: number; index: number }[] = []; + items.forEach((item, index) => { + const score = scoreModel(item, terms); + if (score > 0) scored.push({ item, score, index }); + }); + scored.sort((a, b) => b.score - a.score || a.index - b.index); + return scored.map((row) => row.item); +} + +/** `searchModels` for callers that hold plain ids plus an optional catalog. */ +export function searchModelIds( + ids: readonly string[], + query: string, + lookup?: ModelEntryLookup, +): readonly string[] { + const terms = splitQueryTerms(query); + if (terms.length === 0) return ids; + const items = ids.map((id) => ({ id, entry: lookup?.(id) })); + return searchModels(items, query).map((item) => item.id); +} diff --git a/src/llm/provider/openai/ascii-header-guard.test.ts b/src/llm/provider/openai/ascii-header-guard.test.ts new file mode 100644 index 00000000..6cc68632 --- /dev/null +++ b/src/llm/provider/openai/ascii-header-guard.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import { assertAsciiApiKey, isAsciiOnly } from "./ascii-header-guard.js"; +import { buildOpenAiAuthHeaders } from "./openai-auth-headers.js"; +import { + buildOpenAiHeaders, + OpenAiHttpError, + openAiFetch, +} from "./openai-http.js"; + +describe("isAsciiOnly", () => { + it("accepts plain ASCII keys and the empty string", () => { + expect(isAsciiOnly("")).toBe(true); + expect(isAsciiOnly("sk-abc123_-.")).toBe(true); + // Every printable ASCII byte is allowed in a header value. + expect(isAsciiOnly("Bearer sk-XYZ~!@#$%^&*()")).toBe(true); + }); + + it("rejects a key with a character above the ASCII range", () => { + expect(isAsciiOnly("sk-т")).toBe(false); // Cyrillic "т" (U+0442) + expect(isAsciiOnly("sk-café")).toBe(false); // "é" (U+00E9) + expect(isAsciiOnly("sk-“smart”")).toBe(false); // curly quotes + }); +}); + +describe("assertAsciiApiKey", () => { + it("returns an ASCII key unchanged", () => { + expect(assertAsciiApiKey("sk-plain")).toBe("sk-plain"); + }); + + it("throws a clear, actionable error for a non-ASCII key", () => { + expect(() => assertAsciiApiKey("sk-т")).toThrow( + "API key contains non-ASCII characters. Use a plain ASCII key.", + ); + }); +}); + +describe("buildOpenAiAuthHeaders header guard", () => { + it("guards the named api-key header path, not just the bearer default", () => { + // Anthropic-style presets carry the key in `x-api-key`; the assert + // sits in the one builder both paths share, so this throws too. + expect(() => + buildOpenAiAuthHeaders("sk-т", { apiKeyHeader: "x-api-key" }), + ).toThrow(/non-ASCII/); + }); + + it("passes an ASCII key through to the named header", () => { + const headers = buildOpenAiAuthHeaders("sk-ok", { apiKeyHeader: "x-api-key" }); + expect(headers["x-api-key"]).toBe("sk-ok"); + }); +}); + +describe("buildOpenAiHeaders header guard", () => { + const deps = { + baseUrl: "http://127.0.0.1:9931", + extraHeaders: {}, + requestTimeoutMs: 1000, + fetchImpl: fetch, + label: "local", + }; + + it("does not throw a raw ByteString error for a non-ASCII key", () => { + // The header building must fail with our named error, never the + // opaque "Cannot convert argument to a ByteString" from `fetch`. + let caught: unknown; + try { + buildOpenAiHeaders({ ...deps, apiKey: "sk-т" }, false); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain("non-ASCII"); + expect((caught as Error).message).not.toContain("ByteString"); + }); + + it("builds an Authorization header for an ASCII key", () => { + const headers = buildOpenAiHeaders({ ...deps, apiKey: "sk-ok" }, false); + expect(headers.authorization).toBe("Bearer sk-ok"); + }); + + it("omits Authorization entirely for a keyless server", () => { + const headers = buildOpenAiHeaders({ ...deps, apiKey: "" }, false); + expect(headers.authorization).toBeUndefined(); + }); + + it("classifies a non-ASCII key as a 401 at request time, before any fetch", async () => { + // A legacy bad key in .env reaches openAiFetch directly. It must fail + // as an auth error — deterministic, unretried, and a fallback chain + // advances past it — with the guard's message intact, not wrapped as + // a network failure. + const fetchImpl = vi.fn(); + let caught: unknown; + try { + await openAiFetch({ ...deps, apiKey: "sk-т", fetchImpl }, "/v1/chat", null, {}, false); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(OpenAiHttpError); + expect((caught as OpenAiHttpError).status).toBe(401); + expect((caught as Error).message).toContain("non-ASCII"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/llm/provider/openai/ascii-header-guard.ts b/src/llm/provider/openai/ascii-header-guard.ts new file mode 100644 index 00000000..f8eb64ef --- /dev/null +++ b/src/llm/provider/openai/ascii-header-guard.ts @@ -0,0 +1,31 @@ +/** + * HTTP header values are byte strings: every character must fit in a + * single byte (0-255). `fetch` enforces this and throws a raw + * `ByteString` conversion error the moment a header value carries a + * character above that range. An API key with a stray non-ASCII + * character (a Cyrillic letter pasted by mistake, a smart quote from a + * doc) is the usual cause, and the raw error names an index and a code + * point rather than the key, so the guards here catch it first and say + * what to do instead. + */ + +/** True when every character of `s` is in the ASCII range (code points 0-127). */ +export function isAsciiOnly(s: string): boolean { + // eslint-disable-next-line no-control-regex + return /^[\x00-\x7f]*$/.test(s); +} + +/** + * Return `apiKey` unchanged when it can be sent in an `Authorization` + * header, or throw a clear error naming the fix. Header values must be + * ASCII, so a non-ASCII key would otherwise blow up inside `fetch` with + * an opaque `ByteString` message that never mentions the key at all. + */ +export function assertAsciiApiKey(apiKey: string): string { + if (!isAsciiOnly(apiKey)) { + throw new Error( + "API key contains non-ASCII characters. Use a plain ASCII key.", + ); + } + return apiKey; +} diff --git a/src/llm/provider/openai/fetch-openai-compat-models.test.ts b/src/llm/provider/openai/fetch-openai-compat-models.test.ts index 500883ae..8d7638fc 100644 --- a/src/llm/provider/openai/fetch-openai-compat-models.test.ts +++ b/src/llm/provider/openai/fetch-openai-compat-models.test.ts @@ -24,7 +24,7 @@ describe("fetchOpenAiCompatModels", () => { expect(ids).toEqual(["Qwen/Qwen3-8B", "zephyr"]); expect(fetchMock.mock.calls[0]?.[0]).toBe("https://vllm.example/v1/models"); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - headers: { Authorization: "Bearer key" }, + headers: { authorization: "Bearer key" }, }); expect(getCachedOpenAiCompatModels("https://vllm.example/", "key")).toEqual(ids); @@ -83,6 +83,46 @@ describe("fetchOpenAiCompatModels", () => { ).toBeUndefined(); }); + it("puts the key in the header the service names, not in Authorization", async () => { + // The blocker this parameter exists for: a service that reads + // `Authorization: Bearer` as an OAuth token rejects an API key sent + // that way, so discovery 401s before the operator ever reaches a + // model list. Nothing in the response shape reveals the cause. + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "claude-opus-5" }] }), + })); + vi.stubGlobal("fetch", fetchMock); + + await fetchOpenAiCompatModels("https://named-header.example", "sk-test", { + apiKeyHeader: "x-api-key", + headers: { "some-version": "2023-06-01" }, + }); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(headers["x-api-key"]).toBe("sk-test"); + expect(headers["some-version"]).toBe("2023-06-01"); + expect(headers.authorization).toBeUndefined(); + }); + + it("still sends mandatory static headers when there is no key", async () => { + // A version header is part of the request contract, not part of the + // credential — dropping it with the key would turn a 401 into a 400. + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "m" }] }), + })); + vi.stubGlobal("fetch", fetchMock); + + await fetchOpenAiCompatModels("https://keyless-static.example", undefined, { + apiKeyHeader: "x-api-key", + headers: { "some-version": "2023-06-01" }, + }); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(headers).toEqual({ "some-version": "2023-06-01" }); + }); + it("throws on a rejected request so callers can fall back to typing", async () => { vi.stubGlobal( "fetch", @@ -93,4 +133,19 @@ describe("fetchOpenAiCompatModels", () => { ).rejects.toThrow("http 401"); expect(getCachedOpenAiCompatModels("https://locked.example")).toBeUndefined(); }); + + it("rejects a non-ASCII key with a readable reason, never a ByteString crash", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "m" }] }), + })); + vi.stubGlobal("fetch", fetchMock); + + // "sk-т" carries a Cyrillic character that cannot sit in a header. + await expect( + fetchOpenAiCompatModels("https://byte.example", "sk-т"), + ).rejects.toThrow(/non-ASCII/); + // The guard fires before the request, so `fetch` never runs. + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/llm/provider/openai/fetch-openai-compat-models.ts b/src/llm/provider/openai/fetch-openai-compat-models.ts index 4c0e94b9..cebd0d9f 100644 --- a/src/llm/provider/openai/fetch-openai-compat-models.ts +++ b/src/llm/provider/openai/fetch-openai-compat-models.ts @@ -4,6 +4,10 @@ * synchronously through the module cache, same shape as the OpenRouter picker. */ +import { + buildOpenAiAuthHeaders, + type OpenAiCompatAuth, +} from "./openai-auth-headers.js"; import { normalizeOpenAiBaseUrl } from "./normalize-openai-base-url.js"; const CACHE_TTL_MS = 60 * 60 * 1000; @@ -45,17 +49,28 @@ export function getCachedOpenAiCompatModelsForBaseUrl( return best?.ids; } -/** Throws on unreachable/unauthorized servers so the caller can fall back to typing. */ +/** + * Throws on unreachable/unauthorized servers so the caller can fall back to typing. + * + * `auth` describes how this endpoint wants credentials presented; both a + * `ProviderPreset` and a saved `UserLlmProviderEntry` satisfy it + * structurally, so callers pass whichever they hold. It is deliberately + * **not** part of the cache key: the header contract is a property of the + * endpoint, so the same base URL always implies the same headers, and + * keying on it would only fragment the cache that the read-only lookups + * (which know a URL and a key, never a header set) share. + */ export async function fetchOpenAiCompatModels( baseUrl: string, apiKey?: string, + auth?: OpenAiCompatAuth, ): Promise { const cached = getCachedOpenAiCompatModels(baseUrl, apiKey); if (cached) return cached; const base = normalizeOpenAiBaseUrl(baseUrl); const res = await fetch(`${base}/v1/models`, { - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + headers: buildOpenAiAuthHeaders(apiKey, auth), signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`http ${res.status}`); diff --git a/src/llm/provider/openai/merge-tool-name.test.ts b/src/llm/provider/openai/merge-tool-name.test.ts new file mode 100644 index 00000000..b9a0ab0f --- /dev/null +++ b/src/llm/provider/openai/merge-tool-name.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { mergeToolName } from "./openai-stream-consumer.js"; + +/** + * Reported as "Anthropic models do not work at all", with: + * + * Turn failed [tool]: tool not registered in this agent: + * replyreplyreplyreplyreplyreplyreplyreplyreplyreplyreply + * + * Arguments stream as fragments and concatenate. A *name* does not — + * the OpenAI contract sends it once, whole — so the accumulator + * appended, which is right for every provider that follows the + * contract and catastrophic for the ones that repeat the full name in + * every delta. The turn died naming a tool nobody had written. + */ +describe("mergeToolName", () => { + it("takes the first name", () => { + expect(mergeToolName("", "reply")).toBe("reply"); + }); + + it("drops a repeat of the whole name", () => { + expect(mergeToolName("reply", "reply")).toBe("reply"); + }); + + it("survives the reported stream verbatim", () => { + // Eleven deltas, each carrying the full name. + let name = ""; + for (let i = 0; i < 11; i++) name = mergeToolName(name, "reply"); + expect(name).toBe("reply"); + }); + + it("still joins genuine fragments", () => { + // A provider that really does split the name must keep working: + // the two cases are distinguishable and both are served. + expect(mergeToolName("os.fs", ".read")).toBe("os.fs.read"); + expect(mergeToolName("re", "ply")).toBe("reply"); + }); + + it("repairs a name that was already doubled before a repeat", () => { + expect(mergeToolName("replyreply", "reply")).toBe("replyreply"); + }); + + it("does not mistake a fragment for a repeat", () => { + // `read` is not a repeat of `os.fs.` — appending is correct. + expect(mergeToolName("os.fs.", "read")).toBe("os.fs.read"); + }); +}); diff --git a/src/llm/provider/openai/openai-auth-headers.ts b/src/llm/provider/openai/openai-auth-headers.ts new file mode 100644 index 00000000..d7d98237 --- /dev/null +++ b/src/llm/provider/openai/openai-auth-headers.ts @@ -0,0 +1,69 @@ +/** + * The one place that decides how an API key is attached to an outgoing + * request for an `openai-compatible` endpoint. + * + * Both request paths — model discovery (`fetch-openai-compat-models.ts`) + * and every chat/embedding call (`openai-http.ts`) — go through this + * function so they cannot drift. They did drift in spirit before: each + * hard-coded `Authorization: Bearer`, which is why a preset for a vendor + * that authenticates any other way could 401 on discovery *and* on every + * subsequent turn with nothing in config able to correct it. + */ + +import { assertAsciiApiKey } from "./ascii-header-guard.js"; + +/** + * How a service wants credentials presented. Both fields are optional and + * the empty object reproduces the historical behaviour exactly: + * `Authorization: Bearer ` and no extra headers. + * + * `ProviderPreset` and `UserLlmProviderEntry` both carry these two field + * names, so a preset or a saved config entry can be passed straight in. + */ +export type OpenAiCompatAuth = { + /** + * Header that carries the API key verbatim, for services that do not + * accept `Authorization: Bearer` (Anthropic wants `x-api-key`; a + * `Bearer sk-ant-…` is read as an OAuth token and always rejected). + * Absent means the OpenAI convention: `authorization: Bearer `. + */ + readonly apiKeyHeader?: string; + /** + * Static headers every request to the service must carry, e.g. + * Anthropic's mandatory `anthropic-version`. Never holds secrets — + * the key travels in `apiKeyHeader` (or the bearer default) so it can + * keep coming from the environment instead of `config.json`. + */ + readonly headers?: Readonly>; +}; + +/** + * Headers that authenticate one request. Keyless servers (a local LM + * Studio, an unauthenticated vLLM) get no auth header at all: `Bearer ` + * with an empty token is malformed and some proxies reject it outright. + * The static `headers` still go out — a version header is part of the + * request contract whether or not a key exists. + */ +export function buildOpenAiAuthHeaders( + apiKey: string | undefined, + auth: OpenAiCompatAuth | undefined, +): Record { + const out: Record = {}; + if (apiKey) { + // A non-ASCII key cannot travel in a header value — `fetch` throws an + // opaque ByteString conversion error from inside the call. Assert in + // the one place every request path passes through, so the failure + // names the key and the fix, on the bearer and named-header paths alike. + assertAsciiApiKey(apiKey); + const named = auth?.apiKeyHeader?.trim(); + if (named) { + out[named.toLowerCase()] = apiKey; + } else { + out.authorization = `Bearer ${apiKey}`; + } + } + for (const [name, value] of Object.entries(auth?.headers ?? {})) { + out[name.toLowerCase()] = value; + } + return out; +} diff --git a/src/llm/provider/openai/openai-build-body.test.ts b/src/llm/provider/openai/openai-build-body.test.ts index eaff9d83..fc10ef1b 100644 --- a/src/llm/provider/openai/openai-build-body.test.ts +++ b/src/llm/provider/openai/openai-build-body.test.ts @@ -86,4 +86,89 @@ describe("buildOpenAiChatBody", () => { ); expect(body.response_format).toBeUndefined(); }); + + it("merges extraBody vendor fields into the request body", () => { + const body = buildOpenAiChatBody({ prompt: "hi" }, "qwen3.8-27b", false, { + chat_template_kwargs: { enable_thinking: false }, + }); + expect(body.chat_template_kwargs).toEqual({ enable_thinking: false }); + expect(body.model).toBe("qwen3.8-27b"); + }); + + it("keeps the body byte-identical when extraBody is absent", () => { + const withoutArg = buildOpenAiChatBody({ prompt: "hi" }, "qwen3.8-27b", false); + const withUndefined = buildOpenAiChatBody( + { prompt: "hi" }, + "qwen3.8-27b", + false, + undefined, + ); + expect(JSON.stringify(withUndefined)).toBe(JSON.stringify(withoutArg)); + }); + + it("does not let extraBody override reserved keys", () => { + const body = buildOpenAiChatBody( + { + prompt: "hi", + tools: [ + { + type: "function", + function: { name: "search", parameters: { type: "object" } }, + }, + ], + }, + "qwen3.8-27b", + true, + { + model: "attacker-model", + messages: [{ role: "user", content: "overwritten" }], + stream: false, + tools: [], + }, + ); + expect(body.model).toBe("qwen3.8-27b"); + expect(body.messages).toEqual([{ role: "user", content: "hi" }]); + expect(body.stream).toBe(true); + expect(body.tools).toHaveLength(1); + }); + + it("drops a reserved key that the builder itself never set", () => { + // `tools` is absent when the caller sends no tools; extraBody must not + // be able to smuggle a tool contract in through the passthrough. + const body = buildOpenAiChatBody({ prompt: "hi" }, "qwen3.8-27b", false, { + tools: [ + { + type: "function", + function: { name: "shell", parameters: { type: "object" } }, + }, + ], + }); + expect(body.tools).toBeUndefined(); + expect("tools" in body).toBe(false); + }); + + it("serializes parallel_tool_calls: false when the executor asks for a single call (issue #104)", () => { + // `maxParallelToolCalls=1` (or a provider that cannot emit parallel + // calls) must reach the wire so the flag acts as a + // provider-compatibility control, not just an executor cap. + const body = buildOpenAiChatBody( + { + prompt: "hi", + tools: [{ type: "function", function: { name: "read" } }], + parallelToolCalls: false, + }, + "gpt-5-2", + false, + ); + expect(body.parallel_tool_calls).toBe(false); + }); + + it("does not attach parallel_tool_calls to a request without tools", () => { + const body = buildOpenAiChatBody( + { prompt: "hi", parallelToolCalls: false }, + "gpt-5-2", + false, + ); + expect("parallel_tool_calls" in body).toBe(false); + }); }); diff --git a/src/llm/provider/openai/openai-build-body.ts b/src/llm/provider/openai/openai-build-body.ts index 3f02d7f9..0b0e1fb9 100644 --- a/src/llm/provider/openai/openai-build-body.ts +++ b/src/llm/provider/openai/openai-build-body.ts @@ -2,10 +2,19 @@ import { getConfig } from "../../../config/index.js"; import type { CompletionRequest } from "../completion-types.js"; import { filterCloudCompletionRequest } from "./sampling-filter.js"; +/** + * Fields the caller owns unconditionally. `extraBody` is merged *under* + * these, so a vendor passthrough can add `chat_template_kwargs` or + * `enable_thinking` but can never detach the request from the resolved + * model, rewrite the prompt, flip streaming, or drop the tool contract. + */ +const RESERVED_BODY_KEYS = ["model", "messages", "stream", "tools"] as const; + export function buildOpenAiChatBody( request: CompletionRequest, defaultChatModel: string, stream: boolean, + extraBody?: Record, ): Record { const filtered = filterCloudCompletionRequest(request); const body: Record = { @@ -48,5 +57,13 @@ export function buildOpenAiChatBody( }, }; } - return body; + if (!extraBody) return body; + // Vendor passthrough. Merged last so it can reach fields this builder + // does not model, then reserved keys are restored on top. + const merged: Record = { ...body, ...extraBody }; + for (const key of RESERVED_BODY_KEYS) { + if (key in body) merged[key] = body[key]; + else delete merged[key]; + } + return merged; } diff --git a/src/llm/provider/openai/openai-http.test.ts b/src/llm/provider/openai/openai-http.test.ts index 8a4e8d57..eecd66a9 100644 --- a/src/llm/provider/openai/openai-http.test.ts +++ b/src/llm/provider/openai/openai-http.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { OpenAiHttpError, + buildOpenAiHeaders, humanizeOpenAiHttpError, openAiPostJson, openAiStartStream, @@ -255,3 +256,60 @@ describe("classification", () => { ); }); }); + +describe("buildOpenAiHeaders", () => { + const base: OpenAiHttpDeps = { + baseUrl: "https://api.example.com", + apiKey: "k", + extraHeaders: {}, + requestTimeoutMs: 1, + fetchImpl: fetch, + label: "p", + }; + + it("defaults to Authorization: Bearer", () => { + expect(buildOpenAiHeaders(base, false)).toMatchObject({ + authorization: "Bearer k", + "content-type": "application/json", + accept: "application/json", + }); + }); + + it("moves the key into apiKeyHeader and drops Authorization entirely", () => { + // Not "in addition to": a service that reads Authorization as an + // OAuth token rejects the request on the stray header alone. + const headers = buildOpenAiHeaders( + { ...base, apiKeyHeader: "x-api-key" }, + false, + ); + expect(headers["x-api-key"]).toBe("k"); + expect(headers.authorization).toBeUndefined(); + }); + + it("sends no auth header at all for a keyless server", () => { + // `Bearer ` with an empty token is malformed; so is an empty + // `x-api-key`. Neither shape may be emitted. + const headers = buildOpenAiHeaders( + { ...base, apiKey: "", apiKeyHeader: "x-api-key" }, + false, + ); + expect(headers.authorization).toBeUndefined(); + expect(headers["x-api-key"]).toBeUndefined(); + }); + + it("carries the entry's static headers alongside the key", () => { + const headers = buildOpenAiHeaders( + { + ...base, + apiKeyHeader: "x-api-key", + extraHeaders: { "anthropic-version": "2023-06-01" }, + }, + true, + ); + expect(headers).toMatchObject({ + "x-api-key": "k", + "anthropic-version": "2023-06-01", + accept: "text/event-stream", + }); + }); +}); diff --git a/src/llm/provider/openai/openai-http.ts b/src/llm/provider/openai/openai-http.ts index 3bfbe7ff..f64bff84 100644 --- a/src/llm/provider/openai/openai-http.ts +++ b/src/llm/provider/openai/openai-http.ts @@ -1,7 +1,16 @@ +import { buildOpenAiAuthHeaders } from "./openai-auth-headers.js"; +import { readErrnoCode } from "../../errno-code.js"; + export type OpenAiHttpDeps = { baseUrl: string; apiKey: string; extraHeaders: Record; + /** + * Header that carries the API key when the service does not accept + * `Authorization: Bearer` (Anthropic wants `x-api-key`). Absent keeps + * the OpenAI convention. See `openai-auth-headers.ts`. + */ + apiKeyHeader?: string; requestTimeoutMs: number; fetchImpl: typeof fetch; /** Provider id shown in user-facing failure messages ("openrouter"). */ @@ -32,9 +41,21 @@ export class OpenAiHttpError extends Error { public readonly retryAfterMs: number | null = null, /** Provider id for user-facing wording; falls back to the host. */ public readonly providerLabel = "", + /** + * Errno of the underlying failure (`ECONNREFUSED`, `ENOTFOUND`, + * `UND_ERR_*`, …) when the transport left one behind. A cloud + * provider that is unreachable and one that refused the request + * both arrive with `status === null`; this is what tells them + * apart in a postmortem. + */ + public readonly code: string | undefined = undefined, + options?: { cause?: unknown }, ) { super(message); this.name = "OpenAiHttpError"; + if (options?.cause !== undefined) { + (this as { cause?: unknown }).cause = options.cause; + } } } @@ -105,11 +126,13 @@ export function buildOpenAiHeaders( return { "content-type": "application/json", accept: stream ? "text/event-stream" : "application/json", - // Keyless servers (a local LM Studio, an unauthenticated vLLM) get no - // authorization header at all: `Bearer ` with an empty token is - // malformed and some proxies reject it outright. - ...(deps.apiKey ? { authorization: `Bearer ${deps.apiKey}` } : {}), - ...deps.extraHeaders, + // Auth (and any service-mandated static headers) come from the one + // builder model discovery also uses, so the two request paths cannot + // disagree about how this endpoint is authenticated. + ...buildOpenAiAuthHeaders(deps.apiKey, { + ...(deps.apiKeyHeader ? { apiKeyHeader: deps.apiKeyHeader } : {}), + headers: deps.extraHeaders, + }), }; } @@ -172,6 +195,26 @@ export async function openAiFetch( stream: boolean, method: "GET" | "POST" = "POST", ): Promise { + // Built before the try below, which would wrap the throw as a + // retryable "network error" and replace its message with a + // connectivity hint. Classified as a 401 instead: a key that cannot + // form a header is the same class as a dead key — deterministic, never + // retried, and a fallback chain advances past it to a link whose key + // may work. + let headers: Record; + try { + headers = buildOpenAiHeaders(deps, stream); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new OpenAiHttpError( + detail, + 401, + `${deps.baseUrl}${path}`, + false, + null, + deps.label, + ); + } const controller = new AbortController(); let timedOut = false; const timer = setTimeout(() => { @@ -190,7 +233,7 @@ export async function openAiFetch( try { return await deps.fetchImpl(`${deps.baseUrl}${path}`, { method, - headers: buildOpenAiHeaders(deps, stream), + headers, ...(body && method === "POST" ? { body: JSON.stringify(body) } : {}), signal: controller.signal, }); @@ -207,10 +250,13 @@ export async function openAiFetch( true, null, deps.label, + undefined, + { cause: err }, ); } // fetch threw without an HTTP response: DNS failure, refused - // connection, TLS error, socket reset. + // connection, TLS error, socket reset. Which of those it was lives + // in the errno — keep it, and the original error with it. const detail = err instanceof Error ? err.message : String(err); throw new OpenAiHttpError( `openai provider network error: ${detail}`, @@ -219,6 +265,8 @@ export async function openAiFetch( false, null, deps.label, + readErrnoCode(err), + { cause: err }, ); } finally { clearTimeout(timer); diff --git a/src/llm/provider/openai/openai-provider.ts b/src/llm/provider/openai/openai-provider.ts index af400e7d..e20ae8e1 100644 --- a/src/llm/provider/openai/openai-provider.ts +++ b/src/llm/provider/openai/openai-provider.ts @@ -36,6 +36,11 @@ export interface OpenAiProviderOptions { apiKey: string; defaultChatModel: string; headers?: Record; + /** + * Header that carries the API key when the service does not accept + * `Authorization: Bearer`. See `openai-auth-headers.ts`. + */ + apiKeyHeader?: string; supportsVision?: boolean; supportsParallelTools?: boolean; supportsPromptCache?: boolean; @@ -46,6 +51,12 @@ export interface OpenAiProviderOptions { streamConsumer?: StreamConsumer; apiPathPrefix?: string; taggedToolCompatibility?: "qwen"; + /** + * Vendor-specific fields merged into every chat completion body. + * See `RESERVED_BODY_KEYS` in `openai-build-body.ts` for the keys + * this passthrough cannot override. + */ + extraBody?: Record; } export class OpenAiProvider implements LlmProvider { @@ -59,6 +70,7 @@ export class OpenAiProvider implements LlmProvider { private readonly defaultChatModel: string; private readonly apiPathPrefix: string; private readonly taggedToolCompatibility: "qwen" | undefined; + private readonly extraBody: Record | undefined; constructor(options: OpenAiProviderOptions) { this.id = options.id; @@ -79,10 +91,12 @@ export class OpenAiProvider implements LlmProvider { this.defaultChatModel = options.defaultChatModel; this.apiPathPrefix = normalizeApiPathPrefix(options.apiPathPrefix ?? "/v1"); this.taggedToolCompatibility = options.taggedToolCompatibility; + this.extraBody = options.extraBody; this.http = { baseUrl: normalizeOpenAiBaseUrl(options.baseUrl), apiKey: options.apiKey, extraHeaders: options.headers ?? {}, + ...(options.apiKeyHeader ? { apiKeyHeader: options.apiKeyHeader } : {}), requestTimeoutMs: options.requestTimeoutMs ?? 600_000, fetchImpl: options.fetchImpl ?? fetch, label: options.id, @@ -90,7 +104,7 @@ export class OpenAiProvider implements LlmProvider { } async complete(request: CompletionRequest): Promise { - const body = buildOpenAiChatBody(request, this.defaultChatModel, false); + const body = buildOpenAiChatBody(request, this.defaultChatModel, false, this.extraBody); const json = await openAiPostJson( this.http, `${this.apiPathPrefix}/chat/completions`, @@ -107,7 +121,7 @@ export class OpenAiProvider implements LlmProvider { async *completeStream( request: CompletionRequest, ): AsyncGenerator { - const body = buildOpenAiChatBody(request, this.defaultChatModel, true); + const body = buildOpenAiChatBody(request, this.defaultChatModel, true, this.extraBody); // Opening the stream (connect + status check) happens inside the // client's bounded retry, strictly before the first chunk exists. // From here on the stream is live and failures are terminal. @@ -146,14 +160,19 @@ export class OpenAiProvider implements LlmProvider { if (accumulatedReasoning.length > 0 && final.reasoningContent.length === 0) { final.reasoningContent = accumulatedReasoning; } - if (this.taggedToolCompatibility === "qwen") { - // Buffer-then-adapt: the tagged `` payload may be split - // across deltas, so adapt only the fully-buffered message. Text and - // reasoning deltas were already yielded above for live UX; the adapt - // seam just rewrites the final result (content → tool_calls). - return adaptQwenCompletionResult(final, request); - } - return final; + // Tagged Qwen calls are synthesized only after the stream has been + // fully buffered. Apply termination safety after that adaptation seam, + // so native and tagged calls are judged from the same final dispatchable + // tool-call set. A synthetic `finishReason: "tool_calls"` from the + // adapter is not evidence that the provider actually terminated cleanly. + const adaptedFinal = + this.taggedToolCompatibility === "qwen" + ? adaptQwenCompletionResult(final, request) + : final; + return applyToolCallTerminationSafety( + adaptedFinal, + streamFinal?.terminalObserved === true, + ); } async health(): Promise { @@ -238,3 +257,18 @@ function completionFromStreamFinal( finishReason, }; } + +function applyToolCallTerminationSafety( + result: CompletionResult, + terminalObserved: boolean, +): CompletionResult { + const hasToolCalls = (result.toolCalls?.length ?? 0) > 0; + if (!hasToolCalls || terminalObserved || result.truncated) { + return result; + } + return { + ...result, + stop: false, + truncated: true, + }; +} diff --git a/src/llm/provider/openai/openai-stream-consumer.ts b/src/llm/provider/openai/openai-stream-consumer.ts index a866e3c6..c917bccf 100644 --- a/src/llm/provider/openai/openai-stream-consumer.ts +++ b/src/llm/provider/openai/openai-stream-consumer.ts @@ -37,20 +37,36 @@ export function createOpenAiStreamConsumer( let finishReason: string | null = null; let modelId: string | null = null; let usage: CompletionUsage | undefined; + // A trustworthy terminal signal: an explicit provider finish_reason + // on any chunk, or a parser-recognized terminal event (`[DONE]`). + // Some OpenAI-compatible providers send a final finish_reason and + // then simply close the connection without ever emitting `[DONE]` — + // that still counts. A bare `reader.read()` EOF with neither must + // NOT be conflated with either, since a still-open tool call's + // arguments may be mid-stream. + let terminalObserved = false; const toolCalls = new Map(); try { while (true) { if (signal?.aborted) break; const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); + if (done) { + // Flush TextDecoder state and treat a final non-empty SSE event + // as an implicit last boundary. Some providers/proxies close the + // response immediately after the terminal event instead of + // writing the conventional trailing blank line. + buffer += decoder.decode(); + } else { + buffer += decoder.decode(value, { stream: true }); + } let boundary = buffer.indexOf("\n\n"); - while (boundary >= 0) { - const rawEvent = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); + while (boundary >= 0 || (done && buffer.trim().length > 0)) { + const rawEvent = boundary >= 0 ? buffer.slice(0, boundary) : buffer; + buffer = boundary >= 0 ? buffer.slice(boundary + 2) : ""; const chunk = parseOpenAiSseEvent(rawEvent, reasoning, toolArgsBuffer); content += chunk.delta; reasoningContent += chunk.reasoningDelta; + if (chunk.finishReason !== null) terminalObserved = true; finishReason = chunk.finishReason ?? finishReason; modelId = chunk.modelId ?? modelId; usage = normaliseUsage(chunk.usage) ?? usage; @@ -64,6 +80,7 @@ export function createOpenAiStreamConsumer( modelId, usage, toolCalls, + terminalObserved: true, }); } if (chunk.toolArgsDelta !== undefined) { @@ -93,6 +110,7 @@ export function createOpenAiStreamConsumer( } boundary = buffer.indexOf("\n\n"); } + if (done) break; } } finally { reader.releaseLock(); @@ -105,6 +123,7 @@ export function createOpenAiStreamConsumer( modelId, usage, toolCalls, + terminalObserved, }); }, }; @@ -120,7 +139,12 @@ function applyToolCallDeltas( }; if (delta.id) current.id = delta.id; if (delta.type) current.type = delta.type; - if (delta.function?.name) current.function.name += delta.function.name; + if (delta.function?.name) { + current.function.name = mergeToolName( + current.function.name, + delta.function.name, + ); + } if (delta.function?.arguments) { current.function.arguments += delta.function.arguments; } @@ -128,6 +152,39 @@ function applyToolCallDeltas( } } +/** + * Fold a streamed `function.name` fragment into what we have so far. + * + * Arguments really are fragments and really do concatenate. A *name* + * does not: the OpenAI streaming contract sends it once, whole, in the + * first delta for its index — so the accumulator appended, and that was + * right for every provider that follows the contract. + * + * Anthropic-compatible endpoints repeat the **full name in every delta** + * for the call. Appending them produced tool names like + * `replyreplyreplyreplyreply…`, which failed registry lookup and killed + * the turn with `tool not registered in this agent` — the whole model + * family was unusable, and the error named a tool nobody had written. + * + * So: a chunk identical to what is already accumulated is a repeat and + * is dropped; anything else is appended, which keeps genuine + * fragmentation (`re` + `ply`) working for any provider that does it. + * The two cases are distinguishable and this is the only rule that + * serves both. + */ +export function mergeToolName(current: string, incoming: string): string { + if (current.length === 0) return incoming; + if (current === incoming) return current; + // A provider that repeats the whole name *and* has already been + // appended to once — `replyreply` arriving alongside another `reply`. + // Cheap to check, and it is the shape a partially-fixed stream takes. + if (current.endsWith(incoming) && current.length % incoming.length === 0) { + const repeats = current.length / incoming.length; + if (incoming.repeat(repeats) === current) return current; + } + return current + incoming; +} + function buildFinalResult(args: { content: string; reasoningContent: string; @@ -135,6 +192,7 @@ function buildFinalResult(args: { modelId: string | null; usage?: CompletionUsage; toolCalls: ReadonlyMap; + terminalObserved: boolean; }): StreamFinalResult { const sortedToolCalls = [...args.toolCalls.entries()] .sort(([a], [b]) => a - b) @@ -145,6 +203,7 @@ function buildFinalResult(args: { reasoningContent: args.reasoningContent, finishReason: args.finishReason, modelId: args.modelId, + terminalObserved: args.terminalObserved, ...(args.usage ? { usage: args.usage } : {}), ...(sortedToolCalls.length > 0 ? { toolCalls: sortedToolCalls } : {}), }; diff --git a/src/llm/provider/openai/openai-tool-call-adapter.test.ts b/src/llm/provider/openai/openai-tool-call-adapter.test.ts index a773464a..ed1227d8 100644 --- a/src/llm/provider/openai/openai-tool-call-adapter.test.ts +++ b/src/llm/provider/openai/openai-tool-call-adapter.test.ts @@ -4,6 +4,7 @@ import { nameUnescape, descriptorsToOpenAiTools, openAiToolCallsToBatch, + ToolCallArgumentsParseError, } from "./openai-tool-call-adapter.js"; describe("OpenAiToolCallAdapter", () => { @@ -32,6 +33,70 @@ describe("OpenAiToolCallAdapter", () => { expect(batch.calls[0]?.args).toMatchObject({ text: "hello" }); }); + it("maps a legitimately empty arguments string to {}", () => { + const batch = openAiToolCallsToBatch([ + { function: { name: "os__fs__list", arguments: "" } }, + ]); + expect(batch.calls[0]?.args).toEqual({}); + const whitespaceOnly = openAiToolCallsToBatch([ + { function: { name: "os__fs__list", arguments: " " } }, + ]); + expect(whitespaceOnly.calls[0]?.args).toEqual({}); + }); + + it("throws ToolCallArgumentsParseError on malformed non-empty JSON instead of substituting {}", () => { + expect(() => + openAiToolCallsToBatch([ + { function: { name: "os__fs__delete", arguments: '{"path":"widget.txt' } }, + ]), + ).toThrow(ToolCallArgumentsParseError); + }); + + it("throws on container-level truncated JSON instead of substituting {}", () => { + expect(() => + openAiToolCallsToBatch([ + { + function: { + name: "os__shell__run", + arguments: '{"commands":["npm install","npm test"', + }, + }, + ]), + ).toThrow(ToolCallArgumentsParseError); + }); + + it("throws when arguments parse to valid JSON that is not an object (array/primitive)", () => { + expect(() => + openAiToolCallsToBatch([ + { function: { name: "os__fs__delete", arguments: "[1,2,3]" } }, + ]), + ).toThrow(ToolCallArgumentsParseError); + expect(() => + openAiToolCallsToBatch([ + { function: { name: "os__fs__delete", arguments: "5" } }, + ]), + ).toThrow(ToolCallArgumentsParseError); + }); + + it("never includes the raw arguments string in the thrown error's message", () => { + const secret = '{"path":"/etc/shadow","token":"sk-super-secret-do-not-log'; + try { + openAiToolCallsToBatch([{ function: { name: "os__fs__delete", arguments: secret } }]); + expect.unreachable("expected a throw"); + } catch (err) { + expect(err).toBeInstanceOf(ToolCallArgumentsParseError); + expect((err as Error).message).not.toContain("sk-super-secret"); + expect((err as Error).message).not.toContain("/etc/shadow"); + } + }); + + it("valid object args still parse normally (control)", () => { + const batch = openAiToolCallsToBatch([ + { function: { name: "os__fs__read", arguments: '{"path":"a.txt"}' } }, + ]); + expect(batch.calls[0]?.args).toEqual({ path: "a.txt" }); + }); + it("includes reply and finish in descriptorsToOpenAiTools", () => { const tools = descriptorsToOpenAiTools([ { diff --git a/src/llm/provider/openai/openai-tool-call-adapter.ts b/src/llm/provider/openai/openai-tool-call-adapter.ts index d8ac0e5b..76e0cbb7 100644 --- a/src/llm/provider/openai/openai-tool-call-adapter.ts +++ b/src/llm/provider/openai/openai-tool-call-adapter.ts @@ -97,18 +97,37 @@ export function descriptorsToOpenAiTools( return out; } +/** + * A tool call's `function.arguments` was non-empty but not valid JSON (or + * not a JSON object). Thrown rather than silently substituting `{}` so the + * failure reaches `tryParseToolCalls`'s existing catch block and routes + * through the same one-shot repair path grammar-parsed batches already + * use — never include the raw arguments here, they may carry sensitive + * user data and this message can reach logs. + */ +export class ToolCallArgumentsParseError extends Error { + constructor(toolName: string) { + super(`tool call "${toolName}" arguments are not a valid JSON object`); + this.name = "ToolCallArgumentsParseError"; + } +} + +/** + * Parses one tool call's raw argument string. A genuinely empty/whitespace + * string is a legitimate zero-arg call and maps to `{}`. Anything + * non-empty that fails to parse, or parses to something other than a JSON + * object, throws instead of falling back to `{}` — a truncated or + * malformed argument string must never be silently treated the same as an + * intentional empty call. + */ function parseArguments(raw: string): Record { const trimmed = raw.trim(); if (!trimmed) return {}; - try { - const parsed = JSON.parse(trimmed) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // fall through + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; } - return {}; + throw new SyntaxError("tool call arguments must be a JSON object"); } export function openAiToolCallsToBatch( @@ -118,9 +137,18 @@ export function openAiToolCallsToBatch( const calls: ToolCallPayload[] = []; for (const tc of toolCalls) { const name = nameUnescape(tc.function.name); + let args: Record; + try { + args = parseArguments(tc.function.arguments); + } catch (err) { + if (err instanceof SyntaxError) { + throw new ToolCallArgumentsParseError(name); + } + throw err; + } calls.push({ tool: name, - args: parseArguments(tc.function.arguments), + args, ...(reasoningText ? { reasoning: reasoningText } : {}), }); } diff --git a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts index c0479104..0e079a68 100644 --- a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts +++ b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts @@ -29,7 +29,7 @@ describe("refreshOpenRouterChatCatalogFromApi", () => { vi.unstubAllGlobals(); }); - it("filters out Anthropic and keeps tool-capable models", async () => { + it("keeps Anthropic alongside every other tool-capable model", async () => { vi.stubGlobal( "fetch", vi.fn(async () => ({ @@ -78,7 +78,10 @@ describe("refreshOpenRouterChatCatalogFromApi", () => { expect(picks.some((p) => p.id === "openrouter/auto")).toBe(true); expect(picks.some((p) => p.id === "qwen/qwen3.6-35b-a3b")).toBe(true); expect(picks.some((p) => p.id === "qwen/qwen3.5-35b-a3b")).toBe(false); - expect(picks.some((p) => p.id.startsWith("anthropic/"))).toBe(false); + // Was `toBe(false)`: `scoreChat` used to return -1 for every + // `anthropic/*` id, which hid the whole Claude line from the picker. + // Vendor is a ranking input now, not a gate. + expect(picks.some((p) => p.id === "anthropic/claude-sonnet-4")).toBe(true); }); it("keeps every advertised model instead of a capped head", async () => { diff --git a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts index 510e0be2..15f3ca67 100644 --- a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts +++ b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts @@ -49,10 +49,26 @@ function hasTools(m: OpenRouterApiModel): boolean { return readAdvertisedTools(m) ?? true; } +/** + * Ranking, not gatekeeping. + * + * This function used to return -1 for every `anthropic/*` id and + * everything matching `/gemini/i`, which removed ~40 currently served + * models — the whole Claude 5 and Gemini 3.x lines — from the picker + * with no way for an operator to get them back. Nothing in the runtime + * needs that: both families speak the same OpenAI-shaped + * `/v1/chat/completions` OpenRouter exposes for everything else, and + * `native_tools` transport is what the picker already requires via + * `hasTools`. The exclusions are gone; the families are scored instead, + * so the models this agent is tuned for still sort to the top. + * + * A negative score is now reserved for rows that genuinely cannot be + * used: non-chat surfaces (embeddings, rerank, TTS) and models that + * explicitly advertise no tool support. + */ function scoreChat(m: OpenRouterApiModel): number { const id = m.id ?? ""; - if (!id || id.startsWith("anthropic/")) return -1; - if (/gemini/i.test(id)) return -1; + if (!id) return -1; if (/qwen3\.5/i.test(id)) return -1; if (/embed|rerank|moderation|ocr|tts|transcribe/i.test(id)) return -1; if (!hasTools(m)) return -1; @@ -62,6 +78,10 @@ function scoreChat(m: OpenRouterApiModel): number { else if (ctx >= 200_000) s += 5; if (/qwen3\.7|qwen3\.6/i.test(id)) s += 20; if (/gpt-5\./i.test(id)) s += 15; + if (/claude-(opus|sonnet|fable|haiku)-5|claude-opus-4\.8/i.test(id)) s += 18; + else if (id.startsWith("anthropic/")) s += 6; + if (/gemini-3\./i.test(id)) s += 14; + else if (/gemini/i.test(id)) s += 4; if (/deepseek.*v4|deepseek.*v3/i.test(id)) s += 10; if (/kimi-k2\.6/i.test(id)) s += 12; else if (/kimi-k2/i.test(id)) s += 8; @@ -146,8 +166,8 @@ let inFlight: Promise | null = null; /** * Pull the public OpenRouter model list and rebuild the TUI picker - * (non-Anthropic, `tools`-capable chat models). Falls back to the static - * catalog on network/parse errors. + * (every `tools`-capable chat model OpenRouter advertises). Falls back to + * the static catalog on network/parse errors. * * Concurrent callers share one request: the TUI triggers this from both * the panel prefetch and the wizard's picker step, and doubling the diff --git a/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts b/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts new file mode 100644 index 00000000..f100ff52 --- /dev/null +++ b/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts @@ -0,0 +1,168 @@ +import { chatModel, type CatalogRow } from "../model-catalog-entry.js"; + +/** + * Hosted frontier chat models on OpenRouter — the vendors that only ship + * behind an API. + * + * Generated from `https://openrouter.ai/api/v1/models` on 2026-08-19 and + * hand-curated down to the current generation of each family: every row's + * `contextWindow`, `supportsVision` (`architecture.input_modalities` + * contains `image`), `supportsPromptCache` (`pricing.input_cache_read` is + * published) and `pricing` (USD per 1M tokens) comes from that response, + * and every id advertises `tools` in `supported_parameters`. + * + * Anthropic and Gemini rows live here because they are no longer filtered + * out — see the note on `scoreChat` in `fetch-openrouter-chat-catalog.ts`. + */ +export const OPENROUTER_FRONTIER_CHAT_MODELS: readonly CatalogRow[] = [ + // Anthropic — Claude 5 / 4.8 + chatModel({ + id: "anthropic/claude-opus-5", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 25 }, + }), + chatModel({ + id: "anthropic/claude-opus-5-fast", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 10, output: 50 }, + }), + chatModel({ + id: "anthropic/claude-sonnet-5", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 10 }, + }), + chatModel({ + id: "anthropic/claude-fable-5", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 10, output: 50 }, + }), + chatModel({ + id: "anthropic/claude-opus-4.8", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 25 }, + }), + chatModel({ + id: "anthropic/claude-haiku-4.5", + contextWindow: 200_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 1, output: 5 }, + }), + // Google — Gemini 3.x + chatModel({ + id: "google/gemini-3.7-flash", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.375, output: 1.875 }, + }), + chatModel({ + id: "google/gemini-3.6-flash", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.75, output: 3.75 }, + }), + chatModel({ + id: "google/gemini-3.5-flash", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 1.5, output: 9 }, + }), + chatModel({ + id: "google/gemini-3.5-flash-lite", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.3, output: 2.5 }, + }), + chatModel({ + id: "google/gemini-3.1-pro-preview", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 12 }, + }), + // OpenAI — GPT-5.x + chatModel({ + id: "openai/gpt-5.6-sol", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2.5, output: 15 }, + }), + chatModel({ + id: "openai/gpt-5.6-terra", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 12 }, + }), + chatModel({ + id: "openai/gpt-5.6-luna", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.2, output: 1.2 }, + }), + chatModel({ + id: "openai/gpt-5.5", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 30 }, + }), + chatModel({ + id: "openai/gpt-5.4", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2.5, output: 15 }, + }), + chatModel({ + id: "openai/gpt-5.4-mini", + contextWindow: 400_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.75, output: 4.5 }, + }), + chatModel({ + id: "openai/gpt-5.4-nano", + contextWindow: 400_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.2, output: 1.25 }, + }), + // xAI — Grok 4.x + chatModel({ + id: "x-ai/grok-4.6", + contextWindow: 500_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "x-ai/grok-4.5", + contextWindow: 500_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "x-ai/grok-4.3", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 1.25, output: 2.5 }, + }),]; diff --git a/src/llm/provider/openrouter/openrouter-models-catalog.test.ts b/src/llm/provider/openrouter/openrouter-models-catalog.test.ts index d87d16e5..05a87b45 100644 --- a/src/llm/provider/openrouter/openrouter-models-catalog.test.ts +++ b/src/llm/provider/openrouter/openrouter-models-catalog.test.ts @@ -5,23 +5,46 @@ import { } from "./openrouter-models-catalog.js"; describe("OPENROUTER_MODELS_CATALOG", () => { - it("does not list Anthropic chat models", () => { - for (const [id, entry] of OPENROUTER_MODELS_CATALOG) { - if (entry.kind !== "chat") continue; - expect(id.startsWith("anthropic/")).toBe(false); + it("lists the current Anthropic chat models", () => { + // The previous snapshot asserted the opposite: no `anthropic/*` row + // was allowed here, mirroring the vendor filter that used to sit in + // `scoreChat`. Both are gone — OpenRouter serves Claude on the same + // OpenAI-shaped chat-completions surface as everything else, so + // hiding it only cost operators the models they asked for. + for (const id of ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5"]) { + expect(OPENROUTER_MODELS_CATALOG.get(id)?.kind).toBe("chat"); + expect(OPENROUTER_CHAT_MODEL_ORDER).toContain(id); + } + }); + + it("lists the current Gemini chat models", () => { + for (const id of ["google/gemini-3.7-flash", "google/gemini-3.5-flash"]) { + expect(OPENROUTER_MODELS_CATALOG.get(id)?.kind).toBe("chat"); + expect(OPENROUTER_CHAT_MODEL_ORDER).toContain(id); } }); - it("does not list Gemini chat models", () => { + it("gives every chat row a positive context window and a price", () => { for (const [id, entry] of OPENROUTER_MODELS_CATALOG) { if (entry.kind !== "chat") continue; - expect(/gemini/i.test(id)).toBe(false); - } - for (const id of OPENROUTER_CHAT_MODEL_ORDER) { - expect(/gemini/i.test(id)).toBe(false); + expect(entry.contextWindow, id).toBeGreaterThan(0); + expect(entry.pricing, id).toBeDefined(); + expect(entry.pricing!.input, id).toBeGreaterThanOrEqual(0); + expect(entry.pricing!.output, id).toBeGreaterThanOrEqual(0); } }); + it("keeps the picker order free of duplicates and in sync with the map", () => { + // The chat rows now come from two sibling modules, so a copy/paste + // between them would otherwise land silently as a duplicate key. + const order = OPENROUTER_CHAT_MODEL_ORDER; + expect(new Set(order).size).toBe(order.length); + const chatIds = [...OPENROUTER_MODELS_CATALOG] + .filter(([, entry]) => entry.kind === "chat") + .map(([id]) => id); + expect([...order].sort()).toEqual([...chatIds].sort()); + }); + it("orders TUI chat picks with openrouter/auto first", () => { expect(OPENROUTER_CHAT_MODEL_ORDER[0]).toBe("openrouter/auto"); for (const id of OPENROUTER_CHAT_MODEL_ORDER) { diff --git a/src/llm/provider/openrouter/openrouter-models-catalog.ts b/src/llm/provider/openrouter/openrouter-models-catalog.ts index 61a314ce..012de6aa 100644 --- a/src/llm/provider/openrouter/openrouter-models-catalog.ts +++ b/src/llm/provider/openrouter/openrouter-models-catalog.ts @@ -1,205 +1,109 @@ import type { ModelCatalogEntry } from "../model-resolver.js"; - -type Price = { input: number; output: number }; - -type ChatModelSpec = { - id: string; - contextWindow: number; - supportsVision: boolean; - supportsPromptCache?: boolean; - pricing: Price; -}; - -type EmbeddingModelSpec = ChatModelSpec & { - dim: number; -}; - -function chatModel(spec: ChatModelSpec): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "chat", - contextWindow: spec.contextWindow, - supportsVision: spec.supportsVision, - supportsTools: "parallel", - supportsPromptCache: spec.supportsPromptCache ?? false, - reasoningFormat: "none", - pricing: spec.pricing, - }, - ]; -} - -function embeddingModel(spec: EmbeddingModelSpec): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "embedding", - contextWindow: spec.contextWindow, - dim: spec.dim, - supportsVision: false, - supportsTools: "none", - supportsPromptCache: spec.supportsPromptCache ?? false, - reasoningFormat: "none", - pricing: spec.pricing, - }, - ]; -} +import { embeddingModel } from "../model-catalog-entry.js"; +import { OPENROUTER_FRONTIER_CHAT_MODELS } from "./openrouter-frontier-chat-models.js"; +import { OPENROUTER_OPEN_WEIGHT_CHAT_MODELS } from "./openrouter-open-weight-chat-models.js"; /** - * Static fallback catalog (May 2026 OpenRouter slugs). The TUI wizard - * prefers {@link refreshOpenRouterChatCatalogFromApi} when online; this - * map backs offline runs and `resolveModel` metadata. + * Static fallback catalog, regenerated from the public OpenRouter model + * list on 2026-08-19. The TUI wizard prefers + * {@link refreshOpenRouterChatCatalogFromApi} when online; this map backs + * offline runs and `resolveModel` metadata (context window, capabilities, + * price per 1M tokens). + * + * The chat rows live in two sibling files — hosted frontier models and + * open-weight ones — to stay inside the 300-line limit. Embedding rows + * stay here; there are two of them and OpenRouter has not changed their + * pricing since the previous snapshot. */ export const OPENROUTER_MODELS_CATALOG: ReadonlyMap = new Map([ - chatModel({ - id: "openrouter/auto", - contextWindow: 2_000_000, - supportsVision: true, - pricing: { input: 0, output: 0 }, - }), - chatModel({ - id: "qwen/qwen3.7-max", - contextWindow: 1_000_000, - supportsVision: false, - pricing: { input: 1.25, output: 3.75 }, - }), - chatModel({ - id: "qwen/qwen3.6-35b-a3b", - contextWindow: 262_144, - supportsVision: true, - pricing: { input: 0.15, output: 1 }, - }), - chatModel({ - id: "qwen/qwen3.6-flash", - contextWindow: 1_000_000, - supportsVision: true, - pricing: { input: 0.19, output: 1.13 }, - }), - chatModel({ - id: "openai/gpt-5.5", - contextWindow: 1_050_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 5, output: 30 }, - }), - chatModel({ - id: "openai/gpt-5.4", - contextWindow: 1_050_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 2.5, output: 15 }, - }), - chatModel({ - id: "openai/gpt-5.4-mini", - contextWindow: 400_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 0.75, output: 4.5 }, - }), - chatModel({ - id: "openai/gpt-5.4-nano", - contextWindow: 400_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 0.2, output: 1.25 }, - }), - chatModel({ - id: "x-ai/grok-4.3", - contextWindow: 1_000_000, - supportsVision: true, - pricing: { input: 1.25, output: 2.5 }, - }), - chatModel({ - id: "deepseek/deepseek-v4-flash", - contextWindow: 1_048_576, - supportsVision: false, - pricing: { input: 0.1, output: 0.2 }, - }), - chatModel({ - id: "deepseek/deepseek-v4-pro", - contextWindow: 1_048_576, - supportsVision: false, - pricing: { input: 0.43, output: 0.87 }, - }), - chatModel({ - id: "moonshotai/kimi-k2.7-code", - contextWindow: 262_144, - supportsVision: true, - pricing: { input: 0.95, output: 4.0 }, - }), - chatModel({ - id: "mistralai/mistral-medium-3-5", - contextWindow: 262_144, - supportsVision: true, - pricing: { input: 1.5, output: 7.5 }, - }), - chatModel({ - id: "minimax/minimax-m3", - contextWindow: 1_048_576, - supportsVision: true, - pricing: { input: 0.3, output: 1.2 }, - }), - chatModel({ - id: "minimax/minimax-m2.7", - contextWindow: 204_800, - supportsVision: false, - pricing: { input: 0.28, output: 1.2 }, - }), - chatModel({ - id: "z-ai/glm-4.7-flash", - contextWindow: 202_752, - supportsVision: false, - pricing: { input: 0.06, output: 0.4 }, - }), - chatModel({ - id: "z-ai/glm-5.2", - contextWindow: 1_048_576, - supportsVision: false, - pricing: { input: 1, output: 4 }, - }), - chatModel({ - id: "z-ai/glm-5.1", - contextWindow: 202_752, - supportsVision: false, - pricing: { input: 0.98, output: 3.08 }, - }), + [ + "openrouter/auto", + { + id: "openrouter/auto", + kind: "chat", + contextWindow: 2_000_000, + supportsVision: true, + supportsTools: "parallel", + supportsPromptCache: false, + reasoningFormat: "none", + // Routed: the price is whatever model OpenRouter picks. + pricing: { input: 0, output: 0 }, + }, + ], + ...OPENROUTER_FRONTIER_CHAT_MODELS, + ...OPENROUTER_OPEN_WEIGHT_CHAT_MODELS, embeddingModel({ id: "openai/text-embedding-3-small", contextWindow: 8192, dim: 1536, - supportsVision: false, pricing: { input: 0.02, output: 0 }, }), embeddingModel({ id: "openai/text-embedding-3-large", contextWindow: 8192, dim: 3072, - supportsVision: false, pricing: { input: 0.13, output: 0 }, }), ]); -/** Static TUI order when the live API fetch is unavailable. */ +/** + * Static TUI order when the live API fetch is unavailable: the curated + * catalog order, `openrouter/auto` first. + */ export const OPENROUTER_CHAT_MODEL_ORDER: readonly string[] = [ "openrouter/auto", - "qwen/qwen3.7-max", - "qwen/qwen3.6-35b-a3b", - "qwen/qwen3.6-flash", + "anthropic/claude-opus-5", + "anthropic/claude-opus-5-fast", + "anthropic/claude-sonnet-5", + "anthropic/claude-fable-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-haiku-4.5", + "google/gemini-3.7-flash", + "google/gemini-3.6-flash", + "google/gemini-3.5-flash", + "google/gemini-3.5-flash-lite", + "google/gemini-3.1-pro-preview", + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", "openai/gpt-5.5", "openai/gpt-5.4", "openai/gpt-5.4-mini", "openai/gpt-5.4-nano", + "x-ai/grok-4.6", + "x-ai/grok-4.5", "x-ai/grok-4.3", - "deepseek/deepseek-v4-flash", + "qwen/qwen3.8-max", + "qwen/qwen3.8-2.4t-a95b", + "qwen/qwen3.8-27b", + "qwen/qwen3.7-max", + "qwen/qwen3.7-plus", + "qwen/qwen3.7-flash", + "qwen/qwen3.6-35b-a3b", + "qwen/qwen3.6-flash", + "qwen/qwen3-coder-plus", "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "moonshotai/kimi-k3", "moonshotai/kimi-k2.7-code", - "mistralai/mistral-medium-3-5", - "minimax/minimax-m3", - "minimax/minimax-m2.7", - "z-ai/glm-4.7-flash", + "moonshotai/kimi-k2.6", + "z-ai/glm-5.3", "z-ai/glm-5.2", "z-ai/glm-5.1", -]; + "z-ai/glm-4.7-flash", + "minimax/minimax-m3", + "minimax/minimax-m2.7", + "mistralai/mistral-large-2512", + "mistralai/mistral-medium-3-5", + "mistralai/ministral-8b-2512", + "meta-llama/llama-4-maverick", + "meta-llama/llama-4-scout", + "meta-llama/llama-3.3-70b-instruct", + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nemotron-3.5-lightning", + "amazon/nova-premier-v1", + "amazon/nova-2-lite-v1", + "bytedance-seed/seed-2.0-code",]; diff --git a/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts b/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts new file mode 100644 index 00000000..514dfe92 --- /dev/null +++ b/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts @@ -0,0 +1,246 @@ +import { chatModel, type CatalogRow } from "../model-catalog-entry.js"; + +/** + * Open-weight chat models on OpenRouter — families whose weights are + * published, served here by whichever provider OpenRouter routes to. + * + * Same provenance as the frontier list: generated from + * `https://openrouter.ai/api/v1/models` on 2026-08-19, curated to the + * current generation of each family, `tools`-capable only. + */ +export const OPENROUTER_OPEN_WEIGHT_CHAT_MODELS: readonly CatalogRow[] = [ + // Qwen + chatModel({ + id: "qwen/qwen3.8-max", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "qwen/qwen3.8-2.4t-a95b", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "qwen/qwen3.8-27b", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.45, output: 3.2 }, + }), + chatModel({ + id: "qwen/qwen3.7-max", + contextWindow: 1_000_000, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 1.475, output: 4.425 }, + }), + chatModel({ + id: "qwen/qwen3.7-plus", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.32, output: 1.28 }, + }), + chatModel({ + id: "qwen/qwen3.7-flash", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.03, output: 0.13 }, + }), + chatModel({ + id: "qwen/qwen3.6-35b-a3b", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.14, output: 1 }, + }), + chatModel({ + id: "qwen/qwen3.6-flash", + contextWindow: 1_000_000, + supportsVision: true, + pricing: { input: 0.188, output: 1.125 }, + }), + chatModel({ + id: "qwen/qwen3-coder-plus", + contextWindow: 1_000_000, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.65, output: 3.25 }, + }), + // DeepSeek + chatModel({ + id: "deepseek/deepseek-v4-pro", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.66, output: 1.98 }, + }), + chatModel({ + id: "deepseek/deepseek-v4-flash", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.083, output: 0.165 }, + }), + // Moonshot AI — Kimi + chatModel({ + id: "moonshotai/kimi-k3", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 3, output: 15 }, + }), + chatModel({ + id: "moonshotai/kimi-k2.7-code", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.71, output: 3.5 }, + }), + chatModel({ + id: "moonshotai/kimi-k2.6", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.95, output: 4 }, + }), + // Z.ai — GLM + chatModel({ + id: "z-ai/glm-5.3", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 1.4, output: 4.4 }, + }), + chatModel({ + id: "z-ai/glm-5.2", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.966, output: 3.036 }, + }), + chatModel({ + id: "z-ai/glm-5.1", + contextWindow: 204_800, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.966, output: 3.036 }, + }), + chatModel({ + id: "z-ai/glm-4.7-flash", + contextWindow: 202_752, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.06, output: 0.4 }, + }), + // MiniMax + chatModel({ + id: "minimax/minimax-m3", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.3, output: 1.2 }, + }), + chatModel({ + id: "minimax/minimax-m2.7", + contextWindow: 204_800, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.3, output: 1.2 }, + }), + // Mistral + chatModel({ + id: "mistralai/mistral-large-2512", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.5, output: 1.5 }, + }), + chatModel({ + id: "mistralai/mistral-medium-3-5", + contextWindow: 262_144, + supportsVision: true, + pricing: { input: 1.5, output: 7.5 }, + }), + chatModel({ + id: "mistralai/ministral-8b-2512", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.15, output: 0.15 }, + }), + // Meta — Llama + chatModel({ + id: "meta-llama/llama-4-maverick", + contextWindow: 1_048_576, + supportsVision: true, + pricing: { input: 0.2, output: 0.8 }, + }), + chatModel({ + id: "meta-llama/llama-4-scout", + contextWindow: 1_310_720, + supportsVision: true, + pricing: { input: 0.1, output: 0.3 }, + }), + chatModel({ + id: "meta-llama/llama-3.3-70b-instruct", + contextWindow: 131_072, + supportsVision: false, + pricing: { input: 0.1, output: 0.32 }, + }), + // OpenAI gpt-oss (open weights) + chatModel({ + id: "openai/gpt-oss-120b", + contextWindow: 131_072, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.03, output: 0.17 }, + }), + chatModel({ + id: "openai/gpt-oss-20b", + contextWindow: 131_072, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.03, output: 0.13 }, + }), + // NVIDIA — Nemotron + chatModel({ + id: "nvidia/nemotron-3-ultra-550b-a55b", + contextWindow: 512_288, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.6, output: 3.6 }, + }), + chatModel({ + id: "nvidia/nemotron-3.5-lightning", + contextWindow: 1_000_000, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.08, output: 0.2 }, + }), + // Amazon — Nova + chatModel({ + id: "amazon/nova-premier-v1", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2.5, output: 12.5 }, + }), + chatModel({ + id: "amazon/nova-2-lite-v1", + contextWindow: 1_000_000, + supportsVision: true, + pricing: { input: 0.3, output: 2.5 }, + }), + // ByteDance — Seed + chatModel({ + id: "bytedance-seed/seed-2.0-code", + contextWindow: 262_144, + supportsVision: true, + pricing: { input: 0.5, output: 3 }, + }),]; diff --git a/src/llm/provider/registry/provider-registry.test.ts b/src/llm/provider/registry/provider-registry.test.ts index 2fb5eef5..e7a67281 100644 --- a/src/llm/provider/registry/provider-registry.test.ts +++ b/src/llm/provider/registry/provider-registry.test.ts @@ -19,6 +19,7 @@ describe("ProviderRegistry", () => { expect(kinds).toContain("qwen-openai-compatible"); expect(kinds).toContain("openrouter"); expect(kinds).toContain("gemini"); + expect(kinds).toContain("subscription-cli"); }); it("resolveLlmConfig synthesizes local-llama when llm block absent", () => { diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts index fc35ae78..3066198b 100644 --- a/src/llm/provider/registry/provider-types.ts +++ b/src/llm/provider/registry/provider-types.ts @@ -1,4 +1,5 @@ import type { AtomicAgentConfig } from "../../../config/index.js"; +import type { UserSubscriptionCliOptions } from "../../../config/llm-config.js"; import type { LlamaServerClient } from "../../llama-server-client.js"; import type { ModelProfile } from "../../model-profile.js"; import type { StructuredLogger } from "../../../tracing/index.js"; @@ -26,11 +27,35 @@ export type LlmProviderConfigEntry = { defaultChatModel?: string; defaultEmbeddingModel?: string; headers?: Record; + /** + * Header that carries this entry's API key when the service does not + * accept `Authorization: Bearer` (Anthropic wants `x-api-key`). Rides + * on the entry, not on the preset table, so the saved provider keeps + * working after a restart. See `openai/openai-auth-headers.ts`. + */ + apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; requestTimeoutMs?: number; promptCache?: "auto" | "off" | "explicit-markers"; providerPreferences?: Record; + /** + * Vendor-specific fields merged into the OpenAI-compatible chat + * completion body. Lets a deployment reach extensions that are not + * part of the OpenAI schema (e.g. Alibaba Model Studio's + * `chat_template_kwargs.enable_thinking`) without a code change per + * vendor. + * + * **Reserved keys win.** `model`, `messages`, `stream` and `tools` + * are re-applied after the merge, so a stray entry can never detach + * the request from the resolved model or drop the tool contract. + */ + extraBody?: Record; + /** + * Settings for a `subscription-cli` provider — which vendor CLI to + * drive and how to invoke it. Absent on every other kind. + */ + subscriptionCli?: UserSubscriptionCliOptions; userModels?: ReadonlyArray; }; diff --git a/src/llm/provider/registry/register-built-in-providers.ts b/src/llm/provider/registry/register-built-in-providers.ts index 976a6cf2..4f8dc7a5 100644 --- a/src/llm/provider/registry/register-built-in-providers.ts +++ b/src/llm/provider/registry/register-built-in-providers.ts @@ -14,6 +14,12 @@ import { OPENROUTER_APP_REFERER, OPENROUTER_APP_TITLE, } from "../openrouter/openrouter-provider.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../../config/provider-auth-mode.js"; +import { + registerBuiltInCliAdapters, + resolveCliAdapter, + SubscriptionCliProvider, +} from "../subscription-cli/index.js"; import { registerProviderKind } from "./provider-types.js"; let registered = false; @@ -54,9 +60,11 @@ export function registerBuiltInProviderKinds(): void { apiKey: entry.apiKey ?? "", defaultChatModel: entry.defaultChatModel, headers: entry.headers, + apiKeyHeader: entry.apiKeyHeader, supportsVision: entry.supportsVision ?? true, supportsParallelTools: entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, + extraBody: entry.extraBody, }); }); @@ -73,10 +81,12 @@ export function registerBuiltInProviderKinds(): void { apiKey: entry.apiKey ?? "", defaultChatModel: entry.defaultChatModel, headers: entry.headers, + apiKeyHeader: entry.apiKeyHeader, supportsVision: entry.supportsVision ?? true, supportsParallelTools: entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, taggedToolCompatibility: "qwen", + extraBody: entry.extraBody, }); }); @@ -124,4 +134,37 @@ export function registerBuiltInProviderKinds(): void { requestTimeoutMs: entry.requestTimeoutMs, }); }); + + registerProviderKind(SUBSCRIPTION_CLI_KIND, (ctx) => { + const entry = ctx.entry; + const options = entry.subscriptionCli; + if (!options) { + throw new Error( + `${SUBSCRIPTION_CLI_KIND} provider "${entry.id}" requires a subscriptionCli block naming the cli to drive`, + ); + } + registerBuiltInCliAdapters(); + const descriptor = resolveCliAdapter(options.cli); + return new SubscriptionCliProvider({ + id: entry.id, + descriptor, + // The state dir, not the agent's working directory: with tools + // disabled there is nothing to read there anyway, and it keeps a + // project-level CLAUDE.md out of the completion. + cwd: ctx.config.paths.stateDir, + ...(entry.defaultChatModel ? { model: entry.defaultChatModel } : {}), + ...(options.binPath ? { binPath: options.binPath } : {}), + ...(options.extraArgs ? { extraArgs: options.extraArgs } : {}), + ...(options.streaming === undefined + ? {} + : { streaming: options.streaming }), + ...(options.maxBudgetUsd === undefined + ? {} + : { maxBudgetUsd: options.maxBudgetUsd }), + ...(entry.requestTimeoutMs + ? { requestTimeoutMs: entry.requestTimeoutMs } + : {}), + onNotice: (message) => ctx.logger.warn("llm.subscription_cli", { message }), + }); + }); } diff --git a/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts b/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts new file mode 100644 index 00000000..3393e5fd --- /dev/null +++ b/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; + +import { claudeCliAdapter } from "./claude-cli-adapter.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, +} from "./subscription-cli-errors.js"; + +const input = { + model: "sonnet", + systemPrompt: "SYSTEM", + extraArgs: [] as readonly string[], +}; + +/** Captured verbatim from `claude -p --output-format json` v2.1.220. */ +const REAL_ENVELOPE = JSON.stringify({ + is_error: false, + duration_api_ms: 4630, + num_turns: 1, + stop_reason: "end_turn", + session_id: "32c150e6-44a2-422d-a03d-76b18b607b71", + total_cost_usd: 0.0363007, + usage: { + input_tokens: 2, + cache_creation_input_tokens: 5777, + cache_read_input_tokens: 3289, + output_tokens: 4, + }, + modelUsage: { + "claude-sonnet-5": { contextWindow: 1_000_000, maxOutputTokens: 64_000 }, + }, + permission_denials: [], + subtype: "success", + api_error_status: null, + result: "OK", + type: "result", +}); + +describe("claudeCliAdapter argv", () => { + it("passes the headless, tool-free, stateless flag set", () => { + const args = claudeCliAdapter.completeArgs(input); + expect(args).toContain("--print"); + expect(args).toContain("--strict-mcp-config"); + expect(args).toContain("--no-session-persistence"); + expect(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2)).toEqual([ + "--tools", + "", + ]); + expect(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2)).toEqual([ + "--model", + "sonnet", + ]); + expect( + args.slice( + args.indexOf("--output-format"), + args.indexOf("--output-format") + 2, + ), + ).toEqual(["--output-format", "json"]); + expect( + args.slice( + args.indexOf("--system-prompt"), + args.indexOf("--system-prompt") + 2, + ), + ).toEqual(["--system-prompt", "SYSTEM"]); + }); + + it("never passes flags that would defeat subscription auth or the approval ladder", () => { + for (const build of [ + claudeCliAdapter.completeArgs, + claudeCliAdapter.streamArgs, + ]) { + const args = build({ ...input, responseSchema: { type: "object" } }); + // --bare makes the CLI read ANTHROPIC_API_KEY only, never OAuth. + expect(args).not.toContain("--bare"); + expect(args).not.toContain("--dangerously-skip-permissions"); + expect(args).not.toContain("--allow-dangerously-skip-permissions"); + expect(args).not.toContain("--add-dir"); + expect(args).not.toContain("--permission-mode"); + } + }); + + it("never places the prompt on argv", () => { + // Regression guard for E2BIG: a two-zone prompt exceeds the 128 KiB + // single-argument limit, so it must travel on stdin. + const prompt = "P".repeat(200_000); + const args = claudeCliAdapter.completeArgs(input); + expect(args.some((arg) => arg.includes(prompt))).toBe(false); + expect(args.join(" ").length).toBeLessThan(4096); + }); + + it("adds --verbose only on the streaming path", () => { + expect(claudeCliAdapter.completeArgs(input)).not.toContain("--verbose"); + const streamArgs = claudeCliAdapter.streamArgs(input); + // Verified: `--print` + `--output-format stream-json` errors without it. + expect(streamArgs).toContain("--verbose"); + expect(streamArgs).toContain("--include-partial-messages"); + expect( + streamArgs.slice( + streamArgs.indexOf("--output-format"), + streamArgs.indexOf("--output-format") + 2, + ), + ).toEqual(["--output-format", "stream-json"]); + }); + + it("passes --json-schema only when a schema is set and small enough", () => { + expect(claudeCliAdapter.completeArgs(input)).not.toContain("--json-schema"); + + const schema = { type: "object", properties: { name: { type: "string" } } }; + const withSchema = claudeCliAdapter.completeArgs({ + ...input, + responseSchema: schema, + }); + expect( + withSchema[withSchema.indexOf("--json-schema") + 1], + ).toBe(JSON.stringify(schema)); + + const huge = { type: "object", description: "x".repeat(40_000) }; + expect( + claudeCliAdapter.completeArgs({ ...input, responseSchema: huge }), + ).not.toContain("--json-schema"); + }); + + it("appends extraArgs verbatim, last", () => { + const args = claudeCliAdapter.completeArgs({ + ...input, + extraArgs: ["--effort", "high"], + maxBudgetUsd: 5, + }); + expect(args.slice(-2)).toEqual(["--effort", "high"]); + expect(args).toContain("--max-budget-usd"); + expect(args[args.indexOf("--max-budget-usd") + 1]).toBe("5"); + }); + + it("health uses --version, not a real turn", () => { + expect(claudeCliAdapter.healthArgs()).toEqual(["--version"]); + }); +}); + +describe("claudeCliAdapter parseResult", () => { + it("maps the real success envelope", () => { + const result = claudeCliAdapter.parseResult(REAL_ENVELOPE, "sonnet"); + expect(result.content).toBe("OK"); + expect(result.finishReason).toBe("stop"); + expect(result.truncated).toBe(false); + expect(result.stop).toBe(true); + expect(result.slotId).toBe(-1); + expect(result.modelId).toBe("claude-sonnet-5"); + expect(result.cacheHitTokens).toBe(3289); + // prompt tokens = fresh + cache-write + cache-read, matching the + // OpenAI `prompt_tokens` semantics the usage meter expects. + expect(result.usage).toEqual({ + promptTokens: 2 + 5777 + 3289, + completionTokens: 4, + totalTokens: 2 + 5777 + 3289 + 4, + }); + expect(result.timing.predictedMs).toBe(4630); + }); + + it("treats a tool_use stop as a normal stop", () => { + // --json-schema is implemented as a forced tool call, so a perfectly + // successful structured completion reports stop_reason tool_use. + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + is_error: false, + result: '{"name":"Ada"}', + stop_reason: "tool_use", + }), + "sonnet", + ); + expect(result.finishReason).toBe("stop"); + expect(result.content).toBe('{"name":"Ada"}'); + }); + + it("reports truncation on max_tokens", () => { + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + is_error: false, + result: "half", + stop_reason: "max_tokens", + }), + "sonnet", + ); + expect(result.truncated).toBe(true); + expect(result.stop).toBe(false); + expect(result.finishReason).toBe("length"); + }); + + it("ignores the internal helper model in modelUsage", () => { + // Observed live: a `sonnet` turn also bills a haiku helper turn for + // Claude Code's own post-turn summary. Reporting haiku as the model + // that served the completion would corrupt cost and model analytics. + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + result: "hi", + modelUsage: { + "claude-haiku-4-5-20251001": { outputTokens: 13 }, + "claude-sonnet-5": { outputTokens: 4 }, + }, + }), + "sonnet", + ); + expect(result.modelId).toBe("claude-sonnet-5"); + }); + + it("keeps the requested model when only a helper model was billed", () => { + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + result: "hi", + modelUsage: { "claude-haiku-4-5-20251001": { outputTokens: 13 } }, + }), + "sonnet", + ); + expect(result.modelId).toBe("sonnet"); + }); + + it("falls back to the configured model when modelUsage is absent", () => { + const result = claudeCliAdapter.parseResult( + JSON.stringify({ subtype: "success", result: "hi" }), + "opus", + ); + expect(result.modelId).toBe("opus"); + }); + + it("throws on an error envelope and keeps the message", () => { + expect(() => + claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "error_during_execution", + is_error: true, + result: "5-hour limit reached; resets at 14:00", + }), + "sonnet", + ), + ).toThrow(/5-hour limit reached/); + }); + + it("maps a 401 to an auth error", () => { + expect(() => + claudeCliAdapter.parseResult( + JSON.stringify({ subtype: "success", api_error_status: 401 }), + "sonnet", + ), + ).toThrow(SubscriptionCliAuthError); + }); + + it("throws rather than silently returning empty on non-JSON output", () => { + expect(() => claudeCliAdapter.parseResult("not json", "sonnet")).toThrow( + SubscriptionCliInvocationError, + ); + }); +}); + +describe("claudeCliAdapter parseStreamEvent", () => { + it("extracts text deltas", () => { + expect( + claudeCliAdapter.parseStreamEvent( + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "1\n2" }, + }, + }), + ), + ).toEqual({ kind: "delta", text: "1\n2" }); + }); + + it("marks the terminal result envelope", () => { + const line = JSON.stringify({ type: "result", subtype: "success" }); + expect(claudeCliAdapter.parseStreamEvent(line)).toEqual({ + kind: "final", + raw: line, + }); + }); + + it("surfaces a throttled rate-limit event as a notice, ignores allowed", () => { + expect( + claudeCliAdapter.parseStreamEvent( + JSON.stringify({ + type: "rate_limit_event", + rate_limit_info: { status: "allowed", rateLimitType: "five_hour" }, + }), + ), + ).toEqual({ kind: "ignore" }); + expect( + claudeCliAdapter.parseStreamEvent( + JSON.stringify({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "five_hour" }, + }), + ), + ).toEqual({ kind: "notice", message: "claude rate limit rejected (five_hour)" }); + }); + + it("ignores unknown, empty and malformed lines instead of failing", () => { + for (const line of [ + "", + " ", + "{ not json", + JSON.stringify({ type: "system", subtype: "init" }), + JSON.stringify({ type: "assistant", message: {} }), + JSON.stringify({ type: "stream_event", event: { type: "message_stop" } }), + ]) { + expect(claudeCliAdapter.parseStreamEvent(line)).toEqual({ kind: "ignore" }); + } + }); +}); diff --git a/src/llm/provider/subscription-cli/claude-cli-adapter.ts b/src/llm/provider/subscription-cli/claude-cli-adapter.ts new file mode 100644 index 00000000..5d74631a --- /dev/null +++ b/src/llm/provider/subscription-cli/claude-cli-adapter.ts @@ -0,0 +1,273 @@ +import type { CompletionResult } from "../completion-types.js"; +import type { + CliAdapterDescriptor, + CliArgsInput, + CliStreamEvent, +} from "./cli-adapter-descriptor.js"; +import { + CLAUDE_CLI_CHAT_MODELS, + CLAUDE_CLI_CONTEXT_WINDOW, + CLAUDE_CLI_DEFAULT_CHAT_MODEL, +} from "./claude-cli-models.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, +} from "./subscription-cli-errors.js"; + +/** + * Replaces Claude Code's own system prompt for the duration of one + * completion. It has to be a replacement, not an append: the default is + * a coding-agent prompt that plans, narrates and reaches for tools, + * which competes with the complete two-zone prompt atomic-agent already + * built. This is also the only steering channel we have outside that + * prompt, so it is spent on the output contract. + */ +export const CLAUDE_CLI_SYSTEM_PROMPT = + "You are an inference backend. The user message is a complete, " + + "self-contained prompt that carries its own instructions and output " + + "contract. Follow it exactly and emit only what it asks for — no " + + "preamble, no commentary, no summary of what you are about to do. " + + "Do not use tools; the prompt's own protocol is the only one that applies."; + +/** + * Above this the schema would eat into the argv budget for no benefit; + * the sub-runners that set `responseFormat` all tolerate free-form + * content, so dropping the flag degrades gracefully. + */ +const MAX_SCHEMA_ARG_BYTES = 32 * 1024; + +function baseArgs(input: CliArgsInput): string[] { + return [ + "--print", + "--input-format", + "text", + ...(input.model ? ["--model", input.model] : []), + "--system-prompt", + input.systemPrompt, + // Safety-critical, not an optimisation: Claude Code's built-in + // Bash/Edit/Write would otherwise run on the user's machine outside + // atomic-agent's approval ladder. + "--tools", + "", + // No --mcp-config is passed, so this drops the user's MCP servers + // rather than inheriting them into a stateless completion. + "--strict-mcp-config", + // atomic-agent owns session state and re-sends the whole prompt each + // step; CLI-side history would double-count context and litter the + // user's session list. + "--no-session-persistence", + ]; +} + +function tailArgs(input: CliArgsInput): string[] { + const out: string[] = []; + if (input.responseSchema) { + const encoded = JSON.stringify(input.responseSchema); + if (encoded.length <= MAX_SCHEMA_ARG_BYTES) { + out.push("--json-schema", encoded); + } + } + if (input.maxBudgetUsd !== undefined) { + out.push("--max-budget-usd", String(input.maxBudgetUsd)); + } + out.push(...input.extraArgs); + return out; +} + +interface ClaudeResultEnvelope { + type?: string; + subtype?: string; + is_error?: boolean; + result?: string; + stop_reason?: string | null; + api_error_status?: number | null; + duration_api_ms?: number; + permission_denials?: unknown[]; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + modelUsage?: Record; +} + +/** + * `stop_reason` doubles as a structured-output signal: with + * `--json-schema` the CLI implements the constraint as a forced tool + * call and reports `tool_use` even though the text in `result` is the + * whole answer. Treat it as a normal stop — we never surface tool calls + * from this provider. + */ +function toFinishReason(stopReason: string | null | undefined): string | null { + if (!stopReason) return null; + if (stopReason === "end_turn" || stopReason === "tool_use") return "stop"; + if (stopReason === "max_tokens") return "length"; + return stopReason; +} + +/** + * `modelUsage` is keyed by every model the CLI billed for this turn, + * which includes the small helper model Claude Code uses for its own + * side tasks (post-turn summaries). Taking the first key would report + * `claude-haiku-4-5` as the model that served a `sonnet` request, so the + * requested model stays authoritative and `modelUsage` is used only to + * expand an alias into the concrete id it resolved to. + */ +function resolveModelId( + modelUsage: Record | undefined, + requested: string, +): string { + const keys = Object.keys(modelUsage ?? {}); + if (keys.includes(requested)) return requested; + return keys.find((key) => key.includes(requested)) ?? requested; +} + +function parseResult(stdout: string, fallbackModel: string): CompletionResult { + let envelope: ClaudeResultEnvelope; + try { + envelope = JSON.parse(stdout.trim()) as ClaudeResultEnvelope; + } catch { + throw new SubscriptionCliInvocationError( + `claude returned output that is not JSON: ${stdout.slice(0, 500)}`, + ); + } + const status = envelope.api_error_status ?? null; + if (status === 401 || status === 403) { + throw new SubscriptionCliAuthError( + "claude", + "Run `claude` in a terminal and complete /login, then retry.", + `api_error_status ${status}`, + ); + } + if (envelope.is_error || (envelope.subtype && envelope.subtype !== "success")) { + // The message is the only description of subscription rate limits and + // usage caps, so it is passed through rather than summarised away. + throw new SubscriptionCliInvocationError( + `claude reported ${envelope.subtype ?? "an error"}${ + status ? ` (api status ${status})` : "" + }: ${envelope.result ?? "no detail"}`, + ); + } + + const usage = envelope.usage ?? {}; + const promptTokens = + (usage.input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0); + const completionTokens = usage.output_tokens ?? 0; + const predictedMs = envelope.duration_api_ms ?? 0; + const modelId = resolveModelId(envelope.modelUsage, fallbackModel); + const truncated = envelope.stop_reason === "max_tokens"; + + return { + content: envelope.result ?? "", + reasoningContent: "", + stop: !truncated, + truncated, + timing: { + promptMs: 0, + predictedMs, + promptTokens, + predictedTokens: completionTokens, + }, + cacheHitTokens: usage.cache_read_input_tokens ?? 0, + // No slot affinity: every completion is a fresh process. + slotId: -1, + modelId, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + }, + finishReason: toFinishReason(envelope.stop_reason), + }; +} + +interface ClaudeStreamLine { + type?: string; + event?: { + type?: string; + delta?: { type?: string; text?: string }; + }; + rate_limit_info?: { status?: string; rateLimitType?: string }; +} + +function parseStreamEvent(line: string): CliStreamEvent { + const trimmed = line.trim(); + if (trimmed.length === 0) return { kind: "ignore" }; + let parsed: ClaudeStreamLine; + try { + parsed = JSON.parse(trimmed) as ClaudeStreamLine; + } catch { + // A partial or unrecognised line is never fatal: the terminal + // `result` envelope carries the authoritative text either way. + return { kind: "ignore" }; + } + if (parsed.type === "result") return { kind: "final", raw: trimmed }; + if (parsed.type === "rate_limit_event") { + const info = parsed.rate_limit_info ?? {}; + return info.status && info.status !== "allowed" + ? { + kind: "notice", + message: `claude rate limit ${info.status}${ + info.rateLimitType ? ` (${info.rateLimitType})` : "" + }`, + } + : { kind: "ignore" }; + } + if (parsed.type === "stream_event") { + const event = parsed.event ?? {}; + if ( + event.type === "content_block_delta" && + event.delta?.type === "text_delta" && + typeof event.delta.text === "string" + ) { + return { kind: "delta", text: event.delta.text }; + } + } + return { kind: "ignore" }; +} + +export const claudeCliAdapter: CliAdapterDescriptor = { + cli: "claude", + displayName: "Claude Code subscription", + defaultBinary: "claude", + defaultChatModel: CLAUDE_CLI_DEFAULT_CHAT_MODEL, + systemPrompt: CLAUDE_CLI_SYSTEM_PROMPT, + staticModels: CLAUDE_CLI_CHAT_MODELS, + contextWindow: CLAUDE_CLI_CONTEXT_WINDOW, + schemaDelivery: "inline", + streamMode: "ndjson", + installHint: + "Install Claude Code (https://claude.com/claude-code) and run `claude` once to sign in, or set llm.providers[].subscriptionCli.binPath to the binary's absolute path.", + authHint: "Run `claude` in a terminal and complete /login, then retry.", + buildStdin(prompt) { + // Claude takes the steering through --system-prompt, so the prompt + // reaches the model exactly as atomic-agent built it. + return prompt; + }, + completeArgs(input) { + return [...baseArgs(input), "--output-format", "json", ...tailArgs(input)]; + }, + streamArgs(input) { + return [ + ...baseArgs(input), + "--output-format", + "stream-json", + "--include-partial-messages", + // Verified requirement: `--print` with `--output-format=stream-json` + // errors out without it. + "--verbose", + ...tailArgs(input), + ]; + }, + healthArgs() { + // Cheap liveness only. It cannot detect a signed-out CLI — that + // surfaces on the first completion as SubscriptionCliAuthError — + // but a real turn would cost seconds and tokens on every poll. + return ["--version"]; + }, + parseResult, + parseStreamEvent, +}; diff --git a/src/llm/provider/subscription-cli/claude-cli-models.ts b/src/llm/provider/subscription-cli/claude-cli-models.ts new file mode 100644 index 00000000..0a8b8cbe --- /dev/null +++ b/src/llm/provider/subscription-cli/claude-cli-models.ts @@ -0,0 +1,41 @@ +/** + * Models the `claude` CLI accepts for `--model`. The CLI exposes no + * list command, so this is curated: aliases first because they keep + * working across releases, then the pinned ids for reproducibility. + * + * When Anthropic ships a new model, add its id here — nothing else in + * the provider needs to change. + */ +export const CLAUDE_CLI_MODEL_ALIASES = [ + "opus", + "sonnet", + "haiku", + "fable", +] as const; + +export const CLAUDE_CLI_MODEL_IDS = [ + "claude-opus-5", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-fable-5", + "claude-opus-4-8", +] as const; + +export const CLAUDE_CLI_CHAT_MODELS: readonly string[] = [ + ...CLAUDE_CLI_MODEL_ALIASES, + ...CLAUDE_CLI_MODEL_IDS, +]; + +/** + * The alias, not a pinned id: a subscription user wants the current + * model behind the name they already use in Claude Code. + */ +export const CLAUDE_CLI_DEFAULT_CHAT_MODEL = "sonnet"; + +/** + * Conservative floor rather than the 1M ceiling the top models carry. + * This only feeds `capabilities.contextWindow`, which the runtime uses + * to decide when to compact — overstating it for a `haiku` session + * would let the prompt grow past what that model accepts. + */ +export const CLAUDE_CLI_CONTEXT_WINDOW = 200_000; diff --git a/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts new file mode 100644 index 00000000..d3066323 --- /dev/null +++ b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts @@ -0,0 +1,81 @@ +import type { SubscriptionCliName } from "../../../config/llm-config.js"; +import type { CompletionResult } from "../completion-types.js"; + +/** Everything the argv builders need from one completion request. */ +export interface CliArgsInput { + /** + * Empty when the operator set no model and the CLI resolves one + * itself — Codex under a ChatGPT login rejects every explicit id, so + * the flag has to be omitted rather than guessed at. + */ + model: string; + systemPrompt: string; + /** JSON Schema from `CompletionRequest.responseFormat`, when set. */ + responseSchema?: Record; + /** Path to that schema on disk, for CLIs that take a file. */ + responseSchemaPath?: string; + maxBudgetUsd?: number; + extraArgs: readonly string[]; +} + +/** One parsed line of a streaming CLI's NDJSON output. */ +export type CliStreamEvent = + | { kind: "delta"; text: string } + /** Terminal envelope — the same payload the buffered path parses. */ + | { kind: "final"; raw: string } + /** Something worth logging but not worth failing on (rate-limit warnings). */ + | { kind: "notice"; message: string } + | { kind: "ignore" }; + +/** + * Everything that differs between one vendor CLI and another. The + * provider class holds no CLI-specific knowledge, so adding a CLI is a + * new descriptor plus a `SUBSCRIPTION_CLIS` entry — and a vendor + * changing its interface is an edit to one file. + */ +export interface CliAdapterDescriptor { + readonly cli: SubscriptionCliName; + readonly displayName: string; + readonly defaultBinary: string; + readonly defaultChatModel: string; + /** Replaces the CLI's own system prompt for the duration of a turn. */ + readonly systemPrompt: string; + readonly staticModels: readonly string[]; + readonly contextWindow: number; + /** + * How the CLI accepts a structured-output schema: `claude` takes it + * inline on argv, `codex` takes a path to a file on disk. + */ + readonly schemaDelivery: "inline" | "file" | "none"; + /** `"none"` means `completeStream` must fall back to buffering. */ + readonly streamMode: "ndjson" | "none"; + readonly installHint: string; + readonly authHint: string; + /** + * The text written to the child's stdin. Exists because only some + * CLIs have a system-prompt flag; the rest must carry that steering + * inside the prompt itself. + */ + buildStdin(prompt: string, systemPrompt: string): string; + completeArgs(input: CliArgsInput): string[]; + streamArgs(input: CliArgsInput): string[]; + healthArgs(): string[]; + parseResult(stdout: string, fallbackModel: string): CompletionResult; + parseStreamEvent(line: string): CliStreamEvent; +} + +const descriptors = new Map(); + +export function registerCliAdapter(descriptor: CliAdapterDescriptor): void { + descriptors.set(descriptor.cli, descriptor); +} + +export function resolveCliAdapter( + cli: SubscriptionCliName, +): CliAdapterDescriptor { + const descriptor = descriptors.get(cli); + if (!descriptor) { + throw new Error(`unknown subscription cli "${cli}"`); + } + return descriptor; +} diff --git a/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts b/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts new file mode 100644 index 00000000..fdba207a --- /dev/null +++ b/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { codexCliAdapter } from "./codex-cli-adapter.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, +} from "./subscription-cli-errors.js"; + +const input = { model: "", systemPrompt: "SYSTEM", extraArgs: [] as readonly string[] }; + +/** Captured verbatim from `codex exec --json` v0.148.0. */ +const SUCCESS = [ + JSON.stringify({ type: "thread.started", thread_id: "01a0" }), + JSON.stringify({ type: "turn.started" }), + JSON.stringify({ + type: "item.completed", + item: { id: "item_0", type: "agent_message", text: "OK" }, + }), + JSON.stringify({ + type: "turn.completed", + usage: { + input_tokens: 13459, + cached_input_tokens: 5888, + cache_write_input_tokens: 0, + output_tokens: 5, + reasoning_output_tokens: 27, + }, + }), +].join("\n"); + +describe("codexCliAdapter argv", () => { + it("runs exec headless, sandboxed, and stateless", () => { + const args = codexCliAdapter.completeArgs(input); + expect(args[0]).toBe("exec"); + expect(args).toContain("--json"); + expect(args).toContain("--ephemeral"); + expect(args).toContain("--skip-git-repo-check"); + expect(args).toContain("--ignore-user-config"); + expect(args.slice(args.indexOf("-s"), args.indexOf("-s") + 2)).toEqual([ + "-s", + "read-only", + ]); + // Trailing `-` is what makes Codex read the prompt from stdin. + expect(args[args.length - 1]).toBe("-"); + }); + + it("omits -m entirely when no model is configured", () => { + // Verified live: under a ChatGPT login Codex rejects every explicit + // model id and resolves one server-side. + expect(codexCliAdapter.completeArgs(input)).not.toContain("-m"); + expect(codexCliAdapter.defaultChatModel).toBe(""); + expect(codexCliAdapter.staticModels).toEqual([]); + }); + + it("passes an operator-chosen model when one is set", () => { + const args = codexCliAdapter.completeArgs({ ...input, model: "gpt-5.1" }); + expect(args[args.indexOf("-m") + 1]).toBe("gpt-5.1"); + }); + + it("takes the schema as a file path, never inline", () => { + expect(codexCliAdapter.schemaDelivery).toBe("file"); + const args = codexCliAdapter.completeArgs({ + ...input, + responseSchemaPath: "/tmp/s/schema.json", + }); + expect(args[args.indexOf("--output-schema") + 1]).toBe("/tmp/s/schema.json"); + expect(args).not.toContain("--json-schema"); + }); + + it("never passes the dangerous escape hatches", () => { + const args = codexCliAdapter.completeArgs({ + ...input, + extraArgs: ["--enable", "x"], + }); + expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(args).not.toContain("--dangerously-bypass-hook-trust"); + expect(args).not.toContain("--add-dir"); + }); + + it("carries the steering in stdin, since codex has no system-prompt flag", () => { + const stdin = codexCliAdapter.buildStdin("PROMPT", "SYSTEM"); + expect(stdin).toBe("SYSTEM\n\nPROMPT"); + expect(codexCliAdapter.completeArgs(input)).not.toContain("--system-prompt"); + }); +}); + +describe("codexCliAdapter parseResult", () => { + it("maps the real success stream", () => { + const result = codexCliAdapter.parseResult(SUCCESS, ""); + expect(result.content).toBe("OK"); + expect(result.finishReason).toBe("stop"); + expect(result.slotId).toBe(-1); + // cached_input_tokens is a subset of input_tokens here, unlike + // Claude's disjoint counters, so it is reported and not added. + expect(result.usage).toEqual({ + promptTokens: 13459, + completionTokens: 5 + 27, + totalTokens: 13459 + 32, + }); + expect(result.cacheHitTokens).toBe(5888); + }); + + it("throws on turn.failed even though codex exits 0", () => { + // The whole reason this parser cannot trust the exit code. + const failed = [ + JSON.stringify({ type: "turn.started" }), + JSON.stringify({ + type: "turn.failed", + error: { message: "The 'x' model is not supported when using Codex with a ChatGPT account." }, + }), + ].join("\n"); + expect(() => codexCliAdapter.parseResult(failed, "")).toThrow( + SubscriptionCliInvocationError, + ); + expect(() => codexCliAdapter.parseResult(failed, "")).toThrow( + /not supported when using Codex/, + ); + }); + + it("treats a stream with no turn.completed as a failure, not empty content", () => { + expect(() => + codexCliAdapter.parseResult( + JSON.stringify({ type: "thread.started" }), + "", + ), + ).toThrow(/no turn.completed/); + }); + + it("classifies a signed-out failure as an auth error", () => { + const failed = JSON.stringify({ + type: "turn.failed", + error: { message: "401 Unauthorized" }, + }); + expect(() => codexCliAdapter.parseResult(failed, "")).toThrow( + SubscriptionCliAuthError, + ); + }); + + it("ignores the non-fatal metadata warning when the turn still completes", () => { + const withWarning = [ + JSON.stringify({ + type: "item.completed", + item: { id: "item_0", type: "error", message: "Model metadata not found" }, + }), + JSON.stringify({ + type: "item.completed", + item: { id: "item_1", type: "agent_message", text: "fine" }, + }), + JSON.stringify({ type: "turn.completed", usage: {} }), + ].join("\n"); + expect(codexCliAdapter.parseResult(withWarning, "").content).toBe("fine"); + }); + + it("ignores malformed lines rather than failing the turn", () => { + const noisy = `not json\n${SUCCESS}\n\n`; + expect(codexCliAdapter.parseResult(noisy, "").content).toBe("OK"); + }); +}); diff --git a/src/llm/provider/subscription-cli/codex-cli-adapter.ts b/src/llm/provider/subscription-cli/codex-cli-adapter.ts new file mode 100644 index 00000000..688708e1 --- /dev/null +++ b/src/llm/provider/subscription-cli/codex-cli-adapter.ts @@ -0,0 +1,202 @@ +import type { CompletionResult } from "../completion-types.js"; +import type { + CliAdapterDescriptor, + CliArgsInput, + CliStreamEvent, +} from "./cli-adapter-descriptor.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, + looksLikeAuthFailure, +} from "./subscription-cli-errors.js"; + +/** + * Codex has no `--system-prompt`, so the steering has to ride inside the + * prompt. Kept short and prepended once, ahead of the two-zone prompt + * atomic-agent already built. + */ +export const CODEX_CLI_SYSTEM_PROMPT = + "You are being used as a text completion engine, not as an agent. " + + "Do NOT act on the request below and do NOT use any of your own tools: " + + "no shell, no file reads or writes, no search. Your own working " + + "directory is unrelated to the request and inspecting it is always " + + "wrong. The message below is a complete prompt that defines its own " + + "output protocol — usually a JSON array of tool calls to be executed " + + "by a different program. Your entire job is to produce the next " + + "message in that protocol, exactly as the prompt specifies. Emit only " + + "that, with no preamble, no commentary, and no explanation of what " + + "you would do. If the prompt asks for a file to be read, you emit the " + + "tool call that reads it; you never read it yourself."; + +function baseArgs(input: CliArgsInput): string[] { + return [ + "exec", + "--json", + // Atomic owns session state and re-sends the whole prompt each step. + "--ephemeral", + // The working directory is the state dir, which is not a repository. + "--skip-git-repo-check", + // Drops the operator's own config.toml, and with it their MCP + // servers, from what should be a stateless completion. + "--ignore-user-config", + // The closest Codex has to Claude's `--tools ""`. It does not remove + // the tools, it confines them: a model-generated command cannot + // write outside the sandbox. See the README for the honest limits. + "-s", + "read-only", + // Verified: under a ChatGPT login Codex rejects every explicit model + // id ("not supported when using Codex with a ChatGPT account") and + // resolves one server-side, so the flag is omitted unless the + // operator deliberately set one. + ...(input.model ? ["-m", input.model] : []), + // Unlike Claude's inline --json-schema, Codex reads the schema from + // a file the provider staged for us. + ...(input.responseSchemaPath + ? ["--output-schema", input.responseSchemaPath] + : []), + ...input.extraArgs, + // Trailing `-`: read the prompt from stdin rather than argv. + "-", + ]; +} + +interface CodexUsage { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + reasoning_output_tokens?: number; +} + +interface CodexEvent { + type?: string; + message?: string; + item?: { type?: string; text?: string; message?: string }; + usage?: CodexUsage; + error?: { message?: string }; +} + +function parseLine(line: string): CodexEvent | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + try { + return JSON.parse(trimmed) as CodexEvent; + } catch { + return null; + } +} + +/** + * Codex exits 0 even when the turn fails — a bad model id, an expired + * login and a rate limit all produce a clean exit with a `turn.failed` + * event. The stream is therefore the only reliable success signal, and + * this parser treats a missing `turn.completed` as a failure rather than + * returning empty content. + */ +function parseResult(stdout: string, fallbackModel: string): CompletionResult { + let text = ""; + let usage: CodexUsage | undefined; + let completed = false; + let failure: string | null = null; + + for (const line of stdout.split("\n")) { + const event = parseLine(line); + if (!event) continue; + if (event.type === "item.completed" && event.item) { + if (event.item.type === "agent_message" && event.item.text) { + text = event.item.text; + } else if (event.item.type === "error" && event.item.message) { + // Non-fatal on its own (e.g. "model metadata not found"); only + // a turn.failed decides the turn. + failure ??= event.item.message; + } + } else if (event.type === "turn.completed") { + completed = true; + usage = event.usage; + } else if (event.type === "turn.failed") { + failure = event.error?.message ?? failure ?? "turn failed"; + completed = false; + } else if (event.type === "error" && event.message) { + failure = event.message; + } + } + + if (!completed) { + const detail = failure ?? "codex produced no turn.completed event"; + if (looksLikeAuthFailure(detail)) { + throw new SubscriptionCliAuthError( + "codex", + "Run `codex login` and sign in with your ChatGPT account, then retry.", + detail.slice(0, 500), + ); + } + throw new SubscriptionCliInvocationError(`codex turn failed: ${detail}`); + } + + // `cached_input_tokens` is a subset of `input_tokens` here, unlike + // Claude's disjoint cache counters — so it is reported, not added. + const promptTokens = usage?.input_tokens ?? 0; + const completionTokens = + (usage?.output_tokens ?? 0) + (usage?.reasoning_output_tokens ?? 0); + + return { + content: text, + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 0, + predictedMs: 0, + promptTokens, + predictedTokens: completionTokens, + }, + cacheHitTokens: usage?.cached_input_tokens ?? 0, + slotId: -1, + modelId: fallbackModel || null, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + }, + finishReason: "stop", + }; +} + +export const codexCliAdapter: CliAdapterDescriptor = { + cli: "codex", + displayName: "OpenAI Codex subscription", + defaultBinary: "codex", + // Empty on purpose: Codex picks the model the account supports. + defaultChatModel: "", + staticModels: [], + // Codex does not publish a context window per model here; this only + // feeds compaction timing, so a conservative floor is the safe choice. + contextWindow: 200_000, + schemaDelivery: "file", + // No incremental text events were observed on `exec --json` — output + // arrives in one `item.completed`. Buffering is therefore honest + // rather than a limitation we could paper over. + streamMode: "none", + systemPrompt: CODEX_CLI_SYSTEM_PROMPT, + installHint: + "Install the Codex CLI (`npm i -g @openai/codex`) and run `codex login`, or set llm.providers[].subscriptionCli.binPath to the binary's absolute path.", + authHint: + "Run `codex login` and sign in with your ChatGPT account, then retry.", + buildStdin(prompt, systemPrompt) { + return `${systemPrompt}\n\n${prompt}`; + }, + completeArgs(input) { + return baseArgs(input); + }, + streamArgs(input) { + // streamMode is "none", so the provider never calls this; keeping it + // identical means a future streaming opt-in cannot drift. + return baseArgs(input); + }, + healthArgs() { + return ["--version"]; + }, + parseResult, + parseStreamEvent(): CliStreamEvent { + return { kind: "ignore" }; + }, +}; diff --git a/src/llm/provider/subscription-cli/index.ts b/src/llm/provider/subscription-cli/index.ts new file mode 100644 index 00000000..d2e14491 --- /dev/null +++ b/src/llm/provider/subscription-cli/index.ts @@ -0,0 +1,41 @@ +export { + claudeCliAdapter, + CLAUDE_CLI_SYSTEM_PROMPT, +} from "./claude-cli-adapter.js"; +export { + codexCliAdapter, + CODEX_CLI_SYSTEM_PROMPT, +} from "./codex-cli-adapter.js"; +export { + CLAUDE_CLI_CHAT_MODELS, + CLAUDE_CLI_CONTEXT_WINDOW, + CLAUDE_CLI_DEFAULT_CHAT_MODEL, +} from "./claude-cli-models.js"; +export { + registerCliAdapter, + resolveCliAdapter, + type CliAdapterDescriptor, + type CliArgsInput, + type CliStreamEvent, +} from "./cli-adapter-descriptor.js"; +export { registerBuiltInCliAdapters } from "./register-cli-adapters.js"; +export { resolveCliBinary } from "./resolve-cli-binary.js"; +export { + runCliCommand, + type CliRunner, + type CliRunOptions, + type CliRunOutcome, +} from "./run-cli-completion.js"; +export { + streamCliCommand, + type CliStreamRunner, +} from "./stream-cli-completion.js"; +export { + SubscriptionCliProvider, + type SubscriptionCliProviderOptions, +} from "./subscription-cli-provider.js"; +export { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; diff --git a/src/llm/provider/subscription-cli/register-cli-adapters.ts b/src/llm/provider/subscription-cli/register-cli-adapters.ts new file mode 100644 index 00000000..85d72361 --- /dev/null +++ b/src/llm/provider/subscription-cli/register-cli-adapters.ts @@ -0,0 +1,17 @@ +import { registerCliAdapter } from "./cli-adapter-descriptor.js"; +import { claudeCliAdapter } from "./claude-cli-adapter.js"; +import { codexCliAdapter } from "./codex-cli-adapter.js"; + +let registered = false; + +/** + * Wire the shipped CLI descriptors into the lookup. Idempotent, and + * called from the provider factory so importing the provider class + * alone never has a registration side effect. + */ +export function registerBuiltInCliAdapters(): void { + if (registered) return; + registered = true; + registerCliAdapter(claudeCliAdapter); + registerCliAdapter(codexCliAdapter); +} diff --git a/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts b/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts new file mode 100644 index 00000000..32f7912c --- /dev/null +++ b/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveCliBinary } from "./resolve-cli-binary.js"; + +describe("resolveCliBinary", () => { + it("prefers a configured binPath on every platform", () => { + expect(resolveCliBinary("claude", "/opt/bin/claude", "darwin")).toBe( + "/opt/bin/claude", + ); + expect(resolveCliBinary("claude", "C:\\bin\\claude.cmd", "win32")).toBe( + "C:\\bin\\claude.cmd", + ); + }); + + it("hands the bare name to spawn on posix", () => { + expect(resolveCliBinary("claude", undefined, "darwin")).toBe("claude"); + expect(resolveCliBinary("codex", undefined, "linux")).toBe("codex"); + }); + + it("finds the .cmd shim on windows, where spawn with shell:false would not", () => { + const present = new Set(["C:\\npm\\claude.cmd"]); + expect( + resolveCliBinary("claude", undefined, "win32", { PATH: "C:\\npm" }, (p) => + present.has(p), + ), + ).toBe("C:\\npm\\claude.cmd"); + }); + + it("falls back to the bare name on windows so ENOENT still surfaces", () => { + expect( + resolveCliBinary("claude", undefined, "win32", { PATH: "C:\\npm" }, () => false), + ).toBe("claude"); + }); +}); diff --git a/src/llm/provider/subscription-cli/resolve-cli-binary.ts b/src/llm/provider/subscription-cli/resolve-cli-binary.ts new file mode 100644 index 00000000..c89b6454 --- /dev/null +++ b/src/llm/provider/subscription-cli/resolve-cli-binary.ts @@ -0,0 +1,38 @@ +import { existsSync } from "node:fs"; +import { win32 } from "node:path"; + +/** + * Pick the command to spawn for a vendor CLI. + * + * A configured `binPath` always wins — that is the escape hatch for a + * binary outside `PATH`. Otherwise we hand the bare name to `spawn`, + * which resolves it through `PATH` itself, except on Windows: with + * `shell: false` Node will not try the `PATHEXT` suffixes, so + * `spawn("claude")` misses the `claude.cmd` shim npm installs. There we + * walk `PATH` ourselves and return the first suffixed hit. + */ +export function resolveCliBinary( + defaultBinary: string, + binPath?: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + fileExists: (path: string) => boolean = existsSync, +): string { + if (binPath && binPath.length > 0) return binPath; + if (platform !== "win32") return defaultBinary; + if (win32.isAbsolute(defaultBinary)) return defaultBinary; + + const suffixes = [".cmd", ".exe", ".bat", ""]; + // Windows path semantics regardless of the host we are running on, + // so the branch is testable from macOS/Linux. + for (const dir of (env.PATH ?? "").split(";")) { + if (dir.length === 0) continue; + for (const suffix of suffixes) { + const candidate = win32.join(dir, `${defaultBinary}${suffix}`); + if (fileExists(candidate)) return candidate; + } + } + // Nothing on PATH — hand back the bare name so the ENOENT surfaces + // from spawn with the standard not-installed message. + return defaultBinary; +} diff --git a/src/llm/provider/subscription-cli/run-cli-completion.test.ts b/src/llm/provider/subscription-cli/run-cli-completion.test.ts new file mode 100644 index 00000000..3b439af3 --- /dev/null +++ b/src/llm/provider/subscription-cli/run-cli-completion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { runCliCommand, type CliRunOptions } from "./run-cli-completion.js"; +import { SubscriptionCliAuthError } from "./subscription-cli-errors.js"; + +/** + * Real children, not a mocked runner: the case these cover is what + * happens to a pending stdin write when the child stops reading, which + * only the kernel can produce. + */ +function options(script: string, extra: Partial = {}): CliRunOptions { + return { + binary: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + timeoutMs: 15_000, + maxOutputBytes: 1024 * 1024, + installHint: "install it", + authHint: "log in", + ...extra, + }; +} + +/** + * Past the ~64 KiB pipe buffer. The provider's own comment notes a + * two-zone prompt "routinely exceeds the 128 KiB single-argument limit", + * so this is an ordinary session, not a pathological one. + */ +const BIG_PROMPT = "x".repeat(1024 * 1024); + +describe("runCliCommand with an undrained prompt", () => { + it("reports a signed-out CLI as an auth error, not a broken pipe", async () => { + const script = ` + process.stderr.write("Please run /login to authenticate", () => process.exit(1)); + `; + await expect( + runCliCommand(options(script, { input: BIG_PROMPT })), + ).rejects.toBeInstanceOf(SubscriptionCliAuthError); + }); + + it("refuses a half-delivered prompt even when the CLI exits 0", async () => { + // `codex` exits 0 even on failure, so without this the caller would + // parse a completion computed from a prompt we never finished + // sending and treat it as a good answer. + const script = `process.stdout.write("{}", () => process.exit(0));`; + await expect( + runCliCommand(options(script, { input: BIG_PROMPT })), + ).rejects.toThrow(/stopped reading the prompt/); + }); + + it("passes a prompt the CLI actually reads straight through", async () => { + const script = ` + let n = 0; + process.stdin.on("data", (c) => { n += c.length; }); + process.stdin.on("end", () => process.stdout.write(String(n))); + `; + const out = await runCliCommand(options(script, { input: BIG_PROMPT })); + expect(out.stdout).toBe(String(BIG_PROMPT.length)); + }); +}); diff --git a/src/llm/provider/subscription-cli/run-cli-completion.ts b/src/llm/provider/subscription-cli/run-cli-completion.ts new file mode 100644 index 00000000..1ba70c31 --- /dev/null +++ b/src/llm/provider/subscription-cli/run-cli-completion.ts @@ -0,0 +1,97 @@ +import { runCommand } from "../../../sandbox/command-runner.js"; +import { + isEnoent, + mapCliFailure, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; + +export interface CliRunOptions { + binary: string; + args: readonly string[]; + /** Prompt text, written to stdin. Never placed on argv — see the provider. */ + input?: string; + cwd: string; + timeoutMs: number; + maxOutputBytes: number; + signal?: AbortSignal; + installHint: string; + authHint: string; +} + +export interface CliRunOutcome { + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; +} + +/** + * Injection seam. Tests substitute their own runner so no test ever + * spawns a real CLI; mirrors `OpenAiProviderOptions.fetchImpl`. + */ +export type CliRunner = (options: CliRunOptions) => Promise; + +/** + * Run a vendor CLI to completion and hand back its stdout, or throw a + * typed error. Builds on `runCommand`, which already provides + * shell-free spawn, stdin injection, timeout, an output cap and Windows + * tree-kill; this adds the failure taxonomy on top, the same way + * `git-runner.ts` wraps it for git. + */ +export const runCliCommand: CliRunner = async (options) => { + let result; + try { + result = await runCommand(options.binary, [...options.args], { + cwd: options.cwd, + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + shell: false, + // Inherit the environment untouched. We deliberately neither set + // nor clear ANTHROPIC_API_KEY: setting it would silently move the + // user onto API billing, clearing it would break anyone who wants + // exactly that. + ...(options.input === undefined ? {} : { input: options.input }), + ...(options.signal ? { signal: options.signal } : {}), + }); + } catch (err) { + if (isEnoent(err)) { + throw new SubscriptionCliNotInstalledError( + options.binary, + options.installHint, + ); + } + throw err; + } + + // `inputTruncated` is its own failure condition: a CLI that stops + // reading mid-prompt answered a different question than the one we + // asked, and `codex` exits 0 even when it fails, so the exit code + // alone would let that through as a good completion. + if ( + result.exitCode !== 0 || + result.timedOut || + result.truncated || + result.inputTruncated + ) { + throw mapCliFailure({ + binary: options.binary, + installHint: options.installHint, + authHint: options.authHint, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + timedOut: result.timedOut, + truncated: result.truncated, + inputTruncated: result.inputTruncated, + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + }); + } + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + durationMs: result.durationMs, + }; +}; diff --git a/src/llm/provider/subscription-cli/stream-cli-completion.test.ts b/src/llm/provider/subscription-cli/stream-cli-completion.test.ts new file mode 100644 index 00000000..cf877706 --- /dev/null +++ b/src/llm/provider/subscription-cli/stream-cli-completion.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { CliRunOptions } from "./run-cli-completion.js"; +import { streamCliCommand } from "./stream-cli-completion.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; + +/** + * These exercise the real spawn/line-splitting path against a scripted + * node child — never against a vendor CLI. Mocking `child_process` + * instead would test the mock, not the buffering behaviour that the + * NDJSON reader actually has to get right. + */ +function options(script: string, extra: Partial = {}): CliRunOptions { + return { + binary: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + timeoutMs: 15_000, + maxOutputBytes: 1024 * 1024, + installHint: "install it", + authHint: "log in", + ...extra, + }; +} + +async function collect(opts: CliRunOptions): Promise { + const lines: string[] = []; + for await (const line of streamCliCommand(opts)) lines.push(line); + return lines; +} + +describe("streamCliCommand", () => { + it("reassembles lines split across chunk boundaries", async () => { + // Deliberately writes half a JSON object, pauses, then the rest. + const script = ` + process.stdout.write('{"type":"a"}\\n{"ty'); + setTimeout(() => { + process.stdout.write('pe":"b"}\\n{"type":"c"}\\n'); + }, 20); + `; + expect(await collect(options(script))).toEqual([ + '{"type":"a"}', + '{"type":"b"}', + '{"type":"c"}', + ]); + }); + + it("yields a final line that has no trailing newline", async () => { + const script = `process.stdout.write('one\\ntwo');`; + expect(await collect(options(script))).toEqual(["one", "two"]); + }); + + it("delivers the prompt on stdin", async () => { + const script = ` + let buf = ""; + process.stdin.on("data", (c) => { buf += c; }); + process.stdin.on("end", () => process.stdout.write(buf.length + "\\n")); + `; + expect(await collect(options(script, { input: "x".repeat(5000) }))).toEqual([ + "5000", + ]); + }); + + it("raises a typed error when the binary does not exist", async () => { + await expect( + collect(options("", { binary: "definitely-not-a-real-binary-xyz" })), + ).rejects.toBeInstanceOf(SubscriptionCliNotInstalledError); + }); + + it("surfaces stderr when the child exits non-zero", async () => { + const script = ` + process.stderr.write("weekly limit reached"); + process.exit(3); + `; + await expect(collect(options(script))).rejects.toThrow( + /exited with code 3[\s\S]*weekly limit reached/, + ); + }); + + it("maps a signed-out message to an auth error", async () => { + const script = ` + process.stderr.write("Please run /login to authenticate"); + process.exit(1); + `; + // Classified as an auth failure (not a generic non-zero exit), and + // the descriptor's own hint is what reaches the user. + await expect(collect(options(script))).rejects.toThrow( + /is not signed in\. log in/, + ); + }); + + it("stops the child when the caller aborts", async () => { + const controller = new AbortController(); + // Emits one line, then would hang for a minute. + const script = ` + process.stdout.write('{"type":"a"}\\n'); + setTimeout(() => {}, 60000); + `; + const lines: string[] = []; + const started = Date.now(); + await expect( + (async () => { + for await (const line of streamCliCommand( + options(script, { signal: controller.signal }), + )) { + lines.push(line); + controller.abort(); + } + })(), + ).rejects.toThrow(); + expect(lines).toEqual(['{"type":"a"}']); + // SIGTERM must land well before the child's own 60s timer. + expect(Date.now() - started).toBeLessThan(10_000); + }); + + it("does not leak the child when the consumer abandons the iterator", async () => { + const script = ` + process.stdout.write('{"type":"a"}\\n'); + setTimeout(() => {}, 60000); + `; + const iterator = streamCliCommand(options(script)); + const first = await iterator.next(); + expect(first.value).toBe('{"type":"a"}'); + // The generator's finally block is responsible for the kill. + await iterator.return(); + }); +}); + +/** A prompt well past the ~64 KiB pipe buffer, so the write cannot flush at once. */ +const BIG_PROMPT = "x".repeat(1024 * 1024); + +describe("streamCliCommand stdin", () => { + it("maps a signed-out CLI that never read a 1 MiB prompt to an auth error", async () => { + // Without an `error` listener on `child.stdin` the EPIPE from the + // undrained write is an uncaught exception, and + // `installGlobalErrorHandlers` keeps that fatal — the operator loses + // the session instead of being told to run /login. + const script = ` + process.stderr.write("Please run /login to authenticate", () => process.exit(1)); + `; + await expect( + collect(options(script, { input: BIG_PROMPT })), + ).rejects.toBeInstanceOf(SubscriptionCliAuthError); + }); + + it("survives an abort fired while a 1 MiB prompt is still draining", async () => { + // Ctrl+C in the TUI: onAbort -> stop("abort") -> SIGTERM lands on a + // child that has not read its stdin, so the pending write faults. + const controller = new AbortController(); + const script = ` + process.stdout.write('{"type":"a"}\\n'); + setTimeout(() => {}, 60000); + `; + const lines: string[] = []; + await expect( + (async () => { + for await (const line of streamCliCommand( + options(script, { input: BIG_PROMPT, signal: controller.signal }), + )) { + lines.push(line); + controller.abort(); + } + })(), + ).rejects.toThrow(); + expect(lines).toEqual(['{"type":"a"}']); + }); + + it("refuses a run whose prompt was only half delivered, even on exit 0", async () => { + // `codex` exits 0 even when it fails, so the exit code alone would + // let a completion computed from a truncated prompt through. + const script = ` + process.stdout.write('{"type":"a"}\\n', () => process.exit(0)); + `; + await expect(collect(options(script, { input: BIG_PROMPT }))).rejects.toThrow( + /stopped reading the prompt/, + ); + }); +}); + +describe("streamCliCommand SIGKILL escalation", () => { + const strays: number[] = []; + + afterEach(() => { + // Belt and braces: nothing this file spawns may outlive the suite. + for (const pid of strays.splice(0)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone, which is the point of the test + } + } + }); + + function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + async function waitUntilGone(pid: number, budgetMs: number): Promise { + const started = Date.now(); + while (Date.now() - started < budgetMs) { + if (!alive(pid)) return Date.now() - started; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return -1; + } + + it("force-kills a child that traps SIGTERM instead of orphaning it", async () => { + // The `finally` used to clear the SIGKILL timer `stop` had just + // armed, so this child survived every abort — one orphan per + // cancelled turn. It reports its own pid so the test can watch it. + const script = ` + process.on("SIGTERM", () => {}); + process.stdout.write(process.pid + "\\n"); + setInterval(() => {}, 1000); + `; + const iterator = streamCliCommand(options(script)); + const first = await iterator.next(); + const pid = Number(first.value); + expect(Number.isInteger(pid)).toBe(true); + strays.push(pid); + + await iterator.return(); + // SIGTERM is ignored, so only the 2s escalation can end it. + expect(alive(pid)).toBe(true); + const tookMs = await waitUntilGone(pid, 8_000); + expect(tookMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/llm/provider/subscription-cli/stream-cli-completion.ts b/src/llm/provider/subscription-cli/stream-cli-completion.ts new file mode 100644 index 00000000..3ac907d4 --- /dev/null +++ b/src/llm/provider/subscription-cli/stream-cli-completion.ts @@ -0,0 +1,177 @@ +import { spawn } from "node:child_process"; +import { isBrokenPipe } from "../../../sandbox/index.js"; +import type { CliRunOptions } from "./run-cli-completion.js"; +import { + isEnoent, + mapCliFailure, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; + +/** Grace period between asking a child to stop and killing it. */ +const SIGKILL_DELAY_MS = 2_000; +/** A single NDJSON line larger than this means the stream went wrong. */ +const MAX_LINE_BYTES = 4 * 1024 * 1024; + +export type CliStreamRunner = ( + options: CliRunOptions, +) => AsyncGenerator; + +/** + * Spawn a CLI and yield its stdout one line at a time. + * + * Separate from `runCliCommand` because the buffered runner resolves + * only once the process exits, which is exactly what streaming must + * avoid. The generator's `finally` always kills the child, so a consumer + * that abandons the iterator cannot leak a process. + */ +export const streamCliCommand: CliStreamRunner = async function* (options) { + const child = spawn(options.binary, [...options.args], { + cwd: options.cwd, + env: process.env, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + ...(process.platform === "win32" ? { windowsHide: true } : {}), + }); + + let stderr = ""; + let timedOut = false; + let inputTruncated = false; + let stdinError: Error | null = null; + let killTimer: NodeJS.Timeout | null = null; + let settled = false; + + const stop = (reason: "timeout" | "abort" | "done") => { + if (settled) return; + if (reason === "timeout") timedOut = true; + try { + child.kill("SIGTERM"); + } catch { + // already gone + } + // Escalate only if SIGTERM was not enough. A second `stop` (abort + // followed by the generator's own cleanup) must not re-arm it, or + // the first timer is orphaned and fires at a pid we no longer track. + if (killTimer) return; + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + }, SIGKILL_DELAY_MS); + killTimer.unref?.(); + }; + + const timer = + options.timeoutMs > 0 && Number.isFinite(options.timeoutMs) + ? setTimeout(() => stop("timeout"), options.timeoutMs) + : null; + const onAbort = () => stop("abort"); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + if (stderr.length < options.maxOutputBytes) stderr += chunk; + }); + + const exited = new Promise<{ code: number | null }>((resolve, reject) => { + child.on("error", (err) => { + settled = true; + reject( + isEnoent(err) + ? new SubscriptionCliNotInstalledError( + options.binary, + options.installHint, + ) + : err, + ); + }); + child.on("close", (code) => { + settled = true; + resolve({ code }); + }); + }); + + // The exit promise is awaited only after stdout drains, so attach a + // no-op handler now: a spawn error (ENOENT) rejects immediately and + // would otherwise be reported as an unhandled rejection before the + // real await picks it up. Other awaiters still see the rejection. + exited.catch(() => {}); + + // Ctrl+C in the TUI runs `onAbort` -> `stop("abort")` -> SIGTERM while + // a prompt past the pipe buffer (~64 KiB) is still draining, so the + // write fails with EPIPE. An `error` on a stream with no listener is + // fatal for the process, which would turn the most routine action in + // the TUI — cancelling a turn — into a lost session. The broken pipe + // is expected here; the child's exit code still reports the outcome. + child.stdin.on("error", (err: NodeJS.ErrnoException) => { + if (isBrokenPipe(err)) { + inputTruncated = true; + return; + } + stdinError ??= err; + }); + + if (options.input !== undefined) child.stdin.write(options.input); + child.stdin.end(); + + child.stdout.setEncoding("utf8"); + let buffer = ""; + try { + for await (const chunk of child.stdout as AsyncIterable) { + buffer += chunk; + if (buffer.length > MAX_LINE_BYTES) { + throw new Error( + `${options.binary} emitted a line larger than ${MAX_LINE_BYTES} bytes`, + ); + } + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + yield line; + newline = buffer.indexOf("\n"); + } + } + // A stream that ends without a trailing newline still has a line. + if (buffer.length > 0) yield buffer; + + const { code } = await exited; + // A stdin failure that is not a broken pipe is a local fault, not + // something the CLI's exit code explains — report it as itself. + if (stdinError) throw stdinError; + if (code !== 0 || timedOut || inputTruncated) { + throw mapCliFailure({ + binary: options.binary, + installHint: options.installHint, + authHint: options.authHint, + exitCode: code, + stdout: "", + stderr, + timedOut, + truncated: false, + inputTruncated, + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + }); + } + } finally { + if (timer) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + if (!settled) stop("done"); + // Cancel the SIGKILL escalation only once the child is actually + // gone. Clearing it unconditionally cancelled the timer `stop` had + // armed microseconds earlier, so a child that traps SIGTERM was + // never force-killed and survived as an orphan — one per aborted + // turn. While it is still alive, let the delay run and disarm on + // exit instead. + if (killTimer) { + const armed = killTimer; + const disarm = () => clearTimeout(armed); + // `.then(f, f)` rather than `.finally`: the latter returns a + // promise that re-throws, and nobody is left to await it here. + if (settled) disarm(); + else void exited.then(disarm, disarm); + } + } +}; diff --git a/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts b/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts new file mode 100644 index 00000000..d412794f --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + isEnoent, + looksLikeAuthFailure, + mapCliFailure, + SubscriptionCliAuthError, + SubscriptionCliInvocationError, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; + +const base = { + binary: "claude", + installHint: "Install Claude Code.", + authHint: "Run `claude` and complete /login.", + exitCode: 1, + stdout: "", + stderr: "", + timedOut: false, + truncated: false, + timeoutMs: 1000, + maxOutputBytes: 4096, +}; + +describe("isEnoent", () => { + it("detects the spawn error for a missing binary", () => { + expect(isEnoent(Object.assign(new Error("x"), { code: "ENOENT" }))).toBe(true); + expect(isEnoent(new Error("x"))).toBe(false); + expect(isEnoent(null)).toBe(false); + }); +}); + +describe("looksLikeAuthFailure", () => { + it("matches the signed-out phrasings", () => { + for (const text of [ + "Please run /login to authenticate", + "You are not logged in", + "Authentication required", + "Invalid API key", + "401 Unauthorized", + "credentials expired", + ]) { + expect(looksLikeAuthFailure(text)).toBe(true); + } + }); + + it("does not claim an auth problem for ordinary failures", () => { + // A false positive would send the user to /login for a rate limit. + for (const text of [ + "5-hour limit reached; resets at 14:00", + "network error: ECONNRESET", + "model not found", + "Overloaded", + ]) { + expect(looksLikeAuthFailure(text)).toBe(false); + } + }); +}); + +describe("mapCliFailure", () => { + it("reports a timeout with the budget that was exceeded", () => { + const err = mapCliFailure({ ...base, timedOut: true }); + expect(err).toBeInstanceOf(SubscriptionCliInvocationError); + expect(err.message).toMatch(/timed out after 1000ms/); + }); + + it("refuses to parse truncated output rather than failing later", () => { + const err = mapCliFailure({ ...base, truncated: true }); + expect(err.message).toMatch(/refusing to parse a truncated response/); + }); + + it("maps a signed-out CLI to an auth error carrying the hint", () => { + const err = mapCliFailure({ + ...base, + stderr: "Error: not logged in. Please run /login", + }); + expect(err).toBeInstanceOf(SubscriptionCliAuthError); + expect(err.message).toMatch(/complete \/login/); + }); + + it("passes an unexplained failure through verbatim", () => { + // Subscription rate limits have no structured form; swallowing the + // text would leave the user with an exit code and nothing else. + const err = mapCliFailure({ + ...base, + exitCode: 2, + stderr: "weekly limit reached, resets Monday", + }); + expect(err).toBeInstanceOf(SubscriptionCliInvocationError); + expect(err.message).toMatch(/exited with code 2/); + expect(err.message).toMatch(/weekly limit reached, resets Monday/); + }); + + it("truncates a huge stderr instead of pasting megabytes into the message", () => { + const err = mapCliFailure({ ...base, stderr: "e".repeat(10_000) }); + expect(err.message.length).toBeLessThan(3000); + }); +}); + +describe("error messages", () => { + it("tells the user how to fix a missing binary", () => { + const err = new SubscriptionCliNotInstalledError("claude", "Install it."); + expect(err.message).toMatch(/"claude" was not found on PATH/); + expect(err.message).toMatch(/Install it\./); + }); +}); diff --git a/src/llm/provider/subscription-cli/subscription-cli-errors.ts b/src/llm/provider/subscription-cli/subscription-cli-errors.ts new file mode 100644 index 00000000..fc3bad06 --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-errors.ts @@ -0,0 +1,127 @@ +/** + * Failure taxonomy for CLI-backed providers. The three cases the user + * can actually act on are kept apart from each other: the binary is + * missing, the CLI is signed out, or the invocation itself failed. + */ + +export class SubscriptionCliNotInstalledError extends Error { + constructor(binary: string, installHint: string) { + super(`"${binary}" was not found on PATH. ${installHint}`); + this.name = "SubscriptionCliNotInstalledError"; + } +} + +export class SubscriptionCliAuthError extends Error { + constructor(binary: string, authHint: string, detail?: string) { + super( + `"${binary}" is not signed in. ${authHint}${detail ? ` (${detail})` : ""}`, + ); + this.name = "SubscriptionCliAuthError"; + } +} + +export class SubscriptionCliInvocationError extends Error { + readonly exitCode: number | null; + constructor(message: string, exitCode: number | null = null) { + super(message); + this.name = "SubscriptionCliInvocationError"; + this.exitCode = exitCode; + } +} + +/** + * Signed-out CLIs do not use a stable exit code, so the text is the only + * signal. Kept deliberately narrow: a false positive here would relabel + * a real API error as "run /login" and send the user down a dead end. + */ +const AUTH_PATTERNS = [ + /\bplease run\s+\/login\b/i, + /\brun\s+`?\/login`?\b/i, + /\bnot (?:logged in|authenticated|signed in)\b/i, + /\bauthentication (?:required|failed|error)\b/i, + /\binvalid api key\b/i, + /\bunauthorized\b/i, + /\bcredentials (?:are )?(?:missing|expired|invalid)\b/i, +]; + +export function looksLikeAuthFailure(text: string): boolean { + return AUTH_PATTERNS.some((re) => re.test(text)); +} + +/** `spawn` reports a missing binary as an ENOENT on the error event. */ +export function isEnoent(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { code?: unknown }).code === "ENOENT" + ); +} + +export interface CliFailureInput { + binary: string; + installHint: string; + authHint: string; + exitCode: number | null; + stdout: string; + stderr: string; + timedOut: boolean; + truncated: boolean; + /** The CLI stopped reading stdin before the prompt was fully written. */ + inputTruncated?: boolean; + timeoutMs: number; + maxOutputBytes: number; +} + +const DETAIL_CHARS = 2048; + +/** + * Turn a finished-but-unhappy CLI run into a typed error. Callers hand + * the raw streams over verbatim: subscription rate-limit messages have + * no documented structured form, so swallowing the text would leave the + * user with an exit code and no explanation. + */ +export function mapCliFailure(input: CliFailureInput): Error { + if (input.timedOut) { + return new SubscriptionCliInvocationError( + `"${input.binary}" timed out after ${input.timeoutMs}ms`, + input.exitCode, + ); + } + if (input.truncated) { + return new SubscriptionCliInvocationError( + `"${input.binary}" produced more than ${input.maxOutputBytes} bytes; refusing to parse a truncated response`, + input.exitCode, + ); + } + const combined = `${input.stderr}\n${input.stdout}`; + if (looksLikeAuthFailure(combined)) { + return new SubscriptionCliAuthError( + input.binary, + input.authHint, + tail(input.stderr || input.stdout), + ); + } + // Checked after the auth patterns: a signed-out CLI is what usually + // drops the pipe, and "run /login" is the more actionable message. + if (input.inputTruncated) { + return new SubscriptionCliInvocationError( + `"${input.binary}" stopped reading the prompt before it was fully written (exit ${ + input.exitCode ?? "null" + }): ${tail(input.stderr || input.stdout) || "no output"}`, + input.exitCode, + ); + } + return new SubscriptionCliInvocationError( + `"${input.binary}" exited with code ${input.exitCode ?? "null"}: ${ + tail(input.stderr || input.stdout) || "no output" + }`, + input.exitCode, + ); +} + +function tail(text: string): string { + const trimmed = text.trim(); + return trimmed.length > DETAIL_CHARS + ? `…${trimmed.slice(-DETAIL_CHARS)}` + : trimmed; +} diff --git a/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts b/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts new file mode 100644 index 00000000..571b8137 --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; + +import type { AtomicAgentConfig } from "../../../config/index.js"; +import { getConfig } from "../../../config/index.js"; +import { VisionUnsupportedError } from "../llm-provider.js"; +import { + getProviderFactory, + type LlmProviderConfigEntry, +} from "../registry/provider-types.js"; +import { registerBuiltInProviderKinds } from "../registry/register-built-in-providers.js"; +import { claudeCliAdapter } from "./claude-cli-adapter.js"; +import type { CliRunOptions, CliRunOutcome } from "./run-cli-completion.js"; +import { SubscriptionCliProvider } from "./subscription-cli-provider.js"; +import { SubscriptionCliNotInstalledError } from "./subscription-cli-errors.js"; + +const SUCCESS = JSON.stringify({ + subtype: "success", + is_error: false, + result: "hello", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 3 }, +}); + +function stubRunner(stdout: string, calls: CliRunOptions[] = []) { + return async (options: CliRunOptions): Promise => { + calls.push(options); + return { stdout, stderr: "", exitCode: 0, durationMs: 1 }; + }; +} + +function makeProvider(overrides: Partial[0]> = {}) { + return new SubscriptionCliProvider(buildOptions(overrides)); +} + +function buildOptions(overrides: Record = {}) { + return { + id: "claude-cli", + descriptor: claudeCliAdapter, + cwd: "/tmp", + runCliImpl: stubRunner(SUCCESS), + ...overrides, + } as ConstructorParameters[0]; +} + +describe("SubscriptionCliProvider capabilities", () => { + it("declares the native transport with no vision and no slot affinity", () => { + const provider = makeProvider(); + // native_tools, despite never returning tool_calls: it routes + // step-executor down its guarded recovery ladder instead of the + // repair path, which would cost a second CLI invocation. + expect(provider.capabilities.toolTransport).toBe("native_tools"); + expect(provider.toolCallAdapter).not.toBeNull(); + expect(provider.streamConsumer).toBeNull(); + expect(provider.capabilities.vision).toBe(false); + expect(provider.capabilities.supportsSlotAffinity).toBe(false); + expect(provider.capabilities.supportsPromptCache).toBe(true); + expect(provider.capabilities.contextWindow).toBeGreaterThan(0); + }); + + it("rejects vision instead of pretending", async () => { + await expect( + makeProvider().describeImage({ prompt: "x", images: [] }), + ).rejects.toBeInstanceOf(VisionUnsupportedError); + }); + + it("lists models without spawning anything", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + expect(await provider.listModels()).toContain("sonnet"); + expect(calls).toHaveLength(0); + }); + + it("closes without error", async () => { + await expect(makeProvider().close()).resolves.toBeUndefined(); + }); +}); + +describe("SubscriptionCliProvider.complete", () => { + it("sends the prompt on stdin and never on argv", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + const prompt = "P".repeat(200_000); + const result = await provider.complete({ prompt }); + + expect(result.content).toBe("hello"); + expect(calls).toHaveLength(1); + expect(calls[0]?.input).toBe(prompt); + expect(calls[0]?.args.some((arg) => arg.includes("PPPP"))).toBe(false); + }); + + it("uses the configured model and appends extraArgs", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ + model: "opus", + extraArgs: ["--effort", "high"], + runCliImpl: stubRunner(SUCCESS, calls), + }); + await provider.complete({ prompt: "x" }); + const args = calls[0]?.args ?? []; + expect(args[args.indexOf("--model") + 1]).toBe("opus"); + expect(args.slice(-2)).toEqual(["--effort", "high"]); + }); + + it("forwards the abort signal to the child", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + const controller = new AbortController(); + await provider.complete({ prompt: "x", signal: controller.signal }); + expect(calls[0]?.signal).toBe(controller.signal); + }); + + it("passes responseFormat through as --json-schema", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + await provider.complete({ + prompt: "x", + responseFormat: { name: "vote", schema: { type: "object" } }, + }); + expect(calls[0]?.args).toContain("--json-schema"); + }); +}); + +describe("SubscriptionCliProvider.completeStream", () => { + it("falls back to one buffered chunk when streaming is disabled", async () => { + const provider = makeProvider({ streaming: false }); + const deltas: string[] = []; + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) { + if (next.value.delta) deltas.push(next.value.delta); + next = await iterator.next(); + } + expect(deltas).toEqual(["hello"]); + expect(next.value.content).toBe("hello"); + }); + + it("streams deltas and returns the parsed final envelope", async () => { + const lines = [ + JSON.stringify({ type: "system", subtype: "init" }), + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "he" }, + }, + }), + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "llo" }, + }, + }), + SUCCESS.replace('"subtype"', '"type":"result","subtype"'), + ]; + const provider = makeProvider({ + streamCliImpl: async function* () { + for (const line of lines) yield line; + }, + }); + const deltas: string[] = []; + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) { + if (next.value.delta) deltas.push(next.value.delta); + next = await iterator.next(); + } + expect(deltas).toEqual(["he", "llo"]); + expect(next.value.content).toBe("hello"); + }); + + it("emits the final text once when no delta was recognised", async () => { + // Safety net for a stream schema we do not control: a mismatch must + // degrade to buffered behaviour, never to an empty turn. + const provider = makeProvider({ + streamCliImpl: async function* () { + yield JSON.stringify({ type: "stream_event", event: { type: "unknown" } }); + yield SUCCESS.replace('"subtype"', '"type":"result","subtype"'); + }, + }); + const deltas: string[] = []; + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) { + if (next.value.delta) deltas.push(next.value.delta); + next = await iterator.next(); + } + expect(deltas).toEqual(["hello"]); + }); + + it("fails loudly when the stream ends with no result envelope", async () => { + const provider = makeProvider({ + streamCliImpl: async function* () { + yield JSON.stringify({ type: "system" }); + }, + }); + const iterator = provider.completeStream({ prompt: "x" }); + await expect( + (async () => { + let next = await iterator.next(); + while (!next.done) next = await iterator.next(); + })(), + ).rejects.toThrow(/without a result envelope/); + }); + + it("routes rate-limit notices to onNotice instead of failing", async () => { + const notices: string[] = []; + const provider = makeProvider({ + onNotice: (message: string) => notices.push(message), + streamCliImpl: async function* () { + yield JSON.stringify({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "five_hour" }, + }); + yield SUCCESS.replace('"subtype"', '"type":"result","subtype"'); + }, + }); + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) next = await iterator.next(); + expect(notices).toEqual(["claude rate limit rejected (five_hour)"]); + }); +}); + +describe("SubscriptionCliProvider.health", () => { + it("is reachable when the version probe exits cleanly", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ + runCliImpl: stubRunner("2.1.220 (Claude Code)", calls), + }); + const health = await provider.health(); + expect(health.reachable).toBe(true); + expect(calls[0]?.args).toEqual(["--version"]); + // A health probe must never send a prompt or cost tokens. + expect(calls[0]?.input).toBeUndefined(); + }); + + it("reports an actionable message when the binary is missing", async () => { + const provider = makeProvider({ + runCliImpl: async () => { + throw new SubscriptionCliNotInstalledError("claude", "Install it."); + }, + }); + const health = await provider.health(); + expect(health.reachable).toBe(false); + expect(health.error).toMatch(/not found on PATH/); + }); +}); + +describe("registry factory", () => { + it("builds the provider from a subscription-cli entry", async () => { + registerBuiltInProviderKinds(); + const factory = getProviderFactory("subscription-cli"); + expect(factory).toBeDefined(); + const entry: LlmProviderConfigEntry = { + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "opus", + subscriptionCli: { cli: "claude" }, + }; + const provider = await factory!({ + config: getConfig() as AtomicAgentConfig, + entry, + logger: { debug() {}, info() {}, warn() {}, error() {} } as never, + }); + expect(provider).toBeInstanceOf(SubscriptionCliProvider); + expect(provider.id).toBe("claude-cli"); + }); + + it("refuses an entry with no subscriptionCli block", () => { + registerBuiltInProviderKinds(); + const factory = getProviderFactory("subscription-cli")!; + // Config parsing rejects this first; the factory guard is the + // backstop for an entry built in code rather than loaded from disk. + expect(() => + factory({ + config: getConfig() as AtomicAgentConfig, + entry: { id: "claude-cli", kind: "subscription-cli" }, + logger: { debug() {}, info() {}, warn() {}, error() {} } as never, + }), + ).toThrow(/requires a subscriptionCli block/); + }); +}); diff --git a/src/llm/provider/subscription-cli/subscription-cli-provider.ts b/src/llm/provider/subscription-cli/subscription-cli-provider.ts new file mode 100644 index 00000000..b431cf87 --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-provider.ts @@ -0,0 +1,287 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + CompletionRequest, + CompletionResult, + StreamChunk, +} from "../completion-types.js"; +import type { + LlmProvider, + ProviderCapabilities, + ProviderHealthResult, + VisionRequest, + VisionResult, +} from "../llm-provider.js"; +import { VisionUnsupportedError } from "../llm-provider.js"; +import type { ToolCallAdapter } from "../adapters/tool-call-adapter.js"; +import { openAiToolCallAdapter } from "../openai/openai-tool-call-adapter.js"; +import type { CliAdapterDescriptor } from "./cli-adapter-descriptor.js"; +import { resolveCliBinary } from "./resolve-cli-binary.js"; +import { runCliCommand, type CliRunner } from "./run-cli-completion.js"; +import { + streamCliCommand, + type CliStreamRunner, +} from "./stream-cli-completion.js"; + +/** Matches `OpenAiProvider`'s default; a CLI turn is never quick. */ +const DEFAULT_TIMEOUT_MS = 600_000; +/** + * `runCommand` defaults to 256 KiB, which would silently truncate a long + * completion and hand `JSON.parse` a torn object. + */ +const MAX_COMPLETION_BYTES = 8 * 1024 * 1024; +const HEALTH_TIMEOUT_MS = 5_000; +const MAX_HEALTH_BYTES = 64 * 1024; + +export interface SubscriptionCliProviderOptions { + id: string; + descriptor: CliAdapterDescriptor; + /** Working directory for the child — the state dir, not the agent's cwd. */ + cwd: string; + model?: string; + binPath?: string; + extraArgs?: readonly string[]; + streaming?: boolean; + maxBudgetUsd?: number; + requestTimeoutMs?: number; + onNotice?: (message: string) => void; + /** Test seams; default to the real spawn-backed implementations. */ + runCliImpl?: CliRunner; + streamCliImpl?: CliStreamRunner; +} + +/** + * Drives an already-signed-in vendor CLI (`claude`, `codex`) as an LLM + * backend so a flat-rate subscription can power the agent with no API + * key. The CLI authenticates from its own session — this provider never + * reads, copies or replays OAuth tokens or keychain entries. + * + * Every CLI-specific decision lives in the descriptor; this class only + * knows how to run a process and shape the result. + */ +export class SubscriptionCliProvider implements LlmProvider { + readonly id: string; + readonly name: string; + readonly capabilities: ProviderCapabilities; + /** + * We never return `tool_calls`, yet the transport is `native_tools` + * and the adapter is present on purpose. On the grammar transport a + * format drift throws out of `parseToolCalls` and costs a second full + * CLI invocation on the repair path; on the native transport an empty + * `toolCalls` sends step-executor down its guarded recovery ladder, + * which parses the tool-call JSON out of `content` inside a + * try/catch and otherwise wraps the prose as a `reply`. Same result + * when the model complies, no extra process when it does not. + */ + readonly toolCallAdapter: ToolCallAdapter = openAiToolCallAdapter; + /** Streaming is owned end to end here; that seam consumes SSE bytes. */ + readonly streamConsumer = null; + + private readonly descriptor: CliAdapterDescriptor; + private readonly binary: string; + private readonly cwd: string; + private readonly model: string; + private readonly extraArgs: readonly string[]; + private readonly streamingEnabled: boolean; + private readonly maxBudgetUsd: number | undefined; + private readonly timeoutMs: number; + private readonly onNotice: ((message: string) => void) | undefined; + private readonly runCli: CliRunner; + private readonly streamCli: CliStreamRunner; + + constructor(options: SubscriptionCliProviderOptions) { + const descriptor = options.descriptor; + this.id = options.id; + this.descriptor = descriptor; + this.name = descriptor.displayName; + this.binary = resolveCliBinary(descriptor.defaultBinary, options.binPath); + this.cwd = options.cwd; + // May be empty: Codex under a ChatGPT login rejects explicit model + // ids and resolves one itself, so the flag is then omitted. + this.model = options.model ?? descriptor.defaultChatModel; + this.extraArgs = options.extraArgs ?? []; + this.streamingEnabled = + descriptor.streamMode === "ndjson" && options.streaming !== false; + this.maxBudgetUsd = options.maxBudgetUsd; + this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS; + this.onNotice = options.onNotice; + this.runCli = options.runCliImpl ?? runCliCommand; + this.streamCli = options.streamCliImpl ?? streamCliCommand; + this.capabilities = { + vision: false, + visionSource: "config-disabled", + toolTransport: "native_tools", + contextWindow: descriptor.contextWindow, + supportsParallelTools: false, + // Every completion is a fresh process; there is no slot to pin. + supportsSlotAffinity: false, + // Verified: server-side prompt caching survives across separate + // invocations, so the KV-stable two-zone prompt still pays off. + supportsPromptCache: true, + reasoningFormat: "none", + }; + } + + async complete(request: CompletionRequest): Promise { + const staged = await this.stageSchema(request); + try { + const args = this.descriptor.completeArgs( + this.argsInput(request, staged.path), + ); + const outcome = await this.runCli(this.runOptions(args, request)); + return this.descriptor.parseResult(outcome.stdout, this.model); + } finally { + await staged.cleanup(); + } + } + + /** + * Some CLIs take the structured-output schema inline on argv, others + * only as a path. Writing that file is a side effect, so it lives here + * rather than inside the argv builders, which stay pure and testable. + */ + private async stageSchema( + request: CompletionRequest, + ): Promise<{ path?: string; cleanup: () => Promise }> { + const noop = { cleanup: async () => {} }; + if ( + this.descriptor.schemaDelivery !== "file" || + !request.responseFormat + ) { + return noop; + } + const dir = await mkdtemp(join(tmpdir(), "atomic-cli-schema-")); + const path = join(dir, "schema.json"); + await writeFile(path, JSON.stringify(request.responseFormat.schema), "utf8"); + return { + path, + cleanup: async () => { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + }, + }; + } + + async *completeStream( + request: CompletionRequest, + ): AsyncGenerator { + if (!this.streamingEnabled) { + const result = await this.complete(request); + if (result.content.length > 0) { + yield { delta: result.content, reasoningDelta: "", done: false }; + } + yield { delta: "", reasoningDelta: "", done: true }; + return result; + } + + const args = this.descriptor.streamArgs(this.argsInput(request)); + const lines = this.streamCli(this.runOptions(args, request)); + let final: string | null = null; + let sawDelta = false; + + for await (const line of lines) { + const event = this.descriptor.parseStreamEvent(line); + if (event.kind === "delta") { + sawDelta = true; + yield { delta: event.text, reasoningDelta: "", done: false }; + } else if (event.kind === "final") { + final = event.raw; + } else if (event.kind === "notice") { + this.onNotice?.(event.message); + } + } + + if (final === null) { + throw new Error( + `${this.binary} stream ended without a result envelope`, + ); + } + const result = this.descriptor.parseResult(final, this.model); + // Safety net for a stream schema we do not control: if no delta was + // recognised, emit the authoritative text once so a mismatch + // degrades to buffered behaviour instead of an empty turn. + if (!sawDelta && result.content.length > 0) { + yield { delta: result.content, reasoningDelta: "", done: false }; + } + yield { delta: "", reasoningDelta: "", done: true }; + return result; + } + + async describeImage(_request: VisionRequest): Promise { + throw new VisionUnsupportedError(this.id); + } + + async health(): Promise { + const started = Date.now(); + try { + await this.runCli({ + binary: this.binary, + args: this.descriptor.healthArgs(), + cwd: this.cwd, + timeoutMs: HEALTH_TIMEOUT_MS, + maxOutputBytes: MAX_HEALTH_BYTES, + installHint: this.descriptor.installHint, + authHint: this.descriptor.authHint, + }); + return { + reachable: true, + status: null, + error: null, + latencyMs: Date.now() - started, + }; + } catch (err) { + return { + reachable: false, + status: null, + error: err instanceof Error ? err.message : String(err), + latencyMs: Date.now() - started, + }; + } + } + + async listModels(): Promise { + // Curated list, no probe: the CLI exposes no model-list command. + return this.descriptor.staticModels; + } + + async close(): Promise { + // Nothing to release — every invocation is its own short-lived process. + } + + private argsInput(request: CompletionRequest, schemaPath?: string) { + const delivery = this.descriptor.schemaDelivery; + return { + model: this.model, + systemPrompt: this.descriptor.systemPrompt, + ...(request.responseFormat && delivery === "inline" + ? { responseSchema: request.responseFormat.schema } + : {}), + ...(schemaPath ? { responseSchemaPath: schemaPath } : {}), + ...(this.maxBudgetUsd === undefined + ? {} + : { maxBudgetUsd: this.maxBudgetUsd }), + extraArgs: this.extraArgs, + }; + } + + private runOptions(args: readonly string[], request: CompletionRequest) { + return { + binary: this.binary, + args, + // The prompt goes on stdin, never argv: a two-zone prompt routinely + // exceeds the 128 KiB single-argument limit once the conversation + // zone fills, and argv delivery would fail with E2BIG on exactly + // the long sessions that matter most. + input: this.descriptor.buildStdin( + request.prompt, + this.descriptor.systemPrompt, + ), + cwd: this.cwd, + timeoutMs: this.timeoutMs, + maxOutputBytes: MAX_COMPLETION_BYTES, + installHint: this.descriptor.installHint, + authHint: this.descriptor.authHint, + ...(request.signal ? { signal: request.signal } : {}), + }; + } +} diff --git a/src/llm/provider/verify/classify-verify-response.test.ts b/src/llm/provider/verify/classify-verify-response.test.ts new file mode 100644 index 00000000..b7eb3608 --- /dev/null +++ b/src/llm/provider/verify/classify-verify-response.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { OpenAiHttpError } from "../openai/openai-http.js"; +import { + classifyVerifyResponse, + classifyVerifyTransportError, +} from "./classify-verify-response.js"; + +describe("classifyVerifyResponse", () => { + it("treats any 2xx as proof the key is live and funded", () => { + expect(classifyVerifyResponse(200, "{}")).toEqual({ + kind: "status", + status: "ok", + }); + }); + + it("reads 402 as an empty account", () => { + expect(classifyVerifyResponse(402, "Payment Required")).toEqual({ + kind: "status", + status: "no_balance", + }); + }); + + it("separates a dead key from a drained one on 401/403", () => { + expect(classifyVerifyResponse(401, "No auth credentials found")).toEqual({ + kind: "status", + status: "invalid_key", + }); + // Prepaid services answer 403 with a perfectly valid key once the + // credit is gone; refusing it as "wrong key" would send the operator + // hunting for a new one. + expect( + classifyVerifyResponse(403, '{"error":"insufficient credits"}'), + ).toEqual({ kind: "status", status: "no_balance" }); + }); + + it("keeps a bare 429 soft and a quota 429 hard", () => { + expect(classifyVerifyResponse(429, "slow down")).toEqual({ + kind: "status", + status: "rate_limited", + }); + expect( + classifyVerifyResponse(429, '{"error":{"code":"insufficient_quota"}}'), + ).toEqual({ kind: "status", status: "no_balance" }); + }); + + it("reads Gemini's 400 for a bad key as a bad key", () => { + // The OpenAI-compatible Gemini surface answers 400 INVALID_ARGUMENT + // where every other service answers 401. + expect( + classifyVerifyResponse( + 400, + '{"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT"}}', + ), + ).toEqual({ kind: "status", status: "invalid_key" }); + }); + + it("asks for the other token field instead of blaming the key", () => { + expect( + classifyVerifyResponse( + 400, + "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.", + ), + ).toEqual({ kind: "retry_token_field" }); + }); + + it("moves to the next candidate when the model is the problem", () => { + expect(classifyVerifyResponse(404, "no such model")).toEqual({ + kind: "retry_next_model", + }); + expect( + classifyVerifyResponse(400, '{"error":"The model `x` does not exist"}'), + ).toEqual({ kind: "retry_next_model" }); + }); + + it("falls back to a provider fault for anything else", () => { + expect(classifyVerifyResponse(503, "upstream unavailable")).toEqual({ + kind: "status", + status: "provider_error", + }); + }); +}); + +describe("classifyVerifyTransportError", () => { + it("tells our own deadline apart from an unreachable host", () => { + const timedOut = new OpenAiHttpError("t", null, "u", true, null, "p"); + expect(classifyVerifyTransportError(timedOut)).toBe("timeout"); + + const network = new OpenAiHttpError("n", null, "u", false, null, "p"); + expect(classifyVerifyTransportError(network)).toBe("unreachable"); + }); + + it("reports an abort as a cancellation", () => { + const abort = new Error("aborted"); + abort.name = "AbortError"; + expect(classifyVerifyTransportError(abort)).toBe("cancelled"); + }); +}); diff --git a/src/llm/provider/verify/classify-verify-response.ts b/src/llm/provider/verify/classify-verify-response.ts new file mode 100644 index 00000000..9e08abc1 --- /dev/null +++ b/src/llm/provider/verify/classify-verify-response.ts @@ -0,0 +1,91 @@ +/** + * Turning one HTTP answer into a verdict about the key. + * + * Providers disagree on how they say "no money" and "wrong key": OpenAI + * sends 429 `insufficient_quota`, OpenRouter 402, Anthropic-style + * gateways 403 with billing wording, and Gemini answers a 400 for a bad + * key rather than a 401. The status code alone is therefore not enough, + * so the body is consulted for wording before falling back to the code. + */ + +import { OpenAiHttpError } from "../openai/openai-http.js"; +import type { ProviderVerifyStatus } from "./verify-types.js"; + +export type VerifyResponseVerdict = + | { readonly kind: "status"; readonly status: ProviderVerifyStatus } + /** Same model, resend with the other max-tokens field. */ + | { readonly kind: "retry_token_field" } + /** This model is unusable for this key; try the next candidate. */ + | { readonly kind: "retry_next_model" }; + +const BILLING_WORDING = + /insufficient|quota|credit|billing|payment|balance|top ?up|out of funds|resource[_ ]exhausted/; +const KEY_WORDING = + /api[_ ]?key|unauthenticated|unauthorized|invalid authentication|permission denied/; +const MISSING_MODEL_WORDING = + /model.{0,40}(not found|does not exist|is not available|unknown|unsupported|invalid)|(not found|unknown|unsupported).{0,20}model/; +const TOKEN_FIELD_WORDING = /max_tokens|max_completion_tokens/; + +export function classifyVerifyResponse( + httpStatus: number, + body: string, +): VerifyResponseVerdict { + if (httpStatus >= 200 && httpStatus < 300) { + // A completion came back, so the account could pay for the token it + // just spent. That is the whole point of probing with a paid model. + return { kind: "status", status: "ok" }; + } + const text = body.toLowerCase(); + + if (httpStatus === 402) return verdict("no_balance"); + + if (httpStatus === 401 || httpStatus === 403) { + // Services that bill by prepaid credit answer 401/403 once the + // balance is gone, with a key that is otherwise perfectly valid. + return verdict(BILLING_WORDING.test(text) ? "no_balance" : "invalid_key"); + } + + if (httpStatus === 429) { + // Only a quota/credit refusal is a money problem. A bare 429 is the + // provider asking us to slow down, which proves the key works. + return verdict(BILLING_WORDING.test(text) ? "no_balance" : "rate_limited"); + } + + if (httpStatus === 404) return { kind: "retry_next_model" }; + + if (httpStatus === 400) { + // Gemini's OpenAI-compatible surface answers 400 INVALID_ARGUMENT + // for a bad key instead of 401. + if (KEY_WORDING.test(text)) return verdict("invalid_key"); + if (BILLING_WORDING.test(text)) return verdict("no_balance"); + if (MISSING_MODEL_WORDING.test(text)) return { kind: "retry_next_model" }; + // Newer OpenAI models reject `max_tokens` and want + // `max_completion_tokens`; that is our request being wrong, not the + // key, so the same model gets one more chance with the other field. + if (TOKEN_FIELD_WORDING.test(text)) return { kind: "retry_token_field" }; + } + + return verdict("provider_error"); +} + +/** A thrown transport failure, which says nothing about the key itself. */ +export function classifyVerifyTransportError(err: unknown): ProviderVerifyStatus { + if (err instanceof OpenAiHttpError) { + if (err.timedOut) return "timeout"; + if (err.status === null) return "unreachable"; + return "provider_error"; + } + if (isAbortError(err)) return "cancelled"; + return "unreachable"; +} + +export function isAbortError(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === "AbortError" || err.name === "TimeoutError") + ); +} + +function verdict(status: ProviderVerifyStatus): VerifyResponseVerdict { + return { kind: "status", status }; +} diff --git a/src/llm/provider/verify/index.ts b/src/llm/provider/verify/index.ts new file mode 100644 index 00000000..a100e465 --- /dev/null +++ b/src/llm/provider/verify/index.ts @@ -0,0 +1,20 @@ +export { + classifyVerifyResponse, + classifyVerifyTransportError, + type VerifyResponseVerdict, +} from "./classify-verify-response.js"; +export { + cheapestPaidOpenRouterModel, + pickProbeModels, +} from "./pick-probe-models.js"; +export { + PROVIDER_VERIFY_TIMEOUT_MS, + verifyProviderKey, +} from "./verify-provider-key.js"; +export { + isBlockingVerifyStatus, + type ProviderVerifyKind, + type ProviderVerifyResult, + type ProviderVerifyStatus, + type ProviderVerifyTarget, +} from "./verify-types.js"; diff --git a/src/llm/provider/verify/pick-probe-models.test.ts b/src/llm/provider/verify/pick-probe-models.test.ts new file mode 100644 index 00000000..b5f7a63b --- /dev/null +++ b/src/llm/provider/verify/pick-probe-models.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function importFresh(): Promise< + typeof import("./pick-probe-models.js") +> { + // The OpenRouter catalog caches at module scope, so a test that primes + // it would otherwise leak into the next one. + vi.resetModules(); + return import("./pick-probe-models.js"); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("pickProbeModels", () => { + it("never probes OpenRouter with a free model", async () => { + // A zero-cost model answers 200 on a key with no credit at all, + // which is exactly the case the check exists to catch. + const { pickProbeModels, cheapestPaidOpenRouterModel } = await importFresh(); + const cheapest = cheapestPaidOpenRouterModel(); + expect(cheapest).not.toBeNull(); + expect(cheapest).not.toBe("openrouter/auto"); + expect(cheapest).not.toContain(":free"); + + const picks = pickProbeModels({ kind: "openrouter" }); + expect(picks[0]).toBe(cheapest); + }); + + it("keeps the free rows of a live catalog out of the choice", async () => { + const { refreshOpenRouterChatCatalogFromApi } = await import( + "../openrouter/fetch-openrouter-chat-catalog.js" + ); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [ + { + id: "vendor/free-model:free", + name: "Free", + context_length: 128_000, + pricing: { prompt: "0", completion: "0" }, + supported_parameters: ["tools"], + }, + { + id: "vendor/cheap-model", + name: "Cheap", + context_length: 128_000, + pricing: { prompt: "0.0000001", completion: "0.0000002" }, + supported_parameters: ["tools"], + }, + ], + }), + })), + ); + await refreshOpenRouterChatCatalogFromApi(); + + const { cheapestPaidOpenRouterModel } = await import( + "./pick-probe-models.js" + ); + expect(cheapestPaidOpenRouterModel()).toBe("vendor/cheap-model"); + }); + + it("adds the operator's own pick as the fallback candidate", async () => { + const { pickProbeModels, cheapestPaidOpenRouterModel } = await importFresh(); + const picks = pickProbeModels({ + kind: "openrouter", + selectedModelId: "vendor/picked", + }); + expect(picks).toEqual([cheapestPaidOpenRouterModel(), "vendor/picked"]); + }); + + it("probes the chosen model where the catalog has no prices", async () => { + const { pickProbeModels } = await importFresh(); + const { AIMLAPI_DEFAULT_CHAT_MODEL } = await import( + "../aimlapi/aimlapi-models-catalog.js" + ); + const { GEMINI_DEFAULT_CHAT_MODEL } = await import( + "../gemini/gemini-provider.js" + ); + + expect( + pickProbeModels({ kind: "aimlapi", selectedModelId: "openai/gpt-5-nano" }), + ).toEqual(["openai/gpt-5-nano", AIMLAPI_DEFAULT_CHAT_MODEL]); + expect(pickProbeModels({ kind: "gemini" })).toEqual([ + GEMINI_DEFAULT_CHAT_MODEL, + ]); + }); + + it("uses the discovered list for an arbitrary compatible endpoint", async () => { + const { pickProbeModels } = await importFresh(); + expect( + pickProbeModels({ + kind: "openai-compatible", + selectedModelId: " ", + listedModelIds: ["local-a", "local-b"], + }), + ).toEqual(["local-a"]); + expect( + pickProbeModels({ + kind: "openai-compatible", + selectedModelId: "typed-id", + listedModelIds: ["local-a"], + }), + ).toEqual(["typed-id", "local-a"]); + }); +}); diff --git a/src/llm/provider/verify/pick-probe-models.ts b/src/llm/provider/verify/pick-probe-models.ts new file mode 100644 index 00000000..349c8f38 --- /dev/null +++ b/src/llm/provider/verify/pick-probe-models.ts @@ -0,0 +1,84 @@ +/** + * Which model the credential check should spend a token on. + * + * The check has to prove the account can actually pay, so a free model + * is the wrong instrument: `openrouter/auto` and every `:free` slug + * answer 200 on a key with zero credit, which would turn the balance + * check into a formality. Where the catalog carries prices we take the + * cheapest *paid* model; where it does not, the model the operator just + * chose is the honest probe — it is the one they are about to use. + */ + +import { AIMLAPI_DEFAULT_CHAT_MODEL } from "../aimlapi/aimlapi-models-catalog.js"; +import { GEMINI_DEFAULT_CHAT_MODEL } from "../gemini/gemini-provider.js"; +import { listOpenRouterChatPicks } from "../openrouter/fetch-openrouter-chat-catalog.js"; +import type { ProviderVerifyKind } from "./verify-types.js"; + +/** More than two candidates would turn a check into a shopping trip. */ +const MAX_PROBE_MODELS = 2; + +export function pickProbeModels(input: { + kind: ProviderVerifyKind; + /** The model the wizard is about to save, when it knows one. */ + selectedModelId?: string | null; + /** Ids already listed from `/v1/models`, when that call was made. */ + listedModelIds?: readonly string[]; +}): readonly string[] { + const selected = input.selectedModelId?.trim() || null; + const listed = input.listedModelIds?.filter((id) => id.length > 0) ?? []; + + if (input.kind === "openrouter") { + return dedupe([cheapestPaidOpenRouterModel(), selected]); + } + if (input.kind === "aimlapi") { + // The AI/ML API catalog carries no prices, so there is nothing to + // rank; the operator's own pick is the closest thing to a known cost. + return dedupe([selected, AIMLAPI_DEFAULT_CHAT_MODEL]); + } + if (input.kind === "gemini") { + return dedupe([selected, GEMINI_DEFAULT_CHAT_MODEL]); + } + // An arbitrary OpenAI-compatible endpoint has no catalog we can price, + // and its `/v1/models` list is already on hand from the model step. + return dedupe([selected, listed[0] ?? null]); +} + +/** + * Cheapest OpenRouter chat model with a non-zero input price, from the + * live catalog when it has been fetched and the static one otherwise. + * Ties break on output price, then id, so the choice is stable across + * runs rather than dependent on catalog order. + */ +export function cheapestPaidOpenRouterModel(): string | null { + let best: { id: string; input: number; output: number } | null = null; + for (const pick of listOpenRouterChatPicks()) { + const pricing = pick.entry.pricing; + if (!pricing || !(pricing.input > 0)) continue; + const candidate = { + id: pick.id, + input: pricing.input, + output: pricing.output ?? 0, + }; + if (!best || isCheaper(candidate, best)) best = candidate; + } + return best?.id ?? null; +} + +function isCheaper( + a: { id: string; input: number; output: number }, + b: { id: string; input: number; output: number }, +): boolean { + if (a.input !== b.input) return a.input < b.input; + if (a.output !== b.output) return a.output < b.output; + return a.id.localeCompare(b.id) < 0; +} + +function dedupe(ids: readonly (string | null)[]): readonly string[] { + const out: string[] = []; + for (const id of ids) { + if (!id || out.includes(id)) continue; + out.push(id); + if (out.length === MAX_PROBE_MODELS) break; + } + return out; +} diff --git a/src/llm/provider/verify/verify-provider-key.test.ts b/src/llm/provider/verify/verify-provider-key.test.ts new file mode 100644 index 00000000..a35f80ef --- /dev/null +++ b/src/llm/provider/verify/verify-provider-key.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; + +import { verifyProviderKey } from "./verify-provider-key.js"; +import type { ProviderVerifyTarget } from "./verify-types.js"; + +function target( + overrides: Partial = {}, +): ProviderVerifyTarget { + return { + label: "testprov", + baseUrl: "https://api.example.com", + apiPathPrefix: "/v1", + apiKey: "sk-secret-key", + probeModels: ["cheap-model"], + ...overrides, + }; +} + +function response(body: unknown, status = 200): Response { + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function bodyOf(call: Parameters[]): Record { + return JSON.parse(String((call[1] as RequestInit).body)) as Record< + string, + unknown + >; +} + +describe("verifyProviderKey", () => { + it("spends one token on the cheapest model and reports ok", async () => { + const fetchImpl = vi.fn(async () => response({ choices: [] })); + const result = await verifyProviderKey(target(), { fetchImpl }); + + expect(result).toMatchObject({ status: "ok", probedModel: "cheap-model" }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://api.example.com/v1/chat/completions"); + expect( + (init.headers as Record).authorization, + ).toBe("Bearer sk-secret-key"); + expect(bodyOf(fetchImpl.mock.calls[0] as never)).toMatchObject({ + model: "cheap-model", + max_tokens: 1, + stream: false, + }); + }); + + it("does not retry a refused key", async () => { + // The shared HTTP client retries three times with backoff; a key + // check must answer at the first no. + const fetchImpl = vi.fn(async () => + response({ error: "No auth credentials found" }, 401), + ); + const result = await verifyProviderKey(target(), { fetchImpl }); + + expect(result.status).toBe("invalid_key"); + expect(result.httpStatus).toBe(401); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("reports an empty account", async () => { + const fetchImpl = vi.fn(async () => response("Insufficient credits", 402)); + const result = await verifyProviderKey(target(), { fetchImpl }); + expect(result.status).toBe("no_balance"); + }); + + it("falls back to the second candidate when the first is gone", async () => { + const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { model: string }; + return body.model === "gone-model" + ? response({ error: "no such model" }, 404) + : response({ choices: [] }); + }); + const result = await verifyProviderKey( + target({ probeModels: ["gone-model", "live-model"] }), + { fetchImpl }, + ); + + expect(result).toMatchObject({ status: "ok", probedModel: "live-model" }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("resends with max_completion_tokens when the model demands it", async () => { + const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + return "max_tokens" in body + ? response( + { error: "Unsupported parameter: 'max_tokens'. Use 'max_completion_tokens'." }, + 400, + ) + : response({ choices: [] }); + }); + const result = await verifyProviderKey(target(), { fetchImpl }); + + expect(result.status).toBe("ok"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(bodyOf(fetchImpl.mock.calls[1] as never)).toMatchObject({ + max_completion_tokens: 1, + }); + }); + + it("gives up after three requests", async () => { + const fetchImpl = vi.fn(async () => response({ error: "not found" }, 404)); + const result = await verifyProviderKey( + target({ probeModels: ["a", "b"] }), + { fetchImpl }, + ); + + expect(result.status).toBe("model_unavailable"); + expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(3); + }); + + it("reports our own deadline as a timeout, not a bad key", async () => { + const fetchImpl = vi.fn( + (_url: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }), + ); + const result = await verifyProviderKey(target(), { + fetchImpl: fetchImpl as unknown as typeof fetch, + timeoutMs: 10, + }); + expect(result.status).toBe("timeout"); + }); + + it("reports a caller abort as a cancellation", async () => { + const controller = new AbortController(); + controller.abort(); + const fetchImpl = vi.fn(async () => response({ choices: [] })); + const result = await verifyProviderKey(target(), { + fetchImpl, + signal: controller.signal, + }); + expect(result.status).toBe("cancelled"); + }); + + it("reports an unreachable host without blaming the key", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("fetch failed"); + }); + const result = await verifyProviderKey(target(), { fetchImpl }); + expect(result.status).toBe("unreachable"); + }); + + it("never puts the key in the reported detail", async () => { + const fetchImpl = vi.fn(async () => + response("Bearer sk-secret-key rejected", 403), + ); + const result = await verifyProviderKey( + target({ apiKey: "sk-secret-key" }), + { fetchImpl }, + ); + expect(result.detail).not.toContain("sk-secret-key"); + }); +}); diff --git a/src/llm/provider/verify/verify-provider-key.ts b/src/llm/provider/verify/verify-provider-key.ts new file mode 100644 index 00000000..8a9f8f10 --- /dev/null +++ b/src/llm/provider/verify/verify-provider-key.ts @@ -0,0 +1,198 @@ +/** + * Prove a cloud API key is usable before anything is written to disk. + * + * A key can be well-formed, present in `.env` and completely dead: wrong + * service, revoked, or attached to an account with no credit. `/v1/models` + * does not settle it — plenty of endpoints list models for an + * unauthenticated caller, and none of them charge for the listing. The + * only answer that proves both authentication and funds is a real + * completion, so this asks for exactly one token from the cheapest model + * available (see `pick-probe-models`). + */ + +import { + openAiFetch, + type OpenAiHttpDeps, +} from "../openai/openai-http.js"; +import { + classifyVerifyResponse, + classifyVerifyTransportError, + isAbortError, +} from "./classify-verify-response.js"; +import type { + ProviderVerifyResult, + ProviderVerifyStatus, + ProviderVerifyTarget, +} from "./verify-types.js"; + +/** + * Short on purpose. This runs while the operator watches a wizard, and + * a slow provider is a reason to save with a warning, not to freeze the + * screen for the 600s a normal completion is allowed. + */ +export const PROVIDER_VERIFY_TIMEOUT_MS = 8_000; + +/** model → other token field → next model. Never more than that. */ +const MAX_VERIFY_REQUESTS = 3; + +/** Provider error bodies are quoted back bounded, same cap as the HTTP layer. */ +const VERIFY_DETAIL_MAX_LEN = 300; + +export async function verifyProviderKey( + target: ProviderVerifyTarget, + opts: { + signal?: AbortSignal; + timeoutMs?: number; + fetchImpl?: typeof fetch; + } = {}, +): Promise { + const startedAt = Date.now(); + const models = target.probeModels.filter((id) => id.length > 0); + if (models.length === 0) { + return result("model_unavailable", null, null, "no model to test with", startedAt); + } + + const deps: OpenAiHttpDeps = { + baseUrl: target.baseUrl, + apiKey: target.apiKey, + extraHeaders: target.extraHeaders ?? {}, + requestTimeoutMs: opts.timeoutMs ?? PROVIDER_VERIFY_TIMEOUT_MS, + fetchImpl: opts.fetchImpl ?? fetch, + label: target.label, + }; + const path = `${target.apiPathPrefix}/chat/completions`; + + let requests = 0; + let tokenField: "max_tokens" | "max_completion_tokens" = "max_tokens"; + let lastVerdict: { + status: ProviderVerifyStatus; + model: string; + httpStatus: number; + detail: string; + } | null = null; + + for (const model of models) { + // The token-field retry is per model: an endpoint that wants + // `max_completion_tokens` wants it for the next candidate too. + for (;;) { + if (requests >= MAX_VERIFY_REQUESTS) { + return lastVerdict + ? result( + lastVerdict.status, + lastVerdict.model, + lastVerdict.httpStatus, + lastVerdict.detail, + startedAt, + target.apiKey, + ) + : result("model_unavailable", model, null, "no usable model", startedAt); + } + if (opts.signal?.aborted) { + return result("cancelled", model, null, "check cancelled", startedAt); + } + requests += 1; + + let res: Response; + try { + res = await openAiFetch( + deps, + path, + probeBody(model, tokenField), + { ...(opts.signal ? { signal: opts.signal } : {}) }, + false, + "POST", + ); + } catch (err) { + if (opts.signal?.aborted || isAbortError(err)) { + return result("cancelled", model, null, "check cancelled", startedAt); + } + const status = classifyVerifyTransportError(err); + return result( + status, + model, + null, + err instanceof Error ? err.message : String(err), + startedAt, + target.apiKey, + ); + } + + const body = res.ok ? "" : await readBounded(res); + const verdict = classifyVerifyResponse(res.status, body); + if (verdict.kind === "retry_token_field" && tokenField === "max_tokens") { + tokenField = "max_completion_tokens"; + continue; + } + if (verdict.kind === "retry_next_model" || verdict.kind === "retry_token_field") { + lastVerdict = { + status: "model_unavailable", + model, + httpStatus: res.status, + detail: body, + }; + break; + } + return result(verdict.status, model, res.status, body, startedAt, target.apiKey); + } + } + + return lastVerdict + ? result( + lastVerdict.status, + lastVerdict.model, + lastVerdict.httpStatus, + lastVerdict.detail, + startedAt, + target.apiKey, + ) + : result("model_unavailable", models[0] ?? null, null, "no usable model", startedAt); +} + +/** + * One token, no sampling, no tools. Hand-built rather than reusing + * `buildOpenAiChatBody`, which pulls token limits out of the config and + * adds tool plumbing a probe has no use for. + */ +function probeBody( + model: string, + tokenField: "max_tokens" | "max_completion_tokens", +): Record { + return { + model, + messages: [{ role: "user", content: "ping" }], + [tokenField]: 1, + temperature: 0, + stream: false, + }; +} + +async function readBounded(res: Response): Promise { + const text = await res.text().catch(() => ""); + return text.slice(0, VERIFY_DETAIL_MAX_LEN); +} + +function result( + status: ProviderVerifyStatus, + probedModel: string | null, + httpStatus: number | null, + detail: string, + startedAt: number, + apiKey = "", +): ProviderVerifyResult { + return { + status, + probedModel, + httpStatus, + detail: redactKey(detail, apiKey).slice(0, VERIFY_DETAIL_MAX_LEN), + latencyMs: Date.now() - startedAt, + }; +} + +/** + * Some providers echo the offending credential back in the error body, + * and this detail is headed for a status line and the log file. + */ +function redactKey(detail: string, apiKey: string): string { + if (apiKey.length < 8) return detail; + return detail.split(apiKey).join("***"); +} diff --git a/src/llm/provider/verify/verify-types.ts b/src/llm/provider/verify/verify-types.ts new file mode 100644 index 00000000..17ab313e --- /dev/null +++ b/src/llm/provider/verify/verify-types.ts @@ -0,0 +1,67 @@ +/** + * Shapes for the pre-save credential check: what to probe, and what the + * probe concluded. Kept free of config and UI imports so the check can + * run from the wizard, from onboarding, or from a future "test key" + * action without dragging any of them along. + */ + +/** The cloud kinds a key can be checked for. Local servers never carry one. */ +export type ProviderVerifyKind = + | "openrouter" + | "aimlapi" + | "gemini" + | "openai-compatible"; + +export type ProviderVerifyStatus = + /** The provider answered a real completion: the key is live and funded. */ + | "ok" + /** The provider does not recognize this key, or refuses it outright. */ + | "invalid_key" + /** The key authenticates but the account cannot pay for a token. */ + | "no_balance" + /** None of the probe models exist for this key; auth stays unproven. */ + | "model_unavailable" + /** Throttled right now — which itself proves the key authenticated. */ + | "rate_limited" + /** No HTTP response at all: DNS, refused connection, TLS, offline. */ + | "unreachable" + /** Our own deadline fired before the provider answered. */ + | "timeout" + /** The provider failed in a way that says nothing about the key. */ + | "provider_error" + /** The operator (or the caller) aborted the check. */ + | "cancelled"; + +export interface ProviderVerifyTarget { + /** Service name for user-facing wording ("OpenRouter", "Groq"). */ + readonly label: string; + /** API root without the version prefix, already normalized. */ + readonly baseUrl: string; + /** Version prefix the service uses: `/v1`, Gemini's `/v1beta/openai`. */ + readonly apiPathPrefix: string; + /** Trimmed key. A target is never built without one. */ + readonly apiKey: string; + /** Ordered candidates; at most the first two are tried. */ + readonly probeModels: readonly string[]; + readonly extraHeaders?: Record; +} + +export interface ProviderVerifyResult { + readonly status: ProviderVerifyStatus; + /** The model the verdict came from, `null` when nothing was answered. */ + readonly probedModel: string | null; + readonly httpStatus: number | null; + /** Bounded provider text for the status line and logs; never the key. */ + readonly detail: string; + readonly latencyMs: number; +} + +/** + * The two verdicts that must stop a save. Everything else is a report: + * a machine behind a proxy, an offline laptop or a throttled key still + * has to be configurable, and refusing there would strand the operator + * with no way to enter a key at all. + */ +export function isBlockingVerifyStatus(status: ProviderVerifyStatus): boolean { + return status === "invalid_key" || status === "no_balance"; +} diff --git a/src/llm/reliability/classify-failure.test.ts b/src/llm/reliability/classify-failure.test.ts index d176d428..963847e2 100644 --- a/src/llm/reliability/classify-failure.test.ts +++ b/src/llm/reliability/classify-failure.test.ts @@ -57,3 +57,33 @@ describe("classifyFailure", () => { expect(classifyFailure(undefined)).toBe("tool"); }); }); + +describe("classifyFailure — raw network failures", () => { + it("maps undici's `fetch failed` to transport, not tool", () => { + const inner = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:19091"), { + code: "ECONNREFUSED", + }); + const err = Object.assign(new TypeError("fetch failed"), { cause: inner }); + expect(classifyFailure(err)).toBe("transport"); + }); + + it("maps a socket that died mid-body to transport", () => { + expect(classifyFailure(new Error("terminated"))).toBe("transport"); + expect(classifyFailure(new Error("socket hang up"))).toBe("transport"); + }); + + it("still treats a genuine runtime bug as a tool failure", () => { + expect(classifyFailure(new TypeError("x.map is not a function"))).toBe( + "tool", + ); + }); + + it("keeps cancellation ahead of the network branch", () => { + // An aborted request surfaces as ECONNRESET; user intent still wins. + const err = Object.assign(new Error("The operation was aborted"), { + name: "AbortError", + code: "ECONNRESET", + }); + expect(classifyFailure(err)).toBe("cancelled"); + }); +}); diff --git a/src/llm/reliability/classify-failure.ts b/src/llm/reliability/classify-failure.ts index 3ef992e1..08764071 100644 --- a/src/llm/reliability/classify-failure.ts +++ b/src/llm/reliability/classify-failure.ts @@ -3,6 +3,7 @@ import { OpenAiHttpError } from "../provider/openai/openai-http.js"; import { ToolCallParseError } from "../grammar/tool-call-grammar.js"; import type { LlmFailureCategory } from "./failure-category.js"; import { LlmFailure } from "./llm-failures.js"; +import { isNetworkError } from "./network-error.js"; /** * Classify any thrown value into the canonical failure taxonomy. @@ -13,6 +14,14 @@ import { LlmFailure } from "./llm-failures.js"; * classifier from legacy surfaces (direct `LlamaServerError` throws, * grammar parser errors, abort signals, and anything else treated as * a tool-layer problem by default). + * + * The `isNetworkError` branch sits between the abort check and that + * default: an untyped socket failure (MCP streamable-http, embeddings, + * a vendor SDK with its own `fetch`) is a `transport` problem even + * though no typed client wrapped it. Filing one as `tool` is wrong in + * both directions — the user reads "Turn failed [tool]" for someone + * else's dead socket, and `shouldAdvance` refuses to fall over to the + * next provider because a tool failure is by definition our own bug. */ export function classifyFailure(err: unknown): LlmFailureCategory { if (err instanceof LlmFailure) return err.category; @@ -28,6 +37,9 @@ export function classifyFailure(err: unknown): LlmFailureCategory { // TransportError contract. if (err instanceof OpenAiHttpError) return "transport"; if (isAbortError(err)) return "cancelled"; + // Checked after the abort branch on purpose: an aborted request can + // surface as ECONNRESET, and a user pressing Esc is not a fallover. + if (isNetworkError(err)) return "transport"; return "tool"; } diff --git a/src/llm/reliability/index.ts b/src/llm/reliability/index.ts index 065207a7..b17ef8db 100644 --- a/src/llm/reliability/index.ts +++ b/src/llm/reliability/index.ts @@ -12,5 +12,9 @@ export { } from "./llm-failures.js"; export type { LlmFailureOptions } from "./llm-failures.js"; export { classifyFailure } from "./classify-failure.js"; +export { + isNetworkError, + readNetworkErrorCode, +} from "./network-error.js"; export { detectModelFailure } from "./detect-model-failure.js"; export type { DetectedModelFailure } from "./detect-model-failure.js"; diff --git a/src/llm/reliability/network-error.test.ts b/src/llm/reliability/network-error.test.ts new file mode 100644 index 00000000..c4cdf978 --- /dev/null +++ b/src/llm/reliability/network-error.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { isNetworkError, readNetworkErrorCode } from "./network-error.js"; + +/** The shape undici throws from `fetch` when the connection fails. */ +function fetchFailed(cause: unknown): TypeError { + return Object.assign(new TypeError("fetch failed"), { cause }); +} + +describe("readNetworkErrorCode", () => { + it("reads a connection errno off the error itself", () => { + const err = Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }); + expect(readNetworkErrorCode(err)).toBe("ECONNREFUSED"); + }); + + it("reads the errno out of undici's cause chain", () => { + const inner = Object.assign(new Error("read ECONNRESET"), { + code: "ECONNRESET", + }); + expect(readNetworkErrorCode(fetchFailed(inner))).toBe("ECONNRESET"); + }); + + it("accepts undici's own UND_ERR_* codes", () => { + const inner = Object.assign(new Error("other side closed"), { + code: "UND_ERR_SOCKET", + }); + expect(readNetworkErrorCode(fetchFailed(inner))).toBe("UND_ERR_SOCKET"); + }); + + it("ignores stdio errnos — a broken local pipe is not an upstream failure", () => { + const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + expect(readNetworkErrorCode(err)).toBeUndefined(); + const eio = Object.assign(new Error("write EIO"), { code: "EIO" }); + expect(readNetworkErrorCode(eio)).toBeUndefined(); + }); + + it("ignores unrelated codes and non-errors", () => { + expect( + readNetworkErrorCode(Object.assign(new Error("x"), { code: "ENOENT" })), + ).toBeUndefined(); + expect(readNetworkErrorCode("boom")).toBeUndefined(); + expect(readNetworkErrorCode(null)).toBeUndefined(); + }); + + it("survives a self-referential cause chain", () => { + const err = new Error("loop") as Error & { cause?: unknown }; + err.cause = err; + expect(readNetworkErrorCode(err)).toBeUndefined(); + }); +}); + +describe("isNetworkError", () => { + it("recognises a bare `fetch failed` with no errno anywhere", () => { + expect(isNetworkError(new TypeError("fetch failed"))).toBe(true); + }); + + it("recognises undici's socket messages", () => { + expect(isNetworkError(new Error("terminated"))).toBe(true); + expect(isNetworkError(new Error("socket hang up"))).toBe(true); + expect(isNetworkError(fetchFailed(new Error("other side closed")))).toBe( + true, + ); + }); + + it("recognises an errno carried anywhere in the chain", () => { + const inner = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:19091"), { + code: "ECONNREFUSED", + }); + expect(isNetworkError(fetchFailed(inner))).toBe(true); + }); + + it("does not claim ordinary runtime bugs", () => { + expect(isNetworkError(new TypeError("x.map is not a function"))).toBe(false); + expect(isNetworkError(new Error("tool crashed"))).toBe(false); + expect(isNetworkError(undefined)).toBe(false); + }); +}); diff --git a/src/llm/reliability/network-error.ts b/src/llm/reliability/network-error.ts new file mode 100644 index 00000000..baa9eb40 --- /dev/null +++ b/src/llm/reliability/network-error.ts @@ -0,0 +1,106 @@ +/** + * Recognition of raw network failures that reach the runtime *untyped*. + * + * `LlamaServerClient` and the OpenAI HTTP client both wrap their own + * failures into `LlamaServerError` / `OpenAiHttpError`, so the classifier + * can read a status off them. Everything else that talks HTTP — MCP + * streamable-http transports, embedding calls, vendor SDKs that bring + * their own `fetch` — throws whatever `undici` threw: a bare + * `TypeError: fetch failed` whose `cause` carries the real errno, or an + * `Error: terminated` when the socket dies mid-body. + * + * Without this recognition those land in `classifyFailure`'s catch-all + * and are filed as `tool` failures, which is wrong twice over: the user + * is told "Turn failed [tool]" for someone else's dead socket, and + * `shouldAdvance` refuses to fall over to the next provider because a + * tool failure is by definition our own bug, not the provider's. + */ + +/** + * Connection-level errno codes. Deliberately excludes `EPIPE` / `EIO`: + * those are overwhelmingly stdio (a closed host pipe, a vanished tty) + * rather than an upstream provider, and misreading one as `transport` + * would send the fallback chain hunting for a different provider over a + * broken *local* stream. + */ +const NETWORK_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ECONNABORTED", + "EHOSTUNREACH", + "ENETDOWN", + "ENETUNREACH", + "ENOTFOUND", + "EAI_AGAIN", + "EPROTO", + "ETIMEDOUT", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", +]); + +/** undici stamps its own failures with `UND_ERR_*` (`UND_ERR_SOCKET`, …). */ +const UNDICI_CODE_PREFIX = "UND_ERR_"; + +/** + * Messages undici/Node produce for a dead connection when no errno + * survives the wrapping. `fetch failed` is the generic outer message; + * the rest are the inner ones seen in the wild. + */ +const NETWORK_MESSAGES = [ + /^fetch failed$/i, + /^terminated$/i, + /socket hang up/i, + /other side closed/i, + /client network socket disconnected/i, + /network socket disconnected/i, +]; + +/** Depth cap on the `cause` walk — a chain longer than this is a cycle. */ +const MAX_CAUSE_DEPTH = 5; + +/** + * The errno-style code carried by `err` or anything in its `cause` + * chain, when that code names a connection-level failure. Returns + * `undefined` for everything else — including codes we deliberately do + * not treat as network failures. + */ +export function readNetworkErrorCode(err: unknown): string | undefined { + for (const link of causeChain(err)) { + const code = (link as { code?: unknown }).code; + if (typeof code !== "string") continue; + if (NETWORK_ERROR_CODES.has(code) || code.startsWith(UNDICI_CODE_PREFIX)) { + return code; + } + } + return undefined; +} + +/** + * True when `err` is a raw transport failure rather than a defect in our + * own code: an errno from the connection layer anywhere in the cause + * chain, or one of undici's stock "the socket is gone" messages. + * + * Callers must check for cancellation FIRST — an aborted request can + * surface as `ECONNRESET`, and a user pressing Esc is not a network + * failure. + */ +export function isNetworkError(err: unknown): boolean { + if (readNetworkErrorCode(err) !== undefined) return true; + for (const link of causeChain(err)) { + const message = (link as { message?: unknown }).message; + if (typeof message !== "string") continue; + if (NETWORK_MESSAGES.some((re) => re.test(message.trim()))) return true; + } + return false; +} + +/** `err` followed by its `cause` links, bounded and cycle-safe. */ +function* causeChain(err: unknown): Generator { + let current = err; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (typeof current !== "object" || current === null) return; + yield current; + const next = (current as { cause?: unknown }).cause; + if (next === current) return; + current = next; + } +} diff --git a/src/local-llm/backend-installer.test.ts b/src/local-llm/backend-installer.test.ts index 0f63e357..6a6822c7 100644 --- a/src/local-llm/backend-installer.test.ts +++ b/src/local-llm/backend-installer.test.ts @@ -1,13 +1,51 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import JSZip from "jszip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { downloadBackend, isBackendDownloaded } from "./backend-installer.js"; +import { + checkForBackendUpdate, + downloadBackend, + isBackendDownloaded, + resetLatestReleaseCache, +} from "./backend-installer.js"; import { resolveServerBinPath } from "./backend-paths.js"; -import { readBackendVersion } from "./backend-version.js"; +import { readBackendVersion, writeBackendVersion } from "./backend-version.js"; + +/** Minimal GitHub releases-list payload for the macOS arm64 asset. */ +function releasesResponse( + releases: Array<{ + tag: string; + url?: string; + publishedAt?: string | null; + assetName?: string; + }>, +): Response { + return new Response( + JSON.stringify( + releases.map((r) => ({ + tag_name: r.tag, + published_at: r.publishedAt === undefined ? null : r.publishedAt, + assets: [ + { + name: r.assetName ?? "llama-turboquant-macos-arm64.zip", + browser_download_url: r.url ?? "https://example.com/asset.zip", + }, + ], + })), + ), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} describe("backend-installer", () => { let dir: string; @@ -16,10 +54,12 @@ describe("backend-installer", () => { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "local-llm-be-")); prevFetch = globalThis.fetch; + resetLatestReleaseCache(); }); afterEach(() => { globalThis.fetch = prevFetch; + resetLatestReleaseCache(); rmSync(dir, { recursive: true, force: true }); }); @@ -180,6 +220,323 @@ describe("backend-installer", () => { } }); + it("keeps the working install when the download fails mid-flight", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + // Pre-existing, working install. + const backendDir = join(dir, "backend"); + mkdirSync(backendDir, { recursive: true }); + writeFileSync(join(backendDir, "llama-server"), "#!/bin/sh\necho old\n", { + mode: 0o755, + }); + writeBackendVersion(dir, { + tag: "turboquant-old", + downloadedAt: "2026-01-01T00:00:00.000Z", + asset: "llama-turboquant-macos-arm64.zip", + }); + + globalThis.fetch = vi.fn(async (url: string | URL) => { + const u = String(url); + if (u.includes("/releases")) { + return releasesResponse([ + { tag: "turboquant-new", publishedAt: "2026-02-01T00:00:00Z" }, + ]); + } + // Asset download dies part-way through, as a dropped connection does. + throw new Error("socket hang up"); + }) as typeof fetch; + + try { + await expect(downloadBackend(dir)).rejects.toThrow(/socket hang up/); + + const binPath = resolveServerBinPath(dir, "llama-server"); + expect(existsSync(binPath)).toBe(true); + expect(readFileSync(binPath, "utf-8").includes("echo old")).toBe(true); + expect(isBackendDownloaded(dir)).toBe(true); + // The version record must still describe the install that is live. + expect(readBackendVersion(dir)?.tag).toBe("turboquant-old"); + // No staging leftovers. + expect(existsSync(`${join(dir, "backend")}.next`)).toBe(false); + expect(existsSync(`${join(dir, "backend")}.old`)).toBe(false); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("keeps the working install when the archive has no server binary", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + const backendDir = join(dir, "backend"); + mkdirSync(backendDir, { recursive: true }); + writeFileSync(join(backendDir, "llama-server"), "#!/bin/sh\necho old\n", { + mode: 0o755, + }); + + // Well-formed zip, but it ships the wrong payload — the corrupt / + // mis-built release case. + const zip = new JSZip(); + zip.file("release-root/README.md", Buffer.from("no binary here")); + const zipBuf = await zip.generateAsync({ type: "nodebuffer" }); + + globalThis.fetch = vi.fn(async (url: string | URL) => { + const u = String(url); + if (u.includes("/releases")) { + return releasesResponse([ + { tag: "turboquant-broken", publishedAt: "2026-02-01T00:00:00Z" }, + ]); + } + return new Response(zipBuf, { + status: 200, + headers: { "content-length": String(zipBuf.length) }, + }); + }) as typeof fetch; + + try { + await expect(downloadBackend(dir)).rejects.toThrow(/not found after extract/); + const binPath = resolveServerBinPath(dir, "llama-server"); + expect(readFileSync(binPath, "utf-8").includes("echo old")).toBe(true); + expect(existsSync(`${join(dir, "backend")}.next`)).toBe(false); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("replaces a stale staging dir left by a previous crash", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + // Crash leftovers: a half-extracted `.next` carrying a foreign + // wrapper dir that would poison the flatten step, and a `.old`. + const stagingDir = join(dir, "backend.next"); + mkdirSync(join(stagingDir, "build", "bin"), { recursive: true }); + writeFileSync(join(stagingDir, "build", "bin", "llama-server"), "stale"); + mkdirSync(join(dir, "backend.old"), { recursive: true }); + + const zip = new JSZip(); + zip.file("llama-server", Buffer.from("#!/bin/sh\necho fresh\n")); + const zipBuf = await zip.generateAsync({ type: "nodebuffer" }); + + globalThis.fetch = vi.fn(async (url: string | URL) => { + const u = String(url); + if (u.includes("/releases")) { + return releasesResponse([ + { tag: "turboquant-fresh", publishedAt: "2026-02-01T00:00:00Z" }, + ]); + } + return new Response(zipBuf, { + status: 200, + headers: { "content-length": String(zipBuf.length) }, + }); + }) as typeof fetch; + + try { + await downloadBackend(dir); + const binPath = resolveServerBinPath(dir, "llama-server"); + expect(readFileSync(binPath, "utf-8").includes("echo fresh")).toBe(true); + expect(existsSync(join(dir, "backend", "build"))).toBe(false); + expect(existsSync(stagingDir)).toBe(false); + expect(existsSync(join(dir, "backend.old"))).toBe(false); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("records the release timestamp so later checks can order against it", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + const zip = new JSZip(); + zip.file("llama-server", Buffer.from("#!/bin/sh\necho ok\n")); + const zipBuf = await zip.generateAsync({ type: "nodebuffer" }); + + globalThis.fetch = vi.fn(async (url: string | URL) => { + const u = String(url); + if (u.includes("/releases")) { + return releasesResponse([ + { tag: "turboquant-dated", publishedAt: "2026-02-03T04:05:06Z" }, + ]); + } + return new Response(zipBuf, { + status: 200, + headers: { "content-length": String(zipBuf.length) }, + }); + }) as typeof fetch; + + try { + await downloadBackend(dir); + expect(readBackendVersion(dir)?.releasedAt).toBe("2026-02-03T04:05:06Z"); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("does not downgrade when a re-published older tag heads the list", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + writeBackendVersion(dir, { + tag: "turboquant-june", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset: "llama-turboquant-macos-arm64.zip", + releasedAt: "2026-06-01T00:00:00Z", + }); + // A maintainer re-published the old January release, so GitHub's + // created_at ordering puts it first. Its own timestamp is still older. + globalThis.fetch = vi.fn(async () => + releasesResponse([ + { tag: "turboquant-january", publishedAt: "2026-01-01T00:00:00Z" }, + { tag: "turboquant-june", publishedAt: "2026-06-01T00:00:00Z" }, + ]), + ) as typeof fetch; + + try { + const check = await checkForBackendUpdate(dir); + expect(check.updateAvailable).toBe(false); + expect(check.latestTag).toBe("turboquant-june"); + expect(check.currentTag).toBe("turboquant-june"); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("does not downgrade when the newest available release predates the install", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + writeBackendVersion(dir, { + tag: "turboquant-june", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset: "llama-turboquant-macos-arm64.zip", + releasedAt: "2026-06-01T00:00:00Z", + }); + // The June release was deleted from the repo; the newest one still + // listed is older than what this machine already runs. + globalThis.fetch = vi.fn(async () => + releasesResponse([ + { tag: "turboquant-may", publishedAt: "2026-05-01T00:00:00Z" }, + { tag: "turboquant-april", publishedAt: "2026-04-01T00:00:00Z" }, + ]), + ) as typeof fetch; + + try { + const check = await checkForBackendUpdate(dir); + expect(check.updateAvailable).toBe(false); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("still updates when the resolved release is genuinely newer", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + writeBackendVersion(dir, { + tag: "turboquant-june", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset: "llama-turboquant-macos-arm64.zip", + releasedAt: "2026-06-01T00:00:00Z", + }); + globalThis.fetch = vi.fn(async () => + releasesResponse([ + { tag: "turboquant-july", publishedAt: "2026-07-01T00:00:00Z" }, + { tag: "turboquant-june", publishedAt: "2026-06-01T00:00:00Z" }, + ]), + ) as typeof fetch; + + try { + const check = await checkForBackendUpdate(dir); + expect(check.updateAvailable).toBe(true); + expect(check.latestTag).toBe("turboquant-july"); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("updates on a variant change even though the tag is unchanged", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + // Installed the Vulkan build; the machine now warrants CUDA. Same + // tag, same timestamp — recency must not veto the variant re-pull. + writeBackendVersion(dir, { + tag: "turboquant-win", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset: "llama-turboquant-windows-x64-cuda-13.3.zip", + releasedAt: "2026-06-01T00:00:00Z", + }); + globalThis.fetch = vi.fn(async () => + releasesResponse([ + { + tag: "turboquant-win", + publishedAt: "2026-06-01T00:00:00Z", + assetName: "llama-turboquant-windows-x64-vulkan.zip", + }, + ]), + ) as typeof fetch; + + try { + const check = await checkForBackendUpdate(dir); + expect(check.updateAvailable).toBe(true); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("treats a page-1 miss for this platform as 'no update', not an error", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + writeBackendVersion(dir, { + tag: "turboquant-installed", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset: "llama-turboquant-macos-arm64.zip", + }); + // Page 1 is all Windows releases — the macOS asset fell off the end. + globalThis.fetch = vi.fn(async () => + releasesResponse([ + { + tag: "turboquant-windows-9", + publishedAt: "2026-07-01T00:00:00Z", + assetName: "llama-turboquant-windows-x64-vulkan.zip", + }, + ]), + ) as typeof fetch; + + try { + const check = await checkForBackendUpdate(dir); + expect(check.updateAvailable).toBe(false); + expect(check.latestTag).toBeNull(); + expect(check.currentTag).toBe("turboquant-installed"); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + + it("bounds the releases request with a timeout signal", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + let seenSignal: AbortSignal | undefined; + globalThis.fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => { + seenSignal = init?.signal ?? undefined; + return releasesResponse([ + { tag: "turboquant-x", publishedAt: "2026-07-01T00:00:00Z" }, + ]); + }) as typeof fetch; + + try { + await checkForBackendUpdate(dir); + // A black-holed connection must not hang the start path until the + // OS TCP timeout, so the request has to carry an abort signal. + expect(seenSignal).toBeInstanceOf(AbortSignal); + expect(seenSignal?.aborted).toBe(false); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + it("isBackendDownloaded is false on unsupported platform (darwin x64)", () => { const platformSpy = vi .spyOn(process, "platform", "get") diff --git a/src/local-llm/backend-installer.ts b/src/local-llm/backend-installer.ts index 56e303e1..76271de8 100644 --- a/src/local-llm/backend-installer.ts +++ b/src/local-llm/backend-installer.ts @@ -1,26 +1,18 @@ -import { execSync } from "node:child_process"; -import { - chmodSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - renameSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { dirname, join, relative } from "node:path"; - -import JSZip from "jszip"; +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; import { resolveBackendDir, resolveServerBinPath } from "./backend-paths.js"; +import { + extractBackendArchive, + rmDirQuiet, + swapInStagedBackend, +} from "./backend-staging.js"; import { downloadFile } from "./download-file.js"; -import { readBackendVersion, writeBackendVersion } from "./backend-version.js"; +import { readBackendVersion, writeBackendVersionAt } from "./backend-version.js"; import { resolvePlatformAsset, UnsupportedPlatformError } from "./platform-assets.js"; import { resolveDownloadAsset } from "./windows-backend-variant.js"; -const GITHUB_REPO = "AtomicBot-ai/atomic-llama-cpp-turboquant"; +const GITHUB_REPO = "AtomicBot-ai/atomic-llama-cpp-turboquant-nightly"; /** * Anonymous GitHub API allows ~60 req/h per IP. The Models tab polls @@ -41,16 +33,36 @@ const RELEASE_CACHE_TTL_MS = 10 * 60_000; */ const RELEASES_PER_PAGE = 30; +/** + * Timeout for the small releases-list JSON request. With auto-update on + * this call sits on the critical path of every managed start, and a + * black-holed connection (captive portal, DNS sinkhole) would otherwise + * hang until the OS TCP timeout — ~130s on Linux — before the daemon + * even begins to boot. Aborting at 5s turns that into a normal check + * failure and the caller starts the binary already on disk. Only the + * JSON request is bounded; the multi-hundred-MB asset download keeps + * the caller's own `opts.signal`. + */ +const RELEASES_FETCH_TIMEOUT_MS = 5_000; + export type ReleaseAsset = { name: string; browser_download_url: string }; export interface LatestReleaseInfo { tag: string; assets: ReleaseAsset[]; + /** + * `published_at` (or `created_at` when a release was never published) + * as ISO-8601, or null when GitHub omitted both. Used to order this + * release against the installed one — the repo is a nightly, and its + * tags are not semver-sortable. + */ + releasedAt: string | null; } interface ReleaseCacheEntry { fetchedAt: number; - release: LatestReleaseInfo; + /** `null` = scanned successfully, no release carries our asset. */ + release: LatestReleaseInfo | null; } /** @@ -66,9 +78,13 @@ export function resetLatestReleaseCache(): void { releaseCache.clear(); } +/** + * Resolve the newest release that ships this platform's asset, or + * `null` when none of the scanned releases carries it. + */ export async function fetchLatestRelease(opts?: { force?: boolean; -}): Promise { +}): Promise { const { assetName } = resolveDownloadAsset(); const cached = releaseCache.get(assetName); if ( @@ -86,7 +102,10 @@ export async function fetchLatestRelease(opts?: { const token = (process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim(); if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch(url, { headers }); + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(RELEASES_FETCH_TIMEOUT_MS), + }); if (!res.ok) { if (res.status === 403 || res.status === 429) { throw new GithubRateLimitedError(res.status); @@ -96,26 +115,52 @@ export async function fetchLatestRelease(opts?: { const data = (await res.json()) as Array<{ tag_name: string; draft?: boolean; + published_at?: string | null; + created_at?: string | null; assets: Array<{ name: string; browser_download_url: string }>; }>; - // GitHub lists releases newest-first; pick the first (non-draft) whose - // assets include the asset for the current platform. - const match = (Array.isArray(data) ? data : []).find( + // GitHub lists releases newest-first *by creation*, which is not the + // same as newest-published: re-publishing or backfilling an old tag + // moves it. Collect every non-draft release carrying our platform + // asset and pick the one with the newest release timestamp, falling + // back to GitHub's own order when timestamps are missing. + const candidates = (Array.isArray(data) ? data : []).filter( (r) => !r.draft && (r.assets ?? []).some((a) => a.name === assetName), ); - if (!match) { - throw new Error( - `No release found containing asset ${assetName} (scanned ${RELEASES_PER_PAGE} releases)`, - ); + if (candidates.length === 0) { + // A rarely-built platform's newest asset can fall off page 1 of the + // list. That is "nothing to update to", not a hard error: throwing + // here would fail the check on every single start and, with the + // pre-staging installer, was the only thing standing between the + // user and a working binary already on disk. + releaseCache.set(assetName, { fetchedAt: Date.now(), release: null }); + return null; } + const match = candidates.reduce((best, cur) => + releaseTime(cur) > releaseTime(best) ? cur : best, + ); const release: LatestReleaseInfo = { tag: match.tag_name, assets: match.assets ?? [], + releasedAt: match.published_at ?? match.created_at ?? null, }; releaseCache.set(assetName, { fetchedAt: Date.now(), release }); return release; } +/** + * Sort key for release recency. `-Infinity` for a release with no + * usable timestamp so it never displaces a dated one; ties keep the + * earlier (GitHub-ordered) candidate because `reduce` only swaps on a + * strict improvement. + */ +function releaseTime(r: { published_at?: string | null; created_at?: string | null }): number { + const raw = r.published_at ?? r.created_at; + if (!raw) return -Infinity; + const t = Date.parse(raw); + return Number.isNaN(t) ? -Infinity : t; +} + export class GithubRateLimitedError extends Error { constructor(public readonly status: number) { super( @@ -127,110 +172,62 @@ export class GithubRateLimitedError extends Error { export async function checkForBackendUpdate( dataDir: string, -): Promise<{ updateAvailable: boolean; latestTag: string; currentTag: string | null }> { +): Promise<{ + updateAvailable: boolean; + latestTag: string | null; + currentTag: string | null; +}> { const current = readBackendVersion(dataDir); const release = await fetchLatestRelease(); // A variant mismatch counts as an update even at the same tag: a // Windows box that installed the Vulkan build before its NVIDIA driver // was present would otherwise keep running Vulkan (and offloading to - // whatever device Vulkan enumerates) forever. + // whatever device Vulkan enumerates) forever. This is a property of + // the local machine, not of release ordering, so it is checked before + // (and independently of) the recency comparison. const variantStale = current?.asset !== undefined && current.asset !== resolveDownloadAsset().assetName; + if (release === null) { + // Nothing resolvable to update *to* — keep whatever is installed. + return { + updateAvailable: false, + latestTag: null, + currentTag: current?.tag ?? null, + }; + } return { - updateAvailable: current?.tag !== release.tag || variantStale, + updateAvailable: variantStale || isNewerRelease(current, release), latestTag: release.tag, currentTag: current?.tag ?? null, }; } -function normalizeZipPath(entryName: string): string { - return entryName.replace(/\\/g, "/"); -} - -/** - * Recursively walk `root`, return the absolute path to the first file - * whose basename equals `name`. Used after zip extraction to find the - * `llama-server` binary regardless of the archive's internal nesting - * (some releases wrap it under `build/bin/`, others under a single - * top-level folder, others drop it at the root). - */ -function findFileByName(root: string, name: string): string | null { - const stack = [root]; - while (stack.length) { - const cur = stack.pop()!; - let entries: string[]; - try { - entries = readdirSync(cur); - } catch { - continue; - } - for (const entry of entries) { - const full = join(cur, entry); - let st; - try { - st = statSync(full); - } catch { - continue; - } - if (st.isDirectory()) { - stack.push(full); - } else if (entry === name) { - return full; - } - } - } - return null; -} - -/** - * Move every file in `from` (recursively) into `to`, flattening into - * siblings at `to`'s root. Used to promote `backend/build/bin/*` or - * `backend/release-root/*` up to `backend/` after extraction so the - * `llama-server` binary lives at the path `resolveServerBinPath` - * expects. Existing files at the destination are overwritten. - */ -function moveContentsFlat(from: string, to: string): void { - const walk = (dir: string): void => { - const entries = readdirSync(dir); - for (const entry of entries) { - const src = join(dir, entry); - const st = statSync(src); - if (st.isDirectory()) { - walk(src); - continue; - } - const dst = join(to, entry); - mkdirSync(dirname(dst), { recursive: true }); - try { - renameSync(src, dst); - } catch { - // Cross-device or other rename failure — fall back to copy+unlink. - writeFileSync(dst, readFileSync(src)); - try { - rmSync(src, { force: true }); - } catch { - /* ignore */ - } - } - } - }; - walk(from); -} - /** - * Return the first path segment under `backendRoot` leading to - * `fileInside` — e.g. for `backendRoot=/.../backend` and - * `fileInside=/.../backend/build/bin/llama-server` this returns - * `/.../backend/build`. Used after the flatten step to delete the - * now-empty wrapper tree. + * Is `release` genuinely newer than what is installed? + * + * A bare `current.tag !== release.tag` also fires when the resolved + * release is *older* — which happens for real on a nightly repo whose + * tags are not semver-sortable: re-publishing or backfilling a tag + * moves it, and every client would silently downgrade on next start. + * Two contending releases would additionally re-download hundreds of MB + * and bounce the daemon on *every* start. + * + * Release timestamps are the only defensible ordering available here, + * so when both sides carry one we require a strict increase. When + * either is missing — no install yet, or a version file written before + * `releasedAt` existed — we fall back to tag inequality so those users + * still converge onto the current build once. */ -function topLevelWrapper(backendRoot: string, fileInside: string): string | null { - const rel = relative(backendRoot, fileInside); - // `relative` yields platform-native separators: `/` on POSIX, `\` on - // Windows. Match either so the wrapper dir is cleaned up on both. - const firstSep = rel.search(/[/\\]/); - if (firstSep < 0) return null; - return join(backendRoot, rel.slice(0, firstSep)); +function isNewerRelease( + current: { tag: string; releasedAt?: string } | null, + release: LatestReleaseInfo, +): boolean { + if (current === null) return true; + if (current.tag === release.tag) return false; + const currentAt = current.releasedAt ? Date.parse(current.releasedAt) : NaN; + const releaseAt = release.releasedAt ? Date.parse(release.releasedAt) : NaN; + if (Number.isNaN(currentAt) || Number.isNaN(releaseAt)) return true; + return releaseAt > currentAt; } export function isBackendDownloaded(dataDir: string): boolean { @@ -253,6 +250,11 @@ export async function downloadBackend( // Always hit GitHub for an actual install so we don't grab a stale // tag from the snapshot cache. const release = await fetchLatestRelease({ force: true }); + if (release === null) { + throw new Error( + `No release found containing asset ${assetName} (scanned ${RELEASES_PER_PAGE} releases)`, + ); + } const asset = release.assets.find((a) => a.name === assetName); if (!asset) { const known = release.assets.map((a) => a.name).join(", "); @@ -262,94 +264,49 @@ export async function downloadBackend( } const backendDir = resolveBackendDir(dataDir); - // Wipe any leftovers from a previous failed download so the flatten - // step can't pick up stale `bin/` or `build/` wrappers. Done before - // mkdir so a missing dir is fine. - try { - rmSync(backendDir, { recursive: true, force: true }); - } catch { - /* ignore */ - } - mkdirSync(backendDir, { recursive: true }); - const archivePath = join(backendDir, assetName); - - await downloadFile(asset.browser_download_url, archivePath, { - onProgress: opts?.onProgress, - userAgent: "atomic-agent/local-llm-backend-download", - signal: opts?.signal, - }); - - const zip = await JSZip.loadAsync(readFileSync(archivePath)); - // Extract preserving the archive's internal layout. Flattening happens - // in a second pass so we can support any of: - // * `llama-server` (flat) - // * `release-root/llama-server` (single top folder) - // * `build/bin/llama-server` (nested) - for (const entry of Object.values(zip.files)) { - if (entry.dir) continue; - const rel = normalizeZipPath(entry.name); - if (!rel || rel.endsWith("/")) continue; - const out = join(backendDir, rel); - mkdirSync(dirname(out), { recursive: true }); - const buf = await entry.async("nodebuffer"); - writeFileSync(out, buf); - try { - chmodSync(out, 0o755); - } catch { - /* Windows may ignore chmod */ - } - } + // Download and extract into a sibling staging dir, never into the + // live one. The old code wiped `backend/` first and only then pulled + // several hundred MB, so a network drop, a Ctrl-C, a corrupt zip or a + // full disk left the user with no backend at all — and with + // auto-update this path now runs on every managed start, not just an + // explicit `models update`. Siblings (not tmpdir) so the final swap + // stays on one filesystem and can be a rename. + const stagingDir = `${backendDir}.next`; + const retiredDir = `${backendDir}.old`; + // A previous crash can leave either behind; both are scratch, and a + // stale `.next` would otherwise poison the flatten step with foreign + // `bin/` or `build/` wrappers. + rmDirQuiet(stagingDir); + rmDirQuiet(retiredDir); + mkdirSync(stagingDir, { recursive: true }); - const foundBin = findFileByName(backendDir, binaryName); - if (!foundBin) { - throw new Error( - `llama-server binary not found after extract (searched for ${binaryName} under ${backendDir})`, - ); - } - const expectedBin = resolveServerBinPath(dataDir, binaryName); - if (foundBin !== expectedBin) { - // Promote the binary's parent directory contents into `backendDir` - // so `llama-server` (and any sibling shared libs) sit at the path - // the daemon lifecycle expects, then remove the now-orphaned - // wrapper directories (e.g. `build/`, `release-root/`). - moveContentsFlat(dirname(foundBin), backendDir); - const topWrapper = topLevelWrapper(backendDir, foundBin); - if (topWrapper !== null) { - try { - rmSync(topWrapper, { recursive: true, force: true }); - } catch { - /* ignore — stray files, not fatal */ - } - } - } + try { + const archivePath = join(stagingDir, assetName); + await downloadFile(asset.browser_download_url, archivePath, { + onProgress: opts?.onProgress, + userAgent: "atomic-agent/local-llm-backend-download", + signal: opts?.signal, + }); - if (process.platform === "darwin") { - try { - execSync(`xattr -cr "${backendDir}"`, { timeout: 10_000 }); - } catch { - /* xattr may fail */ - } - } + await extractBackendArchive(archivePath, stagingDir, binaryName); - rmSync(archivePath, { force: true }); + // The version record lives inside `backend/`, so it is staged with + // the rest of the tree and rides in on the swap. It therefore never + // describes anything other than what is actually live, and a + // failure before this point leaves the old record untouched. + writeBackendVersionAt(stagingDir, { + tag: release.tag, + downloadedAt: new Date().toISOString(), + asset: assetName, + ...(release.releasedAt ? { releasedAt: release.releasedAt } : {}), + }); - if (!existsSync(expectedBin)) { - throw new Error( - `llama-server binary not found after extract + flatten (expected ${binaryName} at ${expectedBin}; ` + - `original location was ${relative(backendDir, foundBin)})`, - ); + swapInStagedBackend(backendDir, stagingDir, retiredDir); + } catch (err) { + // Leave the existing install exactly as it was. + rmDirQuiet(stagingDir); + throw err; } - try { - chmodSync(expectedBin, 0o755); - } catch { - /* Windows */ - } - - writeBackendVersion(dataDir, { - tag: release.tag, - downloadedAt: new Date().toISOString(), - asset: assetName, - }); return { ok: true, tag: release.tag }; } diff --git a/src/local-llm/backend-staging.ts b/src/local-llm/backend-staging.ts new file mode 100644 index 00000000..f8550eac --- /dev/null +++ b/src/local-llm/backend-staging.ts @@ -0,0 +1,267 @@ +import { execSync } from "node:child_process"; +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative } from "node:path"; + +import JSZip from "jszip"; + +/** + * Filesystem side of a backend install: unpack an archive into a + * staging directory, normalise the layout, and swap it over the live + * one only once it is complete and usable. + * + * Kept separate from `backend-installer.ts` so the release-resolution + * logic there is not interleaved with extraction mechanics. + */ + +function normalizeZipPath(entryName: string): string { + return entryName.replace(/\\/g, "/"); +} + +/** + * Recursively walk `root`, return the absolute path to the first file + * whose basename equals `name`. Used after zip extraction to find the + * `llama-server` binary regardless of the archive's internal nesting + * (some releases wrap it under `build/bin/`, others under a single + * top-level folder, others drop it at the root). + */ +function findFileByName(root: string, name: string): string | null { + const stack = [root]; + while (stack.length) { + const cur = stack.pop()!; + let entries: string[]; + try { + entries = readdirSync(cur); + } catch { + continue; + } + for (const entry of entries) { + const full = join(cur, entry); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + stack.push(full); + } else if (entry === name) { + return full; + } + } + } + return null; +} + +/** + * Move every file in `from` (recursively) into `to`, flattening into + * siblings at `to`'s root. Used to promote `backend/build/bin/*` or + * `backend/release-root/*` up to `backend/` after extraction so the + * `llama-server` binary lives at the path `resolveServerBinPath` + * expects. Existing files at the destination are overwritten. + */ +function moveContentsFlat(from: string, to: string): void { + const walk = (dir: string): void => { + const entries = readdirSync(dir); + for (const entry of entries) { + const src = join(dir, entry); + const st = statSync(src); + if (st.isDirectory()) { + walk(src); + continue; + } + const dst = join(to, entry); + mkdirSync(dirname(dst), { recursive: true }); + try { + renameSync(src, dst); + } catch { + // Cross-device or other rename failure — fall back to copy+unlink. + writeFileSync(dst, readFileSync(src)); + try { + rmSync(src, { force: true }); + } catch { + /* ignore */ + } + } + } + }; + walk(from); +} + +/** + * Return the first path segment under `backendRoot` leading to + * `fileInside` — e.g. for `backendRoot=/.../backend` and + * `fileInside=/.../backend/build/bin/llama-server` this returns + * `/.../backend/build`. Used after the flatten step to delete the + * now-empty wrapper tree. + */ +function topLevelWrapper(backendRoot: string, fileInside: string): string | null { + const rel = relative(backendRoot, fileInside); + // `relative` yields platform-native separators: `/` on POSIX, `\` on + // Windows. Match either so the wrapper dir is cleaned up on both. + const firstSep = rel.search(/[/\\]/); + if (firstSep < 0) return null; + return join(backendRoot, rel.slice(0, firstSep)); +} + +/** + * Remove `dir` if it exists, ignoring failures. Used for staging / + * rollback scratch dirs where a leftover is a nuisance, not a fault. + */ +export function rmDirQuiet(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } +} + +/** + * Is the staged tree a usable install? Guards the swap: only an + * extraction that actually produced an executable server binary at the + * path the daemon launches is allowed to replace a working one. + */ +function stagedBinaryUsable(binPath: string): boolean { + try { + const st = statSync(binPath); + if (!st.isFile() || st.size === 0) return false; + } catch { + return false; + } + if (process.platform === "win32") return true; + try { + accessSync(binPath, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Extract `archivePath` into `stagingDir` and normalise the layout so + * `binaryName` ends up at the staging root, executable. Throws when the + * archive does not yield a usable server binary — the caller is + * expected to discard the staging dir and keep the previous install. + */ +export async function extractBackendArchive( + archivePath: string, + stagingDir: string, + binaryName: string, +): Promise { + const zip = await JSZip.loadAsync(readFileSync(archivePath)); + // Extract preserving the archive's internal layout. Flattening + // happens in a second pass so we can support any of: + // * `llama-server` (flat) + // * `release-root/llama-server` (single top folder) + // * `build/bin/llama-server` (nested) + for (const entry of Object.values(zip.files)) { + if (entry.dir) continue; + const rel = normalizeZipPath(entry.name); + if (!rel || rel.endsWith("/")) continue; + const out = join(stagingDir, rel); + mkdirSync(dirname(out), { recursive: true }); + const buf = await entry.async("nodebuffer"); + writeFileSync(out, buf); + try { + chmodSync(out, 0o755); + } catch { + /* Windows may ignore chmod */ + } + } + + const foundBin = findFileByName(stagingDir, binaryName); + if (!foundBin) { + throw new Error( + `llama-server binary not found after extract (searched for ${binaryName} under ${stagingDir})`, + ); + } + const stagedBin = join(stagingDir, binaryName); + if (foundBin !== stagedBin) { + // Promote the binary's parent directory contents into the staging + // root so `llama-server` (and any sibling shared libs) sit at the + // path the daemon lifecycle expects once swapped in, then remove + // the now-orphaned wrapper dirs (e.g. `build/`, `release-root/`). + moveContentsFlat(dirname(foundBin), stagingDir); + const topWrapper = topLevelWrapper(stagingDir, foundBin); + if (topWrapper !== null) { + try { + rmSync(topWrapper, { recursive: true, force: true }); + } catch { + /* ignore — stray files, not fatal */ + } + } + } + + if (process.platform === "darwin") { + try { + execSync(`xattr -cr "${stagingDir}"`, { timeout: 10_000 }); + } catch { + /* xattr may fail */ + } + } + + rmSync(archivePath, { force: true }); + + if (!existsSync(stagedBin)) { + throw new Error( + `llama-server binary not found after extract + flatten (expected ${binaryName} at ${stagedBin}; ` + + `original location was ${relative(stagingDir, foundBin)})`, + ); + } + try { + chmodSync(stagedBin, 0o755); + } catch { + /* Windows */ + } + if (!stagedBinaryUsable(stagedBin)) { + throw new Error( + `staged llama-server at ${stagedBin} is not a usable executable — keeping the existing install`, + ); + } +} + +/** + * Replace `backendDir` with `stagingDir`. Two renames, which is the + * closest to atomic a directory swap gets on POSIX and Windows alike: + * the live dir is moved aside first so the second rename lands on a + * free name (`rename` onto a non-empty dir fails on both platforms). + * + * The window where neither dir is at the live path spans one rename of + * an already-materialised sibling — microseconds, and no I/O that can + * block on the network or the disk filling up. If the *second* rename + * still fails, the old install is rolled back so the caller is left + * with a working backend rather than none. + */ +export function swapInStagedBackend( + backendDir: string, + stagingDir: string, + retiredDir: string, +): void { + const hadLive = existsSync(backendDir); + if (hadLive) renameSync(backendDir, retiredDir); + try { + renameSync(stagingDir, backendDir); + } catch (err) { + if (hadLive) { + try { + renameSync(retiredDir, backendDir); + } catch { + /* rollback failed too — surface the original error */ + } + } + throw err; + } + rmDirQuiet(retiredDir); +} + diff --git a/src/local-llm/backend-version.ts b/src/local-llm/backend-version.ts index a06c624f..ac4d2ebb 100644 --- a/src/local-llm/backend-version.ts +++ b/src/local-llm/backend-version.ts @@ -1,5 +1,5 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import { resolveVersionFilePath } from "./backend-paths.js"; @@ -16,6 +16,16 @@ export interface BackendVersionInfo { * installs predating this field. */ asset?: string; + /** + * `published_at` (falling back to `created_at`) of the GitHub release + * this install came from, ISO-8601. `checkForBackendUpdate` compares + * it against the resolved release so a re-published or backfilled + * older tag cannot present itself as an upgrade. Absent on installs + * predating this field, which is treated as "unknown, allow the + * tag-difference verdict to stand" so those users still get one more + * update. + */ + releasedAt?: string; } export function readBackendVersion(dataDir: string): BackendVersionInfo | null { @@ -28,7 +38,25 @@ export function readBackendVersion(dataDir: string): BackendVersionInfo | null { } export function writeBackendVersion(dataDir: string, info: BackendVersionInfo): void { - const p = resolveVersionFilePath(dataDir); + writeVersionFile(resolveVersionFilePath(dataDir), info); +} + +/** + * Write the version record into an arbitrary backend directory rather + * than the live one. The version file lives *inside* `backend/`, so a + * staged install must carry its own copy — writing it to the live path + * before the swap would describe a build that is not on disk yet, and + * writing it after would leave a window where the swapped-in binary is + * described by the previous tag. + */ +export function writeBackendVersionAt( + backendDir: string, + info: BackendVersionInfo, +): void { + writeVersionFile(join(backendDir, "backend-version.json"), info); +} + +function writeVersionFile(p: string, info: BackendVersionInfo): void { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(info, null, 2) + "\n"); } diff --git a/src/local-llm/download-file.test.ts b/src/local-llm/download-file.test.ts index be338bca..ff991ad5 100644 --- a/src/local-llm/download-file.test.ts +++ b/src/local-llm/download-file.test.ts @@ -90,6 +90,82 @@ describe("download-file", () => { expect(existsSync(dest)).toBe(false); expect(existsSync(`${dest}.tmp`)).toBe(false); }); + + it("keeps the byte counter moving when chunks are smaller than one percent", async () => { + // A real GGUF pull: one percent of the declared total is far larger than + // a single chunk, so tying updates to whole-percent changes leaves the + // counter frozen for seconds. Here the transfer never even reaches 1%. + const declaredTotal = 1_000_000_000; + const chunkSize = 1_000; + const count = 5; + let emitted = 0; + const body = new ReadableStream({ + async pull(controller) { + if (emitted >= count) { + controller.close(); + return; + } + emitted += 1; + await new Promise((resolve) => setTimeout(resolve, 250)); + controller.enqueue(Buffer.alloc(chunkSize)); + }, + }); + + globalThis.fetch = vi.fn(async () => { + return new Response(body, { + status: 200, + headers: { "content-length": String(declaredTotal) }, + }); + }) as typeof fetch; + + const seen: Array<{ percent: number; transferred: number }> = []; + await downloadFile("https://example.invalid/big.bin", join(dir, "big.bin"), { + onProgress: (percent, transferred) => { + seen.push({ percent, transferred }); + }, + }); + + // Percent rounds to 0 throughout — the bytes are the only signal the + // user has, and they must keep arriving. + expect(seen.every((s) => s.percent === 0)).toBe(true); + expect(seen.length).toBeGreaterThanOrEqual(count); + for (let i = 1; i < seen.length; i++) { + expect(seen[i].transferred).toBeGreaterThan(seen[i - 1].transferred); + } + expect(seen.at(-1)?.transferred).toBe(chunkSize * count); + }); + + it("still reports progress when the server sends no content-length", async () => { + // total === 0 pins percent at 0 forever, which used to wedge the old + // guard shut after the very first chunk. + let emitted = 0; + const body = new ReadableStream({ + async pull(controller) { + if (emitted >= 5) { + controller.close(); + return; + } + emitted += 1; + await new Promise((resolve) => setTimeout(resolve, 250)); + controller.enqueue(Buffer.alloc(1_000)); + }, + }); + + globalThis.fetch = vi.fn(async () => { + return new Response(body, { status: 200 }); + }) as typeof fetch; + + const seen: number[] = []; + await downloadFile("https://example.invalid/nolen.bin", join(dir, "nolen.bin"), { + onProgress: (_percent, transferred) => { + seen.push(transferred); + }, + }); + + expect(seen.length).toBeGreaterThan(1); + expect(seen.at(-1)).toBe(5_000); + }); + }); async function waitFor(predicate: () => boolean): Promise { diff --git a/src/local-llm/download-file.ts b/src/local-llm/download-file.ts index a51f844a..26bfd822 100644 --- a/src/local-llm/download-file.ts +++ b/src/local-llm/download-file.ts @@ -2,6 +2,8 @@ import * as fs from "node:fs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { huggingFaceToken } from "./huggingface-api.js"; + export type DownloadProgressFn = ( percent: number, transferred: number, @@ -42,6 +44,11 @@ export async function downloadFile( if (isGitHub) { const token = (process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim(); if (token) headers.Authorization = `Bearer ${token}`; + } else if (url.includes("huggingface.co")) { + // Gated repos answer 401 without this; public ones ignore it, so it + // costs nothing to send whenever the operator has a token exported. + const token = huggingFaceToken(); + if (token) headers.Authorization = `Bearer ${token}`; } const res = await fetch(url, { @@ -57,7 +64,30 @@ export async function downloadFile( const totalRaw = res.headers.get("content-length"); const total = totalRaw ? parseInt(totalRaw, 10) : 0; let transferred = 0; - let lastReportedPercent = -1; + let lastEmitAt = 0; + let lastEmittedBytes = -1; + + /** + * Progress used to be emitted only when the whole-number percentage + * changed, which left the byte counter frozen between those moments: one + * percent of a 4 GB GGUF is ~41 MB, so at realistic speeds the UI sat + * still for seconds at a time and the download looked stalled. Worse, a + * response without `content-length` pins `percent` at 0 forever, so after + * the first chunk the counter never moved again. + * + * Emit on a time base instead. The percentage still only changes when it + * changes; the bytes advance visibly, which is the part that tells the + * user the transfer is alive. + */ + const PROGRESS_INTERVAL_MS = 200; + + const emitProgress = (now: number): void => { + if (transferred === lastEmittedBytes) return; + lastEmitAt = now; + lastEmittedBytes = transferred; + const percent = total > 0 ? Math.round((transferred / total) * 100) : 0; + opts?.onProgress?.(percent, transferred, total); + }; const reader = res.body.getReader(); const trackingStream = new ReadableStream({ @@ -66,14 +96,16 @@ export async function downloadFile( const { done, value } = await reader.read(); throwIfAborted(opts?.signal); if (done) { + // The last partial interval still owes the user its final numbers, + // including the terminal 100%. + emitProgress(Date.now()); controller.close(); return; } transferred += value.byteLength; - const percent = total > 0 ? Math.round((transferred / total) * 100) : 0; - if (percent !== lastReportedPercent) { - lastReportedPercent = percent; - opts?.onProgress?.(percent, transferred, total); + const now = Date.now(); + if (now - lastEmitAt >= PROGRESS_INTERVAL_MS) { + emitProgress(now); } controller.enqueue(value); }, diff --git a/src/local-llm/ensure-latest-backend.test.ts b/src/local-llm/ensure-latest-backend.test.ts new file mode 100644 index 00000000..41c67b8f --- /dev/null +++ b/src/local-llm/ensure-latest-backend.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./backend-installer.js", async () => { + const actual = + await vi.importActual( + "./backend-installer.js", + ); + return { + ...actual, + checkForBackendUpdate: vi.fn(), + downloadBackend: vi.fn(), + isBackendDownloaded: vi.fn(), + }; +}); + +vi.mock("./daemon-lifecycle.js", async () => { + const actual = + await vi.importActual( + "./daemon-lifecycle.js", + ); + return { + ...actual, + readRunningPid: vi.fn(), + stopChatAndEmbeddingDaemons: vi.fn(), + }; +}); + +vi.mock("./session-registry.js", async () => { + const actual = + await vi.importActual( + "./session-registry.js", + ); + return { + ...actual, + hasOtherLiveSessions: vi.fn(), + }; +}); + +import { + checkForBackendUpdate, + downloadBackend, + isBackendDownloaded, +} from "./backend-installer.js"; +import { + readRunningPid, + stopChatAndEmbeddingDaemons, +} from "./daemon-lifecycle.js"; +import { maybeAutoUpdateBackend } from "./ensure-latest-backend.js"; +import { hasOtherLiveSessions } from "./session-registry.js"; + +describe("maybeAutoUpdateBackend", () => { + afterEach(() => { + vi.mocked(checkForBackendUpdate).mockReset(); + vi.mocked(downloadBackend).mockReset(); + vi.mocked(readRunningPid).mockReset(); + vi.mocked(stopChatAndEmbeddingDaemons).mockReset(); + vi.mocked(hasOtherLiveSessions).mockReset(); + vi.mocked(hasOtherLiveSessions).mockReturnValue(false); + vi.mocked(isBackendDownloaded).mockReset(); + vi.mocked(isBackendDownloaded).mockReturnValue(true); + }); + + it("is a no-op when autoUpdate is off", async () => { + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: false }); + expect(result).toEqual({ action: "skipped" }); + expect(checkForBackendUpdate).not.toHaveBeenCalled(); + expect(downloadBackend).not.toHaveBeenCalled(); + }); + + it("does not download when the installed tag already matches latest", async () => { + vi.mocked(checkForBackendUpdate).mockResolvedValue({ + updateAvailable: false, + latestTag: "turboquant-07b9908", + currentTag: "turboquant-07b9908", + }); + + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result).toEqual({ + action: "current", + tag: "turboquant-07b9908", + }); + expect(downloadBackend).not.toHaveBeenCalled(); + expect(stopChatAndEmbeddingDaemons).not.toHaveBeenCalled(); + }); + + it("stops a running daemon then downloads when a newer tag exists", async () => { + vi.mocked(checkForBackendUpdate).mockResolvedValue({ + updateAvailable: true, + latestTag: "turboquant-07b9908", + currentTag: "b10269-1.5.1", + }); + vi.mocked(readRunningPid).mockReturnValue(4242); + vi.mocked(stopChatAndEmbeddingDaemons).mockResolvedValue(); + vi.mocked(downloadBackend).mockResolvedValue({ + ok: true, + tag: "turboquant-07b9908", + }); + + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result).toEqual({ + action: "updated", + from: "b10269-1.5.1", + to: "turboquant-07b9908", + }); + expect(stopChatAndEmbeddingDaemons).toHaveBeenCalledWith("/tmp/data"); + expect(downloadBackend).toHaveBeenCalledTimes(1); + }); + + it("does not stop when nothing is running, then still downloads", async () => { + vi.mocked(checkForBackendUpdate).mockResolvedValue({ + updateAvailable: true, + latestTag: "turboquant-new", + currentTag: null, + }); + vi.mocked(readRunningPid).mockReturnValue(null); + vi.mocked(downloadBackend).mockResolvedValue({ + ok: true, + tag: "turboquant-new", + }); + + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result.action).toBe("updated"); + expect(stopChatAndEmbeddingDaemons).not.toHaveBeenCalled(); + }); + + it("defers the download when another live session owns the running daemon", async () => { + vi.mocked(checkForBackendUpdate).mockResolvedValue({ + updateAvailable: true, + latestTag: "turboquant-new", + currentTag: "old", + }); + vi.mocked(readRunningPid).mockReturnValue(99); + vi.mocked(hasOtherLiveSessions).mockReturnValue(true); + + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result).toEqual({ action: "deferred", reason: "other_session" }); + expect(stopChatAndEmbeddingDaemons).not.toHaveBeenCalled(); + expect(downloadBackend).not.toHaveBeenCalled(); + }); + + it("folds a download failure into update_failed so start can continue", async () => { + vi.mocked(checkForBackendUpdate).mockResolvedValue({ + updateAvailable: true, + latestTag: "turboquant-new", + currentTag: "turboquant-old", + }); + vi.mocked(readRunningPid).mockReturnValue(4242); + vi.mocked(stopChatAndEmbeddingDaemons).mockResolvedValue(); + vi.mocked(downloadBackend).mockRejectedValue(new Error("socket hang up")); + // Staged install: the previous binary survives a failed download. + vi.mocked(isBackendDownloaded).mockReturnValue(true); + + // The daemon has already been stopped at this point, so throwing + // would leave the user with nothing running at all. + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result).toEqual({ + action: "update_failed", + error: "socket hang up", + backendUsable: true, + }); + expect(stopChatAndEmbeddingDaemons).toHaveBeenCalledWith("/tmp/data"); + }); + + it("reports backendUsable false when nothing is left to start", async () => { + vi.mocked(checkForBackendUpdate).mockResolvedValue({ + updateAvailable: true, + latestTag: "turboquant-new", + currentTag: null, + }); + vi.mocked(readRunningPid).mockReturnValue(null); + vi.mocked(downloadBackend).mockRejectedValue(new Error("disk full")); + vi.mocked(isBackendDownloaded).mockReturnValue(false); + + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result).toEqual({ + action: "update_failed", + error: "disk full", + backendUsable: false, + }); + }); + + it("folds a daemon-stop failure into update_failed rather than throwing", async () => { + vi.mocked(checkForBackendUpdate).mockResolvedValue({ + updateAvailable: true, + latestTag: "turboquant-new", + currentTag: "turboquant-old", + }); + vi.mocked(readRunningPid).mockReturnValue(4242); + vi.mocked(stopChatAndEmbeddingDaemons).mockRejectedValue( + new Error("kill EPERM"), + ); + + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result).toEqual({ + action: "update_failed", + error: "kill EPERM", + backendUsable: true, + }); + expect(downloadBackend).not.toHaveBeenCalled(); + }); + + it("folds a GitHub check failure into check_failed so start can continue", async () => { + vi.mocked(checkForBackendUpdate).mockRejectedValue( + new Error("GitHub API rate-limited (HTTP 403)"), + ); + + const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true }); + expect(result).toEqual({ + action: "check_failed", + error: "GitHub API rate-limited (HTTP 403)", + }); + expect(downloadBackend).not.toHaveBeenCalled(); + }); +}); diff --git a/src/local-llm/ensure-latest-backend.ts b/src/local-llm/ensure-latest-backend.ts new file mode 100644 index 00000000..cbdd7aea --- /dev/null +++ b/src/local-llm/ensure-latest-backend.ts @@ -0,0 +1,110 @@ +import { + checkForBackendUpdate, + downloadBackend, + isBackendDownloaded, +} from "./backend-installer.js"; +import type { DownloadProgressFn } from "./download-file.js"; +import { + readRunningPid, + stopChatAndEmbeddingDaemons, +} from "./daemon-lifecycle.js"; +import { hasOtherLiveSessions } from "./session-registry.js"; + +export type AutoUpdateBackendResult = + | { action: "skipped" } + | { action: "current"; tag: string | null } + | { action: "updated"; from: string | null; to: string } + | { action: "deferred"; reason: "other_session" | "daemon_live" } + | { action: "check_failed"; error: string } + /** + * The version check said "update", but stopping the daemon or + * downloading the replacement failed. `backendUsable` reports whether + * a server binary is still on disk: the staged installer keeps the + * previous install intact, so this is almost always true and the + * caller should start it. False means there is genuinely nothing to + * run and the caller must fail. + */ + | { action: "update_failed"; error: string; backendUsable: boolean }; + +/** + * When `enabled`, pull a newer llama.cpp backend from GitHub Releases + * before the managed daemon starts. Missing-backend first install is + * still owned by the TUI/CLI start paths; this only upgrades an already + * installed zip. Failures anywhere in the update are fire-safe: this + * never throws, and every non-fatal outcome leaves the caller free to + * start the binary already on disk instead of aborting the turn. That + * matters most *after* the daemon was stopped for the update — an + * exception there used to leave the user with nothing running, which is + * strictly worse than never having attempted the update. + */ +export async function maybeAutoUpdateBackend( + dataDir: string, + opts: { + enabled: boolean; + onProgress?: DownloadProgressFn; + onWillDownload?: () => void; + /** + * Abort the (27-39 MB) asset download. Without one a stalled but + * open connection never resolves and the update hangs for the + * lifetime of the process. + */ + signal?: AbortSignal; + /** + * Never stop a running daemon to install the update. Set by the + * deferred pass that runs *after* start: there the live daemon is + * the one serving the user, and `hasOtherLiveSessions` cannot see + * it — it skips our own pid by design — so without this the + * background update would kill the model mid-turn. + */ + keepDaemonRunning?: boolean; + }, +): Promise { + if (!opts.enabled) return { action: "skipped" }; + + let check: Awaited>; + try { + check = await checkForBackendUpdate(dataDir); + } catch (err) { + return { + action: "check_failed", + error: err instanceof Error ? err.message : String(err), + }; + } + if (!check.updateAvailable) { + return { action: "current", tag: check.latestTag }; + } + + // Replacing the zip while llama-server still holds the old binary + // fails on Windows (file lock) and leaves POSIX starts racing the + // live pid. Stop both daemons first; the caller starts them after. + // Skip the stop when another TUI/CLI session is live — killing their + // model mid-chat is worse than sitting on an old tag until next solo start. + try { + if (readRunningPid(dataDir) !== null) { + if (opts.keepDaemonRunning) { + return { action: "deferred", reason: "daemon_live" }; + } + if (hasOtherLiveSessions(dataDir)) { + return { action: "deferred", reason: "other_session" }; + } + await stopChatAndEmbeddingDaemons(dataDir); + } + + opts.onWillDownload?.(); + const downloaded = await downloadBackend(dataDir, { + onProgress: opts.onProgress, + signal: opts.signal, + }); + return { + action: "updated", + from: check.currentTag, + to: downloaded.tag, + }; + } catch (err) { + return { + action: "update_failed", + error: err instanceof Error ? err.message : String(err), + backendUsable: isBackendDownloaded(dataDir), + }; + } +} diff --git a/src/local-llm/huggingface-api.ts b/src/local-llm/huggingface-api.ts new file mode 100644 index 00000000..d958e501 --- /dev/null +++ b/src/local-llm/huggingface-api.ts @@ -0,0 +1,97 @@ +/** + * The two calls the first-run flow makes against huggingface.co: list a + * repo's files, and read the token that unlocks a gated one. Derived from + * PR #38 by sachin-detrax, whose error wording for the 401/404 pair is + * kept because Hugging Face genuinely does not distinguish "private" from + * "does not exist" and the message has to cover both. + */ + +const HF_API = "https://huggingface.co/api"; + +export interface HuggingFaceFile { + path: string; + sizeBytes: number; +} + +export function huggingFaceToken(): string | null { + const raw = (process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || "").trim(); + return raw.length > 0 ? raw : null; +} + +async function fetchHfJson( + path: string, + opts?: { signal?: AbortSignal; timeoutMs?: number }, +): Promise { + const token = huggingFaceToken(); + const timeout = AbortSignal.timeout(opts?.timeoutMs ?? 15_000); + let res: Response; + try { + res = await fetch(`${HF_API}${path}`, { + headers: { + "User-Agent": "atomic-agent/local-llm", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: opts?.signal ? AbortSignal.any([opts.signal, timeout]) : timeout, + }); + } catch (err) { + // The caller's own cancellation is not a network failure — let it + // through untranslated so the screen that cancelled can tell the + // difference from huggingface.co being down. + if (opts?.signal?.aborted) throw err; + throw new Error( + `Could not reach huggingface.co: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (res.status === 401 || res.status === 403) { + throw new Error( + `Hugging Face returned ${res.status}: either no such repo, or it is gated. ` + + (token + ? "Your HF_TOKEN does not grant access — accept the licence on huggingface.co." + : "If it is gated, accept its licence on huggingface.co and export HF_TOKEN."), + ); + } + if (res.status === 404) { + throw new Error("Hugging Face returned 404: no repo or revision by that name."); + } + if (!res.ok) { + throw new Error(`Hugging Face returned HTTP ${res.status} ${res.statusText}.`); + } + return res.json(); +} + +/** Every `.gguf` in a repo revision, with its real (LFS) size. */ +export async function listHuggingFaceGgufFiles( + repoId: string, + revision = "main", + opts?: { signal?: AbortSignal }, +): Promise { + const raw = await fetchHfJson( + `/models/${repoId}/tree/${encodeURIComponent(revision)}?recursive=true`, + opts, + ); + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + const record = entry as Record; + const path = typeof record.path === "string" ? record.path : null; + if (!path || !path.toLowerCase().endsWith(".gguf")) return []; + // Everything over 10 MB is stored in LFS, where `size` on the tree + // entry is the pointer file's size, not the model's. + const lfs = record.lfs as Record | undefined; + const sizeBytes = + typeof lfs?.size === "number" + ? lfs.size + : typeof record.size === "number" + ? record.size + : 0; + return [{ path, sizeBytes }]; + }); +} + +export function resolveHuggingFaceFileUrl( + repoId: string, + revision: string, + filePath: string, +): string { + const encoded = filePath.split("/").map(encodeURIComponent).join("/"); + return `https://huggingface.co/${repoId}/resolve/${encodeURIComponent(revision)}/${encoded}`; +} diff --git a/src/local-llm/huggingface-fit.test.ts b/src/local-llm/huggingface-fit.test.ts new file mode 100644 index 00000000..dff948e8 --- /dev/null +++ b/src/local-llm/huggingface-fit.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + describeRejectedGgufFiles, + judgeGgufFile, + ramWarningFor, + type GgufVerdict, +} from "./huggingface-fit.js"; + +describe("judgeGgufFile", () => { + const rows: { path: string; verdict: GgufVerdict }[] = [ + { path: "Qwen3.5-4B-UD-Q4_K_XL.gguf", verdict: "usable" }, + { path: "Q4_K_M/Qwen3.5-4B-Q4_K_M.gguf", verdict: "usable" }, + { path: "gemma-4-E4B-it-IQ3_XXS.gguf", verdict: "usable" }, + // An unfamiliar naming scheme is not evidence of anything; let the + // operator try it rather than hiding the only file in the repo. + { path: "model.gguf", verdict: "usable" }, + { path: "mmproj-BF16.gguf", verdict: "projector" }, + { path: "nested/mmproj-F16.gguf", verdict: "projector" }, + { path: "Qwen3.5-35B-Q4_K_M-00001-of-00003.gguf", verdict: "sharded" }, + { path: "Qwen3.5-35B-Q4_K_M-00003-of-00003.gguf", verdict: "sharded" }, + { path: "mtp/Qwen3.5-4B-MTP-Q4_K_M.gguf", verdict: "companion" }, + { path: "Qwen3.5-4B-mtp-Q8_0.gguf", verdict: "companion" }, + { path: "Qwen3.5-4B-F16.gguf", verdict: "unquantised" }, + { path: "Qwen3.5-4B-BF16.gguf", verdict: "unquantised" }, + { path: "gemma-4-E4B.f32.gguf", verdict: "unquantised" }, + { path: "README.md", verdict: "unquantised" }, + ]; + + for (const row of rows) { + it(`calls ${row.path} ${row.verdict}`, () => { + expect(judgeGgufFile(row.path).verdict).toBe(row.verdict); + }); + } + + it("gives every refusal a reason and every acceptance none", () => { + for (const row of rows) { + const { verdict, reason } = judgeGgufFile(row.path); + if (verdict === "usable") expect(reason).toBeNull(); + else expect(reason).toMatch(/\S/); + } + }); + + // The projector refusal is the one an operator is most likely to hit by + // copying a file link, so it has to say what to do instead. + it("tells the operator to name the repo when they picked the projector", () => { + expect(judgeGgufFile("mmproj-BF16.gguf").reason).toContain("name the repo"); + }); +}); + +describe("describeRejectedGgufFiles", () => { + it("says nothing when nothing was filtered out", () => { + expect(describeRejectedGgufFiles(["usable", "usable"])).toBeNull(); + }); + + it("counts each kind of refusal", () => { + expect(describeRejectedGgufFiles(["sharded", "sharded", "projector"])).toBe( + "3 more files hidden: 2 multi-part, 1 vision projector", + ); + }); + + it("keeps the singular for one file", () => { + expect(describeRejectedGgufFiles(["unquantised"])).toBe( + "1 more file hidden: 1 full-precision", + ); + }); +}); + +describe("ramWarningFor", () => { + const rows: { fileSizeGb: number; hostRamGb: number; warns: boolean }[] = [ + { fileSizeGb: 4.2, hostRamGb: 16, warns: false }, + { fileSizeGb: 16, hostRamGb: 16, warns: false }, + { fileSizeGb: 17.3, hostRamGb: 16, warns: true }, + { fileSizeGb: 40, hostRamGb: 8, warns: true }, + // Sizes are estimates from the API; a missing one is not a warning. + { fileSizeGb: 0, hostRamGb: 8, warns: false }, + ]; + + for (const row of rows) { + it(`${row.fileSizeGb} GB on ${row.hostRamGb} GB ${row.warns ? "warns" : "is quiet"}`, () => { + const warning = ramWarningFor(row.fileSizeGb, row.hostRamGb); + if (row.warns) expect(warning).toMatch(/slow/); + else expect(warning).toBeNull(); + }); + } + + // It is drawn on one row of a step with a fixed budget, and Ink 7 + // overlaps rather than clips, so a wrap here would paint over the list. + it("stays inside the narrowest supported terminal", () => { + expect((ramWarningFor(999.9, 4) ?? "").length).toBeLessThanOrEqual(67); + }); +}); diff --git a/src/local-llm/huggingface-fit.ts b/src/local-llm/huggingface-fit.ts new file mode 100644 index 00000000..30afe413 --- /dev/null +++ b/src/local-llm/huggingface-fit.ts @@ -0,0 +1,141 @@ +/** + * Whether a GGUF sitting in a Hugging Face repo could run under this + * agent at all — the "fits at least in theory" gate on the first-run + * picker. Pure string and arithmetic work so the verdicts can be tested + * as a table; nothing here touches the network or the filesystem. + * + * The judgement is deliberately conservative in one direction only: a + * file is rejected when it is positively identified as something + * llama-server cannot serve on its own, and accepted otherwise. An + * unrecognised naming scheme is a reason to let the operator try, not a + * reason to hide the file. + */ + +export type GgufVerdict = + | "usable" + /** An `mmproj-*.gguf` vision projector — an accessory, not weights. */ + | "projector" + /** One part of a `-00001-of-000NN` set; the downloader fetches one file. */ + | "sharded" + /** A speculative-decoding (MTP/NextN) companion, not a servable model. */ + | "companion" + /** F16/F32/BF16 weights: a conversion step, not a quantisation. */ + | "unquantised"; + +export interface GgufJudgement { + verdict: GgufVerdict; + /** Shown verbatim when this file is the one the operator asked for. */ + reason: string | null; +} + +export function isMmprojFile(path: string): boolean { + return /(^|\/)mmproj[^/]*\.gguf$/i.test(path); +} + +/** + * Multi-part GGUFs are named `…-00001-of-00003.gguf`. The installer + * fetches exactly one file, so any shard — the first included — yields a + * model that cannot load. Serving them means fetching the whole set. + */ +export function isShardedGguf(path: string): boolean { + return /-\d{5}-of-\d{5}\.gguf$/i.test(path); +} + +/** + * MTP/NextN companions are GGUFs but not runnable models, and they are + * small, so a size-based fallback would happily pick one when a repo + * ships nothing else recognisable. + */ +export function isMtpCompanionFile(path: string): boolean { + const name = path.split("/").pop() ?? path; + return /(^|\/)mtp\//i.test(path) || /(^|[-_.])mtp([-_.]|\.gguf$)/i.test(name); +} + +/** + * Full-precision weights. Repos that ship quants almost always ship the + * F16 they were quantised from next to them, and it is several times the + * size of anything the operator wants on a first run. + */ +export function isFullPrecisionGguf(path: string): boolean { + const name = path.split("/").pop() ?? path; + return /(^|[-_.])(?:f16|f32|bf16|fp16|fp32)(?=[-_.]|\.gguf$)/i.test(name); +} + +export function judgeGgufFile(path: string): GgufJudgement { + if (!/\.gguf$/i.test(path)) { + return { verdict: "unquantised", reason: `${path} is not a .gguf file` }; + } + if (isMmprojFile(path)) { + return { + verdict: "projector", + reason: + "that is a vision projector, not model weights — name the repo instead " + + "and the projector is picked up with it", + }; + } + if (isShardedGguf(path)) { + return { + verdict: "sharded", + reason: + "that is one part of a multi-part model; only the part would be " + + "downloaded and it would not load. Pick a single-file quant.", + }; + } + if (isMtpCompanionFile(path)) { + return { + verdict: "companion", + reason: + "that looks like a speculative-decoding companion (MTP/NextN), not " + + "runnable weights — pick the main GGUF.", + }; + } + if (isFullPrecisionGguf(path)) { + return { + verdict: "unquantised", + reason: + "that is the full-precision conversion, not a quantisation — pick a " + + "Q4/Q5/Q8 file from the same repo.", + }; + } + return { verdict: "usable", reason: null }; +} + +/** Plural-aware tally of what was filtered out, or `null` when nothing was. */ +export function describeRejectedGgufFiles( + verdicts: readonly GgufVerdict[], +): string | null { + const labels: Record, string> = { + projector: "vision projector", + sharded: "multi-part", + companion: "speculative-decoding companion", + unquantised: "full-precision", + }; + const counts = new Map(); + for (const verdict of verdicts) { + if (verdict === "usable") continue; + const label = labels[verdict]; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + if (counts.size === 0) return null; + const parts = [...counts].map(([label, n]) => `${n} ${label}`); + const total = [...counts.values()].reduce((a, b) => a + b, 0); + return `${total} more file${total === 1 ? "" : "s"} hidden: ${parts.join(", ")}`; +} + +/** + * Weights bigger than physical RAM still start — llama.cpp memory-maps + * the file and the OS pages it in — they are just slow enough that + * saying so is worth a line. This warns; nothing acts on it. + * + * Kept to one short line on purpose: it is drawn inside a step with a + * fixed row budget, and Ink 7 overlaps the rows above rather than + * clipping, so a wrap here would paint over the file list. + */ +export function ramWarningFor(fileSizeGb: number, hostRamGb: number): string | null { + if (fileSizeGb <= 0 || hostRamGb <= 0) return null; + if (fileSizeGb <= hostRamGb) return null; + return ( + `${fileSizeGb.toFixed(1)} GB model, ${hostRamGb} GB of RAM — ` + + `it will run from disk, slowly.` + ); +} diff --git a/src/local-llm/huggingface-model-def.ts b/src/local-llm/huggingface-model-def.ts new file mode 100644 index 00000000..94a5f665 --- /dev/null +++ b/src/local-llm/huggingface-model-def.ts @@ -0,0 +1,77 @@ +/** + * Assemble a `LocalModelDef` for a model nobody curated. Ported from + * PR #38 by sachin-detrax; the id slug and the RAM estimates are its + * work, kept because the rest of the local-LLM stack already consumes + * that shape and needs no second one. + */ + +import { resolveHuggingFaceFileUrl, type HuggingFaceFile } from "./huggingface-api.js"; +import type { LocalModelDef, LocalModelId } from "./models-catalog.js"; + +const BYTES_PER_GB = 1024 * 1024 * 1024; + +/** + * A filesystem-safe id for a user-added model. `/models//` + * is created verbatim from this, so the character filter has to survive + * Windows path rules as well as POSIX ones. + */ +export function buildCustomModelId(repoId: string, filePath: string): LocalModelId { + const base = filePath.split("/").pop()!.replace(/\.gguf$/i, ""); + const slug = `${repoId}-${base}` + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-|-$/g, ""); + return `custom-${slug.slice(0, 80)}`; +} + +export function formatGgufSize(bytes: number): string { + if (bytes <= 0) return "unknown"; + const gb = bytes / BYTES_PER_GB; + return gb >= 1 ? `${gb.toFixed(1)} GB` : `${Math.round(bytes / (1024 * 1024))} MB`; +} + +export function ggufSizeGb(bytes: number): number { + return bytes / BYTES_PER_GB; +} + +/** + * The curated catalog hand-writes a context window and a RAM envelope + * per model. Neither is exposed by the Hugging Face API without reading + * the GGUF header, so both are estimated: RAM as weights × 1.2 (minimum) + * and × 1.5 + 2 GB (recommended), and `maxContextLength: 0` hands the + * context decision to `resolveEffectiveContextSize`, which fits it to the + * device. Both numbers are advisory everywhere they are read. + */ +export function buildCustomModelDef(input: { + repoId: string; + revision: string; + file: HuggingFaceFile; + mmproj: HuggingFaceFile | null; +}): LocalModelDef { + const { repoId, revision, file, mmproj } = input; + const fileSizeGb = ggufSizeGb(file.sizeBytes); + const filename = file.path.split("/").pop()!; + const base: LocalModelDef = { + id: buildCustomModelId(repoId, file.path), + name: `${repoId} · ${filename}`, + filename, + huggingFaceUrl: resolveHuggingFaceFileUrl(repoId, revision, file.path), + fileSizeGb, + sizeLabel: formatGgufSize(file.sizeBytes), + description: `Added from huggingface.co/${repoId}`, + maxContextLength: 0, + contextLabel: "auto", + minRamGb: Math.max(1, Math.ceil(fileSizeGb * 1.2)), + recommendedRamGb: Math.max(2, Math.ceil(fileSizeGb * 1.5) + 2), + family: "custom", + supportsVision: mmproj !== null, + }; + if (!mmproj) return base; + return { + ...base, + mmprojUrl: resolveHuggingFaceFileUrl(repoId, revision, mmproj.path), + mmprojFilename: mmproj.path.split("/").pop()!, + mmprojFileSizeGb: ggufSizeGb(mmproj.sizeBytes), + }; +} diff --git a/src/local-llm/huggingface-ref.test.ts b/src/local-llm/huggingface-ref.test.ts new file mode 100644 index 00000000..b75faf57 --- /dev/null +++ b/src/local-llm/huggingface-ref.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { parseHuggingFaceModelRef } from "./huggingface-ref.js"; + +describe("parseHuggingFaceModelRef", () => { + const accepted: { + name: string; + input: string; + repoId: string; + revision: string; + filePath: string | null; + }[] = [ + { + name: "a bare owner/repo id", + input: "unsloth/Qwen3.5-4B-GGUF", + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + filePath: null, + }, + { + name: "the repo page URL", + input: "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF", + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + filePath: null, + }, + { + name: "a repo URL with a trailing query string", + input: "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF?library=llama-cpp", + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + filePath: null, + }, + { + name: "a URL with no scheme", + input: "huggingface.co/Qwen/Qwen3.5-4B-GGUF", + repoId: "Qwen/Qwen3.5-4B-GGUF", + revision: "main", + filePath: null, + }, + { + name: "the hf.co short host", + input: "hf.co/Qwen/Qwen3.5-4B-GGUF", + repoId: "Qwen/Qwen3.5-4B-GGUF", + revision: "main", + filePath: null, + }, + { + name: "a /tree/ URL on a branch", + input: "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/tree/refs-pr-2", + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "refs-pr-2", + filePath: null, + }, + { + name: "a /blob/ URL naming one .gguf", + input: + "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/blob/main/Qwen3.5-4B-UD-Q4_K_XL.gguf", + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + filePath: "Qwen3.5-4B-UD-Q4_K_XL.gguf", + }, + { + name: "a /resolve/ URL naming one .gguf in a folder", + input: + "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Q4_K_M/model.gguf", + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + filePath: "Q4_K_M/model.gguf", + }, + { + name: "an hf:// reference with a revision", + input: "hf://Qwen/Qwen3.5-4B-GGUF@v1.0/model-Q4_K_M.gguf", + repoId: "Qwen/Qwen3.5-4B-GGUF", + revision: "v1.0", + filePath: "model-Q4_K_M.gguf", + }, + { + name: "a pasted two-argument hf download command", + input: "hf download unsloth/Qwen3.5-4B-GGUF Qwen3.5-4B-Q4_K_M.gguf --local-dir .", + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + filePath: "Qwen3.5-4B-Q4_K_M.gguf", + }, + ]; + + for (const row of accepted) { + it(`accepts ${row.name}`, () => { + expect(parseHuggingFaceModelRef(row.input)).toEqual({ + repoId: row.repoId, + revision: row.revision, + filePath: row.filePath, + }); + }); + } + + // Owners are case-sensitive on Hugging Face, and `new URL` lowercases a + // hostname — so an `hf://` reference must never go through it. + it("keeps the owner's case in an hf:// reference", () => { + expect(parseHuggingFaceModelRef("hf://Qwen/Qwen3.5-4B-GGUF").repoId).toBe( + "Qwen/Qwen3.5-4B-GGUF", + ); + }); + + const rejected: { name: string; input: string; message: RegExp }[] = [ + { name: "empty input", input: " ", message: /repo id or a huggingface\.co URL/ }, + { name: "a plain search phrase", input: "qwen coder 30b", message: /Not a Hugging Face URL/ }, + // `new URL` reads a bare word as a hostname, so this lands on the + // wrong-host branch rather than the unparseable one. Either way it + // is refused, and the message still quotes what was typed. + { name: "one bare word", input: "qwen", message: /Not a huggingface\.co URL: "qwen"/ }, + { + name: "a URL on another host", + input: "https://example.com/owner/repo", + message: /Not a huggingface\.co URL/, + }, + { + name: "a huggingface.co URL naming no repo", + input: "https://huggingface.co/unsloth", + message: /names no repo/, + }, + { + name: "a /blob/ URL with no file after the revision", + input: "https://huggingface.co/owner/repo/blob/main", + message: /names no file/, + }, + { + name: "a dataset in hf:// form", + input: "hf://datasets/owner/repo", + message: /not a model repo/, + }, + ]; + + for (const row of rejected) { + it(`rejects ${row.name}`, () => { + expect(() => parseHuggingFaceModelRef(row.input)).toThrow(row.message); + }); + } +}); diff --git a/src/local-llm/huggingface-ref.ts b/src/local-llm/huggingface-ref.ts new file mode 100644 index 00000000..a9fff742 --- /dev/null +++ b/src/local-llm/huggingface-ref.ts @@ -0,0 +1,126 @@ +/** + * Turn whatever a person pastes into a Hugging Face repo (and possibly a + * file) they meant. Ported from PR #38 by sachin-detrax, which worked + * this shape out against the forms people actually copy off a model card. + */ + +const HF_HOSTS = new Set(["huggingface.co", "www.huggingface.co", "hf.co"]); + +/** A repo, plus the one file inside it the reference named, if any. */ +export interface HuggingFaceModelRef { + /** `owner/name`. */ + repoId: string; + /** Git revision — branch, tag or sha. `main` when the reference omits one. */ + revision: string; + /** Path of a specific `.gguf` inside the repo, when one was named. */ + filePath: string | null; +} + +const REPO_ID_RE = /^[\w.-]+\/[\w.-]+$/; + +/** + * Strip a copied `hf download …` line down to its argument and drop any + * trailing flags. The model card prints the whole command, so that is + * what lands on the clipboard. + */ +function stripDownloadCommand(raw: string): string { + return raw + .replace(/^(?:hf|huggingface-cli|huggingface_hub)\s+download\s+/i, "") + .split(/\s+--/)[0]! + .trim(); +} + +/** + * `hf://owner/repo[@revision]/path/to/file.gguf`, the scheme the `hf` CLI + * accepts. + * + * Parsed by hand rather than with `new URL`: that puts the owner in the + * host slot and lowercases it, and Hugging Face owners are + * case-sensitive, so `hf://Qwen/…` would silently resolve to nothing. + */ +function parseHfSchemeRef(raw: string): HuggingFaceModelRef { + const segments = raw.replace(/^hf:\/\//i, "").split("/").filter(Boolean); + const head = segments[0]?.toLowerCase(); + if (head === "datasets" || head === "spaces") { + throw new Error( + `hf://${head}/… points at a ${head.replace(/s$/, "")}, not a model repo`, + ); + } + if (head === "models") segments.shift(); + const [owner, repoAndRevision, ...fileSegments] = segments; + if (!owner || !repoAndRevision) { + throw new Error(`hf:// reference is missing /: ${JSON.stringify(raw)}`); + } + // Only the simple `repo@rev` form is supported, which is the one the + // CLI prints; a revision containing a slash (`refs/pr/1`) would be + // indistinguishable from the file path that follows it. + const at = repoAndRevision.lastIndexOf("@"); + const name = at > 0 ? repoAndRevision.slice(0, at) : repoAndRevision; + const revision = at > 0 ? repoAndRevision.slice(at + 1) : "main"; + return { + repoId: `${owner}/${name}`, + revision: revision || "main", + filePath: fileSegments.length > 0 ? fileSegments.join("/") : null, + }; +} + +/** + * Accepts, in order of how often they get pasted: + * https://huggingface.co// + * https://huggingface.co///tree/ + * https://huggingface.co///blob|resolve//.gguf + * hf.co// + * hf:///[@rev][/.gguf] + * hf download / .gguf + * / + * + * Anything else throws with a message meant for the screen: the caller + * shows it verbatim rather than turning it into "invalid input". + */ +export function parseHuggingFaceModelRef(raw: string): HuggingFaceModelRef { + const command = stripDownloadCommand(raw.trim()); + const trimmed = command.replace(/[?#].*$/, ""); + if (trimmed.length === 0) throw new Error("Type a repo id or a huggingface.co URL."); + + if (/^hf:\/\//i.test(trimmed)) return parseHfSchemeRef(trimmed); + + // The two-argument `hf download` form. Narrow on purpose: the first + // token has to look like a repo id, so an ordinary two-word phrase + // still falls through to the URL branch and is rejected there. + const tokens = trimmed.split(/\s+/); + if (tokens.length === 2 && REPO_ID_RE.test(tokens[0]!) && /\.gguf$/i.test(tokens[1]!)) { + return { repoId: tokens[0]!, revision: "main", filePath: tokens[1]! }; + } + + if (REPO_ID_RE.test(trimmed)) { + return { repoId: trimmed, revision: "main", filePath: null }; + } + + let url: URL; + try { + url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`); + } catch { + throw new Error( + `Not a Hugging Face URL or an owner/name id: ${JSON.stringify(raw.trim())}`, + ); + } + if (!HF_HOSTS.has(url.hostname.toLowerCase())) { + throw new Error(`Not a huggingface.co URL: ${JSON.stringify(raw.trim())}`); + } + const segments = url.pathname.split("/").filter(Boolean); + if (segments[0] === "models") segments.shift(); + const [owner, name, verb, revision, ...rest] = segments; + if (!owner || !name) { + throw new Error(`That URL names no repo: ${JSON.stringify(raw.trim())}`); + } + const repoId = `${owner}/${name}`; + if (verb === "resolve" || verb === "blob") { + const filePath = rest.join("/"); + if (!filePath) { + throw new Error(`That URL names no file: ${JSON.stringify(raw.trim())}`); + } + return { repoId, revision: revision || "main", filePath }; + } + if (verb === "tree") return { repoId, revision: revision || "main", filePath: null }; + return { repoId, revision: "main", filePath: null }; +} diff --git a/src/local-llm/huggingface-resolve.test.ts b/src/local-llm/huggingface-resolve.test.ts new file mode 100644 index 00000000..26382d1d --- /dev/null +++ b/src/local-llm/huggingface-resolve.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildCustomModelDef } from "./huggingface-model-def.js"; +import { resolveHuggingFaceGgufChoices } from "./huggingface-resolve.js"; + +const GB = 1024 * 1024 * 1024; + +interface TreeEntry { + path: string; + lfs?: { size: number }; + size?: number; +} + +/** Stand in for the `/api/models//tree/` response. */ +function stubTree(entries: TreeEntry[], status = 200): void { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(entries), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ); +} + +function stubStatus(status: number): void { + vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status }))); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("resolveHuggingFaceGgufChoices", () => { + it("offers every servable quant, best-known first", async () => { + stubTree([ + { path: "Qwen3.5-4B-Q8_0.gguf", lfs: { size: 4 * GB } }, + { path: "Qwen3.5-4B-UD-Q4_K_XL.gguf", lfs: { size: 2.7 * GB } }, + { path: "Qwen3.5-4B-Q5_K_M.gguf", lfs: { size: 3 * GB } }, + ]); + const resolved = await resolveHuggingFaceGgufChoices("unsloth/Qwen3.5-4B-GGUF"); + expect(resolved.repoId).toBe("unsloth/Qwen3.5-4B-GGUF"); + expect(resolved.choices.map((c) => c.filename)).toEqual([ + "Qwen3.5-4B-UD-Q4_K_XL.gguf", + "Qwen3.5-4B-Q5_K_M.gguf", + "Qwen3.5-4B-Q8_0.gguf", + ]); + expect(resolved.choices[0]!.sizeLabel).toBe("2.7 GB"); + expect(resolved.hidden).toBeNull(); + }); + + it("hides what it cannot serve and says how much it hid", async () => { + stubTree([ + { path: "model-Q4_K_M.gguf", lfs: { size: 2 * GB } }, + { path: "model-F16.gguf", lfs: { size: 8 * GB } }, + { path: "model-Q4_K_M-00001-of-00002.gguf", lfs: { size: 1 * GB } }, + { path: "mmproj-BF16.gguf", lfs: { size: 0.5 * GB } }, + ]); + const resolved = await resolveHuggingFaceGgufChoices("owner/repo"); + expect(resolved.choices.map((c) => c.filename)).toEqual(["model-Q4_K_M.gguf"]); + expect(resolved.hidden).toBe( + "3 more files hidden: 1 full-precision, 1 multi-part, 1 vision projector", + ); + expect(resolved.mmproj?.path).toBe("mmproj-BF16.gguf"); + }); + + it("collapses to the one file a direct link named", async () => { + stubTree([ + { path: "model-Q4_K_M.gguf", lfs: { size: 2 * GB } }, + { path: "model-Q8_0.gguf", lfs: { size: 4 * GB } }, + ]); + const resolved = await resolveHuggingFaceGgufChoices( + "https://huggingface.co/owner/repo/blob/main/model-Q8_0.gguf", + ); + expect(resolved.choices.map((c) => c.filename)).toEqual(["model-Q8_0.gguf"]); + }); + + it("refuses a direct link to a shard, quoting why", async () => { + stubTree([{ path: "model-00001-of-00003.gguf", lfs: { size: 4 * GB } }]); + await expect( + resolveHuggingFaceGgufChoices( + "https://huggingface.co/owner/repo/blob/main/model-00001-of-00003.gguf", + ), + ).rejects.toThrow(/one part of a multi-part model/); + }); + + it("refuses a repo that holds no GGUF at all", async () => { + stubTree([{ path: "model.safetensors", size: 4 * GB }]); + await expect(resolveHuggingFaceGgufChoices("owner/repo")).rejects.toThrow( + /No \.gguf files in owner\/repo/, + ); + }); + + it("refuses a repo whose only GGUFs are unservable", async () => { + stubTree([ + { path: "model-F16.gguf", lfs: { size: 8 * GB } }, + { path: "mmproj-F16.gguf", lfs: { size: 0.5 * GB } }, + ]); + await expect(resolveHuggingFaceGgufChoices("owner/repo")).rejects.toThrow( + /none this agent can serve \(2 more files hidden/, + ); + }); + + it("names the gating when Hugging Face refuses the listing", async () => { + stubStatus(401); + await expect(resolveHuggingFaceGgufChoices("owner/gated")).rejects.toThrow( + /gated/, + ); + }); + + it("says the repo does not exist on a 404", async () => { + stubStatus(404); + await expect(resolveHuggingFaceGgufChoices("owner/nope")).rejects.toThrow( + /no repo or revision by that name/, + ); + }); + + it("reports an unreachable host rather than throwing a raw fetch error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("fetch failed"); + }), + ); + await expect(resolveHuggingFaceGgufChoices("owner/repo")).rejects.toThrow( + /Could not reach huggingface\.co/, + ); + }); + + // The LFS pointer, not the model, is what `size` reports for anything + // over 10 MB — reading it would put every large model at a few hundred + // bytes and silence the RAM warning. + it("prefers the LFS size over the tree entry's own", async () => { + stubTree([{ path: "model-Q4_K_M.gguf", size: 135, lfs: { size: 6 * GB } }]); + const resolved = await resolveHuggingFaceGgufChoices("owner/repo"); + expect(resolved.choices[0]!.fileSizeGb).toBeCloseTo(6, 3); + }); +}); + +describe("buildCustomModelDef", () => { + it("mints a filesystem-safe id and an advisory RAM envelope", async () => { + stubTree([{ path: "Qwen3.5-4B-UD-Q4_K_XL.gguf", lfs: { size: 2.7 * GB } }]); + const resolved = await resolveHuggingFaceGgufChoices("unsloth/Qwen3.5-4B-GGUF"); + const choice = resolved.choices[0]!; + const def = buildCustomModelDef({ + repoId: resolved.repoId, + revision: resolved.revision, + file: { path: choice.path, sizeBytes: choice.sizeBytes }, + mmproj: null, + }); + expect(def.id).toBe("custom-unsloth-qwen3.5-4b-gguf-qwen3.5-4b-ud-q4_k_xl"); + expect(def.family).toBe("custom"); + expect(def.supportsVision).toBe(false); + expect(def.huggingFaceUrl).toBe( + "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-UD-Q4_K_XL.gguf", + ); + expect(def.minRamGb).toBe(4); + expect(def.recommendedRamGb).toBe(7); + // Zero hands the decision to `resolveEffectiveContextSize`, which + // fits the window to the device instead of guessing here. + expect(def.maxContextLength).toBe(0); + }); + + it("carries the projector through when the repo ships one", async () => { + stubTree([ + { path: "model-Q4_K_M.gguf", lfs: { size: 2 * GB } }, + { path: "mmproj-BF16.gguf", lfs: { size: 0.5 * GB } }, + ]); + const resolved = await resolveHuggingFaceGgufChoices("owner/repo"); + const choice = resolved.choices[0]!; + const def = buildCustomModelDef({ + repoId: resolved.repoId, + revision: resolved.revision, + file: { path: choice.path, sizeBytes: choice.sizeBytes }, + mmproj: resolved.mmproj, + }); + expect(def.supportsVision).toBe(true); + expect(def.mmprojFilename).toBe("mmproj-BF16.gguf"); + }); +}); diff --git a/src/local-llm/huggingface-resolve.ts b/src/local-llm/huggingface-resolve.ts new file mode 100644 index 00000000..8c9821da --- /dev/null +++ b/src/local-llm/huggingface-resolve.ts @@ -0,0 +1,132 @@ +/** + * Reference in, downloadable choices out. One call the first-run screen + * can await: it parses what was typed, asks Hugging Face what is in the + * repo, and either returns the GGUFs worth offering or throws a sentence + * the screen can print as-is. + */ + +import { + listHuggingFaceGgufFiles, + type HuggingFaceFile, +} from "./huggingface-api.js"; +import { + describeRejectedGgufFiles, + isMmprojFile, + judgeGgufFile, + type GgufVerdict, +} from "./huggingface-fit.js"; +import { formatGgufSize, ggufSizeGb } from "./huggingface-model-def.js"; +import { parseHuggingFaceModelRef } from "./huggingface-ref.js"; + +/** One servable GGUF, in the shape the picker draws. */ +export interface HuggingFaceGgufChoice { + path: string; + filename: string; + sizeBytes: number; + fileSizeGb: number; + sizeLabel: string; +} + +export interface HuggingFaceRepoChoices { + repoId: string; + revision: string; + /** + * Best-known quantisation first (see `QUANT_PREFERENCE`), then by + * size within a rank — the file most likely to run well here leads + * the list, and that is rarely the smallest one. + */ + choices: readonly HuggingFaceGgufChoice[]; + /** The projector to pull alongside, when the repo ships one. */ + mmproj: HuggingFaceFile | null; + /** One line naming what was filtered out, or `null` when nothing was. */ + hidden: string | null; +} + +/** Best-known quants first; anything unrecognised sorts by size after them. */ +const QUANT_PREFERENCE = ["q4_k_xl", "q4_k_m", "q4_k_s", "q4_0", "q5_k_m", "q8_0"]; + +function quantRank(path: string): number { + const lower = path.toLowerCase(); + const index = QUANT_PREFERENCE.findIndex((quant) => lower.includes(quant)); + return index === -1 ? QUANT_PREFERENCE.length : index; +} + +function toChoice(file: HuggingFaceFile): HuggingFaceGgufChoice { + return { + path: file.path, + filename: file.path.split("/").pop() ?? file.path, + sizeBytes: file.sizeBytes, + fileSizeGb: ggufSizeGb(file.sizeBytes), + sizeLabel: formatGgufSize(file.sizeBytes), + }; +} + +function pickMmproj(files: readonly HuggingFaceFile[]): HuggingFaceFile | null { + const projectors = files.filter((file) => isMmprojFile(file.path)); + if (projectors.length === 0) return null; + return [...projectors].sort((a, b) => a.sizeBytes - b.sizeBytes)[0]!; +} + +/** + * Resolve a pasted reference into the files worth offering. + * + * A reference that names one file collapses to a single choice — or to a + * refusal quoting why that file cannot be served, which is more useful + * than silently substituting a different one. + */ +export async function resolveHuggingFaceGgufChoices( + reference: string, + opts?: { signal?: AbortSignal }, +): Promise { + const ref = parseHuggingFaceModelRef(reference); + const files = await listHuggingFaceGgufFiles(ref.repoId, ref.revision, opts); + if (files.length === 0) { + throw new Error( + `No .gguf files in ${ref.repoId} — that is the original model, not a ` + + `GGUF conversion of it. Look for a "-GGUF" repo of the same name.`, + ); + } + const mmproj = pickMmproj(files); + + if (ref.filePath) { + const named = files.find((file) => file.path === ref.filePath); + if (!named) { + throw new Error(`${ref.filePath} is not in ${ref.repoId} @ ${ref.revision}.`); + } + const judgement = judgeGgufFile(named.path); + if (judgement.verdict !== "usable") { + throw new Error(`Cannot use ${named.path}: ${judgement.reason}`); + } + return { + repoId: ref.repoId, + revision: ref.revision, + choices: [toChoice(named)], + mmproj, + hidden: null, + }; + } + + const usable: HuggingFaceFile[] = []; + const rejected: GgufVerdict[] = []; + for (const file of files) { + const { verdict } = judgeGgufFile(file.path); + if (verdict === "usable") usable.push(file); + else rejected.push(verdict); + } + if (usable.length === 0) { + throw new Error( + `${ref.repoId} has ${files.length} GGUF file${files.length === 1 ? "" : "s"} but ` + + `none this agent can serve (${describeRejectedGgufFiles(rejected) ?? "unknown"}).`, + ); + } + const choices = usable + .sort((a, b) => quantRank(a.path) - quantRank(b.path) || a.sizeBytes - b.sizeBytes) + .map(toChoice); + return { + repoId: ref.repoId, + revision: ref.revision, + choices, + mmproj, + hidden: describeRejectedGgufFiles(rejected), + }; +} diff --git a/src/local-llm/index.ts b/src/local-llm/index.ts index cccdd389..7f3ed3e7 100644 --- a/src/local-llm/index.ts +++ b/src/local-llm/index.ts @@ -3,10 +3,13 @@ export { DEFAULT_LLAMACPP_MODEL_ID, getLocalModelDef, isKnownLocalModelId, + listLocalModels, + setCustomLocalModels, EMBEDDING_MODELS_CATALOG, DEFAULT_EMBEDDING_MODEL_ID, getEmbeddingModelDef, isKnownEmbeddingModelId, + type CuratedLocalModelId, type LocalModelId, type LocalModelDef, type EmbeddingModelId, @@ -50,6 +53,10 @@ export { GithubRateLimitedError, type LatestReleaseInfo, } from "./backend-installer.js"; +export { + maybeAutoUpdateBackend, + type AutoUpdateBackendResult, +} from "./ensure-latest-backend.js"; export { isModelDownloaded, isMmprojDownloaded, @@ -95,3 +102,36 @@ export { type StartBothResult, } from "./daemon-lifecycle.js"; export { readLogTail, type LogTailResult } from "./log-tail.js"; + +export { + huggingFaceToken, + listHuggingFaceGgufFiles, + resolveHuggingFaceFileUrl, + type HuggingFaceFile, +} from "./huggingface-api.js"; +export { + describeRejectedGgufFiles, + isFullPrecisionGguf, + isMmprojFile, + isMtpCompanionFile, + isShardedGguf, + judgeGgufFile, + ramWarningFor, + type GgufJudgement, + type GgufVerdict, +} from "./huggingface-fit.js"; +export { + buildCustomModelDef, + buildCustomModelId, + formatGgufSize, + ggufSizeGb, +} from "./huggingface-model-def.js"; +export { + parseHuggingFaceModelRef, + type HuggingFaceModelRef, +} from "./huggingface-ref.js"; +export { + resolveHuggingFaceGgufChoices, + type HuggingFaceGgufChoice, + type HuggingFaceRepoChoices, +} from "./huggingface-resolve.js"; diff --git a/src/local-llm/models-catalog.test.ts b/src/local-llm/models-catalog.test.ts index 1ea7f7b3..9afc93c6 100644 --- a/src/local-llm/models-catalog.test.ts +++ b/src/local-llm/models-catalog.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; +import { + detectModelProfile, + PLAIN_INSTRUCT_PROFILE, +} from "../llm/model-profile.js"; +import { MUSE_PROPS } from "../llm/model-profile.fixtures.js"; import { DEFAULT_EMBEDDING_MODEL_ID, DEFAULT_LLAMACPP_MODEL_ID, @@ -11,10 +16,10 @@ import { } from "./models-catalog.js"; describe("models-catalog", () => { - it("has exactly 10 Qwen+Gemma models with unique ids", () => { - expect(LOCAL_MODELS_CATALOG.length).toBe(10); + it("has exactly 12 Qwen+Gemma+Nemotron+Muse models with unique ids", () => { + expect(LOCAL_MODELS_CATALOG.length).toBe(12); const ids = new Set(LOCAL_MODELS_CATALOG.map((m) => m.id)); - expect(ids.size).toBe(10); + expect(ids.size).toBe(12); }); it("defaults to qwen-3.5-4b", () => { @@ -25,9 +30,16 @@ describe("models-catalog", () => { // no `` / `enable_thinking` markers. Because `--chat-template-file` // is what `/props.chat_template` reports back, it demoted the profile to // `plain-instruct` and deadlocked the grammar. See chat-templates.test.ts. - it("does not override the Qwen 3.5 chat template", () => { - expect(getLocalModelDef("qwen-3.5-4b").chatTemplateAsset).toBeUndefined(); - expect(getLocalModelDef("qwen-3.5-35b").chatTemplateAsset).toBeUndefined(); + // + // Catalog-wide rather than per-id: any entry that grows an override is + // exposed to the same failure mode, so adding one has to be a deliberate + // act that edits this test (and states why the override keeps every + // reasoning marker `detectModelProfile` keys on) — not a silent field. + it("ships no chat template override on any catalog entry", () => { + for (const def of LOCAL_MODELS_CATALOG) { + expect(def.chatTemplateAsset, `${def.id} must not override its chat template`) + .toBeUndefined(); + } }); it("throws on unknown id", () => { @@ -36,16 +48,43 @@ describe("models-catalog", () => { ).toThrow(/unknown local model id/); }); - it("marks every catalog entry as vision-capable with mmproj URL", () => { - expect(LOCAL_MODELS_CATALOG.length).toBeGreaterThan(0); - for (const def of LOCAL_MODELS_CATALOG) { - expect(def.supportsVision).toBe(true); + it("ships mmproj URL for every vision-capable catalog entry", () => { + const visionModels = LOCAL_MODELS_CATALOG.filter((m) => m.supportsVision); + expect(visionModels.length).toBeGreaterThan(0); + for (const def of visionModels) { expect(def.mmprojUrl).toMatch(/^https:\/\//); expect(def.mmprojFilename).toMatch(/\.gguf$/); expect(typeof def.mmprojFileSizeGb).toBe("number"); } }); + it("omits mmproj fields on text-only catalog entries", () => { + const textOnly = LOCAL_MODELS_CATALOG.filter((m) => !m.supportsVision); + expect(textOnly.map((m) => m.id)).toEqual(["nemotron-3.5-30b-a3b"]); + for (const def of textOnly) { + expect(def.mmprojUrl).toBeUndefined(); + expect(def.mmprojFilename).toBeUndefined(); + expect(def.mmprojFileSizeGb).toBeUndefined(); + } + }); + + // Interim contract for Muse Glimmer. The catalog advertises multimodal + // (real: mmproj ships below) but NOT a native tool format, because there + // is none wired: the daemon passes `model.id` as the llama-server alias, + // and `muse-glimmer-30b` matches no alias hint in `selectBaseProfile`, so + // `/props` resolves to `plain-instruct` and tool calls run on the generic + // GBNF array grammar. This test is the tripwire: the day someone wires a + // native ATEM/Harmony profile, it fails and forces the description to be + // updated in the same commit instead of drifting into an over-promise. + it("resolves Muse Glimmer to plain-instruct, and says so in its description", () => { + expect(detectModelProfile(MUSE_PROPS)).toEqual(PLAIN_INSTRUCT_PROFILE); + + const muse = getLocalModelDef("muse-glimmer-30b"); + expect(muse.supportsVision).toBe(true); + expect(muse.description).not.toMatch(/atem|harmony/i); + expect(muse.description).toMatch(/generic tool calling/i); + }); + it("ensures mmproj URL points at the same HF repo as the GGUF weights", () => { for (const def of LOCAL_MODELS_CATALOG) { if (!def.mmprojUrl) continue; diff --git a/src/local-llm/models-catalog.ts b/src/local-llm/models-catalog.ts index 6cc284f3..80d8bcd0 100644 --- a/src/local-llm/models-catalog.ts +++ b/src/local-llm/models-catalog.ts @@ -1,9 +1,10 @@ /** - * Curated GGUF catalog (Qwen + Gemma only). URLs mirror atomic-hermes - * desktop local LLM models; `family` replaces UI-only icon fields. + * Curated GGUF catalog (Qwen + Gemma + Nemotron + Muse). URLs mirror + * atomic-hermes desktop local LLM models; `family` replaces UI-only + * icon fields. */ -export type LocalModelId = +export type CuratedLocalModelId = | "qwen-3.5-4b" | "qwen-3.5-9b" | "qwen-3.5-35b" @@ -13,7 +14,18 @@ export type LocalModelId = | "gemma-4-e4b" | "gemma-4-12b" | "gemma-4-26b-a4b" - | "gemma-4-31b"; + | "gemma-4-31b" + | "nemotron-3.5-30b-a3b" + | "muse-glimmer-30b"; + +/** + * A chat model identifier: a curated catalog entry, or a model the + * operator added from an arbitrary Hugging Face repo (`custom-`, + * minted by `buildCustomModelId`). The `custom-` prefix is load-bearing + * — it opens the id space without weakening the typed wall against + * `EmbeddingModelId`, since no embedding id can satisfy this type. + */ +export type LocalModelId = CuratedLocalModelId | `custom-${string}`; /** * Memory-v2 phase 1B. Embedding model identifiers. A separate union @@ -43,7 +55,8 @@ export interface LocalModelDef { contextLabel: string; minRamGb: number; recommendedRamGb: number; - family: "qwen" | "gemma"; + /** `custom` covers every user-added model, whatever it is underneath. */ + family: "qwen" | "gemma" | "nemotron" | "muse" | "custom"; /** * Jinja file under `assets/ai-models/` passed to llama-server as * `--chat-template-file`, overriding the template baked into the GGUF. @@ -280,18 +293,84 @@ export const LOCAL_MODELS_CATALOG: readonly LocalModelDef[] = [ mmprojFilename: "mmproj-F16.gguf", mmprojFileSizeGb: 0.90, }, + { + id: "nemotron-3.5-30b-a3b", + name: "NVIDIA Nemotron 3.5 Lightning 30B-A3B GGUF", + filename: "NVIDIA-Nemotron-3.5-Lightning-30B-A3B-AD-IQ4_NL.gguf", + huggingFaceUrl: + "https://huggingface.co/AtomicChat/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF/resolve/main/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-AD-IQ4_NL.gguf", + fileSizeGb: 19.65, + sizeLabel: "19.7 GB", + description: "Hybrid Mamba2 MoE reasoning, imatrix-calibrated", + maxContextLength: 262_144, + contextLabel: "256K", + minRamGb: 24, + recommendedRamGb: 32, + family: "nemotron", + tag: "New", + supportsVision: false, + }, + { + id: "muse-glimmer-30b", + name: "Meta Muse Glimmer 30B GGUF", + filename: "Muse-Glimmer-30B-UD-Q4_K_XL.gguf", + huggingFaceUrl: + "https://huggingface.co/unsloth/Muse-Glimmer-30B-GGUF/resolve/main/Muse-Glimmer-30B-UD-Q4_K_XL.gguf", + fileSizeGb: 15.9, + sizeLabel: "15.9 GB", + // Vision is fully wired (mmproj below). The native ATEM/Harmony tool + // format is NOT: the daemon passes `model.id` as the llama-server + // alias (`daemon-lifecycle.ts`), and `muse-glimmer-30b` matches no + // alias hint in `selectBaseProfile`, so this resolves to + // `plain-instruct` and tool calls go through the generic GBNF array + // grammar. Pinned by `models-catalog.test.ts`; reword only when a + // native profile actually lands. + description: "Multimodal 30B MoE, generic tool calling", + maxContextLength: 131_072, + contextLabel: "128K", + minRamGb: 20, + recommendedRamGb: 32, + family: "muse", + tag: "New", + supportsVision: true, + mmprojUrl: + "https://huggingface.co/unsloth/Muse-Glimmer-30B-GGUF/resolve/main/mmproj-Muse-Glimmer-30B-Q8_0.gguf", + mmprojFilename: "mmproj-Muse-Glimmer-30B-Q8_0.gguf", + mmprojFileSizeGb: 2.05, + }, ]; export const DEFAULT_LLAMACPP_MODEL_ID: LocalModelId = "qwen-3.5-4b"; +/** + * Models the operator added themselves, mirrored out of + * `localModels.customModels` in the user config so that every existing + * catalog consumer — daemon start, the installer, the TUI rows, the CLI + * — resolves them through the same pair of lookups below. + * + * A module-level registry rather than a `getConfig()` call because + * `config-schema` imports this file, and the import cycle would cost + * more than the mutable module state does. `loadConfig()` populates it. + */ +let customModels: readonly LocalModelDef[] = []; + +export function setCustomLocalModels(defs: readonly LocalModelDef[]): void { + customModels = defs; +} + +/** The curated catalog followed by the operator's own additions. */ +export function listLocalModels(): readonly LocalModelDef[] { + return [...LOCAL_MODELS_CATALOG, ...customModels]; +} + export function getLocalModelDef(id: LocalModelId): LocalModelDef { - const found = LOCAL_MODELS_CATALOG.find((m) => m.id === id); + const found = listLocalModels().find((m) => m.id === id); if (!found) throw new Error(`unknown local model id: ${id}`); return found; } export function isKnownLocalModelId(raw: string): raw is LocalModelId { - return LOCAL_MODELS_CATALOG.some((m) => m.id === raw); + return listLocalModels().some((m) => m.id === raw); } /** diff --git a/src/prompt/build-prompt-types.ts b/src/prompt/build-prompt-types.ts index 10478330..96cc6110 100644 --- a/src/prompt/build-prompt-types.ts +++ b/src/prompt/build-prompt-types.ts @@ -23,6 +23,20 @@ export interface BuildPromptInput { currentDate?: string; tokenBudget?: number; conversationMaxTokens?: number; + /** Overrides `agent.conversationMaxPairs` for this build. */ + conversationMaxPairs?: number; + /** + * The model's context window, when something other than the profile + * probe knows it. + * + * `profile.contextWindow` is filled only by the llama-server `/props` + * probe, so on a cloud model the budget had no window at all and every + * window-relative decision — the auto cap especially — silently fell + * back to a fixed number. The provider catalogue does know, and this + * is how that reaches the budget. Kept separate from `profile` so the + * UI can still tell a probed window from a catalogued one. + */ + contextWindow?: number | null; worldSnapshotMaxTokens?: number; completionMaxTokens?: number; transientNotice?: string; @@ -81,5 +95,33 @@ export interface BuiltPrompt { truncation: BuiltPromptTruncationFlags; contextWindow: number | null; conversationCapEffective: number; + /** + * `agent.conversationMaxTokens` was left at `0` — the transcript takes + * whatever the window leaves rather than sitting under a fixed + * ceiling. Reported rather than inferred: under auto the configured + * figure in `limits.conversation` is a *fallback* for an unknown + * window, not a ceiling, and comparing it against + * `conversationCapEffective` — which is how the UI decides what is + * holding the transcript down — would name the wrong knob. + */ + conversationCapAuto: boolean; droppedTurns: number; + /** Macro-turns the prompt carries. */ + conversationPairs: number; + /** Macro-turns dropped whole. */ + droppedPairs: number; + /** The cap in force, i.e. `agent.conversationMaxPairs`. */ + conversationPairsCap: number; + /** Which limit made the cut, when history was trimmed at all. */ + conversationBoundBy: "pairs" | "tokens" | null; + /** + * Token cost of each macro-turn, oldest first. + * + * Published so the context panel can answer "what would N tasks cost?" + * with a prefix sum instead of waiting for the next prompt build — + * lowering the pair count has to move the gauge while the operator is + * looking at it, not one turn later. Per-turn costs are already + * memoised, so this is close to free. + */ + pairCosts: number[]; } diff --git a/src/prompt/build-prompt.test.ts b/src/prompt/build-prompt.test.ts index e64906ff..6d7ea8e8 100644 --- a/src/prompt/build-prompt.test.ts +++ b/src/prompt/build-prompt.test.ts @@ -599,6 +599,11 @@ describe("buildPrompt", () => { capabilities: CAPS, skillCatalog: SKILLS, tokenBudget: 400, + // This test is about the token axis, as its name says. History is + // capped on a second, independent axis now — tasks — and the + // fixture is 31 of them, so opt out of that one to keep measuring + // the thing under test. + conversationMaxPairs: 100, }); expect(prompt.tail).toContain("the latest important question"); expect(prompt.tail).toContain("noise 0"); diff --git a/src/prompt/build-prompt.ts b/src/prompt/build-prompt.ts index f45b8c69..ca93eb67 100644 --- a/src/prompt/build-prompt.ts +++ b/src/prompt/build-prompt.ts @@ -1,4 +1,4 @@ -import { getConfig } from "../config/index.js"; +import { getConfig, USER_CONFIG_DEFAULTS } from "../config/index.js"; import { getReasoningTurnFraming } from "../llm/model-profile.js"; import { renderProfileSection } from "../memory/profile-renderer.js"; import { @@ -7,7 +7,10 @@ import { } from "../memory/notes-renderer.js"; import { renderLessonsSection } from "../memory/lessons/lessons-renderer.js"; import { renderProceduresSection } from "../memory/procedures/procedures-renderer.js"; -import { packConversation } from "../session/conversation-turn.js"; +import { + packConversation, + pairTokenCosts, +} from "../session/conversation-turn.js"; import { renderPackedConversation, renderWorldSnapshotSection, @@ -24,6 +27,7 @@ import { renderTaskPolicy } from "./render-task-policy.js"; import { checkBudget, computeEffectiveConversationCap, + CONVERSATION_CAP_AUTO, defaultBudget, estimateTokens, truncateToTokens, @@ -82,13 +86,30 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { const budgetTotal = input.tokenBudget ?? config.agent.tokenBudget; const conversationMaxTokens = input.conversationMaxTokens ?? config.agent.conversationMaxTokens; + // `0` means "let the window decide" (`CONVERSATION_CAP_AUTO`). + const conversationCapAuto = conversationMaxTokens <= CONVERSATION_CAP_AUTO; + const conversationMaxPairs = + input.conversationMaxPairs ?? config.agent.conversationMaxPairs; const worldSnapshotMaxTokens = input.worldSnapshotMaxTokens ?? config.agent.worldSnapshotMaxTokens; const completionMaxTokens = input.completionMaxTokens ?? config.localModels.completionMaxTokens; + // Under auto the conversation share is pinned to the schema default + // rather than left to `defaultBudget`'s `tokenBudget * 0.35`. + // + // That share is a sensible split of a *fixed* budget; it is a + // catastrophic ceiling for an operator who asked for no ceiling. With + // the default `tokenBudget: 3000` it is 1050 tokens, and when nothing + // knows the window `computeEffectiveConversationCap` returns the + // configured figure verbatim — so pressing "set auto" on a model whose + // window could not be probed took the transcript from 32k to 1050, a + // 30x cut in the exact direction the button promises to go. "Let the + // window decide" must never quietly mean "assume a tiny window". const limits = defaultBudget(budgetTotal, { - conversation: conversationMaxTokens, + conversation: conversationCapAuto + ? USER_CONFIG_DEFAULTS.agent.conversationMaxTokens + : conversationMaxTokens, worldSnapshot: worldSnapshotMaxTokens, }); @@ -210,11 +231,16 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { limits.worldSnapshot, ); - const contextWindow = input.profile?.contextWindow ?? null; + // The probe first, then whatever the caller resolved (the provider + // catalogue, for a cloud model that has no `/props` to read). Before + // this the budget simply had no window off the local path. + const contextWindow = + input.profile?.contextWindow ?? input.contextWindow ?? null; const sessionTokenEstimate = estimateTokens(sessionPartsForBudget) + loadedToolsTokens; const conversationCapEffective = computeEffectiveConversationCap({ configuredCap: limits.conversation, + ...(conversationCapAuto ? { autoFill: true } : {}), contextWindow: contextWindow ?? undefined, stablePrefixTokens: estimateTokens(stablePrefix), sessionTokens: sessionTokenEstimate, @@ -228,7 +254,12 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { completionMaxTokens, }); - const packed = packConversation(input.session.turns, conversationCapEffective); + const packed = packConversation(input.session.turns, conversationCapEffective, { + maxPairs: conversationMaxPairs, + ...(input.session.macroTurnStarts + ? { macroTurnStarts: input.session.macroTurnStarts } + : {}), + }); const conversation = renderPackedConversation(packed); const taskPolicy = renderTaskPolicy({ userMessage: input.userMessage ?? null, @@ -373,6 +404,15 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { truncation, contextWindow, conversationCapEffective, + conversationCapAuto, droppedTurns: packed.droppedCount, + conversationPairs: packed.visiblePairs, + droppedPairs: packed.droppedPairs, + conversationPairsCap: conversationMaxPairs, + conversationBoundBy: packed.boundBy, + pairCosts: pairTokenCosts( + input.session.turns, + input.session.macroTurnStarts, + ), }; } diff --git a/src/prompt/conversation-cap-auto.test.ts b/src/prompt/conversation-cap-auto.test.ts new file mode 100644 index 00000000..9fb2017c --- /dev/null +++ b/src/prompt/conversation-cap-auto.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { buildPrompt } from "./build-prompt.js"; +import { createEmptySessionState } from "../session/session-state.js"; +import type { SessionState } from "../session/session-state.js"; +import { USER_CONFIG_DEFAULTS } from "../config/index.js"; +import { PLAIN_INSTRUCT_PROFILE } from "../llm/model-profile.js"; +import type { + CapabilitiesSummary, + SkillCatalogEntry, + ToolDescriptor, +} from "./stable-prefix.js"; +import type { ConversationTurn } from "../session/conversation-turn.js"; + +const TOOLS: ToolDescriptor[] = [ + { name: "finish", summary: "Signal goal completion.", argsSchema: "{}" }, +]; +const CAPS: CapabilitiesSummary = { + browser: false, + filesystem: false, + shell: false, + network: false, +}; +const SKILLS: SkillCatalogEntry[] = []; + +function sessionWith(turns: ConversationTurn[]): SessionState { + return { ...createEmptySessionState({ id: "s", workingDir: "/work" }), turns }; +} + +function task(i: number): ConversationTurn[] { + return [ + { kind: "user", text: `ask ${i}`, at: 1000 + i * 10 }, + { kind: "assistant_reply", text: `answer ${i}`, at: 1001 + i * 10 }, + ]; +} + +function build(overrides: Record) { + return buildPrompt({ + session: sessionWith([...task(1), ...task(2), ...task(3)]), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + ...overrides, + }); +} + +/** + * `set auto` promises the transcript will fill whatever the model's + * window leaves. It used to do the opposite whenever nothing knew the + * window: auto dropped the explicit cap, `defaultBudget` fell back to + * its `tokenBudget * 0.35` share — 1050 with the shipped default — and + * with no window to measure against, that fallback *was* the answer. + * Pressing the button on a cloud model took the transcript from 32k to + * 1050. + * + * The existing coverage in `token-budget.test.ts` could not catch it, + * because it hands `computeEffectiveConversationCap` a `configuredCap` + * directly instead of going through `buildPrompt`, which is where the + * substitution happens. + */ +describe("the transcript cap under auto", () => { + it("never collapses to the budget share when no window is known", () => { + const built = build({ conversationMaxTokens: 0 }); + expect(built.contextWindow).toBeNull(); + expect(built.conversationCapAuto).toBe(true); + expect(built.conversationCapEffective).toBe( + USER_CONFIG_DEFAULTS.agent.conversationMaxTokens, + ); + expect(built.conversationCapEffective).toBeGreaterThan(10_000); + }); + + it("is never worse than the fixed cap it replaced", () => { + // The whole promise of the button in one line. + const fixed = build({ conversationMaxTokens: 32_000 }); + const auto = build({ conversationMaxTokens: 0 }); + expect(auto.conversationCapEffective).toBeGreaterThanOrEqual( + fixed.conversationCapEffective, + ); + }); + + it("fills the window when one is known", () => { + const auto = build({ conversationMaxTokens: 0, contextWindow: 128_000 }); + expect(auto.contextWindow).toBe(128_000); + expect(auto.conversationCapEffective).toBeGreaterThan(32_000); + }); +}); + +/** + * The catalogue knows a cloud model's window; the `/props` probe does + * not exist there. Until this input existed the budget simply had no + * window off the local path. + */ +describe("where the window comes from", () => { + it("takes the caller's window when there is no profile probe", () => { + expect(build({ contextWindow: 200_000 }).contextWindow).toBe(200_000); + }); + + it("prefers the probe when both are known", () => { + // The probe is the physical truth about the server actually serving + // this request; the catalogue is a published figure about a name. + const built = build({ + profile: { ...PLAIN_INSTRUCT_PROFILE, contextWindow: 8_192 }, + contextWindow: 128_000, + }); + expect(built.contextWindow).toBe(8_192); + }); + + it("still reports no window when neither knows one", () => { + expect(build({}).contextWindow).toBeNull(); + }); +}); + +describe("what the prompt reports about pairs", () => { + it("counts the tasks it carried and the cap in force", () => { + const built = build({ conversationMaxPairs: 2 }); + expect(built.conversationPairsCap).toBe(2); + expect(built.conversationPairs).toBe(2); + expect(built.droppedPairs).toBe(1); + }); + + it("publishes a cost per task, oldest first", () => { + const built = build({ conversationMaxPairs: 100 }); + expect(built.pairCosts).toHaveLength(3); + for (const cost of built.pairCosts) expect(cost).toBeGreaterThan(0); + }); +}); + +/** + * The default is a real behaviour change: before this, only the 32k + * token cap bound, and a long, cheap history survived whole. Pinned so + * it can never drift silently. + */ +describe("the shipped default", () => { + it("carries twenty tasks and no more", () => { + const many: ConversationTurn[] = []; + for (let i = 0; i < 30; i += 1) many.push(...task(i)); + const built = buildPrompt({ + session: sessionWith(many), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + }); + expect(built.conversationPairsCap).toBe( + USER_CONFIG_DEFAULTS.agent.conversationMaxPairs, + ); + expect(built.conversationPairs).toBe(20); + expect(built.droppedPairs).toBe(10); + expect(built.text).not.toContain("ask 0"); + expect(built.text).toContain("ask 29"); + }); +}); diff --git a/src/prompt/default-tool-args-schemas.test.ts b/src/prompt/default-tool-args-schemas.test.ts index a88577dc..80bffb87 100644 --- a/src/prompt/default-tool-args-schemas.test.ts +++ b/src/prompt/default-tool-args-schemas.test.ts @@ -69,6 +69,45 @@ describe("default tool argsJsonSchema map", () => { }); }); + // Issue #185: `paths` was a bare string[] with no upper bound, so + // neither cloud schema validation nor grammar-constrained decoding + // could hint the 4-image cap and the model learned it by failing. + it("pins vision.describe schema (paths capped at maxItems 4)", () => { + const schema = getDefaultArgsJsonSchema("vision.describe"); + expect(schema).toMatchObject({ + type: "object", + required: ["prompt"], + additionalProperties: false, + }); + const properties = (schema as { properties: Record }).properties; + expect(properties.paths).toEqual({ + type: "array", + items: { type: "string" }, + maxItems: 4, + }); + }); + + it("does not leak vision.describe's maxItems onto other string[] schemas", () => { + const properties = ( + getDefaultArgsJsonSchema("os.shell.run") as { + properties: Record; + } + ).properties; + expect(properties.args).not.toHaveProperty("maxItems"); + }); + + it("documents the vision.describe image cap in the stable-prefix descriptor", () => { + const descriptor = DEFAULT_TOOL_DESCRIPTORS.find( + (d) => d.name === "vision.describe", + ); + expect(descriptor).toBeDefined(); + // The descriptor array is static and cannot read config, so it + // documents the DEFAULT with wording that stays true if someone + // raises `vision.maxImagesPerCall`. + expect(descriptor!.summary).toContain("at most 4 images per call by default"); + expect(descriptor!.argsSchema).toContain("at most 4 by default"); + }); + it("attachDefaultArgsJsonSchema preserves an explicit override (MCP inputSchema path)", () => { const override = { type: "object" as const, diff --git a/src/prompt/default-tool-args-schemas.ts b/src/prompt/default-tool-args-schemas.ts index 7049acaf..dc1da011 100644 --- a/src/prompt/default-tool-args-schemas.ts +++ b/src/prompt/default-tool-args-schemas.ts @@ -462,6 +462,7 @@ const DEFAULT_TOOL_ARGS_SCHEMAS: ReadonlyMap = new Map< url: stringSchema, extractMode: { type: "string", enum: ["markdown", "text"] }, maxChars: numberSchema, + timeoutMs: numberSchema, }, ["url"], ), @@ -587,7 +588,12 @@ const DEFAULT_TOOL_ARGS_SCHEMAS: ReadonlyMap = new Map< { prompt: stringSchema, path: stringSchema, - paths: stringArraySchema, + // `maxItems` mirrors the DEFAULT of `config.vision.maxImagesPerCall` + // (4). The runtime check in `buildVisionDescribeTool` reads the live + // config and stays authoritative; this bound is a hint so cloud + // providers and grammar-constrained decoding stop the model from + // emitting a 20-image call it can only discover is invalid by failing. + paths: { ...stringArraySchema, maxItems: 4 }, }, ["prompt"], ), diff --git a/src/prompt/default-tool-descriptors-a.ts b/src/prompt/default-tool-descriptors-a.ts index c5fefaf6..007a151c 100644 --- a/src/prompt/default-tool-descriptors-a.ts +++ b/src/prompt/default-tool-descriptors-a.ts @@ -213,7 +213,7 @@ export const DEFAULT_TOOL_DESCRIPTORS_A: readonly ToolDescriptor[] = [ name: "os.web.fetch", summary: "Read a web page as readable markdown/text (cf-markdown → Readability → basic). GET only, no JS, no auth; SSRF-guarded; read-only. For raw API/JSON or POST, use os.http.request.", - argsSchema: `{ url: string, extractMode?: "markdown" | "text", maxChars?: number }`, + argsSchema: `{ url: string, extractMode?: "markdown" | "text", maxChars?: number, timeoutMs?: number }`, examples: [ '{"url":"https://example.com/article"}', '{"url":"https://docs.example.com/guide","extractMode":"text","maxChars":20000}', diff --git a/src/prompt/default-tool-descriptors-b.ts b/src/prompt/default-tool-descriptors-b.ts index 6fa9957b..1783dde0 100644 --- a/src/prompt/default-tool-descriptors-b.ts +++ b/src/prompt/default-tool-descriptors-b.ts @@ -162,9 +162,9 @@ export const DEFAULT_TOOL_DESCRIPTORS_B: readonly ToolDescriptor[] = [ // `{paths: [...]}` without `prompt` and burning a step on the // schema error before retrying with the right shape. name: "vision.describe", - summary: "Describe one or more images via the configured vision LLM. Only available when the active model + provider support multimodal input.", + summary: "Describe one or more images via the configured vision LLM. Only available when the active model + provider support multimodal input. Accepts at most 4 images per call by default (`vision.maxImagesPerCall`); to cover more images, split them across several calls.", argsSchema: - "{ prompt: string, path?: string, paths?: string[] /* png|jpg|jpeg|webp|gif */ }", + "{ prompt: string, path?: string, paths?: string[] /* png|jpg|jpeg|webp|gif; at most 4 by default */ }", examples: [ '{"path":"./screenshot.png","prompt":"What error is shown?"}', '{"paths":["a.png","b.png"],"prompt":"Compare these two diagrams"}', diff --git a/src/prompt/token-budget.test.ts b/src/prompt/token-budget.test.ts index 282063e8..44b809c9 100644 --- a/src/prompt/token-budget.test.ts +++ b/src/prompt/token-budget.test.ts @@ -41,6 +41,65 @@ describe("computeEffectiveConversationCap", () => { completionMaxTokens: 4096, }; + /** + * The report this behaviour came from: `llama-server -c 48000`, and + * the composer reads `32k`. Nothing is broken — 32k is + * `agent.conversationMaxTokens`, and it is a *ceiling*, so it does not + * move when the window grows past it. These pin both halves: that the + * old default really does decline the extra room, and that `0` claims + * it. + */ + describe("a window larger than the configured ceiling", () => { + const window48k = { ...base, contextWindow: 48_000 }; + + it("holds the transcript at the configured ceiling", () => { + // 48000 - 2000 - 400 - 2000 - 4096 - 512 = 38 992 available, and + // the operator's 32k ceiling is the smaller of the two. + expect(computeEffectiveConversationCap(window48k)).toBe(32_000); + }); + + it("fills the window under auto", () => { + expect( + computeEffectiveConversationCap({ ...window48k, autoFill: true }), + ).toBe(38_992); + }); + + it("is unchanged by auto when the window is the smaller of the two", () => { + // A 32k window leaves 22 992 — under the 32k ceiling — so the + // ceiling was never what bound, and switching it off buys nothing. + // This is why the default can stay where it is: for everyone whose + // window is at or below it, auto is a no-op. + const window32k = { ...base, contextWindow: 32_768 }; + expect(computeEffectiveConversationCap(window32k)).toBe(23_760); + expect( + computeEffectiveConversationCap({ ...window32k, autoFill: true }), + ).toBe(23_760); + }); + + it("falls back to the configured figure under auto with no window", () => { + // Auto cannot mean "unbounded": with no window there is nothing to + // subtract from, and an unbounded transcript against somebody + // else's server is a promise this process cannot keep. + expect( + computeEffectiveConversationCap({ + ...base, + contextWindow: undefined, + autoFill: true, + }), + ).toBe(32_000); + }); + + it("keeps the floor under auto on a window too small to hold the prompt", () => { + expect( + computeEffectiveConversationCap({ + ...base, + contextWindow: 4096, + autoFill: true, + }), + ).toBe(512); + }); + }); + it("returns the configured cap when the model context window is unknown", () => { const cap = computeEffectiveConversationCap({ ...base, diff --git a/src/prompt/token-budget.ts b/src/prompt/token-budget.ts index 2220ebd0..bcc48fbd 100644 --- a/src/prompt/token-budget.ts +++ b/src/prompt/token-budget.ts @@ -103,6 +103,14 @@ export interface EffectiveConversationCapInput { */ loadedToolsTokens?: number; completionMaxTokens: number; + /** + * `agent.conversationMaxTokens` was left at {@link CONVERSATION_CAP_AUTO}: + * the transcript takes whatever the window leaves rather than being + * held under a fixed ceiling. `configuredCap` is then only the + * fallback for an unknown window — see + * {@link computeEffectiveConversationCap}. + */ + autoFill?: boolean; } /** @@ -144,12 +152,36 @@ export function minUsableContextWindow(completionMaxTokens: number): number { */ export const CONVERSATION_CAP_FLOOR = 512; +/** + * `agent.conversationMaxTokens: 0` — let the window decide. + * + * The same sentinel `localModels.managed.contextSize` already uses for + * the same idea, and for the same reason: the useful value is a function + * of hardware the config file cannot see, so the only honest fixed + * number is "don't fix it". + * + * The knob it replaces was a *ceiling*, and a ceiling that never rises + * is indistinguishable from a bug once the window grows past it. An + * operator who starts `llama-server` with `-c 48000` has said what they + * want the agent to have; a 32k cap sitting above that window quietly + * declines two thirds of the difference, and the only visible trace is a + * number in the composer that looks like it *is* the window. + */ +export const CONVERSATION_CAP_AUTO = 0; + /** * Resolve the actual cap enforced on the `### conversation` section for * a given prompt-build. When the runtime knows the model's physical * `contextWindow` (from `llama-server /props`), clamp the user-chosen * `configuredCap` to the space that remains after all fixed costs. * When `contextWindow` is unknown, trust the user's config as-is. + * + * Under `autoFill` there is no configured ceiling at all: whatever the + * window leaves over is the cap. `configuredCap` is still read in that + * mode, but only as the fallback for a window nobody knows — a cloud + * model with no published context length gives the maths nothing to + * subtract from, and an unbounded transcript there would be a promise + * about someone else's server that this process cannot keep. */ export function computeEffectiveConversationCap( input: EffectiveConversationCapInput, @@ -170,6 +202,7 @@ export function computeEffectiveConversationCap( (input.loadedToolsTokens ?? 0) - input.completionMaxTokens - CONVERSATION_CAP_SAFETY_MARGIN; + if (input.autoFill) return Math.max(CONVERSATION_CAP_FLOOR, available); return Math.max( CONVERSATION_CAP_FLOOR, Math.min(input.configuredCap, available), diff --git a/src/runtime/bootstrap-queued-turn.test.ts b/src/runtime/bootstrap-queued-turn.test.ts new file mode 100644 index 00000000..65600da3 --- /dev/null +++ b/src/runtime/bootstrap-queued-turn.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createAgentRuntime } from "./bootstrap.js"; +import { resetConfigCache } from "../config/index.js"; +import type { BrowserBackend } from "../tools/browser/browser-backend.js"; +import type { CompletionResult } from "../llm/llama-server-client.js"; + +/** + * The stale-SessionState lost update (§"Concurrency contract": never + * hold a stale `SessionState` between enqueue and run). + * + * `runTurn` is `enqueue({ run: () => executeTurn(session, …) })`. A + * caller that captured `session` while a turn from another origin was + * still running — the TUI switching into a foreign-busy thread is the + * case in point — used to run the queued turn on that pre-switch + * snapshot once the lock freed. Both turns then saved sessions built + * from the same ancestor, and whichever finished last clobbered the + * other's transcript. The fix re-reads the latest stored session inside + * the queued callback, at run() time. + */ + +/** The browser tools are registered but never invoked by these turns. */ +function inertBackend(): BrowserBackend { + return { + ensureReady: async () => undefined, + shutdown: async () => undefined, + } as unknown as BrowserBackend; +} + +function completion(content: string): CompletionResult { + return { + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 10, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; +} + +function userTexts(turns: readonly { kind: string }[]): string[] { + return turns + .filter((t) => t.kind === "user") + .map((t) => (t as { text: string }).text); +} + +describe("runTurn queued behind a foreign turn", () => { + let stateDir: string; + let workingDir: string; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-runtime-queued-")); + workingDir = mkdtempSync(join(tmpdir(), "atomic-cwd-queued-")); + mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true }); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars"); + resetConfigCache(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + rmSync(workingDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + resetConfigCache(); + }); + + it("re-reads the stored session at run() time instead of the enqueue-time snapshot", async () => { + let inferences = 0; + let releaseForeignTurn!: () => void; + const foreignTurnGate = new Promise((resolve) => { + releaseForeignTurn = resolve; + }); + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: inertBackend(), + skipLlamaHealthCheck: true, + llamaComplete: async () => { + inferences += 1; + // Inference 1 belongs to the foreign turn (it owns the lock + // first); holding it open is what keeps that turn running + // while the second caller enqueues with its stale snapshot. + if (inferences === 1) await foreignTurnGate; + return completion( + JSON.stringify({ tool: "reply", args: { text: `reply ${inferences}` } }), + ); + }, + }, + }); + try { + const session = runtime.createSession(); + // The snapshot a host holds across the enqueue — captured before + // the foreign turn below writes its result to the store. + const staleSnapshot = session; + + const foreign = runtime.runTurn(session, "foreign work", { + origin: "scheduler", + maxSteps: 4, + }); + // Parked FIFO behind the foreign turn, snapshot captured NOW. + const queued = runtime.runTurn(staleSnapshot, "operator message", { + origin: "tui", + maxSteps: 4, + }); + releaseForeignTurn(); + const [foreignResult, queuedResult] = await Promise.all([ + foreign, + queued, + ]); + expect(foreignResult.reason).toBe("reply"); + expect(queuedResult.reason).toBe("reply"); + + // The queued turn ran on top of the foreign turn's save… + expect(userTexts(queuedResult.session.turns)).toEqual([ + "foreign work", + "operator message", + ]); + // …and the store holds BOTH conversations, not the last writer's. + const stored = runtime.sessionStore.load(session.id); + expect(stored).not.toBeNull(); + expect(userTexts(stored?.turns ?? [])).toEqual([ + "foreign work", + "operator message", + ]); + } finally { + await runtime.shutdown(); + } + }); +}); diff --git a/src/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts index 66f28fe1..1827fc5f 100644 --- a/src/runtime/bootstrap.test.ts +++ b/src/runtime/bootstrap.test.ts @@ -34,6 +34,8 @@ import type { TypeInput, } from "../tools/browser/browser-backend.js"; import type { LogRecord } from "../tracing/structured-logger.js"; +import type { AgentLoopEvent } from "../agent/agent-loop.js"; +import type { CompletionResult } from "../llm/llama-server-client.js"; class FakeBackend implements BrowserBackend { public shutdowns = 0; @@ -918,3 +920,225 @@ describe("createAgentRuntime", () => { } }); }); + +describe("createAgentRuntime steering", () => { + let stateDir: string; + let workingDir: string; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-runtime-steer-")); + workingDir = mkdtempSync(join(tmpdir(), "atomic-cwd-steer-")); + mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true }); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars"); + resetConfigCache(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + rmSync(workingDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + resetConfigCache(); + }); + + it("refuses to steer a session with no turn in flight", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true }, + }); + try { + const session = runtime.createSession(); + // Nothing is running: steering would silently vanish, so the + // caller is told "no" and can fall back to a normal turn. + expect(runtime.steer(session.id, "hello?")).toBe(false); + expect(runtime.steeringInbox.peek(session.id)).toEqual([]); + } finally { + await runtime.shutdown(); + } + }); + + it("accepts a steer while a turn holds the session lock", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true }, + }); + try { + const session = runtime.createSession(); + let release!: () => void; + const held = new Promise((res) => { + release = res; + }); + const inFlight = runtime.turnController.enqueue({ + sessionId: session.id, + origin: "tui", + run: async () => { + // Stand in for `AgentLoop.runTurn`, which opens the steering + // window on entry — the queue lock alone is not what makes a + // session steerable. + runtime.steeringInbox.open(session.id); + expect(runtime.steer(session.id, "change course")).toBe(true); + expect(runtime.steeringInbox.peek(session.id)).toEqual([ + "change course", + ]); + await held; + return null; + }, + }); + release(); + await inFlight; + // Still pending: only the agent loop drains it. + expect(runtime.steeringInbox.drain(session.id)).toEqual(["change course"]); + } finally { + await runtime.shutdown(); + } + }); + + /** + * The lost-update window. `runTurn` is `enqueue({ run: () => + * executeTurn(...) })`; spelling that composition out by hand is the + * only way to stand *between* the loop's final drain and the + * controller's `busy.delete`, which is where a `steer()` used to be + * accepted and then stranded. Everything else here is the production + * wiring: real `TurnController`, real `SteeringInbox`, real + * `AgentLoop`, real `runtime.steer`. No sleeps, no timing luck. + */ + it("refuses a steer that lands after the turn's final drain", async () => { + const events: AgentLoopEvent[] = []; + let inferences = 0; + // Assigned right after bootstrap; the completer only runs inside a + // turn, which is later still. + let sessionId = ""; + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + handlers: { onAgentEvent: (event) => events.push(event) }, + overrides: { + browserBackend: new FakeBackend(), + skipLlamaHealthCheck: true, + llamaComplete: async () => { + inferences += 1; + if (inferences === 1) { + // Sent while step 0's inference is in flight — the window + // is open, so this one must be accepted AND delivered. + expect(runtime.steer(sessionId, "check the logs first")).toBe(true); + return completion(JSON.stringify({ tool: "noop", args: {} })); + } + return completion( + JSON.stringify({ tool: "reply", args: { text: "done" } }), + ); + }, + }, + }); + // A trivial non-terminal tool so the turn has a step boundary at + // all; a one-step turn could not exercise steering. + runtime.toolRegistry.register({ + name: "noop", + description: "does nothing", + readonly: true, + run: async () => ({ + tool: "noop", + status: "ok" as const, + summary: "noop", + details: {}, + truncated: false, + }), + }); + const session = runtime.createSession(); + sessionId = session.id; + const lateSteerResults: boolean[] = []; + try { + const result = await runtime.turnController.enqueue({ + sessionId, + origin: "tui", + run: async () => { + const r = await runtime.executeTurn(session, "do the thing", { + maxSteps: 4, + }); + // The loop has returned, so its final drain has happened. + // The controller clears `busy` in its own `finally`, i.e. + // after this body settles — so right here the two facts + // disagree, and `isBusy` is the stale one. + expect(runtime.turnController.isBusy(sessionId)).toBe(true); + lateSteerResults.push(runtime.steer(sessionId, "too late, stop")); + return r; + }, + }); + + // The in-flight steer landed where it should: a real user turn, + // folded into the next step. + expect(result.reason).toBe("reply"); + expect( + result.session.turns + .filter((t) => t.kind === "user") + .map((t) => (t as { text: string }).text), + ).toEqual(["do the thing", "check the logs first"]); + expect(events).toContainEqual({ + type: "steer_applied", + text: "check the logs first", + stepIndex: 1, + }); + + // The late one did not. The caller is told "not steered" while + // that is still true, so it can re-route... + expect(lateSteerResults).toEqual([false]); + // ...and nothing is left behind for a later turn to pick up. + expect(runtime.steeringInbox.peek(sessionId)).toEqual([]); + expect(result.undelivered).toEqual([]); + + // The symptom, spelled out: the next turn on this session must + // not open with a "while you were working" notice about a turn + // that ended before it started. + events.length = 0; + const tails: string[] = []; + const next = await runtime.runTurn(result.session, "next question", { + maxSteps: 4, + eventHook: (event) => { + if ( + event.type === "llm_event" && + event.event.type === "prompt_captured" + ) { + tails.push(event.event.tail); + } + }, + }); + expect(next.reason).toBe("reply"); + expect(events.filter((e) => e.type === "steer_applied")).toEqual([]); + expect(tails.length).toBeGreaterThan(0); + for (const tail of tails) expect(tail).not.toContain("too late, stop"); + } finally { + await runtime.shutdown(); + } + }); + + it("drops pending steers on shutdown", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true }, + }); + const session = runtime.createSession(); + runtime.steeringInbox.open(session.id); + runtime.steeringInbox.push(session.id, "stale"); + await runtime.shutdown(); + expect(runtime.steeringInbox.peek(session.id)).toEqual([]); + // The window is closed too: nothing will ever drain it again. + expect(runtime.steer(session.id, "after shutdown")).toBe(false); + }); +}); + +function completion(content: string): CompletionResult { + return { + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 10, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; +} + diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index b246bed9..c73326aa 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -11,6 +11,7 @@ import { import type { LlmStreamParams } from "../agent/step-executor.js"; import { TurnController } from "./turn-controller.js"; +import { SteeringInbox } from "./steering-inbox.js"; import type { TurnEventHook, TurnOrigin } from "./turn-controller.js"; import type { ChannelStatus } from "./channel-status.js"; @@ -70,6 +71,7 @@ import { resolveLlmConfig, } from "../llm/provider/index.js"; import { resolveActiveToolTransport } from "../llm/provider/registry/resolve-tool-transport.js"; +import { catalogForProvider } from "../llm/provider/catalog-for-provider.js"; import { CostAccumulator } from "../llm/provider/cost-accumulator.js"; import { resolveModel, @@ -182,7 +184,10 @@ import { AnalyticsStateStore, createAnalyticsClient, captureAppInstalled, + captureAppOpened, captureMessageSent, + captureModelConfigured, + captureOnboardingStep, sanitizeModelAlias, TurnUsageMeter, } from "../analytics/index.js"; @@ -194,7 +199,15 @@ import { import { getAppVersion } from "../version.js"; export interface RuntimeEventHandlers { - onAgentEvent?: (event: AgentLoopEvent) => void; + /** + * Global event sink, fired for every turn on every session. The + * second argument names the session the event belongs to (from the + * per-turn `AsyncLocalStorage` frame) so a host rendering a single + * session — the TUI — can drop events from turns running in the + * background instead of painting them into the wrong transcript. It + * is absent for events emitted outside a turn frame. + */ + onAgentEvent?: (event: AgentLoopEvent, sessionId?: string) => void; onApprovalRequest?: (request: ApprovalRequest) => void; onSkillRegistryChange?: (entries: SkillCatalogEntry[]) => void; /** @@ -234,6 +247,18 @@ export interface CreateAgentRuntimeOptions { * config or by providing their own sinks. */ traceDefault?: boolean; + /** + * Whether this runtime is being created for an interactive launch a + * person actually performed, which is what `app_opened` counts. + * + * Defaults to `false` because `createAgentRuntime` is also the entry + * point for headless work — scheduled/cron tasks, `run`, `serve`, + * the sidecar. Those create a runtime with nobody at the keyboard, and + * counting them would inflate the denominator of the activation + * funnel: one user with an hourly task would look like 24 launches a + * day. Only the TUI passes `true`. + */ + interactiveLaunch?: boolean; /** Optional overrides — used by tests to inject fakes. */ overrides?: { llamaComplete?: (params: LlmStreamParams) => Promise; @@ -292,6 +317,29 @@ export interface AgentRuntime { * funnels through this controller internally. */ readonly turnController: TurnController; + /** + * Out-of-band channel for messages sent to a session whose turn is + * already running. `TurnController` is strictly FIFO by design, so a + * mid-turn message would otherwise have to wait for the turn to + * close; the inbox lets it reach the model at the next step boundary + * instead. Prefer {@link AgentRuntime.steer} over touching this + * directly — it is the same call with the intent documented. + */ + readonly steeringInbox: SteeringInbox; + /** + * Fold `text` into the turn currently running on `sessionId`. + * + * Returns `false` — and queues nothing — when no running turn is + * still able to pick the message up (no turn in flight, or the turn + * has already done its final drain), when the text is blank, or when + * the inbox for that session is full. A `false` return means "not + * steered": the caller is expected to fall back to a normal + * `runTurn`, or to its own message queue. `true` means the message is + * either delivered at a step boundary or returned on + * `RunTurnResult.undelivered` — never stranded. Never starts a turn + * on its own. + */ + steer(sessionId: string, text: string): boolean; /** * Durable user-profile store. Present even when * `memory.profile.enabled` is `false`, because the store owns the @@ -490,6 +538,19 @@ export interface AgentRuntime { * (the TUI settings tab). Idempotent. */ setAnalyticsEnabled(enabled: boolean): Promise; + /** + * Report that the first-run flow reached `step` (a closed + * `OnboardingStep` name, never free text). `outcome` is passed only on + * the terminal step. A no-op while analytics is off. The TUI owns the + * flow, so it is the caller; the runtime owns the client. + */ + reportOnboardingStep(step: string, outcome?: string): void; + /** + * Report that a provider was verified and saved — the install has a + * working backend. Fires at most once per install (state-store + * guarded); a no-op while analytics is off. + */ + reportModelConfigured(provider: string, kind: "local" | "cloud"): void; /** * Live approval level (1 = every gated action asks … 5 = approve * everything). Reads the gate, not the boot-time config snapshot, so @@ -511,6 +572,20 @@ export interface AgentRuntime { * are not resolved retroactively. */ setApprovalLevel(level: number): void; + /** + * Plan mode: read-only until further notice. + * + * Orthogonal to the approval ladder, and deliberately so — the ladder + * answers "does this need to ask first", plan mode answers "is this + * the kind of thing we are doing right now". Every mutating tool is + * refused with a message telling the model to present a plan instead; + * every read-only tool still runs. See `agent/plan-mode.ts`. + * + * Session state rather than config: a "look but do not touch" that + * survived a restart would be a mystery rather than a memory. + */ + getPlanMode(): boolean; + setPlanMode(on: boolean): void; /** Close all resources (browser, sqlite, llama client). Safe to call twice. */ shutdown(): Promise; } @@ -570,6 +645,13 @@ export async function createAgentRuntime( logger, }); captureAppInstalled(analytics, analyticsStateStore); + // Every interactive launch, not just the first: `app_installed` alone + // cannot tell a download that never ran from one that ran and stalled. + // Gated on the entry point opting in, so a cron task or a `serve` + // process does not read as somebody opening the app. + if (options.interactiveLaunch === true) { + captureAppOpened(analytics); + } // Anonymous error reporting (Sentry). Shares the opt-out flag // (`config.analytics.enabled`) and the anonymous install id with @@ -637,6 +719,18 @@ export async function createAgentRuntime( logger.info("analytics toggled", { enabled }); }; + // Both read `analytics` at call time, so a hot-toggle is picked up + // without re-registering anything. + const reportOnboardingStep = (step: string, outcome?: string): void => { + captureOnboardingStep(analytics, step, outcome); + }; + const reportModelConfigured = ( + provider: string, + kind: "local" | "cloud", + ): void => { + captureModelConfigured(analytics, analyticsStateStore, { provider, kind }); + }; + const traceEnabled = resolveTraceEnabled( config.tracing.trace.enabled, options.traceDefault, @@ -649,7 +743,82 @@ export async function createAgentRuntime( logger, }) : null; + /** + * Trace recorders keyed by session id, bounded so a long-lived runtime + * that serves many sessions (sidecar, HTTP server, background tasks) + * cannot grow this map without limit. + * + * `Map` preserves *insertion* order, which is not the same as recency: + * re-reading a key does not move it. Evicting `keys().next()` therefore + * targets the oldest-*created* session, which in a long-lived runtime is + * usually the operator's own still-running one. `touchRecorder` re-inserts + * on every access so the order really is least-recently-used, and + * `dropRecorder` removes a session's recorder when the session itself goes + * away — cheaper and more correct than waiting for the cap to push it out. + * + * Eviction is not free: `beginSession` is written to run once per NDJSON + * file, so re-creating an evicted recorder appends a second + * `session_started` and restarts `seq` at 0 in a file that already has + * events. Anything sorting or de-duplicating by `seq` then mis-orders. + * That is why an actively-running session is never evicted. + */ + const MAX_TRACE_RECORDERS = 64; const recorders = new Map(); + /** Sessions with a turn in flight. Never evicted; see `evictRecorders`. */ + const activeTraceSessions = new Set(); + + /** Look a recorder up and mark it most-recently-used. */ + const touchRecorder = (sessionId: string): TraceRecorder | undefined => { + const recorder = recorders.get(sessionId); + if (recorder !== undefined) { + recorders.delete(sessionId); + recorders.set(sessionId, recorder); + } + return recorder; + }; + + /** Sessions deleted mid-turn, to be dropped once their turn releases. */ + const pendingRecorderDrops = new Set(); + + /** + * Forget a session's recorder once the session is gone. + * + * A delete that lands mid-turn must not unpin the running turn: the HTTP + * route deletes without an `isBusy` check (unlike the TUI, which refuses), + * and dropping the pin there would let the next burst evict a recorder the + * turn is still writing through — reintroducing the split trace file this + * pinning exists to prevent. Such a delete is deferred instead, and the + * turn's `finally` completes it; leaving it to cap pressure would strand the + * recorder of a session that no longer exists until 64 more arrive. + */ + const dropRecorder = (sessionId: string): void => { + if (activeTraceSessions.has(sessionId)) { + pendingRecorderDrops.add(sessionId); + return; + } + pendingRecorderDrops.delete(sessionId); + recorders.delete(sessionId); + }; + + /** + * Trim to the cap, least-recently-used first, skipping sessions with a live + * turn and `exempt` (the entry the caller just created — it has not had a + * chance to be pinned yet, and evicting it would throw away the recorder + * whose creation triggered this call). + * + * If everything is pinned the map is allowed over the cap: losing a running + * session's trace is worse than holding a few extra recorders, and the + * excess drains as those turns finish and release their pins. + */ + const evictRecorders = (exempt?: string): void => { + if (recorders.size <= MAX_TRACE_RECORDERS) return; + for (const sessionId of [...recorders.keys()]) { + if (recorders.size <= MAX_TRACE_RECORDERS) break; + if (sessionId === exempt) continue; + if (activeTraceSessions.has(sessionId)) continue; + recorders.delete(sessionId); + } + }; /** * Per-turn context used to route `loopDeps.onEvent` calls back to * the correct session. Two sessions running concurrently each have @@ -658,6 +827,7 @@ export async function createAgentRuntime( * pointer. */ const turnContext = new AsyncLocalStorage<{ sessionId: string }>(); + const steeringInbox = new SteeringInbox(); const turnController = new TurnController({ onHookError: (err, ctxInfo) => { logger.warn("turn event hook threw", { @@ -679,7 +849,7 @@ export async function createAgentRuntime( const emitAgentLoopEvent = (event: AgentLoopEvent): void => { const ctx = turnContext.getStore(); if (ctx) { - const recorder = recorders.get(ctx.sessionId); + const recorder = touchRecorder(ctx.sessionId); recorder?.onAgentEvent(event); turnController.emit(ctx.sessionId, event); } @@ -689,7 +859,7 @@ export async function createAgentRuntime( category: event.category, }); } - options.handlers?.onAgentEvent?.(event); + options.handlers?.onAgentEvent?.(event, ctx?.sessionId); }; // Cross-provider fallover breaker. Owns no timer — every decision is @@ -733,7 +903,11 @@ export async function createAgentRuntime( !options.overrides?.deferLlamaHealthCheck && !options.overrides?.llamaComplete ) { - const health = await checkLlamaServer(); + // One attempt, not the retry ladder: this probe exists to log a line, + // and with llama down the default ladder (5 attempts, exponential + // backoff) stalled every boot for 15.5 s before the loop then failed + // fast anyway. The first real completion is the retry. + const health = await checkLlamaServer({ retries: 0 }); if (!health.reachable) { logger.warn("llama-server health check failed", { error: health.error, @@ -1020,6 +1194,15 @@ export async function createAgentRuntime( // column-only `listRecentWorkingDirs` projection, so the store must // exist by the time `registerOsTools` wires the closure below. const sessionStore = new SessionStore(); + // Drop a session's trace recorder when the session itself is deleted, so + // the map shrinks on teardown instead of relying on the cap to push + // entries out. Wrapped here rather than at each call site (the TUI and the + // HTTP route both delete sessions) so every caller gets it. + const deleteSession = sessionStore.delete.bind(sessionStore); + sessionStore.delete = (id: string): void => { + dropRecorder(id); + deleteSession(id); + }; const toolRegistry = new ToolRegistry(); toolRegistry.register(finishTool); @@ -1126,6 +1309,7 @@ export async function createAgentRuntime( transport: resolveActiveToolTransport(resolved, provider), adapter: provider.toolCallAdapter ?? null, slotAffinity: provider.capabilities.supportsSlotAffinity, + parallelTools: provider.capabilities.supportsParallelTools, }; }; @@ -1170,10 +1354,16 @@ export async function createAgentRuntime( const turnUsageMeter = new TurnUsageMeter(); /** - * Pricing for a model id on the active provider, when the operator - * configured any. Cloud entries carry `userModels[].pricing`; local - * runners have none, which is why turn cost is reported as absent - * rather than zero for them. + * Pricing for a model id on the active provider, when any is known. + * + * Two sources, in `resolveModel`'s own precedence: a hand-configured + * `userModels[].pricing` first, then the provider's bundled catalog. + * The catalog is what makes cost work out of the box on OpenRouter and + * aimlapi, whose published prices ship with the agent; without it only + * operators who priced their models by hand ever saw a `cost_usd`. + * + * Local runners still resolve to no pricing, which is why turn cost is + * reported as absent rather than zero for them. */ const resolveModelPricing = ( modelId: string | null, @@ -1184,7 +1374,28 @@ export async function createAgentRuntime( (p) => p.id === resolved.activeTextProvider, ); if (!entry) return undefined; - return resolveModel(entry, modelId); + return resolveModel(entry, modelId, catalogForProvider(entry)); + }; + + /** + * The active model's context window, for providers the `/props` probe + * cannot reach. + * + * `source === "default"` is deliberately treated as unknown. That + * branch is `DEFAULT_CHAT`'s nominal 128k — a placeholder, not a fact + * about the model actually serving the request — and a budget computed + * against a guessed window silently mis-sizes every prompt. Better to + * report no window and let the caller fall back to a fixed cap it can + * defend. The same reasoning keeps the TUI gauge from drawing itself + * against that number. + * + * Resolved per step rather than captured once, so switching model + * mid-session is picked up by the next prompt. + */ + const resolveCatalogContextWindow = (): number | null => { + const model = resolveModelPricing(resolveActiveModelName()); + if (!model || model.source === "default") return null; + return model.contextWindow > 0 ? model.contextWindow : null; }; // Vision reuses the active text provider when it exposes describeImage. @@ -1427,7 +1638,7 @@ export async function createAgentRuntime( // `turn_finished`, so a missing recorder is a normal "tracing // disabled for this session" outcome, not an error. emitTrace: (event: ReflectionTraceEvent) => { - const recorder = recorders.get(event.sessionId); + const recorder = touchRecorder(event.sessionId); if (!recorder) return; recorder.recordReflection({ outcome: event.outcome, @@ -1495,7 +1706,7 @@ export async function createAgentRuntime( // Per-session trace emission — same resolve-by-sessionId // pattern as reflection / vote. emitTrace: (event) => { - const recorder = recorders.get(event.sessionId); + const recorder = touchRecorder(event.sessionId); if (!recorder) return; recorder.recordLinkGenerator({ outcome: event.outcome, @@ -1570,7 +1781,7 @@ export async function createAgentRuntime( // recorder is a normal "tracing disabled for this session" // outcome, not an error. emitTrace: (event) => { - const recorder = recorders.get(event.sessionId); + const recorder = touchRecorder(event.sessionId); if (!recorder) return; if (event.type === "applied") { recorder.recordVoteApplied({ @@ -1722,7 +1933,7 @@ export async function createAgentRuntime( // not exist yet on the very first turn; a missing recorder is a // normal "tracing disabled" outcome. emitTrace: (event) => { - const recorder = recorders.get(event.sessionId); + const recorder = touchRecorder(event.sessionId); if (!recorder) return; recorder.recordQueryRewriter({ outcome: event.outcome }); }, @@ -1734,18 +1945,31 @@ export async function createAgentRuntime( }); } + // Plan mode. Session state, deliberately not config: it is a stance + // for the next few turns, not a setting, and a "look but do not touch" + // that survived a restart would be a mystery rather than a memory. + let planMode = false; + // The `skillCatalog` is a getter so that `agent-loop` reads the current // value on every step — `refreshSkills()` then does not require tearing // down the loop. const loopDeps = { registry: toolRegistry, + // A getter, so `runtime.setPlanMode` is observed by the next tool + // call rather than by the next process. Same reason the approval + // gate is the single live switch rather than a boolean copied into + // each tool registration. + isPlanMode: () => planMode, slotManager, grammar, llmComplete, + // Mid-turn steering: the loop drains this at every step boundary. + steeringInbox, ...(llmCompleteStream ? { llmCompleteStream } : {}), toolDescriptors: effectiveToolDescriptors, capabilities, profile, + contextWindow: resolveCatalogContextWindow, ...(profileManager ? { profileManager } : {}), ...(config.memory.profile.enabled ? { profileFactsProvider: () => profileStore.list() } @@ -1822,6 +2046,10 @@ export async function createAgentRuntime( enumerable: true, get: () => resolveActiveLlmSlice().slotAffinity, }); + Object.defineProperty(loopDeps, "supportsParallelTools", { + enumerable: true, + get: () => resolveActiveLlmSlice().parallelTools, + }); const loop = new AgentLoop( loopDeps as typeof loopDeps & { skillCatalog: readonly SkillCatalogEntry[]; @@ -1840,6 +2068,9 @@ export async function createAgentRuntime( const shutdown = async (): Promise => { if (shutdownCalled) return; shutdownCalled = true; + // Nothing will drain the inbox after this point; drop pending + // steers so a message cannot resurface in a later process. + steeringInbox.clearAll(); // Cancel any in-flight reflection before tearing down the profile // store — otherwise a late-arriving completion could try to write // into a closed SQLite connection. @@ -2016,7 +2247,7 @@ export async function createAgentRuntime( const ensureRecorder = (session: SessionState): TraceRecorder | null => { if (!traceBus) return null; - const existing = recorders.get(session.id); + const existing = touchRecorder(session.id); if (existing) return existing; const recorder = createTraceRecorder({ sessionId: session.id, @@ -2027,6 +2258,11 @@ export async function createAgentRuntime( ...(session.metadata ? { metadata: session.metadata } : {}), }); recorders.set(session.id, recorder); + // Exempt the entry just created: the caller pins it only after this + // returns, so without this it is the sole unpinned entry when every other + // session is mid-turn and would evict itself — losing the whole turn's + // trace to a file that already has its `session_started` line. + evictRecorders(session.id); return recorder; }; @@ -2049,17 +2285,57 @@ export async function createAgentRuntime( runOptions: { maxSteps?: number; signal?: AbortSignal } = {}, ): Promise => { ensureRecorder(session); + // Pin this session for the duration of the turn. Without it a burst of + // new sessions can push this one's recorder out mid-turn, after which + // `emitAgentLoopEvent`'s `recorders.get(...)?.` silently drops every + // remaining event of the turn and any tool call whose `pendingCalls` + // entry went with it is logged with empty args. + activeTraceSessions.add(session.id); return turnContext.run({ sessionId: session.id }, async () => { - const result = await loop.runTurn(session, { - userMessage, - maxSteps: runOptions.maxSteps ?? config.agent.maxSteps, - signal: runOptions.signal ?? new AbortController().signal, - }); - sessionStore.save(result.session); - return result; + try { + const result = await loop.runTurn(session, { + userMessage, + maxSteps: runOptions.maxSteps ?? config.agent.maxSteps, + signal: runOptions.signal ?? new AbortController().signal, + }); + sessionStore.save(result.session); + return result; + } finally { + activeTraceSessions.delete(session.id); + // A delete that arrived mid-turn was deferred to keep the pin honest; + // complete it now that nothing is writing through the recorder. + if (pendingRecorderDrops.has(session.id)) { + pendingRecorderDrops.delete(session.id); + recorders.delete(session.id); + } + // The turn may have out-waited a burst that could not evict while it + // was pinned; settle the map now that it can. + evictRecorders(); + } }); }; + /** + * Public entry point for mid-turn steering. Deliberately does NOT + * enqueue: the whole point is to reach the turn that is already + * running, and going through `turnController` would put the message + * behind it. + * + * One call, one decision. It deliberately does NOT pre-check + * `turnController.isBusy`: that is a second fact which stops being + * true at a different moment than "the loop will drain this again" + * (the loop's final drain happens inside `runTurn`, `busy.delete` + * later in the controller's `finally`). Guarding on it made this a + * check-then-act with a real lost-update window — accepted here, + * never delivered, and resurfacing at step 0 of some later turn under + * a "while you were working" notice about a turn that had already + * ended. `push` alone is authoritative: it accepts only while the + * running turn's window is open, and that window is closed by the + * same call that performs the final drain. + */ + const steer = (sessionId: string, text: string): boolean => + steeringInbox.push(sessionId, text); + const runTurn = async ( session: SessionState, userMessage: string, @@ -2074,7 +2350,23 @@ export async function createAgentRuntime( const submission = { sessionId: session.id, origin, - run: () => executeTurn(session, userMessage, runOptions), + // Re-read the freshest stored session when the queue hands over + // the lock, not when the caller enqueued: between those moments a + // turn from another origin (scheduler, HTTP, a TUI thread the + // operator backgrounded) can finish and save, and running on the + // caller's snapshot would make whichever turn saves last clobber + // the other's transcript. This is the contract's own rule — never + // hold a stale `SessionState` between enqueue and run; re-read + // inside the queued callback (§"Concurrency contract"). A session + // the store cannot answer for (never persisted, or deleted while + // parked) falls back to the caller's copy, the pre-existing + // behaviour. + run: () => + executeTurn( + sessionStore.load(session.id) ?? session, + userMessage, + runOptions, + ), ...(runOptions.eventHook ? { eventHook: runOptions.eventHook } : {}), ...(runOptions.signal ? { signal: runOptions.signal } : {}), } as const; @@ -2393,6 +2685,8 @@ export async function createAgentRuntime( slotManager, sessionStore, turnController, + steeringInbox, + steer, profileStore, notesStore, lessonStore, @@ -2421,8 +2715,14 @@ export async function createAgentRuntime( setApprovalHandlerForSession: (sessionId, handler) => approvalRouter.setForSession(sessionId, handler), setAnalyticsEnabled, + reportOnboardingStep, + reportModelConfigured, getApprovalLevel: () => approvals.getLevel(), setApprovalLevel: (level) => approvals.setLevel(level), + getPlanMode: () => planMode, + setPlanMode: (on: boolean) => { + planMode = on; + }, shutdown, } as AgentRuntime & { telegramChannel: TelegramChannel | null }; Object.defineProperty(runtime, "skillCatalog", { diff --git a/src/runtime/heap-guard.test.ts b/src/runtime/heap-guard.test.ts new file mode 100644 index 00000000..57a258c5 --- /dev/null +++ b/src/runtime/heap-guard.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { + classifyHeap, + createHeapGuard, + readProcessHeap, + HEAP_WARN_RATIO, + HEAP_CRITICAL_RATIO, + type HeapReading, +} from "./heap-guard.js"; + +const GB = 1_073_741_824; +const reading = (usedGb: number, limitGb = 4): HeapReading => ({ + usedBytes: usedGb * GB, + limitBytes: limitGb * GB, +}); + +describe("classifyHeap", () => { + it("stays quiet with headroom", () => { + const s = classifyHeap(reading(1)); + expect(s.severity).toBe("ok"); + expect(s.message).toBeNull(); + }); + + it("warns at the warn ratio and names the remedy", () => { + const s = classifyHeap(reading(4 * HEAP_WARN_RATIO)); + expect(s.severity).toBe("warn"); + expect(s.message).toContain("max-old-space-size"); + }); + + it("escalates at the critical ratio", () => { + const s = classifyHeap(reading(4 * HEAP_CRITICAL_RATIO)); + expect(s.severity).toBe("critical"); + expect(s.message).toMatch(/critical/); + }); + + it("reports the real numbers in MB, matching the #121 crash", () => { + // The reported crash: 4083 MB used against a ~4288 MB ceiling. + const s = classifyHeap({ usedBytes: 4083 * 1_048_576, limitBytes: 4288 * 1_048_576 }); + expect(s.severity).toBe("critical"); + expect(s.message).toContain("4083 MB"); + expect(s.message).toContain("4288 MB"); + }); + + it("does not divide by zero when no ceiling is reported", () => { + const s = classifyHeap({ usedBytes: 5 * GB, limitBytes: 0 }); + expect(s.severity).toBe("ok"); + expect(s.ratio).toBe(0); + }); +}); + +describe("createHeapGuard", () => { + it("reports each escalation once, not on every poll", () => { + let used = 1; + const guard = createHeapGuard(() => reading(used)); + expect(guard.check()).toBeNull(); // ok + used = 3.2; // ~80% -> warn + expect(guard.check()?.severity).toBe("warn"); + expect(guard.check()).toBeNull(); // still warn, stay quiet + expect(guard.check()).toBeNull(); + used = 3.8; // ~95% -> critical + expect(guard.check()?.severity).toBe("critical"); + expect(guard.check()).toBeNull(); + }); + + it("re-arms after memory is released", () => { + let used = 3.2; + const guard = createHeapGuard(() => reading(used)); + expect(guard.check()?.severity).toBe("warn"); + used = 1; // recovered + expect(guard.check()).toBeNull(); + used = 3.2; // climbs again -> warn again + expect(guard.check()?.severity).toBe("warn"); + }); +}); + +describe("readProcessHeap", () => { + it("reads a real, sane ceiling from this process", () => { + const r = readProcessHeap(); + expect(r.limitBytes).toBeGreaterThan(0); + expect(r.usedBytes).toBeGreaterThan(0); + expect(r.usedBytes).toBeLessThan(r.limitBytes); + }); +}); diff --git a/src/runtime/heap-guard.ts b/src/runtime/heap-guard.ts new file mode 100644 index 00000000..f270fe60 --- /dev/null +++ b/src/runtime/heap-guard.ts @@ -0,0 +1,111 @@ +import v8 from "node:v8"; + +/** + * Heap headroom watchdog. + * + * Issue #121: a long session died with `FATAL ERROR: Ineffective + * mark-compacts near heap limit` at 4083 MB — Node's *default* ceiling + * (~4288 MB), because the SEA build sets no `--max-old-space-size`. The + * process vanished with no warning and took ~40 minutes of work with it. + * + * V8 cannot raise its own ceiling after startup (`setFlagsFromString` + * is a no-op for `--max-old-space-size` once the heap exists), so this + * cannot prevent the crash. What it can do is make the crash *legible*: + * warn while there is still headroom to save work and restart with a + * bigger ceiling. + * + * Pure except for the injected `readHeap`, so the thresholds are unit + * testable without allocating gigabytes. + */ + +export interface HeapReading { + usedBytes: number; + limitBytes: number; +} + +export type HeapSeverity = "ok" | "warn" | "critical"; + +export interface HeapStatus { + severity: HeapSeverity; + usedBytes: number; + limitBytes: number; + /** Fraction of the ceiling in use, 0..1. */ + ratio: number; + /** Operator-facing line; `null` while `ok`. */ + message: string | null; +} + +/** Fraction of the heap ceiling at which we start warning. */ +export const HEAP_WARN_RATIO = 0.75; +/** Fraction at which a crash is imminent and the wording escalates. */ +export const HEAP_CRITICAL_RATIO = 0.9; + +export function readProcessHeap(): HeapReading { + const s = v8.getHeapStatistics(); + return { usedBytes: s.used_heap_size, limitBytes: s.heap_size_limit }; +} + +function mb(bytes: number): string { + return `${Math.round(bytes / 1_048_576)} MB`; +} + +/** + * Classify one heap reading. `limitBytes <= 0` (a runtime that does not + * report a ceiling) is treated as "ok" rather than dividing by zero. + */ +export function classifyHeap(reading: HeapReading): HeapStatus { + const { usedBytes, limitBytes } = reading; + if (!Number.isFinite(limitBytes) || limitBytes <= 0) { + return { severity: "ok", usedBytes, limitBytes, ratio: 0, message: null }; + } + const ratio = usedBytes / limitBytes; + if (ratio >= HEAP_CRITICAL_RATIO) { + return { + severity: "critical", + usedBytes, + limitBytes, + ratio, + message: + `memory critical: ${mb(usedBytes)} of ${mb(limitBytes)} used. ` + + `The agent may be killed by the runtime without warning — finish or ` + + `save this turn, then restart with a bigger heap: ` + + `NODE_OPTIONS=--max-old-space-size=8192`, + }; + } + if (ratio >= HEAP_WARN_RATIO) { + return { + severity: "warn", + usedBytes, + limitBytes, + ratio, + message: + `memory high: ${mb(usedBytes)} of ${mb(limitBytes)} used. ` + + `Long sessions with large file reads can exhaust the default heap; ` + + `restarting with NODE_OPTIONS=--max-old-space-size=8192 raises it.`, + }; + } + return { severity: "ok", usedBytes, limitBytes, ratio, message: null }; +} + +/** + * Stateful wrapper that reports only when severity *rises*, so a session + * sitting at 76% does not repeat the same line on every poll. Dropping + * back below a threshold re-arms it. + */ +export function createHeapGuard( + readHeap: () => HeapReading = readProcessHeap, +): { check: () => HeapStatus | null } { + const rank: Record = { ok: 0, warn: 1, critical: 2 }; + let reported: HeapSeverity = "ok"; + return { + check(): HeapStatus | null { + const status = classifyHeap(readHeap()); + if (rank[status.severity] > rank[reported]) { + reported = status.severity; + return status; + } + if (rank[status.severity] < rank[reported]) reported = status.severity; + return null; + }, + }; +} diff --git a/src/runtime/recorder-eviction.test.ts b/src/runtime/recorder-eviction.test.ts new file mode 100644 index 00000000..0eb13ec9 --- /dev/null +++ b/src/runtime/recorder-eviction.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; + +/** + * Regression: issue #121 — `bootstrap.ts` kept a `TraceRecorder` per session + * id in a Map that had no `delete`/`clear` anywhere, so a long-lived runtime + * serving many sessions (sidecar, HTTP server, background tasks) grew it + * forever. The map is now bounded, evicts least-recently-*used* first, and + * never evicts a session with a turn in flight. + * + * `recorders` is a closure-private detail of `createAgentRuntime`, so this + * mirrors the three helpers it uses (`touchRecorder`, `dropRecorder`, + * `evictRecorders`) and pins the rules they implement. An earlier version of + * this file re-implemented plain insertion-order eviction and so agreed with + * the bug it was meant to catch: `Map` preserves insertion order, which is + * not recency, so the oldest-*created* session was evicted even while it was + * the one actively running. + */ +function makeRecorderMap(cap: number) { + const recorders = new Map(); + const active = new Set(); + const pendingDrops = new Set(); + + const touch = (id: string) => { + const found = recorders.get(id); + if (found !== undefined) { + recorders.delete(id); + recorders.set(id, found); + } + return found; + }; + + const evict = (exempt?: string) => { + if (recorders.size <= cap) return; + for (const id of [...recorders.keys()]) { + if (recorders.size <= cap) break; + if (id === exempt) continue; + if (active.has(id)) continue; + recorders.delete(id); + } + }; + + return { + recorders, + active, + touch, + evict, + ensure(id: string) { + const existing = touch(id); + if (existing) return existing; + const created = { id }; + recorders.set(id, created); + evict(id); + return created; + }, + drop(id: string) { + if (active.has(id)) { pendingDrops.add(id); return; } + pendingDrops.delete(id); + recorders.delete(id); + }, + endTurn(id: string) { + active.delete(id); + if (pendingDrops.has(id)) { pendingDrops.delete(id); recorders.delete(id); } + evict(); + }, + }; +} + +describe("trace recorder map eviction (issue #121)", () => { + it("stays at the cap no matter how many sessions arrive", () => { + const m = makeRecorderMap(64); + for (let i = 0; i < 5_000; i += 1) m.ensure(`s-${i}`); + expect(m.recorders.size).toBe(64); + }); + + it("keeps the newest entry and drops the least recently used", () => { + const m = makeRecorderMap(3); + for (let i = 0; i < 10; i += 1) m.ensure(`s-${i}`); + expect([...m.recorders.keys()]).toEqual(["s-7", "s-8", "s-9"]); + }); + + it("a session that keeps working is not evicted by newer arrivals", () => { + // The bug: insertion order never refreshed on a hit, so the oldest + // *created* session — usually the operator's own long-running one — was + // the first thrown away no matter how recently it had spoken. + const m = makeRecorderMap(3); + m.ensure("operator"); + for (let i = 0; i < 20; i += 1) { + m.ensure(`sidecar-${i}`); + m.ensure("operator"); // still working + } + expect(m.recorders.has("operator")).toBe(true); + }); + + it("never evicts a session with a turn in flight", () => { + // Losing a running session's recorder mid-turn silently drops the rest + // of that turn's events, so an active session outranks the cap. + const m = makeRecorderMap(3); + m.ensure("busy"); + m.active.add("busy"); + for (let i = 0; i < 50; i += 1) m.ensure(`other-${i}`); + expect(m.recorders.has("busy")).toBe(true); + + // Once the turn ends it becomes evictable like anything else. + m.active.delete("busy"); + for (let i = 0; i < 50; i += 1) m.ensure(`later-${i}`); + expect(m.recorders.has("busy")).toBe(false); + }); + + it("deleting a session drops its recorder immediately", () => { + const m = makeRecorderMap(64); + m.ensure("gone"); + expect(m.recorders.has("gone")).toBe(true); + m.drop("gone"); + expect(m.recorders.has("gone")).toBe(false); + expect(m.active.has("gone")).toBe(false); + }); + + it("a new session is never evicted by its own insertion", () => { + // The bug: `ensureRecorder` evicts right after inserting, but the caller + // pins the session only after that returns. With every other entry + // mid-turn, the newcomer was the sole unpinned entry and deleted itself — + // then ran a whole turn writing through a recorder no longer in the map, + // leaving a trace file with a lone `session_started` line. + const m = makeRecorderMap(64); + for (let i = 0; i < 64; i += 1) { + m.ensure(`busy-${i}`); + m.active.add(`busy-${i}`); + } + m.ensure("newcomer"); + expect(m.recorders.has("newcomer")).toBe(true); + // Everything else is pinned, so the map is allowed over the cap until + // those turns finish. + expect(m.recorders.size).toBe(65); + }); + + it("deleting a session mid-turn does not unpin the running turn", () => { + // The HTTP route deletes with no `isBusy` guard, unlike the TUI. Dropping + // the pin there would let the next burst evict a recorder the turn is + // still writing through. + const m = makeRecorderMap(3); + m.ensure("running"); + m.active.add("running"); + m.drop("running"); + expect(m.recorders.has("running")).toBe(true); + expect(m.active.has("running")).toBe(true); + + // The turn's finally completes the deferred delete, rather than leaving + // a dead session's recorder to be pushed out by cap pressure later. + m.endTurn("running"); + expect(m.recorders.has("running")).toBe(false); + }); + + it("re-inserting an existing key does not grow the map", () => { + const m = makeRecorderMap(2); + m.ensure("a"); + m.ensure("b"); + m.ensure("a"); + expect(m.recorders.size).toBe(2); + }); +}); diff --git a/src/runtime/steering-inbox.test.ts b/src/runtime/steering-inbox.test.ts new file mode 100644 index 00000000..b225f989 --- /dev/null +++ b/src/runtime/steering-inbox.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { MAX_PENDING_STEERS, SteeringInbox } from "./steering-inbox.js"; + +/** An inbox with one session already accepting steers, as a live turn leaves it. */ +function openInbox(...sessionIds: string[]): SteeringInbox { + const inbox = new SteeringInbox(); + for (const id of sessionIds) inbox.open(id); + return inbox; +} + +describe("SteeringInbox", () => { + it("drains what was pushed, in order", () => { + const inbox = openInbox("s1"); + expect(inbox.push("s1", "first")).toBe(true); + expect(inbox.push("s1", "second")).toBe(true); + expect(inbox.drain("s1")).toEqual(["first", "second"]); + }); + + it("empties the slot on drain so one message is delivered once", () => { + const inbox = openInbox("s1"); + inbox.push("s1", "only"); + expect(inbox.drain("s1")).toEqual(["only"]); + expect(inbox.drain("s1")).toEqual([]); + }); + + it("returns an empty array for a session that was never pushed to", () => { + expect(new SteeringInbox().drain("nobody")).toEqual([]); + }); + + it("keeps sessions isolated", () => { + const inbox = openInbox("a", "b"); + inbox.push("a", "for-a"); + inbox.push("b", "for-b"); + expect(inbox.drain("a")).toEqual(["for-a"]); + expect(inbox.drain("b")).toEqual(["for-b"]); + }); + + it("trims and rejects blank text", () => { + const inbox = openInbox("s1"); + expect(inbox.push("s1", " ")).toBe(false); + expect(inbox.push("s1", "\n\t")).toBe(false); + expect(inbox.push("s1", " padded ")).toBe(true); + expect(inbox.drain("s1")).toEqual(["padded"]); + }); + + it("refuses past the per-session cap instead of dropping the oldest", () => { + const inbox = openInbox("s1"); + for (let i = 0; i < MAX_PENDING_STEERS; i += 1) { + expect(inbox.push("s1", `m${i}`)).toBe(true); + } + // A refusal is the signal the caller needs to park the message + // somewhere else; silently evicting m0 would lose it. + expect(inbox.push("s1", "overflow")).toBe(false); + const drained = inbox.drain("s1"); + expect(drained).toHaveLength(MAX_PENDING_STEERS); + expect(drained[0]).toBe("m0"); + expect(drained).not.toContain("overflow"); + }); + + it("accepts again once the cap is drained", () => { + const inbox = openInbox("s1"); + for (let i = 0; i < MAX_PENDING_STEERS; i += 1) inbox.push("s1", `m${i}`); + expect(inbox.push("s1", "nope")).toBe(false); + inbox.drain("s1"); + expect(inbox.push("s1", "yes")).toBe(true); + }); + + it("peek does not consume", () => { + const inbox = openInbox("s1"); + inbox.push("s1", "held"); + expect(inbox.peek("s1")).toEqual(["held"]); + expect(inbox.peek("s1")).toEqual(["held"]); + expect(inbox.drain("s1")).toEqual(["held"]); + }); + + it("clear drops one session, clearAll drops every session", () => { + const inbox = openInbox("a", "b"); + inbox.push("a", "x"); + inbox.push("b", "y"); + inbox.clear("a"); + expect(inbox.peek("a")).toEqual([]); + expect(inbox.peek("b")).toEqual(["y"]); + inbox.clearAll(); + expect(inbox.peek("b")).toEqual([]); + }); + + describe("acceptance window", () => { + it("refuses a push for a session no turn has opened", () => { + const inbox = new SteeringInbox(); + expect(inbox.isOpen("s1")).toBe(false); + expect(inbox.push("s1", "nobody is listening")).toBe(false); + expect(inbox.peek("s1")).toEqual([]); + }); + + it("closeAndDrain hands back what is pending and refuses the next push", () => { + const inbox = openInbox("s1"); + expect(inbox.push("s1", "just in time")).toBe(true); + expect(inbox.closeAndDrain("s1")).toEqual(["just in time"]); + // This is the lost-update window: the turn's final drain has + // happened, so accepting here would strand the message until an + // unrelated later turn picked it up. + expect(inbox.push("s1", "one microtask too late")).toBe(false); + expect(inbox.peek("s1")).toEqual([]); + expect(inbox.isOpen("s1")).toBe(false); + }); + + it("closeAndDrain is idempotent", () => { + const inbox = openInbox("s1"); + inbox.push("s1", "x"); + expect(inbox.closeAndDrain("s1")).toEqual(["x"]); + expect(inbox.closeAndDrain("s1")).toEqual([]); + }); + + it("a mid-turn drain keeps the window open", () => { + const inbox = openInbox("s1"); + inbox.push("s1", "step 0"); + expect(inbox.drain("s1")).toEqual(["step 0"]); + expect(inbox.isOpen("s1")).toBe(true); + expect(inbox.push("s1", "step 1")).toBe(true); + }); + + it("closes only the session it was asked about", () => { + const inbox = openInbox("a", "b"); + inbox.closeAndDrain("a"); + expect(inbox.push("a", "no")).toBe(false); + expect(inbox.push("b", "yes")).toBe(true); + }); + + it("clear and clearAll close the window too", () => { + const inbox = openInbox("a", "b"); + inbox.clear("a"); + expect(inbox.push("a", "no")).toBe(false); + inbox.clearAll(); + expect(inbox.push("b", "no")).toBe(false); + }); + + it("reopens for the next turn on the same session", () => { + const inbox = openInbox("s1"); + inbox.closeAndDrain("s1"); + inbox.open("s1"); + expect(inbox.push("s1", "next turn")).toBe(true); + }); + }); +}); diff --git a/src/runtime/steering-inbox.ts b/src/runtime/steering-inbox.ts new file mode 100644 index 00000000..444d60de --- /dev/null +++ b/src/runtime/steering-inbox.ts @@ -0,0 +1,141 @@ +/** + * Per-session mailbox for user messages that arrive **while a turn is + * already running**. + * + * The runtime has exactly one ordered path into `AgentLoop.runTurn` + * (`TurnController`, per-session FIFO), and that is deliberate: two + * concurrent turns on one session would race the browser, the slot + * manager and the transcript. But FIFO also means a message sent + * mid-turn cannot reach the model until the current turn closes, which + * is the wrong answer when the operator is watching the agent walk off + * a cliff and wants to redirect it *now*. + * + * This inbox is the out-of-band channel for exactly that. It does not + * start turns and it does not touch the queue: `AgentLoop` drains it at + * the top of every step and folds the text into that step's `### notice` + * block. The effect lands at the next **step** boundary — never + * mid-inference, and never mid-tool-call. + * + * Ownership mirrors `TurnController`: one instance per runtime, keyed by + * session id, and cross-session isolated by construction. + */ + +/** + * Maximum messages held for one session before `push` starts refusing. + * A turn stuck in a long tool call can be steered a handful of times + * before the model gets a chance to read any of them; past that the + * caller should queue instead of piling more onto one prompt. Refusing + * is safer than dropping the oldest — the caller learns the message did + * not land and can park it. + */ +export const MAX_PENDING_STEERS = 16; + +/** + * The turn's side of the inbox: open the gate when the turn starts + * accepting steers, drain at each step boundary, and close+drain in one + * step on the way out. Declared narrow so `AgentLoop` never sees `push`. + */ +export interface SteeringChannel { + open(sessionId: string): void; + drain(sessionId: string): readonly string[]; + closeAndDrain(sessionId: string): readonly string[]; +} + +export class SteeringInbox implements SteeringChannel { + private readonly bySession = new Map(); + /** + * Sessions whose running turn is still willing to pick messages up. + * This — not `TurnController.isBusy` — is what `push` gates on. + * + * `isBusy` and "a step boundary is still coming" are two different + * facts that stop being true at two different moments: the loop does + * its final drain inside `runTurn`, while the controller clears + * `busy` later, in its own `finally`. A `push` in that window used to + * be accepted (busy was still true) and then sat here until some + * unrelated later turn drained it — the operator saw the message + * accepted and the running turn never saw it. Making acceptance a + * property of *this* object, flipped by the same call that performs + * the final drain, collapses the two facts into one. + */ + private readonly accepting = new Set(); + + /** + * Start accepting steers for the turn now running on `sessionId`. + * Called by `AgentLoop.runTurn` on entry. Idempotent. + */ + open(sessionId: string): void { + this.accepting.add(sessionId); + } + + /** True while a turn on `sessionId` can still pick messages up. */ + isOpen(sessionId: string): boolean { + return this.accepting.has(sessionId); + } + + /** + * Queue a message for the turn currently running on `sessionId`. + * Returns `false` when no turn is accepting steers for that session, + * when the text is blank, or when the per-session cap is reached — + * callers treat any `false` as "not steered, park it instead". + */ + push(sessionId: string, text: string): boolean { + if (!this.accepting.has(sessionId)) return false; + const trimmed = text.trim(); + if (trimmed.length === 0) return false; + const pending = this.bySession.get(sessionId); + if (pending === undefined) { + this.bySession.set(sessionId, [trimmed]); + return true; + } + if (pending.length >= MAX_PENDING_STEERS) return false; + pending.push(trimmed); + return true; + } + + /** + * Take everything pending for `sessionId` and empty the slot. Always + * returns an array (possibly empty) so callers never branch on + * `undefined`. + */ + drain(sessionId: string): readonly string[] { + const pending = this.bySession.get(sessionId); + if (pending === undefined || pending.length === 0) return []; + this.bySession.delete(sessionId); + return pending; + } + + /** + * Stop accepting and take what is left, as a single indivisible step. + * + * This is the turn's LAST act on the inbox. Everything returned here + * is `RunTurnResult.undelivered` — the caller's to re-route. Every + * `push` that lands after it is refused, so the sender is told "not + * steered" while the fact is still true, instead of being told "yes" + * and having the text stranded until an unrelated later turn. + * + * Idempotent: a second call returns `[]`. + */ + closeAndDrain(sessionId: string): readonly string[] { + this.accepting.delete(sessionId); + return this.drain(sessionId); + } + + /** Non-destructive read, for UI badges and tests. */ + peek(sessionId: string): readonly string[] { + // A copy: the readonly type does not stop the live array from + // mutating under a caller that cached it across a push. + return [...(this.bySession.get(sessionId) ?? [])]; + } + + /** Discard pending messages for one session (session switch / abort). */ + clear(sessionId: string): void { + this.accepting.delete(sessionId); + this.bySession.delete(sessionId); + } + + /** Discard everything (runtime shutdown). */ + clearAll(): void { + this.accepting.clear(); + this.bySession.clear(); + } +} diff --git a/src/sandbox/command-runner.test.ts b/src/sandbox/command-runner.test.ts new file mode 100644 index 00000000..85ec9ee6 --- /dev/null +++ b/src/sandbox/command-runner.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { isBrokenPipe, runCommand } from "./command-runner.js"; + +/** + * These run real children rather than a mocked `child_process`: the + * behaviour under test is what the kernel does when a pipe's reader + * disappears mid-write, which a mock cannot reproduce. + */ +function node(script: string) { + return { command: process.execPath, args: ["-e", script] }; +} + +/** Exits without reading stdin — the shape of a CLI that rejects the request. */ +const REJECTS_INPUT = ` + process.stderr.write("Please run /login to authenticate", () => process.exit(1)); +`; + +/** Reads stdin to the end and reports how many bytes arrived. */ +const COUNTS_INPUT = ` + let n = 0; + process.stdin.on("data", (c) => { n += c.length; }); + process.stdin.on("end", () => process.stdout.write(String(n))); +`; + +const KIB = 1024; + +describe("runCommand stdin", () => { + it("survives a child that exits before draining a 1 MiB payload", async () => { + // Without an `error` listener on `child.stdin` the EPIPE raised here + // is an uncaught exception, which `installGlobalErrorHandlers` keeps + // fatal: the whole agent exits(1) instead of reporting the child's + // own failure. Measured boundary: 16 and 64 KiB flush into the pipe + // buffer and never fault, 128 KiB and up always do. + const { command, args } = node(REJECTS_INPUT); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(1024 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("/login"); + expect(result.inputTruncated).toBe(true); + }); + + it("reports the child's own exit code, not the broken pipe", async () => { + const { command, args } = node(` + process.stderr.write("weekly limit reached", () => process.exit(7)); + `); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(256 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(7); + expect(result.stderr).toBe("weekly limit reached"); + }); + + it("leaves inputTruncated false when the payload fits the pipe buffer", async () => { + // 64 KiB lands in the buffer before the child is gone, so nothing + // faults even though the child never reads it. The flag has to track + // the actual write, not the mere fact that the child ignored stdin. + const { command, args } = node(REJECTS_INPUT); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(64 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.inputTruncated).toBe(false); + }); + + it("delivers the whole payload to a child that reads it", async () => { + const { command, args } = node(COUNTS_INPUT); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(1024 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(String(1024 * KIB)); + expect(result.inputTruncated).toBe(false); + }); +}); + +describe("isBrokenPipe", () => { + it("matches the codes a vanished reader produces", () => { + for (const code of [ + "EPIPE", + "ECONNRESET", + "EOF", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_WRITE_AFTER_END", + ]) { + expect(isBrokenPipe(Object.assign(new Error("x"), { code }))).toBe(true); + } + }); + + it("does not swallow errors that mean something else", () => { + // These have to keep travelling as errors — absorbing everything on + // the stream would turn a real local fault into a silent success. + expect(isBrokenPipe(Object.assign(new Error("x"), { code: "EACCES" }))).toBe( + false, + ); + expect(isBrokenPipe(new Error("no code at all"))).toBe(false); + expect(isBrokenPipe(null)).toBe(false); + }); +}); diff --git a/src/sandbox/command-runner.ts b/src/sandbox/command-runner.ts index ba4b5896..58842f73 100644 --- a/src/sandbox/command-runner.ts +++ b/src/sandbox/command-runner.ts @@ -2,6 +2,25 @@ import { spawn } from "node:child_process"; const IS_WINDOWS = process.platform === "win32"; +/** + * Stdin errors that all mean the same thing: the far end of the pipe is + * gone because the child exited (or was killed) before it drained its + * input. Expected whenever a command rejects the request without reading + * it, so they are absorbed rather than raised — see the handler below. + */ +const BROKEN_PIPE_CODES = new Set([ + "EPIPE", + "ECONNRESET", + "EOF", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_WRITE_AFTER_END", +]); + +export function isBrokenPipe(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | null)?.code; + return typeof code === "string" && BROKEN_PIPE_CODES.has(code); +} + export interface CommandOptions { cwd: string; timeoutMs?: number; @@ -22,6 +41,14 @@ export interface CommandResult { durationMs: number; timedOut: boolean; truncated: boolean; + /** + * The child stopped reading before `input` was fully written, so it + * answered a prompt we only partially delivered. Only reachable with + * payloads past the pipe buffer (~64 KiB); a non-zero `exitCode` + * usually says why, but a CLI that exits 0 regardless would otherwise + * look like a clean run over a truncated prompt. + */ + inputTruncated: boolean; } /** @@ -57,6 +84,7 @@ export async function runCommand( let stdoutBytes = 0; let stderrBytes = 0; let truncated = false; + let inputTruncated = false; let timedOut = false; let settled = false; @@ -147,9 +175,32 @@ export async function runCommand( durationMs: Date.now() - started, timedOut, truncated, + inputTruncated, }); }); + // A child that rejects the request — signed out, unknown model, + // rate-limited — exits without draining stdin, so an `input` larger + // than the pipe buffer (~64 KiB) cannot flush and raises EPIPE. + // Node treats an `error` on a stream with no listener as fatal and + // `installGlobalErrorHandlers` preserves that, which would tear the + // whole runtime down instead of reporting the child's own failure. + // Absorb the broken pipe — `close` still carries the exit code and + // stderr that explain it, and `inputTruncated` keeps a run that + // exits 0 over a half-delivered prompt from passing for a good one. + // Any other stdin error is a genuine local failure and rejects. + child.stdin.on("error", (err: NodeJS.ErrnoException) => { + if (isBrokenPipe(err)) { + inputTruncated = true; + return; + } + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + reject(err); + }); + if (options.input) { child.stdin.write(options.input); } diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 76b11be1..ebc7e040 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -1,4 +1,4 @@ -export { runCommand } from "./command-runner.js"; +export { isBrokenPipe, runCommand } from "./command-runner.js"; export type { CommandOptions, CommandResult } from "./command-runner.js"; export { buildSubshellInvocation, diff --git a/src/session/conversation-pairs.test.ts b/src/session/conversation-pairs.test.ts new file mode 100644 index 00000000..2a4afbc5 --- /dev/null +++ b/src/session/conversation-pairs.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; + +import { + assistantReplyTurn, + assistantToolCallTurn, + macroTurnBoundaries, + packConversation, + toolResultTurn, + userTurn, + type ConversationTurn, +} from "./conversation-turn.js"; + +const BASE = Date.parse("2026-08-27T10:00:00Z"); +let clock = 0; +const at = (): number => BASE + (clock += 1000); + +/** One complete task: ask, one tool round-trip, answer. */ +function task(label: string): ConversationTurn[] { + return [ + userTurn(`ask ${label}`, at()), + assistantToolCallTurn({ tool: "fs.read", args: { path: `/${label}` }, at: at() }), + toolResultTurn({ tool: "fs.read", status: "ok", summary: `read ${label}`, at: at() }), + assistantReplyTurn(`answer ${label}`, at()), + ]; +} + +/** A task the operator cut short: no reply row is ever written. */ +function abandonedTask(label: string): ConversationTurn[] { + return [ + userTurn(`ask ${label}`, at()), + assistantToolCallTurn({ tool: "fs.read", args: { path: `/${label}` }, at: at() }), + toolResultTurn({ tool: "fs.read", status: "ok", summary: `read ${label}`, at: at() }), + ]; +} + +/** Generous enough that only the pairs cap can bite. */ +const NO_TOKEN_PRESSURE = 1_000_000; + +describe("counting macro-turns", () => { + it("opens a pair at each task", () => { + const turns = [...task("a"), ...task("b"), ...task("c")]; + expect(macroTurnBoundaries(turns)).toEqual([0, 4, 8]); + }); + + it("does not open a pair for a steering message", () => { + // Typing while the agent runs appends another `user` row inside the + // same task. Keying on `user` alone would read that as a new task + // and the operator's count would climb without them asking anything. + const turns: ConversationTurn[] = [ + userTurn("ask a", at()), + assistantToolCallTurn({ tool: "fs.read", args: {}, at: at() }), + userTurn("actually, also check b", at()), + toolResultTurn({ tool: "fs.read", status: "ok", summary: "ok", at: at() }), + assistantReplyTurn("answer a", at()), + ...task("c"), + ]; + expect(macroTurnBoundaries(turns)).toEqual([0, 5]); + }); + + it("trusts recorded boundaries over the shape of the transcript", () => { + // A cancelled or `finish`-ended task writes no reply, so derivation + // fuses it into whatever came next. The session records the real + // boundary at the moment the task ends. + const turns = [...abandonedTask("a"), ...task("b")]; + expect(macroTurnBoundaries(turns), "derived misses it").toEqual([0]); + expect(macroTurnBoundaries(turns, [3])).toEqual([0, 3]); + }); + + it("ignores recorded boundaries that no longer address a turn", () => { + const turns = task("a"); + expect(macroTurnBoundaries(turns, [0, 4, 99, -2])).toEqual([0]); + }); +}); + +describe("packing by pairs", () => { + it("keeps exactly the last N tasks", () => { + const turns = [...task("a"), ...task("b"), ...task("c"), ...task("d")]; + const out = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 2 }); + expect(out.visiblePairs).toBe(2); + expect(out.droppedPairs).toBe(2); + expect(out.visibleTurns).toHaveLength(8); + expect(out.visibleTurns[0]).toEqual(turns[8]); + }); + + it("cuts on a task boundary, never mid-task", () => { + // A cut landing after the last reply would make every surviving row + // render as "fresh", which uncaps `os.http.request` bodies and + // silently inflates the section. + const turns = [...task("a"), ...task("b"), ...task("c")]; + const out = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 1 }); + expect(out.visibleTurns[0]?.kind).toBe("user"); + expect(macroTurnBoundaries(turns)).toContain( + turns.length - out.visibleTurns.length, + ); + }); + + it("holds history down even when it would have fitted on tokens", () => { + // The knob exists to bound history on purpose, not only to rescue a + // prompt that overflowed. + const turns = [...task("a"), ...task("b"), ...task("c")]; + const uncapped = packConversation(turns, NO_TOKEN_PRESSURE); + expect(uncapped.droppedCount).toBe(0); + const capped = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 1 }); + expect(capped.droppedCount).toBeGreaterThan(0); + }); + + it("leaves everything alone when there are fewer tasks than the cap", () => { + const turns = [...task("a"), ...task("b")]; + const out = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 10 }); + expect(out.droppedCount).toBe(0); + expect(out.visiblePairs).toBe(2); + expect(out.visibleTurns).toEqual(turns); + }); + + it("names the tasks it dropped, not just the rows", () => { + const turns = [...task("a"), ...task("b"), ...task("c")]; + const out = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 1 }); + expect(out.droppedSummary).toContain("from 2 earlier tasks"); + }); + + it("says task, singular, when it dropped one", () => { + const turns = [...task("a"), ...task("b")]; + const out = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 1 }); + expect(out.droppedSummary).toContain("from 1 earlier task "); + }); +}); + +describe("tokens remain the ceiling", () => { + it("trims completed tasks under token pressure even with pairs to spare", () => { + // A pair has no bounded size — one task can run `maxSteps` tool + // calls — so no pairs value keeps a prompt inside the window on its + // own. With the pairs cap slack, tokens must still cut. + const turns: ConversationTurn[] = []; + for (let i = 0; i < 12; i += 1) { + turns.push( + userTurn(`ask ${i}`, at()), + toolResultTurn({ + tool: "fs.read", + status: "ok", + summary: "x".repeat(400), + at: at(), + }), + assistantReplyTurn(`answer ${i}`, at()), + ); + } + const out = packConversation(turns, 400, { maxPairs: 100 }); + expect(out.droppedCount).toBeGreaterThan(0); + expect(out.visibleTurns.length).toBeLessThan(turns.length); + }); + + it("cannot trim the task still in flight, by design", () => { + // Documenting a real limit rather than asserting it away. The last + // `user` turn is pinned unconditionally, so a single runaway task + // survives whatever either limit says — the pin exists so the model + // never loses the request it is answering, and the cost is that one + // task with a huge tool result can still overflow the window. + const turns: ConversationTurn[] = [userTurn("ask big", at())]; + for (let i = 0; i < 40; i += 1) { + turns.push( + assistantToolCallTurn({ tool: "fs.read", args: { path: `/f${i}` }, at: at() }), + toolResultTurn({ + tool: "fs.read", + status: "ok", + summary: "x".repeat(400), + at: at(), + }), + ); + } + const out = packConversation(turns, 400, { maxPairs: 1 }); + expect(out.droppedCount).toBe(0); + }); + + it("takes whichever limit cuts more", () => { + const turns = [...task("a"), ...task("b"), ...task("c"), ...task("d")]; + // Pairs allows three tasks; tokens allow far less. The tighter wins. + const out = packConversation(turns, 60, { maxPairs: 3 }); + expect(out.visiblePairs).toBeLessThanOrEqual(3); + expect(out.droppedCount).toBeGreaterThan(turns.length - 12); + }); +}); + +describe("the pins survive a pairs cut", () => { + it("always keeps the newest user turn", () => { + const turns = [...task("a"), ...task("b"), userTurn("ask now", at())]; + const out = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 1 }); + expect(out.visibleTurns.at(-1)).toEqual(turns.at(-1)); + }); + + it("keeps the opening instruction of the task in flight", () => { + // A drained steer must not become the only surviving instruction. + const turns: ConversationTurn[] = [ + ...task("a"), + userTurn("ask b", at()), + assistantToolCallTurn({ tool: "fs.read", args: {}, at: at() }), + userTurn("no, do it this way", at()), + ]; + const out = packConversation(turns, NO_TOKEN_PRESSURE, { maxPairs: 1 }); + expect(out.visibleTurns[0]).toEqual(turns[4]); + }); +}); diff --git a/src/session/conversation-turn.test.ts b/src/session/conversation-turn.test.ts index 3591ff96..7097cda7 100644 --- a/src/session/conversation-turn.test.ts +++ b/src/session/conversation-turn.test.ts @@ -287,6 +287,9 @@ describe("conversation-turn helpers", () => { visibleTurns: [], droppedSummary: null, droppedCount: 0, + visiblePairs: 0, + droppedPairs: 0, + boundBy: null, }); }); @@ -330,7 +333,7 @@ describe("conversation-turn helpers", () => { expect(out.droppedCount).toBeLessThan(turns.length); expect(out.visibleTurns.at(-1)).toEqual(turns.at(-1)); expect(out.droppedSummary).toMatch( - /^summary: \d+ older turns dropped \(\d+ user, \d+ tool calls, \d+ replies; first at \S+, last at \S+\)$/, + /^summary: \d+ older turns dropped(?: from \d+ earlier tasks?)? \(\d+ user, \d+ tool calls, \d+ replies; first at \S+, last at \S+\)$/, ); }); @@ -374,3 +377,74 @@ describe("conversation-turn helpers", () => { }); }); }); + +// Regression: issue #121 — `packConversation` re-rendered and re-tokenised +// every historical turn on every agent step, so a long turn burned O(N^2) +// transient strings (~10MB across 25 steps). Costs are now memoised per +// turn object; these tests pin the behaviour that memoisation must not change. +describe("packConversation memoisation (issue #121)", () => { + function longTurns(steps: number): ConversationTurn[] { + const turns: ConversationTurn[] = [userTurn("go")]; + for (let i = 0; i < steps; i += 1) { + turns.push( + assistantToolCallTurn({ tool: "os.fs.read", args: { path: `/x/${i}` } }), + ); + turns.push( + toolResultTurn({ + tool: "os.fs.read", + status: "ok", + summary: `body-${i} ${"s".repeat(2_000)}`, + }), + ); + } + return turns; + } + + it("returns identical results on repeated calls over the same turns", () => { + const turns = longTurns(20); + const first = packConversation(turns, 4_000); + const second = packConversation(turns, 4_000); + expect(second.droppedCount).toBe(first.droppedCount); + expect(second.droppedSummary).toBe(first.droppedSummary); + expect(second.visibleTurns).toEqual(first.visibleTurns); + }); + + it("keeps the fresh/aged distinction — a turn cached as fresh is not reused when aged", () => { + // `os.http.request` renders uncapped while fresh and capped once aged, + // so the same turn object has two different costs. Caching must key on + // the flag, not collapse the two. + const body = "h".repeat(5_000); + const httpResult = toolResultTurn({ + tool: "os.http.request", + status: "ok", + summary: body, + }); + const freshTurns: ConversationTurn[] = [userTurn("go"), httpResult]; + const agedTurns: ConversationTurn[] = [ + userTurn("go"), + httpResult, + assistantReplyTurn("done"), + userTurn("again"), + ]; + // Pack fresh first so the fresh cost is the one cached first. + packConversation(freshTurns, 100_000); + const aged = packConversation(agedTurns, 100_000); + const agedRender = renderTurnForPrompt(httpResult, { inCurrentMacroTurn: false }); + expect(agedRender.length).toBeLessThan(body.length); + expect(aged.visibleTurns).toHaveLength(4); + }); + + it("still drops under budget pressure and pins the last user turn", () => { + // Two macro-turns: the first is droppable, the second is pinned. A + // single-macro-turn fixture would pin everything and drop nothing. + const turns: ConversationTurn[] = [ + ...longTurns(30), + assistantReplyTurn("first answer"), + ...longTurns(5), + ]; + const out = packConversation(turns, 2_000); + expect(out.droppedCount).toBeGreaterThan(0); + expect(out.droppedSummary).toMatch(/^summary: \d+ older turns dropped/); + expect(out.visibleTurns.length).toBeGreaterThan(0); + }); +}); diff --git a/src/session/conversation-turn.ts b/src/session/conversation-turn.ts index d64399f4..b3c6054e 100644 --- a/src/session/conversation-turn.ts +++ b/src/session/conversation-turn.ts @@ -221,6 +221,120 @@ export interface PackedConversation { visibleTurns: ConversationTurn[]; droppedSummary: string | null; droppedCount: number; + /** Macro-turns with at least one row in the visible tail. */ + visiblePairs: number; + /** Macro-turns dropped whole. */ + droppedPairs: number; + /** + * Which limit actually made the cut, so the readout can name it + * instead of inferring it from numbers that look alike. + */ + boundBy: "pairs" | "tokens" | null; +} + +export interface PackConversationOptions { + /** + * Keep at most this many macro-turns. An *additional* constraint, never + * a replacement for `maxTokens`: a pair has no bounded size — one task + * can run `agent.maxSteps` tool calls, and a fresh `os.http.request` + * body renders uncapped — so N pairs can exceed any window. Whichever + * limit cuts more wins. + */ + maxPairs?: number; + /** + * Boundaries recorded by the session (`SessionState.macroTurnStarts`). + * Preferred over deriving them, because a task ended with `finish` or + * cancelled writes no `assistant_reply` and a derived scan would fuse + * it into the next task. + */ + macroTurnStarts?: readonly number[]; +} + +/** + * Start index of every macro-turn, always beginning with `0`. + * + * Prefers the session's recorded boundaries. Falling back to derivation, + * a macro-turn opens at a `user` row whose predecessor is an + * `assistant_reply` — *not* at every `user` row, because steering + * appends extra user rows inside a single task and each one would + * otherwise read as a task of its own. + */ +export function macroTurnBoundaries( + turns: readonly ConversationTurn[], + recorded?: readonly number[], +): number[] { + if (turns.length === 0) return []; + if (recorded && recorded.length > 0) { + const seen = new Set([0]); + for (const index of recorded) { + if (Number.isInteger(index) && index > 0 && index < turns.length) { + seen.add(index); + } + } + return [...seen].sort((a, b) => a - b); + } + const derived = [0]; + for (let i = 1; i < turns.length; i += 1) { + if ( + turns[i]?.kind === "user" && + turns[i - 1]?.kind === "assistant_reply" + ) { + derived.push(i); + } + } + return derived; +} + +/** + * Token cost of each macro-turn, oldest first. + * + * For the readout, not the packer: it lets the UI answer "what would N + * tasks cost?" with a prefix sum, so moving the pairs dial redraws the + * gauge immediately instead of one prompt build later. Costs come from + * the same memoised estimator the packer uses, with the same + * `inCurrentMacroTurn` freshness flag, so the projection and the real + * thing agree. + */ +export function pairTokenCosts( + turns: readonly ConversationTurn[], + recorded?: readonly number[], +): number[] { + const boundaries = macroTurnBoundaries(turns, recorded); + if (boundaries.length === 0) return []; + const currentStart = findCurrentMacroTurnStart(turns); + const costs: number[] = []; + for (let k = 0; k < boundaries.length; k += 1) { + const from = boundaries[k] ?? 0; + const to = boundaries[k + 1] ?? turns.length; + let sum = 0; + for (let i = from; i < to; i += 1) { + const turn = turns[i]; + if (turn) sum += tokenCostForTurn(turn, i >= currentStart); + } + costs.push(sum); + } + return costs; +} + +/** First index to keep so that at most `maxPairs` macro-turns survive. */ +function startIndexForPairs(boundaries: number[], maxPairs: number): number { + if (boundaries.length === 0 || maxPairs <= 0) return 0; + if (boundaries.length <= maxPairs) return 0; + return boundaries[boundaries.length - maxPairs] ?? 0; +} + +/** How many whole macro-turns fall entirely before `startIndex`. */ +function countDroppedPairs( + boundaries: number[], + startIndex: number, + turnCount: number, +): number { + let dropped = 0; + for (let k = 0; k < boundaries.length; k += 1) { + const end = boundaries[k + 1] ?? turnCount; + if (end <= startIndex) dropped += 1; + } + return dropped; } /** @@ -240,63 +354,149 @@ const SUMMARY_TOKEN_RESERVE = 40; export function packConversation( turns: readonly ConversationTurn[], maxTokens: number, + options: PackConversationOptions = {}, ): PackedConversation { if (turns.length === 0) { - return { visibleTurns: [], droppedSummary: null, droppedCount: 0 }; + return { + visibleTurns: [], + droppedSummary: null, + droppedCount: 0, + visiblePairs: 0, + droppedPairs: 0, + boundBy: null, + }; } + const boundaries = macroTurnBoundaries(turns, options.macroTurnStarts); if (maxTokens <= 0) { return { visibleTurns: [], droppedSummary: renderDroppedSummary(turns), droppedCount: turns.length, + visiblePairs: 0, + droppedPairs: boundaries.length, + boundBy: "tokens", }; } + // The pairs cut, computed before anything else so it applies even when + // the transcript would have fitted on tokens alone — the whole point of + // the knob is to hold history down on purpose, not only under pressure. + const pairsStart = + options.maxPairs === undefined + ? 0 + : startIndexForPairs(boundaries, options.maxPairs); + // Estimate sizes with the same `inCurrentMacroTurn` flag the renderer // will apply downstream — otherwise tools that bypass the cap when // fresh (e.g. `os.http.request`) get under-estimated and the packed // section overshoots `maxTokens`. const currentStart = findCurrentMacroTurnStart(turns); - const rendered = turns.map((turn, i) => - renderTurnForPrompt(turn, { inCurrentMacroTurn: i >= currentStart }), + const tokenCosts = turns.map((turn, i) => + tokenCostForTurn(turn, i >= currentStart), ); - const tokenCosts = rendered.map((line) => estimateTokens(line) + 1); const total = tokenCosts.reduce((a, b) => a + b, 0); - if (total <= maxTokens) { - return { visibleTurns: [...turns], droppedSummary: null, droppedCount: 0 }; - } - // Truncation is inevitable — reserve tokens for the summary line so the - // final prompt section still fits within `maxTokens`. - const budget = Math.max(1, maxTokens - SUMMARY_TOKEN_RESERVE); - let acc = 0; - let startIndex = turns.length; - for (let i = turns.length - 1; i >= 0; i -= 1) { - const cost = tokenCosts[i] ?? 0; - if (acc + cost > budget) break; - acc += cost; - startIndex = i; + let startIndex: number; + let tokenStart = 0; + if (total <= maxTokens) { + startIndex = pairsStart; + } else { + // Truncation is inevitable — reserve tokens for the summary line so + // the final prompt section still fits within `maxTokens`. + const budget = Math.max(1, maxTokens - SUMMARY_TOKEN_RESERVE); + let acc = 0; + startIndex = turns.length; + for (let i = turns.length - 1; i >= 0; i -= 1) { + const cost = tokenCosts[i] ?? 0; + if (acc + cost > budget) break; + acc += cost; + startIndex = i; + } + tokenStart = startIndex; + // `max`, never `min`: the two limits are not alternatives. Tokens are + // the ceiling the window imposes and pairs is the operator's own, + // tighter preference, so the later cut wins. + startIndex = Math.max(startIndex, pairsStart); } const lastUserIndex = findLastUserIndex(turns); if (lastUserIndex !== -1 && lastUserIndex < startIndex) { startIndex = lastUserIndex; } + // A drained steer becomes the LAST user turn, which would otherwise + // carry the only pin — under token pressure the macro-turn's founding + // instruction would compress into the dropped-summary line while the + // correction stayed, and the model would continue from the correction + // alone. Pin the current macro-turn's opening user turn as well. + if (currentStart < startIndex && turns[currentStart]?.kind === "user") { + startIndex = currentStart; + } const droppedSlice = turns.slice(0, startIndex); const visibleTurns = turns.slice(startIndex); + const droppedPairs = countDroppedPairs(boundaries, startIndex, turns.length); + const visiblePairs = Math.max(0, boundaries.length - droppedPairs); if (droppedSlice.length === 0) { - return { visibleTurns, droppedSummary: null, droppedCount: 0 }; + return { + visibleTurns, + droppedSummary: null, + droppedCount: 0, + visiblePairs, + droppedPairs, + boundBy: null, + }; } return { visibleTurns, - droppedSummary: renderDroppedSummary(droppedSlice), + droppedSummary: renderDroppedSummary(droppedSlice, droppedPairs), droppedCount: droppedSlice.length, + visiblePairs, + droppedPairs, + // Ties go to pairs: when both limits land on the same row it is the + // operator's own preference that explains the cut, and naming the + // window instead would send them to a setting that changes nothing. + boundBy: pairsStart >= tokenStart ? "pairs" : "tokens", }; } +/** + * Memoised token cost of a single rendered turn. + * + * `packConversation` runs once per agent step and previously re-rendered + * (and re-`JSON.stringify`-ed) every historical turn on each call, only to + * throw the strings away after summing their token cost — O(N) work per + * step, so O(N^2) transient allocation across a long turn. Issue #121 + * reported ~10MB of churn for a 25-step turn. + * + * Turns are immutable once appended, so the cost is keyed on the turn + * object itself. `inCurrentMacroTurn` changes what the renderer emits for + * fresh-bypass tools (`os.http.request`, fresh `gog` shell), so it is part + * of the key rather than folded away. The `WeakMap` lets dropped turns be + * collected with the sessions that own them. + */ +const TURN_TOKEN_COST_CACHE = new WeakMap< + object, + { fresh?: number; aged?: number } +>(); + +function tokenCostForTurn( + turn: ConversationTurn, + inCurrentMacroTurn: boolean, +): number { + const key = turn as unknown as object; + const slot = TURN_TOKEN_COST_CACHE.get(key); + const cached = inCurrentMacroTurn ? slot?.fresh : slot?.aged; + if (cached !== undefined) return cached; + const cost = estimateTokens(renderTurnForPrompt(turn, { inCurrentMacroTurn })) + 1; + const nextSlot = slot ?? {}; + if (inCurrentMacroTurn) nextSlot.fresh = cost; + else nextSlot.aged = cost; + TURN_TOKEN_COST_CACHE.set(key, nextSlot); + return cost; +} + /** * Legacy thin wrapper kept so existing callers/tests that only care about * the trimmed tail still work. New code should prefer `packConversation` @@ -313,7 +513,17 @@ export function trimTurnsToTokens( }; } -function renderDroppedSummary(turns: readonly ConversationTurn[]): string { +/** + * The one line the model gets in place of everything that was dropped. + * + * Names the number of whole tasks lost as well as the rows, because the + * operator caps history in tasks now: "18 rows" says nothing about how + * far back the agent can still see, "4 earlier tasks" says exactly that. + */ +function renderDroppedSummary( + turns: readonly ConversationTurn[], + droppedPairs = 0, +): string { let user = 0; let toolCalls = 0; let replies = 0; @@ -326,7 +536,11 @@ function renderDroppedSummary(turns: readonly ConversationTurn[]): string { const last = turns[turns.length - 1]?.at ?? first; const firstIso = new Date(first).toISOString(); const lastIso = new Date(last).toISOString(); - return `summary: ${turns.length} older turns dropped (${user} user, ${toolCalls} tool calls, ${replies} replies; first at ${firstIso}, last at ${lastIso})`; + const tasks = + droppedPairs > 0 + ? ` from ${droppedPairs} earlier task${droppedPairs === 1 ? "" : "s"}` + : ""; + return `summary: ${turns.length} older turns dropped${tasks} (${user} user, ${toolCalls} tool calls, ${replies} replies; first at ${firstIso}, last at ${lastIso})`; } function findLastUserIndex(turns: readonly ConversationTurn[]): number { diff --git a/src/session/session-exit-status.test.ts b/src/session/session-exit-status.test.ts new file mode 100644 index 00000000..10a73f5e --- /dev/null +++ b/src/session/session-exit-status.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + isFailedSessionStatus, + type SessionStatus, +} from "./session-state.js"; + +describe("isFailedSessionStatus", () => { + it("treats failed and stalled as non-zero exits", () => { + expect(isFailedSessionStatus("failed")).toBe(true); + expect(isFailedSessionStatus("stalled")).toBe(true); + }); + + it("leaves every other status a success", () => { + const rest: SessionStatus[] = [ + "pending", + "running", + "awaiting_approval", + "awaiting_llm", + "completed", + "cancelled", + ]; + for (const status of rest) { + expect(isFailedSessionStatus(status)).toBe(false); + } + }); + + it("covers every member of SessionStatus", () => { + // Guards the list above: a new status added to the union without a + // decision here would otherwise silently default to "success". + const all: Record = { + pending: false, + running: false, + awaiting_approval: false, + awaiting_llm: false, + completed: false, + failed: true, + cancelled: false, + stalled: true, + }; + for (const [status, expected] of Object.entries(all)) { + expect(isFailedSessionStatus(status as SessionStatus)).toBe(expected); + } + }); +}); diff --git a/src/session/session-state.ts b/src/session/session-state.ts index 29502ca2..83fb35c4 100644 --- a/src/session/session-state.ts +++ b/src/session/session-state.ts @@ -25,6 +25,20 @@ export type SessionStatus = */ | "stalled"; +/** + * Whether a session in this state should make the process exit non-zero. + * Both entry points that own an exit code — `atomic-agent run` and the + * TUI's chat orchestrator — read it from here so they cannot drift: they + * previously each carried their own copy of the rule, and only one was + * updated when `stalled` stopped counting as success. + * + * `cancelled` stays truthful to its own contract (the operator asked to + * stop) and is deliberately not a failure. + */ +export function isFailedSessionStatus(status: SessionStatus): boolean { + return status === "failed" || status === "stalled"; +} + export interface KnownFact { text: string; source?: string; @@ -91,6 +105,20 @@ export interface SessionState { stepCount: number; /** Number of completed macro-turns (user → 0..N tools → reply). */ turnCount: number; + /** + * Index into {@link turns} where each macro-turn after the first + * opens. Recorded rather than re-derived, because the shape of the + * transcript cannot always tell you: a task ended with `finish`, or + * cancelled, writes no `assistant_reply`, so a scan looking for reply + * rows would silently fuse it into the task that follows and count one + * pair where the operator sent two. + * + * Written by {@link incrementTurnCount}, which the loop already calls + * at every termination — reply, `finish`, `max_steps`, cancel and + * failure alike. Absent on sessions written before this field existed; + * readers fall back to deriving what they can. + */ + macroTurnStarts?: number[]; /** Full conversation transcript in chronological order. */ turns: ConversationTurn[]; createdAt: number; @@ -173,6 +201,7 @@ export function createEmptySessionState(params: { worldSnapshot: null, stepCount: 0, turnCount: 0, + macroTurnStarts: [], turns: [], createdAt: now, updatedAt: now, @@ -262,8 +291,48 @@ export function recordTurn( }; } +/** + * Close the current macro-turn: bump the counter and remember where the + * next one opens. + * + * The loop already funnels every termination through here — the reply + * path, `finish`, `max_steps`, cancellation and failure — which is + * exactly the set of moments a pair ends, so the boundary is recorded + * without the loop needing to know it is happening. + */ export function incrementTurnCount(state: SessionState): SessionState { - return { ...state, turnCount: state.turnCount + 1, updatedAt: Date.now() }; + return { + ...state, + turnCount: state.turnCount + 1, + macroTurnStarts: appendMacroTurnStart( + state.macroTurnStarts, + state.turns.length, + ), + updatedAt: Date.now(), + }; +} + +/** + * Boundaries a pairs-capped prompt can only ever need the tail of, so the + * list is bounded. 200 is well past `agent.conversationMaxPairs`'s + * ceiling of 100 and keeps a session that runs for days from carrying an + * ever-growing array of integers it will never read. + */ +const MACRO_TURN_START_CAP = 200; + +function appendMacroTurnStart( + starts: number[] | undefined, + index: number, +): number[] { + const prev = starts ?? []; + // A termination that recorded no turns (an empty steer, a cancel + // before the first step) would otherwise push the same index twice and + // read as a pair with nothing in it. + if (prev[prev.length - 1] === index) return prev; + const next = [...prev, index]; + return next.length > MACRO_TURN_START_CAP + ? next.slice(next.length - MACRO_TURN_START_CAP) + : next; } /** diff --git a/src/sidecar/index.ts b/src/sidecar/index.ts index ef08bfa8..a1ab0e96 100644 --- a/src/sidecar/index.ts +++ b/src/sidecar/index.ts @@ -20,6 +20,7 @@ export type { StartSessionPayload, RunStepPayload, SendMessagePayload, + SteerMessagePayload, CancelPayload, ApprovalResponsePayload, GetSessionPayload, diff --git a/src/sidecar/main.ts b/src/sidecar/main.ts index ca529e63..30d54dda 100644 --- a/src/sidecar/main.ts +++ b/src/sidecar/main.ts @@ -13,6 +13,7 @@ import type { CancelPayload, GetSessionPayload, SendMessagePayload, + SteerMessagePayload, SkillInstallPayload, SkillUninstallPayload, StartSessionPayload, @@ -144,6 +145,13 @@ export async function bootstrapSidecar(): Promise<{ text: event.text, }); break; + case "steer_applied": + protocol.emitEvent("steer_applied", { + sessionId, + text: event.text, + stepIndex: event.stepIndex, + }); + break; case "turn_started": protocol.emitEvent("turn_started", { sessionId, @@ -240,7 +248,9 @@ export async function bootstrapSidecar(): Promise<{ await disposeActive(); const workingDir = resolve(request.payload.workingDir); const runtime = await buildRuntime(workingDir); - const health = await checkLlamaServer(); + // Status probe for the desktop shell — one attempt; the retry + // ladder only delayed the `llm_unavailable` event by 15.5 s. + const health = await checkLlamaServer({ retries: 0 }); if (!health.reachable) { const hint = config.localModels.mode === "managed" @@ -308,6 +318,12 @@ export async function bootstrapSidecar(): Promise<{ }, }); active = { ...active, session: result.session }; + // A steer that arrived too late to be drained must not vanish. The + // sidecar has no queue of its own, so surface it to the host, which + // can decide to re-send it as a normal message. + for (const text of result.undelivered ?? []) { + protocol.emitEvent("steer_undelivered", { sessionId, text }); + } return { reason: result.reason, turnCount: result.session.turnCount, @@ -330,6 +346,20 @@ export async function bootstrapSidecar(): Promise<{ }, ); + router.register( + "steer_message", + (request) => { + const { sessionId, text } = request.payload; + if (!active || active.session.id !== sessionId) return { steered: false }; + // Deliberately NOT routed through `turnController.enqueue`: the + // point of steering is to reach the turn that already holds the + // session lock, and enqueueing would put it behind that turn. + // `runtime.steer` returns false when nothing is running, which is + // the host's cue to call `send_message` instead. + return { steered: active.runtime.steer(sessionId, text) }; + }, + ); + router.register( "cancel", (request) => { diff --git a/src/sidecar/sidecar-events.ts b/src/sidecar/sidecar-events.ts index ad9e4f8f..38f7b1c5 100644 --- a/src/sidecar/sidecar-events.ts +++ b/src/sidecar/sidecar-events.ts @@ -10,6 +10,7 @@ export type HostRequestType = | "start_session" | "run_step" | "send_message" + | "steer_message" | "cancel" | "approval_response" | "get_session" @@ -26,6 +27,8 @@ export type SidecarEventType = | "tool_call_started" | "tool_call_result" | "user_message" + | "steer_applied" + | "steer_undelivered" | "assistant_reply" | "assistant_delta" | "reasoning_delta" @@ -91,6 +94,18 @@ export interface SendMessagePayload { maxSteps?: number; } +/** + * Fold a message into the turn already running on `sessionId`. Unlike + * {@link SendMessagePayload} this never starts a turn and never queues + * behind one — see §"Mid-turn steering" in AGENTS.md. The response's + * `steered: false` means the session was idle (or the inbox was full) + * and the host should fall back to `send_message`. + */ +export interface SteerMessagePayload { + sessionId: string; + text: string; +} + export interface CancelPayload { sessionId: string; } @@ -176,6 +191,29 @@ export interface UserMessagePayload { text: string; } +/** + * A mid-turn message reached the model at `stepIndex`. Distinct from + * `user_message`, which marks the message that opened the turn — hosts + * render this one inline inside the running turn. + */ +export interface SteerAppliedPayload { + sessionId: string; + text: string; + stepIndex: number; +} + +/** + * A steer was accepted but the turn ended before the loop could drain + * it (it landed during the final inference, or the turn was cancelled). + * The host owns it now — re-send it as a `send_message` if it still + * makes sense. Emitted rather than silently dropped so "the message you + * sent always goes somewhere" holds on this surface too. + */ +export interface SteerUndeliveredPayload { + sessionId: string; + text: string; +} + export interface AssistantReplyPayload { sessionId: string; text: string; diff --git a/src/sidecar/stdio-protocol.test.ts b/src/sidecar/stdio-protocol.test.ts index 4cb58d85..70658c65 100644 --- a/src/sidecar/stdio-protocol.test.ts +++ b/src/sidecar/stdio-protocol.test.ts @@ -195,3 +195,76 @@ describe("StdioProtocol", () => { expect(received).toHaveLength(1); }); }); + +describe("StdioProtocol output that died under us", () => { + /** A writable whose far end has gone: every write raises EPIPE. */ + function brokenOutput() { + const output = new PassThrough(); + output.setEncoding("utf8"); + const original = output.write.bind(output); + let broken = false; + return { + output, + break() { + broken = true; + output.emit( + "error", + Object.assign(new Error("write EPIPE"), { + code: "EPIPE", + syscall: "write", + }), + ); + }, + install() { + output.write = ((chunk: string) => { + if (broken) { + throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + } + return original(chunk); + }) as typeof output.write; + }, + }; + } + + it("does not rethrow when the host pipe errors — the peer left, we did not break", () => { + const input = new PassThrough(); + const broken = brokenOutput(); + const protocol = new StdioProtocol({ input, output: broken.output }); + expect(protocol.isOutputClosed()).toBe(false); + expect(() => broken.break()).not.toThrow(); + expect(protocol.isOutputClosed()).toBe(true); + }); + + it("silently stops emitting once the output is closed", () => { + const input = new PassThrough(); + const broken = brokenOutput(); + broken.install(); + const protocol = new StdioProtocol({ input, output: broken.output }); + broken.break(); + // The runtime keeps fanning events out at its own pace; none of them + // may resurrect the dead pipe or throw out of `emitEvent`. + expect(() => protocol.emitEvent("log", { message: "after" })).not.toThrow(); + expect(() => protocol.respond("c1", { ok: true })).not.toThrow(); + }); + + it("swallows a synchronous EPIPE from a destroyed stream", () => { + const input = new PassThrough(); + const output = new PassThrough(); + output.destroy(); + const protocol = new StdioProtocol({ input, output }); + expect(() => protocol.emitEvent("log", { message: "x" })).not.toThrow(); + }); + + it("leaves a non-broken-pipe stream error alone", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + new StdioProtocol({ input, output }); + const thrown = new Promise((resolve) => { + process.once("uncaughtException", resolve); + }); + output.emit("error", new Error("genuinely broken")); + await expect(thrown).resolves.toMatchObject({ + message: "genuinely broken", + }); + }); +}); diff --git a/src/sidecar/stdio-protocol.ts b/src/sidecar/stdio-protocol.ts index 6851019a..e4a105a9 100644 --- a/src/sidecar/stdio-protocol.ts +++ b/src/sidecar/stdio-protocol.ts @@ -8,6 +8,7 @@ import type { SidecarResponse, } from "./sidecar-events.js"; import { isHostRequest } from "./sidecar-events.js"; +import { isBrokenPipeError } from "../error-reporting/broken-pipe.js"; export interface StdioProtocolOptions { input: Readable; @@ -26,6 +27,7 @@ export class StdioProtocol { private buffer = ""; private requestHandler: RequestHandler | null = null; private closed = false; + private outputClosed = false; constructor(private readonly options: StdioProtocolOptions) { options.input.setEncoding("utf8"); @@ -34,6 +36,17 @@ export class StdioProtocol { this.flushTail(); this.closed = true; }); + // The host end of the pipe can vanish at any moment — the desktop + // app quits, the CLI that spawned us is killed. Node surfaces that + // as an ASYNCHRONOUS `error` on the stream, and a stream with no + // `error` listener throws it as an uncaught exception: a healthy + // agent dying with a raw EPIPE stack because its peer went away + // first. Listening (and latching `outputClosed`) turns that into + // "stop emitting", which is all a vanished host can mean. + options.output.on("error", (err) => this.onOutputError(err)); + options.output.on("close", () => { + this.outputClosed = true; + }); } onRequest(handler: RequestHandler): void { @@ -78,6 +91,15 @@ export class StdioProtocol { return this.closed; } + /** + * True once the host stopped reading — the pipe errored, was + * destroyed, or ended. Every subsequent `emitEvent` / `respond` is a + * silent no-op; there is nobody left to read the frame. + */ + isOutputClosed(): boolean { + return this.outputClosed; + } + private ingest(chunk: string): void { this.buffer += chunk; let newlineIndex = this.buffer.indexOf("\n"); @@ -114,9 +136,31 @@ export class StdioProtocol { } } + /** + * Latch the output as closed. Only a broken-pipe style failure is + * swallowed — anything else is a real stream bug and is re-thrown on + * the next tick so it is not lost, matching Node's default behaviour + * for an unhandled stream error. + */ + private onOutputError(err: unknown): void { + this.outputClosed = true; + if (isBrokenPipeError(err)) return; + queueMicrotask(() => { + throw err; + }); + } + private writeMessage(message: SidecarMessage): void { + if (this.outputClosed || this.options.output.destroyed) return; const line = `${JSON.stringify(message)}\n`; - this.options.output.write(line); + try { + this.options.output.write(line); + } catch (err) { + // A synchronous throw from `write` (already-destroyed stream) + // reaches us here rather than through the `error` event. + this.outputClosed = true; + if (!isBrokenPipeError(err)) throw err; + } } } diff --git a/src/sidecar/steer-message.test.ts b/src/sidecar/steer-message.test.ts new file mode 100644 index 00000000..caaf817f --- /dev/null +++ b/src/sidecar/steer-message.test.ts @@ -0,0 +1,157 @@ +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { CompletionResult } from "../llm/llama-server-client.js"; +import { resetConfigCache } from "../config/index.js"; +import { createAgentRuntime } from "../runtime/bootstrap.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { FakeBrowserBackend } from "../http/test-harness.js"; + +/** + * Mirrors the production `steer_message` handler in + * `src/sidecar/main.ts` — same convention as + * `send-message-concurrency.test.ts`, which mirrors `send_message` + * rather than driving the stdin protocol. + * + * The property under test is the one that makes steering different from + * every other host request: it must NOT go through + * `turnController.enqueue`. Enqueueing would park the message behind + * the very turn it is meant to redirect, which is the bug this whole + * feature exists to avoid. + */ +function makeSteerHandler(runtime: AgentRuntime, activeSessionId: string) { + return (sessionId: string, text: string): { steered: boolean } => { + if (activeSessionId !== sessionId) return { steered: false }; + return { steered: runtime.steer(sessionId, text) }; + }; +} + +describe("sidecar steer_message", () => { + let stateDir: string; + let workingDir: string; + let runtime: AgentRuntime; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-sidecar-steer-state-")); + workingDir = mkdtempSync(join(tmpdir(), "atomic-sidecar-steer-cwd-")); + mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true }); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars"); + resetConfigCache(); + }); + + afterEach(async () => { + if (runtime) await runtime.shutdown(); + rmSync(stateDir, { recursive: true, force: true }); + rmSync(workingDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + resetConfigCache(); + }); + + it("resolves immediately while a turn holds the session, and lands in that turn", async () => { + const enters: string[] = []; + let releaseFirst: (() => void) | null = null; + const llamaComplete = async (params: { + sessionId: string; + }): Promise => { + if (params.sessionId.startsWith("reflection:")) return reply("ignored"); + enters.push("user-turn"); + if (enters.length === 1) { + await new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return reply("done"); + }; + + runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: new FakeBrowserBackend(), + skipLlamaHealthCheck: true, + llamaComplete, + }, + }); + + const session = runtime.createSession({ metadata: { source: "steer-test" } }); + const steer = makeSteerHandler(runtime, session.id); + + const turn = runtime.runTurn(session, "start working", { + origin: "sidecar", + }); + + const deadline = Date.now() + 5_000; + while (enters.length < 1 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(enters).toEqual(["user-turn"]); + + // The turn is blocked inside its inference. A queued handler would + // hang here; steering must answer now. + expect(steer(session.id, "actually, do it differently")).toEqual({ + steered: true, + }); + expect(runtime.steeringInbox.peek(session.id)).toEqual([ + "actually, do it differently", + ]); + + releaseFirst?.(); + await turn; + }); + + it("refuses when the session is idle so the host falls back to send_message", async () => { + runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: new FakeBrowserBackend(), + skipLlamaHealthCheck: true, + llamaComplete: async () => reply("done"), + }, + }); + const session = runtime.createSession(); + const steer = makeSteerHandler(runtime, session.id); + expect(steer(session.id, "hello?")).toEqual({ steered: false }); + expect(runtime.steeringInbox.peek(session.id)).toEqual([]); + }); + + it("refuses for a session that is not the active one", async () => { + runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: new FakeBrowserBackend(), + skipLlamaHealthCheck: true, + llamaComplete: async () => reply("done"), + }, + }); + const active = runtime.createSession(); + const other = runtime.createSession(); + const steer = makeSteerHandler(runtime, active.id); + expect(steer(other.id, "wrong session")).toEqual({ steered: false }); + expect(runtime.steeringInbox.peek(other.id)).toEqual([]); + }); +}); + +function reply(text: string): CompletionResult { + return { + content: JSON.stringify({ tool: "reply", args: { text } }), + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 0, + predictedMs: 0, + promptTokens: 1, + predictedTokens: 1, + }, + cacheHitTokens: 0, + slotId: 0, + modelId: null, + }; +} diff --git a/src/tasks/task-runner.ts b/src/tasks/task-runner.ts index 56ed84f2..f7bcfb6e 100644 --- a/src/tasks/task-runner.ts +++ b/src/tasks/task-runner.ts @@ -335,6 +335,16 @@ export class TaskRunner { // surface as a `reason: "failed"` outcome instead — treat that // as the same retryable transport-class failure so the operator // sees a consistent retry curve. + // A steer accepted by this turn but never delivered must not vanish + // just because the turn belonged to the scheduler: there is no host + // to hand it to, so the structured log is the surface of record. + if (result.undelivered !== undefined && result.undelivered.length > 0) { + this.options.logger?.warn?.("steering messages stranded by a task turn", { + taskId: claimed.id, + count: result.undelivered.length, + preview: result.undelivered[0]?.slice(0, 120), + }); + } if (result.reason === "failed") { return this.handleFailure(claimed, "transport", new Error("loop reported failed")); } diff --git a/src/tools/coerce-tool-args.test.ts b/src/tools/coerce-tool-args.test.ts new file mode 100644 index 00000000..12a8d84f --- /dev/null +++ b/src/tools/coerce-tool-args.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from "vitest"; +import { ToolRegistry, type ToolContext, type ToolDefinition } from "./tool-registry.js"; +import { coerceToolArgs } from "./coerce-tool-args.js"; + +const ctx: ToolContext = { + workingDir: "/w", + sessionId: "s1", + stepIndex: 0, + signal: new AbortController().signal, +}; + +/** Records the args a tool actually received after registry coercion. */ +function spyTool(name: string): { definition: ToolDefinition; seen: () => Record } { + let received: Record = {}; + return { + seen: () => received, + definition: { + name, + description: name, + readonly: true, + run: async (args) => { + received = args; + return { status: "ok", summary: "", details: {} } as never; + }, + }, + }; +} + +async function invokeWith( + name: string, + args: Record, +): Promise> { + const registry = new ToolRegistry(); + const spy = spyTool(name); + registry.register(spy.definition); + await registry.invoke(name, args, ctx); + return spy.seen(); +} + +describe("coerceToolArgs — real failing calls from the campaign", () => { + it("unwraps a stringified string[] for vision.describe (26 occurrences)", async () => { + const seen = await invokeWith("vision.describe", { + prompt: "read the numbers", + paths: '["/var/crops/num_04.png", "/var/crops/num_05.png"]', + }); + expect(seen.paths).toEqual(["/var/crops/num_04.png", "/var/crops/num_05.png"]); + expect(seen.prompt).toBe("read the numbers"); + }); + + it("unwraps stringified numbers for os.fs.read_document (15 occurrences)", async () => { + const seen = await invokeWith("os.fs.read_document", { + path: "census2011final_en.pdf", + maxBytes: "200000", + pagesFrom: "4", + pagesTo: "12", + }); + expect(seen).toEqual({ + path: "census2011final_en.pdf", + maxBytes: 200000, + pagesFrom: 4, + pagesTo: 12, + }); + }); + + it("unwraps a stringified header object for os.http.request (3 occurrences)", async () => { + const seen = await invokeWith("os.http.request", { + url: "https://example.com", + headers: '{"User-Agent": "Mozilla/5.0 (Macintosh)"}', + }); + expect(seen.headers).toEqual({ "User-Agent": "Mozilla/5.0 (Macintosh)" }); + expect(seen.url).toBe("https://example.com"); + }); + + it("unwraps a stringified number for browser.scroll (1 occurrence)", async () => { + const seen = await invokeWith("browser.scroll", { direction: "down", amount: "3000" }); + expect(seen).toEqual({ direction: "down", amount: 3000 }); + }); +}); + +describe("coerceToolArgs — the union case (browser.scroll `amount`)", () => { + // `amount` is anyOf: ["page" | "half"] | number. The enum strings are + // legal values as written and must survive; a numeric string is not. + it("leaves the enum string \"page\" untouched", () => { + expect(coerceToolArgs("browser.scroll", { direction: "down", amount: "page" })).toEqual({ + direction: "down", + amount: "page", + }); + }); + + it("leaves the enum string \"half\" untouched", () => { + expect(coerceToolArgs("browser.scroll", { direction: "up", amount: "half" })).toEqual({ + direction: "up", + amount: "half", + }); + }); + + it("converts a numeric string on the same union field", () => { + expect(coerceToolArgs("browser.scroll", { direction: "down", amount: "3000" })).toEqual({ + direction: "down", + amount: 3000, + }); + }); + + it("passes an off-schema string through for the tool to reject", () => { + expect(coerceToolArgs("browser.scroll", { direction: "down", amount: "lots" })).toEqual({ + direction: "down", + amount: "lots", + }); + }); + + it("leaves a JSON-looking string on a string|object field alone", () => { + // os.http.request.body accepts a raw string, so `{"a":1}` is a + // legitimate body rather than an over-encoded object. + const args = { url: "https://example.com", method: "POST", body: '{"a":1}' }; + expect(coerceToolArgs("os.http.request", args).body).toBe('{"a":1}'); + }); +}); + +describe("coerceToolArgs — do no harm", () => { + it("leaves a string value for a string-typed field alone", () => { + const args = { path: "1234", format: "5" }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + }); + + it("passes an uncoercible number string through unchanged", () => { + const args = { path: "a.pdf", maxBytes: "not-a-number" }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + expect(coerceToolArgs("os.fs.read_document", args).maxBytes).toBe("not-a-number"); + }); + + it("passes malformed JSON for an array field through without throwing", () => { + const args = { prompt: "p", paths: "[broken" }; + expect(() => coerceToolArgs("vision.describe", args)).not.toThrow(); + expect(coerceToolArgs("vision.describe", args)).toEqual(args); + }); + + it("passes malformed JSON for an object field through without throwing", () => { + const args = { url: "https://example.com", headers: "{not json" }; + expect(() => coerceToolArgs("os.http.request", args)).not.toThrow(); + expect(coerceToolArgs("os.http.request", args).headers).toBe("{not json"); + }); + + it("does not coerce a JSON array of the wrong item type", () => { + // vision.describe.paths is string[]; numbers must not slip through. + const args = { prompt: "p", paths: "[1, 2, 3]" }; + expect(coerceToolArgs("vision.describe", args)).toEqual(args); + }); + + it("leaves an already-correct array untouched (no double parsing)", () => { + const paths = ["/a.png", "/b.png"]; + const result = coerceToolArgs("vision.describe", { prompt: "p", paths }); + expect(result.paths).toBe(paths); + }); + + it("leaves already-correct numbers and objects untouched", () => { + const args = { path: "a.pdf", maxBytes: 200000, pageSeparators: true }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + }); + + it("passes a tool with no registered schema through completely unchanged", () => { + const args = { anything: "[1,2,3]", other: "500" }; + expect(coerceToolArgs("mcp.some.unregistered.tool", args)).toBe(args); + }); + + it("ignores unknown keys not present in the schema properties", () => { + const args = { path: "a.pdf", bogusKey: "[1,2,3]" }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + }); + + it("does not mutate the caller's object", () => { + const args = { prompt: "p", paths: '["/a.png"]' }; + const snapshot = { ...args }; + coerceToolArgs("vision.describe", args); + expect(args).toEqual(snapshot); + expect(args.paths).toBe('["/a.png"]'); + }); + + it("returns the same reference when nothing needed coercing", () => { + const args = { path: "a.pdf", maxBytes: 200 }; + expect(coerceToolArgs("os.fs.read_document", args)).toBe(args); + }); + + it("handles an empty args object", () => { + expect(coerceToolArgs("vision.describe", {})).toEqual({}); + }); + + it("leaves non-string values of the wrong type alone for the tool to reject", () => { + // Only strings are candidates for unwrapping; a bad number stays bad. + const args = { prompt: "p", paths: 42 }; + expect(coerceToolArgs("vision.describe", args)).toEqual(args); + }); +}); + +describe("ToolRegistry.invoke integration", () => { + it("still dispatches to the right tool and returns its result", async () => { + const registry = new ToolRegistry(); + registry.register({ + name: "vision.describe", + description: "d", + readonly: true, + run: async (args) => ({ status: "ok", summary: String(args.paths), details: {} }) as never, + }); + const result = await registry.invoke( + "vision.describe", + { prompt: "p", paths: '["/a.png"]' }, + ctx, + ); + expect(result.status).toBe("ok"); + }); + + it("still throws ToolNotFoundError before any coercion", async () => { + const registry = new ToolRegistry(); + await expect(registry.invoke("nope.missing", { a: "1" }, ctx)).rejects.toThrow( + /tool not registered/, + ); + }); +}); diff --git a/src/tools/coerce-tool-args.ts b/src/tools/coerce-tool-args.ts new file mode 100644 index 00000000..6a9abdff --- /dev/null +++ b/src/tools/coerce-tool-args.ts @@ -0,0 +1,83 @@ +import { + coerceJsonSchemaValue, + validateJsonSchemaValue, +} from "../llm/provider/openai/coerce-json-schema-value.js"; +import { getDefaultArgsJsonSchema } from "../prompt/default-tool-args-schemas.js"; + +type Schema = Record; + +/** + * Repairs tool arguments that arrived one level over-encoded. + * + * Models routinely emit a JSON value wrapped in a string: a number as + * `"200000"`, an array as `"[\"a.png\"]"`, an object as + * `"{\"User-Agent\":\"...\"}"`. The payload is valid JSON of the right + * shape, but the tools' strict `typeof` checks reject it and the step + * is wasted. This runs on every dispatch (see `ToolRegistry.invoke`) + * and unwraps exactly that case. + * + * The rule is do-no-harm. A string is left alone whenever the declared + * schema already accepts it as written, and any coercion failure keeps + * the original value so the tool's own validation produces its normal + * error. A tool with no registered schema passes through unchanged. + */ +export function coerceToolArgs( + name: string, + args: Record, +): Record { + const properties = argsProperties(name); + if (!properties) return args; + + let coerced: Record | null = null; + for (const [key, value] of Object.entries(args)) { + if (typeof value !== "string") continue; + const schema = asSchema(properties[key]); + if (!schema) continue; + + const candidate = tryCoerce(value, schema); + if (candidate === undefined) continue; + + coerced ??= { ...args }; + coerced[key] = candidate; + } + return coerced ?? args; +} + +/** + * Returns the unwrapped value, or `undefined` when the string must be + * left exactly as it is. + * + * The guard that matters is the first one. `browser.scroll`'s `amount` + * is declared `anyOf: ["page" | "half", number]`, so the field accepts + * both strings and numbers: `"page"` validates as written and must stay + * a string, while `"3000"` does not and becomes `3000`. Checking the + * concrete value against the schema — rather than asking whether the + * schema mentions `string` anywhere — gets both halves right, and it + * covers `os.http.request`'s `body` (`string | object`) too, where a + * JSON-looking string is a legitimate value rather than an encoding + * mistake. + */ +function tryCoerce(value: string, schema: Schema): unknown { + try { + if (validateJsonSchemaValue(value, schema)) return undefined; + const candidate = coerceJsonSchemaValue(value, schema); + // A coercion that yields another string changed nothing worth + // rewriting; treat it as a no-op. + return typeof candidate === "string" ? undefined : candidate; + } catch { + // Unsupported schema, or the value does not fit the declared shape. + return undefined; + } +} + +/** The `properties` map of a default tool's args schema, when registered. */ +function argsProperties(name: string): Schema | null { + const schema = getDefaultArgsJsonSchema(name); + return schema ? asSchema(schema.properties) : null; +} + +function asSchema(value: unknown): Schema | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Schema) + : null; +} diff --git a/src/tools/os/archive/tar-backend.ts.bak b/src/tools/os/archive/tar-backend.ts.bak new file mode 100644 index 00000000..957461ba --- /dev/null +++ b/src/tools/os/archive/tar-backend.ts.bak @@ -0,0 +1,269 @@ +import { mkdir, writeFile, symlink, link, access } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { dirname } from "node:path"; +import { Readable } from "node:stream"; +import { createGunzip } from "node:zlib"; +import { extract as tarExtractStream } from "tar-stream"; +import type { Entry as TarEntry, Headers as TarHeaders } from "tar-stream"; +import type { + ArchiveBackend, + ArchiveEntry, + ArchiveEntryKind, + ArchiveFormat, + ExtractOptions, + ExtractReport, +} from "./archive-types.js"; +import { + ExtractBudget, + describeSanitizationError, + sanitizeEntryPath, +} from "./archive-safety.js"; + +/** + * tar-stream based backend. Handles plain tar and gzipped tar (.tar.gz / + * .tgz) by conditionally piping the source buffer through `zlib.createGunzip`. + * + * tar is a streaming format with no central directory, so every operation + * has to read the whole stream. `list` buffers metadata, `readEntry` + * aborts the walk once the requested path is found, and `extract` walks + * once and writes entries as it goes. + */ +export class TarBackend implements ArchiveBackend { + constructor(readonly format: Extract) {} + + async list(data: Buffer): Promise { + const entries: ArchiveEntry[] = []; + await this.walk(data, async (header, stream) => { + // Drain the stream so the parser can advance to the next entry. + for await (const _chunk of stream) void _chunk; + const kind = mapTarType(header.type); + if (!kind) return; + const entry: ArchiveEntry = { + path: normaliseTarName(header.name), + kind, + }; + if (typeof header.size === "number") entry.size = header.size; + if (header.mtime) entry.mtime = header.mtime; + if (kind === "symlink" && header.linkname) entry.linkTarget = header.linkname; + entries.push(entry); + }); + return entries; + } + + async readEntry(data: Buffer, path: string): Promise { + let found: Buffer | null = null; + await this.walk(data, async (header, stream) => { + if (found) { + for await (const _chunk of stream) void _chunk; + return; + } + if (normaliseTarName(header.name) !== path) { + for await (const _chunk of stream) void _chunk; + return; + } + if (header.type && header.type !== "file" && header.type !== "contiguous-file") { + throw new Error(`entry is not a regular file: ${path}`); + } + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + found = Buffer.concat(chunks); + }); + if (!found) throw new Error(`entry not found: ${path}`); + return found; + } + + async extract(data: Buffer, options: ExtractOptions): Promise { + const budget = new ExtractBudget(options.limits); + const report: ExtractReport = { + extractedEntries: 0, + totalBytesWritten: 0, + skippedEntries: [], + warnings: [], + }; + const includeMatches = (p: string) => + !options.include || options.include.length === 0 + ? true + : options.include.some((prefix) => p === prefix || p.startsWith(prefix + "/")); + + await this.walk(data, async (header, stream) => { + const rawName = normaliseTarName(header.name); + const kind = mapTarType(header.type); + if (!kind) { + // Ignore device/fifo/pax-header entries; drain. + for await (const _chunk of stream) void _chunk; + return; + } + const sanitized = sanitizeEntryPath(options.destDir, rawName); + if (!sanitized.ok) { + report.skippedEntries.push({ + path: rawName, + reason: describeSanitizationError(rawName, sanitized.error), + }); + for await (const _chunk of stream) void _chunk; + return; + } + if (!includeMatches(sanitized.value.normalised)) { + for await (const _chunk of stream) void _chunk; + return; + } + + if (kind === "directory") { + await mkdir(sanitized.value.absolute, { recursive: true }); + for await (const _chunk of stream) void _chunk; + return; + } + + if (kind === "symlink") { + if (!options.followSymlinks) { + report.skippedEntries.push({ + path: sanitized.value.normalised, + reason: "symlink entries require followSymlinks=true", + }); + for await (const _chunk of stream) void _chunk; + return; + } + const target = header.linkname ?? ""; + const linkParent = dirname(sanitized.value.normalised); + const resolved = sanitizeEntryPath( + options.destDir, + linkParent === "." ? target : `${linkParent}/${target}`, + ); + if (!resolved.ok) { + report.skippedEntries.push({ + path: sanitized.value.normalised, + reason: `symlink target escapes destDir: ${target}`, + }); + for await (const _chunk of stream) void _chunk; + return; + } + await mkdir(dirname(sanitized.value.absolute), { recursive: true }); + try { + await symlink(target, sanitized.value.absolute); + report.extractedEntries++; + } catch (err) { + report.warnings.push( + `failed to create symlink ${sanitized.value.normalised}: ${(err as Error).message}`, + ); + } + for await (const _chunk of stream) void _chunk; + return; + } + + if (header.type === "link") { + // tar hard link: create a real hard link to the previously-extracted + // target if possible, otherwise skip with a warning. + if (!options.followSymlinks) { + report.skippedEntries.push({ + path: sanitized.value.normalised, + reason: "hard link entries require followSymlinks=true", + }); + for await (const _chunk of stream) void _chunk; + return; + } + const target = header.linkname ?? ""; + const resolved = sanitizeEntryPath(options.destDir, target); + if (!resolved.ok) { + report.skippedEntries.push({ + path: sanitized.value.normalised, + reason: `hard-link target escapes destDir: ${target}`, + }); + for await (const _chunk of stream) void _chunk; + return; + } + await mkdir(dirname(sanitized.value.absolute), { recursive: true }); + try { + await link(resolved.value.absolute, sanitized.value.absolute); + report.extractedEntries++; + } catch (err) { + report.warnings.push( + `failed to create hard link ${sanitized.value.normalised}: ${(err as Error).message}`, + ); + } + for await (const _chunk of stream) void _chunk; + return; + } + + // Regular file: buffer and write. tar streams provide size in the + // header, so we can charge the budget before reading the body and + // short-circuit if the entry would blow the cap. + const declared = typeof header.size === "number" ? header.size : 0; + const decision = budget.chargeEntry(declared); + if (!decision.ok) { + report.skippedEntries.push({ + path: sanitized.value.normalised, + reason: decision.reason, + }); + for await (const _chunk of stream) void _chunk; + return; + } + if (!options.overwrite && (await pathExists(sanitized.value.absolute))) { + report.skippedEntries.push({ + path: sanitized.value.normalised, + reason: "destination exists and overwrite=false", + }); + for await (const _chunk of stream) void _chunk; + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const body = Buffer.concat(chunks); + await mkdir(dirname(sanitized.value.absolute), { recursive: true }); + await writeFile(sanitized.value.absolute, body); + report.extractedEntries++; + report.totalBytesWritten += body.length; + }); + + return report; + } + + /** + * Walks the tar stream entry-by-entry invoking `onEntry` per record. + * Transparently gunzips the source when `format === "tar.gz"`. + */ + private async walk( + data: Buffer, + onEntry: (header: TarHeaders, stream: TarEntry) => Promise, + ): Promise { + const source = Readable.from([data]); + const parser = tarExtractStream(); + const pipeline = this.format === "tar.gz" ? source.pipe(createGunzip()) : source; + pipeline.on("error", (err) => parser.destroy(err)); + pipeline.pipe(parser); + + for await (const entry of parser) { + await onEntry(entry.header, entry); + } + } +} + +function mapTarType(type: TarHeaders["type"]): ArchiveEntryKind | null { + switch (type) { + case "file": + case "contiguous-file": + case "link": + return "file"; + case "directory": + return "directory"; + case "symlink": + return "symlink"; + default: + return null; + } +} + +function normaliseTarName(name: string): string { + // tar often stores directory entries with trailing slashes; strip them + // for parity with zip entry paths. + const out = name.replace(/\/+$/, ""); + return out.startsWith("./") ? out.slice(2) : out; +} + +async function pathExists(p: string): Promise { + try { + await access(p, fsConstants.F_OK); + return true; + } catch { + return false; + } +} diff --git a/src/tools/os/expand-shell-glob-args.test.ts b/src/tools/os/expand-shell-glob-args.test.ts index 42e227c8..02b85837 100644 --- a/src/tools/os/expand-shell-glob-args.test.ts +++ b/src/tools/os/expand-shell-glob-args.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expandShellGlobArgs } from "./expand-shell-glob-args.js"; @@ -26,10 +26,53 @@ describe("expandShellGlobArgs", () => { ); }); - it("omits argv when glob matches nothing (nullglob-style)", async () => { + it("expands a real file glob for rm alongside other argv", async () => { + await writeFile(join(dir, "a.txt"), "1", "utf8"); + await writeFile(join(dir, "b.txt"), "2", "utf8"); + await writeFile(join(dir, "keep.md"), "3", "utf8"); + const out = expandShellGlobArgs("rm", ["*.txt"], dir); + expect(new Set(out)).toEqual( + new Set([join(dir, "a.txt"), join(dir, "b.txt")]), + ); + }); + + it("keeps the pattern verbatim when a glob matches nothing", async () => { await writeFile(join(dir, "c.txt"), "3", "utf8"); const out = expandShellGlobArgs("rm", ["-f", "*.png"], dir); - expect(out).toEqual(["-f"]); + expect(out).toEqual(["-f", "*.png"]); + }); + + it("keeps a path-shaped pattern that matches nothing verbatim", () => { + const out = expandShellGlobArgs("ls", ["./nope/*.png"], dir); + expect(out).toEqual(["./nope/*.png"]); + }); + + it("passes a bash -c payload containing a URL with ? and / through intact", () => { + const payload = + "curl -s 'https://en.wikipedia.org/w/api.php?action=query&prop=revisions&titles=Outer%20Wilds&format=json'"; + const args = ["-c", payload]; + const out = expandShellGlobArgs("bash", args, dir); + expect(out).toEqual(args); + }); + + it("passes a python3 -c payload containing regex metacharacters through intact", () => { + const payload = "import re; print(re.findall(r'a?b*c', 'aabbcc'))"; + const args = ["-c", payload]; + const out = expandShellGlobArgs("python3", args, dir); + expect(out).toEqual(args); + }); + + it("never drops argv for a bash -c payload, so the shell sees its command", () => { + const args = ["-c", "echo 'https://example.com/x?y=1' > /dev/null"]; + const out = expandShellGlobArgs("bash", args, dir); + expect(out.length).toBe(args.length); + expect(out[1]).toBe(args[1]); + }); + + it("does not treat a bare URL argument as a glob", () => { + const url = "https://example.com/w/api.php?action=query&x=*"; + const out = expandShellGlobArgs("curl", ["-s", url], dir); + expect(out).toEqual(["-s", url]); }); it("does not expand bare *.py for find", async () => { @@ -42,4 +85,39 @@ describe("expandShellGlobArgs", () => { const out = expandShellGlobArgs("rm", ["-f"], dir); expect(out).toEqual(["-f"]); }); + + it("still expands a real path argument for an interpreter without -c", async () => { + await writeFile(join(dir, "s1.py"), "x", "utf8"); + const out = expandShellGlobArgs("python3", ["./s1*.py"], dir); + expect(out).toEqual([join(dir, "s1.py")]); + }); + + it("leaves a -c payload alone even when it happens to match real files", async () => { + // The differential case for the exemption itself: without it, this + // payload would be rewritten to the matched path, not passed through. + await mkdir(join(dir, "x")); + await writeFile(join(dir, "x", "y.txt"), "1", "utf8"); + const args = ["-c", "x/*.txt"]; + expect(expandShellGlobArgs("bash", args, dir)).toEqual(args); + }); + + it("exempts the payload for a path-invoked shell and clustered flags", async () => { + await mkdir(join(dir, "x")); + await writeFile(join(dir, "x", "y.txt"), "1", "utf8"); + expect(expandShellGlobArgs("/bin/bash", ["-c", "x/*.txt"], dir)).toEqual([ + "-c", + "x/*.txt", + ]); + expect(expandShellGlobArgs("bash", ["-lc", "x/*.txt"], dir)).toEqual([ + "-lc", + "x/*.txt", + ]); + }); + + it("still expands -c for interpreters whose -c checks a file", async () => { + // perl -c is a syntax check on a script path, not an inline program. + await writeFile(join(dir, "s1.pl"), "x", "utf8"); + const out = expandShellGlobArgs("perl", ["-c", "./s1*.pl"], dir); + expect(out).toEqual(["-c", join(dir, "s1.pl")]); + }); }); diff --git a/src/tools/os/expand-shell-glob-args.ts b/src/tools/os/expand-shell-glob-args.ts index b9373721..308c2c7a 100644 --- a/src/tools/os/expand-shell-glob-args.ts +++ b/src/tools/os/expand-shell-glob-args.ts @@ -1,6 +1,7 @@ import { globSync } from "node:fs"; import { isAbsolute } from "node:path"; import { resolveUserPath } from "./expand-home.js"; +import { basenameCommand } from "./shell-command-guard/normalise.js"; const MAX_GLOB_MATCHES = 10_000; @@ -17,13 +18,77 @@ const RELATIVE_GLOB_CMDS = new Set([ "rmdir", ]); +/** + * Interpreters whose `-c` argument is a program, not a path. The payload + * routinely carries `?`/`*` (regexes, URLs with query strings, glob patterns + * meant for the inner program) and must reach the interpreter verbatim. + * `node`, `perl` and `ruby` are deliberately absent: their `-c` is a + * syntax check that takes a file path, so globbing it stays correct. + */ +const CODE_PAYLOAD_CMDS = new Set([ + "bash", + "sh", + "zsh", + "dash", + "ksh", + "python", + "python3", +]); + +/** Matches with the guard's view of the binary: basename, case-folded. */ +function isCodePayloadCmd(cmd: string): boolean { + const bin = basenameCommand(cmd).toLowerCase().replace(/\.exe$/, ""); + if (CODE_PAYLOAD_CMDS.has(bin)) return true; + return /^python\d+(\.\d+)*$/.test(bin); +} + +/** `-c`, a short-option cluster ending in it (`-lc`, `-ec`), or the long form. */ +function isCodePayloadFlag(arg: string): boolean { + return /^-[a-zA-Z]*c$/.test(arg) || arg === "--command"; +} + +/** + * `scheme://…` — a URL is never a filesystem glob, even with `?` and `/`. + * Two-letter minimum keeps Windows `C://…` sloppy-paths out of the rule. + */ +const URL_RE = /^[a-z][a-z0-9+.-]+:\/\//i; + function hasGlobMetachar(arg: string): boolean { return /[*?]/.test(arg); } +function isUrlLike(arg: string): boolean { + return URL_RE.test(arg); +} + +/** + * Indices of argv entries that are code payloads rather than paths — the + * token right after a `-c` (or a cluster like `-lc`) for a known + * interpreter. `bash -c ''` is the dominant shape; the scan + * stops at the first non-flag token so a later positional argument is not + * mistaken for a payload. A flag that takes a separate value (`-o + * pipefail`, `-X utf8`) ends the scan early — a known limit, and safe: + * the never-drop rule below keeps such a payload intact unless it + * collides with a really-matching file glob. + */ +function codePayloadIndices(cmd: string, args: string[]): ReadonlySet { + const marked = new Set(); + if (!isCodePayloadCmd(cmd)) return marked; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (isCodePayloadFlag(arg)) { + if (i + 1 < args.length) marked.add(i + 1); + break; + } + if (!arg.startsWith("-")) break; + } + return marked; +} + function shouldExpandGlobArg(cmd: string, arg: string): boolean { if (!hasGlobMetachar(arg)) return false; if (arg.startsWith("-")) return false; + if (isUrlLike(arg)) return false; if ( arg.startsWith("/") || arg.startsWith("~/") || @@ -46,15 +111,22 @@ function globMatches(pattern: string, cwd: string): string[] { /** * Expands `*` / `?` in argv the way a shell would for typical file commands, * before `spawn` (which does not perform glob expansion). + * + * An argument is never dropped. A pattern that matches nothing passes through + * verbatim, which is what POSIX shells do by default (bash without + * `nullglob`, zsh with `nomatch` off) — silently discarding it turned a + * correct `bash -c ''` into a bare `bash -c` and made the shell + * fail with "option requires an argument". */ export function expandShellGlobArgs( cmd: string, args: string[], cwd: string, ): string[] { + const codePayloads = codePayloadIndices(cmd, args); const out: string[] = []; - for (const arg of args) { - if (!shouldExpandGlobArg(cmd, arg)) { + for (const [index, arg] of args.entries()) { + if (codePayloads.has(index) || !shouldExpandGlobArg(cmd, arg)) { out.push(arg); continue; } @@ -70,6 +142,7 @@ export function expandShellGlobArgs( continue; } if (matches.length === 0) { + out.push(arg); continue; } out.push(...matches); diff --git a/src/tools/os/fs-glob-real.test.ts b/src/tools/os/fs-glob-real.test.ts deleted file mode 100644 index f00b9156..00000000 --- a/src/tools/os/fs-glob-real.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { osFsGlobTool } from "./fs-glob.js"; - -// Developer-machine real-world smoke: hardcoded to a macOS home path, so it -// only runs on darwin. On other platforms `resolveUserPath` rejects the Unix -// path anyway, so gate the whole suite to darwin. -describe.runIf(process.platform === "darwin")("real-world: find Sibiliainen CV", () => { - it("scope=$HOME, narrower pattern", async () => { - const result = await osFsGlobTool.run( - { - pattern: ["**/*Sibili*", "**/CV*.pdf", "**/*CV.pdf"], - cwd: "/Users/aleksejkalina", - nocase: true, - sortByMtime: true, - limit: 30, - }, - { workingDir: "/Users/aleksejkalina", sessionId: "t", stepIndex: 0, signal: new AbortController().signal }, - ); - const files = result.details.files as string[]; - console.error("HOME (narrower):", JSON.stringify(files.slice(0, 30), null, 2)); - console.error("meta:", { scanned: result.details.scanned, total: result.details.total, truncated: result.details.truncated }); - expect(files.some((f) => f.toLowerCase().includes("sibiliainen"))).toBe(true); - }, 60_000); -}); diff --git a/src/tools/os/fs-grep.test.ts b/src/tools/os/fs-grep.test.ts index c88eda41..a6682c78 100644 --- a/src/tools/os/fs-grep.test.ts +++ b/src/tools/os/fs-grep.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { afterAll, beforeAll, describe, it, expect } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { CommandOptions, CommandResult, @@ -6,11 +9,23 @@ import type { import type { ToolContext } from "../tool-registry.js"; import { buildOsFsGrepTool, parseRipgrepJson } from "./fs-grep.js"; -// A platform-native absolute root: the grep runner is mocked in these tests -// so the path is never touched on disk, but it must be a valid absolute path -// for the host so `resolveUserPath` does not reject a Unix path on Windows. -const FIXTURE_ROOT = - process.platform === "win32" ? "C:\\tmp\\fixture" : "/tmp/fixture"; +// The grep runner is mocked in these tests, but the tool now stats the +// requested path to decide the child process cwd, so the fixture must exist +// on disk. A real temp directory also keeps the paths platform-native, so +// `resolveUserPath` does not reject a Unix path on Windows. +let FIXTURE_ROOT: string; +let FIXTURE_FILE: string; +const FIXTURE_FILE_NAME = "darktrace.txt"; + +beforeAll(async () => { + FIXTURE_ROOT = await mkdtemp(join(tmpdir(), "fs-grep-test-")); + FIXTURE_FILE = join(FIXTURE_ROOT, FIXTURE_FILE_NAME); + await writeFile(FIXTURE_FILE, "endopsychic\n", "utf8"); +}); + +afterAll(async () => { + await rm(FIXTURE_ROOT, { recursive: true, force: true }); +}); function makeCtx(): ToolContext { return { @@ -34,6 +49,7 @@ function makeCommandResult( durationMs: 1, timedOut: false, truncated: false, + inputTruncated: false, ...overrides, }; } @@ -309,4 +325,92 @@ describe("os.fs.grep", () => { expect(capturedArgs[before + 1]).toBe("2"); expect(capturedArgs[after + 1]).toBe("2"); }); + + // Regression coverage for #183: an absolute file path used to be passed + // through as the child process cwd, which `spawn` rejects with ENOTDIR. + it("searches an absolute file path from its parent directory", async () => { + let capturedArgs: string[] = []; + let capturedCwd: string | undefined; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async (_cmd, args, opts: CommandOptions) => { + capturedArgs = args; + capturedCwd = opts.cwd; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { + pattern: "endopsychic", + path: FIXTURE_FILE, + outputMode: "content", + contextAround: 3, + caseInsensitive: true, + }, + makeCtx(), + ); + expect(result.status).toBe("ok"); + expect(capturedCwd).toBe(FIXTURE_ROOT); + expect(capturedArgs[capturedArgs.length - 1]).toBe(FIXTURE_FILE_NAME); + expect(capturedArgs[capturedArgs.length - 2]).toBe("endopsychic"); + }); + + it("searches a relative file path from its parent directory", async () => { + let capturedArgs: string[] = []; + let capturedCwd: string | undefined; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async (_cmd, args, opts: CommandOptions) => { + capturedArgs = args; + capturedCwd = opts.cwd; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { pattern: "endopsychic", path: FIXTURE_FILE_NAME }, + makeCtx(), + ); + expect(result.status).toBe("ok"); + expect(capturedCwd).toBe(FIXTURE_ROOT); + expect(capturedArgs[capturedArgs.length - 1]).toBe(FIXTURE_FILE_NAME); + }); + + it("still searches a directory path with '.' as the target", async () => { + let capturedArgs: string[] = []; + let capturedCwd: string | undefined; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async (_cmd, args, opts: CommandOptions) => { + capturedArgs = args; + capturedCwd = opts.cwd; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { pattern: "endopsychic", path: FIXTURE_ROOT }, + makeCtx(), + ); + expect(result.status).toBe("ok"); + expect(capturedCwd).toBe(FIXTURE_ROOT); + expect(capturedArgs[capturedArgs.length - 1]).toBe("."); + }); + + it("reports a clear error for a path that does not exist", async () => { + let ran = false; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async () => { + ran = true; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { pattern: "foo", path: join(FIXTURE_ROOT, "no-such-file.txt") }, + makeCtx(), + ); + expect(result.status).toBe("error"); + expect(result.summary).toContain("does not exist"); + expect(result.summary).not.toContain("ENOTDIR"); + expect(ran).toBe(false); + }); }); diff --git a/src/tools/os/fs-grep.ts b/src/tools/os/fs-grep.ts index 1a096d45..689156c7 100644 --- a/src/tools/os/fs-grep.ts +++ b/src/tools/os/fs-grep.ts @@ -1,3 +1,5 @@ +import { stat } from "node:fs/promises"; +import { basename, dirname } from "node:path"; import { compressToolResult } from "../../compressor/result-compressor.js"; import { resolveUserPath } from "./expand-home.js"; import { @@ -72,9 +74,22 @@ export function buildOsFsGrepTool( }); } - const rgArgs = buildRgArgs(args); + let target: SearchTarget; + try { + target = await resolveSearchTarget(args.path, ctx.workingDir); + } catch (err) { + const reason = (err as Error).message; + return compressToolResult({ + tool: "os.fs.grep", + status: "error", + output: reason, + details: { path: args.path, hint: reason }, + }); + } + + const rgArgs = buildRgArgs(args, target.searchTarget); const result = await runCommand(rgPath, rgArgs, { - cwd: args.path, + cwd: target.cwd, timeoutMs: args.timeoutMs, signal: ctx.signal, }); @@ -194,7 +209,49 @@ function parseArgs( }; } -function buildRgArgs(args: GrepArgs): string[] { +interface SearchTarget { + /** Directory the ripgrep child process is spawned in. Always a directory. */ + cwd: string; + /** Positional target handed to ripgrep, relative to `cwd`. */ + searchTarget: string; +} + +/** + * Work out where to spawn ripgrep and what to point it at. + * + * `spawn` requires `cwd` to be a directory, so passing a file path straight + * through fails with `ENOTDIR` before ripgrep ever runs. A file is therefore + * searched from its parent directory, with the file name as the positional + * target; a directory keeps the previous behaviour (`cwd` = the directory, + * target = `.`) so glob and type filters resolve the same way as before. + */ +async function resolveSearchTarget( + path: string, + workingDir: string, +): Promise { + let info; + try { + info = await stat(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new Error(`os.fs.grep: path does not exist: ${path}`); + } + throw new Error( + `os.fs.grep: cannot access path ${path}: ${(err as Error).message}`, + ); + } + if (info.isDirectory()) { + return { cwd: path, searchTarget: "." }; + } + const parent = dirname(path); + // `dirname` of a filesystem root returns the root itself; fall back to the + // working directory only when the parent is somehow unusable. + const cwd = parent.length > 0 ? parent : workingDir; + return { cwd, searchTarget: basename(path) }; +} + +function buildRgArgs(args: GrepArgs, searchTarget: string): string[] { const rg: string[] = ["--json"]; if (args.caseInsensitive) rg.push("-i"); if (args.multiline) { @@ -207,7 +264,7 @@ function buildRgArgs(args: GrepArgs): string[] { rg.push("--glob", g); } if (args.type) rg.push("--type", args.type); - rg.push("--", args.pattern, "."); + rg.push("--", args.pattern, searchTarget); return rg; } diff --git a/src/tools/os/fs-require-approval.ts b/src/tools/os/fs-require-approval.ts index a52e952b..ff099737 100644 --- a/src/tools/os/fs-require-approval.ts +++ b/src/tools/os/fs-require-approval.ts @@ -2,6 +2,7 @@ import { requireApproval, type DangerousToolOptions, } from "../../approval/dangerous-tool.js"; +import type { ApprovalCategory } from "../../approval/approval-level.js"; import { categorizeFsMutation, type FsMutationKind, @@ -65,6 +66,27 @@ export interface FsApprovalRequest { * `extract` ignores it. Never re-derived inside the tools layer. */ trustConfigPaths?: readonly string[]; + /** + * Absolute path the operator may retarget from the prompt. Set only + * by `os.fs.write`, whose destination is a free choice; an edit or a + * patch acts on a file the model picked for a reason, so redirecting + * those would be nonsense rather than a feature. + */ + redirectablePath?: string; +} + +/** + * What the funnel reports back once a request survives the gate. + */ +export interface FsApprovalOutcome { + /** + * The category the operator actually approved. Callers that accept a + * retarget compare it against the new path's category: an equal rung + * needs no second prompt, a different one does. + */ + category: ApprovalCategory; + /** Raw retarget as typed, when the operator supplied one. */ + pathOverride?: string; } /** @@ -86,14 +108,14 @@ export async function requireFsApproval( options: DangerousToolOptions, request: FsApprovalRequest, signal: AbortSignal, -): Promise { +): Promise { const category = await categorizeFsMutation(request.kind, request.paths, { workingDir: request.workingDir, ...(request.trustConfigPaths !== undefined ? { trustConfigPaths: request.trustConfigPaths } : {}), }); - await requireApproval( + const outcome = await requireApproval( options, { sessionId: request.sessionId, @@ -104,7 +126,16 @@ export async function requireFsApproval( ...(request.affectedResources !== undefined ? { affectedResources: request.affectedResources } : {}), + ...(request.redirectablePath !== undefined + ? { redirectablePath: request.redirectablePath } + : {}), }, signal, ); + return { + category, + ...(outcome.pathOverride !== undefined + ? { pathOverride: outcome.pathOverride } + : {}), + }; } diff --git a/src/tools/os/fs-write-retarget.test.ts b/src/tools/os/fs-write-retarget.test.ts new file mode 100644 index 00000000..38a38b99 --- /dev/null +++ b/src/tools/os/fs-write-retarget.test.ts @@ -0,0 +1,181 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ApprovalGate, type ApprovalRequest } from "../../approval/approval-gate.js"; +import { buildOsFsWriteTool } from "./fs-write.js"; + +/** + * `[e]` on the approval prompt: the operator moves a write somewhere + * else before approving it. The rule these tests pin is that a retarget + * rides the approval only while it stays on the same rung of the + * ladder — anything else is a new question, and the agent's own config + * is never an answer at all. + */ +describe("os.fs.write retarget", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "fs-write-retarget-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + function ctx() { + return { + workingDir: dir, + sessionId: "s1", + stepIndex: 0, + signal: new AbortController().signal, + }; + } + + it("offers the resolved target as the redirectable path", async () => { + const seen: ApprovalRequest[] = []; + const gate = new ApprovalGate({ + emit: (req) => { + seen.push(req); + gate.resolve({ approvalId: req.approvalId, approved: true }); + }, + }); + const tool = buildOsFsWriteTool({ approvals: gate, approvalRequired: true }); + await tool.run({ path: "out.txt", content: "hi" }, ctx()); + expect(seen[0]?.redirectablePath).toBe(join(dir, "out.txt")); + }); + + it("writes to the operator's path, creating folders that do not exist", async () => { + const gate = new ApprovalGate({ + emit: (req) => + gate.resolve({ + approvalId: req.approvalId, + approved: true, + pathOverride: join(dir, "brand", "new", "index.html"), + }), + }); + const tool = buildOsFsWriteTool({ approvals: gate, approvalRequired: true }); + const result = await tool.run( + { path: "out.txt", content: "

apple

" }, + ctx(), + ); + expect(result.status).toBe("ok"); + expect(await readFile(join(dir, "brand", "new", "index.html"), "utf8")).toBe( + "

apple

", + ); + // The model has to learn where the file actually went, or its next + // step reads back a path that was never written. + expect(result.details.path).toBe(join(dir, "brand", "new", "index.html")); + expect(result.details.requestedPath).toBe(join(dir, "out.txt")); + expect(result.summary).toContain("the operator moved this write"); + }); + + it("asks again when the new target sits on a different rung", async () => { + // workspace → home. One prompt covers one rung; the second prompt + // is the operator confirming the rung they just moved to. + const prompts: ApprovalRequest[] = []; + const outside = join(homedir(), ".atomic-agent-retarget-test", "out.txt"); + const gate = new ApprovalGate({ + emit: (req) => { + prompts.push(req); + if (prompts.length === 1) { + gate.resolve({ + approvalId: req.approvalId, + approved: true, + pathOverride: outside, + }); + return; + } + gate.resolve({ approvalId: req.approvalId, approved: true }); + }, + }); + const tool = buildOsFsWriteTool({ approvals: gate, approvalRequired: true }); + try { + const result = await tool.run({ path: "out.txt", content: "x" }, ctx()); + expect(result.status).toBe("ok"); + expect(prompts).toHaveLength(2); + expect(prompts[0]?.category).toBe("fs_write_workspace"); + expect(prompts[1]?.category).toBe("fs_write_home"); + expect(prompts[1]?.reason).toContain(outside); + } finally { + await rm(join(homedir(), ".atomic-agent-retarget-test"), { + recursive: true, + force: true, + }); + } + }); + + it("refuses a retarget onto the agent's own config", async () => { + const configPath = join(dir, "config.json"); + await writeFile(configPath, "{}", "utf8"); + const gate = new ApprovalGate({ + emit: (req) => + gate.resolve({ + approvalId: req.approvalId, + approved: true, + pathOverride: configPath, + }), + }); + const tool = buildOsFsWriteTool({ + approvals: gate, + approvalRequired: true, + trustConfigPaths: [configPath], + }); + await expect( + tool.run({ path: "out.txt", content: "x" }, ctx()), + ).rejects.toThrow(/refusing to redirect into the agent's own config/); + expect(await readFile(configPath, "utf8")).toBe("{}"); + }); + + it("gives up rather than loop when a host keeps redirecting", async () => { + // A host that answers every prompt with a new target would spin + // forever; the cap turns that into a plain tool error. + let hop = 0; + const gate = new ApprovalGate({ + emit: (req) => + gate.resolve({ + approvalId: req.approvalId, + approved: true, + // Alternating rungs keeps every hop asking again. + pathOverride: + hop++ % 2 === 0 + ? join(homedir(), ".atomic-agent-retarget-loop", `${hop}.txt`) + : join(dir, `${hop}.txt`), + }), + }); + const tool = buildOsFsWriteTool({ approvals: gate, approvalRequired: true }); + try { + await expect( + tool.run({ path: "out.txt", content: "x" }, ctx()), + ).rejects.toThrow(/redirected more than/); + } finally { + await rm(join(homedir(), ".atomic-agent-retarget-loop"), { + recursive: true, + force: true, + }); + } + }); + + it("ignores a pathOverride on a tool that never offered one", async () => { + // `redirectablePath` is the offer; an override without it is a host + // answering a question nobody asked. + const gate = new ApprovalGate({ + emit: (req) => + gate.resolve({ + approvalId: req.approvalId, + approved: true, + pathOverride: join(dir, "elsewhere.txt"), + }), + }); + const decision = await gate.request({ + sessionId: "s1", + tool: "os.shell.run", + category: "shell", + reason: "run something", + }); + expect(decision.approved).toBe(true); + // The gate passes it through untouched — it is `requireApproval` + // that drops it, which the write tool above exercises end to end. + expect(decision.pathOverride).toBe(join(dir, "elsewhere.txt")); + }); +}); diff --git a/src/tools/os/fs-write.ts b/src/tools/os/fs-write.ts index 98c36ab9..976946a8 100644 --- a/src/tools/os/fs-write.ts +++ b/src/tools/os/fs-write.ts @@ -2,12 +2,21 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { compressToolResult } from "../../compressor/result-compressor.js"; import { resolveUserPath } from "./expand-home.js"; +import { categorizeFsMutation } from "./fs-approval-scope.js"; import { requireFsApproval, type FsDangerousToolOptions, } from "./fs-require-approval.js"; import type { ToolDefinition } from "../tool-registry.js"; +/** + * How many times one write may be retargeted from the approval prompt + * before the tool refuses. Each hop is a deliberate keystroke by the + * operator, so this is a runaway guard for a misbehaving host that + * echoes an override back forever — not a limit anyone types into. + */ +const MAX_REDIRECTS = 3; + export function buildOsFsWriteTool( options: FsDangerousToolOptions, ): ToolDefinition { @@ -32,34 +41,84 @@ export function buildOsFsWriteTool( const absolute = resolveUserPath(path, ctx.workingDir); const preview = content.length > 400 ? `${content.slice(0, 400)}…` : content; - await requireFsApproval( - options, - { - kind: "write", - paths: [absolute], - sessionId: ctx.sessionId, - tool: "os.fs.write", - reason: `${mode} ${content.length} bytes into ${absolute}`, - preview, - affectedResources: [absolute], + + // The operator can retarget the write from the prompt ("put it in + // ~/Documents/apple-site instead"). A retarget is never a silent + // widening of what they approved: the new path is re-categorised, + // and only a target on the SAME rung of the ladder rides the + // approval just given. A different rung goes round the loop and + // prompts again for the new path; the agent's own config / `.env` + // is refused outright, since that is the one surface the ladder + // exists to protect and no prompt is offered for it here. + let target = absolute; + let redirects = 0; + for (;;) { + const outcome = await requireFsApproval( + options, + { + kind: "write", + paths: [target], + sessionId: ctx.sessionId, + tool: "os.fs.write", + reason: `${mode} ${content.length} bytes into ${target}`, + preview, + affectedResources: [target], + redirectablePath: target, + workingDir: ctx.workingDir, + trustConfigPaths: options.trustConfigPaths, + }, + ctx.signal, + ); + if (outcome.pathOverride === undefined) break; + + const typed = outcome.pathOverride.trim(); + if (typed.length === 0) { + throw new Error("os.fs.write: empty target path from the approval prompt"); + } + if (++redirects > MAX_REDIRECTS) { + throw new Error( + `os.fs.write: target redirected more than ${MAX_REDIRECTS} times`, + ); + } + const next = resolveUserPath(typed, ctx.workingDir); + const nextCategory = await categorizeFsMutation("write", [next], { workingDir: ctx.workingDir, - trustConfigPaths: options.trustConfigPaths, - }, - ctx.signal, - ); + ...(options.trustConfigPaths !== undefined + ? { trustConfigPaths: options.trustConfigPaths } + : {}), + }); + if (nextCategory === "trust_config") { + throw new Error( + `os.fs.write: refusing to redirect into the agent's own config: ${next}`, + ); + } + target = next; + if (nextCategory === outcome.category) break; + } - await mkdir(dirname(absolute), { recursive: true }); + await mkdir(dirname(target), { recursive: true }); if (mode === "append") { const { appendFile } = await import("node:fs/promises"); - await appendFile(absolute, content, "utf8"); + await appendFile(target, content, "utf8"); } else { - await writeFile(absolute, content, "utf8"); + await writeFile(target, content, "utf8"); } + // The path is echoed in `output` (not just `details`) so a model + // that had its target moved reads where the file actually landed + // and keeps working against the right path. return compressToolResult({ tool: "os.fs.write", status: "ok", - output: `wrote ${content.length} bytes to ${absolute} (${mode})`, - details: { path: absolute, bytes: content.length, mode }, + output: + target === absolute + ? `wrote ${content.length} bytes to ${target} (${mode})` + : `wrote ${content.length} bytes to ${target} (${mode}); the operator moved this write from ${absolute}`, + details: { + path: target, + bytes: content.length, + mode, + ...(target === absolute ? {} : { requestedPath: absolute }), + }, }); }, }; diff --git a/src/tools/os/http-request-curl-meta.test.ts b/src/tools/os/http-request-curl-meta.test.ts new file mode 100644 index 00000000..0565ce54 --- /dev/null +++ b/src/tools/os/http-request-curl-meta.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + CURL_META_MARKER, + CURL_RETRY_AFTER_MARKER, + parseCurlOutput, +} from "./http-request-fetch.js"; + +/** Build a `-w` meta line the way the shipped format string emits it. */ +function meta(fields: { + status?: string; + contentType?: string; + size?: string; + time?: string; + redirectUrl?: string; + retryAfter?: string; +}): string { + const { + status = "200", + contentType = "text/plain", + size = "4", + time = "0.01", + redirectUrl = "", + retryAfter = "", + } = fields; + return ( + `body\n${CURL_META_MARKER}${status}|${contentType}|${size}|${time}|` + + `${redirectUrl}${CURL_RETRY_AFTER_MARKER}${retryAfter}` + ); +} + +/** + * Two of the fields on this line carry text the *origin* chose — the + * redirect URL curl reports verbatim, and `Retry-After`, which is a raw + * response header. Neither can be bounded by the `|` that separates the + * fixed numeric fields, and for a while both shared it: `retry-after` + * sat immediately before `redirect_url`, so a pipe inside the header + * shifted the URL one field to the right. + * + * That is reachable by any origin, on the default follow-redirects + * path, and it did not fail loudly — the caller got `x|https://real/`, + * which fails the SSRF host check, so a plain 429-with-a-redirect was + * reported back to the model as `blocked`. + */ +describe("the curl meta line survives origin-controlled text", () => { + it("keeps the redirect URL whole when Retry-After contains a pipe", () => { + const parsed = parseCurlOutput( + meta({ + status: "429", + redirectUrl: "https://good.example/next", + retryAfter: "5|x", + }), + ); + expect(parsed.redirectUrl).toBe("https://good.example/next"); + expect(parsed.retryAfter).toBe("5|x"); + }); + + it("keeps Retry-After whole when the redirect URL contains a pipe", () => { + // RFC 3986 disallows a bare `|`, but origins emit it and curl + // reports what it was given. + const parsed = parseCurlOutput( + meta({ + status: "429", + redirectUrl: "https://good.example/a|b", + retryAfter: "7", + }), + ); + expect(parsed.redirectUrl).toBe("https://good.example/a|b"); + expect(parsed.retryAfter).toBe("7"); + }); + + it("still reads the fixed fields", () => { + const parsed = parseCurlOutput( + meta({ status: "503", contentType: "application/json", size: "4", time: "1.5" }), + ); + expect(parsed.status).toBe(503); + expect(parsed.contentType).toBe("application/json"); + expect(parsed.sizeDownload).toBe(4); + expect(parsed.timeTotal).toBe(1.5); + expect(parsed.body).toBe("body"); + }); + + it("reports no Retry-After on a curl too old for %header{}", () => { + // Before 7.83 curl echoes the format string instead of a value. + const parsed = parseCurlOutput( + meta({ retryAfter: "%header{retry-after}" }), + ); + expect(parsed.retryAfter).toBe(""); + }); + + it("tolerates a line with no sentinel at all", () => { + const parsed = parseCurlOutput( + `body\n${CURL_META_MARKER}200|text/plain|4|0.01|https://x.example/`, + ); + expect(parsed.status).toBe(200); + expect(parsed.redirectUrl).toBe("https://x.example/"); + expect(parsed.retryAfter).toBe(""); + }); +}); diff --git a/src/tools/os/http-request-fetch.ts b/src/tools/os/http-request-fetch.ts index 0828c0b1..56286998 100644 --- a/src/tools/os/http-request-fetch.ts +++ b/src/tools/os/http-request-fetch.ts @@ -3,8 +3,10 @@ import { type CommandResult, } from "../../sandbox/command-runner.js"; import { CurlUnavailableError, isCurlMissingError } from "./ensure-curl.js"; +import { parseRetryAfterValueMs } from "./retry-after-header.js"; import { assertHostAllowed, + formatResolveEntry, parseHttpUrl, SsrfBlockedError, type HostLookup, @@ -16,10 +18,62 @@ import { * into the body). Deterministic for tests. */ export const CURL_META_MARKER = "__ATOMIC_CURL_META__"; +/** + * Separates `redirect_url` from `retry-after` in the `-w` line. + * + * `|` cannot do it. Both of those fields carry text the *origin* chose — + * one a URL curl reports verbatim, the other a raw response header — and + * a single delimiter can only bound one of them. With both pipe-joined, + * a `Retry-After: 5|x` shifted the URL a field to the right and the + * caller got `x|https://…`, which fails the SSRF host check and is + * reported to the model as `blocked`. + */ +export const CURL_RETRY_AFTER_MARKER = "__ATOMIC_CURL_RA__"; const MAX_REDIRECTS = 5; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +/** + * Statuses worth a second attempt: transient-by-contract, and the same set + * `os.web.fetch` retries. Everything else — 404, 403, 400 — is a stable + * answer that a repeat would only re-spend task budget on. + */ +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); + +/** + * Statuses at which a server is explicitly asking the client to come back. + * A non-idempotent request is only retried on these, and only when the + * server also sent a `Retry-After` (see `isRetryableAttempt`). + */ +const INVITED_RETRY_STATUSES = new Set([429, 503]); + +/** curl's "operation timed out" exit. The other exits are not transient. */ +const CURL_EXIT_TIMEOUT = 28; + +/** + * Methods that are safe to replay. A GET carries no side effect, so a repeat + * is free. A POST may already have been processed by the origin even when the + * response never arrived, so replaying it blindly risks a double submit — the + * one failure mode a retry layer must not introduce. + */ +const IDEMPOTENT_METHODS = new Set(["GET"]); + +export interface HttpRetryConfig { + /** Extra attempts on top of the first. `0` disables retrying. */ + maxRetries: number; + /** First backoff step; each subsequent retry doubles it. */ + retryBaseDelayMs: number; + /** Ceiling on any single wait, including a server-sent `Retry-After`. */ + retryMaxDelayMs: number; +} + +/** Mirrors `web.fetch`'s retry defaults so the two tools behave alike. */ +export const DEFAULT_HTTP_RETRY_CONFIG: HttpRetryConfig = { + maxRetries: 2, + retryBaseDelayMs: 500, + retryMaxDelayMs: 5_000, +}; + export type HttpMethod = "GET" | "POST"; export interface HttpRequestArgs { @@ -39,6 +93,8 @@ export interface GuardedCurlResponse { timeTotal: number; truncated: boolean; redirectChain: string[]; + /** Server-sent `Retry-After` in ms, `null` when absent or unparseable. */ + retryAfterMs: number | null; /** Last curl argv (for diagnostics). */ command: string[]; } @@ -49,6 +105,10 @@ export interface ExecuteGuardedHttpOptions { cwd: string; signal: AbortSignal; maxResponseBytes: number; + /** Retry schedule; omitted means `DEFAULT_HTTP_RETRY_CONFIG`. */ + retry?: HttpRetryConfig; + /** Injectable sleep so retry-backoff tests do not wait in real time. */ + sleep?: (ms: number, signal: AbortSignal) => Promise; } /** @@ -60,23 +120,172 @@ export async function executeGuardedHttpRequest( rawUrl: string, args: HttpRequestArgs, opts: ExecuteGuardedHttpOptions, +): Promise { + const retry = opts.retry ?? DEFAULT_HTTP_RETRY_CONFIG; + const sleep = opts.sleep ?? defaultSleep; + + // Cumulative across attempts. `sendGuardedRequestOnce` advances `url`, + // `method` and `body` before each hop it issues, so a retry — whether after + // a retryable status or a timeout — resumes at the hop that actually failed + // instead of re-walking redirects the origin has already served. + const state: RequestWalkState = { + url: rawUrl, + method: args.method, + body: args.body, + chain: [], + redirects: 0, + totalTime: 0, + truncated: false, + }; + + for (let attempt = 0; ; attempt++) { + let response: GuardedCurlResponse | null = null; + let failure: unknown = null; + try { + response = await sendGuardedRequestOnce(args, opts, state); + } catch (err) { + // Only a curl timeout is worth another attempt; a missing binary, an + // SSRF rejection, or an aborted run must surface immediately. + if ( + !isCurlTransportError(err) || + err.exitCode !== CURL_EXIT_TIMEOUT + ) { + throw err; + } + failure = err; + } + + // The replay-safety question is about the method of the hop that actually + // ran, not the one the caller passed in: a 307/308 carries a POST forward, + // and a 303 downgrades it to a bodyless GET that is safe to replay. + // `state.method` tracks that on both the response and the failure path. + const exhausted = attempt >= retry.maxRetries || opts.signal.aborted; + if (exhausted || !isRetryableAttempt(state.method, response, failure)) { + if (response !== null) return response; + throw failure; + } + + await sleep( + httpBackoffDelayMs(attempt, response?.retryAfterMs ?? null, retry), + opts.signal, + ); + + // The sleep resolves on abort rather than rejecting, so re-check here. + // Without this an Esc during the backoff still spawns one more curl. + if (opts.signal.aborted) { + if (response !== null) return response; + throw failure; + } + } +} + +/** + * Whether this attempt earns a retry. + * + * A GET is replayed on any transient status or a timeout. A non-idempotent + * method (POST) is replayed only when the server sent an explicit invitation + * — a `Retry-After` alongside 429/503 — because the origin may already have + * processed a request whose response never arrived. A bare 502/504 or a + * timeout on a POST is therefore returned as-is rather than double-submitted. + * + * `method` is the method of the hop that actually ran, not the caller's: a + * 307/308 carries a POST forward, so replaying the chain would re-submit the + * body even though the request "started" as a redirect follow. + */ +function isRetryableAttempt( + method: HttpMethod, + response: GuardedCurlResponse | null, + failure: unknown, +): boolean { + const idempotent = IDEMPOTENT_METHODS.has(method); + + if (failure !== null) return idempotent; + if (response === null) return false; + if (!RETRYABLE_STATUSES.has(response.status)) return false; + if (idempotent) return true; + + return ( + INVITED_RETRY_STATUSES.has(response.status) && + response.retryAfterMs !== null + ); +} + +/** + * Delay before retry `attempt` (0-based): `retryBaseDelayMs * 2^attempt`, + * clamped to `retryMaxDelayMs`. A server-sent `Retry-After` wins over the + * computed delay — the origin knows its own recovery window — but is clamped + * the same way so a hostile value cannot park the agent. + */ +function httpBackoffDelayMs( + attempt: number, + retryAfterMs: number | null, + cfg: HttpRetryConfig, +): number { + const backoff = cfg.retryBaseDelayMs * 2 ** attempt; + const chosen = retryAfterMs !== null ? retryAfterMs : backoff; + return Math.min(cfg.retryMaxDelayMs, Math.max(0, chosen)); +} + +function defaultSleep(ms: number, signal: AbortSignal): Promise { + if (ms <= 0 || signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(finish, ms); + function finish(): void { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + +/** + * State that survives across retry attempts. + * + * Everything here is cumulative on purpose. Keeping it inside the per-attempt + * function meant a resumed retry rebuilt it from the resume point: the + * redirect chain lost its earlier hops, elapsed time under-reported, the + * redirect budget reset so a hostile origin got `MAX_REDIRECTS` *per attempt*, + * and — because it was only written back on the response path — a transport + * failure left `url`/`method` stale and rewound the retry to the caller's URL. + */ +interface RequestWalkState { + /** Where the next attempt starts: the last hop actually reached. */ + url: string; + /** Method of that hop. A 307/308 keeps a POST; a 301/302/303 drops to GET. */ + method: HttpMethod; + /** Body of that hop — `undefined` once a redirect has dropped it. */ + body: string | undefined; + /** Every hop visited across all attempts, in order. */ + chain: string[]; + /** Redirects followed across all attempts, against `MAX_REDIRECTS`. */ + redirects: number; + /** Summed curl time across all attempts. */ + totalTime: number; + /** Sticky once any attempt truncated the response. */ + truncated: boolean; +} + +/** One full request/redirect walk. A retry re-enters this from the top. */ +async function sendGuardedRequestOnce( + args: HttpRequestArgs, + opts: ExecuteGuardedHttpOptions, + state: RequestWalkState, ): Promise { const runCommand = opts.runCommand ?? defaultRunCommand; - let currentUrl = parseHttpUrl(rawUrl); - let method = args.method; - let body = args.body; - const chain: string[] = []; + let currentUrl = parseHttpUrl(state.url); + let method = state.method; + let body = state.body; + const chain = state.chain; let lastCommand: string[] = []; - let truncated = false; - let totalTime = 0; - for (let hop = 0; ; hop += 1) { - const pinnedIp = await assertHostAllowed(currentUrl, { + for (;;) { + const pinnedIps = await assertHostAllowed(currentUrl, { lookup: opts.lookup, }); const curlArgs = buildPinnedCurlArgs({ url: currentUrl, - pinnedIp, + pinnedIps, method, headers: args.headers, body, @@ -84,6 +293,13 @@ export async function executeGuardedHttpRequest( }); lastCommand = ["curl", ...curlArgs]; + // Record the hop we are about to make *before* issuing it, so a failure + // resumes here rather than rewinding to the caller's URL and re-walking + // redirects the origin has already served. + state.url = currentUrl.toString(); + state.method = method; + state.body = body; + let result: CommandResult; try { result = await runCommand("curl", curlArgs, { @@ -109,8 +325,8 @@ export async function executeGuardedHttpRequest( } const parsed = parseCurlOutput(result.stdout); - truncated = truncated || result.truncated; - totalTime += parsed.timeTotal; + state.truncated = state.truncated || result.truncated; + state.totalTime += parsed.timeTotal; chain.push(currentUrl.toString()); const shouldFollow = @@ -119,11 +335,14 @@ export async function executeGuardedHttpRequest( parsed.redirectUrl.length > 0; if (shouldFollow) { - if (hop >= MAX_REDIRECTS) { + // Cumulative across attempts: a per-attempt budget would let a hostile + // origin serve MAX_REDIRECTS hops on every retry. + if (state.redirects >= MAX_REDIRECTS) { throw new Error( `os.http.request: too many redirects (> ${MAX_REDIRECTS})`, ); } + state.redirects += 1; // Curl -L semantics: 301/302/303 drop to GET without body; 307/308 keep method+body. if (parsed.status === 301 || parsed.status === 302 || parsed.status === 303) { method = "GET"; @@ -139,9 +358,10 @@ export async function executeGuardedHttpRequest( contentType: parsed.contentType, body: parsed.body, sizeDownload: parsed.sizeDownload, - timeTotal: totalTime, - truncated, - redirectChain: chain, + timeTotal: state.totalTime, + truncated: state.truncated, + redirectChain: [...chain], + retryAfterMs: parseRetryAfterValueMs(parsed.retryAfter), command: lastCommand, }; } @@ -165,7 +385,7 @@ export { SsrfBlockedError }; interface BuildPinnedCurlArgs { url: URL; - pinnedIp: string; + pinnedIps: readonly string[]; method: HttpMethod; headers: Record; body: string | undefined; @@ -176,11 +396,11 @@ function buildPinnedCurlArgs(input: BuildPinnedCurlArgs): string[] { const host = input.url.hostname.replace(/^\[|\]$/g, ""); const port = input.url.port || (input.url.protocol === "https:" ? "443" : "80"); - const resolveTarget = input.pinnedIp.includes(":") - ? `[${input.pinnedIp}]` - : input.pinnedIp; const argv: string[] = [ "-sS", + // Send `[`, `]`, `{`, `}` in URLs literally. Without this curl reads them + // as its own range/set glob syntax and fails with "bad range in URL". + "--globoff", "--max-time", String(Math.ceil(input.timeoutMs / 1000)), // Hop-by-hop follow is owned by executeGuardedHttpRequest so each @@ -188,7 +408,7 @@ function buildPinnedCurlArgs(input: BuildPinnedCurlArgs): string[] { "--max-redirs", "0", "--resolve", - `${host}:${port}:${resolveTarget}`, + formatResolveEntry(host, port, input.pinnedIps), ]; if (input.method !== "GET") argv.push("-X", input.method); for (const [key, value] of Object.entries(input.headers)) { @@ -199,7 +419,13 @@ function buildPinnedCurlArgs(input: BuildPinnedCurlArgs): string[] { } argv.push( "-w", - `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{size_download}|%{time_total}|%{redirect_url}`, + `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{size_download}|` + + // The two origin-controlled fields are separated from each other + // by a sentinel rather than a pipe, so each may contain `|` + // freely: everything between the fourth pipe and the sentinel is + // the URL, everything after the sentinel is the header. See + // CURL_RETRY_AFTER_MARKER. + `%{time_total}|%{redirect_url}${CURL_RETRY_AFTER_MARKER}%header{retry-after}`, ); argv.push("--", input.url.toString()); return argv; @@ -212,6 +438,8 @@ export interface CurlParsedOutput { sizeDownload: number; timeTotal: number; redirectUrl: string; + /** Raw `Retry-After` header, `""` when absent or unsupported by curl. */ + retryAfter: string; } export function parseCurlOutput(stdout: string): CurlParsedOutput { @@ -224,20 +452,35 @@ export function parseCurlOutput(stdout: string): CurlParsedOutput { sizeDownload: stdout.length, timeTotal: 0, redirectUrl: "", + retryAfter: "", }; } const body = stdout.slice(0, markerIdx).replace(/\n$/, ""); const meta = stdout.slice(markerIdx + CURL_META_MARKER.length).trim(); + // The sentinel splits the two origin-controlled fields; `indexOf`, so + // a sentinel forged inside the *URL* can only corrupt the header + // (which is then parsed as a number or a date and dropped) rather + // than the URL the request is about to be checked against. + const raIdx = meta.indexOf(CURL_RETRY_AFTER_MARKER); + const head = raIdx === -1 ? meta : meta.slice(0, raIdx); + const retryAfterRaw = + raIdx === -1 ? "" : meta.slice(raIdx + CURL_RETRY_AFTER_MARKER.length); + // Only the fixed leading fields are pipe-delimited; the remainder of + // `head` is the URL, pipes and all. const [ statusStr = "", contentType = "", sizeStr = "", timeStr = "", - redirectUrl = "", - ] = meta.split("|"); + ...redirectRest + ] = head.split("|"); + const redirectUrl = redirectRest.join("|"); const status = Number.parseInt(statusStr, 10); const sizeDownload = Number.parseInt(sizeStr, 10); const timeTotal = Number.parseFloat(timeStr); + // `%header{}` is curl >= 7.83; older curl emits the literal format string, + // which must not be mistaken for a header value. + const retryAfter = retryAfterRaw.trim(); return { body, status: Number.isFinite(status) ? status : 0, @@ -245,6 +488,7 @@ export function parseCurlOutput(stdout: string): CurlParsedOutput { sizeDownload: Number.isFinite(sizeDownload) ? sizeDownload : body.length, timeTotal: Number.isFinite(timeTotal) ? timeTotal : 0, redirectUrl: redirectUrl.trim(), + retryAfter: retryAfter.startsWith("%header{") ? "" : retryAfter, }; } diff --git a/src/tools/os/http-request-retry.test.ts b/src/tools/os/http-request-retry.test.ts new file mode 100644 index 00000000..6220ec9b --- /dev/null +++ b/src/tools/os/http-request-retry.test.ts @@ -0,0 +1,559 @@ +import { describe, expect, it } from "vitest"; + +import type { + CommandResult, + runCommand as RunCommandType, +} from "../../sandbox/command-runner.js"; +import { executeGuardedHttpRequest } from "./http-request-fetch.js"; +import type { HostLookup } from "./web-fetch-ssrf-guard.js"; + +const publicLookup: HostLookup = async () => [ + { address: "93.184.216.34", family: 4 }, +]; + +/** + * Curl stdout envelope, optionally carrying a Retry-After header value and a + * redirect target. Field order mirrors the `-w` format: redirect_url is last. + */ +function stubStdout( + status: number, + retryAfter = "", + redirectUrl = "", +): string { + return ( + `body\n__ATOMIC_CURL_META__${status}|text/plain|4|0.01|` + + `${redirectUrl}__ATOMIC_CURL_RA__${retryAfter}` + ); +} + +function makeResult(overrides: Partial): CommandResult { + return { + command: "curl", + args: [], + exitCode: 0, + signal: null, + stdout: "", + stderr: "", + durationMs: 1, + timedOut: false, + truncated: false, + inputTruncated: false, + ...overrides, + }; +} + +/** Replays the given results in order, one per curl invocation. */ +function scriptedRunCommand( + results: CommandResult[], + calls: string[][], +): typeof RunCommandType { + return (async (_command: string, args: string[]) => { + const result = results[calls.length] ?? results.at(-1)!; + calls.push(args); + return result; + }) as unknown as typeof RunCommandType; +} + +function run( + input: { + method?: "GET" | "POST"; + results: CommandResult[]; + calls: string[][]; + slept: number[]; + maxRetries?: number; + }, +) { + return executeGuardedHttpRequest( + "https://api.example/v1", + { + method: input.method ?? "GET", + headers: {}, + body: input.method === "POST" ? "{}" : undefined, + timeoutMs: 1000, + followRedirects: false, + }, + { + runCommand: scriptedRunCommand(input.results, input.calls), + lookup: publicLookup, + cwd: "/tmp", + signal: new AbortController().signal, + maxResponseBytes: 100_000, + ...(input.maxRetries !== undefined + ? { + retry: { + maxRetries: input.maxRetries, + retryBaseDelayMs: 500, + retryMaxDelayMs: 5_000, + }, + } + : {}), + sleep: async (ms: number) => { + input.slept.push(ms); + }, + }, + ); +} + +describe("os.http.request retries", () => { + it("retries a 429 GET and returns the eventual success", async () => { + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + results: [ + makeResult({ stdout: stubStdout(429) }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + slept, + }); + + expect(response.status).toBe(200); + expect(calls).toHaveLength(2); + expect(slept).toEqual([500]); + }); + + it("retries 502/503/504 as well", async () => { + for (const status of [502, 503, 504]) { + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + results: [ + makeResult({ stdout: stubStdout(status) }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + slept, + }); + + expect(response.status).toBe(200); + expect(calls).toHaveLength(2); + } + }); + + it("honours Retry-After over its own backoff schedule", async () => { + const calls: string[][] = []; + const slept: number[] = []; + await run({ + results: [ + makeResult({ stdout: stubStdout(429, "2") }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + slept, + }); + + expect(slept).toEqual([2000]); + }); + + it("clamps a hostile Retry-After to the max delay", async () => { + const calls: string[][] = []; + const slept: number[] = []; + await run({ + results: [ + makeResult({ stdout: stubStdout(429, "3600") }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + slept, + }); + + expect(slept).toEqual([5000]); + }); + + it("gives up after maxRetries and returns the last response", async () => { + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + results: [makeResult({ stdout: stubStdout(503) })], + calls, + slept, + }); + + expect(response.status).toBe(503); + expect(calls).toHaveLength(3); // initial + 2 retries + expect(slept).toEqual([500, 1000]); + }); + + it("does not retry a stable 4xx", async () => { + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + results: [makeResult({ stdout: stubStdout(404) })], + calls, + slept, + }); + + expect(response.status).toBe(404); + expect(calls).toHaveLength(1); + expect(slept).toEqual([]); + }); + + it("can be disabled with maxRetries: 0", async () => { + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + results: [makeResult({ stdout: stubStdout(429) })], + calls, + slept, + maxRetries: 0, + }); + + expect(response.status).toBe(429); + expect(calls).toHaveLength(1); + }); + + it("retries a curl timeout on GET", async () => { + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + results: [ + makeResult({ exitCode: 28, stderr: "timed out", timedOut: true }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + slept, + }); + + expect(response.status).toBe(200); + expect(calls).toHaveLength(2); + }); + + it("does not retry a non-timeout curl failure", async () => { + const calls: string[][] = []; + const slept: number[] = []; + await expect( + run({ + results: [makeResult({ exitCode: 6, stderr: "could not resolve host" })], + calls, + slept, + }), + ).rejects.toThrow(/could not resolve host/); + + expect(calls).toHaveLength(1); + }); +}); + +describe("os.http.request retry safety for non-idempotent methods", () => { + it("does NOT replay a POST on a bare 503 (no Retry-After)", async () => { + // The origin may already have processed the request; replaying it blindly + // would risk a double submit. + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + method: "POST", + results: [makeResult({ stdout: stubStdout(503) })], + calls, + slept, + }); + + expect(response.status).toBe(503); + expect(calls).toHaveLength(1); + expect(slept).toEqual([]); + }); + + it("does NOT replay a POST on a curl timeout", async () => { + const calls: string[][] = []; + const slept: number[] = []; + await expect( + run({ + method: "POST", + results: [makeResult({ exitCode: 28, stderr: "timed out", timedOut: true })], + calls, + slept, + }), + ).rejects.toThrow(/timed out/); + + expect(calls).toHaveLength(1); + }); + + it("DOES replay a POST when the server invites it with Retry-After", async () => { + // 429 + Retry-After is an explicit "I did not process this, come back". + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + method: "POST", + results: [ + makeResult({ stdout: stubStdout(429, "1") }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + slept, + }); + + expect(response.status).toBe(200); + expect(calls).toHaveLength(2); + expect(slept).toEqual([1000]); + }); + + it("does NOT replay a POST on 502 even with Retry-After", async () => { + // 502/504 do not carry the same "not processed" guarantee as 429/503. + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + method: "POST", + results: [makeResult({ stdout: stubStdout(502, "1") })], + calls, + slept, + }); + + expect(response.status).toBe(502); + expect(calls).toHaveLength(1); + }); +}); + +describe("os.http.request retry safety across redirects", () => { + /** Drives a full redirect-following request with a scripted curl. */ + function runRedirecting(input: { + method: "GET" | "POST"; + results: CommandResult[]; + calls: string[][]; + signal?: AbortSignal; + sleep?: (ms: number) => Promise; + }) { + return executeGuardedHttpRequest( + "https://api.example/submit", + { + method: input.method, + headers: {}, + body: input.method === "POST" ? "order=1" : undefined, + timeoutMs: 1000, + followRedirects: true, + }, + { + runCommand: scriptedRunCommand(input.results, input.calls), + lookup: publicLookup, + cwd: "/tmp", + signal: input.signal ?? new AbortController().signal, + maxResponseBytes: 100_000, + sleep: input.sleep ?? (async () => {}), + }, + ); + } + + it("does NOT replay a POST carried through a 307 redirect", async () => { + // 307 preserves method and body, so the hop that hit 502 is still a POST + // carrying `order=1`. A bare 502 is not an invitation to come back, so the + // body must not be re-submitted even though the request began as a + // redirect follow rather than a direct POST. + const calls: string[][] = []; + const response = await runRedirecting({ + method: "POST", + results: [ + makeResult({ stdout: stubStdout(307, "", "https://api.example/b") }), + makeResult({ stdout: stubStdout(502) }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + }); + + expect(response.status).toBe(502); + // Two hops: the original POST and the 307 follow. No third attempt. + expect(calls).toHaveLength(2); + expect( + calls.filter((a) => a.includes("--data-binary")), + ).toHaveLength(2); + }); + + it("DOES retry a GET that a 303 downgraded it to", async () => { + // The POST was accepted and answered with 303; the follow-up GET is + // idempotent, so a 429 on it is safe to replay. + const calls: string[][] = []; + const response = await runRedirecting({ + method: "POST", + results: [ + makeResult({ + stdout: stubStdout(303, "", "https://api.example/result"), + }), + makeResult({ stdout: stubStdout(429) }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + }); + + expect(response.status).toBe(200); + // Three hops: the POST, the downgraded GET that hit 429, and its replay. + expect(calls).toHaveLength(3); + // Only the first hop carries the body; the retried GET must not. + expect(calls.filter((a) => a.includes("--data-binary"))).toHaveLength(1); + }); +}); + +describe("os.http.request abort handling", () => { + it("does not issue another request when abort fires during backoff", async () => { + const calls: string[][] = []; + const controller = new AbortController(); + const response = await executeGuardedHttpRequest( + "https://api.example/v1", + { + method: "GET", + headers: {}, + timeoutMs: 1000, + followRedirects: false, + }, + { + runCommand: scriptedRunCommand( + [makeResult({ stdout: stubStdout(429) })], + calls, + ), + lookup: publicLookup, + cwd: "/tmp", + signal: controller.signal, + maxResponseBytes: 100_000, + // Abort mid-wait, as pressing Esc during the backoff would. + sleep: async () => { + controller.abort(); + }, + }, + ); + + expect(calls).toHaveLength(1); + expect(response.status).toBe(429); + }); +}); + +describe("curl metadata parsing", () => { + it("keeps a literal pipe inside redirect_url out of Retry-After", async () => { + const calls: string[][] = []; + const slept: number[] = []; + const response = await run({ + results: [ + makeResult({ + stdout: stubStdout(429, "120", "https://x.test/a|b"), + }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + slept, + }); + + expect(response.status).toBe(200); + // Retry-After must be read as 120s (clamped to the 5s cap), not as the + // fragment of a URL that happened to follow a pipe. + expect(slept).toEqual([5_000]); + }); +}); + +describe("os.http.request retry state across attempts", () => { + /** Drives a redirect-following request with a scripted curl. */ + function runWalk(input: { + method: "GET" | "POST"; + results: CommandResult[]; + calls: string[][]; + }) { + return executeGuardedHttpRequest( + "https://a.example/submit", + { + method: input.method, + headers: {}, + body: input.method === "POST" ? "order=1" : undefined, + timeoutMs: 1000, + followRedirects: true, + }, + { + runCommand: scriptedRunCommand(input.results, input.calls), + lookup: publicLookup, + cwd: "/tmp", + signal: new AbortController().signal, + maxResponseBytes: 100_000, + sleep: async () => {}, + }, + ); + } + + /** The URL each curl invocation targeted, in order. */ + function hosts(calls: string[][]): string[] { + return calls.map((a) => a[a.length - 1]!); + } + + it("a timeout resumes at the failed hop instead of rewinding", async () => { + // The resume point was only advanced on the response path, so a timeout + // left it stale and the retry re-walked the whole chain from the caller's + // URL — re-issuing redirects the origin had already served. + const calls: string[][] = []; + await expect( + runWalk({ + method: "GET", + results: [ + makeResult({ stdout: stubStdout(302, "", "https://b.example/") }), + makeResult({ stdout: stubStdout(302, "", "https://c.example/") }), + makeResult({ exitCode: 28, stderr: "timed out", timedOut: true }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + }), + ).resolves.toMatchObject({ status: 200 }); + + expect(hosts(calls)).toEqual([ + "https://a.example/submit", + "https://b.example/", + "https://c.example/", + "https://c.example/", + ]); + }); + + it("a GET a 303 downgraded to keeps its retry on a timeout", async () => { + // On a failure the method fell back to the caller's, so this bodyless GET + // was judged as the original POST and denied its replay. + const calls: string[][] = []; + const response = await runWalk({ + method: "POST", + results: [ + makeResult({ stdout: stubStdout(303, "", "https://r.example/") }), + makeResult({ exitCode: 28, stderr: "timed out", timedOut: true }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + }); + + expect(response.status).toBe(200); + expect(calls).toHaveLength(3); + // The body is sent once, by the original POST, and never replayed. + expect(calls.filter((a) => a.includes("--data-binary"))).toHaveLength(1); + }); + + it("redirectChain and timeTotal cover every attempt", async () => { + const calls: string[][] = []; + const response = await runWalk({ + method: "GET", + results: [ + makeResult({ stdout: stubStdout(302, "", "https://b.example/") }), + makeResult({ stdout: stubStdout(429) }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + }); + + // Three hops were really made, including the retried one; a chain rebuilt + // per attempt reported only the last. + expect(response.redirectChain).toEqual([ + "https://a.example/submit", + "https://b.example/", + "https://b.example/", + ]); + expect(response.timeTotal).toBeCloseTo(0.03); + }); + + it("the redirect budget is cumulative, not per attempt", async () => { + // A per-attempt budget let a hostile origin serve MAX_REDIRECTS hops on + // every retry — 18 curl invocations against a limit of 5. + const calls: string[][] = []; + await expect( + runWalk({ + method: "GET", + results: [ + ...Array.from({ length: 5 }, (_, i) => + makeResult({ + stdout: stubStdout(302, "", `https://h${i}.example/`), + }), + ), + makeResult({ stdout: stubStdout(429) }), + makeResult({ stdout: stubStdout(200) }), + ], + calls, + }), + ).resolves.toMatchObject({ status: 200 }); + + // 6 hops to exhaust the budget + 1 resumed retry. Never a fresh budget. + expect(calls.length).toBeLessThanOrEqual(8); + }); +}); diff --git a/src/tools/os/http-request.test.ts b/src/tools/os/http-request.test.ts index 0648e2d0..c5be617e 100644 --- a/src/tools/os/http-request.test.ts +++ b/src/tools/os/http-request.test.ts @@ -35,6 +35,7 @@ function makeCommandResult( durationMs: 1, timedOut: false, truncated: false, + inputTruncated: false, ...overrides, }; } @@ -99,8 +100,15 @@ function meta( size: number, time = 0.01, redirectUrl = "", + retryAfter = "", ): string { - return `__ATOMIC_CURL_META__${status}|${contentType}|${size}|${time}|${redirectUrl}`; + // Field order mirrors the `-w` format: the two origin-controlled + // fields are separated from each other by a sentinel rather than a + // pipe, so either may contain a literal `|`. + return ( + `__ATOMIC_CURL_META__${status}|${contentType}|${size}|${time}` + + `|${redirectUrl}__ATOMIC_CURL_RA__${retryAfter}` + ); } describe("hostAllowed", () => { @@ -633,6 +641,28 @@ describe("os.http.request", () => { expect(capture.args).not.toContain("-L"); }); + it("passes --globoff so bracketed URLs are not read as curl ranges", async () => { + // Real failure from the field: without --globoff curl rejects the + // `[Dd]` character set with "curl: (3) bad range in URL position 158". + const url = + "http://web.archive.org/cdx/search/cdx?url=base-search.net" + + "&matchType=domain&filter=original:.*[Dd]ewey.*&collapse=urlkey"; + const capture: { cmd?: string; args?: string[] } = {}; + const tool = buildOsHttpRequestTool({ + lookup: publicLookup, + approvals: approveAll(), + approvalRequired: false, + config: makeHttpConfig({ approvalMode: "never" }), + runCommand: fakeRun(capture, { + stdout: "ok\n" + meta(200, "text/plain", 2), + }), + }); + await tool.run({ url }, makeCtx()); + expect(capture.args).toContain("--globoff"); + // The bracketed URL still reaches curl verbatim as the final operand. + expect(capture.args![capture.args!.length - 1]).toContain("[Dd]ewey"); + }); + it("re-validates redirect hops and blocks a private Location target", async () => { const calls: string[][] = []; const runCommand = async ( diff --git a/src/tools/os/http-request.ts b/src/tools/os/http-request.ts index aebe8007..4596b250 100644 --- a/src/tools/os/http-request.ts +++ b/src/tools/os/http-request.ts @@ -55,7 +55,7 @@ export function buildOsHttpRequestTool( return { name: "os.http.request", description: - "Raw HTTP GET or POST via the system `curl` binary for APIs and machine-readable endpoints (JSON, XML, plain text). Returns the response body verbatim — no HTML extraction or cleanup. To read a human web page as markdown/text, use `os.web.fetch` instead. Blocks private/internal addresses (SSRF) like `os.web.fetch`, pins DNS with curl `--resolve`, and re-validates each redirect hop. Host allowlist and approval policy come from `config.http`. Body is capped at `config.http.maxResponseBytes`.", + "Raw HTTP GET or POST via the system `curl` binary for APIs and machine-readable endpoints (JSON, XML, plain text). Returns the response body verbatim — no HTML extraction or cleanup. To read a human web page as markdown/text, use `os.web.fetch` instead. Blocks private/internal addresses (SSRF) like `os.web.fetch`, pins DNS with curl `--resolve`, and re-validates each redirect hop. Host allowlist and approval policy come from `config.http`. Body is capped at `config.http.maxResponseBytes`. Retries transient failures (429/502/503/504 and connection timeouts) with exponential backoff, honouring `Retry-After`; a POST is only retried when the server explicitly invites it, so a request is never double-submitted.", readonly: false, async run(rawArgs, ctx) { const httpCfg = options.config.http; diff --git a/src/tools/os/index.ts b/src/tools/os/index.ts index f2b6d017..cfaceb5a 100644 --- a/src/tools/os/index.ts +++ b/src/tools/os/index.ts @@ -132,7 +132,7 @@ export function registerOsTools( }), ); registry.register(buildOsWebSearchTool({ config: options.config })); - registry.register(buildOsWebFetchTool()); + registry.register(buildOsWebFetchTool({ config: options.config })); registry.register(osClipboardReadTool); registry.register(osClipboardWriteTool); registry.register(osWindowListTool); diff --git a/src/tools/os/read-document/extractors/pdf-extractor.canvas-warnings.test.ts b/src/tools/os/read-document/extractors/pdf-extractor.canvas-warnings.test.ts new file mode 100644 index 00000000..9abc356f --- /dev/null +++ b/src/tools/os/read-document/extractors/pdf-extractor.canvas-warnings.test.ts @@ -0,0 +1,318 @@ +import { execFile } from "node:child_process"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); + +/** + * Regression test for issue #117. + * + * pdfjs-dist warns at *import time* when its optional `@napi-rs/canvas` + * dependency is missing. Those warnings look like extraction failures even + * though text-only extraction is unaffected. They cannot be reproduced inside + * the vitest process: pdfjs is very likely already imported (module cache), and + * a normal dev install has `@napi-rs/canvas` present, which hides the symptom + * entirely. + * + * So this test builds a throwaway package tree that resolves `pdfjs-dist` but + * *not* `@napi-rs/canvas` — the shape produced by `npm ci --omit=optional` and + * by our SEA bundle — and runs the extractor in a fresh child process. + */ + +const KNOWN_CANVAS_WARNINGS = [ + 'Cannot load "@napi-rs/canvas" package', + "Cannot polyfill `DOMMatrix`", + "Cannot polyfill `ImageData`", + "Cannot polyfill `Path2D`", +]; + +const TEXT_MARKER = "ATOMIC_PDF_MARKER"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "../../../../.."); + +/** A tiny single-page PDF with uncompressed, extractable text. */ +function buildPdf(): string { + const content = `BT /F1 24 Tf 72 700 Td (${TEXT_MARKER}) Tj ET`; + const objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R " + + "/Resources << /Font << /F1 5 0 R >> >> >>", + `<< /Length ${content.length} >>\nstream\n${content}\nendstream`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]; + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + objs.forEach((obj, i) => { + offsets.push(pdf.length); + pdf += `${i + 1} 0 obj\n${obj}\nendobj\n`; + }); + const xref = pdf.length; + pdf += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`; + for (const off of offsets) { + pdf += `${String(off).padStart(10, "0")} 00000 n \n`; + } + pdf += + `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\n` + + `startxref\n${xref}\n%%EOF\n`; + return pdf; +} + +/** + * Build a sandbox whose `node_modules` contains only `pdfjs-dist`, with + * `@napi-rs/canvas` deliberately absent — the shape of `npm ci --omit=optional` + * and of our SEA bundle. + * + * pdfjs must be *copied*, not symlinked. It resolves the optional canvas + * package via `createRequire(import.meta.url)`, and `import.meta.url` points at + * the module's real path — Node resolves symlinks by default. A symlinked + * pdfjs would therefore walk up the *real* `node_modules` and find the canvas + * package that a dev machine has installed, silently defeating the isolation. + */ +async function makeCanvasFreeSandbox(): Promise { + const dir = await mkdtemp(join(tmpdir(), "atomic-pdf-nocanvas-")); + const nodeModules = join(dir, "node_modules"); + const require = createRequire(import.meta.url); + const pdfjsRoot = dirname(require.resolve("pdfjs-dist/package.json")); + + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "sandbox", private: true, type: "module" }), + ); + await mkdir(nodeModules, { recursive: true }); + // Skip pdfjs's own nested deps and the browser-only `web/` viewer. The + // filter receives absolute *source* paths, so compare against the portion + // below `pdfjsRoot` — matching on the absolute path would reject the copy + // root itself (it already sits inside a `node_modules` directory). + await cp(pdfjsRoot, join(nodeModules, "pdfjs-dist"), { + recursive: true, + dereference: true, + filter: (src) => { + const rel = src.slice(pdfjsRoot.length); + return !rel.startsWith(`${sep}node_modules`) && !rel.startsWith(`${sep}web`); + }, + }); + return dir; +} + +/** + * The extractor's loading strategy, mirrored as standalone source so it can run + * in the sandbox without pulling in the whole TypeScript build. `applyFix` + * toggles the fix, which lets the same harness prove the test actually detects + * the bug (ablation) rather than passing vacuously. + */ +function sandboxScript(pdfPath: string, applyFix: boolean): string { + return ` +import Module from "node:module"; +import fs from "node:fs"; + +const CANVAS_PACKAGE = "@napi-rs/canvas"; +const CANVAS_STUB = { + DOMMatrix: class {}, ImageData: class {}, Path2D: class {}, +}; + +async function load() { + return import("pdfjs-dist/legacy/build/pdf.mjs"); +} + +let pdfjs; +if (${applyFix ? "true" : "false"}) { + const original = Module._load; + Module._load = function (...args) { + if (args[0] === CANVAS_PACKAGE) return CANVAS_STUB; + return original.apply(this, args); + }; + try { pdfjs = await load(); } finally { Module._load = original; } +} else { + pdfjs = await load(); +} + +globalThis.pdfjsWorker = await import("pdfjs-dist/legacy/build/pdf.worker.mjs"); + +const doc = await pdfjs.getDocument({ + data: new Uint8Array(fs.readFileSync(${JSON.stringify(pdfPath)})), + disableFontFace: true, + useSystemFonts: false, + isEvalSupported: false, + verbosity: 0, +}).promise; + +let text = ""; +for (let p = 1; p <= doc.numPages; p++) { + const c = await (await doc.getPage(p)).getTextContent(); + text += c.items.map((i) => i.str).join(""); +} +await doc.destroy(); +process.stdout.write("EXTRACTED:" + text + "\\n"); +`; +} + +async function runInSandbox( + applyFix: boolean, +): Promise<{ stdout: string; stderr: string }> { + const dir = await makeCanvasFreeSandbox(); + try { + const pdfPath = join(dir, "sample.pdf"); + await writeFile(pdfPath, buildPdf(), "latin1"); + const scriptPath = join(dir, "run.mjs"); + await writeFile(scriptPath, sandboxScript(pdfPath, applyFix)); + return await execFileAsync(process.execPath, [scriptPath], { + cwd: dir, + encoding: "utf8", + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe("pdf extractor: optional canvas warnings (issue #117)", () => { + it("sanity-checks that the sandbox really lacks @napi-rs/canvas", async () => { + const dir = await makeCanvasFreeSandbox(); + try { + // Probe from inside the copied pdfjs build — that is where pdfjs itself + // resolves the optional package from. Probing at the sandbox root would + // pass even if a symlinked pdfjs could still reach the real install. + const probe = join( + dir, + "node_modules", + "pdfjs-dist", + "legacy", + "build", + "probe.mjs", + ); + await writeFile( + probe, + 'import { createRequire } from "node:module";\n' + + "try {\n" + + ' createRequire(import.meta.url).resolve("@napi-rs/canvas");\n' + + ' process.stdout.write("RESOLVED");\n' + + '} catch { process.stdout.write("ABSENT"); }\n', + ); + const { stdout } = await execFileAsync(process.execPath, [probe], { + cwd: dir, + encoding: "utf8", + }); + expect(stdout).toBe("ABSENT"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 60_000); + + it("emits the canvas warnings without the fix (ablation)", async () => { + const { stdout, stderr } = await runInSandbox(false); + // Guard against a vacuous pass: the ablation must still extract text. + expect(stdout).toContain(`EXTRACTED:${TEXT_MARKER}`); + const combined = stdout + stderr; + for (const warning of KNOWN_CANVAS_WARNINGS) { + expect(combined).toContain(warning); + } + }, 60_000); + + it("emits no canvas warnings with the fix, and still extracts text", async () => { + const { stdout, stderr } = await runInSandbox(true); + expect(stdout).toContain(`EXTRACTED:${TEXT_MARKER}`); + const combined = stdout + stderr; + for (const warning of KNOWN_CANVAS_WARNINGS) { + expect(combined).not.toContain(warning); + } + }, 60_000); + + it("does not intercept console anywhere in the extractor", async () => { + const { readFile } = await import("node:fs/promises"); + const source = await readFile(join(here, "pdf-extractor.ts"), "utf8"); + expect(source).not.toMatch(/console\s*\.\s*(log|warn|error)\s*=/); + }); + + it("leaves Module._load restored after loading pdfjs", async () => { + const Module = (await import("node:module")).default as unknown as { + _load: unknown; + }; + const before = Module._load; + const { pdfExtractor } = await import("./pdf-extractor.js"); + const data = Buffer.from(buildPdf(), "latin1"); + const result = await pdfExtractor({ + data, + path: join(repoRoot, "sample.pdf"), + } as Parameters[0]); + expect(result.text).toContain(TEXT_MARKER); + expect(Module._load).toBe(before); + }, 60_000); + + it("the real extractor stays quiet in this install (gates the CI matrix)", async () => { + // The sandbox tests above build their own canvas-free node_modules, so + // they answer the same way whether or not the outer install has + // `@napi-rs/canvas` — which left the "no canvas" CI job unable to fail + // for the reason it exists. This one runs the *real* extractor against + // the *real* install: it passes when canvas is present (the quiet path is + // skipped) and fails when canvas is absent and the fix is broken. That + // difference is the signal the matrix is built to carry. + // + // It must run in a fresh process. pdfjs emits these warnings once, at + // import time, via `console.log` — by the time an in-process test could + // install a spy, an earlier test in the same file has already imported + // the module and the warnings are long gone. + const pdfPath = join(tmpdir(), `canvas-gate-${process.pid}.pdf`); + const scriptPath = join(tmpdir(), `canvas-gate-${process.pid}.mjs`); + await writeFile(pdfPath, buildPdf(), "latin1"); + await writeFile( + scriptPath, + [ + 'import { readFile } from "node:fs/promises";', + `const { pdfExtractor } = await import(${JSON.stringify( + pathToFileURL(join(here, "pdf-extractor.ts")).href, + )});`, + `const data = await readFile(${JSON.stringify(pdfPath)});`, + `const result = await pdfExtractor({ data, path: ${JSON.stringify( + pdfPath, + )} });`, + // Generous slice: the text opens with a `--- page 1 ---` header, and a + // tighter bound would cut the marker off if that header ever grows. + 'process.stdout.write("\\nEXTRACTED:" + result.text.slice(0, 200));', + ].join("\n"), + ); + + try { + // The warnings are emitted at import time, before extraction, so they + // are in the buffer even when the child later fails. `execFileAsync` + // rejects on a non-zero exit, which would skip the assertions entirely + // and report "Command failed: … canvas-gate.mjs" instead of naming the + // regression — so read the output off the rejection too. + let output: string; + try { + const { stdout, stderr } = await execFileAsync( + process.execPath, + ["--import", "tsx", scriptPath], + { cwd: repoRoot, encoding: "utf8" }, + ); + output = `${stdout}\n${stderr}`; + } catch (err) { + const failed = err as { stdout?: string; stderr?: string }; + output = `${failed.stdout ?? ""}\n${failed.stderr ?? ""}`; + for (const warning of KNOWN_CANVAS_WARNINGS) { + expect(output).not.toContain(warning); + } + throw err; + } + + // Guard against a vacuous pass: `EXTRACTED:` alone prints even when the + // extractor returns empty text, so assert the marker came through. The + // extracted text opens with a `--- page 1 ---` header, so the marker + // follows the prefix rather than sitting flush against it. + expect(output).toContain("EXTRACTED:"); + expect(output).toContain(TEXT_MARKER); + for (const warning of KNOWN_CANVAS_WARNINGS) { + expect(output).not.toContain(warning); + } + } finally { + await rm(pdfPath, { force: true }); + await rm(scriptPath, { force: true }); + } + }, 60_000); +}); diff --git a/src/tools/os/read-document/extractors/pdf-extractor.ts b/src/tools/os/read-document/extractors/pdf-extractor.ts index 278a2209..89ba2218 100644 --- a/src/tools/os/read-document/extractors/pdf-extractor.ts +++ b/src/tools/os/read-document/extractors/pdf-extractor.ts @@ -1,3 +1,5 @@ +import Module, { createRequire } from "node:module"; + import type { Extractor, ExtractResult } from "./extractor-types.js"; /** @@ -22,6 +24,8 @@ export const pdfExtractor: Extractor = async (input) => { isEvalSupported: false, // 0 = ERRORS only. Suppresses the cosmetic "standardFontDataUrl not // provided" warning — we don't render fonts, just extract glyph runs. + // Note this only governs per-document warnings; import-time warnings are + // handled by `withQuietCanvasResolution` below. verbosity: 0, }).promise; @@ -143,16 +147,106 @@ function clampPageRange( * resolver handles it from `node_modules` in dev — and stash the namespace * on `globalThis.pdfjsWorker`, which works identically in both runtimes. */ -let pdfJsModule: typeof import("pdfjs-dist/legacy/build/pdf.mjs") | undefined; -async function loadPdfJs(): Promise< +let pdfJsPromise: + | Promise + | undefined; +function loadPdfJs(): Promise< typeof import("pdfjs-dist/legacy/build/pdf.mjs") > { - if (!pdfJsModule) { - const mod = await import("pdfjs-dist/legacy/build/pdf.mjs"); + // Memoize the *promise*, not the resolved module. Two concurrent + // read_document calls must share one import — otherwise both could enter + // the canvas shim below and the second would restore `Module._load` while + // the first is still importing. + pdfJsPromise ??= (async () => { + const mod = await withQuietCanvasResolution( + () => import("pdfjs-dist/legacy/build/pdf.mjs"), + ); await ensurePdfWorkerOnMainThread(); - pdfJsModule = mod; + return mod; + })(); + return pdfJsPromise; +} + +/** + * pdfjs's `node_utils` module tries to `require("@napi-rs/canvas")` at import + * time to polyfill `DOMMatrix`/`ImageData`/`Path2D`. That package is an + * *optional* dependency of pdfjs-dist and is deliberately not shipped in our + * SEA bundle, nor installed by `npm ci --omit=optional`. When it is absent + * pdfjs prints four warnings straight to stdout: + * + * Warning: Cannot load "@napi-rs/canvas" package: ... + * Warning: Cannot polyfill `DOMMatrix`, rendering may be broken. + * Warning: Cannot polyfill `ImageData`, rendering may be broken. + * Warning: Cannot polyfill `Path2D`, rendering may be broken. + * + * They are non-actionable for us: those three globals only matter when + * *rendering* pages to a canvas, and this extractor only ever reads glyph + * runs via `getTextContent()`. But they read like extraction failures. + * + * `verbosity: 0` on `getDocument` cannot suppress them — pdfjs keeps + * verbosity in a module-level variable, and the warnings above are emitted by + * top-level code while the module is still being imported, long before any + * per-document option applies. `setVerbosityLevel` is not exported, so there + * is no public API to mute it ahead of the import either. + * + * So we satisfy the require instead of silencing the complaint: hand pdfjs a + * stub carrying the three constructors it looks for. Scoped deliberately: + * + * - Only the exact `@napi-rs/canvas` specifier is intercepted; every other + * request falls through to the real loader untouched. + * - `Module._load` is restored in a `finally`, so a failed import cannot + * leave the hook installed. + * - We never touch `console` — global console interception would hide + * unrelated diagnostics and is unsafe under concurrent reads. + * + * If the real `@napi-rs/canvas` *is* installed we leave it alone, so a normal + * npm install keeps genuine canvas support. + */ +async function withQuietCanvasResolution(load: () => Promise): Promise { + if (canvasPackageIsInstalled()) return load(); + + // `Module._load` is a private Node API, but it is the only interception + // point for the `createRequire(...)` call pdfjs makes internally. It has + // been stable across Node's entire CJS lifetime; if it ever disappears we + // fall back to loading normally (noisy, but never broken). + const nodeModule = Module as unknown as { + _load?: (...args: unknown[]) => unknown; + }; + const originalLoad = nodeModule._load; + if (typeof originalLoad !== "function") return load(); + + nodeModule._load = function patchedLoad(...args: unknown[]) { + if (args[0] === CANVAS_PACKAGE) return CANVAS_STUB; + return originalLoad.apply(this, args); + }; + try { + return await load(); + } finally { + nodeModule._load = originalLoad; + } +} + +const CANVAS_PACKAGE = "@napi-rs/canvas"; + +/** + * Minimal stand-ins for the three constructors pdfjs copies onto `globalThis`. + * Text extraction never instantiates them — pdfjs only reaches for them on + * rendering paths we do not use. They exist so the polyfill block finds + * something and stays quiet. + */ +const CANVAS_STUB = { + DOMMatrix: class DOMMatrixStub {}, + ImageData: class ImageDataStub {}, + Path2D: class Path2DStub {}, +}; + +function canvasPackageIsInstalled(): boolean { + try { + createRequire(import.meta.url).resolve(CANVAS_PACKAGE); + return true; + } catch { + return false; } - return pdfJsModule; } // pdfjs-dist does not ship `.d.ts` declarations for the worker entry point diff --git a/src/tools/os/retry-after-header.test.ts b/src/tools/os/retry-after-header.test.ts new file mode 100644 index 00000000..dce2fa5c --- /dev/null +++ b/src/tools/os/retry-after-header.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { parseRetryAfterValueMs } from "./retry-after-header.js"; + +const NOW = Date.parse("2026-08-20T12:00:00Z"); + +describe("parseRetryAfterValueMs", () => { + it("reads the delta-seconds form", () => { + expect(parseRetryAfterValueMs("120", NOW)).toBe(120_000); + }); + + it("reads the HTTP-date form relative to now", () => { + expect(parseRetryAfterValueMs("Thu, 20 Aug 2026 12:00:05 GMT", NOW)).toBe(5000); + }); + + it("clamps an already-elapsed date to zero rather than negative", () => { + expect(parseRetryAfterValueMs("Thu, 20 Aug 2026 11:00:00 GMT", NOW)).toBe(0); + }); + + it("returns null for absent or unparseable values", () => { + expect(parseRetryAfterValueMs(undefined, NOW)).toBeNull(); + expect(parseRetryAfterValueMs(null, NOW)).toBeNull(); + expect(parseRetryAfterValueMs("", NOW)).toBeNull(); + expect(parseRetryAfterValueMs(" ", NOW)).toBeNull(); + expect(parseRetryAfterValueMs("soon", NOW)).toBeNull(); + }); + + it("rejects a partially-numeric value instead of reading it as seconds", () => { + expect(parseRetryAfterValueMs("10abc", NOW)).toBeNull(); + }); +}); diff --git a/src/tools/os/retry-after-header.ts b/src/tools/os/retry-after-header.ts new file mode 100644 index 00000000..d863dd01 --- /dev/null +++ b/src/tools/os/retry-after-header.ts @@ -0,0 +1,36 @@ +/** + * `Retry-After` normalisation, shared by `os.web.fetch` and `os.http.request`. + * + * Both tools retry transient failures and both honour a server-sent + * `Retry-After`, but they read it off curl differently (`%{header_json}` vs + * `%header{retry-after}`). The RFC 9110 value grammar is the same either way, + * so it is defined once here rather than drifting between two copies. + */ + +/** + * Parse a raw `Retry-After` value to milliseconds. Handles both documented + * forms — delta-seconds (`120`) and an HTTP-date — and returns `null` for + * anything unparseable, so callers fall back to their own backoff schedule. + * + * A date already in the past clamps to `0`: retry immediately rather than + * not at all. + */ +export function parseRetryAfterValueMs( + value: string | null | undefined, + now: number = Date.now(), +): number | null { + if (typeof value !== "string") return null; + const text = value.trim(); + if (text.length === 0) return null; + + // Anchored so a partially-numeric value like "10abc" is rejected rather + // than silently read as 10 seconds. + if (/^\d+$/.test(text)) { + const seconds = Number.parseInt(text, 10); + return Number.isFinite(seconds) ? seconds * 1000 : null; + } + + const dateMs = Date.parse(text); + if (Number.isNaN(dateMs)) return null; + return Math.max(0, dateMs - now); +} diff --git a/src/tools/os/shell.ts b/src/tools/os/shell.ts index 954860fe..c99aa283 100644 --- a/src/tools/os/shell.ts +++ b/src/tools/os/shell.ts @@ -163,10 +163,11 @@ export function needsShellInterpretation( /** * Interpreter / wrapper binaries whose danger lives in their arguments, * not their name (`bash -c ""`). The shell tool withholds the - * shape-grant unit (`[a]`) for these: a grant keyed on `bash` would - * silence arbitrary code for the rest of the session. Matches the shells - * covered by the guard's `dangerous.shell_dash_c` rule. `[s]` (the whole - * shell category) and `[y]` (this call only) stay available. + * shape grant for these: a grant keyed on `bash` would silence + * arbitrary code for the rest of the session. Matches the shells + * covered by the guard's `dangerous.shell_dash_c` rule. The category + * grant (the whole shell category) and a plain approve (this call only) + * stay available. */ const OPAQUE_INTERPRETER_SHAPES: ReadonlySet = new Set([ "bash", diff --git a/src/tools/os/web-fetch-challenge.test.ts b/src/tools/os/web-fetch-challenge.test.ts new file mode 100644 index 00000000..d3f626a1 --- /dev/null +++ b/src/tools/os/web-fetch-challenge.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + describeChallenge, + detectChallenge, +} from "./web-fetch-challenge.js"; + +const CLOUDFLARE_200 = `Just a moment... + +

Enable JavaScript and cookies to continue

+ +
`; + +const CLOUDFLARE_403 = ` +Attention Required! | Cloudflare +

Sorry, you have been blocked

`; + +const REAL_ARTICLE = `Rate limiting +

Rate limiting

A rate limit is a cap on how +many requests a client may make. Servers behind a CDN often enforce one.

+
`; + +describe("detectChallenge", () => { + it("catches a challenge served as 200", () => { + // The one that mattered most: extraction succeeds on these, so the + // model received "Just a moment…" as the body of the article it + // asked for, with nothing anywhere saying it had been fenced out. + const verdict = detectChallenge({ + status: 200, + contentType: "text/html; charset=utf-8", + body: CLOUDFLARE_200, + }); + expect(verdict.challenged).toBe(true); + expect(verdict.marker).toBe("just a moment..."); + }); + + it("catches a challenge served as 403", () => { + const verdict = detectChallenge({ + status: 403, + contentType: "text/html", + body: CLOUDFLARE_403, + }); + expect(verdict.challenged).toBe(true); + }); + + it("leaves a real page alone even when it discusses rate limits", () => { + for (const status of [200, 403, 503]) { + expect( + detectChallenge({ + status, + contentType: "text/html", + body: REAL_ARTICLE, + }).challenged, + ).toBe(false); + } + }); + + it("does not send the agent to a browser for an API refusal", () => { + // A JSON 403 is a server saying no. Opening Chrome would be a + // slower way to receive the same answer. + expect( + detectChallenge({ + status: 403, + contentType: "application/json", + body: '{"error":"forbidden","detail":"please verify you are a human"}', + }).challenged, + ).toBe(false); + }); + + it("ignores statuses a challenge is never served under", () => { + expect( + detectChallenge({ + status: 404, + contentType: "text/html", + body: CLOUDFLARE_200, + }).challenged, + ).toBe(false); + }); + + it("only scans the head of the body", () => { + // A long document that happens to quote a marker deep inside is a + // document, not a wall. + const buried = `${"

ordinary prose.

".repeat(400)}just a moment...`; + expect( + detectChallenge({ + status: 200, + contentType: "text/html", + body: buried, + }).challenged, + ).toBe(false); + }); + + it("treats a missing content-type as possibly-html", () => { + expect( + detectChallenge({ status: 503, contentType: "", body: CLOUDFLARE_200 }) + .challenged, + ).toBe(true); + }); +}); + +describe("describeChallenge", () => { + it("names the tool that gets through, and says not to retry", () => { + const message = describeChallenge( + "https://example.com/a", + 403, + "just a moment...", + ); + expect(message).toContain("browser.navigate"); + expect(message).toContain("browser.read_aria"); + expect(message).toContain("do not re-fetch"); + }); +}); diff --git a/src/tools/os/web-fetch-challenge.ts b/src/tools/os/web-fetch-challenge.ts new file mode 100644 index 00000000..0a060b19 --- /dev/null +++ b/src/tools/os/web-fetch-challenge.ts @@ -0,0 +1,108 @@ +/** + * Is this response a bot wall rather than the page that was asked for? + * + * `os.web.fetch` is curl with no JavaScript engine, so a site behind + * Cloudflare, Akamai or PerimeterX does not answer it with the article — + * it answers with an interstitial whose whole content is "prove you are + * a browser". Two things went wrong with that before: + * + * 1. A challenge served as **200** was extracted and returned as the + * page. The model got "Just a moment…" as the body of the article + * it asked for and had no way to know it had been fenced out. + * 2. A challenge served as **403** came back as `HTTP 403 for `, + * which is indistinguishable from a page that genuinely refuses + * everyone — so the model either gave up on a reachable page or + * re-fetched the same wall. + * + * Neither says the thing that would actually help, which is that this + * app has a real browser and the wall is exactly what it is for. + * + * Detection is markers-in-body, not status alone: plenty of 403s are + * ordinary refusals and plenty of challenges are 200s, so the status is + * corroboration rather than evidence. + */ + +/** + * Phrases that appear in the *visible* text or the meta tags of the + * interstitials, chosen to be ones a normal article would not contain. + * Matched case-insensitively against the first slice of the body. + */ +const CHALLENGE_MARKERS: readonly string[] = [ + // Cloudflare + "just a moment...", + "checking your browser before accessing", + "attention required! | cloudflare", + "cf-browser-verification", + "cf_chl_opt", + "/cdn-cgi/challenge-platform/", + "enable javascript and cookies to continue", + // Akamai + "reference #18.", + "access denied | akamai", + // PerimeterX / HUMAN + "px-captcha", + "please verify you are a human", + // Imperva / Incapsula + "incapsula incident id", + "request unsuccessful. incapsula", + // Generic + "ddos protection by", + "verifying you are human", + "captcha-delivery.com", +]; + +/** + * How much of the body to scan. A challenge page is small and says so + * immediately; a real article that happens to quote one of these phrases + * says it well past the first few kilobytes. The cap also keeps this off + * the hot path for a 2 MB document. + */ +const SCAN_BYTES = 4096; + +/** Statuses a challenge is served under. `200` is deliberately included. */ +const CHALLENGE_STATUSES = new Set([200, 202, 403, 429, 503]); + +export interface ChallengeVerdict { + challenged: boolean; + /** The marker that matched, for the message the model reads. */ + marker: string | null; +} + +export function detectChallenge(input: { + status: number; + contentType: string; + body: string; +}): ChallengeVerdict { + if (!CHALLENGE_STATUSES.has(input.status)) { + return { challenged: false, marker: null }; + } + // A JSON or plain-text 403 is an API saying no, not a wall asking for + // a browser. Sending the agent to Chrome for that would be a slower + // way to get the same refusal. + const type = input.contentType.toLowerCase(); + if (type.length > 0 && !type.includes("html")) { + return { challenged: false, marker: null }; + } + const head = input.body.slice(0, SCAN_BYTES).toLowerCase(); + for (const marker of CHALLENGE_MARKERS) { + if (head.includes(marker)) return { challenged: true, marker }; + } + return { challenged: false, marker: null }; +} + +/** + * What to tell the model. Names the tool that gets through, because the + * whole failure mode is an agent that does not realise there is one. + */ +export function describeChallenge( + url: string, + status: number, + marker: string, +): string { + return ( + `${url} answered with a bot-protection challenge (HTTP ${status}, matched ` + + `"${marker}") rather than the page. This is a JavaScript wall and ` + + `os.web.fetch cannot pass it. Open the page with browser.navigate and ` + + `read it with browser.read_aria instead; do not re-fetch this URL.` + ); +} diff --git a/src/tools/os/web-fetch-ssrf-guard.test.ts b/src/tools/os/web-fetch-ssrf-guard.test.ts index 0228c2fc..cb4bf63d 100644 --- a/src/tools/os/web-fetch-ssrf-guard.test.ts +++ b/src/tools/os/web-fetch-ssrf-guard.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { assertHostAllowed, + formatResolveEntry, isBlockedIp, parseHttpUrl, SsrfBlockedError, @@ -74,7 +75,55 @@ describe("assertHostAllowed", () => { const pinned = await assertHostAllowed(parseHttpUrl("https://example.com"), { lookup: lookupTo("93.184.216.34"), }); - expect(pinned).toBe("93.184.216.34"); + expect(pinned).toEqual(["93.184.216.34"]); + }); + + /** + * The guard used to return `addresses[0]`, which turned every + * multi-homed host into a single-address host: one blackholed CDN + * edge, or an AAAA record sorting first on a machine with no IPv6 + * route, and the fetch failed on a site every other client could + * open. Handing over the whole list is safe precisely because the + * check above is all-or-nothing — a set that survives it is a set + * curl may try in any order. + */ + it("returns every safe address, in resolver order", async () => { + const pinned = await assertHostAllowed(parseHttpUrl("https://example.com"), { + lookup: lookupTo("2606:2800:220:1::1", "93.184.216.34", "93.184.216.35"), + }); + expect(pinned).toEqual([ + "2606:2800:220:1::1", + "93.184.216.34", + "93.184.216.35", + ]); + }); + + it("still refuses the whole set when one address is private", async () => { + await expect( + assertHostAllowed(parseHttpUrl("https://evil.example"), { + lookup: lookupTo("93.184.216.34", "93.184.216.35", "127.0.0.1"), + }), + ).rejects.toBeInstanceOf(SsrfBlockedError); + }); + + describe("formatResolveEntry", () => { + it("joins the list the way curl reads it", () => { + expect( + formatResolveEntry("example.com", "443", [ + "93.184.216.34", + "93.184.216.35", + ]), + ).toBe("example.com:443:93.184.216.34,93.184.216.35"); + }); + + it("brackets each IPv6 literal individually", () => { + expect( + formatResolveEntry("example.com", "443", [ + "2606:2800:220:1::1", + "93.184.216.34", + ]), + ).toBe("example.com:443:[2606:2800:220:1::1],93.184.216.34"); + }); }); it("throws when any resolved address is private (rebinding defense)", async () => { diff --git a/src/tools/os/web-fetch-ssrf-guard.ts b/src/tools/os/web-fetch-ssrf-guard.ts index 2f503f1e..8c7f0ee3 100644 --- a/src/tools/os/web-fetch-ssrf-guard.ts +++ b/src/tools/os/web-fetch-ssrf-guard.ts @@ -166,14 +166,30 @@ export interface AssertHostAllowedOptions { /** * Resolve `url`'s hostname and reject when **any** resolved address is in a - * blocked range. Returns a single safe address to pin curl to via + * blocked range. Returns *every* safe address, to pin curl to via * `--resolve`, which closes the DNS-rebinding gap between this check and the * actual connection. Throws {@link SsrfBlockedError} on any violation. + * + * **Why all of them.** This used to return `addresses[0]` and hand curl a + * single pin, which quietly turned every multi-homed host into a + * single-address host. A browser, or curl left to its own resolver, gets + * the whole list and walks it: it opens the next address when one refuses + * the connection, and it runs Happy Eyeballs across the two families so a + * machine with no working IPv6 route still reaches a host whose AAAA + * record happens to sort first. Pinned to one address, none of that + * happens — one blackholed CDN edge, or one unreachable family, and the + * fetch fails outright on a site every other client on the machine can + * open. That is the "some websites are not reachable" report. + * + * The SSRF property is unchanged, and is why handing over the whole list + * is safe: the loop below rejects the request if *any* resolved address is + * private, so the set that survives is a set curl may try in any order. + * Order is preserved as the resolver gave it. */ export async function assertHostAllowed( url: URL, options: AssertHostAllowedOptions = {}, -): Promise { +): Promise { const lookup = options.lookup ?? defaultLookup; const host = url.hostname.replace(/^\[|\]$/g, ""); if (host.length === 0) { @@ -207,5 +223,28 @@ export async function assertHostAllowed( ); } } - return addresses[0]!.address; + return addresses.map(({ address }) => address); +} + +/** + * Format the guard's safe-address list for one `--resolve` entry. + * + * curl accepts `host:port:addr[,addr]...` and treats that list the way it + * treats a resolver's own answer: it moves to the next address when one + * fails to connect, and runs Happy Eyeballs across the two families. + * Handing it a single address threw all of that away — see + * `assertHostAllowed` for what that cost. + * + * IPv6 literals are bracketed individually, which is what curl wants + * inside a comma-separated list. + */ +export function formatResolveEntry( + host: string, + port: string, + addresses: readonly string[], +): string { + const list = addresses + .map((address) => (address.includes(":") ? `[${address}]` : address)) + .join(","); + return `${host}:${port}:${list}`; } diff --git a/src/tools/os/web-fetch.test.ts b/src/tools/os/web-fetch.test.ts index a6388942..67e3082b 100644 --- a/src/tools/os/web-fetch.test.ts +++ b/src/tools/os/web-fetch.test.ts @@ -3,6 +3,7 @@ import { buildOsWebFetchTool, parseCurlMeta } from "./web-fetch.js"; import type { runCommand as RunCommandType } from "../../sandbox/command-runner.js"; import type { HostLookup } from "./web-fetch-ssrf-guard.js"; import type { ToolContext } from "../tool-registry.js"; +import { USER_CONFIG_DEFAULTS } from "../../config/index.js"; const MARKER = "__ATOMIC_WEBFETCH_META__"; @@ -19,8 +20,32 @@ function curlStdout(opts: { status: number; contentType: string; redirectUrl?: string; + /** Response headers rendered the way curl's `%{header_json}` emits them. */ + headers?: Record; }): string { - return `${opts.body}\n${MARKER}${opts.status}|${opts.contentType}|${opts.redirectUrl ?? ""}|${opts.body.length}`; + const headerJson = + opts.headers === undefined + ? "" + : JSON.stringify( + Object.fromEntries( + Object.entries(opts.headers).map(([k, v]) => [k, [v]]), + ), + ); + return `${opts.body}\n${MARKER}${opts.status}|${opts.contentType}|${opts.redirectUrl ?? ""}|${opts.body.length}|${headerJson}`; +} + +/** Collects backoff waits instead of sleeping, so retry tests run instantly. */ +function fakeSleep(): { + sleep: (ms: number, signal: AbortSignal) => Promise; + waits: number[]; +} { + const waits: number[] = []; + return { + waits, + sleep: async (ms: number) => { + waits.push(ms); + }, + }; } function makeRunCommand( @@ -66,6 +91,28 @@ describe("parseCurlMeta", () => { expect(parsed.contentType).toBe("text/html; charset=utf-8"); expect(parsed.redirectUrl).toBe("https://x/redir"); }); + + it("parses Retry-After out of the header_json field", () => { + const parsed = parseCurlMeta( + `body\n${MARKER}503|text/html|| 5|{"retry-after":["7"],"server":["x"]}`, + ); + expect(parsed.status).toBe(503); + expect(parsed.retryAfterMs).toBe(7_000); + }); + + it("keeps header_json containing pipes out of the fixed fields", () => { + const parsed = parseCurlMeta( + `body\n${MARKER}200|text/html||4|{"x-thing":["a|b"],"retry-after":["3"]}`, + ); + expect(parsed.contentType).toBe("text/html"); + expect(parsed.retryAfterMs).toBe(3_000); + }); + + it("yields no Retry-After when header_json is absent (older curl)", () => { + const parsed = parseCurlMeta(`body\n${MARKER}503|text/html||4|%{header_json}`); + expect(parsed.status).toBe(503); + expect(parsed.retryAfterMs).toBeNull(); + }); }); describe("os.web.fetch tool", () => { @@ -189,4 +236,427 @@ describe("os.web.fetch tool", () => { expect(result.summary).toContain("HTTP 500"); expect(result.details.status).toBe(500); }); + + it("passes --globoff so bracketed URLs are not read as curl ranges", async () => { + // Real failure from the field: without --globoff curl rejects the + // `[... TO ...]` date range with "curl: (3) bad range in URL position 124". + const url = + "http://export.arxiv.org/api/query?search_query=all:%22multiwavelength%22" + + "+AND+submittedDate:[202102010000+TO+202104300000]&start=0&max_results=30"; + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "", + status: 200, + contentType: "application/atom+xml", + }), + })), + ); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup }); + await tool.run({ url }, ctx()); + expect(run).toHaveBeenCalledTimes(1); + const args = run.mock.calls[0]![1] as string[]; + expect(args).toContain("--globoff"); + // The bracketed URL still reaches curl verbatim as the final operand. + expect(args[args.length - 1]).toContain("[202102010000"); + }); +}); + +// Issue #181 — 236 timeout failures each burned a fixed 30s of the task +// budget, with no connect timeout and no way to shorten the wait. +describe("os.web.fetch timeouts (#181)", () => { + function cfg(fetch: Partial<{ + timeoutMs: number; + connectTimeoutMs: number; + maxRetries: number; + retryBaseDelayMs: number; + retryMaxDelayMs: number; + }>) { + return { + web: { + search: USER_CONFIG_DEFAULTS.web.search, + fetch: { ...USER_CONFIG_DEFAULTS.web.fetch, ...fetch }, + }, + }; + } + + it("passes --connect-timeout so dead hosts fail fast", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup }); + await tool.run({ url: "https://example.com/doc" }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + // Default connect budget is 10s, well below the 30s overall budget. + expect(args[args.indexOf("--connect-timeout") + 1]).toBe("10"); + }); + + it("uses the configured timeoutMs for --max-time", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + config: cfg({ timeoutMs: 8_000, connectTimeoutMs: 3_000 }), + }); + await tool.run({ url: "https://example.com/doc" }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + expect(args[args.indexOf("--max-time") + 1]).toBe("8"); + expect(args[args.indexOf("--connect-timeout") + 1]).toBe("3"); + }); + + it("lets a per-call timeoutMs override the configured default", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + config: cfg({ timeoutMs: 30_000 }), + }); + await tool.run({ url: "https://example.com/doc", timeoutMs: 5_000 }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + expect(args[args.indexOf("--max-time") + 1]).toBe("5"); + }); + + it("never lets the connect budget exceed a smaller per-call timeout", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + config: cfg({ timeoutMs: 30_000, connectTimeoutMs: 10_000 }), + }); + await tool.run({ url: "https://example.com/doc", timeoutMs: 2_000 }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + expect(args[args.indexOf("--max-time") + 1]).toBe("2"); + expect(args[args.indexOf("--connect-timeout") + 1]).toBe("2"); + }); +}); + +// Issue #180 — os.web.fetch never retried. 128 of 130 real 503s came from +// web.archive.org, which serves the very same URL seconds later. +describe("os.web.fetch retries (#180)", () => { + const ARCHIVE_URL = "https://web.archive.org/web/2023/https://example.com"; + + it("retries a 503 from web.archive.org and succeeds on the second attempt", async () => { + let attempts = 0; + const run = vi.fn( + makeRunCommand(() => { + attempts += 1; + if (attempts === 1) { + return { + stdout: curlStdout({ + body: "slow down", + status: 503, + contentType: "text/html", + }), + }; + } + return { + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + }; + }), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: ARCHIVE_URL }, ctx()); + expect(result.status).toBe("ok"); + expect(result.details.status).toBe(200); + expect(run).toHaveBeenCalledTimes(2); + // First backoff is the base delay. + expect(waits).toEqual([500]); + }); + + it("returns the error once the retry budget is exhausted", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "still down", + status: 503, + contentType: "text/html", + }), + })), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: ARCHIVE_URL }, ctx()); + expect(result.status).toBe("error"); + expect(result.details.status).toBe(503); + // Default maxRetries: 2 → 3 attempts total, exponential 500ms then 1000ms. + expect(run).toHaveBeenCalledTimes(3); + expect(waits).toEqual([500, 1_000]); + }); + + it("does NOT retry a 404", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "not found", + status: 404, + contentType: "text/html", + }), + })), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://example.com/missing" }, ctx()); + expect(result.status).toBe("error"); + expect(run).toHaveBeenCalledTimes(1); + expect(waits).toEqual([]); + }); + + it("retries a curl timeout (exit 28)", async () => { + let attempts = 0; + const run = vi.fn( + makeRunCommand(() => { + attempts += 1; + if (attempts === 1) { + return { + stdout: "", + exitCode: 28, + stderr: "curl: (28) Connection timed out after 30006 milliseconds", + }; + } + return { + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + }; + }), + ); + const { sleep } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://example.com/slow" }, ctx()); + expect(result.status).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry a non-timeout curl failure", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: "", + exitCode: 6, + stderr: "curl: (6) Could not resolve host: nope.invalid", + })), + ); + const { sleep } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://nope.invalid/x" }, ctx()); + expect(result.status).toBe("error"); + expect(result.summary).toContain("Could not resolve host"); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("honours a Retry-After header over the computed backoff", async () => { + let attempts = 0; + const run = vi.fn( + makeRunCommand(() => { + attempts += 1; + if (attempts === 1) { + return { + stdout: curlStdout({ + body: "", + status: 429, + contentType: "text/html", + headers: { "retry-after": "2" }, + }), + }; + } + return { + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + }; + }), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://example.com/limited" }, ctx()); + expect(result.status).toBe("ok"); + // 2s from the header, not the 500ms base delay. + expect(waits).toEqual([2_000]); + }); + + it("clamps an oversized Retry-After to retryMaxDelayMs", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "", + status: 503, + contentType: "text/html", + headers: { "retry-after": "3600" }, + }), + })), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + await tool.run({ url: ARCHIVE_URL }, ctx()); + // Never parks the agent for an hour — capped at the 5s default. + expect(waits).toEqual([5_000, 5_000]); + }); + + it("respects maxRetries: 0 (retrying disabled)", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: "", status: 503, contentType: "text/html" }), + })), + ); + const { sleep } = fakeSleep(); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + sleep, + config: { + web: { + search: USER_CONFIG_DEFAULTS.web.search, + fetch: { ...USER_CONFIG_DEFAULTS.web.fetch, maxRetries: 0 }, + }, + }, + }); + const result = await tool.run({ url: ARCHIVE_URL }, ctx()); + expect(result.status).toBe("error"); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("stops retrying once the abort signal fires", async () => { + const controller = new AbortController(); + const run = vi.fn( + makeRunCommand(() => { + // The task is cancelled while the first attempt is in flight. + controller.abort(); + return { + stdout: curlStdout({ body: "", status: 503, contentType: "text/html" }), + }; + }), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run( + { url: ARCHIVE_URL }, + { ...ctx(), signal: controller.signal }, + ); + expect(result.status).toBe("error"); + expect(run).toHaveBeenCalledTimes(1); + expect(waits).toEqual([]); + }); +}); + +/** + * The reachability half: what curl is actually told to do. + */ +describe("os.web.fetch reaches a host the way a browser would", () => { + function captureArgs(): { args: string[][]; runCommand: typeof RunCommandType } { + const args: string[][] = []; + const runCommand = (async (_command: string, argv: string[]) => { + args.push(argv); + return { + command: "curl", + args: argv, + exitCode: 0, + signal: null, + stdout: curlStdout({ + body: ARTICLE, + status: 200, + contentType: "text/html", + }), + stderr: "", + durationMs: 1, + timedOut: false, + truncated: false, + }; + }) as unknown as typeof RunCommandType; + return { args, runCommand }; + } + + it("pins every resolved address, not just the first", async () => { + // One `--resolve` carrying the whole list is what lets curl move on + // when an address refuses the connection, and what lets Happy + // Eyeballs pick a family the machine can actually route. Pinned to + // `addresses[0]`, a host whose AAAA sorts first was unreachable on + // an IPv4-only machine — on a site every other client could open. + const { args, runCommand } = captureArgs(); + const tool = buildOsWebFetchTool({ + runCommand, + lookup: async () => [ + { address: "2606:2800:220:1::1", family: 6 }, + { address: "93.184.216.34", family: 4 }, + { address: "93.184.216.35", family: 4 }, + ], + config: USER_CONFIG_DEFAULTS as never, + }); + await tool.run({ url: "https://example.com/a" }, ctx()); + const argv = args[0]!; + const resolveValue = argv[argv.indexOf("--resolve") + 1]; + expect(resolveValue).toBe( + "example.com:443:[2606:2800:220:1::1],93.184.216.34,93.184.216.35", + ); + }); + + it("asks for, and undoes, compression", async () => { + const { args, runCommand } = captureArgs(); + const tool = buildOsWebFetchTool({ + runCommand, + lookup: publicLookup, + config: USER_CONFIG_DEFAULTS as never, + }); + await tool.run({ url: "https://example.com/a" }, ctx()); + expect(args[0]).toContain("--compressed"); + }); +}); + +describe("os.web.fetch against a bot wall", () => { + const CHALLENGE = `Just a moment... +
Enable JavaScript and cookies to continue +
`; + + function toolReturning(status: number, body: string) { + return buildOsWebFetchTool({ + runCommand: makeRunCommand(() => ({ + stdout: curlStdout({ body, status, contentType: "text/html" }), + })), + lookup: publicLookup, + config: USER_CONFIG_DEFAULTS as never, + sleep: fakeSleep().sleep, + }); + } + + it("reports a 200-status challenge instead of returning it as the page", async () => { + // This is the one that silently poisoned answers: extraction works + // fine on a challenge page, so "Just a moment…" came back as the + // article's body with nothing saying otherwise. + const result = await toolReturning(200, CHALLENGE).run( + { url: "https://example.com/a" }, + ctx(), + ); + expect(result.status).toBe("error"); + expect(result.summary).toContain("bot-protection challenge"); + expect(result.summary).toContain("browser.navigate"); + expect(result.summary).not.toContain("Just a moment"); + }); + + it("names the browser rather than reporting a bare 403", async () => { + const result = await toolReturning(403, CHALLENGE).run( + { url: "https://example.com/a" }, + ctx(), + ); + expect(result.status).toBe("error"); + expect(result.details.retryWith).toBe("browser.navigate"); + expect(result.summary).toContain("do not re-fetch"); + }); + + it("leaves an ordinary page alone", async () => { + const result = await toolReturning(200, ARTICLE).run( + { url: "https://example.com/a" }, + ctx(), + ); + expect(result.status).toBe("ok"); + expect(result.summary).toContain("Hello"); + }); }); diff --git a/src/tools/os/web-fetch.ts b/src/tools/os/web-fetch.ts index 7071cad4..6226f026 100644 --- a/src/tools/os/web-fetch.ts +++ b/src/tools/os/web-fetch.ts @@ -4,10 +4,13 @@ import { type CommandResult, } from "../../sandbox/command-runner.js"; import type { ToolDefinition } from "../tool-registry.js"; +import type { AtomicAgentConfig, WebFetchConfig } from "../../config/index.js"; import { extractWebContent, type ExtractMode } from "./web-fetch-extract.js"; import { CurlUnavailableError, isCurlMissingError } from "./ensure-curl.js"; +import { parseRetryAfterValueMs } from "./retry-after-header.js"; import { assertHostAllowed, + formatResolveEntry, parseHttpUrl, SsrfBlockedError, type HostLookup, @@ -16,11 +19,40 @@ import { const TOOL_NAME = "os.web.fetch"; const DEFAULT_TIMEOUT_MS = 30_000; +import { + describeChallenge, + detectChallenge, +} from "./web-fetch-challenge.js"; + const MAX_RESPONSE_BYTES = 2_000_000; const MAX_REDIRECTS = 3; const DEFAULT_MAX_CHARS = 50_000; const MAX_CHARS_CAP = 50_000; +/** + * Fallback `web.fetch` settings for callers that construct the tool without a + * config (tests, embedders). Mirrors `USER_CONFIG_DEFAULTS.web.fetch`. + */ +const DEFAULT_FETCH_CONFIG: WebFetchConfig = { + timeoutMs: DEFAULT_TIMEOUT_MS, + connectTimeoutMs: 10_000, + maxRetries: 2, + retryBaseDelayMs: 500, + retryMaxDelayMs: 5_000, +}; + +/** + * HTTP statuses worth a second attempt. 503 dominates the field data (and is + * overwhelmingly `web.archive.org` shedding load, which serves the very same + * URL seconds later); 429/502/504 are the other transient-by-contract codes. + * Everything else — notably 4xx like 404/403 — is a stable answer that would + * only burn budget on a repeat. + */ +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); + +/** curl's "operation timed out" exit. The other exits are not transient. */ +const CURL_EXIT_TIMEOUT = 28; + const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; @@ -37,12 +69,21 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); export interface OsWebFetchOptions { runCommand?: typeof defaultRunCommand; lookup?: HostLookup; + /** + * Timeout / retry tunables. Optional so existing callers and tests keep + * working — when absent `DEFAULT_FETCH_CONFIG` applies, which reproduces the + * pre-v38 30s budget. + */ + config?: Pick; + /** Injectable sleep so retry-backoff tests do not wait in real time. */ + sleep?: (ms: number, signal: AbortSignal) => Promise; } interface WebFetchArgs { url: string; mode: ExtractMode; maxChars: number; + timeoutMs: number; } interface CurlResponse { @@ -51,6 +92,8 @@ interface CurlResponse { redirectUrl: string; body: string; truncated: boolean; + /** Seconds parsed from a `Retry-After` response header, when present. */ + retryAfterMs: number | null; } interface FetchOutcome { @@ -66,6 +109,8 @@ export function buildOsWebFetchTool( options: OsWebFetchOptions = {}, ): ToolDefinition { const runCommand = options.runCommand ?? defaultRunCommand; + const fetchCfg = options.config?.web.fetch ?? DEFAULT_FETCH_CONFIG; + const sleep = options.sleep ?? defaultSleep; return { name: TOOL_NAME, description: @@ -73,13 +118,17 @@ export function buildOsWebFetchTool( "extracts content: prefers Cloudflare 'Markdown for Agents' " + "(Accept: text/markdown), then Mozilla Readability, then a basic " + "tag-stripping fallback. GET only, no auth headers, no JavaScript " + - "(use browser.* for JS-heavy pages). Blocks private/internal " + - "addresses (SSRF) and re-validates each redirect hop. For raw " + - "API/JSON responses, custom headers, auth, or POST, use " + - "os.http.request instead.", + "(use browser.* for JS-heavy pages; a page that answers with a " + + "bot-protection challenge is reported as such, with browser.navigate " + + "named as the way through). Blocks private/internal " + + "addresses (SSRF) and re-validates each redirect hop. Retries " + + "transient failures (429/502/503/504, connection timeouts) with " + + "exponential backoff. Optional `timeoutMs` overrides the configured " + + "per-attempt budget. For raw API/JSON responses, custom headers, " + + "auth, or POST, use os.http.request instead.", readonly: true, async run(rawArgs, ctx) { - const args = parseArgs(rawArgs); + const args = parseArgs(rawArgs, fetchCfg.timeoutMs); let outcome: FetchOutcome; try { outcome = await fetchWithGuard(args.url, { @@ -87,6 +136,9 @@ export function buildOsWebFetchTool( lookup: options.lookup, cwd: ctx.workingDir, signal: ctx.signal, + fetchCfg, + timeoutMs: args.timeoutMs, + sleep, }); } catch (err) { return compressToolResult({ @@ -100,6 +152,33 @@ export function buildOsWebFetchTool( }); } + // Before extraction: a challenge page extracts perfectly well, and + // that is the problem — "Just a moment…" comes back looking like + // the article's first paragraph. + const challenge = detectChallenge({ + status: outcome.status, + contentType: outcome.contentType, + body: outcome.body, + }); + if (challenge.challenged) { + return compressToolResult({ + tool: TOOL_NAME, + status: "error", + output: describeChallenge( + outcome.finalUrl, + outcome.status, + challenge.marker ?? "", + ), + details: { + url: args.url, + finalUrl: outcome.finalUrl, + status: outcome.status, + challenge: challenge.marker, + retryWith: "browser.navigate", + }, + }); + } + const extracted = extractWebContent({ body: outcome.body, contentType: outcome.contentType, @@ -149,7 +228,10 @@ export function buildOsWebFetchTool( }; } -function parseArgs(rawArgs: Record): WebFetchArgs { +function parseArgs( + rawArgs: Record, + defaultTimeoutMs: number, +): WebFetchArgs { const url = rawArgs.url; if (typeof url !== "string" || url.length === 0) { throw new Error(`${TOOL_NAME}: \`url\` must be a non-empty string`); @@ -168,7 +250,14 @@ function parseArgs(rawArgs: Record): WebFetchArgs { if (typeof rawArgs.maxChars === "number" && Number.isFinite(rawArgs.maxChars)) { maxChars = Math.min(MAX_CHARS_CAP, Math.max(1, Math.trunc(rawArgs.maxChars))); } - return { url, mode, maxChars }; + // Mirrors os.http.request: a per-call `timeoutMs` overrides the configured + // default so the model can shorten the budget for a host it expects to be + // slow, instead of losing the full default on every attempt. + const timeoutMs = + typeof rawArgs.timeoutMs === "number" && Number.isFinite(rawArgs.timeoutMs) + ? Math.max(1, Math.trunc(rawArgs.timeoutMs)) + : defaultTimeoutMs; + return { url, mode, maxChars, timeoutMs }; } interface FetchWithGuardOptions { @@ -176,12 +265,32 @@ interface FetchWithGuardOptions { lookup?: HostLookup; cwd: string; signal: AbortSignal; + fetchCfg: WebFetchConfig; + /** Effective per-attempt budget (per-call arg, else `fetchCfg.timeoutMs`). */ + timeoutMs: number; + sleep: (ms: number, signal: AbortSignal) => Promise; +} + +/** Thrown by `curlOnce` when curl itself failed (non-zero exit). */ +class CurlFailedError extends Error { + constructor( + message: string, + readonly exitCode: number, + ) { + super(message); + this.name = "CurlFailedError"; + } } /** * Fetch `rawUrl`, following redirects manually (curl `--max-redirs 0`) so the * SSRF guard can re-validate every hop and pin curl to a verified IP via * `--resolve`, closing the DNS-rebinding window. + * + * Each hop is retried independently for transient failures. `os.web.fetch` is + * GET-only (no method argument exists, and curl is invoked without `-X`/`-d`), + * so every request is idempotent and safe to repeat — there is no + * non-idempotent case to exclude here. */ async function fetchWithGuard( rawUrl: string, @@ -190,8 +299,7 @@ async function fetchWithGuard( let currentUrl = parseHttpUrl(rawUrl); const chain: string[] = []; for (let hop = 0; ; hop++) { - const pinnedIp = await assertHostAllowed(currentUrl, { lookup: opts.lookup }); - const res = await curlOnce(currentUrl, pinnedIp, opts); + const res = await fetchHopWithRetry(currentUrl, opts); chain.push(currentUrl.toString()); if (REDIRECT_STATUSES.has(res.status) && res.redirectUrl.length > 0) { if (hop >= MAX_REDIRECTS) { @@ -213,17 +321,106 @@ async function fetchWithGuard( } } +/** + * One redirect hop, retried on transient failure with exponential backoff. + * + * Retryable: HTTP 429/502/503/504, and curl exit 28 (`--max-time` / + * `--connect-timeout` expiry). Everything else — a 404, a DNS failure, a TLS + * error — is returned or thrown on the first attempt, because repeating it only + * spends task budget for the same answer. + * + * The budget is deliberately small (`maxRetries`, default 2). Worst case adds + * two attempts plus backoff on top of the first, which is bounded by + * `retryMaxDelayMs` per wait rather than growing without limit. `ctx.signal` is + * honoured both during the sleep and by `runCommand`, so an aborted task stops + * immediately instead of finishing its retry ladder. + */ +async function fetchHopWithRetry( + url: URL, + opts: FetchWithGuardOptions, +): Promise { + const { maxRetries } = opts.fetchCfg; + for (let attempt = 0; ; attempt++) { + // Re-resolve on every attempt: the guard must pin a freshly verified IP + // rather than trusting one resolved before an arbitrary backoff wait. + const pinnedIps = await assertHostAllowed(url, { lookup: opts.lookup }); + + let res: CurlResponse | null = null; + let failure: unknown = null; + try { + res = await curlOnce(url, pinnedIps, opts); + } catch (err) { + // Only a curl timeout is worth another attempt; a missing curl binary or + // an aborted run must surface immediately. + if ( + !(err instanceof CurlFailedError) || + err.exitCode !== CURL_EXIT_TIMEOUT + ) { + throw err; + } + failure = err; + } + + const retryable = + failure !== null || (res !== null && RETRYABLE_STATUSES.has(res.status)); + if (!retryable || attempt >= maxRetries || opts.signal.aborted) { + if (res !== null) return res; + throw failure; + } + + await opts.sleep( + backoffDelayMs(attempt, res?.retryAfterMs ?? null, opts.fetchCfg), + opts.signal, + ); + } +} + +/** + * Delay before retry `attempt` (0-based): `retryBaseDelayMs * 2^attempt`, + * clamped to `retryMaxDelayMs`. Defaults give 500ms then 1000ms — long enough + * for a load-shedding origin like `web.archive.org` to recover, short enough + * that two retries cost ~1.5s against a 25-minute task budget. + * + * A `Retry-After` sent by the server wins over the computed delay, since the + * origin knows its own recovery window, but is still clamped to + * `retryMaxDelayMs` so a large or hostile value cannot park the agent. + */ +function backoffDelayMs( + attempt: number, + retryAfterMs: number | null, + cfg: WebFetchConfig, +): number { + const backoff = cfg.retryBaseDelayMs * 2 ** attempt; + const chosen = retryAfterMs !== null ? retryAfterMs : backoff; + return Math.min(cfg.retryMaxDelayMs, Math.max(0, chosen)); +} + +function defaultSleep(ms: number, signal: AbortSignal): Promise { + if (ms <= 0 || signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(finish, ms); + function finish(): void { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + async function curlOnce( url: URL, - pinnedIp: string, + pinnedIps: readonly string[], opts: FetchWithGuardOptions, ): Promise { - const curlArgs = buildCurlArgs(url, pinnedIp); + const curlArgs = buildCurlArgs(url, pinnedIps, opts); let result: CommandResult; try { result = await opts.runCommand("curl", curlArgs, { cwd: opts.cwd, - timeoutMs: DEFAULT_TIMEOUT_MS + 2_000, + // Outer guard sits just above curl's own `--max-time` so curl reports the + // timeout itself (exit 28) instead of being killed by the runner. + timeoutMs: opts.timeoutMs + 2_000, signal: opts.signal, maxOutputBytes: MAX_RESPONSE_BYTES + 1024, }); @@ -232,23 +429,49 @@ async function curlOnce( throw err; } if (result.exitCode !== 0) { - throw new Error(`${TOOL_NAME}: ${formatCurlError(result)}`); + throw new CurlFailedError( + `${TOOL_NAME}: ${formatCurlError(result)}`, + result.exitCode ?? -1, + ); } return { ...parseCurlMeta(result.stdout), truncated: result.truncated }; } -function buildCurlArgs(url: URL, pinnedIp: string): string[] { +function buildCurlArgs( + url: URL, + pinnedIps: readonly string[], + opts: Pick, +): string[] { const host = url.hostname.replace(/^\[|\]$/g, ""); const port = url.port || (url.protocol === "https:" ? "443" : "80"); - const resolveTarget = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp; + // Never let the connect budget exceed the overall one — a per-call + // `timeoutMs` smaller than the configured connect timeout must still cap the + // handshake. + const connectTimeoutMs = Math.min( + opts.fetchCfg.connectTimeoutMs, + opts.timeoutMs, + ); return [ "-sS", + // Send `[`, `]`, `{`, `}` in URLs literally. Without this curl reads them + // as its own range/set glob syntax and fails with "bad range in URL". + "--globoff", "--max-time", - String(Math.ceil(DEFAULT_TIMEOUT_MS / 1000)), + String(Math.ceil(opts.timeoutMs / 1000)), + // Fail fast on hosts that never complete a handshake instead of holding + // the whole `--max-time` budget open for them. + "--connect-timeout", + String(Math.ceil(connectTimeoutMs / 1000)), "--max-redirs", "0", "--resolve", - `${host}:${port}:${resolveTarget}`, + formatResolveEntry(host, port, pinnedIps), + // Advertise the encodings curl can actually undo, and undo them. + // Without this, a server that compresses regardless of the request + // hands back bytes the extractor reads as binary noise — and some + // hosts behave differently for a client that claims no encoding + // support at all, since no browser has looked like that in years. + "--compressed", "-H", "Accept: text/markdown, text/html;q=0.9, */*;q=0.1", "-H", @@ -256,7 +479,11 @@ function buildCurlArgs(url: URL, pinnedIp: string): string[] { "-H", "Accept-Language: en-US,en;q=0.9", "-w", - `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{redirect_url}|%{size_download}`, + // `%{header_json}` is last on purpose: it is multi-line JSON that can + // itself contain `|`, so every pipe-delimited field must precede it. + // Requires curl >= 7.83; older curl emits the literal token, which + // `parseCurlMeta` tolerates by yielding no Retry-After. + `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{redirect_url}|%{size_download}|%{header_json}`, "--", url.toString(), ]; @@ -267,20 +494,58 @@ export function parseCurlMeta( ): Omit { const markerIdx = stdout.lastIndexOf(CURL_META_MARKER); if (markerIdx === -1) { - return { status: 0, contentType: "", redirectUrl: "", body: stdout }; + return { + status: 0, + contentType: "", + redirectUrl: "", + body: stdout, + retryAfterMs: null, + }; } const body = stdout.slice(0, markerIdx).replace(/\n$/, ""); const meta = stdout.slice(markerIdx + CURL_META_MARKER.length).trim(); - const [statusStr = "", contentType = "", redirectUrl = ""] = meta.split("|"); + // Split off exactly the four fixed fields; whatever follows is + // `%{header_json}`, which may itself contain `|` and newlines. + const parts = meta.split("|"); + const [statusStr = "", contentType = "", redirectUrl = ""] = parts; + const headerJson = parts.slice(4).join("|"); const status = Number.parseInt(statusStr, 10); return { status: Number.isFinite(status) ? status : 0, contentType: contentType.trim(), redirectUrl: redirectUrl.trim(), body, + retryAfterMs: parseRetryAfterMs(headerJson), }; } +/** + * Pull `Retry-After` out of curl's `%{header_json}` blob and normalise it to + * milliseconds. Handles both RFC 9110 forms — delta-seconds and an HTTP-date — + * and returns `null` for anything unparseable (including older curl builds that + * do not support `%{header_json}` and emit the literal token instead), so a + * missing or malformed header simply falls back to plain exponential backoff. + */ +function parseRetryAfterMs(headerJson: string): number | null { + const trimmed = headerJson.trim(); + if (trimmed.length === 0 || !trimmed.startsWith("{")) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + // curl lowercases header names, but match case-insensitively regardless. + const entry = Object.entries(parsed as Record).find( + ([key]) => key.toLowerCase() === "retry-after", + ); + const rawValue = entry?.[1]; + const value = Array.isArray(rawValue) ? rawValue[0] : rawValue; + if (typeof value !== "string") return null; + return parseRetryAfterValueMs(value); +} + function formatCurlError(result: CommandResult): string { const stderr = result.stderr.trim(); if (stderr.length > 0) return stderr; diff --git a/src/tools/os/web-search/providers/assert-provider-status.ts b/src/tools/os/web-search/providers/assert-provider-status.ts new file mode 100644 index 00000000..3a6a4c49 --- /dev/null +++ b/src/tools/os/web-search/providers/assert-provider-status.ts @@ -0,0 +1,36 @@ +import type { SearchHttpResponse } from "../transport/index.js"; +import { WebSearchRateLimitedError } from "../web-search-errors.js"; +import type { WebSearchProviderName } from "../web-search-provider.js"; + +/** + * The one place a provider turns an HTTP status into a throw. + * + * It exists to make 429 a *typed* outcome. Every provider used to raise + * a bare `Error` with the status baked into the message — `Exa returned + * HTTP 429` — which the orchestrator could only treat as "something + * went wrong, try the next one". A quota and a broken endpoint are not + * the same failure and do not want the same response: one should stop + * being asked for a while, the other should be retried the moment the + * next query arrives. + * + * `label` is the provider's display name (`Exa`, `DuckDuckGo`); `name` + * is its registry key. Keeping both means the message an operator reads + * stays the one they are used to while the value the orchestrator + * matches on is the enum. + */ +export function assertProviderStatus( + response: Pick, + name: WebSearchProviderName, + label: string, +): void { + if (response.status === 429) { + throw new WebSearchRateLimitedError( + name, + response.retryAfterMs, + `${label} returned HTTP 429 (rate limited)`, + ); + } + if (response.status >= 400) { + throw new Error(`${label} returned HTTP ${response.status}`); + } +} diff --git a/src/tools/os/web-search/providers/brave-provider.ts b/src/tools/os/web-search/providers/brave-provider.ts index ef1aae8f..f4caf165 100644 --- a/src/tools/os/web-search/providers/brave-provider.ts +++ b/src/tools/os/web-search/providers/brave-provider.ts @@ -1,3 +1,4 @@ +import { assertProviderStatus } from "./assert-provider-status.js"; import { searchHttp } from "../transport/search-http.js"; import type { WebSearchHttpDeps, @@ -46,9 +47,7 @@ export function createBraveProvider( runCommand: deps.runCommand, lookup: deps.lookup, }); - if (response.status >= 400) { - throw new Error(`Brave search returned HTTP ${response.status}`); - } + assertProviderStatus(response, "brave", "Brave search"); return parseBraveJson(response.body, options.maxResults); }, }; diff --git a/src/tools/os/web-search/providers/duckduckgo-provider.ts b/src/tools/os/web-search/providers/duckduckgo-provider.ts index 3612880d..da1dcade 100644 --- a/src/tools/os/web-search/providers/duckduckgo-provider.ts +++ b/src/tools/os/web-search/providers/duckduckgo-provider.ts @@ -1,3 +1,4 @@ +import { assertProviderStatus } from "./assert-provider-status.js"; import { parseHTML } from "linkedom"; import { searchHttp } from "../transport/search-http.js"; @@ -40,9 +41,7 @@ export function createDuckDuckGoProvider( runCommand: deps.runCommand, lookup: deps.lookup, }); - if (response.status >= 400) { - throw new Error(`DuckDuckGo returned HTTP ${response.status}`); - } + assertProviderStatus(response, "duckduckgo", "DuckDuckGo"); const results = parseDuckDuckGoHtml(response.body, options.maxResults); if (results.length === 0 && isBotChallenge(response.body)) { throw new WebSearchBlockedError("duckduckgo"); diff --git a/src/tools/os/web-search/providers/exa-provider.ts b/src/tools/os/web-search/providers/exa-provider.ts index 03bb1f5f..8b7d27cf 100644 --- a/src/tools/os/web-search/providers/exa-provider.ts +++ b/src/tools/os/web-search/providers/exa-provider.ts @@ -1,3 +1,4 @@ +import { assertProviderStatus } from "./assert-provider-status.js"; import { searchHttp } from "../transport/search-http.js"; import type { WebSearchHttpDeps, @@ -66,9 +67,7 @@ export function createExaProvider( runCommand: deps.runCommand, lookup: deps.lookup, }); - if (response.status >= 400) { - throw new Error(`Exa API returned HTTP ${response.status}`); - } + assertProviderStatus(response, "exa", "Exa API"); return parseExaApiJson(response.body, options.maxResults); } @@ -97,9 +96,7 @@ export function createExaProvider( runCommand: deps.runCommand, lookup: deps.lookup, }); - if (response.status >= 400) { - throw new Error(`Exa returned HTTP ${response.status}`); - } + assertProviderStatus(response, "exa", "Exa"); const text = extractExaText(response.body); return parseExaTextResults(text, options.maxResults); }, diff --git a/src/tools/os/web-search/providers/search-orchestrator.test.ts b/src/tools/os/web-search/providers/search-orchestrator.test.ts index 1a72fbd6..0541b361 100644 --- a/src/tools/os/web-search/providers/search-orchestrator.test.ts +++ b/src/tools/os/web-search/providers/search-orchestrator.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import type { AtomicAgentConfig } from "../../../../config/index.js"; import { runWebSearchWithFallback } from "./search-orchestrator.js"; +import { createProviderCooldown } from "../transport/provider-cooldown.js"; import { createSearchCache } from "../transport/search-cache.js"; -import { WebSearchBlockedError } from "../web-search-errors.js"; +import { + WebSearchBlockedError, + WebSearchRateLimitedError, +} from "../web-search-errors.js"; import type { WebSearchProviderName, WebSearchProviderOptions, @@ -201,3 +205,168 @@ describe("runWebSearchWithFallback", () => { ).rejects.toBeInstanceOf(WebSearchBlockedError); }); }); + +/** + * Issue #179, reproduced at the level it actually bites. + * + * The transport already retries a 429 twice against the same provider, + * which is right for a burst. What the campaign measured was not a + * burst: 1341 429s spread evenly across 24 hours, 8-20 an hour, not + * tracking concurrency. Against a standing quota, every search paid for + * three doomed requests and ~1.5s of backoff before reaching the + * provider that was always going to answer it — and the answer, coming + * from the weaker fallback, looked exactly like a normal one. + */ +describe("a provider under a standing rate limit", () => { + const T0 = 5_000_000; + + function limitedThenFallback() { + const exa = stubProvider("exa", async () => { + throw new WebSearchRateLimitedError("exa", null); + }); + const ddg = stubProvider("duckduckgo", async () => [RESULT]); + vi.mocked(resolveProviderByName).mockImplementation((name) => + name === "exa" ? exa : ddg, + ); + return { exa, ddg }; + } + + it("stops asking it, instead of asking it again on every query", async () => { + const { exa, ddg } = limitedThenFallback(); + const cooldown = createProviderCooldown(); + const config = makeConfig({ provider: "exa", fallback: ["duckduckgo"] }); + let clock = T0; + + const first = await runWebSearchWithFallback({ + config, + deps: {}, + options: makeOptions(), + cooldown, + now: () => clock, + }); + expect(first.provider).toBe("duckduckgo"); + expect(exa.search).toHaveBeenCalledOnce(); + + // Ten more searches inside the park. Before this, each one re-entered + // the retry ladder against a provider that could not answer. + clock = T0 + 30_000; + for (let i = 0; i < 10; i++) { + const out = await runWebSearchWithFallback({ + config, + deps: {}, + options: { ...makeOptions(), query: `q${i}` }, + cooldown, + now: () => clock, + }); + expect(out.provider).toBe("duckduckgo"); + } + expect(exa.search).toHaveBeenCalledOnce(); + expect(ddg.search).toHaveBeenCalledTimes(11); + }); + + it("tries it again once the park expires", async () => { + const { exa } = limitedThenFallback(); + const cooldown = createProviderCooldown(); + const config = makeConfig({ provider: "exa", fallback: ["duckduckgo"] }); + let clock = T0; + + await runWebSearchWithFallback({ + config, deps: {}, options: makeOptions(), cooldown, now: () => clock, + }); + clock = T0 + 61_000; + await runWebSearchWithFallback({ + config, + deps: {}, + options: { ...makeOptions(), query: "later" }, + cooldown, + now: () => clock, + }); + expect(exa.search).toHaveBeenCalledTimes(2); + }); + + it("says out loud that the answer came from the fallback", async () => { + // The other half of #179: the chain worked, so nothing failed, so + // nothing was reported — and a whole campaign was quietly served by + // the weaker provider. + const { } = limitedThenFallback(); + const cooldown = createProviderCooldown(); + const config = makeConfig({ provider: "exa", fallback: ["duckduckgo"] }); + + const first = await runWebSearchWithFallback({ + config, deps: {}, options: makeOptions(), cooldown, now: () => T0, + }); + expect(first.degraded).toEqual([ + "exa rate limited (HTTP 429), parked for 1m", + ]); + + const second = await runWebSearchWithFallback({ + config, + deps: {}, + options: { ...makeOptions(), query: "next" }, + cooldown, + now: () => T0 + 20_000, + }); + expect(second.degraded).toEqual([ + "exa skipped: rate limited, retrying in 40s", + ]); + }); + + it("still serves a parked provider's cached results", async () => { + // The park is about quota, not staleness. An answer already in hand + // is not worse because the provider that gave it has since run out. + const exa = stubProvider("exa", async () => [RESULT]); + vi.mocked(resolveProviderByName).mockReturnValue(exa); + const cooldown = createProviderCooldown(); + const cache = createSearchCache({ ttlMs: 60_000 }); + const config = makeConfig({ provider: "exa", fallback: [] }); + + await runWebSearchWithFallback({ + config, deps: {}, options: makeOptions(), cache, cooldown, now: () => T0, + }); + cooldown.park("exa", T0, null); + + const out = await runWebSearchWithFallback({ + config, deps: {}, options: makeOptions(), cache, cooldown, now: () => T0, + }); + expect(out.fromCache).toBe(true); + expect(out.results).toEqual([RESULT]); + expect(exa.search).toHaveBeenCalledOnce(); + }); + + it("does not park a provider that failed for some other reason", async () => { + // A blocked page or a dead endpoint should be retried on the next + // query; only a quota earns silence. + const exa = stubProvider("exa", async () => { + throw new WebSearchBlockedError("exa"); + }); + const ddg = stubProvider("duckduckgo", async () => [RESULT]); + vi.mocked(resolveProviderByName).mockImplementation((name) => + name === "exa" ? exa : ddg, + ); + const cooldown = createProviderCooldown(); + const config = makeConfig({ provider: "exa", fallback: ["duckduckgo"] }); + + for (let i = 0; i < 3; i++) { + const out = await runWebSearchWithFallback({ + config, + deps: {}, + options: { ...makeOptions(), query: `q${i}` }, + cooldown, + now: () => T0, + }); + expect(out.degraded).toEqual([]); + } + expect(exa.search).toHaveBeenCalledTimes(3); + }); + + it("behaves exactly as before when no cooldown is supplied", async () => { + const { exa } = limitedThenFallback(); + const config = makeConfig({ provider: "exa", fallback: ["duckduckgo"] }); + for (let i = 0; i < 3; i++) { + await runWebSearchWithFallback({ + config, deps: {}, options: { ...makeOptions(), query: `q${i}` }, + }); + } + expect(exa.search).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/tools/os/web-search/providers/search-orchestrator.ts b/src/tools/os/web-search/providers/search-orchestrator.ts index 80b9c505..2ef1ac74 100644 --- a/src/tools/os/web-search/providers/search-orchestrator.ts +++ b/src/tools/os/web-search/providers/search-orchestrator.ts @@ -3,7 +3,14 @@ import { buildSearchCacheKey, type SearchCache, } from "../transport/search-cache.js"; -import { WebSearchBlockedError } from "../web-search-errors.js"; +import { + formatCooldown, + type ProviderCooldown, +} from "../transport/provider-cooldown.js"; +import { + WebSearchBlockedError, + WebSearchRateLimitedError, +} from "../web-search-errors.js"; import type { WebSearchHttpDeps, WebSearchProviderName, @@ -19,12 +26,32 @@ export interface WebSearchOrchestratorInput { cache?: SearchCache; /** Process env source for provider key checks; injectable for tests. */ env?: NodeJS.ProcessEnv; + /** + * Parked providers. Optional so existing callers and tests keep + * working; without it the chain behaves exactly as it did, which is + * to say it walks back into the same rate limit on every query. + */ + cooldown?: ProviderCooldown; + /** Injectable clock, so a cooldown test does not wait out a real minute. */ + now?: () => number; } export interface WebSearchOrchestratorResult { results: WebSearchResult[]; provider: WebSearchProviderName; fromCache: boolean; + /** + * Providers that did not get to answer, and why — a rate limit they + * had just hit, or one they are still parked for. Empty on the happy + * path. + * + * This is the answer to the half of #179 that backoff does not touch: + * the fallback chain worked exactly as designed, so nothing failed, + * so nothing was reported — and a campaign spent 44% of its tool calls + * being quietly served by the weaker provider. A degradation nobody + * can see is not a degradation anybody fixes. + */ + degraded: readonly string[]; } /** @@ -41,12 +68,25 @@ export async function runWebSearchWithFallback( const env = input.env ?? process.env; const chain = buildProviderChain(search.provider, search.fallback); + const now = input.now ?? Date.now; + const cooldown = input.cooldown; let firstError: unknown; let lastEmpty: WebSearchOrchestratorResult | undefined; + const degraded: string[] = []; for (const name of chain) { if (!isProviderUsable(name, input.config, env)) continue; + // Parked for a rate limit it hit earlier. Skipping it here is the + // whole point: against a standing quota the alternative is three + // requests that cannot succeed and ~1.5s of backoff, on every + // single query, before reaching the provider that was always going + // to serve it. + // + // The cache is still consulted first — a parked provider's earlier + // answers are not stale just because its quota ran out. + const parkedFor = cooldown?.remainingMs(name, now()) ?? 0; + const cacheKey = buildSearchCacheKey( name, input.options.query, @@ -55,9 +95,16 @@ export async function runWebSearchWithFallback( const cached = input.cache?.get(cacheKey); if (cached) { if (cached.length > 0) { - return { results: cached, provider: name, fromCache: true }; + return { results: cached, provider: name, fromCache: true, degraded }; } - lastEmpty = { results: cached, provider: name, fromCache: true }; + lastEmpty = { results: cached, provider: name, fromCache: true, degraded }; + continue; + } + + if (parkedFor > 0) { + degraded.push( + `${name} skipped: rate limited, retrying in ${formatCooldown(parkedFor)}`, + ); continue; } @@ -65,12 +112,22 @@ export async function runWebSearchWithFallback( try { const results = await provider.search(input.options); input.cache?.set(cacheKey, results); + // It answered, so whatever it was parked for is over. Clearing + // the strike count here is what keeps the escalation honest: the + // ladder measures *consecutive* failures, not lifetime ones. + cooldown?.clear(name); if (results.length > 0) { - return { results, provider: name, fromCache: false }; + return { results, provider: name, fromCache: false, degraded }; } - lastEmpty = { results, provider: name, fromCache: false }; + lastEmpty = { results, provider: name, fromCache: false, degraded }; } catch (err) { if (firstError === undefined) firstError = err; + if (err instanceof WebSearchRateLimitedError && cooldown) { + const parked = cooldown.park(name, now(), err.retryAfterMs); + degraded.push( + `${name} rate limited (HTTP 429), parked for ${formatCooldown(parked)}`, + ); + } // WebSearchBlockedError and transport throws both advance the chain. } } diff --git a/src/tools/os/web-search/providers/searxng-provider.ts b/src/tools/os/web-search/providers/searxng-provider.ts index acddbf51..bb33e8bd 100644 --- a/src/tools/os/web-search/providers/searxng-provider.ts +++ b/src/tools/os/web-search/providers/searxng-provider.ts @@ -1,3 +1,4 @@ +import { assertProviderStatus } from "./assert-provider-status.js"; import { searchHttp } from "../transport/search-http.js"; import type { WebSearchHttpDeps, @@ -40,9 +41,7 @@ export function createSearxngProvider( runCommand: deps.runCommand, lookup: deps.lookup, }); - if (response.status >= 400) { - throw new Error(`SearXNG returned HTTP ${response.status}`); - } + assertProviderStatus(response, "searxng", "SearXNG"); return parseSearxngJson(response.body, options.maxResults); }, }; diff --git a/src/tools/os/web-search/tool/index.ts b/src/tools/os/web-search/tool/index.ts index d97e7b43..6c61e756 100644 --- a/src/tools/os/web-search/tool/index.ts +++ b/src/tools/os/web-search/tool/index.ts @@ -1,2 +1,4 @@ export { buildOsWebSearchTool } from "./web-search-tool.js"; export type { OsWebSearchOptions } from "./web-search-tool.js"; +export { checkMissingSearchKey } from "./warn-missing-search-key.js"; +export type { MissingSearchKeyWarning } from "./warn-missing-search-key.js"; diff --git a/src/tools/os/web-search/tool/warn-missing-search-key.test.ts b/src/tools/os/web-search/tool/warn-missing-search-key.test.ts new file mode 100644 index 00000000..fe270f62 --- /dev/null +++ b/src/tools/os/web-search/tool/warn-missing-search-key.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import type { AtomicAgentConfig } from "../../../../config/index.js"; +import { checkMissingSearchKey } from "./warn-missing-search-key.js"; + +function makeConfig( + overrides: Partial = {}, +): Pick { + return { + web: { + search: { + enabled: true, + provider: "exa", + maxResults: 8, + timeoutMs: 15_000, + cacheTtlMinutes: 15, + fallback: ["duckduckgo"], + searxng: { instanceUrl: null }, + exa: { + endpoint: "https://mcp.exa.ai/mcp", + apiEndpoint: "https://api.exa.ai/search", + apiKeyEnv: "EXA_API_KEY", + }, + brave: { apiKeyEnv: "BRAVE_SEARCH_API_KEY" }, + ...overrides, + }, + }, + } as Pick; +} + +describe("checkMissingSearchKey", () => { + it("warns on the shipped default: exa primary with no EXA_API_KEY", () => { + const warning = checkMissingSearchKey({ config: makeConfig(), env: {} }); + + expect(warning).not.toBeNull(); + expect(warning!.provider).toBe("exa"); + expect(warning!.apiKeyEnv).toBe("EXA_API_KEY"); + // The message must name the silent consequence, not just the missing key. + expect(warning!.message).toContain("EXA_API_KEY"); + expect(warning!.message).toContain("duckduckgo"); + expect(warning!.message).toContain("429"); + }); + + it("stays silent when the key is present", () => { + expect( + checkMissingSearchKey({ config: makeConfig(), env: { EXA_API_KEY: "k" } }), + ).toBeNull(); + }); + + it("treats a whitespace-only key as missing", () => { + expect( + checkMissingSearchKey({ config: makeConfig(), env: { EXA_API_KEY: " " } }), + ).not.toBeNull(); + }); + + it("stays silent for keyless-by-design providers", () => { + for (const provider of ["duckduckgo", "searxng"] as const) { + expect( + checkMissingSearchKey({ config: makeConfig({ provider }), env: {} }), + ).toBeNull(); + } + }); + + it("warns for a brave primary against its own env var", () => { + const warning = checkMissingSearchKey({ + config: makeConfig({ provider: "brave" }), + env: {}, + }); + + expect(warning!.apiKeyEnv).toBe("BRAVE_SEARCH_API_KEY"); + }); + + it("stays silent when search is disabled outright", () => { + expect( + checkMissingSearchKey({ config: makeConfig({ enabled: false }), env: {} }), + ).toBeNull(); + }); + + it("says so when no fallback is configured", () => { + const warning = checkMissingSearchKey({ + config: makeConfig({ fallback: [] }), + env: {}, + }); + + expect(warning!.message).toContain("no fallback configured"); + }); + + it("dedupes the primary out of the reported fallback chain", () => { + const warning = checkMissingSearchKey({ + config: makeConfig({ fallback: ["exa", "duckduckgo"] }), + env: {}, + }); + + expect(warning!.fallback).toEqual(["duckduckgo"]); + }); +}); diff --git a/src/tools/os/web-search/tool/warn-missing-search-key.ts b/src/tools/os/web-search/tool/warn-missing-search-key.ts new file mode 100644 index 00000000..d7a308b7 --- /dev/null +++ b/src/tools/os/web-search/tool/warn-missing-search-key.ts @@ -0,0 +1,91 @@ +import type { AtomicAgentConfig } from "../../../../config/index.js"; +import type { WebSearchProviderName } from "../web-search-provider.js"; + +/** + * Startup diagnostic for a keyless primary search provider. + * + * `web.search.provider` defaults to `exa` with a `duckduckgo` fallback, and + * Exa's keyless endpoint answers HTTP 429 under sustained agent load. The + * fallback chain then works exactly as designed, so nothing hard-fails — the + * run just quietly produces weaker groundings than the operator configured. + * That silent degradation is the failure mode this warning exists to break: + * it neither works well nor tells you why (#179). + */ + +/** Providers whose configured `apiKeyEnv` materially changes their quota. */ +const KEYED_PROVIDERS = new Set(["exa", "brave"]); + +export interface MissingSearchKeyWarning { + provider: WebSearchProviderName; + apiKeyEnv: string; + /** Providers that will actually serve traffic once the primary is limited. */ + fallback: WebSearchProviderName[]; + message: string; +} + +/** + * Returns a warning when the configured primary provider reads an API key + * from the environment and that variable resolves to nothing. Returns `null` + * for a keyed primary, a keyless-by-design primary (`duckduckgo`, `searxng`), + * or when search is disabled outright. + */ +export function checkMissingSearchKey(input: { + config: Pick; + env: NodeJS.ProcessEnv; +}): MissingSearchKeyWarning | null { + const search = input.config.web.search; + if (!search.enabled) return null; + + const provider = search.provider; + if (!KEYED_PROVIDERS.has(provider)) return null; + + const apiKeyEnv = + provider === "exa" ? search.exa.apiKeyEnv : search.brave.apiKeyEnv; + const key = input.env[apiKeyEnv]?.trim(); + if (typeof key === "string" && key.length > 0) return null; + + // Dedupe the primary out of the chain the same way the orchestrator does. + const fallback = search.fallback.filter((name) => name !== provider); + + return { + provider, + apiKeyEnv, + fallback, + message: buildMessage(provider, apiKeyEnv, fallback), + }; +} + +/** + * Providers that actually have a keyless tier. Exa falls back to the + * public MCP endpoint without a key; Brave has no such tier, so a + * keyless Brave is not "degraded" — `isProviderUsable` skips it outright + * and the chain never sends it a request. Telling that operator to + * expect 429s points them at a rate limit that cannot happen instead of + * at the real problem: their configured primary is disabled. + */ +const KEYLESS_TIER_PROVIDERS = new Set(["exa"]); + +function buildMessage( + provider: WebSearchProviderName, + apiKeyEnv: string, + fallback: WebSearchProviderName[], +): string { + const destination = + fallback.length > 0 ? fallback.join(", ") : "no other provider"; + if (!KEYLESS_TIER_PROVIDERS.has(provider)) { + return ( + `web.search: provider "${provider}" is configured but ${apiKeyEnv} is not set, ` + + `so it is skipped entirely — every search goes to ${destination}. ` + + `Set ${apiKeyEnv} to use it.` + ); + } + const consequence = + fallback.length > 0 + ? `expect HTTP 429 and silent degradation to ${fallback.join(", ")}` + : "expect HTTP 429 with no fallback configured"; + return ( + `web.search: provider "${provider}" is configured but ${apiKeyEnv} is not set; ` + + `running on the keyless tier — ${consequence}. ` + + `Set ${apiKeyEnv} for search-heavy autonomous work.` + ); +} diff --git a/src/tools/os/web-search/tool/web-search-tool.test.ts b/src/tools/os/web-search/tool/web-search-tool.test.ts index ccc992e2..6e710622 100644 --- a/src/tools/os/web-search/tool/web-search-tool.test.ts +++ b/src/tools/os/web-search/tool/web-search-tool.test.ts @@ -190,3 +190,42 @@ describe("os.web.search", () => { expect(result.details.provider).toBe("duckduckgo"); }); }); + +describe("buildOsWebSearchTool keyless-provider warning", () => { + it("warns once at construction, not once per search", async () => { + // Per-search warnings would flood a long autonomous run; the operator + // needs exactly one line telling them search is degraded (#179). + const warnings: string[] = []; + // Fail every curl immediately: this test is about warning cardinality, + // and a real network round-trip would make it slow and flaky. + const failingRunCommand = (async () => { + throw new Error("network disabled in test"); + }) as unknown as typeof RunCommandType; + const tool = buildOsWebSearchTool({ + config: makeConfig({ provider: "exa", fallback: ["duckduckgo"] }), + env: {}, + warn: (message) => warnings.push(message), + runCommand: failingRunCommand, + lookup: publicLookup, + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("EXA_API_KEY"); + + await tool.run({ query: "a" }, makeCtx()).catch(() => undefined); + await tool.run({ query: "b" }, makeCtx()).catch(() => undefined); + + expect(warnings).toHaveLength(1); + }); + + it("stays silent when the provider key is set", () => { + const warnings: string[] = []; + buildOsWebSearchTool({ + config: makeConfig({ provider: "exa" }), + env: { EXA_API_KEY: "k" }, + warn: (message) => warnings.push(message), + }); + + expect(warnings).toEqual([]); + }); +}); diff --git a/src/tools/os/web-search/tool/web-search-tool.ts b/src/tools/os/web-search/tool/web-search-tool.ts index 4aa76469..d60e4061 100644 --- a/src/tools/os/web-search/tool/web-search-tool.ts +++ b/src/tools/os/web-search/tool/web-search-tool.ts @@ -6,8 +6,10 @@ import { import type { ToolDefinition } from "../../../tool-registry.js"; import type { HostLookup } from "../../web-fetch-ssrf-guard.js"; import { runWebSearchWithFallback } from "../providers/index.js"; +import { createProviderCooldown } from "../transport/provider-cooldown.js"; import { createSearchCache } from "../transport/search-cache.js"; import type { WebSearchResult } from "../web-search-provider.js"; +import { checkMissingSearchKey } from "./warn-missing-search-key.js"; const TOOL_NAME = "os.web.search"; const MAX_RESULTS_CAP = 20; @@ -16,6 +18,10 @@ export interface OsWebSearchOptions { config: Pick; runCommand?: typeof defaultRunCommand; lookup?: HostLookup; + /** Process env source for the missing-key check; injectable for tests. */ + env?: NodeJS.ProcessEnv; + /** Warning sink; defaults to stderr. Injectable for tests. */ + warn?: (message: string) => void; } interface WebSearchArgs { @@ -29,6 +35,23 @@ export function buildOsWebSearchTool(options: OsWebSearchOptions): ToolDefinitio // HTTP round-trip — the primary defence against provider rate-limiting. const cfg0 = options.config.web.search; const cache = createSearchCache({ ttlMs: cfg0.cacheTtlMinutes * 60_000 }); + // Same lifetime and same reason as the cache: a rate limit is a fact + // about the last few minutes of this process, so it lives in this + // closure rather than in a global or on disk. + const cooldown = createProviderCooldown(); + + // Emitted once at construction, not per search: a keyless primary provider + // degrades every subsequent query, and one line at startup is what turns + // that from invisible into diagnosable (#179). + const missingKey = checkMissingSearchKey({ + config: options.config, + env: options.env ?? process.env, + }); + if (missingKey) { + const warn = + options.warn ?? ((message: string) => process.stderr.write(`${message}\n`)); + warn(missingKey.message); + } return { name: TOOL_NAME, description: @@ -64,17 +87,21 @@ export function buildOsWebSearchTool(options: OsWebSearchOptions): ToolDefinitio signal: ctx.signal, }, cache, + cooldown, }); return compressToolResult( { tool: TOOL_NAME, status: "ok", - output: renderResults(outcome.results), + output: renderNotes(outcome.degraded) + renderResults(outcome.results), details: { provider: outcome.provider, fromCache: outcome.fromCache, query: args.query, results: outcome.results, + ...(outcome.degraded.length > 0 + ? { degraded: outcome.degraded } + : {}), }, }, { @@ -113,6 +140,22 @@ function parseArgs( return { query: query.trim(), maxResults }; } +/** + * Whatever the chain had to skip to answer, printed above the results. + * + * In `details` as well, but `details` is structured metadata and this is + * the half the model reads. The degradation #179 measured was invisible + * precisely because it was not a failure: the fallback worked, results + * came back, and nothing anywhere said they came from the weaker + * provider because the stronger one was out of quota. A model that can + * see the line can say so in its answer; an operator reading the + * transcript can act on it. + */ +function renderNotes(degraded: readonly string[]): string { + if (degraded.length === 0) return ""; + return `${degraded.map((note) => `[search] ${note}`).join("\n")}\n\n`; +} + function renderResults(results: readonly WebSearchResult[]): string { if (results.length === 0) return "No search results."; return results diff --git a/src/tools/os/web-search/transport/index.ts b/src/tools/os/web-search/transport/index.ts index a9a22a9d..5b8ce558 100644 --- a/src/tools/os/web-search/transport/index.ts +++ b/src/tools/os/web-search/transport/index.ts @@ -7,6 +7,21 @@ export type { SearchHttpRequest, SearchHttpResponse, } from "./search-http.js"; +export { + computeRetryDelayMs, + DEFAULT_SEARCH_RETRY_POLICY, + MAX_RETRY_AFTER_MS, + parseRetryAfterMs, +} from "./retry-after.js"; +export type { SearchRetryPolicy } from "./retry-after.js"; +export { + createProviderCooldown, + formatCooldown, +} from "./provider-cooldown.js"; +export type { + ProviderCooldown, + ProviderCooldownOptions, +} from "./provider-cooldown.js"; export { buildSearchCacheKey, createSearchCache, diff --git a/src/tools/os/web-search/transport/provider-cooldown.test.ts b/src/tools/os/web-search/transport/provider-cooldown.test.ts new file mode 100644 index 00000000..aa293240 --- /dev/null +++ b/src/tools/os/web-search/transport/provider-cooldown.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + createProviderCooldown, + formatCooldown, +} from "./provider-cooldown.js"; + +/** + * The behaviour issue #179 asked for, stated as a schedule rather than + * as "backoff": against a *standing* quota — 1341 429s spread evenly + * across 24 hours — the useful move is not waiting longer between + * retries, it is not sending the retries. + */ +describe("createProviderCooldown", () => { + const T0 = 1_000_000; + + it("parks nothing until something is rate limited", () => { + const cooldown = createProviderCooldown(); + expect(cooldown.isParked("exa", T0)).toBe(false); + expect(cooldown.remainingMs("exa", T0)).toBe(0); + }); + + it("parks for a minute on the first 429", () => { + const cooldown = createProviderCooldown(); + expect(cooldown.park("exa", T0, null)).toBe(60_000); + expect(cooldown.isParked("exa", T0)).toBe(true); + expect(cooldown.remainingMs("exa", T0 + 59_000)).toBe(1000); + expect(cooldown.isParked("exa", T0 + 60_001)).toBe(false); + }); + + it("parks only the provider that was limited", () => { + const cooldown = createProviderCooldown(); + cooldown.park("exa", T0, null); + expect(cooldown.isParked("duckduckgo", T0)).toBe(false); + }); + + it("doubles on each consecutive 429, up to the ceiling", () => { + const cooldown = createProviderCooldown(); + expect(cooldown.park("exa", T0, null)).toBe(60_000); + expect(cooldown.park("exa", T0, null)).toBe(120_000); + expect(cooldown.park("exa", T0, null)).toBe(240_000); + expect(cooldown.park("exa", T0, null)).toBe(480_000); + expect(cooldown.park("exa", T0, null)).toBe(900_000); + expect(cooldown.park("exa", T0, null)).toBe(900_000); + }); + + it("keeps escalating across an expired park", () => { + // A provider limited three times in ten minutes has a standing + // quota whether or not its last park has lapsed. Restarting the + // ladder at one minute each time would walk straight back into it. + const cooldown = createProviderCooldown(); + cooldown.park("exa", T0, null); + expect(cooldown.park("exa", T0 + 61_000, null)).toBe(120_000); + }); + + it("resets the ladder once the provider answers", () => { + const cooldown = createProviderCooldown(); + cooldown.park("exa", T0, null); + cooldown.park("exa", T0, null); + cooldown.clear("exa"); + expect(cooldown.isParked("exa", T0)).toBe(false); + expect(cooldown.park("exa", T0, null)).toBe(60_000); + }); + + it("honours a longer Retry-After than the ladder would pick", () => { + const cooldown = createProviderCooldown(); + expect(cooldown.park("exa", T0, 300_000)).toBe(300_000); + }); + + it("ignores a Retry-After shorter than the ladder", () => { + // Otherwise a provider answering `Retry-After: 1` on every request + // defeats the escalation by being polite about it. + const cooldown = createProviderCooldown(); + cooldown.park("exa", T0, null); + expect(cooldown.park("exa", T0, 1000)).toBe(120_000); + }); + + it("clamps a Retry-After that is really a lockout", () => { + // A day-long header would park the provider past the end of any + // session, on one server's say-so. + const cooldown = createProviderCooldown(); + expect(cooldown.park("exa", T0, 86_400_000)).toBe(900_000); + }); + + it("treats a negative Retry-After as no advice at all", () => { + const cooldown = createProviderCooldown(); + expect(cooldown.park("exa", T0, -5000)).toBe(60_000); + }); +}); + +describe("formatCooldown", () => { + it("reads as a wait, not as a number of milliseconds", () => { + expect(formatCooldown(45_000)).toBe("45s"); + expect(formatCooldown(60_000)).toBe("1m"); + expect(formatCooldown(90_000)).toBe("1m 30s"); + expect(formatCooldown(900_000)).toBe("15m"); + expect(formatCooldown(0)).toBe("0s"); + expect(formatCooldown(-1)).toBe("0s"); + }); +}); diff --git a/src/tools/os/web-search/transport/provider-cooldown.ts b/src/tools/os/web-search/transport/provider-cooldown.ts new file mode 100644 index 00000000..53a6d92f --- /dev/null +++ b/src/tools/os/web-search/transport/provider-cooldown.ts @@ -0,0 +1,139 @@ +import type { WebSearchProviderName } from "../web-search-provider.js"; + +/** + * Which providers are parked, and until when. + * + * The transport already retries a 429 twice against the same provider + * before the orchestrator advances the chain, which is the right + * behaviour for a *burst*. Issue #179 measured what happens when the + * limit is not a burst: 1341 `Exa returned HTTP 429` errors in one + * campaign — 44% of all tool failures — spread evenly across all + * twenty-four hours, 8 to 20 an hour, not tracking concurrency at all. + * That is a standing quota on the keyless tier. + * + * Against a standing quota the retry ladder is worse than useless. Every + * search re-enters it from the top: three requests that cannot succeed, + * ~1.5s of backoff slept through, and only then the fallback that was + * always going to serve the query. Multiply by a search-heavy run. + * + * So a provider that is out of quota gets parked. The next search skips + * it outright — no request, no sleep — and goes straight to the + * provider that can actually answer. When the park expires it is tried + * again, and one success clears the record. + * + * **Per-runtime, like the result cache.** Not a global singleton and not + * persisted: a quota window is a fact about the last few minutes, and a + * cooldown restored from disk at start-up would park a provider on + * yesterday's evidence. + */ +export interface ProviderCooldown { + /** True while `name` is parked — the orchestrator skips it. */ + isParked(name: WebSearchProviderName, now: number): boolean; + /** Milliseconds left on the park, or `0` when it is not parked. */ + remainingMs(name: WebSearchProviderName, now: number): number; + /** + * Record a rate limit and park the provider. Returns the park length + * actually applied, so the caller can say it out loud. + */ + park( + name: WebSearchProviderName, + now: number, + retryAfterMs: number | null, + ): number; + /** A provider answered. Forget its history so the next park starts small. */ + clear(name: WebSearchProviderName): void; +} + +export interface ProviderCooldownOptions { + /** First park after a single 429. */ + baseMs?: number; + /** Ceiling on the doubling. */ + maxMs?: number; +} + +/** + * One minute, then two, then four… A single 429 is often a burst that + * the transport's retries did not quite outlast, and parking such a + * provider for a quarter of an hour would be its own kind of silent + * degradation. Repeated 429s are the signal that the limit is standing, + * and that is what the doubling is listening for. + */ +const DEFAULT_BASE_MS = 60_000; + +/** + * Fifteen minutes. Long enough that a search-heavy run stops paying the + * failed-request tax, short enough that a quota which resets hourly is + * noticed within the same session. + */ +const DEFAULT_MAX_MS = 15 * 60_000; + +/** + * A `Retry-After` this long is not advice about the next few seconds, + * it is a lockout — and honouring it verbatim would park the provider + * past the end of most sessions on one header. Clamped to the same + * ceiling the doubling respects. + */ +const MAX_HONOURED_RETRY_AFTER_MS = DEFAULT_MAX_MS; + +interface CooldownEntry { + until: number; + /** Consecutive parks, for the doubling. */ + strikes: number; +} + +export function createProviderCooldown( + options: ProviderCooldownOptions = {}, +): ProviderCooldown { + const baseMs = options.baseMs ?? DEFAULT_BASE_MS; + const maxMs = options.maxMs ?? DEFAULT_MAX_MS; + const entries = new Map(); + + function remainingMs(name: WebSearchProviderName, now: number): number { + const entry = entries.get(name); + if (!entry) return 0; + return Math.max(0, entry.until - now); + } + + return { + remainingMs, + isParked(name, now) { + return remainingMs(name, now) > 0; + }, + park(name, now, retryAfterMs) { + const previous = entries.get(name); + // Strikes survive an expired park on purpose: a provider that has + // been rate-limited three times in the last ten minutes has a + // standing quota whether or not its last park has lapsed, and + // restarting the ladder at one minute each time would walk it + // back into the same wall. `clear` is what resets this, and only + // a successful search calls `clear`. + const strikes = (previous?.strikes ?? 0) + 1; + const escalated = Math.min(maxMs, baseMs * 2 ** (strikes - 1)); + // The server's own number wins when it gave one — it is the only + // party that knows when the window actually resets — but never + // below the escalated floor, or a provider answering + // `Retry-After: 1` on every request would defeat the ladder by + // being polite about it. + const advertised = + retryAfterMs === null + ? 0 + : Math.min(MAX_HONOURED_RETRY_AFTER_MS, Math.max(0, retryAfterMs)); + const parkMs = Math.max(escalated, advertised); + entries.set(name, { until: now + parkMs, strikes }); + return parkMs; + }, + clear(name) { + entries.delete(name); + }, + }; +} + +/** `90000` -> `1m 30s`, for the line the model reads. */ +export function formatCooldown(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + if (minutes === 0) return `${seconds}s`; + if (seconds === 0) return `${minutes}m`; + return `${minutes}m ${seconds}s`; +} diff --git a/src/tools/os/web-search/transport/retry-after.test.ts b/src/tools/os/web-search/transport/retry-after.test.ts new file mode 100644 index 00000000..13a72f21 --- /dev/null +++ b/src/tools/os/web-search/transport/retry-after.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + computeRetryDelayMs, + DEFAULT_SEARCH_RETRY_POLICY, + MAX_RETRY_AFTER_MS, + parseRetryAfterMs, +} from "./retry-after.js"; + +const NOW = Date.parse("2026-08-20T12:00:00Z"); + +describe("parseRetryAfterMs", () => { + it("reads the delta-seconds form", () => { + expect(parseRetryAfterMs("2", NOW)).toBe(2000); + }); + + it("reads the HTTP-date form relative to now", () => { + expect(parseRetryAfterMs("Thu, 20 Aug 2026 12:00:03 GMT", NOW)).toBe(3000); + }); + + it("clamps a hostile far-future value to the ceiling", () => { + // One bad header must not stall an agent turn for minutes. + expect(parseRetryAfterMs("3600", NOW)).toBe(MAX_RETRY_AFTER_MS); + }); + + it("treats an already-elapsed date as no wait", () => { + expect(parseRetryAfterMs("Thu, 20 Aug 2026 11:59:00 GMT", NOW)).toBe(0); + }); + + it("returns null when absent or unparseable so backoff takes over", () => { + expect(parseRetryAfterMs(undefined, NOW)).toBeNull(); + expect(parseRetryAfterMs(null, NOW)).toBeNull(); + expect(parseRetryAfterMs("", NOW)).toBeNull(); + expect(parseRetryAfterMs("soon", NOW)).toBeNull(); + // Must not accept a partially-numeric value as 10 seconds. + expect(parseRetryAfterMs("10abc", NOW)).toBeNull(); + }); +}); + +describe("computeRetryDelayMs", () => { + it("doubles the base delay per attempt when the server gave no header", () => { + const policy = DEFAULT_SEARCH_RETRY_POLICY; + expect(computeRetryDelayMs({ attempt: 1, policy, retryAfterMs: null })).toBe(500); + expect(computeRetryDelayMs({ attempt: 2, policy, retryAfterMs: null })).toBe(1000); + expect(computeRetryDelayMs({ attempt: 3, policy, retryAfterMs: null })).toBe(2000); + }); + + it("prefers the server's Retry-After over its own schedule", () => { + expect( + computeRetryDelayMs({ + attempt: 1, + policy: DEFAULT_SEARCH_RETRY_POLICY, + retryAfterMs: 4000, + }), + ).toBe(4000); + }); + + it("clamps its own exponential schedule to the ceiling", () => { + expect( + computeRetryDelayMs({ + attempt: 20, + policy: DEFAULT_SEARCH_RETRY_POLICY, + retryAfterMs: null, + }), + ).toBe(MAX_RETRY_AFTER_MS); + }); +}); diff --git a/src/tools/os/web-search/transport/retry-after.ts b/src/tools/os/web-search/transport/retry-after.ts new file mode 100644 index 00000000..0e69f8c5 --- /dev/null +++ b/src/tools/os/web-search/transport/retry-after.ts @@ -0,0 +1,71 @@ +/** + * Retry scheduling for rate-limited (HTTP 429) search responses. + * + * The keyless tiers every default provider rides on (Exa's MCP endpoint, + * DuckDuckGo's HTML endpoint) answer 429 under sustained agent load. Before + * this module a single 429 threw straight out of the provider and the + * orchestrator advanced the chain, which permanently downgraded a + * search-heavy session to the weakest provider on the first transient limit. + * Retrying the primary a couple of times first keeps the configured provider + * in play; the fallback chain remains the backstop when the limit is real. + */ + +/** Ceiling on a server-advertised `Retry-After`, so one hostile header cannot stall a turn. */ +export const MAX_RETRY_AFTER_MS = 10_000; + +export interface SearchRetryPolicy { + /** Extra attempts after the initial request. `0` disables retrying. */ + maxRetries: number; + /** Delay for the first retry; each subsequent retry doubles it. */ + baseDelayMs: number; +} + +export const DEFAULT_SEARCH_RETRY_POLICY: SearchRetryPolicy = { + maxRetries: 2, + baseDelayMs: 500, +}; + +/** + * Parse a `Retry-After` header value. Supports both documented forms: + * delta-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026 07:28:00 GMT`). + * Returns `null` when absent or unparseable so the caller falls back to its + * own backoff schedule. The result is clamped to `[0, MAX_RETRY_AFTER_MS]`. + */ +export function parseRetryAfterMs( + headerValue: string | null | undefined, + now: number, +): number | null { + if (typeof headerValue !== "string") return null; + const raw = headerValue.trim(); + if (raw.length === 0) return null; + + // delta-seconds. Guard against `Number.parseInt` accepting "10abc". + if (/^\d+$/.test(raw)) { + const seconds = Number.parseInt(raw, 10); + if (!Number.isFinite(seconds)) return null; + return clampDelay(seconds * 1000); + } + + const at = Date.parse(raw); + if (!Number.isFinite(at)) return null; + return clampDelay(at - now); +} + +/** + * Delay before retry number `attempt` (1-based): the server's `Retry-After` + * when it gave one, otherwise exponential backoff from `baseDelayMs`. + */ +export function computeRetryDelayMs(input: { + attempt: number; + policy: SearchRetryPolicy; + retryAfterMs: number | null; +}): number { + if (input.retryAfterMs !== null) return clampDelay(input.retryAfterMs); + const exponent = Math.max(0, input.attempt - 1); + return clampDelay(input.policy.baseDelayMs * 2 ** exponent); +} + +function clampDelay(ms: number): number { + if (!Number.isFinite(ms) || ms <= 0) return 0; + return Math.min(ms, MAX_RETRY_AFTER_MS); +} diff --git a/src/tools/os/web-search/transport/search-http.test.ts b/src/tools/os/web-search/transport/search-http.test.ts new file mode 100644 index 00000000..06d3a930 --- /dev/null +++ b/src/tools/os/web-search/transport/search-http.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { searchHttp } from "./search-http.js"; +import type { runCommand as RunCommandType } from "../../../../sandbox/command-runner.js"; +import type { HostLookup } from "../../web-fetch-ssrf-guard.js"; + +const publicLookup: HostLookup = async () => [ + { address: "93.184.216.34", family: 4 }, +]; + +/** + * Builds the curl stdout envelope that `parseCurlMeta` expects: the response + * body followed by the trailing `__ATOMIC_WEB_SEARCH_META__status|ct|redir|size` + * block that searchHttp appends via `curl -w`. + */ +function stubCurlStdout(body: string): string { + return `${body}\n__ATOMIC_WEB_SEARCH_META__200|text/html||${body.length}`; +} + +/** Curl envelope for an arbitrary status, with an optional Retry-After header. */ +function stubCurlStatus(status: number, retryAfter = ""): string { + const header = `__ATOMIC_WEB_SEARCH_HEADERS__${retryAfter}`; + return `body\n__ATOMIC_WEB_SEARCH_META__${status}|text/html||4|${header}`; +} + +/** Replays the given stdout envelopes in order, one per curl invocation. */ +function scriptedRunCommand( + stdouts: string[], + calls: string[][], +): typeof RunCommandType { + return (async (_command: string, args: string[]) => { + const stdout = stdouts[calls.length] ?? stdouts.at(-1)!; + calls.push(args); + return { + command: "curl", + args, + exitCode: 0, + signal: null, + stdout, + stderr: "", + durationMs: 1, + timedOut: false, + truncated: false, + }; + }) as unknown as typeof RunCommandType; +} + +function capturingRunCommand(calls: string[][]): typeof RunCommandType { + return (async (_command: string, args: string[]) => { + calls.push(args); + return { + command: "curl", + args, + exitCode: 0, + signal: null, + stdout: stubCurlStdout(""), + stderr: "", + durationMs: 1, + timedOut: false, + truncated: false, + }; + }) as unknown as typeof RunCommandType; +} + +describe("searchHttp curl argv", () => { + it("passes --globoff so bracketed URLs are not read as curl ranges", async () => { + // Search queries routinely carry `[`/`]`/`{`/`}`. Without --globoff curl + // reads them as its own range/set syntax and fails with "bad range in URL". + const calls: string[][] = []; + const url = + "https://search.example/search?q=filter:original:.*[Dd]ewey.*" + + "&range=[202102010000+TO+202104300000]"; + await searchHttp({ + url, + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: capturingRunCommand(calls), + lookup: publicLookup, + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("--globoff"); + // The bracketed URL still reaches curl verbatim as the final operand. + expect(calls[0]![calls[0]!.length - 1]).toContain("[Dd]ewey"); + }); +}); + +describe("searchHttp 429 retry", () => { + /** Records requested backoff instead of spending real wall-clock. */ + function fakeSleep(slept: number[]) { + return async (ms: number) => { + slept.push(ms); + }; + } + + it("retries the SAME provider on 429 and returns the eventual success", async () => { + // The regression this guards: one transient 429 used to throw straight out + // of the provider, permanently downgrading the session to a weaker one. + const calls: string[][] = []; + const slept: number[] = []; + const response = await searchHttp({ + url: "https://search.example/q", + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: scriptedRunCommand( + [stubCurlStatus(429), stubCurlStdout("ok")], + calls, + ), + lookup: publicLookup, + sleep: fakeSleep(slept), + }); + + expect(calls).toHaveLength(2); + expect(response.status).toBe(200); + expect(slept).toEqual([500]); + }); + + it("honours the server's Retry-After over its own backoff schedule", async () => { + const calls: string[][] = []; + const slept: number[] = []; + await searchHttp({ + url: "https://search.example/q", + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: scriptedRunCommand( + [stubCurlStatus(429, "3"), stubCurlStdout("ok")], + calls, + ), + lookup: publicLookup, + sleep: fakeSleep(slept), + now: () => Date.parse("2026-08-20T12:00:00Z"), + }); + + expect(slept).toEqual([3000]); + }); + + it("gives up after maxRetries and returns the 429 so the chain advances", async () => { + // Retrying must not mask a real, standing rate limit: the fallback chain + // is still the backstop once the retries are spent. + const calls: string[][] = []; + const slept: number[] = []; + const response = await searchHttp({ + url: "https://search.example/q", + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: scriptedRunCommand([stubCurlStatus(429)], calls), + lookup: publicLookup, + sleep: fakeSleep(slept), + }); + + expect(response.status).toBe(429); + expect(calls).toHaveLength(3); // initial + 2 retries + expect(slept).toEqual([500, 1000]); + }); + + it("does not retry a non-429 failure", async () => { + const calls: string[][] = []; + const slept: number[] = []; + const response = await searchHttp({ + url: "https://search.example/q", + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: scriptedRunCommand([stubCurlStatus(503)], calls), + lookup: publicLookup, + sleep: fakeSleep(slept), + }); + + expect(response.status).toBe(503); + expect(calls).toHaveLength(1); + expect(slept).toEqual([]); + }); + + it("can be disabled with maxRetries: 0", async () => { + const calls: string[][] = []; + await searchHttp({ + url: "https://search.example/q", + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: scriptedRunCommand([stubCurlStatus(429)], calls), + lookup: publicLookup, + retryPolicy: { maxRetries: 0, baseDelayMs: 500 }, + sleep: async () => {}, + }); + + expect(calls).toHaveLength(1); + }); + + it("tolerates a curl too old for %header{} and falls back to backoff", async () => { + // curl < 7.83 emits the literal format string; it must not be read as a + // Retry-After value. + const calls: string[][] = []; + const slept: number[] = []; + await searchHttp({ + url: "https://search.example/q", + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: scriptedRunCommand( + [ + "body\n__ATOMIC_WEB_SEARCH_META__429|text/html||4|" + + "__ATOMIC_WEB_SEARCH_HEADERS__%header{retry-after}", + stubCurlStdout("ok"), + ], + calls, + ), + lookup: publicLookup, + sleep: fakeSleep(slept), + }); + + expect(slept).toEqual([500]); + }); +}); diff --git a/src/tools/os/web-search/transport/search-http.ts b/src/tools/os/web-search/transport/search-http.ts index bdc1fd52..9117f2f6 100644 --- a/src/tools/os/web-search/transport/search-http.ts +++ b/src/tools/os/web-search/transport/search-http.ts @@ -4,12 +4,20 @@ import { } from "../../../../sandbox/command-runner.js"; import { assertHostAllowed, + formatResolveEntry, parseHttpUrl, type HostLookup, } from "../../web-fetch-ssrf-guard.js"; import { CurlUnavailableError, isCurlMissingError } from "../../ensure-curl.js"; +import { + computeRetryDelayMs, + DEFAULT_SEARCH_RETRY_POLICY, + parseRetryAfterMs, + type SearchRetryPolicy, +} from "./retry-after.js"; const CURL_META_MARKER = "__ATOMIC_WEB_SEARCH_META__"; +const CURL_HEADER_MARKER = "__ATOMIC_WEB_SEARCH_HEADERS__"; const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000; const MAX_REDIRECTS = 3; const USER_AGENT = @@ -31,6 +39,12 @@ export interface SearchHttpRequest { maxResponseBytes?: number; runCommand?: typeof defaultRunCommand; lookup?: HostLookup; + /** Overrides the 429 retry schedule; `maxRetries: 0` disables retrying. */ + retryPolicy?: SearchRetryPolicy; + /** Injectable sleep so tests do not spend real wall-clock in backoff. */ + sleep?: (ms: number, signal: AbortSignal) => Promise; + /** Injectable clock for deterministic `Retry-After` HTTP-date parsing. */ + now?: () => number; } export interface SearchHttpResponse { @@ -40,12 +54,20 @@ export interface SearchHttpResponse { body: string; truncated: boolean; redirectChain: string[]; + /** + * The server's parsed `Retry-After`, or `null` when it did not send + * one. Surfaced rather than consumed internally: once the retry + * ladder is spent, how long to park the provider is a question only + * the server can answer, and the caller is the one parking it. + */ + retryAfterMs: number | null; } interface CurlResponse { status: number; contentType: string; redirectUrl: string; + retryAfter: string; body: string; truncated: boolean; } @@ -53,18 +75,57 @@ interface CurlResponse { export async function searchHttp( request: SearchHttpRequest, ): Promise { + const policy = request.retryPolicy ?? DEFAULT_SEARCH_RETRY_POLICY; + const sleep = request.sleep ?? defaultSleep; + const now = request.now ?? Date.now; + + // Attempt 0 is the initial request; 1..maxRetries are 429 retries. A 429 is + // retried against the SAME provider before the orchestrator is allowed to + // advance the chain, so a transient limit cannot permanently downgrade the + // session to a weaker provider. + for (let attempt = 0; ; attempt++) { + const { retryAfter, ...rest } = await sendOnce(request); + const retryAfterMs = parseRetryAfterMs(retryAfter, now()); + const response: SearchHttpResponse = { ...rest, retryAfterMs }; + if (response.status !== 429 || attempt >= policy.maxRetries) { + return response; + } + const delayMs = computeRetryDelayMs({ + attempt: attempt + 1, + policy, + retryAfterMs, + }); + await sleep(delayMs, request.signal); + // The operator pressed Esc (or the turn was aborted) while we were + // waiting out a rate limit. Sleeping through the abort and then + // firing the next request anyway spends the user's quota on a turn + // that no longer exists — and the request it starts cannot be + // cancelled by the same signal it just ignored. + if (request.signal?.aborted) return response; + } +} + +/** Internal shape: the raw header, before the retry loop parses it. */ +interface SearchHttpAttempt extends Omit { + retryAfter: string; +} + +/** One full request/redirect walk. Retrying re-enters this from the top. */ +async function sendOnce( + request: SearchHttpRequest, +): Promise { const runCommand = request.runCommand ?? defaultRunCommand; const method = request.method ?? "GET"; let currentUrl = parseHttpUrl(request.url); const chain: string[] = []; for (let hop = 0; ; hop++) { - const pinnedIp = await assertHostAllowed(currentUrl, { + const pinnedIps = await assertHostAllowed(currentUrl, { lookup: request.lookup, }); const curlArgs = buildCurlArgs({ url: currentUrl, - pinnedIp, + pinnedIps, method, headers: request.headers ?? {}, hasBody: request.body !== undefined, @@ -106,13 +167,32 @@ export async function searchHttp( body: response.body, truncated: response.truncated, redirectChain: chain, + retryAfter: response.retryAfter, }; } } +/** + * Abort-aware sleep. A cancelled turn must not sit out the backoff: the + * pending timer is cleared and the wait resolves immediately so the caller + * observes the abort on its next checkpoint. + */ +function defaultSleep(ms: number, signal: AbortSignal): Promise { + if (ms <= 0 || signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(finish, ms); + function finish(): void { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + function buildCurlArgs(input: { url: URL; - pinnedIp: string; + pinnedIps: readonly string[]; method: SearchHttpMethod; headers: Record; hasBody: boolean; @@ -120,17 +200,17 @@ function buildCurlArgs(input: { }): string[] { const host = input.url.hostname.replace(/^\[|\]$/g, ""); const port = input.url.port || (input.url.protocol === "https:" ? "443" : "80"); - const resolveTarget = input.pinnedIp.includes(":") - ? `[${input.pinnedIp}]` - : input.pinnedIp; const args = [ "-sS", + // Send `[`, `]`, `{`, `}` in URLs literally. Without this curl reads them + // as its own range/set glob syntax and fails with "bad range in URL". + "--globoff", "--max-time", String(Math.ceil(input.timeoutMs / 1000)), "--max-redirs", "0", "--resolve", - `${host}:${port}:${resolveTarget}`, + formatResolveEntry(host, port, input.pinnedIps), "-H", `User-Agent: ${USER_AGENT}`, "-H", @@ -144,7 +224,8 @@ function buildCurlArgs(input: { } args.push( "-w", - `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{redirect_url}|%{size_download}`, + `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{redirect_url}|` + + `%{size_download}|${CURL_HEADER_MARKER}%header{retry-after}`, "--", input.url.toString(), ); @@ -154,16 +235,32 @@ function buildCurlArgs(input: { export function parseCurlMeta(stdout: string): Omit { const markerIdx = stdout.lastIndexOf(CURL_META_MARKER); if (markerIdx === -1) { - return { status: 0, contentType: "", redirectUrl: "", body: stdout }; + return { + status: 0, + contentType: "", + redirectUrl: "", + retryAfter: "", + body: stdout, + }; } const body = stdout.slice(0, markerIdx).replace(/\n$/, ""); const meta = stdout.slice(markerIdx + CURL_META_MARKER.length).trim(); const [statusStr = "", contentType = "", redirectUrl = ""] = meta.split("|"); const status = Number.parseInt(statusStr, 10); + // Read the header block off the whole meta line rather than a fixed field: + // a `Retry-After` value may itself contain `|`, and the marker is the only + // reliable delimiter. `%header{}` is curl >= 7.83; older curl emits the + // literal format string, which must not be read as a value. + const headerIdx = meta.indexOf(CURL_HEADER_MARKER); + const retryAfter = + headerIdx === -1 + ? "" + : meta.slice(headerIdx + CURL_HEADER_MARKER.length).trim(); return { status: Number.isFinite(status) ? status : 0, contentType: contentType.trim(), redirectUrl: redirectUrl.trim(), + retryAfter: retryAfter.startsWith("%header{") ? "" : retryAfter, body, }; } diff --git a/src/tools/os/web-search/web-search-errors.ts b/src/tools/os/web-search/web-search-errors.ts index 41e81d5d..884377f4 100644 --- a/src/tools/os/web-search/web-search-errors.ts +++ b/src/tools/os/web-search/web-search-errors.ts @@ -15,3 +15,33 @@ export class WebSearchBlockedError extends Error { this.provider = provider; } } + +/** + * A provider answered 429 after the transport had already spent its + * retries on it. + * + * A subclass of {@link WebSearchBlockedError} rather than a sibling, + * because everything that already treats a blocked provider as "advance + * the chain" should keep doing exactly that. What the subclass adds is + * the one fact the orchestrator needs to stop *re-asking*: this was a + * quota, not a bad page, and asking again in two seconds will produce + * the same answer. + * + * `retryAfterMs` carries the server's own `Retry-After` when it sent + * one. It is the difference between guessing how long to wait and being + * told. + */ +export class WebSearchRateLimitedError extends WebSearchBlockedError { + /** Server-advertised wait, or `null` when it did not say. */ + readonly retryAfterMs: number | null; + + constructor( + provider: WebSearchProviderName, + retryAfterMs: number | null = null, + message?: string, + ) { + super(provider, message ?? `${provider} returned HTTP 429 (rate limited)`); + this.name = "WebSearchRateLimitedError"; + this.retryAfterMs = retryAfterMs; + } +} diff --git a/src/tools/tool-registry.ts b/src/tools/tool-registry.ts index 5f8e41c5..f5fce7af 100644 --- a/src/tools/tool-registry.ts +++ b/src/tools/tool-registry.ts @@ -1,4 +1,5 @@ import type { CompressedToolResult } from "../compressor/result-compressor.js"; +import { coerceToolArgs } from "./coerce-tool-args.js"; export interface ToolContext { /** Working directory for OS tools and relative path resolution. */ @@ -64,6 +65,10 @@ export class ToolRegistry { ctx: ToolContext, ): Promise { const tool = this.get(name); - return tool.run(args, ctx); + // Models sometimes emit a JSON value one level over-encoded (a + // number as "200000", an array as "[\"a.png\"]"). Unwrap those + // before dispatch; anything that cannot be coerced is passed + // through untouched so the tool reports its own error. + return tool.run(coerceToolArgs(name, args), ctx); } } diff --git a/src/tools/vision/describe.test.ts b/src/tools/vision/describe.test.ts index f54b5d7b..19a268ab 100644 --- a/src/tools/vision/describe.test.ts +++ b/src/tools/vision/describe.test.ts @@ -127,6 +127,88 @@ describe("buildVisionDescribeTool", () => { expect(call.images[0]!.mimeType).toBe("image/png"); }); + // Issue #185: the per-call image cap was enforced but documented + // nowhere the model could read, so it discovered the limit only by + // burning a step on a failed 8/12/20-image call. The cap now appears + // in the tool description, and the error names the remedy. + it("documents the image cap in the tool description", () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + expect(tool.description).toContain("At most 4 images per call"); + expect(tool.description).toMatch(/split/i); + }); + + it("reflects a reconfigured cap in the tool description", () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 7, + maxImageBytes: 1024, + }); + expect(tool.description).toContain("At most 7 images per call"); + }); + + it("rejects more images than the cap and names the split remedy", async () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + const result = await tool.run( + { + prompt: "describe", + paths: Array.from({ length: 12 }, (_, i) => `img-${i}.png`), + }, + ctx(process.cwd()), + ); + expect(result.status).toBe("error"); + expect(result.summary).toContain("at most 4 images per call (got 12)"); + // 12 / 4 = 3 calls. The remedy is the point: the model should not + // have to guess how to recover from the cap. + expect(result.summary).toContain("split into 3 calls of at most 4"); + }); + + it("rounds the suggested call count up for a partial final batch", async () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + const result = await tool.run( + { + prompt: "describe", + paths: Array.from({ length: 13 }, (_, i) => `img-${i}.png`), + }, + ctx(process.cwd()), + ); + expect(result.status).toBe("error"); + // Math.ceil(13 / 4) === 4, not 3. + expect(result.summary).toContain("split into 4 calls of at most 4"); + }); + + it("accepts exactly the cap without erroring", async () => { + const tmp = await mkdtemp(join(tmpdir(), "vision-tool-")); + const paths: string[] = []; + for (let i = 0; i < 4; i += 1) { + const path = join(tmp, `image-${i}.png`); + await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + paths.push(path); + } + const provider = fakeProvider(); + const tool = buildVisionDescribeTool({ + provider, + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + const result = await tool.run({ prompt: "describe", paths }, ctx(tmp)); + expect(result.status).toBe("ok"); + const call = (provider.describeImage as ReturnType).mock + .calls[0]![0] as VisionRequest; + expect(call.images).toHaveLength(4); + }); + it("rejects images that exceed maxImageBytes", async () => { const tmp = await mkdtemp(join(tmpdir(), "vision-tool-")); const path = join(tmp, "big.png"); diff --git a/src/tools/vision/describe.ts b/src/tools/vision/describe.ts index 61b87939..1751c9ba 100644 --- a/src/tools/vision/describe.ts +++ b/src/tools/vision/describe.ts @@ -55,8 +55,7 @@ export function buildVisionDescribeTool( ): ToolDefinition { return { name: "vision.describe", - description: - "Describe one or more images via the configured vision LLM. Use when the user attaches an image or asks what is on a screenshot.", + description: `Describe one or more images via the configured vision LLM. Use when the user attaches an image or asks what is on a screenshot. At most ${options.maxImagesPerCall} images per call; to cover more, split them across several calls.`, readonly: true, async run(rawArgs, ctx) { let parsed: ParsedArgs; @@ -66,8 +65,10 @@ export function buildVisionDescribeTool( return errorResult((error as Error).message); } if (parsed.paths.length > options.maxImagesPerCall) { + const calls = Math.ceil(parsed.paths.length / options.maxImagesPerCall); return errorResult( - `at most ${options.maxImagesPerCall} images per call (got ${parsed.paths.length})`, + `at most ${options.maxImagesPerCall} images per call (got ${parsed.paths.length})` + + ` — split into ${calls} calls of at most ${options.maxImagesPerCall}`, ); } if (!options.provider.capabilities.vision) { diff --git a/src/tracing/agent-metrics.ts b/src/tracing/agent-metrics.ts index 7cd1afeb..faeed275 100644 --- a/src/tracing/agent-metrics.ts +++ b/src/tracing/agent-metrics.ts @@ -94,6 +94,7 @@ export const METRIC_NAMES = { telegramMessagesSent: "agent.telegram.messages_sent", telegramApprovalsResolved: "agent.telegram.approvals_resolved", batchTrimmed: "agent.batch.trimmed", + batchWaveSplit: "agent.batch.wave_split", } as const; export type MetricName = (typeof METRIC_NAMES)[keyof typeof METRIC_NAMES]; @@ -149,6 +150,23 @@ export interface BatchTrimmedMetricSample { droppedCount: number; } +/** + * A mechanically wave-split oversized pure-read batch (issue #111). The + * runtime executes it deterministically in waves of at most `cap` + * instead of spending an LLM repair round-trip. Tagged by original size + * and cap so dashboards can spot models that routinely overshoot the + * prompt's stated limit. + */ +export interface BatchWaveSplitMetricSample { + sessionId: string; + /** Original batch size the model emitted. Always > cap. */ + originalSize: number; + /** Wave size cap (== agent.maxParallelToolCalls). */ + cap: number; + /** Number of waves: ceil(originalSize / cap). */ + waveCount: number; +} + /** * Canonical outcome taxonomy for the async reflection runner. Kept here * next to the other metric samples so dashboards can enumerate the full @@ -713,6 +731,22 @@ export class AgentMetrics { }); } + /** + * Record a mechanical wave split of an oversized all-`pure_read` batch + * (issue #111). Tagged by `originalSize` and `cap` so dashboards can + * distinguish a mild 9-call overshoot from a wholesale 15-call one; + * `waveCount` ships as a histogram value so percentile analyses are + * cheap. + */ + recordBatchWaveSplit(sample: BatchWaveSplitMetricSample): void { + this.collector.counter(METRIC_NAMES.batchWaveSplit, 1, { + sessionId: sample.sessionId, + originalSize: String(sample.originalSize), + cap: String(sample.cap), + waveCount: String(sample.waveCount), + }); + } + /** * Record the outcome of a single async end-of-turn reflection call. * The counter is tagged by `outcome` so dashboards can surface the diff --git a/src/tui/agent-event-reducer.test.ts b/src/tui/agent-event-reducer.test.ts index 366408ce..a78e62f0 100644 --- a/src/tui/agent-event-reducer.test.ts +++ b/src/tui/agent-event-reducer.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { BuiltPrompt } from "../prompt/build-prompt-types.js"; import { reduceTuiState, type TuiAction } from "./agent-event-reducer.js"; +import { providerRow } from "./composer-switch/composer-switch-fixtures.js"; import { canAcceptMessage, createInitialTuiState, @@ -17,7 +19,9 @@ function fakeSession(overrides: Partial = {}): TuiSessionInfo { browserHeadless: false, approvalLevel: 5, maxSteps: 10, + completionMaxTokens: 2048, skillCount: 0, + localBackendConfigured: false, ...overrides, }; } @@ -75,11 +79,44 @@ describe("reduceTuiState", () => { reason: "dangerous shell command", preview: "rm -rf /tmp/x", }; - const next = reduceTuiState(initial, { type: "approval_requested", request }); + const next = apply(initial, [ + // The request freezes the composer only when it was raised by the + // session on screen. + { type: "session_created", sessionId: "s-1" }, + { type: "approval_requested", request }, + ]); expect(next.status).toBe("awaiting_approval"); expect(next.pendingApproval?.approvalId).toBe("a-1"); }); + it("points at a background session's approval instead of arming the modal", () => { + // A turn the operator switched away from (or a scheduled task's + // turn) can still raise an approval, but it must NOT occupy + // `pendingApproval`: every approval key answers whatever that slot + // holds, so a reflexive Ctrl+C would deny a call the operator + // cannot see. The transcript gets a pointer naming the owner; the + // orchestrator re-raises the prompt when that session is switched + // into. + const initial = createInitialTuiState(fakeSession()); + const request = { + approvalId: "a-bg", + sessionId: "s-background", + tool: "os.shell.exec", + category: "shell" as const, + reason: "dangerous shell command", + }; + const next = apply(initial, [ + { type: "session_created", sessionId: "s-visible" }, + { type: "approval_requested", request }, + ]); + expect(next.pendingApproval).toBeNull(); + expect(next.status).toBe("idle"); + const notice = next.messages.at(-1); + expect(notice?.role).toBe("system"); + expect(notice?.text).toContain("s-background"); + expect(notice?.text).toContain("switch to it to answer"); + }); + it("should clear pending approval after resolve and restore running", () => { const initial = createInitialTuiState(fakeSession()); const request = { @@ -89,6 +126,7 @@ describe("reduceTuiState", () => { reason: "fs write", }; const next = apply(initial, [ + { type: "session_created", sessionId: "s-1" }, { type: "approval_requested", request }, { type: "approval_resolved", approvalId: "a-1", approved: true }, ]); @@ -106,6 +144,156 @@ describe("reduceTuiState", () => { expect(next).toBe(initial); }); + describe("context usage", () => { + const prompt = ( + overrides: Partial = {}, + ): BuiltPrompt => + ({ + text: "", + stablePrefix: "", + tail: "", + tokens: { + stablePrefix: 5000, + loadedSkills: 0, + sessionFacts: 0, + loadedTools: 0, + profile: 0, + worldSnapshot: 0, + conversation: 7000, + recalled: 0, + memoryIndex: 0, + taskPolicy: 0, + total: 12_000, + }, + limits: { + total: 40_000, + stablePrefix: 14_000, + session: 6000, + worldSnapshot: 6000, + conversation: 14_000, + }, + truncated: false, + truncation: { + loadedSkills: false, + sessionFacts: false, + loadedTools: false, + profile: false, + worldSnapshot: false, + conversation: false, + recalled: false, + memoryIndex: false, + }, + contextWindow: 32_768, + conversationCapEffective: 14_000, + droppedTurns: 0, + ...overrides, + }) as BuiltPrompt; + + const promptBuilt = (overrides: Partial = {}): TuiAction => ({ + type: "agent_event", + event: { + type: "llm_event", + event: { type: "prompt_built", prompt: prompt(overrides), slotId: 0 }, + }, + }); + + it("reads the window fill off the built prompt", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + promptBuilt({ droppedTurns: 3 }), + ]); + expect(next.contextUsage.tokens).toBe(12_000); + expect(next.contextUsage.contextWindow).toBe(32_768); + expect(next.contextUsage.droppedTurns).toBe(3); + expect(next.contextUsage.sections.map((s) => s.label)).toEqual([ + "prompt scaffold", + "conversation", + ]); + }); + + /** + * `prompt_built` carries `estimateTokens`, which over-counts by + * design. The completion carries what the provider's own tokenizer + * saw, and that is the figure worth showing. + */ + it("replaces the estimate with the provider's own count", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + promptBuilt(), + { + type: "agent_event", + event: { + type: "llm_event", + event: { + type: "llm_completed", + completion: { + timing: { promptTokens: 10_450 }, + } as never, + }, + }, + }, + ]); + expect(next.contextUsage.tokens).toBe(10_450); + // Everything else came from the prompt and still stands. + expect(next.contextUsage.contextWindow).toBe(32_768); + }); + + it("keeps the estimate when the provider reports no count", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + promptBuilt(), + { + type: "agent_event", + event: { + type: "llm_event", + event: { type: "llm_completed", completion: {} as never }, + }, + }, + ]); + expect(next.contextUsage.tokens).toBe(12_000); + }); + + /** + * The regression this slice exists to avoid: `startNewRun` wipes + * every per-turn metric, and the window is emphatically not a + * per-turn metric — it does not empty when you press Enter. + */ + it("survives the start of the next turn", () => { + const started = apply(createInitialTuiState(fakeSession()), [ + promptBuilt(), + { + type: "agent_event", + event: { + type: "llm_event", + event: { + type: "prompt_captured", + stepIndex: 0, + stablePrefixHash: "h", + tail: "", + tokens: { total: 12_000, stablePrefix: 5000, tail: 7000 }, + slotId: 0, + cacheReused: true, + }, + }, + }, + ]); + // Both readouts are populated before the turn boundary… + expect(started.metrics.promptTokensLast).toBe(12_000); + expect(started.contextUsage.tokens).toBe(12_000); + + const next = reduceTuiState(started, { type: "message_submitted" }); + // …and only the per-turn metric is cleared by it. + expect(next.metrics.promptTokensLast).toBeNull(); + expect(next.contextUsage.tokens).toBe(12_000); + }); + + it("resets when the transcript is cleared or the session changes", () => { + const built = apply(createInitialTuiState(fakeSession()), [promptBuilt()]); + expect(reduceTuiState(built, { type: "chat_cleared" }).contextUsage.tokens).toBeNull(); + expect( + reduceTuiState(built, { type: "session_created", sessionId: "s2" }) + .contextUsage.tokens, + ).toBeNull(); + }); + }); + it("should track cache hits and token totals from metrics", () => { const initial = createInitialTuiState(fakeSession()); const ts = Date.now(); @@ -165,6 +353,65 @@ describe("reduceTuiState", () => { expect(errMsg?.text).toBe("Turn failed [tool]: boom"); }); + it("appends the llama hint on transport failure for a custom-id llama-server route", () => { + const initial = createInitialTuiState(fakeSession()); + const next = apply(initial, [ + { + type: "providers_refresh", + rows: [ + // KIND is what makes the route local — the id is deliberately + // not `local-llama`. + providerRow({ + id: "my-llama", + kind: "llama-server", + isActiveText: true, + hasApiKey: false, + chatModel: null, + chatModelOptions: [], + }), + ], + }, + { type: "message_submitted" }, + { + type: "agent_event", + event: { + type: "loop_failed", + error: new Error("fetch failed"), + category: "transport", + }, + }, + ]); + const errMsg = next.messages.find( + (m) => m.role === "system" && m.variant === "warn", + ); + expect(errMsg?.text).toContain( + "llama-server is not reachable at http://127.0.0.1:8080", + ); + }); + + it("keeps the llama hint off a cloud route's transport failure", () => { + const initial = createInitialTuiState(fakeSession()); + const next = apply(initial, [ + { + type: "providers_refresh", + rows: [providerRow({ id: "openrouter", kind: "openrouter", isActiveText: true })], + }, + { type: "message_submitted" }, + { + type: "agent_event", + event: { + type: "loop_failed", + error: new Error("fetch failed"), + category: "transport", + }, + }, + ]); + const errMsg = next.messages.find( + (m) => m.role === "system" && m.variant === "warn", + ); + expect(errMsg?.text).toBe("Turn failed [transport]: fetch failed"); + }); + it("maps loop_completed reason failed to failed outcome", () => { const initial = createInitialTuiState(fakeSession()); const next = apply(initial, [ @@ -383,4 +630,207 @@ describe("reduceTuiState", () => { }); expect(down.session.approvalLevel).toBe(2); }); + + it("renders a mid-turn steer inline in the turn that is already running", () => { + const running = apply(createInitialTuiState(fakeSession()), [ + { type: "agent_event", event: { type: "user_message", text: "deploy" } }, + { type: "message_submitted" }, + { type: "agent_event", event: { type: "turn_started", turnIndex: 0 } }, + { type: "agent_event", event: { type: "step_started", stepIndex: 0 } }, + { + type: "agent_event", + event: { + type: "llm_event", + event: { + type: "tool_call_executed", + result: { + tool: "os.fs.read", + status: "ok", + summary: "read config", + truncated: false, + }, + }, + }, + }, + { type: "agent_event", event: { type: "step_started", stepIndex: 1 } }, + ]); + const feedBefore = running.feed.length; + + const next = reduceTuiState(running, { + type: "agent_event", + event: { type: "steer_applied", text: "use the staging db", stepIndex: 1 }, + }); + + // The operator's words show up as a user message, in the same + // transcript as everything else... + const last = next.messages[next.messages.length - 1]; + expect(last?.role).toBe("user"); + expect(last?.text).toBe("use the staging db"); + // ...with a feed line tying it to the step it reached. + expect(next.feed.length).toBe(feedBefore + 1); + expect(next.feed[next.feed.length - 1]?.line).toContain("step 1"); + // ...and none of the per-turn resets a NEW turn would bring: this + // is a correction to the turn in flight, not the start of one. + expect(next.status).toBe("running"); + expect(next.currentStep).toBe(1); + expect(next.currentTurnToolSteps).toBe(running.currentTurnToolSteps); + expect(next.runStartedAt).toBe(running.runStartedAt); + }); + + it("reports a trimmed tool batch instead of swallowing it", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + { type: "agent_event", event: { type: "step_started", stepIndex: 0 } }, + { + type: "agent_event", + event: { + type: "llm_event", + event: { + type: "batch_trimmed", + stepIndex: 0, + originalSize: 3, + kept: "os.fs.write", + dropped: ["os.shell.run", "os.fs.trash"], + reason: "approval-gated-batched", + }, + }, + }, + ]); + const line = next.feed[next.feed.length - 1]?.line ?? ""; + expect(line).toContain("os.fs.write"); + expect(line).toContain("2 of 3"); + }); + + it("reports a wave-split batch without implying anything was dropped", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + { type: "agent_event", event: { type: "step_started", stepIndex: 0 } }, + { + type: "agent_event", + event: { + type: "llm_event", + event: { + type: "batch_wave_split", + stepIndex: 0, + originalSize: 14, + cap: 8, + waveCount: 2, + boundaries: [0, 8], + }, + }, + }, + ]); + const line = next.feed[next.feed.length - 1]?.line ?? ""; + expect(line).toContain("14 reads"); + expect(line).toContain("2 waves"); + expect(line).toContain("nothing dropped"); + }); +}); + +describe("llm health visibility", () => { + it("does not mark local as configured just because a probe failed", () => { + const state = apply(createInitialTuiState(fakeSession()), [ + { + type: "llm_health_updated", + status: "unreachable", + checkedAt: 1, + latencyMs: null, + error: "connect ECONNREFUSED 127.0.0.1:8080", + }, + ]); + + // A fresh install probes a default URL nobody chose; a refusal there is + // not news, and the badge stays hidden. + expect(state.llmHealth.status).toBe("unreachable"); + expect(state.llmHealth.localConfigured).toBe(false); + }); + + it("latches on after a healthy probe and survives the server dying", () => { + const healthy = apply(createInitialTuiState(fakeSession()), [ + { + type: "llm_health_updated", + status: "healthy", + checkedAt: 1, + latencyMs: 3, + error: null, + }, + ]); + expect(healthy.llmHealth.localConfigured).toBe(true); + + // Somebody who really runs llama-server keeps the signal when it stops. + const died = apply(healthy, [ + { + type: "llm_health_updated", + status: "unreachable", + checkedAt: 2, + latencyMs: null, + error: "connect ECONNREFUSED 127.0.0.1:8080", + }, + ]); + expect(died.llmHealth.localConfigured).toBe(true); + expect(died.llmHealth.status).toBe("unreachable"); + }); + + it("starts visible when config already says local", () => { + const state = createInitialTuiState( + fakeSession({ localBackendConfigured: true }), + ); + expect(state.llmHealth.localConfigured).toBe(true); + }); +}); + + +describe("turn_gate_blocked", () => { + it("after a fresh submit: prints the warn message and hands the composer back", () => { + const submitted = apply(createInitialTuiState(fakeSession()), [ + { type: "message_submitted" }, + ]); + expect(submitted.status).toBe("running"); + + const blocked = reduceTuiState(submitted, { + type: "turn_gate_blocked", + text: "local model qwen-3.5-4b is not downloaded — open Models (/local) and press Enter on it to download", + }); + + expect(blocked.status).toBe("idle"); + expect(canAcceptMessage(blocked)).toBe(true); + const last = blocked.messages.at(-1); + expect(last?.role).toBe("system"); + expect(last?.variant).toBe("warn"); + expect(last?.text).toContain("qwen-3.5-4b"); + expect(blocked.feed.at(-1)?.line).toContain("blocked:"); + }); + + it("a blocked fresh submit makes no run-history entry — it never ran", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + // A full earlier turn, so the trap has bait: the blocked text + // never reaches `state.messages`, and a history entry minted for + // the block would carry THIS message instead. + { type: "agent_event", event: { type: "user_message", text: "earlier turn" } }, + { type: "message_submitted" }, + { type: "agent_event", event: { type: "loop_completed", reason: "finish" } }, + { type: "message_submitted" }, + { + type: "turn_gate_blocked", + text: "local model qwen-3.5-4b is not downloaded (message returned to the editor)", + }, + ]); + expect(next.status).toBe("idle"); + expect(next.lastRunStatus).toBe("blocked: local model not ready"); + expect(next.runHistory).toHaveLength(1); + expect(next.runHistory[0]?.outcome).toBe("completed"); + expect(next.runHistory[0]?.message).toBe("earlier turn"); + }); + + it("at drain time (already idle): message only, no phantom run-history entry", () => { + const initial = createInitialTuiState(fakeSession()); + const blocked = reduceTuiState(initial, { + type: "turn_gate_blocked", + text: "local model qwen-3.5-4b is not downloaded\n dropped: second", + }); + + expect(blocked.status).toBe("idle"); + expect(blocked.runHistory).toHaveLength(0); + expect(blocked.messages.at(-1)?.text).toContain("dropped: second"); + // The feed line stays single-line even for a multi-line message. + expect(blocked.feed.at(-1)?.line).not.toContain("\n"); + }); }); diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index dc580c08..2f8baaf6 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -1,4 +1,9 @@ import type { AgentLoopEvent } from "../agent/agent-loop.js"; +import { + contextUsageFromPrompt, + EMPTY_CONTEXT_USAGE, +} from "./context-usage-from-prompt.js"; +import { formatBackgroundApprovalNotice } from "./detached-turns.js"; import { formatAgentErrorForChat } from "./format-agent-error-for-chat.js"; import { formatFeedLine } from "./format-event.js"; import { @@ -10,18 +15,23 @@ import { beginStreamingToolCall, finalizeStreamingToolCall, finishRun, + finishRunWithoutHistory, finishTurn, pushRing, startNewRun, upsertReasoning, } from "./reducer-helpers.js"; import { reduceUiAction } from "./reduce-ui-actions.js"; +import { reduceComposerSwitchAction } from "./composer-switch/composer-switch-reducer.js"; +import { selectComposerBackend } from "./composer-switch/composer-switch-rows.js"; import { reduceLocalModelsAction } from "./local-models/local-models-reducer.js"; import { reduceTasksAction } from "./tasks/tasks-reducer.js"; import { reduceSkillsAction } from "./skills/skills-reducer.js"; import { reduceMemoryAction } from "./memory/memory-reducer.js"; import { reduceMcpAction } from "./mcp/mcp-reducer.js"; +import { reduceUninstallAction } from "./uninstall/uninstall-reducer.js"; import { reduceImportAction } from "./import/import-reducer.js"; +import { reduceOnboardingAction } from "./onboarding/onboarding-reducer.js"; import { reduceProvidersPanel } from "./providers/providers-reducer.js"; import { reduceLlmPanelAction } from "./llm-panel/llm-panel-reducer.js"; import { reduceFallbackPanelAction } from "./llm-panel/fallback/fallback-panel-reducer.js"; @@ -33,6 +43,14 @@ import type { RunOutcome, StreamingToolCall, TuiState } from "./tui-state.js"; export type { TuiAction } from "./tui-action.js"; export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { + // First in the chain, and only ever claims an action while the + // first-run flow is open. Several actions belong to two owners then — + // a finished model pull, a saved provider — and the flow has to see + // them to advance. A handled action never reaches the rest of the + // chain, so it delegates the panel half to the owning slice rather + // than duplicating it. + const onboardingHandled = reduceOnboardingAction(state, action); + if (onboardingHandled !== null) return onboardingHandled; const localModelsHandled = reduceLocalModelsAction(state, action); if (localModelsHandled !== null) return localModelsHandled; const tasksHandled = reduceTasksAction(state, action); @@ -41,6 +59,8 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { if (skillsHandled !== null) return skillsHandled; const memoryHandled = reduceMemoryAction(state, action); if (memoryHandled !== null) return memoryHandled; + const uninstallHandled = reduceUninstallAction(state, action); + if (uninstallHandled !== null) return uninstallHandled; const mcpHandled = reduceMcpAction(state, action); if (mcpHandled !== null) return mcpHandled; const importHandled = reduceImportAction(state, action); @@ -55,6 +75,8 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { if (telegramHandled !== null) return telegramHandled; const privacyHandled = reducePrivacyAction(state, action); if (privacyHandled !== null) return privacyHandled; + const composerSwitchHandled = reduceComposerSwitchAction(state, action); + if (composerSwitchHandled !== null) return composerSwitchHandled; const uiHandled = reduceUiAction(state, action); if (uiHandled !== null) return uiHandled; switch (action.type) { @@ -72,7 +94,14 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { ...(action.variant ? { variant: action.variant } : {}), }); case "session_created": - return { ...state, session: { ...state.session, sessionId: action.sessionId } }; + return { + ...state, + session: { ...state.session, sessionId: action.sessionId }, + // A different thread has a different window fill. Carrying the + // old figure over would read as "this fresh session is already + // 40% full" until the first prompt lands. + contextUsage: EMPTY_CONTEXT_USAGE, + }; case "skill_count_changed": return { ...state, session: { ...state.session, skillCount: action.count } }; case "approval_level_changed": @@ -81,16 +110,92 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { session: { ...state.session, approvalLevel: action.approvalLevel }, }; case "agent_event": + // Events from a turn running on a *different* session — one the + // operator backgrounded by switching away, or a scheduler / + // Telegram / HTTP turn — must not paint into the transcript on + // screen (or flip `status`, which is what used to freeze the + // composer). An untagged event was emitted outside a turn frame + // (global notices) and passes through as before. + if ( + action.sessionId !== undefined && + action.sessionId !== state.session.sessionId + ) { + return state; + } return reduceAgentEvent(state, action.event); + case "session_delete_requested": + return { + ...state, + sessionDelete: { + sessionId: action.sessionId, + preview: action.preview, + // Destructive default: the dialog opens on Cancel. + cursor: "cancel", + }, + }; + case "session_delete_cursor_set": + if (!state.sessionDelete) return state; + return { + ...state, + sessionDelete: { ...state.sessionDelete, cursor: action.cursor }, + }; + case "session_delete_closed": + return { ...state, sessionDelete: null }; case "approval_requested": + // A request raised by a session that is NOT on screen must never + // arm the modal: every approval key (and the prose-deny submit) + // answers whatever `pendingApproval` holds, so parking a + // background thread's question here would let a reflexive Ctrl+C + // deny a tool call the operator cannot even see — and abort the + // visible turn in the same press. The request stays pending at + // the gate; the transcript gets a pointer naming the owner, and + // `switchSession` re-raises the prompt once that owner is + // visible. + if (action.request.sessionId !== state.session.sessionId) { + return appendChatMessage(state, { + role: "system", + text: formatBackgroundApprovalNotice(action.request), + variant: "warn", + }); + } return { ...state, status: "awaiting_approval", pendingApproval: action.request, + // A redirect re-prompts for the new target; the previous + // prompt's draft must not leak into it. + approvalPathDraft: null, }; case "approval_resolved": if (state.pendingApproval?.approvalId !== action.approvalId) return state; + return { + ...state, + pendingApproval: null, + approvalPathDraft: null, + // Resolving the visible turn's request resumes that turn: + // `running`. Resolving a background turn's request resumes a + // turn this transcript is not showing — the visible status + // (idle, or a run of its own) is left alone. + status: + state.pendingApproval.sessionId === state.session.sessionId + ? "running" + : state.status, + }; + case "approval_path_edit_opened": + if (!state.pendingApproval) return state; + return { ...state, approvalPathDraft: action.path }; + case "approval_path_edit_changed": + if (state.approvalPathDraft === null) return state; + return { ...state, approvalPathDraft: action.value }; + case "approval_path_edit_closed": + return { ...state, approvalPathDraft: null }; return { ...state, pendingApproval: null, status: "running" }; + case "composer_notice": + if (state.composerNotice === action.text) return state; + return { ...state, composerNotice: action.text }; + case "composer_selection_changed": + if (state.composerHasSelection === action.hasSelection) return state; + return { ...state, composerHasSelection: action.hasSelection }; case "metric": return applyMetric(state, action.sample); case "log": @@ -113,14 +218,45 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { return { ...state, activeTab: action.tab }; case "abort_requested": return { ...state, aborting: true }; - case "input_changed": + case "input_changed": { + // Moving the caret re-emits the buffer unchanged (the editor owns + // the cursor and reports it through `onChange`). That is not an + // edit, so it must not knock us out of history recall — otherwise + // a single Left/Right after Up dropped the recall position and the + // parked draft with it. + if (action.value === state.inputValue) return state; return { ...state, inputValue: action.value, inputHistoryCursor: null, + inputHistoryDraft: null, }; + } case "message_submitted": return startNewRun(state); + case "turn_gate_blocked": { + const withMessage = appendChatMessage( + appendFeed(state, { + kind: "runtime_info", + stepIndex: null, + line: `» blocked: ${action.text.split("\n")[0] ?? action.text}`, + color: "yellow", + }), + { role: "system", text: action.text, variant: "warn" }, + ); + // A drained queue message is gated after the previous turn already + // returned the app to idle — nothing to finish then. The fresh + // submit path arrives here `running` (from `message_submitted`) + // with no turn behind it, so the idle reset is what hands the + // composer back — WITHOUT a run-history entry: the blocked text + // never reached `state.messages`, so an entry would carry the + // previous turn's message, and a refused submit is not a run. + if (state.status !== "running") return withMessage; + return finishRunWithoutHistory( + withMessage, + "blocked: local model not ready", + ); + } case "quit_requested": return { ...state, status: "quitting", aborting: true }; case "loaded_skill": { @@ -146,6 +282,11 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { lastCheckedAt: action.checkedAt, latencyMs: action.latencyMs, error: action.error, + // A server that answers is a server somebody meant to run, even if + // config never said so. Latch it on so the indicator appears for + // that user and survives the server later going down. + localConfigured: + state.llmHealth.localConfigured || action.status === "healthy", }, }; case "llm_model_updated": @@ -204,6 +345,21 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState { switch (event.type) { case "user_message": return appendUserMessage(state, event.text); + case "steer_applied": + // A message the operator sent mid-turn, folded into the prompt of + // the step named here. It renders INLINE in the running turn: same + // chat bubble as any user message, but none of the per-turn resets + // `user_message` implies — no `startNewRun`, no step counter reset. + // The feed line is what ties it to the step it actually reached. + return appendUserMessage( + appendFeed(state, { + kind: "runtime_info", + stepIndex: event.stepIndex, + line: `» steering applied at step ${event.stepIndex}`, + color: "yellow", + }), + event.text, + ); case "turn_started": return { ...state, @@ -306,6 +462,15 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState { const chatError = formatAgentErrorForChat( event.category, event.error.message, + { + // The same "is the chat route a llama-server" answer the + // composer's backend control renders — KIND-based, so a + // llama-server entry under a custom id still earns the hint; + // only a `cloud` route must not (the hint names the llama + // URL). Rows land at TUI start via the providers refresh. + activeProviderIsLocal: selectComposerBackend(state) !== "cloud", + llamaUrl: state.session.llamaUrl, + }, ); return finishRun( appendChatMessage( @@ -320,8 +485,23 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState { { outcome: "failed", reason: event.error.message, lastRunStatus }, ); } - default: + case "loop_detected": + // Deliberately not rendered: the loop detector's own `### notice` + // changes what the model does, and the operator sees the effect + // through the tool calls that follow. Listed explicitly so the + // exhaustiveness check below stays meaningful. + return state; + default: { + // `steer_applied` shipped with a doc comment promising inline + // rendering and no case here, and a bare `default: return state` + // meant TypeScript had nothing to say about it. This makes the + // next new `AgentLoopEvent` a compile error instead of a silent + // no-op — while still returning `state` at runtime, because a UI + // reducer must never throw on an event it does not know. + const unhandled: never = event; + void unhandled; return state; + } } } @@ -493,7 +673,68 @@ function reduceStepEvent( line: ` ! [${event.category}] ${event.error.message}`, color: "red", }); - default: + case "batch_trimmed": + // Surfaced by the exhaustiveness check below: the model asked for + // `originalSize` calls and only one ran. That is worth a line — + // otherwise the dropped calls reappear one-by-one next step with + // no explanation for why the batch shrank. + return appendFeed(state, { + kind: "runtime_info", + stepIndex: event.stepIndex, + line: ` ~ batch trimmed to ${event.kept} (${event.dropped.length} of ${event.originalSize} deferred: ${event.reason})`, + color: "yellow", + }); + case "batch_wave_split": + // Issue #111: an oversized pure-read batch ran in bounded waves. + // Nothing was dropped — every call executed — so the feed line + // says so explicitly (otherwise the follow-up step's tool calls + // look like a re-run of the same reads). + return appendFeed(state, { + kind: "runtime_info", + stepIndex: event.stepIndex, + line: ` ~ ${event.originalSize} reads split into ${event.waveCount} waves of ≤ ${event.cap} (nothing dropped)`, + color: "yellow", + }); + case "prompt_built": + // The feed still ignores the prompt text itself — it would drown + // the log — but the token breakdown that comes with it is the only + // authoritative statement of what is in the window right now. + return { + ...state, + contextUsage: contextUsageFromPrompt(event.prompt), + // Reality has caught up with the selector: the prompt was built + // against the number the operator chose, so the local override + // is no longer telling anyone anything the measurement does not. + // Cleared only on a match, because a build that predates the + // change would otherwise snap the selector back to the old value + // in front of them. + contextPanelPairsDraft: + state.contextPanelPairsDraft === event.prompt.conversationPairsCap + ? null + : state.contextPanelPairsDraft, + }; + case "llm_completed": { + // `prompt_built` carried an estimate (`estimateTokens` over-counts + // by design); the provider just reported what its own tokenizer + // actually counted — llama.cpp from `tokens_evaluated`, an + // OpenAI-compatible cloud from `usage.prompt_tokens`. Prefer it, + // and leave the estimate standing when nothing was reported. + const counted = event.completion.timing?.promptTokens ?? 0; + if (counted <= 0) return state; + return { + ...state, + contextUsage: { ...state.contextUsage, tokens: counted }, + }; + } + case "llm_raw_completion": + // Raw plumbing: the whole completion object, the unparsed text. + // The trace recorder wants them; the chat feed would drown in + // them. Listed so the exhaustiveness check holds. + return state; + default: { + const unhandled: never = event; + void unhandled; return state; + } } } diff --git a/src/tui/alt-screen.ts b/src/tui/alt-screen.ts index 75600b60..5e35c727 100644 --- a/src/tui/alt-screen.ts +++ b/src/tui/alt-screen.ts @@ -4,12 +4,15 @@ * buffer and the host terminal scrollback is preserved untouched. * * Only emits sequences when the target stream is a TTY; in pipes/CI the - * helpers become no-ops. Installation is idempotent and paired with a - * `process.on('exit')` hook so the alt screen is always left even if an - * uncaught exception or signal terminates the process. + * helpers become no-ops. Installation is idempotent and paired with the + * shared net in `terminal-restore.ts` so the alt screen is always left + * — including on an uncaught exception, where the restore now runs + * *before* the crash text is printed rather than after. */ import type { Writable } from "node:stream"; +import { registerTerminalRestore } from "./terminal-restore.js"; + const ENTER_ALT_SCREEN = "\u001B[?1049h"; const LEAVE_ALT_SCREEN = "\u001B[?1049l"; const ENABLE_ALT_SCROLL = "\u001B[?1007h"; @@ -56,11 +59,10 @@ export function enterAltScreen(options: AltScreenOptions = {}): AltScreenControl }; // Last-chance cleanup if the process dies without a clean teardown — // missing this handler is how TUIs leave terminals in a broken state. - const onExit = (): void => restore(); - process.once("exit", onExit); + const unregister = registerTerminalRestore(restore); return { restore: () => { - process.off("exit", onExit); + unregister(); restore(); }, }; diff --git a/src/tui/app-key-bindings-selection.test.ts b/src/tui/app-key-bindings-selection.test.ts new file mode 100644 index 00000000..95ab91b9 --- /dev/null +++ b/src/tui/app-key-bindings-selection.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Key } from "ink"; + +import { handleAppKey } from "./app-key-bindings.js"; +import { + createInitialTuiState, + type TuiSessionInfo, + type TuiState, +} from "./tui-state.js"; + +/** + * The Ctrl+C stand-down ladder around a composer selection. + * + * A live selection makes the *editor* own Ctrl+C (copy), so the global + * layer must not arm the quit for the same press — but only while the + * editor actually has the keyboard. The selection flag survives Tab + * into the sidebar, an open menu, and an armed leader; in all of those + * the editor's handler is inactive and a stand-down would leave Ctrl+C + * claimed by nobody (no abort, no quit) until focus returned. + */ +function stubSession(): TuiSessionInfo { + return { + sessionId: "s-x", + workingDir: "/tmp/w", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chromium", + browserHeadless: true, + approvalLevel: 5, + maxSteps: 8, + skillCount: 0, + }; +} + +function ctrlC(): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: true, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + super: false, + hyper: false, + capsLock: false, + numLock: false, + } as Key; +} + +function pressCtrlC( + stateOverrides: Partial, + options: { menuLeaderArmed?: boolean } = {}, +) { + const state: TuiState = { + ...createInitialTuiState(stubSession()), + uiMode: "chat" as const, + composerHasSelection: true, + inputValue: "hello", + ...stateOverrides, + }; + const setCtrlCArmed = vi.fn(); + const handled = handleAppKey("c", ctrlC(), { + state, + dispatch: vi.fn(), + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + }, + ctrlCArmed: false, + setCtrlCArmed, + sidebarVisible: true, + menuLeaderArmed: options.menuLeaderArmed ?? false, + setMenuLeaderArmed: vi.fn(), + activateMenuNode: vi.fn(), + }); + return { handled, setCtrlCArmed }; +} + +describe("Ctrl+C vs composer selection", () => { + it("stands down while the focused editor holds a selection", () => { + const run = pressCtrlC({ chatFocus: "editor" }); + expect(run.handled).toBe(false); + expect(run.setCtrlCArmed).not.toHaveBeenCalledWith(true); + }); + + it("still arms the quit when focus is on the sidebar", () => { + const run = pressCtrlC({ chatFocus: "sidebar" }); + expect(run.handled).toBe(true); + expect(run.setCtrlCArmed).toHaveBeenCalledWith(true); + }); + + it("is still claimed (by the menu layer) while the menu is open", () => { + // The menu sits above the Ctrl+C branch in the ladder and takes the + // key itself. What matters here is that a stale selection does not + // make anyone stand down into a dead key: the press is handled. + const run = pressCtrlC({ chatFocus: "editor", menuOpen: true }); + expect(run.handled).toBe(true); + }); + + it("still aborts through an armed leader", () => { + // A modified key falls through the armed-leader branch by design; + // the selection must not turn that fall-through into a dead key. + const run = pressCtrlC({ chatFocus: "editor" }, { menuLeaderArmed: true }); + expect(run.handled).toBe(true); + expect(run.setCtrlCArmed).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/tui/app-key-bindings.test.ts b/src/tui/app-key-bindings.test.ts index d291e133..4c61a23e 100644 --- a/src/tui/app-key-bindings.test.ts +++ b/src/tui/app-key-bindings.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import type { Key } from "ink"; -import { handleAppKey } from "./app-key-bindings.js"; +import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import type { MenuNode } from "./menu/menu-registry.js"; +import { createOnboardingState } from "./onboarding/onboarding-state.js"; import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js"; import type { ApprovalRequest } from "../approval/approval-gate.js"; @@ -400,11 +402,11 @@ describe("handleAppKey", () => { expect(dispatch).not.toHaveBeenCalled(); }); - it("y on a pending approval resolves it with no grant", () => { + it("ctrl+y on a pending approval resolves it with no grant", () => { const state = createInitialTuiState(stubSession()); state.pendingApproval = pendingRequest(); const onApprovalDecision = vi.fn(); - const handled = handleAppKey("y", emptyKey(), { + const handled = handleAppKey("y", emptyKey({ ctrl: true }), { state, dispatch: vi.fn(), callbacks: { onApprovalDecision, onAbort: vi.fn(), onQuit: vi.fn() }, @@ -416,12 +418,52 @@ describe("handleAppKey", () => { expect(onApprovalDecision).toHaveBeenCalledWith("ap-1", true); }); - it("s on a grantable approval resolves with a category grant and confirms it", () => { + it("keys never answer a background session's approval — Ctrl+C keeps its normal meaning", () => { + // A request owned by an off-screen session (the reducer keeps it + // out of the slot, but the keys must not trust that blind): Ctrl+C + // must behave exactly as it does with no prompt up — abort the + // visible run — and NOT deny the background session's tool call. + const state = createInitialTuiState(stubSession()); + state.pendingApproval = pendingRequest({ sessionId: "s-background" }); + state.status = "running"; + const onApprovalDecision = vi.fn(); + const onAbort = vi.fn(); + const dispatch = vi.fn(); + const handled = handleAppKey("c", emptyKey({ ctrl: true }), { + state, + dispatch, + callbacks: { onApprovalDecision, onAbort, onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + expect(onApprovalDecision).not.toHaveBeenCalled(); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + }); + + it("y is not a verdict on a background session's approval", () => { + const state = createInitialTuiState(stubSession()); + state.pendingApproval = pendingRequest({ sessionId: "s-background" }); + const onApprovalDecision = vi.fn(); + handleAppKey("y", emptyKey(), { + state, + dispatch: vi.fn(), + callbacks: { onApprovalDecision, onAbort: vi.fn(), onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(onApprovalDecision).not.toHaveBeenCalled(); + }); + + it("ctrl+f on a grantable approval resolves with a category grant and confirms it", () => { const state = createInitialTuiState(stubSession()); state.pendingApproval = pendingRequest(); const onApprovalDecision = vi.fn(); const dispatch = vi.fn(); - const handled = handleAppKey("s", emptyKey(), { + const handled = handleAppKey("f", emptyKey({ ctrl: true }), { state, dispatch, callbacks: { onApprovalDecision, onAbort: vi.fn(), onQuit: vi.fn() }, @@ -438,12 +480,12 @@ describe("handleAppKey", () => { }); }); - it("a on a shell approval with a shape resolves with a shape grant and confirms it", () => { + it("ctrl+b on a shell approval with a shape resolves with a shape grant and confirms it", () => { const state = createInitialTuiState(stubSession()); state.pendingApproval = pendingRequest({ commandShape: "git" }); const onApprovalDecision = vi.fn(); const dispatch = vi.fn(); - const handled = handleAppKey("a", emptyKey(), { + const handled = handleAppKey("b", emptyKey({ ctrl: true }), { state, dispatch, callbacks: { onApprovalDecision, onAbort: vi.fn(), onQuit: vi.fn() }, @@ -459,7 +501,7 @@ describe("handleAppKey", () => { }); }); - it("s is inert on a trust_config approval (never grantable)", () => { + it("ctrl+f is inert on a trust_config approval (never grantable)", () => { const state = createInitialTuiState(stubSession()); state.pendingApproval = pendingRequest({ category: "trust_config", @@ -467,7 +509,7 @@ describe("handleAppKey", () => { commandShape: undefined, }); const onApprovalDecision = vi.fn(); - const handled = handleAppKey("s", emptyKey(), { + const handled = handleAppKey("f", emptyKey({ ctrl: true }), { state, dispatch: vi.fn(), callbacks: { onApprovalDecision, onAbort: vi.fn(), onQuit: vi.fn() }, @@ -480,7 +522,7 @@ describe("handleAppKey", () => { expect(onApprovalDecision).not.toHaveBeenCalled(); }); - it("a is inert on a non-shell approval (no shape to grant)", () => { + it("ctrl+b is inert on a non-shell approval with no retarget", () => { const state = createInitialTuiState(stubSession()); state.pendingApproval = pendingRequest({ category: "fs_write_home", @@ -544,4 +586,371 @@ describe("handleAppKey", () => { }); expect(onSidebarTaskActivated).toHaveBeenCalledWith("task-id-42"); }); + + it("Esc while running aborts the turn when the chat is pinned to the bottom", () => { + const state = createInitialTuiState(stubSession()); + state.status = "running"; + const dispatch = vi.fn(); + const onAbort = vi.fn(); + const handled = handleAppKey("", emptyKey({ escape: true }), { + state, + dispatch, + callbacks: { onApprovalDecision: vi.fn(), onAbort, onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + }); + + it("Esc while running snaps the scrolled-back chat home instead of aborting", () => { + // Reported sequence: submit, PageUp to read back through the + // streaming answer, Esc. The scroll-reset rung documents that it + // runs "before doing anything else"; the abort claim must not eat + // the turn out from under an operator who was only scrolling. + const state = createInitialTuiState(stubSession()); + state.status = "running"; + state.chatScrollOffset = 8; + const dispatch = vi.fn(); + const onAbort = vi.fn(); + const handled = handleAppKey("", emptyKey({ escape: true }), { + state, + dispatch, + callbacks: { onApprovalDecision: vi.fn(), onAbort, onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + expect(onAbort).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ type: "chat_scroll_reset" }); + expect(dispatch).not.toHaveBeenCalledWith({ type: "abort_requested" }); + }); + + it("Esc while running on a debug tab aborts even with a stale scroll offset", () => { + // Nothing resets `chatScrollOffset` on a mode switch, and the chat + // is off-screen in debug mode — snapping an invisible log back would + // just make Esc look dead there. + const state = createInitialTuiState(stubSession()); + state.status = "running"; + state.uiMode = "debug"; + state.activeTab = "logs"; + state.chatScrollOffset = 8; + const dispatch = vi.fn(); + const onAbort = vi.fn(); + const handled = handleAppKey("", emptyKey({ escape: true }), { + state, + dispatch, + callbacks: { onApprovalDecision: vi.fn(), onAbort, onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + }); +}); + +describe("handleAppKey while a turn is running", () => { + it("Ctrl+P still opens the menu mid-run", () => { + const state = createInitialTuiState(stubSession()); + state.status = "running"; + const dispatch = vi.fn(); + const handled = handleAppKey("p", emptyKey({ ctrl: true }), { + state, + dispatch, + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + menuLeaderArmed: false, + setMenuLeaderArmed: vi.fn(), + activateMenuNode: vi.fn(), + }); + expect(handled).toBe(true); + expect(dispatch).toHaveBeenCalledWith({ type: "menu_opened" }); + }); +}); + +describe("handleAppKey with the ctrl+g leader armed", () => { + function pressWhileArmed( + input: string, + key: Key, + state = createInitialTuiState(stubSession()), + ) { + const activated: MenuNode[] = []; + const dispatch = vi.fn(); + const setMenuLeaderArmed = vi.fn(); + const setCtrlCArmed = vi.fn(); + const onAbort = vi.fn(); + const onQuit = vi.fn(); + const handled = handleAppKey(input, key, { + state, + dispatch, + callbacks: { + onApprovalDecision: vi.fn(), + onAbort, + onQuit, + }, + ctrlCArmed: false, + setCtrlCArmed, + sidebarVisible: false, + menuLeaderArmed: true, + setMenuLeaderArmed, + activateMenuNode: (node) => activated.push(node), + }); + return { + handled, + activated, + dispatch, + setMenuLeaderArmed, + setCtrlCArmed, + onAbort, + onQuit, + }; + } + + it("a bare chord key activates its node", () => { + const run = pressWhileArmed("c", emptyKey()); + expect(run.activated.map((n) => n.id)).toEqual(["go.manage.mcp"]); + expect(run.handled).toBe(true); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + }); + + it("the new-session and switch-session chords fire while a turn is running", () => { + // The controls-stay-live rule: a running turn must not block + // creating or switching sessions — the semantics (detach, keep the + // turn running in its thread) live in the orchestrator, so the key + // table's only job is to still deliver the activation. + for (const [chord, nodeId] of [ + ["n", "session.new"], + ["u", "session.switch"], + ] as const) { + const state = createInitialTuiState(stubSession()); + state.status = "running"; + const run = pressWhileArmed(chord, emptyKey(), state); + expect(run.activated.map((n) => n.id)).toEqual([nodeId]); + expect(run.handled).toBe(true); + } + }); + + it("an unclaimed bare key is swallowed rather than leaked to the prompt", () => { + const run = pressWhileArmed("z", emptyKey()); + expect(run.activated).toEqual([]); + expect(run.handled).toBe(true); + }); + + it("Ctrl+C disarms and aborts the turn instead of jumping to the MCP tab", () => { + const state = createInitialTuiState(stubSession()); + state.status = "running"; + const run = pressWhileArmed("c", emptyKey({ ctrl: true }), state); + expect(run.activated).toEqual([]); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + expect(run.setCtrlCArmed).toHaveBeenCalledWith(true); + expect(run.onAbort).toHaveBeenCalled(); + expect(run.dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + expect(run.handled).toBe(true); + }); + + it("Ctrl+Q disarms without quitting the app", () => { + const run = pressWhileArmed("q", emptyKey({ ctrl: true })); + expect(run.activated).toEqual([]); + expect(run.onQuit).not.toHaveBeenCalled(); + expect(run.dispatch).not.toHaveBeenCalledWith({ type: "quit_requested" }); + // Nothing else binds ctrl+q, so the key falls through unclaimed — + // which is the point: the leader no longer stands in the way. + expect(run.handled).toBe(false); + }); + + it("Ctrl+L disarms and falls through instead of opening the LLM tab", () => { + const run = pressWhileArmed("l", emptyKey({ ctrl: true })); + expect(run.activated).toEqual([]); + expect(run.dispatch).not.toHaveBeenCalled(); + expect(run.handled).toBe(false); + }); + + it("Esc disarms and is swallowed, so it cancels the leader", () => { + const run = pressWhileArmed("", emptyKey({ escape: true })); + expect(run.activated).toEqual([]); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + expect(run.handled).toBe(true); + }); +}); + +describe("handlePanelEscape", () => { + it("sends an unclaimed Esc home to Run", () => { + const dispatch = vi.fn(); + const consumed = handlePanelEscape(emptyKey({ escape: true }), { + panelHandled: false, + editorFocus: false, + dispatch, + }); + expect(consumed).toBe(true); + expect(dispatch).toHaveBeenCalledWith({ type: "ui_mode_set", mode: "chat" }); + }); + + it("leaves the panel alone when its own layer already claimed Esc", () => { + // A modal, an open search input or a detail view returns `true` from + // the panel's key layer — the operator meant "close that", not + // "leave the panel", so the fallback must stay out of the way. + const dispatch = vi.fn(); + const consumed = handlePanelEscape(emptyKey({ escape: true }), { + panelHandled: true, + editorFocus: false, + dispatch, + }); + expect(consumed).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("defers to the chat editor when the editor holds focus", () => { + // On tabs that keep the editor focused, Esc already means + // abort / scroll-reset / quit inside the editor's own hook. + const dispatch = vi.fn(); + const consumed = handlePanelEscape(emptyKey({ escape: true }), { + panelHandled: false, + editorFocus: true, + dispatch, + }); + expect(consumed).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("ignores every key that is not Esc", () => { + const dispatch = vi.fn(); + const consumed = handlePanelEscape(emptyKey({ tab: true }), { + panelHandled: false, + editorFocus: false, + dispatch, + }); + expect(consumed).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); + +describe("Ctrl+T — Enter-while-busy mode", () => { + function ctx(state: ReturnType, extra = {}) { + return { + state, + dispatch: vi.fn(), + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + onWhileBusyModePersistRequested: vi.fn(), + }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + ...extra, + }; + } + + it("toggles the mode and asks for it to be persisted", () => { + const state = createInitialTuiState(stubSession()); + expect(state.whileBusyMode).toBe("steer"); + const c = ctx(state); + const handled = handleAppKey("t", emptyKey({ ctrl: true }), c); + expect(handled).toBe(true); + expect(c.dispatch).toHaveBeenCalledWith({ + type: "while_busy_mode_changed", + mode: "queue", + }); + expect(c.callbacks.onWhileBusyModePersistRequested).toHaveBeenCalledWith( + "queue", + ); + }); + + it("persists the opposite direction from queue mode", () => { + const state = { ...createInitialTuiState(stubSession()), whileBusyMode: "queue" as const }; + const c = ctx(state); + handleAppKey("t", emptyKey({ ctrl: true }), c); + expect(c.callbacks.onWhileBusyModePersistRequested).toHaveBeenCalledWith( + "steer", + ); + }); + + it("leaves a pending approval alone — y/n/esc own the keyboard there", () => { + const state = { + ...createInitialTuiState(stubSession()), + pendingApproval: pendingRequest(), + }; + const c = ctx(state); + const handled = handleAppKey("t", emptyKey({ ctrl: true }), c); + expect(handled).toBe(false); + expect(c.dispatch).not.toHaveBeenCalledWith({ + type: "while_busy_mode_changed", + }); + }); + + it("ignores a plain t", () => { + const c = ctx(createInitialTuiState(stubSession())); + handleAppKey("t", emptyKey(), c); + expect(c.dispatch).not.toHaveBeenCalledWith({ + type: "while_busy_mode_changed", + }); + }); +}); + + +describe("handleAppKey during onboarding", () => { + function splashState() { + // The splash is the first onboarding step; the quit path must not + // depend on which step is up, but intro is where the gap was seen. + const onboarding = createOnboardingState("http://127.0.0.1:8080"); + return { ...createInitialTuiState(stubSession()), onboarding }; + } + + function ctx( + state: ReturnType, + extra: Record = {}, + ) { + return { + state, + dispatch: vi.fn(), + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + ...extra, + }; + } + + it("first Ctrl+C on the splash arms the quit chord, exactly as in chat", () => { + const c = ctx(splashState()); + const handled = handleAppKey("c", emptyKey({ ctrl: true }), c); + expect(handled).toBe(true); + expect(c.setCtrlCArmed).toHaveBeenCalledWith(true); + expect(c.callbacks.onQuit).not.toHaveBeenCalled(); + expect(c.dispatch).not.toHaveBeenCalledWith({ type: "quit_requested" }); + }); + + it("second Ctrl+C inside the window quits from the splash", () => { + const c = ctx(splashState(), { ctrlCArmed: true }); + const handled = handleAppKey("c", emptyKey({ ctrl: true }), c); + expect(handled).toBe(true); + expect(c.callbacks.onAbort).toHaveBeenCalled(); + expect(c.callbacks.onQuit).toHaveBeenCalled(); + expect(c.dispatch).toHaveBeenCalledWith({ type: "quit_requested" }); + }); + + it("any other key is swallowed and breaks an armed chord, as chat keys do", () => { + const c = ctx(splashState(), { ctrlCArmed: true }); + const handled = handleAppKey("x", emptyKey(), c); + expect(handled).toBe(true); + expect(c.setCtrlCArmed).toHaveBeenCalledWith(false); + expect(c.callbacks.onQuit).not.toHaveBeenCalled(); + expect(c.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 66e17b8e..4ace63d8 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -1,3 +1,7 @@ +import { CODING_MODES, type CodingMode } from "./coding-mode.js"; +import { handleComposerSwitchKey } from "./composer-switch/composer-switch-key-bindings.js"; +import type { ComposerSwitchRow } from "./composer-switch/composer-switch-rows.js"; +import { handleContextPanelKey } from "./context-panel-keys.js"; import type { Key } from "ink"; import { canGrantCategory, @@ -6,10 +10,19 @@ import { type ApprovalRequest, } from "../approval/approval-gate.js"; import { formatApprovalCategory } from "../approval/approval-level.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; +import { + handleMenuKey, + isMenuLeaderKey, + isMenuOpenKey, + resolveLeaderChord, +} from "./menu/menu-keys.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { cycleNavSlot, type NavSlot } from "./section.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import type { TuiAction } from "./tui-action.js"; import type { TuiState } from "./tui-state.js"; +import { isUninstallConfirmed } from "./uninstall/uninstall-state.js"; /** * Number of **terminal rows** a single PageUp / PageDown keypress @@ -28,12 +41,33 @@ export interface AppKeyCallbacks { * request's whole category, `"shape"` (`a`, shell only) silences the * request's command binary. Absent = this call only (`y`). */ + /** + * Approve the pending call at a target the operator typed instead of + * the one proposed. The runtime hands the raw string to the tool, + * which resolves and re-categorises it — see `os.fs.write`. + */ + onApprovalRetarget?(approvalId: string, path: string): void; + /** + * A chat message submitted while an approval prompt is up. It denies + * that one call with the message as its reason (so the model reads + * the operator's words as the tool result) and lands the same text in + * the running turn. + */ + onApprovalReply?(approvalId: string, message: string): void; + /** The operator confirmed "delete the session?" for this thread. */ + onSessionDeleteConfirmed?(sessionId: string): void; + /** The word was typed and Enter pressed — take the app down and remove it. */ + onUninstallConfirmed?(): void; onApprovalDecision( approvalId: string, approved: boolean, grant?: ApprovalGrantScope, ): void; onAbort(): void; + /** Persist the Enter-while-busy mode after a Ctrl+T flip. */ + onWhileBusyModePersistRequested?(mode: WhileBusySubmitMode): void; + /** Open a fresh OS terminal window running atomic-agent (Ctrl+N, `/window`). */ + onNewWindowRequested?(): void; onQuit(): void; /** Optional — called when Enter is pressed on the focused sidebar row. */ onSessionSwitchRequested?(sessionId: string): void; @@ -72,79 +106,66 @@ export interface AppKeyContext { * the sidebar steals plain Tab. */ sidebarVisible: boolean; + /** True while a `ctrl+g` leader is waiting for its chord key. */ + menuLeaderArmed: boolean; + setMenuLeaderArmed: (armed: boolean) => void; + /** Navigate to a place, or run an action's slash command. */ + activateMenuNode: (node: MenuNode) => void; + /** + * Switches the transcript cap to auto, for `a` on the open context + * panel. Optional: surfaces without a config writer simply do not + * bind the key. + */ + /** + * Steps the context panel's task selector. A callback rather than an + * action because the work is a config write, and the reducer is pure. + */ + onStepPairs?: (delta: number) => void; + /** Run the row picked in one of the composer's route switches. */ + activateComposerSwitch: (row: ComposerSwitchRow) => void; + /** + * Carry out the plan on offer under `mode`, for the plan hand-off + * chords. Optional: a surface that draws no hand-off binds no keys. + */ + onPlanExecute?: (mode: CodingMode) => void; + /** Decline the plan on offer without leaving plan mode. */ + onPlanDismiss?: () => void; } +/** + * The chord each plan verb answers to. + * + * Deliberately the same shape, and two of the same letters, as + * {@link APPROVAL_CHORDS}: both are a short-lived verdict taken while + * the composer stays live underneath, so both have to be modified keys — + * a bare `y` is text someone is typing. Sharing the keys is safe because + * the two offers can never be on screen together: an approval exists + * only inside a running turn, and the hand-off is only ever raised by a + * turn that has *finished*. The approval branch is still checked first, + * so if that assumption ever breaks the safety-critical prompt wins. + */ +export const PLAN_CHORDS = { + /** Run it, editing freely here and asking about everything else. */ + auto: "y", + /** Run it and stop asking altogether. */ + bypass: "b", + /** Put the plan away; stay in plan mode. */ + dismiss: "d", +} as const; + /** * Global key-binding reducer executed outside the editor focus. Returns * `true` when the key was handled (the editor should ignore it). This * function is side-effectful (calls into `callbacks`) but the state * mutation funnels through `dispatch`, preserving reducer purity. */ -export function handleAppKey( - input: string, - key: Key, - ctx: AppKeyContext, -): boolean { - const { state, dispatch, callbacks, ctrlCArmed, setCtrlCArmed } = ctx; - if (state.pendingApproval) { - return handleApprovalKey(input, key, state.pendingApproval, ctx); - } - // A settled successful self-update parks the UI on a "press any key to - // restart" prompt. The first keystroke (whatever it is) re-execs the new - // binary; `quit_requested` then unmounts Ink so the restart handoff runs. - if (state.updateStatus === "done") { - callbacks.onUpdateRestart?.(); - dispatch({ type: "quit_requested" }); - return true; - } - // The update offer claims only y / n / Esc; anything else (Ctrl+C in - // particular) falls through to the normal handlers below. - if (state.updatePrompt && handleUpdateKey(input, key, ctx)) { - return true; - } - if ( - ctx.sidebarVisible && - state.uiMode === "chat" && - state.chatFocus === "sidebar" - ) { - if (handleSidebarKey(input, key, ctx)) return true; - } - if (key.ctrl && input === "c") { - if (ctrlCArmed) { - callbacks.onAbort(); - callbacks.onQuit(); - dispatch({ type: "quit_requested" }); - return true; - } - setCtrlCArmed(true); - if (state.status === "running" || state.status === "awaiting_approval") { - callbacks.onAbort(); - dispatch({ type: "abort_requested" }); - } - return true; - } - setCtrlCArmed(false); - if ( - state.uiMode === "chat" && - !state.slashPaletteOpen && - !state.pendingApproval - ) { - if (shouldTreatArrowAsChatScroll(input, key, state)) { - dispatch({ - type: "chat_scrolled", - delta: key.upArrow ? CHAT_WHEEL_ARROW_DELTA : -CHAT_WHEEL_ARROW_DELTA, - }); - return true; - } - if (key.pageUp) { - dispatch({ type: "chat_scrolled", delta: CHAT_PAGE_DELTA }); - return true; - } - if (key.pageDown) { - dispatch({ type: "chat_scrolled", delta: -CHAT_PAGE_DELTA }); - return true; - } - } +/** + * A debug-tab surface that owns its own keys is open — a modal, a + * confirm dialog, a wizard, or a focused text field. While one is up, + * global claims (nav cycling, the running Esc-abort) must bow out so + * the surface keeps its keystrokes. + */ +export function isPanelModalOpen(state: TuiState): boolean { const tasksTabBusy = state.uiMode === "debug" && state.activeTab === "tasks" && @@ -205,7 +226,7 @@ export function handleAppKey( // must not cycle the nav away mid-typing. (state.llmPanel.mode === "cloud" && state.llmPanel.cloudModelFilterFocused)); - const debugTabBusy = + return ( tasksTabBusy || skillsTabBusy || memoryTabBusy || @@ -213,7 +234,320 @@ export function handleAppKey( telegramTabBusy || mcpTabBusy || providersTabBusy || - llmTabBusy; + llmTabBusy + ); +} + +/** + * True when this Ctrl+C will be seen by the composer as "copy": a live + * selection AND a focused editor. The conditions after the flag mirror + * the states of `editorFocus` (tui-app.tsx) a selection can coexist + * with — sidebar focus, an open menu / context panel, and an armed + * leader all leave the selection standing while taking the keyboard + * away, and Ctrl+C must keep its global meaning there. + */ +function composerOwnsCtrlC(state: TuiState, menuLeaderArmed: boolean): boolean { + return ( + state.composerHasSelection && + state.uiMode === "chat" && + state.chatFocus === "editor" && + !state.menuOpen && + !state.contextPanelOpen && + !menuLeaderArmed + ); +} + +export function handleAppKey( + input: string, + key: Key, + ctx: AppKeyContext, +): boolean { + const { state, dispatch, callbacks, ctrlCArmed, setCtrlCArmed } = ctx; + // The right-click cut/copy/paste menu is dismissed by the next + // keystroke, whatever it is — the way GUI menus behave. Esc is + // consumed (its only meaning was "close this"); every other key falls + // through and keeps its ordinary meaning after the close. Above the + // onboarding swallow on purpose: the menu opens on onboarding editors + // too, and the flow's key hook never learns it exists. + if (state.contextMenu) { + dispatch({ type: "context_menu_closed" }); + if (key.escape) return true; + } + // The first-run flow owns the whole terminal while it is up: there is + // no chat, no panel and no menu behind it for a key to reach, and the + // screen subscribes to `useInput` itself. Swallow everything here so a + // keystroke is never acted on twice — except Ctrl+C, which must quit + // from setup exactly as it quits from anywhere else. + if (state.onboarding && !(key.ctrl && input === "c")) { + // Any other key breaks an armed quit chord here too — the swallow + // below never reaches the disarm that chat keys pass through, and + // ctrl+c, x, ctrl+c must not quit during setup when it would not + // have quit from chat. + setCtrlCArmed(false); + return true; + } + // Above the session dialog and above approvals: while the uninstall + // ladder is up it is the only thing on screen that can be answered, + // and a key that leaks past it would be a key aimed at a transcript + // the operator has already stopped looking at. + if (state.uninstall) { + return handleUninstallKey(input, key, ctx); + } + if (state.sessionDelete) { + return handleSessionDeleteKey(input, key, ctx); + } + // The plan hand-off. Below the ladders and below approvals, and + // reached only while an offer is actually standing — outside that the + // letters are ordinary text and must reach the draft untouched. + if (state.planHandoff && key.ctrl && !key.meta) { + const lower = input.toLowerCase(); + if (lower === PLAN_CHORDS.auto) { + ctx.onPlanExecute?.("auto"); + return true; + } + if (lower === PLAN_CHORDS.bypass) { + ctx.onPlanExecute?.("bypass"); + return true; + } + if (lower === PLAN_CHORDS.dismiss) { + ctx.onPlanDismiss?.(); + return true; + } + } + // Only the visible thread's question is answerable from the + // keyboard. The reducer never arms `pendingApproval` for another + // session (a background request surfaces as a notice instead), but + // the keys must not trust that invariant blind: a foreign request + // here would otherwise turn Ctrl+C into a cross-session deny plus a + // visible-turn abort in one press. Unmatched, keys fall through to + // their ordinary meanings. + if ( + state.pendingApproval && + state.pendingApproval.sessionId === state.session.sessionId + ) { + return handleApprovalKey(input, key, state.pendingApproval, ctx); + } + // A settled successful self-update parks the UI on a "press any key to + // restart" prompt. The first keystroke (whatever it is) re-execs the new + // binary; `quit_requested` then unmounts Ink so the restart handoff runs. + if (state.updateStatus === "done") { + callbacks.onUpdateRestart?.(); + dispatch({ type: "quit_requested" }); + return true; + } + // The update offer claims only y / n / Esc; anything else (Ctrl+C in + // particular) falls through to the normal handlers below. + if (state.updatePrompt && handleUpdateKey(input, key, ctx)) { + return true; + } + // The mode menu is a dropdown on the composer's toolbar, so it takes + // the keys while it is up — above the operator menu, because ctrl+p + // should close it and open the menu rather than land on both. + if (state.codingModeMenu) { + if (key.escape) { + dispatch({ type: "coding_mode_menu_closed" }); + return true; + } + if (key.upArrow || key.downArrow) { + dispatch({ + type: "coding_mode_menu_cursor_moved", + delta: key.downArrow ? 1 : -1, + }); + return true; + } + if (key.return) { + const picked = CODING_MODES[state.codingModeMenu.cursor]; + if (picked) dispatch({ type: "coding_mode_cycled", mode: picked }); + return true; + } + // Anything else closes the menu and is then handled normally: a + // dropdown that swallowed every keystroke would strand an operator + // who opened it by accident mid-sentence. + dispatch({ type: "coding_mode_menu_closed" }); + } + // The menu and its leader sit above every panel guard on purpose: they are + // the way out of a panel, so a panel must never be able to swallow them. + if (handleMenuKey(input, key, { state, dispatch, activate: ctx.activateMenuNode })) { + return true; + } + // Below the menu on purpose: ctrl+p should still reach the menu from + // an open context panel, and opening the menu closes the panel. + if ( + handleContextPanelKey(input, key, { + state, + dispatch, + ...(ctx.onStepPairs ? { onStepPairs: ctx.onStepPairs } : {}), + }) + ) { + return true; + } + // Same rung, same reason: the composer's route switches let ctrl-chords + // through so the menu stays reachable from inside one. They open only + // where the composer is the surface the operator is looking at — on a + // Manage tab the row is off screen, and a switch anchored to it would + // be a popup with no visible owner. + if ( + handleComposerSwitchKey(input, key, { + state, + dispatch, + activate: ctx.activateComposerSwitch, + canOpen: + state.uiMode === "chat" && + !state.slashPaletteOpen && + !state.pendingApproval && + !state.themePickerOpen && + !state.sessionPickerOpen && + !isPanelModalOpen(state), + }) + ) { + return true; + } + if (ctx.menuLeaderArmed) { + ctx.setMenuLeaderArmed(false); + const node = resolveLeaderChord(input, key); + if (node) { + ctx.activateMenuNode(node); + return true; + } + // An unclaimed *bare* key is swallowed rather than passed on: a + // mistyped leader must not leak a letter into the prompt or fire a + // panel hotkey. A modified key was never a chord, though — it means + // the operator changed their mind — so it only disarms and then falls + // through to the bindings below, where `ctrl+c` still aborts the turn. + if (!key.ctrl && !key.meta) return true; + } + if (!state.slashPaletteOpen && isMenuLeaderKey(input, key)) { + ctx.setMenuLeaderArmed(true); + return true; + } + if (!state.slashPaletteOpen && isMenuOpenKey(input, key)) { + dispatch({ type: "menu_opened" }); + return true; + } + if ( + ctx.sidebarVisible && + state.uiMode === "chat" && + state.chatFocus === "sidebar" + ) { + if (handleSidebarKey(input, key, ctx)) return true; + } + if (key.ctrl && input === "c") { + // With text selected in the composer, Ctrl+C copies it — the + // convention every terminal-adjacent editor follows. The editor owns + // that; arming the quit chord here would make the same keystroke + // mean two things at once. + // …but only while the composer is actually FOCUSED to receive it. + // The flag alone is not enough: it is set by a component that + // unmounts on every Observe / Manage tab, and it survives Tab into + // the sidebar, an open menu, or an armed leader — all states where + // the editor's own handler is inactive. Standing down then would + // leave Ctrl+C claimed by nobody: no abort, no quit, until focus + // returned. Mirror the parts of `editorFocus` that can coexist with + // a live selection. + if (composerOwnsCtrlC(state, ctx.menuLeaderArmed)) return false; + if (ctrlCArmed) { + callbacks.onAbort(); + callbacks.onQuit(); + dispatch({ type: "quit_requested" }); + return true; + } + setCtrlCArmed(true); + if (state.status === "running" || state.status === "awaiting_approval") { + callbacks.onAbort(); + dispatch({ type: "abort_requested" }); + } + return true; + } + setCtrlCArmed(false); + // Ctrl+T flips what Enter does while a turn is running (steer <-> queue). + // Alt/Shift/Ctrl+Enter are all "insert newline" in the editor, so the + // mode cannot live on a Return modifier; an explicit, visible toggle is + // the honest alternative. Guarded like the other global claims so a + // panel modal or the palette never has the mode flipped under it, and + // placed after the Ctrl+C disarm so a flip cannot ride an armed quit. + if ( + key.ctrl && + !key.shift && + !key.meta && + input === "t" && + !state.pendingApproval && + !state.slashPaletteOpen && + !isPanelModalOpen(state) + ) { + const next = state.whileBusyMode === "steer" ? "queue" : "steer"; + dispatch({ type: "while_busy_mode_changed", mode: next }); + callbacks.onWhileBusyModePersistRequested?.(next); + return true; + } + // Esc aborts a turn in flight — the binding the hint strip advertises + // for the whole time `status === "running"`. It has to be claimed here + // rather than in the editor's own Esc handler because the editor is + // `disabled` while a turn runs, which switches its `useInput` off and + // makes the abort branch over there unreachable. Overlays that own Esc + // themselves keep it; a pending approval already returned above. + if ( + key.escape && + state.status === "running" && + !state.slashPaletteOpen && + !state.themePickerOpen && + !state.sessionPickerOpen && + // A panel modal / confirm / wizard / focused field owns Esc for its + // own cancel; aborting the run out from under it would make one + // keypress do two unrelated things (and some of those surfaces run + // their own useInput, which Ink fires regardless of ours). + !isPanelModalOpen(state) + ) { + // Scroll-reset keeps its precedence: Esc with the chat scrolled away + // from the bottom snaps back to the latest reply before doing + // anything else — the rung this branch now runs ahead of, and the + // reason a mid-run PageUp + Esc must not destroy the turn. Only in + // chat mode; on a debug tab the chat is off-screen, so a stale + // offset there would just make Esc look dead. + if (state.uiMode === "chat" && state.chatScrollOffset > 0) { + dispatch({ type: "chat_scroll_reset" }); + return true; + } + callbacks.onAbort(); + dispatch({ type: "abort_requested" }); + return true; + } + if ( + state.uiMode === "chat" && + !state.slashPaletteOpen && + !state.pendingApproval + ) { + if (shouldTreatArrowAsChatScroll(input, key, state)) { + dispatch({ + type: "chat_scrolled", + delta: key.upArrow ? CHAT_WHEEL_ARROW_DELTA : -CHAT_WHEEL_ARROW_DELTA, + }); + return true; + } + if (key.pageUp) { + dispatch({ type: "chat_scrolled", delta: CHAT_PAGE_DELTA }); + return true; + } + if (key.pageDown) { + dispatch({ type: "chat_scrolled", delta: -CHAT_PAGE_DELTA }); + return true; + } + } + const debugTabBusy = isPanelModalOpen(state); + // Ctrl+N opens a fresh OS terminal window running atomic-agent in the + // same working dir. The editor never sees ctrl-modified letters + // (it handles only ctrl+a/e/u/k/w/c), so no keystroke is stolen. + if ( + !debugTabBusy && + !state.slashPaletteOpen && + !state.pendingApproval && + key.ctrl && + !key.shift && + !key.meta && + input === "n" + ) { + callbacks.onNewWindowRequested?.(); + return true; + } // Ctrl+B is the dedicated nav-cycle escape valve: it always advances // one nav slot forward regardless of where focus currently is. This // is the key power users press when they want to reach Observe / @@ -271,6 +605,35 @@ export function handleAppKey( return false; } +/** + * Last-resort Esc handling for the debug (Observe / Manage) panels, + * called by `TuiApp` after the active panel's own key layer declined + * the key. Esc that nobody claimed goes home to Run — the single + * "back" gesture out of a panel, which previously did not exist (the + * only way back was cycling Tab through every remaining sub-tab). + * + * Precedence is preserved by the caller passing `panelHandled`: modals, + * search inputs, detail views and half-typed forms consume Esc in their + * own layer first and never reach here. `editorFocus` guards the tabs + * that leave the chat editor focused — there the editor's own input + * hook owns Esc (scroll-reset / quit; abort is claimed earlier, by + * `handleAppKey`) and must not double-act. + * + * Returns `true` when the key was consumed. + */ +export function handlePanelEscape( + key: Key, + opts: { + panelHandled: boolean; + editorFocus: boolean; + dispatch: (action: TuiAction) => void; + }, +): boolean { + if (!key.escape || opts.panelHandled || opts.editorFocus) return false; + opts.dispatch({ type: "ui_mode_set", mode: "chat" }); + return true; +} + function shouldTreatArrowAsChatScroll( input: string, key: Key, @@ -340,6 +703,25 @@ function handleSidebarKey( } return true; } + // FINDING: deleting a thread was mouse-only, while the `[x]` is + // painted whether or not mouse reporting is on — `/mouse off`, a + // terminal without reporting, or simply keyboard-first operators had + // a visible control they could not reach. Delete / `x` opens the same + // confirmation the mark does. + if ( + state.sidebarSection === "sessions" && + (key.delete || (!key.ctrl && !key.meta && input.toLowerCase() === "x")) + ) { + const entry = state.recentSessions[state.sidebarCursor]; + if (entry) { + dispatch({ + type: "session_delete_requested", + sessionId: entry.sessionId, + preview: entry.preview, + }); + } + return true; + } if (key.return) { if (state.sidebarSection === "tasks") { const visible = selectSidebarTasks(state.tasksPanel.rows); @@ -361,7 +743,12 @@ function handleSidebarKey( return false; } -function applyNavSlot( +/** + * Apply a nav slot — the one place that knows "run" means chat mode and + * every other slot is a debug tab. Exported so a click on a status-bar + * pill lands the operator in exactly the same state Tab would. + */ +export function applyNavSlot( dispatch: (action: TuiAction) => void, slot: NavSlot, ): void { @@ -378,6 +765,7 @@ function handleUpdateKey( key: Key, ctx: AppKeyContext, ): boolean { + if (key.ctrl || key.meta) return false; const lower = input.toLowerCase(); if (lower === "y") { ctx.callbacks.onUpdateConfirmed?.(); @@ -407,67 +795,341 @@ function grantConfirmation( return `granted: ${formatApprovalCategory(request.category)} for this session`; } -function handleApprovalKey( +/** + * Resolve a pending approval: tell the runtime, then fold the decision + * into the reducer (and, for a grant, print the confirmation line). + * Shared by the key handler and the approval modal's clickable + * buttons — one implementation, so the two can never disagree about + * what "approve" means. + */ +export function decideApproval( + request: ApprovalRequest, + approved: boolean, + ctx: { + dispatch: (action: TuiAction) => void; + callbacks: Pick; + }, + grant?: ApprovalGrantScope, +): void { + // Call through without a trailing `undefined`: the callback's arity + // is observable (tests spy on it, hosts may inspect `arguments`). + if (grant) { + ctx.callbacks.onApprovalDecision(request.approvalId, approved, grant); + } else { + ctx.callbacks.onApprovalDecision(request.approvalId, approved); + } + ctx.dispatch({ + type: "approval_resolved", + approvalId: request.approvalId, + approved, + }); + if (approved && grant) { + ctx.dispatch({ + type: "system_message", + text: grantConfirmation(request, grant), + }); + } +} + +/** What a keystroke means to the approval prompt, if anything. */ +export type ApprovalHotkey = + | "approve" + | "grant_category" + | "grant_shape" + | "edit_path" + | "deny" + | "abort"; + +/** + * The chord each approval verb answers to, and the label the button + * prints beside it. + * + * **Why chords and not letters.** The chat composer stays live while a + * prompt is up — that is how an operator answers the agent in words + * instead of a verdict — so a bare `y` is ambiguous by construction. + * The old rule resolved it with the buffer: with nothing typed the + * letters decided, and from the first character on every key was text. + * That works right up until someone starts a message with "yes, but…", + * at which point the `y` has already approved the call. A modified key + * is never text, so the ambiguity does not arise and the buffer no + * longer has to arbitrate. + * + * **Why these four letters.** Every one of them is unclaimed both by + * the app's global chords and by the live editor underneath. That is + * the whole constraint, and it is tighter than it looks: + * + * - `ctrl+a` / `ctrl+e` / `ctrl+u` / `ctrl+k` / `ctrl+w` are the + * editor's own line-editing bindings (`multi-line-editor-keys.ts`). + * Claiming one would fix the typing collision in one direction and + * open it in the other — an operator mid-message would lose + * delete-word to a *deny*. + * - `ctrl+c` / `ctrl+p` / `ctrl+g` / `ctrl+l` / `ctrl+n` / `ctrl+o` / + * `ctrl+q` / `ctrl+r` / `ctrl+t` / `ctrl+x` are global. + * - `ctrl+s` is XOFF, which a terminal outside our raw mode (screen, + * an ssh hop with flow control on) can still eat. + * + * That leaves `ctrl+y`, `ctrl+d`, `ctrl+f` and `ctrl+b`. + * + * **Why `ctrl+b` does two jobs.** `[a]` (grant this command shape) is + * offered only for a `shell` request, and `[e]` (edit the target path) + * only where `redirectablePath` is set — which `os.fs.write` is the + * only tool that does. The two can never be on screen together, so + * they are one slot in the prompt and one chord on the keyboard. The + * button says which one it currently is; `approval-key-arbitration` + * pins the exclusivity so a future tool cannot quietly break it. + */ +export const APPROVAL_CHORDS = { + approve: "y", + deny: "d", + grantCategory: "f", + /** Shape grant and path edit share this — see above. */ + contextual: "b", +} as const; + +/** + * Resolve a keystroke against the pending approval prompt — the single + * place that decides whether a key is a *decision* or ordinary *text*. + * + * Both key layers consult this: `handleApprovalKey` to act, and the + * composer's `claimKey` guard to stand down. One function, so the two + * can never disagree and double-handle a keystroke. + */ +export function approvalHotkey( + state: TuiState, input: string, key: Key, +): ApprovalHotkey | null { + const request = state.pendingApproval; + if (!request) return null; + // Never a verdict on another session's request — same scope guard as + // `handleAppKey`, kept here too because the composer's claimKey + // consults this function directly. + if (request.sessionId !== state.session.sessionId) return null; + // The target field owns every key while it is open. + if (state.approvalPathDraft !== null) return null; + // Esc keeps the old rule, and keeps it for the old reason: it is the + // editor's "clear the draft" key too, so only an empty buffer lets it + // abort the run. Unlike the letters it was never a *decision* — the + // worst a misread Esc does is throw away a half-typed message. + if (key.escape) return state.inputValue.length > 0 ? null : "abort"; + // Everything else is a chord. `meta` is excluded rather than ignored: + // alt+y on a Mac terminal is a character, not a verdict. + if (!key.ctrl || key.meta) return null; + const lower = input.toLowerCase(); + if (lower === APPROVAL_CHORDS.approve) return "approve"; + if (lower === APPROVAL_CHORDS.deny) return "deny"; + if (lower === APPROVAL_CHORDS.grantCategory && canGrantCategory(request)) { + return "grant_category"; + } + if (lower === APPROVAL_CHORDS.contextual) { + if (canGrantShape(request)) return "grant_shape"; + if (canEditPath(request)) return "edit_path"; + } + return null; +} + +/** Whether this request offers a retarget (`[e]`). */ +export function canEditPath(request: ApprovalRequest): boolean { + return typeof request.redirectablePath === "string" + && request.redirectablePath.length > 0; +} + +/** + * Approve the pending call at `path` instead of the proposed target. + * The prompt closes here; whether that path needs another prompt is the + * tool's call, not the UI's — a target on a different rung of the + * ladder comes back as a fresh request. + */ +export function submitApprovalPath( request: ApprovalRequest, + path: string, + ctx: { + dispatch: (action: TuiAction) => void; + callbacks: Pick; + }, +): void { + ctx.callbacks.onApprovalRetarget?.(request.approvalId, path); + ctx.dispatch({ type: "approval_path_edit_closed" }); + ctx.dispatch({ + type: "approval_resolved", + approvalId: request.approvalId, + approved: true, + }); +} + +/** + * Keys for the "delete the session?" dialog. `y` deletes, `n` / Esc + * cancels, ←/→ and Tab move between the two controls, Enter runs the + * focused one — which starts on Cancel, so a reflexive Enter is a + * no-op rather than a lost thread. + * + * Every other key is swallowed: while a destructive confirmation is up, + * a stray letter must not reach the rail or the composer behind it. + */ +function handleSessionDeleteKey( + input: string, + key: Key, ctx: AppKeyContext, ): boolean { + const { state, dispatch, callbacks } = ctx; + const confirm = state.sessionDelete; + if (!confirm) return false; + // Ctrl+C is "stop everything", and while this dialog was up it reached + // no layer at all — the operator could not abort a running turn or arm + // the quit chord without dismissing the dialog first. Close it and let + // the global handler have the key. + if (key.ctrl && input === "c") { + dispatch({ type: "session_delete_closed" }); + return false; + } + if (key.ctrl || key.meta) return false; const lower = input.toLowerCase(); + const close = (): void => dispatch({ type: "session_delete_closed" }); + if (key.escape || lower === "n") { + close(); + return true; + } if (lower === "y") { - ctx.callbacks.onApprovalDecision(request.approvalId, true); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: true, - }); + callbacks.onSessionDeleteConfirmed?.(confirm.sessionId); + close(); return true; } - if (lower === "s" && canGrantCategory(request)) { - ctx.callbacks.onApprovalDecision(request.approvalId, true, "category"); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: true, - }); - ctx.dispatch({ - type: "system_message", - text: grantConfirmation(request, "category"), + if (key.leftArrow || key.rightArrow || key.tab) { + dispatch({ + type: "session_delete_cursor_set", + cursor: confirm.cursor === "yes" ? "cancel" : "yes", }); return true; } - if (lower === "a" && canGrantShape(request)) { - ctx.callbacks.onApprovalDecision(request.approvalId, true, "shape"); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: true, - }); - ctx.dispatch({ - type: "system_message", - text: grantConfirmation(request, "shape"), - }); + if (key.return) { + if (confirm.cursor === "yes") { + callbacks.onSessionDeleteConfirmed?.(confirm.sessionId); + } + close(); return true; } - if (lower === "n") { - ctx.callbacks.onApprovalDecision(request.approvalId, false); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: false, - }); + return true; +} + +/** + * Keys for the uninstall ladder. + * + * Two rules carry the whole design. The first: `y` does nothing, on any + * screen — the reflex answer to a confirm dialog must not be an answer + * here. The second: on the last screen, Enter only means something once + * the word has actually been typed, and every other printable key is + * text going into that field rather than a command. There is no key + * that skips a step and no key that means "yes" twice in a row. + */ +function handleUninstallKey( + input: string, + key: Key, + ctx: AppKeyContext, +): boolean { + const { state, dispatch, callbacks } = ctx; + const flow = state.uninstall; + if (!flow) return false; + const close = (): void => dispatch({ type: "uninstall_closed" }); + + // Nothing is answerable once the app is on its way down — including + // Ctrl+C, which at that point would leave a half-removed install. + if (flow.step === "closing") return true; + + // Ctrl+C closes the dialog and hands the key on, same contract the + // session dialog has: "stop everything" must never be swallowed. + if (key.ctrl && input === "c") { + close(); + return false; + } + if (key.escape) { + close(); + return true; + } + if (key.ctrl || key.meta) return false; + + if (flow.step === "loading" || flow.step === "failed") return true; + + if (flow.step === "review") { + if (key.leftArrow || key.rightArrow || key.tab) { + dispatch({ + type: "uninstall_cursor_set", + cursor: flow.cursor === "cancel" ? "continue" : "cancel", + }); + return true; + } + if (key.return) { + // An empty plan has nothing to continue to, so Enter closes. + if (flow.cursor === "continue" && (flow.preview?.rows.length ?? 0) > 0) { + dispatch({ type: "uninstall_review_accepted" }); + } else { + close(); + } + return true; + } + return true; + } + + // `confirm`: a text field with one accepted value. + if (key.return) { + if (!isUninstallConfirmed(flow.typed)) return true; + dispatch({ type: "uninstall_started" }); + callbacks.onUninstallConfirmed?.(); + return true; + } + if (key.backspace || key.delete) { + dispatch({ type: "uninstall_typed_set", typed: flow.typed.slice(0, -1) }); + return true; + } + if (input && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) { + // Capped at a little over the word's length: a paste of a whole + // paragraph should not become a field the operator has to clear + // one backspace at a time. + const typed = `${flow.typed}${input}`.slice(0, 32); + dispatch({ type: "uninstall_typed_set", typed }); return true; } - if (key.escape || (key.ctrl && input === "c")) { - ctx.callbacks.onApprovalDecision(request.approvalId, false); + return true; +} + +function handleApprovalKey( + input: string, + key: Key, + request: ApprovalRequest, + ctx: AppKeyContext, +): boolean { + // Ctrl+C keeps aborting even with a draft in the buffer: it is the + // "stop everything" key, not a prompt answer. + if (key.ctrl && input === "c") { + decideApproval(request, false, ctx); ctx.callbacks.onAbort(); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: false, - }); ctx.dispatch({ type: "abort_requested" }); return true; } - return false; + switch (approvalHotkey(ctx.state, input, key)) { + case "approve": + decideApproval(request, true, ctx); + return true; + case "grant_category": + decideApproval(request, true, ctx, "category"); + return true; + case "grant_shape": + decideApproval(request, true, ctx, "shape"); + return true; + case "edit_path": + ctx.dispatch({ + type: "approval_path_edit_opened", + path: request.redirectablePath ?? "", + }); + return true; + case "deny": + decideApproval(request, false, ctx); + return true; + case "abort": + decideApproval(request, false, ctx); + ctx.callbacks.onAbort(); + ctx.dispatch({ type: "abort_requested" }); + return true; + default: + return false; + } } diff --git a/src/tui/approval-key-arbitration.test.ts b/src/tui/approval-key-arbitration.test.ts new file mode 100644 index 00000000..7e193d3c --- /dev/null +++ b/src/tui/approval-key-arbitration.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Key } from "ink"; + +import { + approvalHotkey, + canEditPath, + handleAppKey, + submitApprovalPath, +} from "./app-key-bindings.js"; +import { canGrantShape } from "../approval/approval-gate.js"; +import { createInitialTuiState, type TuiSessionInfo, type TuiState } from "./tui-state.js"; +import type { ApprovalRequest } from "../approval/approval-gate.js"; + +/** + * The chat composer stays live under an approval prompt. It used to be + * that one *letter* therefore had two meanings, arbitrated by whether + * the buffer was empty; these tests now pin the arbitration that + * replaced it. Every decision is a ctrl-chord, so a letter is always + * text and the buffer never has to decide. What is left for these tests + * to prove is that the chords are claimed regardless of the draft, that + * they do not collide with the editor's own line-editing chords, and + * that Esc — the one binding both layers still want — stays with the + * editor while there is something to clear. + */ +function key(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + super: false, + hyper: false, + capsLock: false, + numLock: false, + ...overrides, + } as Key; +} + +function session(): TuiSessionInfo { + return { + sessionId: "s-x", + workingDir: "/tmp/w", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chromium", + browserHeadless: true, + approvalLevel: 1, + maxSteps: 8, + skillCount: 0, + }; +} + +function writeRequest(overrides: Partial = {}): ApprovalRequest { + return { + approvalId: "ap-1", + sessionId: "s-x", + tool: "os.fs.write", + category: "fs_write_workspace", + reason: "replace 1337 bytes into /work/site/index.html", + redirectablePath: "/work/site/index.html", + ...overrides, + }; +} + +function pending(overrides: Partial = {}): TuiState { + return { + ...createInitialTuiState(session()), + pendingApproval: writeRequest(), + ...overrides, + }; +} + +describe("approvalHotkey", () => { + const ctrl = { ctrl: true }; + + it("claims its chords", () => { + const state = pending(); + expect(approvalHotkey(state, "y", key(ctrl))).toBe("approve"); + expect(approvalHotkey(state, "d", key(ctrl))).toBe("deny"); + expect(approvalHotkey(state, "f", key(ctrl))).toBe("grant_category"); + expect(approvalHotkey(state, "b", key(ctrl))).toBe("edit_path"); + expect(approvalHotkey(state, "", key({ escape: true }))).toBe("abort"); + }); + + it("never reads a bare letter as a decision", () => { + // The whole point of the change. "yes, but put it in ~/Documents" + // used to approve the call on its first keystroke. + const state = pending(); + for (const letter of ["y", "d", "f", "b", "s", "a", "e", "n"]) { + expect(approvalHotkey(state, letter, key())).toBeNull(); + } + }); + + it("keeps claiming its chords with a draft in the buffer", () => { + // The buffer used to be the arbiter, so a half-typed message + // disarmed every verdict and an operator had to clear it before + // they could answer. A chord is unambiguous either way. + const state = pending({ inputValue: "yes, but put it in " }); + expect(approvalHotkey(state, "y", key(ctrl))).toBe("approve"); + expect(approvalHotkey(state, "d", key(ctrl))).toBe("deny"); + // Esc is the exception, and keeps its old rule for its old reason: + // it is the editor's "clear the draft" key too, and it was never a + // decision — the worst a misread Esc does is discard a message. + expect(approvalHotkey(state, "", key({ escape: true }))).toBeNull(); + }); + + it("leaves the editor's own line-editing chords alone", () => { + // `ctrl+a` / `ctrl+e` / `ctrl+u` / `ctrl+k` / `ctrl+w` are + // `multi-line-editor-keys.ts`. Claiming one would fix the collision + // in one direction and open it in the other: an operator mid-message + // would lose delete-word to a deny. + const state = pending({ inputValue: "half a sentence" }); + for (const letter of ["a", "e", "u", "k", "w", "c", "v", "x"]) { + expect( + approvalHotkey(state, letter, key(ctrl)), + `ctrl+${letter} must stay with the editor`, + ).toBeNull(); + } + }); + + it("stands down while the target field owns the keyboard", () => { + const state = pending({ approvalPathDraft: "/work/site/index.html" }); + expect(approvalHotkey(state, "y", key(ctrl))).toBeNull(); + expect(approvalHotkey(state, "d", key(ctrl))).toBeNull(); + }); + + it("ignores meta so alt+y stays a character, not a verdict", () => { + const state = pending(); + expect(approvalHotkey(state, "y", key({ meta: true }))).toBeNull(); + expect(approvalHotkey(state, "y", key({ ctrl: true, meta: true }))).toBeNull(); + }); + + it("says nothing when no prompt is up", () => { + expect( + approvalHotkey(createInitialTuiState(session()), "y", key(ctrl)), + ).toBeNull(); + }); + + describe("the shared ctrl+b slot", () => { + const shellWithShape = pending({ + pendingApproval: writeRequest({ + tool: "os.shell.run", + category: "shell", + commandShape: "rm", + redirectablePath: undefined, + }), + }); + + it("grants the command shape on a shell request", () => { + expect(approvalHotkey(shellWithShape, "b", key(ctrl))).toBe("grant_shape"); + }); + + it("opens the target field on a write request", () => { + expect(approvalHotkey(pending(), "b", key(ctrl))).toBe("edit_path"); + }); + + it("does nothing where the request offers neither", () => { + const opaque = pending({ + pendingApproval: writeRequest({ + tool: "os.shell.run", + category: "shell", + redirectablePath: undefined, + }), + }); + expect(approvalHotkey(opaque, "b", key(ctrl))).toBeNull(); + }); + + it("is never asked to mean both at once", () => { + // The exclusivity the shared chord rests on, stated as a property + // rather than as three examples: the shape grant is shell-only + // (`canGrantShape`) and the retarget is set by `os.fs.write` + // alone (`redirectablePath`), so no request can offer both. A + // future tool that set `redirectablePath` on a shell request + // would fail here — which is the point. + const requests: ApprovalRequest[] = [ + writeRequest(), + writeRequest({ tool: "os.shell.run", category: "shell", commandShape: "git", redirectablePath: undefined }), + writeRequest({ tool: "os.shell.run", category: "shell", redirectablePath: undefined }), + writeRequest({ tool: "os.fs.trash", category: "fs_trash", redirectablePath: undefined }), + writeRequest({ tool: "os.http.request", category: "http", redirectablePath: undefined }), + ]; + for (const request of requests) { + expect( + canGrantShape(request) && canEditPath(request), + `${request.tool} offers both a shape grant and a retarget`, + ).toBe(false); + } + }); + }); +}); + +describe("handleAppKey under an approval prompt", () => { + function ctx(state: TuiState) { + return { + state, + dispatch: vi.fn(), + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }; + } + + it("ctrl+b opens the target field seeded with the proposed path", () => { + const c = ctx(pending()); + expect(handleAppKey("b", key({ ctrl: true }), c)).toBe(true); + expect(c.dispatch).toHaveBeenCalledWith({ + type: "approval_path_edit_opened", + path: "/work/site/index.html", + }); + expect(c.callbacks.onApprovalDecision).not.toHaveBeenCalled(); + }); + + it("lets a bare letter through to the composer, draft or not", () => { + for (const inputValue of ["", "put it in "]) { + const c = ctx(pending({ inputValue })); + expect(handleAppKey("d", key(), c)).toBe(false); + expect(c.callbacks.onApprovalDecision).not.toHaveBeenCalled(); + } + }); + + it("decides on a chord even with a draft in the buffer", () => { + const c = ctx(pending({ inputValue: "yes, but " })); + expect(handleAppKey("y", key({ ctrl: true }), c)).toBe(true); + expect(c.callbacks.onApprovalDecision).toHaveBeenCalledWith("ap-1", true); + }); + + it("still aborts on Ctrl+C with a draft in the buffer", () => { + // Ctrl+C is "stop everything", not a prompt answer, so it is the + // one key a draft does not disarm. + const c = ctx(pending({ inputValue: "half a sentence" })); + expect(handleAppKey("c", key({ ctrl: true }), c)).toBe(true); + expect(c.callbacks.onApprovalDecision).toHaveBeenCalledWith("ap-1", false); + expect(c.callbacks.onAbort).toHaveBeenCalled(); + }); +}); + +describe("submitApprovalPath", () => { + it("approves the call at the typed path and closes the prompt", () => { + const dispatch = vi.fn(); + const onApprovalRetarget = vi.fn(); + submitApprovalPath(writeRequest(), "~/Documents/apple-site/index.html", { + dispatch, + callbacks: { onApprovalRetarget }, + }); + expect(onApprovalRetarget).toHaveBeenCalledWith( + "ap-1", + "~/Documents/apple-site/index.html", + ); + expect(dispatch).toHaveBeenCalledWith({ type: "approval_path_edit_closed" }); + expect(dispatch).toHaveBeenCalledWith({ + type: "approval_resolved", + approvalId: "ap-1", + approved: true, + }); + }); +}); diff --git a/src/tui/approval-live-composer.test.tsx b/src/tui/approval-live-composer.test.tsx new file mode 100644 index 00000000..1f971862 --- /dev/null +++ b/src/tui/approval-live-composer.test.tsx @@ -0,0 +1,186 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js"; +import type { TuiSessionInfo } from "./tui-state.js"; +import type { ApprovalRequest } from "../approval/approval-gate.js"; + +/** + * End-to-end through the real key layers: an approval prompt no longer + * takes the keyboard hostage, and no longer has to. Ink delivers every + * keystroke to every `useInput` subscription, so these are the tests + * that would catch a `y` that is both a verdict and a character — which + * is exactly what it used to be, arbitrated by whether the composer's + * buffer happened to be empty. + */ +const SESSION: TuiSessionInfo = { + sessionId: "s1", + workingDir: "/tmp/smoke", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 1, + maxSteps: 10, + skillCount: 0, +}; + +const REQUEST: ApprovalRequest = { + approvalId: "ap-1", + sessionId: "s1", + tool: "os.fs.write", + category: "fs_write_workspace", + reason: "replace 1337 bytes into /tmp/smoke/index.html", + redirectablePath: "/tmp/smoke/index.html", +}; + +/** Ink holds a lone Esc for 20ms; every read waits past that window. */ +const ESC = String.fromCharCode(27); + +/** + * The decision chords as the bytes a terminal actually sends. Written + * as control characters rather than as a synthesised `Key` because the + * whole point of this file is to go through Ink's own parser: a chord + * that this app claims but Ink reports differently would pass every + * unit test and fail in a real terminal. + */ +const CTRL_Y = String.fromCharCode(25); +const CTRL_D = String.fromCharCode(4); +const CTRL_B = String.fromCharCode(2); +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, 60)); + +/** Drop SGR colour runs so assertions read the plain text. */ +const strip = (value: string): string => + value.replace(new RegExp(ESC + "\\[[0-9;]*m", "g"), ""); + +interface Calls { + decisions: Array<{ id: string; approved: boolean }>; + retargets: Array<{ id: string; path: string }>; + replies: Array<{ id: string; message: string }>; + aborts: number; +} + +function harness() { + const calls: Calls = { decisions: [], retargets: [], replies: [], aborts: 0 }; + const callbacks: TuiAppCallbacks = { + onApprovalDecision: (id, approved) => calls.decisions.push({ id, approved }), + onApprovalRetarget: (id, path) => calls.retargets.push({ id, path }), + onApprovalReply: (id, message) => calls.replies.push({ id, message }), + onAbort: () => { + calls.aborts++; + }, + onQuit: () => {}, + onMessageSubmitted: () => {}, + }; + const bus = makeTuiEventBus(); + const app = render(); + return { calls, bus, ...app }; +} + +describe("approval prompt with a live composer", () => { + it("types into the input instead of deciding, then sends on Enter", async () => { + const { calls, bus, stdin, lastFrame, unmount } = harness(); + await settle(); + bus.emitApproval(REQUEST); + await settle(); + + // A message that opens with "y" must not approve the write. + stdin.write("yes, put it in ~/Documents/apple-site"); + await settle(); + expect(calls.decisions).toHaveLength(0); + expect(strip(lastFrame() ?? "")).toContain("~/Documents/apple-site"); + + stdin.write("\r"); + await settle(); + expect(calls.replies).toEqual([ + { id: "ap-1", message: "yes, put it in ~/Documents/apple-site" }, + ]); + // The reply IS the verdict, so no separate approve/deny fires. + expect(calls.decisions).toHaveLength(0); + unmount(); + }); + + it("approves on ctrl+y, empty buffer or not", async () => { + const { calls, bus, stdin, unmount } = harness(); + await settle(); + bus.emitApproval(REQUEST); + await settle(); + + // A bare `y` is a character now, in every state. + stdin.write("y"); + await settle(); + expect(calls.decisions).toHaveLength(0); + + // And the chord decides straight through the draft that `y` left — + // which the old buffer-arbitrated rule could not do. + stdin.write(CTRL_Y); + await settle(); + expect(calls.decisions).toEqual([{ id: "ap-1", approved: true }]); + unmount(); + }); + + it("denies on ctrl+d through a draft, and Esc still only clears it", async () => { + const { calls, bus, stdin, lastFrame, unmount } = harness(); + await settle(); + bus.emitApproval(REQUEST); + await settle(); + + stdin.write("nope"); + await settle(); + expect(calls.decisions).toHaveLength(0); + + stdin.write(ESC); + await settle(); + expect(strip(lastFrame() ?? "")).not.toContain("nope"); + // Esc cleared the draft; it must not have aborted the run. This is + // the one binding the two layers still share, and the reason it can + // stay shared is that it was never a verdict. + expect(calls.aborts).toBe(0); + + stdin.write(CTRL_D); + await settle(); + expect(calls.decisions).toEqual([{ id: "ap-1", approved: false }]); + unmount(); + }); + + it("ctrl+b opens the target field, and Enter confirms the typed path", async () => { + const { calls, bus, stdin, lastFrame, unmount } = harness(); + await settle(); + bus.emitApproval(REQUEST); + await settle(); + + stdin.write(CTRL_B); + await settle(); + const editing = strip(lastFrame() ?? ""); + expect(editing).toContain("confirm target path"); + // Seeded with the proposed target, so a small edit stays small. + expect(editing).toContain("/tmp/smoke/index.html"); + + stdin.write("2"); + await settle(); + stdin.write("\r"); + await settle(); + expect(calls.retargets).toEqual([ + { id: "ap-1", path: "/tmp/smoke/index.html2" }, + ]); + unmount(); + }); + + it("Esc in the target field returns to the prompt without deciding", async () => { + const { calls, bus, stdin, lastFrame, unmount } = harness(); + await settle(); + bus.emitApproval(REQUEST); + await settle(); + + stdin.write(CTRL_B); + await settle(); + stdin.write(ESC); + await settle(); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("ctrl+y"); + expect(frame).not.toContain("confirm target path"); + expect(calls.decisions).toHaveLength(0); + expect(calls.retargets).toHaveLength(0); + expect(calls.aborts).toBe(0); + unmount(); + }); +}); diff --git a/src/tui/approval-modal.test.tsx b/src/tui/approval-modal.test.tsx index 9c1a6fbd..9e51c6aa 100644 --- a/src/tui/approval-modal.test.tsx +++ b/src/tui/approval-modal.test.tsx @@ -15,70 +15,147 @@ function request(overrides: Partial = {}): ApprovalRequest { }; } +/** + * The modal takes the target-field props from the app shell; every test + * that is not about the field renders it closed. + */ +function frameOf(req: ApprovalRequest, pathDraft: string | null = null): string { + return ( + render( + {}} + onPathChange={() => {}} + onPathSubmit={() => {}} + onPathCancel={() => {}} + />, + ).lastFrame() ?? "" + ); +} + describe("ApprovalModal", () => { - it("renders the request and the y/n/esc hotkey row", () => { - const frame = render().lastFrame() ?? ""; + it("renders the request and the decision buttons", () => { + const frame = frameOf(request()); expect(frame).toContain("approval required"); expect(frame).toContain("os.shell.run"); - expect(frame).toContain("[y]"); - expect(frame).toContain("[n]"); - expect(frame).toContain("[esc]"); + expect(frame).toContain("approve"); + expect(frame).toContain("deny"); + expect(frame).toContain("esc abort run"); + }); + + it("prints each button's chord on the button", () => { + // The chord is the keyboard path to the same control the mouse + // clicks, so it belongs on the face rather than in a legend three + // lines down that has to be kept in sync by hand. + const frame = frameOf(request({ category: "shell", commandShape: "git" })); + expect(frame).toContain("ctrl+y"); + expect(frame).toContain("ctrl+d"); + expect(frame).toContain("ctrl+f"); + expect(frame).toContain("ctrl+b"); + }); + + it("never offers a bare letter as a decision", () => { + // The regression this whole change exists to prevent: a bracketed + // letter promises that typing it decides the call, and the composer + // underneath is live. + const frame = frameOf(request({ category: "shell", commandShape: "git" })); + for (const marker of ["[y]", "[n]", "[s]", "[a]", "[e]"]) { + expect(frame).not.toContain(marker); + } }); it("shows the ladder category label so the operator sees why it fired", () => { // R5: the prompt carries its `ApprovalCategory`; the modal renders a // human label (`file write · home`) so a home write reads differently // from a trust-config write. - const frame = - render().lastFrame() ?? - ""; + const frame = frameOf(request({ category: "fs_write_home" })); expect(frame).toContain("file write · home"); }); it("points at the Privacy-tab toggle so the off switch is discoverable", () => { - // The footer hint is the discoverability answer to issue #79: `y` - // grants one call, the standing switch lives on the Privacy tab. - const frame = render().lastFrame() ?? ""; - expect(frame).toContain("approves this call once"); + // The footer hint is the discoverability answer to issue #79: + // approving covers one call, the standing switch lives on the + // Privacy tab. + const frame = frameOf(request()); + expect(frame).toContain("approve covers this call once"); expect(frame).toContain("(/privacy)"); }); - it("offers [s] session grant and [a] shape grant for a shell request", () => { - const frame = - render( - , - ).lastFrame() ?? ""; - expect(frame).toContain("[s]"); + it("offers both session grants for a shell request with a shape", () => { + const frame = frameOf(request({ category: "shell", commandShape: "git" })); expect(frame).toContain("this session"); - expect(frame).toContain("[a]"); expect(frame).toContain("git"); }); - it("offers [s] but NOT [a] for a non-shell grantable request", () => { - const frame = - render().lastFrame() ?? - ""; - expect(frame).toContain("[s]"); - expect(frame).not.toContain("[a]"); + it("offers the category grant but no shape grant for a non-shell request", () => { + const frame = frameOf(request({ category: "fs_write_home" })); + expect(frame).toContain("this session"); + expect(frame).toContain("ctrl+f"); + // Nothing to grant a shape for, so the shared slot stays empty. + expect(frame).not.toContain("ctrl+b"); }); - it("offers NO grant keys and warns for a trust_config request", () => { - // trust_config is never grantable: only y/n/esc, plus an explicit note. - const frame = - render().lastFrame() ?? - ""; - expect(frame).not.toContain("[s]"); - expect(frame).not.toContain("[a]"); - expect(frame).toContain("[y]"); - expect(frame).toContain("[n]"); + it("offers NO grants and warns for a trust_config request", () => { + // trust_config is never grantable: approve / deny / esc, plus an + // explicit note. + const frame = frameOf(request({ category: "trust_config" })); + expect(frame).not.toContain("this session"); + expect(frame).not.toContain("ctrl+f"); + expect(frame).toContain("approve"); + expect(frame).toContain("deny"); expect(frame).toContain("never granted for the session"); }); - it("offers [s] but NOT [a] for a shell request with no command shape", () => { + it("offers the retarget button only when the request carries a path", () => { + // The shell request has no target to move; the write does. + expect(frameOf(request())).not.toContain("edit target path"); + const write = frameOf( + request({ + tool: "os.fs.write", + category: "fs_write_workspace", + redirectablePath: "/work/site/index.html", + }), + ); + expect(write).toContain("edit target path"); + // Same slot, same chord as the shape grant — the two can never both + // be offered, which `approval-key-arbitration.test.ts` pins. + expect(write).toContain("ctrl+b"); + }); + + it("swaps the decision buttons for the target field while it is open", () => { + // While the field owns the keyboard the buttons would be a lie — + // those chords are inert until the field closes. + const frame = frameOf( + request({ + tool: "os.fs.write", + category: "fs_write_workspace", + redirectablePath: "/work/site/index.html", + }), + "~/Documents/apple-site/index.html", + ); + expect(frame).toContain("target"); + expect(frame).toContain("~/Documents/apple-site/index.html"); + expect(frame).toContain("confirm target path"); + expect(frame).not.toContain("ctrl+y"); + expect(frame).not.toContain("ctrl+d"); + }); + + it("says the composer is live rather than that the keys stand down", () => { + // The old hint had to explain that typing disarmed the verdicts. + // Nothing disarms now, so the hint says the one thing still worth + // knowing: you may answer in words instead. + const frame = frameOf(request()); + expect(frame).toContain("the composer stays live"); + expect(frame).not.toContain("keys work while the input is empty"); + }); + + it("withholds the shape grant for a shell request with no command shape", () => { // Opaque interpreters (bash -c …) reach the prompt with no - // commandShape, so [a] must not be offered — only [s] / [y]. - const frame = render().lastFrame() ?? ""; - expect(frame).toContain("[s]"); - expect(frame).not.toContain("[a]"); + // commandShape, so the shape grant must not be offered — only the + // category grant and the two decisions. + const frame = frameOf(request()); + expect(frame).toContain("ctrl+f"); + expect(frame).not.toContain("ctrl+b"); }); }); diff --git a/src/tui/approval-modal.tsx b/src/tui/approval-modal.tsx index f12896fe..0c2b60cf 100644 --- a/src/tui/approval-modal.tsx +++ b/src/tui/approval-modal.tsx @@ -3,25 +3,69 @@ import type { ReactElement } from "react"; import { canGrantCategory, canGrantShape, + type ApprovalGrantScope, type ApprovalRequest, } from "../approval/approval-gate.js"; import { formatApprovalCategory } from "../approval/approval-level.js"; +import { + APPROVAL_CHORDS, + canEditPath, + decideApproval, +} from "./app-key-bindings.js"; +import { MultiLineEditor } from "./components/multi-line-editor.js"; +import { readableOn } from "./theme/readable-foreground.js"; +import { theme } from "./theme/theme.js"; +import { MouseTarget, useMouseCommands } from "./mouse/mouse-context.js"; +import { isPrimaryPress } from "./mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "./mouse/mouse-registry.js"; interface ApprovalModalProps { request: ApprovalRequest; + /** Live target-path buffer, or `null` while the field is closed. */ + pathDraft: string | null; + /** Clicking `[e]` — the key does the same via `handleApprovalKey`. */ + onPathOpen: () => void; + onPathChange: (value: string) => void; + onPathSubmit: (value: string) => void; + onPathCancel: () => void; } /** * Displayed as an in-place banner rather than a floating window to keep * rendering predictable across terminals. Hotkey handling lives at the - * app root (`tui-app.tsx`) via ink's `useInput`. + * app root (`tui-app.tsx`) via ink's `useInput`; every button here is + * also a click target, routed through the same `decideApproval` the + * chords use. + * + * **The verbs are buttons now.** They used to be bracketed letters — + * `[y] approve`, `[n] deny` — which is a legend, not a control: it + * describes a key rather than offering something to press, and the one + * thing on screen that *was* pressable looked exactly like the prose + * around it. They are drawn as chips, the same raised face the composer + * gives `send →` and the rail gives `≡ Menu`, so the row reads as a set + * of choices whether the operator reaches for the mouse or the keyboard. + * + * Three tones, and the difference is the point: approve takes the + * raised face, deny takes the palette's `error` as a ground, and the + * session grants take the flatter accent-tinted badge. Approving once + * and granting for a whole session are not the same act, and they no + * longer look like it. */ -export function ApprovalModal({ request }: ApprovalModalProps): ReactElement { +export function ApprovalModal({ + request, + pathDraft, + onPathOpen, + onPathChange, + onPathSubmit, + onPathCancel, +}: ApprovalModalProps): ReactElement { const categoryLabel = formatApprovalCategory(request.category); // `[s]` for any grantable category (everything but trust_config); // `[a]` only when the shell tool supplied a command shape to grant. const grantCategory = canGrantCategory(request); const grantShape = canGrantShape(request); + const editable = canEditPath(request); + const editing = pathDraft !== null; return ( ) : null} - - - [y] approve{" "} - {grantCategory ? ( - <> - [s] allow {categoryLabel} this session{" "} - - ) : null} - {grantShape ? ( - <> - [a] allow all {request.commandShape}{" "} - commands this session{" "} - - ) : null} - [n] deny [esc] abort run - + {editing ? ( + + target + + + + + a target outside this workspace is re-checked and may ask again + + + + enter confirm target path + + + esc back to the prompt + + + + ) : ( + + {/* + Two rows, not one: the pair that always exists sits together on + top, and the optional session-scoped verbs go under them. A + single wrapping row would put `deny` in a different place + depending on which grants this particular request offers, and + the destructive button is the last one that should move. + */} + + + {`✓ approve · ctrl+${APPROVAL_CHORDS.approve}`} + + + + {`✗ deny · ctrl+${APPROVAL_CHORDS.deny}`} + + + {grantCategory || grantShape || editable ? ( + + {grantCategory ? ( + <> + + {`allow ${categoryLabel} this session · ctrl+${APPROVAL_CHORDS.grantCategory}`} + + + + ) : null} + {/* + `grantShape` and `editable` share this slot and share + `ctrl+b`, because they can never both be offered: the shape + grant is shell-only and the retarget is set by `os.fs.write` + alone. Pinned by `approval-key-arbitration.test.ts`. + */} + {grantShape ? ( + + {`allow all ${request.commandShape} this session · ctrl+${APPROVAL_CHORDS.contextual}`} + + ) : null} + {editable ? ( + + {`edit target path… · ctrl+${APPROVAL_CHORDS.contextual}`} + + ) : null} + + ) : null} + + + esc abort run {theme.glyphs.dotSeparator} ctrl+c stop everything + + - {footerHint(grantCategory)} + )} + {editing ? null : ( + {footerHint(grantCategory)} + )} ); } function footerHint(grantable: boolean): string { + // The composer stays live under this prompt, and that is a feature: + // the operator can answer the agent in words instead of a verdict. + // What it no longer costs is the buttons — every one of them is a + // chord, so typing a message that happens to start with "yes" cannot + // approve the call the way a bare `y` did. + const typing = + "the composer stays live — type to answer the agent instead (enter cancels this call and sends it)"; if (!grantable) { - return "trust-config writes are never granted for the session; y approves this call only"; + return `trust-config writes are never granted for the session; approve covers this call only · ${typing}`; } - return "y approves this call once; s / a grant for this session only (never persisted); raise the standing level on the Privacy tab (/privacy)"; + return `approve covers this call once; the session grants last until the app exits (never persisted); raise the standing level on the Privacy tab (/privacy) · ${typing}`; } function clip(value: string, limit: number): string { if (value.length <= limit) return value; return `${value.slice(0, limit - 1)}…`; } + +/** + * How loudly a button asks to be pressed. + * + * `primary` is the raised chip face the composer's `send →` uses. + * `danger` grounds the label in the palette's `error`, with ink chosen + * by measurement rather than by a guess about the theme's polarity. + * `secondary` is the flatter accent-tinted badge, and it is what the + * session grants get: approving one call and trusting a whole category + * until the app exits are different acts, and the row should not + * present them as peers. + */ +type ButtonTone = "primary" | "danger" | "secondary"; + +/** + * The button face. + * + * The padding spaces are load-bearing — the same reason `chip.tsx` + * gives: a coloured ground flush against its label reads as highlighted + * text, not as a control. A terminal has no bevel to draw, so the + * ground and its padding are the entire affordance. + */ +function ButtonFace({ + tone, + children, +}: { + tone: ButtonTone; + children: string; +}): ReactElement { + const background = + tone === "primary" + ? theme.colors.chipBackground + : tone === "danger" + ? theme.colors.error + : theme.colors.badgeBackground; + // `readableOn` measures the ground against both ends of the palette's + // chip pair and takes the better one. `error` is a mid-tone on every + // palette — light enough to need dark ink on some and not on others — + // so a fixed foreground would be wrong on half the registry. + const foreground = + tone === "secondary" ? theme.colors.accent : readableOn(background); + return ( + + {` ${children} `} + + ); +} + +/** + * Click target for the retarget button. Defined separately from + * `ApprovalButton` (rather than reusing it) because it opens the target + * field instead of deciding the request — a click that resolved the + * approval here would be the opposite of what the operator asked for. + */ +function EditPathButton({ + onOpen, + children, +}: { + onOpen: () => void; + children: string; +}): ReactElement { + const face = {children}; + const mouse = useMouseCommands(); + if (!mouse) return face; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onOpen(); + return true; + }} + > + {face} + + ); +} + +interface ApprovalButtonProps { + request: ApprovalRequest; + approved: boolean; + grant?: ApprovalGrantScope; + tone: ButtonTone; + children: string; +} + +/** + * A clickable decision button. Still renders its face when the mouse + * layer is absent, so the modal looks identical with `--no-mouse` and + * under the test renderer — the chord is what drives it there, and a + * button that vanished without a mouse would hide the chord's label + * with it. + */ +function ApprovalButton({ + request, + approved, + grant, + tone, + children, +}: ApprovalButtonProps): ReactElement { + const face = {children}; + const mouse = useMouseCommands(); + if (!mouse) return face; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + decideApproval(request, approved, mouse, grant); + return true; + }} + > + {face} + + ); +} diff --git a/src/tui/backdrop-dismissal.test.ts b/src/tui/backdrop-dismissal.test.ts new file mode 100644 index 00000000..80750560 --- /dev/null +++ b/src/tui/backdrop-dismissal.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + backdropRevertsThemePreview, + resolveBackdropDismissal, +} from "./backdrop-dismissal.js"; +import { createInitialTuiState, type TuiSessionInfo, type TuiState } from "./tui-state.js"; + +function session(): TuiSessionInfo { + return { + sessionId: "s1", + workingDir: "/tmp/w", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chromium", + browserHeadless: true, + approvalLevel: 1, + maxSteps: 8, + skillCount: 0, + }; +} + +function stateWith(overrides: Partial): TuiState { + return { ...createInitialTuiState(session()), ...overrides }; +} + +describe("what a click outside closes", () => { + it("declines the event when nothing is open", () => { + // Returning an action here would make every stray click in the chat + // dispatch a close for a surface that is not there. + expect(resolveBackdropDismissal(stateWith({}))).toBeNull(); + }); + + /** + * The reported bug. `modalOwnsInput` raises the mouse floor for all + * three pickers, so while one was open every control on screen stopped + * answering — and no target took the click that would have closed it + * either. The picker was not modal, it was a hole in the app that only + * the keyboard could climb out of. + */ + it("closes the coding-mode menu", () => { + expect( + resolveBackdropDismissal(stateWith({ codingModeMenu: { cursor: 0 } })), + ).toEqual({ type: "coding_mode_menu_closed" }); + }); + + it("closes the theme picker", () => { + expect(resolveBackdropDismissal(stateWith({ themePickerOpen: true }))).toEqual( + { type: "theme_picker_closed" }, + ); + }); + + it("closes the session picker", () => { + expect( + resolveBackdropDismissal(stateWith({ sessionPickerOpen: true })), + ).toEqual({ type: "session_picker_closed" }); + }); + + it("closes the slash palette", () => { + expect( + resolveBackdropDismissal(stateWith({ slashPaletteOpen: true })), + ).toEqual({ type: "slash_palette_closed" }); + }); + + it("still closes everything it closed before", () => { + expect(resolveBackdropDismissal(stateWith({ menuOpen: true }))).toEqual({ + type: "menu_closed", + }); + expect( + resolveBackdropDismissal(stateWith({ contextPanelOpen: true })), + ).toEqual({ type: "context_panel_closed" }); + }); + + it("resolves a stack to one action, in the documented order", () => { + // The chain is defensive rather than descriptive: the menu closes + // itself before activating a node (`handleMenuKey` dispatches + // `menu_closed` and *then* calls `activate`), so a menu and a picker + // do not actually coexist. What the order guarantees is that a click + // never dispatches two closes, and never picks a surface that is not + // the frontmost one when they do overlap — the confirm ladder, which + // genuinely does open over the menu. + expect( + resolveBackdropDismissal( + stateWith({ menuOpen: true, contextPanelOpen: true }), + ), + ).toEqual({ type: "context_panel_closed" }); + expect( + resolveBackdropDismissal( + stateWith({ menuOpen: true, themePickerOpen: true }), + ), + ).toEqual({ type: "menu_closed" }); + }); +}); + +describe("cancelling the theme picker puts the palette back", () => { + it("asks for a revert only for the theme picker", () => { + // The picker previews live, so a dismissal that skipped the revert + // would silently *apply* whatever the cursor was resting on — the + // opposite of a cancel. + expect(backdropRevertsThemePreview(stateWith({ themePickerOpen: true }))).toBe( + true, + ); + expect(backdropRevertsThemePreview(stateWith({ menuOpen: true }))).toBe(false); + expect(backdropRevertsThemePreview(stateWith({}))).toBe(false); + }); + + it("does not revert when another surface wins the click", () => { + // The revert is keyed off the resolved action, not off + // `themePickerOpen`, so it can never fire for a click that closed + // something else and left the picker up. + expect( + backdropRevertsThemePreview( + stateWith({ themePickerOpen: true, contextPanelOpen: true }), + ), + ).toBe(false); + }); +}); diff --git a/src/tui/backdrop-dismissal.ts b/src/tui/backdrop-dismissal.ts new file mode 100644 index 00000000..9f35ed1e --- /dev/null +++ b/src/tui/backdrop-dismissal.ts @@ -0,0 +1,57 @@ +import type { TuiAction } from "./tui-action.js"; +import type { TuiState } from "./tui-state.js"; + +/** + * Which surface a click outside every popup should close, and how. + * + * Extracted from the handler in `tui-app.tsx` because it is a *policy* + * — one ordered list of "what is open, and what cancels it" — and it was + * the half of that handler nothing could reach: the surrounding closure + * also does grace-period timing, wheel swallowing and a live-preview + * revert, none of which a test wants to stand up in order to ask which + * action a given state should produce. + * + * **The order is the precedence.** Surfaces stack — an uninstall confirm + * opens from the menu, the menu can open over the slash palette — and + * the innermost one is the one a click outside should take. That is why + * this is a chain rather than a lookup: the same click means "cancel the + * uninstall" and "leave the menu alone" at the same time. + * + * Returns `null` when nothing is open, which is the caller's signal to + * decline the event so it falls through to whatever is underneath. + */ +export function resolveBackdropDismissal(state: TuiState): TuiAction | null { + if (state.uninstall) return { type: "uninstall_closed" }; + if (state.sessionDelete) return { type: "session_delete_closed" }; + if (state.contextPanelOpen) return { type: "context_panel_closed" }; + if (state.composerSwitch) return { type: "composer_switch_closed" }; + if (state.codingModeMenu) return { type: "coding_mode_menu_closed" }; + if (state.menuOpen) return { type: "menu_closed" }; + // The three pickers were missing from this list, and that is the whole + // bug: `modalOwnsInput` raises the mouse floor for them, so while one + // was open every control on screen stopped answering — and nothing + // took the click that would have closed it either. The picker was not + // "modal", it was a hole in the app that only the keyboard could climb + // out of. + if (state.themePickerOpen) return { type: "theme_picker_closed" }; + if (state.sessionPickerOpen) return { type: "session_picker_closed" }; + if (state.slashPaletteOpen) return { type: "slash_palette_closed" }; + return null; +} + +/** + * True when closing `state`'s frontmost surface has to put the palette + * back first. + * + * The theme picker previews live — the arrow keys swap the real theme so + * you can see it — so cancelling it is two steps, and the second one is + * not a reducer action: `setActiveTheme` is a module singleton, not + * state. Esc has always done both; a click outside has to as well, or + * dismissing the picker would silently *apply* whatever was under the + * cursor. + */ +export function backdropRevertsThemePreview(state: TuiState): boolean { + return ( + resolveBackdropDismissal(state)?.type === "theme_picker_closed" + ); +} diff --git a/src/tui/build-terminal-launch.test.ts b/src/tui/build-terminal-launch.test.ts new file mode 100644 index 00000000..ea85bd38 --- /dev/null +++ b/src/tui/build-terminal-launch.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; + +import { + agentArgv, + buildTerminalLaunch, + type TerminalLaunchInput, +} from "./build-terminal-launch.js"; + +function input(overrides: Partial = {}): TerminalLaunchInput { + return { + platform: "darwin", + execPath: "/usr/local/bin/node", + argv: ["/usr/local/bin/node", "/opt/atomic/dist/cli/index.js", "tui"], + isSea: false, + cwd: "/home/val/work", + env: {}, + hasBinary: () => false, + ...overrides, + }; +} + +describe("agentArgv", () => { + it("keeps the script path under plain node", () => { + expect(agentArgv(input())).toEqual([ + "/usr/local/bin/node", + "/opt/atomic/dist/cli/index.js", + "tui", + ]); + }); + + it("drops the script slot for a SEA binary", () => { + // A SEA binary is its own entry point; re-injecting argv[1] makes the + // child read the invoke path as a command name ("unknown command"). + expect( + agentArgv( + input({ + isSea: true, + execPath: "/usr/local/bin/atomic-agent", + argv: ["/usr/local/bin/atomic-agent", "/usr/local/bin/atomic-agent"], + }), + ), + ).toEqual(["/usr/local/bin/atomic-agent", "tui"]); + }); + + it("always asks for the tui explicitly", () => { + // The parent may have been started as `atomic-agent` with no args. + expect(agentArgv(input({ argv: ["/usr/local/bin/node", "/opt/a.js"] }))).toContain( + "tui", + ); + }); +}); + +describe("buildTerminalLaunch — macOS", () => { + it("drives Terminal.app through osascript, cd'ing into the working dir", () => { + const launch = buildTerminalLaunch(input()); + expect(launch).not.toBeNull(); + expect(launch?.cmd).toBe("osascript"); + expect(launch?.label).toBe("Terminal"); + const script = launch?.args[1] ?? ""; + expect(script).toContain('tell application "Terminal" to do script'); + expect(script).toContain("cd '/home/val/work'"); + expect(script).toContain("/opt/atomic/dist/cli/index.js"); + expect(script).toContain("tui"); + expect(launch?.args[3]).toContain("activate"); + }); + + it("uses iTerm when the operator already lives in iTerm", () => { + const launch = buildTerminalLaunch( + input({ env: { TERM_PROGRAM: "iTerm.app" } }), + ); + expect(launch?.label).toBe("iTerm"); + expect(launch?.args[1]).toContain('tell application "iTerm"'); + }); + + it("carries a non-default state dir into the new window", () => { + // The spawned terminal starts a login shell and inherits nothing — + // without this the second window would use a different state dir. + const launch = buildTerminalLaunch( + input({ env: { ATOMIC_AGENT_STATE_DIR: "/tmp/state dir" } }), + ); + expect(launch?.args[1]).toContain( + "ATOMIC_AGENT_STATE_DIR='/tmp/state dir'", + ); + }); + + it("escapes quotes in paths for both the shell and AppleScript layers", () => { + const launch = buildTerminalLaunch(input({ cwd: `/home/o'brien/work` })); + const script = launch?.args[1] ?? ""; + // POSIX single-quote escaping, with its backslash doubled by the + // AppleScript escaper so the shell still sees exactly one. + expect(script).toContain(`cd '/home/o'\\\\''brien/work'`); + // And nothing unescaped can close the AppleScript string literal. + const body = script.slice(script.indexOf("do script ") + "do script ".length); + expect(body.slice(1, -1)).not.toMatch(/(^|[^\\])"/); + }); +}); + +describe("buildTerminalLaunch — Linux", () => { + it("returns null when no emulator is installed", () => { + // Headless box: report it, never throw into the render loop. + expect(buildTerminalLaunch(input({ platform: "linux" }))).toBeNull(); + }); + + it("prefers gnome-terminal's `--` argv shape", () => { + const launch = buildTerminalLaunch( + input({ platform: "linux", hasBinary: (n) => n === "gnome-terminal" }), + ); + expect(launch?.cmd).toBe("gnome-terminal"); + expect(launch?.args[0]).toBe("--"); + expect(launch?.args[1]).toBe("sh"); + }); + + it("falls back to xterm when nothing better exists", () => { + const launch = buildTerminalLaunch( + input({ platform: "linux", hasBinary: (n) => n === "xterm" }), + ); + expect(launch?.cmd).toBe("xterm"); + expect(launch?.args[0]).toBe("-e"); + }); + + it("honours $ATOMIC_AGENT_TERMINAL over the probe order", () => { + const launch = buildTerminalLaunch( + input({ + platform: "linux", + env: { ATOMIC_AGENT_TERMINAL: "foot", TERMINAL: "xterm" }, + hasBinary: () => true, + }), + ); + expect(launch?.cmd).toBe("foot"); + // foot has no `-e` — it takes the command bare, like kitty. + expect(launch?.args).toEqual(["sh", "-c", expect.any(String)]); + }); + + it("keeps the window alive after the agent exits", () => { + // `-e` closes the window the moment the command returns, which would + // eat a startup error before anyone could read it. + const launch = buildTerminalLaunch( + input({ platform: "linux", hasBinary: (n) => n === "xterm" }), + ); + expect(launch?.args.at(-1)).toContain('exec "${SHELL:-sh}"'); + }); +}); + +describe("buildTerminalLaunch — Windows", () => { + it("opens a new Windows Terminal window when wt.exe is present", () => { + const launch = buildTerminalLaunch( + input({ + platform: "win32", + hasBinary: (n) => n === "wt.exe", + cwd: "C:\\work", + }), + ); + expect(launch?.cmd).toBe("wt.exe"); + expect(launch?.args.slice(0, 5)).toEqual(["-w", "-1", "nt", "-d", "C:\\work"]); + // The agent runs under `cmd /k` inside wt too: the env prefix must + // reach Windows Terminal and a startup error must stay on screen. + expect(launch?.args.slice(5, 7)).toEqual(["cmd", "/k"]); + expect(launch?.args.at(-1)).toContain("tui"); + }); + + it("falls back to a `start`-ed cmd.exe that stays open", () => { + const launch = buildTerminalLaunch( + input({ platform: "win32", cwd: "C:\\work" }), + ); + expect(launch?.cmd).toBe("cmd.exe"); + // The first `start` argument is its TITLE; unquoted text there is + // read as the program. An explicit empty title keeps cmd the program. + expect(launch?.args.slice(0, 5)).toEqual(["/c", "start", "", "cmd", "/k"]); + }); +}); diff --git a/src/tui/build-terminal-launch.ts b/src/tui/build-terminal-launch.ts new file mode 100644 index 00000000..e43423bc --- /dev/null +++ b/src/tui/build-terminal-launch.ts @@ -0,0 +1,277 @@ +/** + * Resolves "open a new OS terminal window running atomic-agent" into a + * concrete `{cmd, args}` for the current platform. Pure on purpose: the + * PATH probe and the spawn both arrive as inputs, so every branch is + * unit-reachable without touching the machine. + */ + +export interface TerminalLaunch { + readonly cmd: string; + readonly args: readonly string[]; + /** Human name of the terminal being opened, for the chat confirmation. */ + readonly label: string; +} + +export interface TerminalLaunchInput { + readonly platform: NodeJS.Platform; + /** `process.execPath` of the running agent. */ + readonly execPath: string; + /** `process.argv` of the running agent. */ + readonly argv: readonly string[]; + /** `process.execArgv` — loader/inspect flags a dev run needs back. */ + readonly execArgv?: readonly string[]; + /** `isSea()` — a SEA build has no script path in argv. */ + readonly isSea: boolean; + /** Working directory the new window should start in. */ + readonly cwd: string; + readonly env: Readonly>; + /** `true` when `name` resolves to an executable on PATH. */ + readonly hasBinary: (name: string) => boolean; +} + +interface LinuxTerminal { + readonly bin: string; + readonly label: string; + /** Wraps a `sh -c`-able command line into this emulator's argv shape. */ + readonly args: (command: string) => readonly string[]; +} + +/** + * Probed in order. `-e` is the near-universal spelling; gnome-terminal + * deprecated it in favour of `--`, and kitty takes the command bare. + */ +const LINUX_TERMINALS: readonly LinuxTerminal[] = [ + { + bin: "gnome-terminal", + label: "gnome-terminal", + args: (command) => ["--", "sh", "-c", command], + }, + { + bin: "konsole", + label: "konsole", + args: (command) => ["-e", "sh", "-c", command], + }, + { + bin: "xfce4-terminal", + label: "xfce4-terminal", + args: (command) => ["-e", `sh -c ${shellQuote(command)}`], + }, + { bin: "kitty", label: "kitty", args: (command) => ["sh", "-c", command] }, + // foot takes the command bare, like kitty — it has no `-e` at all. + { bin: "foot", label: "foot", args: (command) => ["sh", "-c", command] }, + // terminator and tilix take `-e` as a single command string. + { + bin: "terminator", + label: "terminator", + args: (command) => ["-e", `sh -c ${shellQuote(command)}`], + }, + { + bin: "tilix", + label: "tilix", + args: (command) => ["-e", `sh -c ${shellQuote(command)}`], + }, + { + bin: "alacritty", + label: "alacritty", + args: (command) => ["-e", "sh", "-c", command], + }, + { + bin: "wezterm", + label: "wezterm", + args: (command) => ["start", "--", "sh", "-c", command], + }, + { + bin: "x-terminal-emulator", + label: "x-terminal-emulator", + args: (command) => ["-e", "sh", "-c", command], + }, + { bin: "xterm", label: "xterm", args: (command) => ["-e", "sh", "-c", command] }, +]; + +/** + * Returns `null` — never throws — when the platform offers nothing we + * know how to drive (a headless Linux box with no emulator installed is + * the realistic case). The caller turns that into one warn line. + */ +export function buildTerminalLaunch( + input: TerminalLaunchInput, +): TerminalLaunch | null { + switch (input.platform) { + case "darwin": + return darwinLaunch(input); + case "win32": + return win32Launch(input); + default: + return posixLaunch(input); + } +} + +/** + * The argv the child needs to re-enter the TUI. Mirrors the SEA + * reasoning in `tui-command.ts`'s self-update relaunch: a SEA binary is + * its own entry point, plain node needs the script path back. `tui` is + * always explicit so the new window lands in the UI regardless of how + * the parent process was invoked. + */ +export function agentArgv(input: TerminalLaunchInput): readonly string[] { + const scriptPath = input.isSea ? undefined : input.argv[1]; + // execArgv keeps dev runs honest: under tsx/--import loaders the + // script path alone is not runnable by plain node. + const execArgv = input.execArgv ?? []; + return scriptPath + ? [input.execPath, ...execArgv, scriptPath, "tui"] + : [input.execPath, ...execArgv, "tui"]; +} + +/** + * A freshly spawned terminal starts a login shell and does **not** + * inherit our environment, so a non-default state dir has to travel + * inside the command line — otherwise the second window silently talks + * to a different `~/.atomic-agent`. + */ +function posixCommandLine(input: TerminalLaunchInput): string { + const prefix = forwardedEnv(input.env) + .map(([k, v]) => `${k}=${shellQuote(v)} `) + .join(""); + const agent = agentArgv(input).map(shellQuote).join(" "); + return `cd ${shellQuote(input.cwd)} && ${prefix}${agent}`; +} + +/** + * Every `ATOMIC_AGENT_*` variable travels into the new window, sorted so + * the command line is deterministic. Forwarding only the state dir made + * the second window silently different whenever the parent was launched + * with a custom llama URL, grammar dir or skills dir — the exact failure + * class the state-dir forwarding was added to close. + */ +function forwardedEnv( + env: Readonly>, +): [string, string][] { + return Object.entries(env) + .filter((pair): pair is [string, string] => + pair[0].startsWith("ATOMIC_AGENT_") && typeof pair[1] === "string" && pair[1].length > 0, + ) + .sort(([a], [b]) => (a < b ? -1 : 1)); +} + +function darwinLaunch(input: TerminalLaunchInput): TerminalLaunch { + // Terminal.app is always installed; iTerm only when the operator is + // already living in it. Both keep the shell alive after the agent + // exits, so errors stay on screen. + const script = escapeAppleScript(posixCommandLine(input)); + if (input.env.TERM_PROGRAM === "iTerm.app") { + // iTerm2 has no Terminal-style `do script`: its dictionary is + // "create window with default profile" plus "write text". + return { + cmd: "osascript", + args: [ + "-e", + `tell application "iTerm" to create window with default profile`, + "-e", + `tell application "iTerm" to tell current session of current window to write text "${script}"`, + "-e", + `tell application "iTerm" to activate`, + ], + label: "iTerm", + }; + } + return { + cmd: "osascript", + args: [ + "-e", + `tell application "Terminal" to do script "${script}"`, + "-e", + `tell application "Terminal" to activate`, + ], + label: "Terminal", + }; +} + +function posixLaunch(input: TerminalLaunchInput): TerminalLaunch | null { + // `-e` closes the window the moment the agent exits, which would eat + // a startup error before anyone could read it; drop into a shell in + // the same directory instead. + const command = `${posixCommandLine(input)}; exec "\${SHELL:-sh}"`; + const preferred = + input.env.ATOMIC_AGENT_TERMINAL ?? input.env.TERMINAL ?? null; + if (preferred && input.hasBinary(preferred)) { + const known = LINUX_TERMINALS.find((t) => t.bin === preferred); + return { + cmd: preferred, + // Unknown emulator: the single-string `-e` dialect is the broadest + // (xterm, konsole, terminator and tilix all accept it; the + // multi-arg form breaks the last two). + args: known ? known.args(command) : ["-e", `sh -c ${shellQuote(command)}`], + label: preferred, + }; + } + const found = LINUX_TERMINALS.find((t) => input.hasBinary(t.bin)); + if (!found) return null; + return { cmd: found.bin, args: found.args(command), label: found.label }; +} + +function win32Launch(input: TerminalLaunchInput): TerminalLaunch { + const agent = agentArgv(input); + const prefix = forwardedEnv(input.env) + .map(([k, v]) => `set "${k}=${v}" && `) + .join(""); + // `/k` keeps the console open after the agent exits on BOTH Windows + // paths, matching the POSIX branches — a startup error must stay on + // screen, and the env prefix must reach Windows Terminal too (wt's own + // env inheritance goes through its single-instance monarch, which may + // predate this process). + const command = `${prefix}${agent.map(cmdQuote).join(" ")}`; + if (input.hasBinary("wt.exe")) { + // `-w -1` opens a new window rather than a tab in the existing one. + // wt splits its command line on unquoted `;` (its pane separator), + // so every argument that can carry one is escaped for wt. + return { + cmd: "wt.exe", + args: [ + "-w", + "-1", + "nt", + "-d", + wtEscape(input.cwd), + "cmd", + "/k", + wtEscape(command), + ], + label: "Windows Terminal", + }; + } + return { + cmd: "cmd.exe", + // The first `start` argument is its window title; unquoted, `start` + // reads the next token as the program instead. An explicit empty + // title (serialized as `""`) keeps `cmd /k` the program. + args: ["/c", "start", "", "cmd", "/k", command], + label: "Command Prompt", + }; +} + +/** Windows Terminal splits on unquoted `;` — escape it per wt's rules. */ +function wtEscape(value: string): string { + return value.replace(/;/g, "\\;"); +} + +/** POSIX single-quote quoting — safe for every byte except NUL. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function cmdQuote(value: string): string { + // Embedded quotes double inside a quoted cmd token. `%VAR%` expansion + // inside quotes is a cmd property no quoting silences — a path + // containing a defined %NAME% will still expand; acceptable residual. + const escaped = value.replace(/"/g, '""'); + return /[\s&|<>^%;=,()"]/.test(value) ? `"${escaped}"` : value; +} + +/** + * AppleScript string literal escaping. Backslash first, then the quote — + * reversing the order would double-escape the backslashes we just added. + */ +function escapeAppleScript(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} diff --git a/src/tui/chat-loop-reducer.test.ts b/src/tui/chat-loop-reducer.test.ts index a645c57c..04ba8fdd 100644 --- a/src/tui/chat-loop-reducer.test.ts +++ b/src/tui/chat-loop-reducer.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { reduceTuiState } from "./agent-event-reducer.js"; import { apply, fakeSession } from "./test-fixtures.js"; import type { TuiAction } from "./tui-action.js"; -import { canAcceptMessage, createInitialTuiState } from "./tui-state.js"; +import { + canAcceptMessage, + canTypeMessage, + createInitialTuiState, +} from "./tui-state.js"; describe("chat loop", () => { it("should update inputValue on input_changed", () => { @@ -303,3 +307,37 @@ describe("chat loop", () => { expect(next.runHistory[0]?.durationMs).toBeGreaterThan(0); }); }); + +describe("queued submissions", () => { + it("may be typed while a turn is running", () => { + const running = reduceTuiState(createInitialTuiState(fakeSession()), { + type: "message_submitted", + }); + expect(canAcceptMessage(running)).toBe(false); + expect(canTypeMessage(running)).toBe(true); + }); + + it("does not wipe the live turn's feed the way message_submitted does", () => { + // This is the regression the separate action exists for: reusing + // `message_submitted` for a mid-run send called startNewRun and + // blanked the screen the operator was reading. + const initial = createInitialTuiState(fakeSession()); + const running = apply(initial, [ + { type: "message_submitted" }, + { type: "agent_event", event: { type: "step_started", stepIndex: 0 } }, + { type: "assistant_delta", text: "partial answer" }, + ]); + expect(running.feed.length).toBeGreaterThan(0); + + const afterQueue = reduceTuiState(running, { + type: "message_queued", + text: "one more thing", + }); + + expect(afterQueue.feed).toEqual(running.feed); + expect(afterQueue.streamingAssistantText).toBe("partial answer"); + expect(afterQueue.status).toBe("running"); + expect(afterQueue.queuedMessages).toEqual(["one more thing"]); + expect(afterQueue.inputValue).toBe(""); + }); +}); diff --git a/src/tui/chat-orchestrator-steering.test.ts b/src/tui/chat-orchestrator-steering.test.ts new file mode 100644 index 00000000..bdfbaa4c --- /dev/null +++ b/src/tui/chat-orchestrator-steering.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, it } from "vitest"; + +import { ChatOrchestrator } from "./chat-orchestrator.js"; +import { makeTuiEventBus } from "./make-event-bus.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { SteeringInbox } from "../runtime/steering-inbox.js"; +import { TurnController } from "../runtime/turn-controller.js"; +import type { RunTurnResult } from "../agent/agent-loop.js"; +import { + createEmptySessionState, + type SessionState, +} from "../session/session-state.js"; +import type { LocalTurnGateFacts } from "./local-turn-gate.js"; +import type { TuiAction } from "./tui-action.js"; + +/** Hermetic gate facts: never read the developer's real config/disk. */ +const cloudGateFacts = (): LocalTurnGateFacts => ({ + activeProviderIsLocal: false, + managedMode: false, + modelId: null, + modelDownloaded: true, + fallbackChainLength: 1, +}); + +/** + * The TUI's half of the mid-turn steering contract (AGENTS.md + * §"Mid-turn steering"): + * - a message typed while a turn is running is offered to that turn + * first, and only falls back to the orchestrator's own pending + * queue when `steer` refuses it; + * - `RunTurnResult.undelivered` — messages the turn accepted but + * never delivered — is re-routed onto that same queue. `steer` + * already told the sender "yes"; dropping it here would lose a + * message the operator watched being accepted. + */ + +interface Harness { + chat: ChatOrchestrator; + actions: TuiAction[]; + /** Messages handed to `runtime.runTurn`, in order. */ + started: string[]; + /** Resolve the turn currently in flight. */ + finish(result?: Partial): Promise; + steerCalls: Array<{ sessionId: string; text: string }>; + setSteerable(value: boolean): void; +} + +function makeHarness(): Harness { + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((action) => actions.push(action)); + + const started: string[] = []; + const steerCalls: Array<{ sessionId: string; text: string }> = []; + let steerable = true; + let session: SessionState = createEmptySessionState({ + id: "s-tui", + workingDir: "/work", + }); + let settle: ((result: RunTurnResult) => void) | null = null; + + const runtime = { + createSession: () => session, + sessionStore: { + listRecent: () => [], + load: () => session, + }, + approvals: { clearSessionGrants: () => undefined }, + steer: (sessionId: string, text: string) => { + steerCalls.push({ sessionId, text }); + return steerable; + }, + runTurn: (_session: SessionState, text: string) => { + started.push(text); + return new Promise((resolve) => { + settle = resolve; + }); + }, + } as unknown as AgentRuntime; + + const chat = new ChatOrchestrator(runtime, bus, { + maxSteps: 4, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + return { + chat, + actions, + started, + steerCalls, + setSteerable: (value) => { + steerable = value; + }, + finish: async (result = {}) => { + const resolve = settle; + settle = null; + if (!resolve) throw new Error("no turn in flight"); + resolve({ + session, + reason: "reply", + stepCount: 1, + ...result, + }); + // Two microtask hops: one for `await runtime.runTurn`, one for the + // queue drain that follows it. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }, + }; +} + +function infoLines(actions: readonly TuiAction[]): string[] { + return actions + .filter((a): a is Extract => + a.type === "runtime_info", + ) + .map((a) => a.line); +} + +describe("ChatOrchestrator mid-turn steering", () => { + it("offers a message typed during a turn to that turn", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + h.chat.steerMessage("actually, check the logs first"); + + expect(h.steerCalls).toEqual([ + { sessionId: "s-tui", text: "actually, check the logs first" }, + ]); + // Steered, so it must NOT also become a queued follow-up turn. + await h.finish(); + expect(h.started).toEqual(["do the thing"]); + expect(infoLines(h.actions)).toContain( + "steering the running turn — the agent reads it at the next step", + ); + }); + + it("falls back to the pending queue when the turn refuses the steer", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + h.setSteerable(false); + h.chat.steerMessage("too late for this one"); + + expect(h.steerCalls).toHaveLength(1); + await h.finish(); + // Refused, so it runs as the next turn instead of vanishing. + expect(h.started).toEqual(["do the thing", "too late for this one"]); + }); + + it("re-routes undelivered steers onto the pending queue", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + // `steer` said yes, but the turn ended before a step could drain it. + await h.finish({ undelivered: ["stop, use staging"] }); + + expect(h.started).toEqual(["do the thing", "stop, use staging"]); + expect(infoLines(h.actions)).toContain( + "1 message arrived too late for that turn — sending it next", + ); + }); + + it("puts undelivered steers ahead of messages typed after the refusal", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + h.setSteerable(false); + h.chat.steerMessage("and then deploy"); + await h.finish({ undelivered: ["stop, use staging"] }); + + // "stop, use staging" was sent first (it was still accepted as a + // steer); "and then deploy" only arrived after `steer` refused. + expect(h.started).toEqual(["do the thing", "stop, use staging"]); + await h.finish(); + expect(h.started).toEqual([ + "do the thing", + "stop, use staging", + "and then deploy", + ]); + }); + + it("does nothing extra on an ordinary turn", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + await h.finish({ undelivered: [] }); + expect(h.started).toEqual(["do the thing"]); + expect(infoLines(h.actions)).toEqual([]); + }); +}); + +/** + * The span between "the orchestrator considers a turn in flight" and + * "the loop opened the steering window for it". + * + * `runOneTurn` sets `currentController` and *then* awaits + * `runtime.runTurn`; `AgentLoop.runTurn` opens the window only once the + * submission owns the per-session lock — after `turnController.enqueue` + * has parked in `waitOrAbort` behind whatever is still settling on that + * session. A message submitted in that span sees a turn in flight and a + * shut window, so `steer` refuses it. It is still a correction aimed at + * the turn the operator is watching, so it must not be demoted behind + * backlog, and the operator must still be told it was taken as one. + * + * The harness runs the real `TurnController` and the real + * `SteeringInbox`; only the loop body is stubbed, in the shape + * `AgentLoop.runTurn` actually has (`open` on entry, `closeAndDrain` on + * the way out, and a settle phase after it for the session save plus the + * controller's own `finally`). Gates, not sleeps. + */ +interface Deferred { + promise: Promise; + resolve: () => void; +} + +function deferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** Drain the microtask queue; every gate in this harness is a promise. */ +async function flush(): Promise { + for (let i = 0; i < 12; i += 1) await Promise.resolve(); +} + +interface GapHarness { + chat: ChatOrchestrator; + actions: TuiAction[]; + inbox: SteeringInbox; + /** Turn bodies that actually started, in order. */ + started: string[]; + /** Occupy the session lock from another entry point (scheduler/HTTP). */ + occupy(text: string): Promise; + /** Let the turn running `text` reach its final drain (window shuts). */ + drain(text: string): Promise; + /** Let that turn's promise settle, releasing the per-session lock. */ + settle(text: string): Promise; +} + +const GAP_SESSION = "s-gap"; + +function makeGapHarness(): GapHarness { + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((action) => actions.push(action)); + + const session = createEmptySessionState({ + id: GAP_SESSION, + workingDir: "/work", + }); + const inbox = new SteeringInbox(); + const controller = new TurnController(); + const started: string[] = []; + const gates = new Map(); + const gateFor = (text: string): { drain: Deferred; settle: Deferred } => { + const existing = gates.get(text); + if (existing) return existing; + const fresh = { drain: deferred(), settle: deferred() }; + gates.set(text, fresh); + return fresh; + }; + + const turnBody = async (text: string): Promise => { + // `AgentLoop.runTurn`, in miniature. + inbox.open(session.id); + started.push(text); + await gateFor(text).drain.promise; + const undelivered = inbox.closeAndDrain(session.id); + // The window is shut but the submission still owns the lock — this + // is `sessionStore.save` plus the controller's `finally`, and it is + // where the next submission is parked in `waitOrAbort`. + await gateFor(text).settle.promise; + return { session, reason: "reply", stepCount: 1, undelivered }; + }; + + const runtime = { + createSession: () => session, + sessionStore: { listRecent: () => [], load: () => session }, + approvals: { clearSessionGrants: () => undefined }, + steer: (sessionId: string, text: string) => inbox.push(sessionId, text), + runTurn: ( + _session: SessionState, + text: string, + options: { signal?: AbortSignal } = {}, + ) => + controller.enqueue({ + sessionId: session.id, + origin: "tui" as const, + run: () => turnBody(text), + ...(options.signal ? { signal: options.signal } : {}), + }), + } as unknown as AgentRuntime; + + const chat = new ChatOrchestrator(runtime, bus, { + maxSteps: 4, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + return { + chat, + actions, + inbox, + started, + occupy: (text) => + controller.enqueue({ + sessionId: session.id, + origin: "scheduler", + run: () => turnBody(text), + }), + drain: async (text) => { + gateFor(text).drain.resolve(); + await flush(); + }, + settle: async (text) => { + gateFor(text).settle.resolve(); + await flush(); + }, + }; +} + +describe("ChatOrchestrator steering into the commit-to-open gap", () => { + it("acknowledges a steer sent while the turn is parked behind another one", async () => { + const h = makeGapHarness(); + // An out-of-band turn (scheduler here, HTTP in production) owns the + // session lock. + const occupant = h.occupy("scheduled digest"); + await flush(); + expect(h.started).toEqual(["scheduled digest"]); + + // The TUI commits to a turn: `currentController` is set, but the + // submission is parked in `waitOrAbort` and its loop never ran. + h.chat.sendMessage("do the thing"); + await flush(); + expect(h.started).toEqual(["scheduled digest"]); + + // The occupant does its final drain. Now nothing on this session is + // accepting steers, and nothing will until the parked turn starts. + await h.drain("scheduled digest"); + expect(h.inbox.isOpen(GAP_SESSION)).toBe(false); + + h.chat.steerMessage("actually, check the logs first"); + // Half the defect is the silence: the operator aimed this at a turn + // the TUI shows as running and used to get told so. + expect(infoLines(h.actions)).toContain( + "steering the running turn — it cannot take this one, so it runs as the next turn", + ); + + await h.settle("scheduled digest"); + await occupant; + expect(h.started).toEqual(["scheduled digest", "do the thing"]); + + await h.drain("do the thing"); + await h.settle("do the thing"); + expect(h.started).toEqual([ + "scheduled digest", + "do the thing", + "actually, check the logs first", + ]); + }); + + it("runs a steer sent in that gap before backlog left by an earlier turn", async () => { + const h = makeGapHarness(); + h.chat.sendMessage("do the thing"); + await flush(); + expect(h.started).toEqual(["do the thing"]); + + // Turn 1's window shuts; its promise has not settled, so the TUI + // still shows a turn in flight. + await h.drain("do the thing"); + h.chat.sendMessage("backlog one"); + h.chat.sendMessage("backlog two"); + await h.settle("do the thing"); + // Re-routed in the order they were typed, not reversed. + expect(h.started).toEqual(["do the thing", "backlog one"]); + + // Same gap, one turn later. "stop, use staging" is a correction to + // the turn in flight; "backlog two" was aimed at the turn before it. + await h.drain("backlog one"); + h.chat.steerMessage("stop, use staging"); + await h.settle("backlog one"); + expect(h.started).toEqual([ + "do the thing", + "backlog one", + "stop, use staging", + ]); + + await h.drain("stop, use staging"); + await h.settle("stop, use staging"); + expect(h.started).toEqual([ + "do the thing", + "backlog one", + "stop, use staging", + "backlog two", + ]); + }); + + it("keeps undelivered steers ahead of ones re-routed after the window shut", async () => { + const h = makeGapHarness(); + h.chat.sendMessage("do the thing"); + await flush(); + + // Accepted while the window was open, but no step boundary came: + // the turn hands it back on `undelivered`. + expect(h.inbox.isOpen(GAP_SESSION)).toBe(true); + h.chat.steerMessage("wait, staging"); + expect(h.inbox.peek(GAP_SESSION)).toEqual(["wait, staging"]); + + await h.drain("do the thing"); + // Typed after the window shut, i.e. after "wait, staging". + h.chat.steerMessage("and read the logs"); + await h.settle("do the thing"); + + expect(h.started).toEqual(["do the thing", "wait, staging"]); + await h.drain("wait, staging"); + await h.settle("wait, staging"); + expect(h.started).toEqual([ + "do the thing", + "wait, staging", + "and read the logs", + ]); + }); +}); diff --git a/src/tui/chat-orchestrator-switch.test.ts b/src/tui/chat-orchestrator-switch.test.ts new file mode 100644 index 00000000..530182ed --- /dev/null +++ b/src/tui/chat-orchestrator-switch.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ApprovalGate } from "../approval/approval-gate.js"; +import { createEmptySessionState } from "../session/session-state.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { ChatOrchestrator } from "./chat-orchestrator.js"; +import { SWITCHED_AWAY_APPROVAL_REASON } from "./detached-turns.js"; +import { makeTuiEventBus } from "./make-event-bus.js"; +import type { LocalTurnGateFacts } from "./local-turn-gate.js"; +import type { TuiAction } from "./tui-action.js"; + +/** Hermetic gate facts: never read the developer's real config/disk. */ +const cloudGateFacts = (): LocalTurnGateFacts => ({ + activeProviderIsLocal: false, + managedMode: false, + modelId: null, + modelDownloaded: true, + fallbackChainLength: 1, +}); + +/** + * Detach semantics: creating or switching sessions while a turn is + * running must neither refuse (the pre-detach behaviour) nor abort the + * turn — it keeps running against its own session, per the concurrency + * contract's cross-session parallelism. + */ + +function session(id: string) { + return createEmptySessionState({ id, workingDir: "/tmp" }); +} + +interface TurnHandle { + text: string; + sessionId: string; + signal: AbortSignal; + resolve: (overrides?: { + reason?: string; + undelivered?: readonly string[]; + }) => void; +} + +function makeHarness( + opts: { + busySessions?: readonly string[]; + /** Real gate (or richer stub) for approval-lifecycle tests. */ + approvals?: ApprovalGate; + } = {}, +) { + const turns: TurnHandle[] = []; + const stored = [session("s-a"), session("s-b")]; + let created = 0; + const denyPendingForSession = vi.fn(() => 0); + const clearSessionGrants = vi.fn(); + const runtime = { + createSession: () => { + created += 1; + const fresh = session(`s-new-${created}`); + stored.unshift(fresh); + return fresh; + }, + steer: () => false, + runTurn: ( + s: { id: string }, + text: string, + turnOpts: { signal: AbortSignal }, + ) => + new Promise((res) => { + turns.push({ + text, + sessionId: s.id, + signal: turnOpts.signal, + resolve: (overrides = {}) => + res({ + session: session(s.id), + reason: overrides.reason ?? "reply", + stepCount: 1, + ...(overrides.undelivered + ? { undelivered: overrides.undelivered } + : {}), + }), + }); + }), + sessionStore: { + listRecent: () => stored, + load: (id: string) => stored.find((s) => s.id === id) ?? null, + delete: () => undefined, + }, + approvals: opts.approvals ?? { + clearSessionGrants, + denyPendingForSession, + pendingRequestForSession: () => null, + }, + turnController: { + isBusy: (id: string) => (opts.busySessions ?? []).includes(id), + }, + config: { + update: { checkOnStartup: false, repo: "x/y" }, + tracing: { trace: { dir: "/tmp", enabled: false } }, + }, + profileStore: { list: () => [] }, + skillCatalog: [], + } as unknown as AgentRuntime; + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(runtime, bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + return { + orchestrator, + actions, + turns, + bus, + denyPendingForSession, + clearSessionGrants, + }; +} + +/** Actions emitted after the most recent `session_switched`. */ +function actionsAfterLastSwitch(actions: readonly TuiAction[]): TuiAction[] { + for (let i = actions.length - 1; i >= 0; i -= 1) { + if (actions[i]?.type === "session_switched") return actions.slice(i + 1); + } + return [...actions]; +} + +function lastSwitch(actions: readonly TuiAction[]) { + for (let i = actions.length - 1; i >= 0; i -= 1) { + const action = actions[i]; + if (action?.type === "session_switched") return action; + } + return null; +} + +function warnTexts(actions: readonly TuiAction[]): string[] { + return actions + .filter( + (a): a is Extract => + a.type === "system_message", + ) + .map((a) => a.text); +} + +async function settle() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("ChatOrchestrator new/switch session while a turn is running", () => { + it("newSession detaches the running turn instead of refusing or aborting it", () => { + const { orchestrator, actions, turns } = makeHarness(); + orchestrator.sendMessage("long running work"); + expect(turns).toHaveLength(1); + + orchestrator.newSession(); + + const switched = lastSwitch(actions); + expect(switched?.sessionId).toBe("s-new-2"); + // The old turn keeps running: not aborted, and announced. + expect(turns[0]?.signal.aborted).toBe(false); + expect( + warnTexts(actions).some((t) => t.includes("continues in the background")), + ).toBe(true); + // The new thread is immediately usable — a second turn starts in + // parallel with the detached one (different sessions, no FIFO tie). + orchestrator.sendMessage("fresh thread work"); + expect(turns).toHaveLength(2); + expect(turns[1]?.sessionId).toBe("s-new-2"); + }); + + it("a detached turn's completion neither clobbers the visible session nor drains its queue", async () => { + const { orchestrator, actions, turns } = makeHarness(); + orchestrator.sendMessage("old thread work"); + orchestrator.newSession(); + orchestrator.sendMessage("new thread work"); + orchestrator.sendMessage("parked for the new thread"); + expect(turns).toHaveLength(2); + + turns[0]?.resolve(); + await settle(); + + // The parked message still waits for the NEW thread's turn. + expect(turns).toHaveLength(2); + expect( + warnTexts(actions).some((t) => t.includes("background turn finished")), + ).toBe(true); + + turns[1]?.resolve(); + await settle(); + expect(turns).toHaveLength(3); + expect(turns[2]?.text).toBe("parked for the new thread"); + expect(turns[2]?.sessionId).toBe("s-new-2"); + }); + + it("switching back mid-turn re-attaches the abort handle", () => { + const { orchestrator, actions, turns } = makeHarness(); + orchestrator.sendMessage("work on the first thread"); + const firstThreadId = turns[0]?.sessionId ?? ""; + + orchestrator.switchSession("s-b"); + expect(turns[0]?.signal.aborted).toBe(false); + + orchestrator.switchSession(firstThreadId); + const switched = lastSwitch(actions); + expect(switched?.sessionId).toBe(firstThreadId); + expect(switched?.running).toBe(true); + + // Esc aborts the re-attached turn again. + orchestrator.abortCurrentTurn(); + expect(turns[0]?.signal.aborted).toBe(true); + }); + + it("drops parked messages with a preview notice when switching away", () => { + const { orchestrator, actions, turns } = makeHarness(); + orchestrator.sendMessage("running"); + orchestrator.sendMessage("parked message one"); + orchestrator.sendMessage("parked message two"); + expect(turns).toHaveLength(1); + + orchestrator.switchSession("s-b"); + + const drop = warnTexts(actions).find((t) => t.includes("switched away")); + expect(drop).toContain("2 parked messages"); + expect(drop).toContain("parked message one"); + expect(drop).toContain("parked message two"); + const lastQueue = actions + .filter( + (a): a is Extract => + a.type === "queue_changed", + ) + .at(-1); + expect(lastQueue?.queued).toEqual([]); + }); + + it("denies the left thread's pending approval at the gate", () => { + const { orchestrator, denyPendingForSession, turns } = makeHarness(); + orchestrator.sendMessage("running"); + const leftId = turns[0]?.sessionId ?? ""; + orchestrator.switchSession("s-b"); + expect(denyPendingForSession).toHaveBeenCalledTimes(1); + expect(denyPendingForSession.mock.calls[0]?.[0]).toBe(leftId); + }); + + it("keeps the running thread's grants until its backgrounded turn ends", async () => { + const { orchestrator, clearSessionGrants, turns } = makeHarness(); + orchestrator.sendMessage("running"); + const leftId = turns[0]?.sessionId ?? ""; + orchestrator.switchSession("s-b"); + // Not cleared at switch time: the turn still runs under them. + expect(clearSessionGrants).not.toHaveBeenCalledWith(leftId); + turns[0]?.resolve(); + await settle(); + expect(clearSessionGrants).toHaveBeenCalledWith(leftId); + }); + + it("announces (and does not re-queue) a detached turn's undelivered steers", async () => { + const { orchestrator, actions, turns } = makeHarness(); + orchestrator.sendMessage("running"); + orchestrator.newSession(); + + turns[0]?.resolve({ undelivered: ["correction that came too late"] }); + await settle(); + + const notice = warnTexts(actions).find((t) => + t.includes("steering message"), + ); + expect(notice).toContain("correction that came too late"); + // Nothing started a turn out of it on the new thread. + expect(turns).toHaveLength(1); + }); + + it("switching to the thread already on screen mid-run is a no-op", () => { + const { orchestrator, actions, turns } = makeHarness(); + orchestrator.sendMessage("running"); + orchestrator.sendMessage("parked"); + const before = actions.filter((a) => a.type === "session_switched").length; + + orchestrator.switchSession(turns[0]?.sessionId ?? ""); + + expect(actions.filter((a) => a.type === "session_switched")).toHaveLength( + before, + ); + expect(turns[0]?.signal.aborted).toBe(false); + // The parked queue survived — nothing was detached. + const lastQueue = actions + .filter( + (a): a is Extract => + a.type === "queue_changed", + ) + .at(-1); + expect(lastQueue?.queued).toEqual(["parked"]); + }); + + it("marks a switch into a session busy with a foreign-origin turn as running", () => { + const { orchestrator, actions } = makeHarness({ busySessions: ["s-b"] }); + orchestrator.switchSession("s-b"); + expect(lastSwitch(actions)?.running).toBe(true); + }); + + it("quit aborts detached turns too", () => { + const { orchestrator, turns } = makeHarness(); + orchestrator.sendMessage("running"); + orchestrator.newSession(); + expect(turns[0]?.signal.aborted).toBe(false); + orchestrator.quit(); + expect(turns[0]?.signal.aborted).toBe(true); + }); + + it("leaving a session denies a foreign-origin turn's pending approval too", async () => { + // The operator is READING s-a while a scheduler/HTTP-origin turn + // runs on it: no TUI controller exists, only the foreign turn's + // request parked at the gate. Leaving must still answer it — the + // reducer drops the surface either way, and an unanswered request + // parks that turn on `await request()` forever. + const gate = new ApprovalGate({ emit: () => undefined }); + const { orchestrator, actions } = makeHarness({ + approvals: gate, + busySessions: ["s-a"], + }); + orchestrator.switchSession("s-a"); + const request = gate.request({ + sessionId: "s-a", + tool: "os.shell.run", + category: "shell", + reason: "no guard rule matched", + }); + orchestrator.switchSession("s-b"); + const decision = await request; + expect(decision.approved).toBe(false); + expect(decision.reason).toBe(SWITCHED_AWAY_APPROVAL_REASON); + // Nothing is left parked: the foreign turn can finish. + expect(gate.pendingCount()).toBe(0); + expect( + warnTexts(actions).some((t) => t.includes("pending approval was denied")), + ).toBe(true); + }); + + it("switching into the session that owns a parked approval re-raises the prompt", () => { + const gate = new ApprovalGate({ emit: () => undefined }); + const { orchestrator, actions } = makeHarness({ + approvals: gate, + busySessions: ["s-b"], + }); + // A foreign-origin turn on the off-screen s-b asked its question; + // the reducer showed only a pointer notice. Walking into s-b must + // put the actual prompt up, or it could never be answered. + void gate.request({ + sessionId: "s-b", + tool: "os.fs.write", + category: "fs_write_workspace", + reason: "write outside the workspace", + }); + orchestrator.switchSession("s-b"); + const raised = actionsAfterLastSwitch(actions).find( + (a): a is Extract => + a.type === "approval_requested", + ); + expect(raised?.request.sessionId).toBe("s-b"); + expect(raised?.request.tool).toBe("os.fs.write"); + }); + + it("switching back into a running thread replays the turn's events so far", () => { + // The stored snapshot of a session mid-FIRST-turn is empty (a turn + // saves only when it finishes), so without the replay the operator + // returns to a spinner over a blank page — their own prompt gone. + const h = makeHarness(); + h.orchestrator.sendMessage("first prompt"); + const sid = h.turns[0]?.sessionId ?? ""; + // The runtime streams session-tagged events for the running turn. + h.bus.emitAgentEvent({ type: "user_message", text: "first prompt" }, sid); + h.bus.emitAgentEvent({ type: "step_started", stepIndex: 0 }, sid); + + h.orchestrator.newSession(); + h.orchestrator.switchSession(sid); + + const replayed = actionsAfterLastSwitch(h.actions).filter( + (a): a is Extract => + a.type === "agent_event" && a.sessionId === sid, + ); + expect(replayed.map((a) => a.event.type)).toEqual([ + "user_message", + "step_started", + ]); + const userEvent = replayed[0]?.event; + expect(userEvent?.type === "user_message" && userEvent.text).toBe( + "first prompt", + ); + }); + + it("a second switch-back replays the turn once, not twice", () => { + // The replay is emitted on the same bus the recorder taps; without + // the guard each round trip would double the log. + const h = makeHarness(); + h.orchestrator.sendMessage("first prompt"); + const sid = h.turns[0]?.sessionId ?? ""; + h.bus.emitAgentEvent({ type: "user_message", text: "first prompt" }, sid); + + h.orchestrator.newSession(); + h.orchestrator.switchSession(sid); + h.orchestrator.newSession(); + h.orchestrator.switchSession(sid); + + const replayed = actionsAfterLastSwitch(h.actions).filter( + (a) => a.type === "agent_event", + ); + expect(replayed).toHaveLength(1); + }); +}); diff --git a/src/tui/chat-orchestrator.test.ts b/src/tui/chat-orchestrator.test.ts new file mode 100644 index 00000000..d413b127 --- /dev/null +++ b/src/tui/chat-orchestrator.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createEmptySessionState } from "../session/session-state.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { ChatOrchestrator, MAX_QUEUED_MESSAGES } from "./chat-orchestrator.js"; +import { makeTuiEventBus } from "./make-event-bus.js"; +import type { LocalTurnGateFacts } from "./local-turn-gate.js"; +import type { TuiAction } from "./tui-action.js"; + +/** Hermetic gate facts: never read the developer's real config/disk. */ +const cloudGateFacts = (): LocalTurnGateFacts => ({ + activeProviderIsLocal: false, + managedMode: false, + modelId: null, + modelDownloaded: true, + fallbackChainLength: 1, +}); + +interface Deferred { + promise: Promise<{ session: ReturnType; reason: string; stepCount: number }>; + resolve: () => void; +} + +function session(id = "s1") { + return createEmptySessionState({ id, workingDir: "/tmp" }); +} + +function deferred(id: string): Deferred { + let resolve!: () => void; + const promise = new Promise<{ + session: ReturnType; + reason: string; + stepCount: number; + }>((res) => { + resolve = () => res({ session: session(id), reason: "reply", stepCount: 1 }); + }); + return { promise, resolve }; +} + +/** + * Minimal `AgentRuntime` stand-in. Every sub-orchestrator the + * `ChatOrchestrator` constructor builds only stores references and + * subscribes to the bus, so nothing here needs to do I/O. + */ +function stubRuntime( + runTurn: (text: string, opts: { signal: AbortSignal }) => Promise, +): AgentRuntime { + return { + createSession: () => session(), + // The queue tests exercise the fallback path: a steer that is always + // refused parks every mid-run submission in the orchestrator queue. + steer: () => false, + runTurn: (_s: unknown, text: string, opts: { signal: AbortSignal }) => + runTurn(text, opts), + sessionStore: { listRecent: () => [], load: () => null }, + approvals: { clearSessionGrants: () => undefined }, + config: { update: { checkOnStartup: false, repo: "x/y" }, tracing: { trace: { dir: "/tmp", enabled: false } } }, + profileStore: { list: () => [] }, + skillCatalog: [], + } as unknown as AgentRuntime; +} + +describe("ChatOrchestrator message queue", () => { + it("runs the first message and parks the second until the first settles", async () => { + const first = deferred("s1"); + const second = deferred("s1"); + const seen: string[] = []; + const runTurn = vi.fn((text: string) => { + seen.push(text); + return (seen.length === 1 ? first : second).promise; + }); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + orchestrator.sendMessage("first"); + orchestrator.sendMessage("second"); + expect(seen).toEqual(["first"]); + expect(queueSnapshots(actions).at(-1)).toEqual(["second"]); + + first.resolve(); + await first.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(seen).toEqual(["first", "second"]); + expect(queueSnapshots(actions).at(-1)).toEqual([]); + second.resolve(); + await second.promise; + }); + + it("clearQueue drops parked messages without touching the running turn", async () => { + const first = deferred("s1"); + const runTurn = vi.fn(() => first.promise); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + orchestrator.sendMessage("running"); + orchestrator.sendMessage("parked-a"); + orchestrator.sendMessage("parked-b"); + expect(queueSnapshots(actions).at(-1)).toEqual(["parked-a", "parked-b"]); + + orchestrator.clearQueue(); + expect(queueSnapshots(actions).at(-1)).toEqual([]); + expect(runTurn).toHaveBeenCalledTimes(1); + + first.resolve(); + await first.promise; + await Promise.resolve(); + await Promise.resolve(); + // Nothing left to drain — the cleared queue really is empty. + expect(runTurn).toHaveBeenCalledTimes(1); + }); + + it("clearQueue on an empty queue does not spam the bus", () => { + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator( + stubRuntime(() => new Promise(() => undefined)), + bus, + { maxSteps: 5, llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts }, + ); + orchestrator.clearQueue(); + // The idle boundary re-syncs an (empty) queue unconditionally; what + // must not happen is a non-empty snapshot or an "aborted:" notice. + expect(queueSnapshots(actions).every((q) => q.length === 0)).toBe(true); + }); +}); + +describe("ChatOrchestrator abort", () => { + it("discards parked messages instead of draining them into the next turn", async () => { + const seen: string[] = []; + const runTurn = vi.fn((text: string, opts: { signal: AbortSignal }) => { + seen.push(text); + return abortableTurn(opts.signal); + }); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + orchestrator.sendMessage("running"); + orchestrator.sendMessage("parked-a"); + orchestrator.sendMessage("parked-b"); + expect(queueSnapshots(actions).at(-1)).toEqual(["parked-a", "parked-b"]); + + orchestrator.abortCurrentTurn(); + await settle(); + + // One Esc stops everything: the parked messages must not become turns. + expect(seen).toEqual(["running"]); + expect(runTurn).toHaveBeenCalledTimes(1); + expect(queueSnapshots(actions).at(-1)).toEqual([]); + const aborted = noticeLines(actions).find((l) => + l.startsWith("aborted: dropped 2 parked messages"), + ); + expect(aborted).toBeDefined(); + // The dropped texts ride along so the operator can copy them back. + expect(aborted).toContain("1. parked-a"); + expect(aborted).toContain("2. parked-b"); + }); + + it("stays quiet when the abort had nothing parked to drop", async () => { + const runTurn = vi.fn((_text: string, opts: { signal: AbortSignal }) => + abortableTurn(opts.signal), + ); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + orchestrator.sendMessage("running"); + orchestrator.abortCurrentTurn(); + await settle(); + + // The idle boundary re-syncs an (empty) queue unconditionally; what + // must not happen is a non-empty snapshot or an "aborted:" notice. + expect(queueSnapshots(actions).every((q) => q.length === 0)).toBe(true); + expect(noticeLines(actions).filter((l) => l.startsWith("aborted:"))).toEqual( + [], + ); + }); +}); + +describe("ChatOrchestrator queue bound", () => { + it("caps the queue and names how many messages it dropped", async () => { + const first = deferred("s1"); + const seen: string[] = []; + const runTurn = vi.fn((text: string) => { + seen.push(text); + return first.promise; + }); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + orchestrator.sendMessage("running"); + for (let i = 0; i < MAX_QUEUED_MESSAGES + 3; i += 1) { + orchestrator.sendMessage(`parked-${i}`); + } + + const queued = queueSnapshots(actions).at(-1) ?? []; + expect(queued).toHaveLength(MAX_QUEUED_MESSAGES); + // FIFO: the cap drops the newest arrivals, never the ones already parked. + expect(queued[0]).toBe("parked-0"); + expect(queued.at(-1)).toBe(`parked-${MAX_QUEUED_MESSAGES - 1}`); + expect(runTurn).toHaveBeenCalledTimes(1); + + const full = noticeLines(actions).filter((l) => l.startsWith("queue: full")); + expect(full).toHaveLength(3); + expect(full.at(-1)).toBe( + `queue: full at ${MAX_QUEUED_MESSAGES} — dropped 3 messages (returned to the editor); Esc stops the run, /queue clear empties it`, + ); + + first.resolve(); + await settle(); + // Exactly the parked messages run — the refused ones are gone for good. + expect(seen).toEqual(["running", ...queued]); + expect(seen).not.toContain(`parked-${MAX_QUEUED_MESSAGES}`); + }); + + it("re-publishes the queue on a rejected push so an optimistic insert cannot stick", () => { + const runTurn = vi.fn(() => new Promise(() => undefined)); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + orchestrator.sendMessage("running"); + for (let i = 0; i < MAX_QUEUED_MESSAGES; i += 1) { + orchestrator.sendMessage(`parked-${i}`); + } + const beforeDrop = queueSnapshots(actions).length; + + orchestrator.sendMessage("rejected"); + + const snapshots = queueSnapshots(actions); + expect(snapshots).toHaveLength(beforeDrop + 1); + expect(snapshots.at(-1)).toHaveLength(MAX_QUEUED_MESSAGES); + expect(snapshots.at(-1)).not.toContain("rejected"); + }); + + it("forgets the drop counter once the queue has room again", async () => { + const first = deferred("s1"); + const runTurn = vi.fn(() => first.promise); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", readGateFacts: cloudGateFacts, + }); + + orchestrator.sendMessage("running"); + for (let i = 0; i < MAX_QUEUED_MESSAGES + 2; i += 1) { + orchestrator.sendMessage(`parked-${i}`); + } + orchestrator.clearQueue(); + orchestrator.sendMessage("after-clear"); + // Refills to exactly the cap, then one more that must be refused. + for (let i = 0; i < MAX_QUEUED_MESSAGES; i += 1) { + orchestrator.sendMessage(`again-${i}`); + } + + const full = noticeLines(actions).filter((l) => l.startsWith("queue: full")); + // Two drops before the clear, then the counter restarts at 1 after it. + expect(full.at(-1)).toBe( + `queue: full at ${MAX_QUEUED_MESSAGES} — dropped 1 message (returned to the editor); Esc stops the run, /queue clear empties it`, + ); + }); +}); + +describe("ChatOrchestrator pre-turn local gate", () => { + const blockedFacts = (): LocalTurnGateFacts => ({ + activeProviderIsLocal: true, + managedMode: true, + modelId: "qwen-3.5-4b", + modelDownloaded: false, + fallbackChainLength: 1, + }); + + function gateBlocks(actions: readonly TuiAction[]): readonly string[] { + return actions + .filter((a): a is Extract => + a.type === "turn_gate_blocked", + ) + .map((a) => a.text); + } + + it("blocks a fresh submit and returns the message to the editor", () => { + const runTurn = vi.fn(() => Promise.resolve()); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", + readGateFacts: blockedFacts, + }); + + orchestrator.sendMessage("hello"); + + expect(runTurn).not.toHaveBeenCalled(); + const blocks = gateBlocks(actions); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toContain("qwen-3.5-4b"); + expect(blocks[0]).toContain("(message returned to the editor)"); + const restored = actions.find((a) => a.type === "input_changed"); + expect(restored).toEqual({ type: "input_changed", value: "hello" }); + }); + + it("gates at drain time, not enqueue: a message queued mid-run is judged when it starts", async () => { + // Model fine while the first turn runs; gone by the time the queue drains + // (e.g. the operator switched the managed model mid-turn). + let downloaded = true; + const first = deferred("s1"); + const runTurn = vi.fn(() => first.promise); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", + readGateFacts: () => ({ + ...blockedFacts(), + modelDownloaded: downloaded, + }), + }); + + orchestrator.sendMessage("first"); + orchestrator.sendMessage("second"); + // Enqueue itself is never gated — the message parks normally. + expect(gateBlocks(actions)).toHaveLength(0); + expect(queueSnapshots(actions).at(-1)).toEqual(["second"]); + + downloaded = false; + first.resolve(); + await first.promise; + await Promise.resolve(); + await Promise.resolve(); + + // Only the first message ever reached the runtime; the drained one + // was blocked at ITS turn start and dropped with a preview. + expect(runTurn).toHaveBeenCalledTimes(1); + const blocks = gateBlocks(actions); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toContain("dropped: second"); + }); + + it("with a fallback chain (>1 link) it notices and still runs the turn", async () => { + const first = deferred("s1"); + const runTurn = vi.fn(() => first.promise); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", + readGateFacts: () => ({ ...blockedFacts(), fallbackChainLength: 2 }), + }); + + orchestrator.sendMessage("hello"); + + expect(runTurn).toHaveBeenCalledTimes(1); + expect(gateBlocks(actions)).toHaveLength(0); + const notice = noticeLines(actions).find((l) => + l.includes("fallback chain"), + ); + expect(notice).toContain("qwen-3.5-4b"); + first.resolve(); + await first.promise; + }); + + it("cloud turns never see the gate", () => { + const runTurn = vi.fn(() => Promise.resolve()); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", + readGateFacts: () => ({ + ...blockedFacts(), + activeProviderIsLocal: false, + }), + }); + + orchestrator.sendMessage("hello"); + + expect(runTurn).toHaveBeenCalledTimes(1); + expect(gateBlocks(actions)).toHaveLength(0); + }); +}); + +/** + * A turn that never settles on its own and rejects the moment the + * orchestrator aborts it — what `runtime.runTurn` really does, and the + * only shape that exercises `runOneTurn`'s catch-then-drain tail. + */ +function abortableTurn(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); +} + +/** Let the orchestrator's post-turn continuation (catch → finally → drain) run. */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function noticeLines(actions: readonly TuiAction[]): readonly string[] { + return actions + .filter((a): a is Extract => + a.type === "runtime_info", + ) + .map((a) => a.line); +} + +function queueSnapshots(actions: readonly TuiAction[]): readonly string[][] { + return actions + .filter((a): a is Extract => + a.type === "queue_changed", + ) + .map((a) => [...a.queued]); +} diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index ab6eb096..12c03a1c 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -4,9 +4,24 @@ import { join } from "node:path"; import type { ProfileFact } from "../memory/profile-store.js"; import type { SkillCatalogEntry } from "../prompt/stable-prefix.js"; import type { AgentRuntime } from "../runtime/bootstrap.js"; -import type { SessionState } from "../session/session-state.js"; +import { + isFailedSessionStatus, + type SessionState, +} from "../session/session-state.js"; import { checkForAppUpdate, runAppUpdate, canSelfUpdate } from "../update/index.js"; import { clearTtyScreen } from "./clear-tty-screen.js"; +import { + DetachedTurns, + droppedPreview, + formatBackgroundTurnFailed, + formatBackgroundTurnFinished, + formatDetachedTurnNotice, + formatDroppedQueueOnSwitchNotice, + formatDroppedSteersNotice, + formatReplayGapNotice, + SWITCHED_AWAY_APPROVAL_REASON, + TurnEventBuffer, +} from "./detached-turns.js"; import { captureAndWriteDebugBundle } from "./debug-bundle/index.js"; import { LlmHealthPoller } from "./llm-health/llm-health-poller.js"; import { LocalModelsOrchestrator } from "./local-models/local-models-orchestrator.js"; @@ -21,16 +36,46 @@ import { TuiTelegramOrchestrator } from "./telegram/tui-telegram-orchestrator.js import { PrivacyOrchestrator } from "./privacy/privacy-orchestrator.js"; import type { TuiEventBus } from "./tui-app.js"; import { formatAgentErrorForChat } from "./format-agent-error-for-chat.js"; +import { + ChatPullMirror, + evaluateLocalTurnGate, + readLocalTurnGateFacts, + type LocalTurnGateFacts, +} from "./local-turn-gate.js"; import { turnsToMessages } from "./turns-to-messages.js"; +import { createHeapGuard } from "../runtime/heap-guard.js"; import type { SessionPickerEntry, TuiState } from "./tui-state.js"; const DEBUG_BUNDLE_TRACE_LIMIT = 10; const DEBUG_BUNDLE_DIR_NAME = "atomic-agent-debug"; +/** + * Hard cap on messages parked behind the running turn. + * + * Nothing bounded this before because nothing could reach it: the editor + * was dead for the duration of a turn, so the queue was a de-facto + * zero-length buffer. Now that typing stays live, a leaned-on Enter or a + * multi-line paste can pile up an arbitrary backlog, and every parked + * message is later replayed as a full `runTurn` — an unbounded queue is + * an unattended agent run nobody asked for. + * + * Twenty is past any backlog a human types while watching one turn + * stream, small enough that draining it stays comprehensible, and it + * keeps `emitQueue`'s whole-array copy bounded at 20 elements per push. + */ +export const MAX_QUEUED_MESSAGES = 20; + export interface ChatOrchestratorOptions { maxSteps: number; /** Initial llama-server base URL for the footer health poller. */ llamaUrl: string; + /** + * Facts source for the pre-turn local-model gate. Injectable so tests + * stay hermetic — the default reads live config + disk, and a test + * that never passes this would silently depend on the developer's own + * `~/.atomic-agent` state. + */ + readGateFacts?: () => LocalTurnGateFacts; } /** Multiline text for the chat transcript (`/memory`); feed still gets `runtime_info` lines. */ @@ -62,8 +107,9 @@ function formatSkillCatalogSystemMessage( * Owns the single live chat session. Each call to `sendMessage` queues a * macro-turn through `runtime.runTurn`; only one turn is in flight at any * time so the user can keep typing without racing the agent loop. Abort - * cancels the current turn but keeps the session alive — that is what - * sets chat mode apart from the legacy goal-runner. + * cancels the current turn and discards whatever is parked behind it, + * but keeps the session alive — that is what sets chat mode apart from + * the legacy goal-runner. * * The Tasks tab surface (list/detail/create/cancel/run-now) is delegated * to `TasksOrchestrator`, which is constructed here and exposed via @@ -78,6 +124,54 @@ export class ChatOrchestrator { /** Latest release version captured by `checkForUpdate`, used by `runUpdate`. */ private pendingUpdateVersion: string | null = null; private readonly queue: string[] = []; + /** + * Messages refused since the queue last had room, so a burst reads as + * one escalating counter instead of N identical lines. Reset by the + * first push that fits again. + */ + private droppedWhileFull = 0; + /** + * How many leading `queue` entries are steering re-routes for the turn + * currently in flight. New re-routes are spliced in at this index, so + * they stay ahead of ordinary backlog (they are corrections to the turn + * the operator is watching) while keeping their own typing order. Reset + * whenever a turn starts — a message aimed at the previous turn is + * ordinary backlog from the next one's point of view. + */ + private steeredAhead = 0; + /** + * Abort handles of turns the operator switched away from — see + * `detachRunningTurn`. Switching back re-attaches the handle; quit + * aborts everything still parked here. + */ + private readonly detachedTurns = new DetachedTurns(); + /** + * Rolling log of the agent events emitted by this orchestrator's + * running turns, keyed by session — what a switch-back replays so a + * re-attached thread shows its own prompt and feed instead of the + * empty stored snapshot. Fed by the bus tap in the constructor, + * started per turn in `runOneTurn`, dropped when the turn ends. + */ + private readonly turnEvents = new TurnEventBuffer(); + /** + * Guards the bus tap against recording its own replay — without it a + * second switch-back would replay every event twice. + */ + private replayingTurnEvents = false; + /** + * The visible turn was re-attached by a switch-back mid-run. While + * the operator was away its events were dropped (the reducer filters + * by visible session), so when it finishes the transcript is + * re-emitted from the saved session instead of trusting the stream. + */ + private reattachedMidTurn = false; + /** + * Live view of the model/backend pull the reducer also tracks + * (`localModelsPanel.pull`), fed from the same bus events. The + * pre-turn gate reads it to print real percent + bytes instead of a + * bare "not downloaded" while the fix is already in flight. + */ + private readonly chatPull = new ChatPullMirror(); public exitCode = 0; public readonly tasks: TasksOrchestrator; public readonly skills: SkillsOrchestrator; @@ -116,9 +210,31 @@ export class ChatOrchestrator { onManagedDaemonRestarted: () => { void this.llmHealth.refreshModelLabel(); }, + onManagedModelActivated: () => { + // The operator put a model live and it actually serves — the + // local equivalent of a verified cloud key. Deliberately NOT on + // `onManagedDaemonRestarted`: that also fires from the + // launch-time `autoStartIfReady`, which would report an ordinary + // app start as a first-time setup. `llama.cpp` is the runner, + // never the model id — a local model name is an arbitrary + // operator string. + runtime.reportModelConfigured("llama.cpp", "local"); + }, }); this.telegram = new TuiTelegramOrchestrator(runtime, bus); this.privacy = new PrivacyOrchestrator(runtime, bus); + // Tap the bus rather than the runtime handler: what the reducer was + // offered is exactly what a switch-back may need to replay, session + // tags included. `record` no-ops for sessions without a running + // TUI turn, so scheduler/HTTP-origin events cost one Map miss. + bus.subscribe((action) => { + if (this.replayingTurnEvents) return; + if (action.type !== "agent_event" || action.sessionId === undefined) { + return; + } + this.turnEvents.record(action.sessionId, action.event); + }); + this.chatPull.attach(bus); } /** @@ -196,6 +312,15 @@ export class ChatOrchestrator { * the user to relaunch. */ runUpdate(): void { + // Replacing the binary under a running turn is the one mid-run slash + // command with no safe outcome — now reachable because the editor + // stays live. Refuse it instead of racing the installer. + if (this.currentController || this.detachedTurns.size > 0) { + this.notify( + "update: refused while a turn is running (foreground or background) — abort it or let it finish first", + ); + return; + } this.bus.emit({ type: "update_started" }); void (async () => { try { @@ -219,30 +344,174 @@ export class ChatOrchestrator { } openSessionPicker(): void { - const sessions = this.runtime.sessionStore - .listRecent(25) - .map((s) => toPickerEntry(s)); - this.bus.emit({ type: "session_picker_opened", sessions }); + // Same list the rail shows. The menu's `N recent` badge counts the + // rail's entries, so a picker with its own idea of the set would + // open contradicting the number that advertised it. + this.bus.emit({ + type: "session_picker_opened", + sessions: this.railSessions(), + }); } /** - * Refreshes the always-on sidebar's session list. Called on TUI - * mount + after `session_created` / `session_switched` so the rail - * stays in sync without the user having to open the modal picker. + * The rail lists threads, not allocations. A session exists the moment + * `+ new` mints it — `runtime.createSession` persists it immediately, + * and scheduled tasks, webhooks and Telegram all depend on that — but + * an unnamed row is noise: it says "(empty)" until someone types, and + * two of them are indistinguishable. + * + * So the list shows sessions that have been *spoken to*. The catch is + * timing: the first user turn only reaches SQLite when the whole turn + * finishes (`executeTurn` saves after the loop returns), so a + * store-backed refresh at prompt time still sees nothing. These + * entries bridge that window with a row built from the submitted + * text, each retiring as soon as the store can answer for its id. + * + * A map, not a single slot: with a turn detached in the background, + * the NEXT thread's first prompt would otherwise evict the detached + * thread's stand-in — making the one session the operator most needs + * to find again invisible in the rail and the picker until its turn + * finishes. Bounded by construction: one entry per session whose + * first turn has not been saved yet, i.e. at most the visible thread + * plus the detached ones. */ + private readonly pendingRows = new Map(); + refreshRecentSessions(): void { - const sessions = this.runtime.sessionStore - .listRecent(25) - .map((s) => toPickerEntry(s)); - this.bus.emit({ type: "recent_sessions_updated", sessions }); + this.bus.emit({ + type: "recent_sessions_updated", + sessions: this.railSessions(), + }); + } + + /** Stored threads that have a first prompt, plus the pending ones. */ + private railSessions(): SessionPickerEntry[] { + // Read deeper than we show, because the filter runs HERE and the + // limit runs in SQL. Every `+ new` and every scheduled task mints a + // persisted, unnamed session; filtering a 25-row window would let + // those invisible rows squat it and push real conversations out — + // permanently, since a thread only re-enters the window by being + // spoken to, which you cannot do once it has no row. + const stored = this.runtime.sessionStore + .listRecent(RAIL_SCAN_LIMIT) + .filter((state) => hasFirstPrompt(state)) + .map((s) => toPickerEntry(s)) + .slice(0, RAIL_SESSION_LIMIT); + if (this.pendingRows.size === 0) return stored; + const storedIds = new Set(stored.map((entry) => entry.sessionId)); + const pending: SessionPickerEntry[] = []; + for (const [sessionId, entry] of this.pendingRows) { + // The store caught up: drop the stand-in rather than render the + // same session twice (the rail keys rows by session id). + if (storedIds.has(sessionId)) { + this.pendingRows.delete(sessionId); + continue; + } + pending.push(entry); + } + // Newest stand-in first, matching the store's recency order. + pending.reverse(); + return [...pending, ...stored]; + } + + /** + * Put the current session on the rail the instant its first prompt is + * sent, named by that prompt. Called from `runOneTurn`, which is the + * one funnel every first turn passes through — `sendMessage` and + * `steerMessage` both land there, and hooking either alone would miss + * `/steer ` as an opening prompt. + */ + private noteFirstPrompt(text: string): void { + const session = this.session; + if (!session) return; + if (this.pendingRows.has(session.id)) return; + if (hasFirstPrompt(session)) return; + this.pendingRows.set(session.id, { + sessionId: session.id, + workingDir: session.workingDir, + turnCount: 1, + stepCount: 0, + updatedAt: Date.now(), + preview: text, + }); + this.refreshRecentSessions(); + } + + /** + * Remove a session for good, from the rail's `x` (confirmed). + * + * Deleting the thread the operator is *in* would leave the app + * pointed at a row that no longer exists, so that case rolls straight + * into a fresh session — the same landing `/new` gives. Deleting any + * other thread only refreshes the list. + * + * Refused mid-turn for the same reason switching is: the running turn + * writes its transcript back to the store when it finishes, which + * would resurrect the row that was just deleted. + */ + deleteSession(sessionId: string): void { + if (this.quitting) return; + // Scoped to the thread being deleted: a running turn elsewhere is + // no reason to refuse. The visible controller covers the window + // before the queued turn reaches `isBusy`; `detachedTurns` covers + // the same window for a backgrounded one. + if (this.currentController && this.session?.id === sessionId) { + this.bus.emit({ + type: "system_message", + text: "cannot delete this session while its turn is running — press Esc to stop it first", + variant: "warn", + }); + return; + } + if (this.detachedTurns.has(sessionId)) { + this.bus.emit({ + type: "system_message", + text: "cannot delete that session — its turn is still running in the background (switch to it and press Esc to stop it)", + variant: "warn", + }); + return; + } + // …and not only OUR turn. The same store is written by turns this + // orchestrator never sees: a scheduled task, a Telegram message, an + // HTTP call. Deleting a session while one of those is mid-turn does + // not stick — `executeTurn` saves the finished session afterwards, + // and `save()` is an upsert, so the thread reappears on the rail + // with its whole transcript. The turn controller is the one place + // that knows about every origin. + if (this.runtime.turnController.isBusy(sessionId)) { + this.bus.emit({ + type: "system_message", + text: "cannot delete that session — a turn is running on it (a scheduled task, Telegram, or the HTTP API)", + variant: "warn", + }); + return; + } + const deletingCurrent = this.session?.id === sessionId; + this.pendingRows.delete(sessionId); + this.runtime.sessionStore.delete(sessionId); + this.bus.emit({ + type: "system_message", + text: `session deleted${deletingCurrent ? " — started a fresh one" : ""}`, + }); + if (deletingCurrent) { + this.newSession(); + return; + } + this.refreshRecentSessions(); } switchSession(sessionId: string): void { if (this.quitting) return; - if (this.currentController) { + if (this.currentController && this.session?.id === sessionId) { + // Enter on the picker row that is already open, mid-run. + // Re-loading would replace the live transcript with the stale + // stored copy (a running turn saves only when it finishes), and + // detaching first would drop the queue and deny the approval for + // nothing — so this is a no-op, not a round-trip. + this.bus.emit({ type: "session_picker_closed" }); this.bus.emit({ type: "runtime_info", - line: "cannot switch sessions while a turn is running — press Ctrl+C first", + line: `already on session ${sessionId}`, }); return; } @@ -254,22 +523,150 @@ export class ChatOrchestrator { }); return; } + const notices = this.leaveCurrentSession(); this.session = loaded; - this.queue.length = 0; - // Session grants are point exceptions scoped to the session that - // granted them; a switch must not carry them into the next one. - this.runtime.approvals.clearSessionGrants(); + // Switching back into a thread whose turn we backgrounded earlier + // re-attaches the abort handle: Esc aborts, Enter steers, exactly + // as if the operator had never left. + const resumed = this.detachedTurns.take(sessionId); + if (resumed) { + this.currentController = resumed; + this.reattachedMidTurn = true; + } + // `isBusy` additionally catches turns from other origins (a + // scheduled task, Telegram, HTTP) so the composer offers steer + // instead of pretending the thread is idle. + const running = + resumed !== null || this.runtime.turnController.isBusy(sessionId); this.bus.emit({ type: "session_switched", sessionId: loaded.id, workingDir: loaded.workingDir, messages: turnsToMessages(loaded.turns), + running, }); + // The stored snapshot above misses everything the still-running + // turn has said (a turn saves only when it finishes — for a thread + // mid-first-turn the snapshot is EMPTY, prompt included). Repaint + // from the event log before anything else lands in the transcript. + if (resumed) this.replayTurnEvents(loaded.id); this.refreshRecentSessions(); this.bus.emit({ type: "runtime_info", - line: `switched to session ${loaded.id} (${loaded.turnCount} turn${loaded.turnCount === 1 ? "" : "s"})`, + line: `switched to session ${loaded.id} (${loaded.turnCount} turn${loaded.turnCount === 1 ? "" : "s"})${ + running ? " — a turn is still running here" : "" + }`, }); + // After `session_switched`, so they land in the new transcript + // rather than the one that was just replaced. + for (const notice of notices) this.notify(notice); + // A turn parked on an approval in THIS thread asked its question + // while another transcript was on screen, where it surfaced only as + // a pointer notice (approval keys never answer for an off-screen + // thread). Its owner is visible now — re-raise the actual prompt. + const parkedApproval = this.runtime.approvals.pendingRequestForSession( + loaded.id, + ); + if (parkedApproval) { + this.bus.emit({ type: "approval_requested", request: parkedApproval }); + } + } + + /** + * Re-offer the re-attached turn's buffered events to the reducer. + * They are tagged with the now-visible session, so they apply; live + * events continue from where the buffer ends. When the ring cap ate + * the head of the turn the gap is announced rather than papered over + * — and the end-of-turn re-emit from the saved session restores the + * authoritative transcript either way. + */ + private replayTurnEvents(sessionId: string): void { + const buffered = this.turnEvents.snapshot(sessionId); + if (!buffered) return; + this.replayingTurnEvents = true; + try { + if (buffered.dropped > 0) { + this.bus.emit({ + type: "system_message", + text: formatReplayGapNotice(buffered.dropped), + variant: "warn", + }); + } + for (const event of buffered.events) { + this.bus.emit({ type: "agent_event", event, sessionId }); + } + } finally { + this.replayingTurnEvents = false; + } + } + + /** + * Book the visible session out before another takes its place, and + * return the notices to show once the new transcript is up. + * + * With a turn in flight this is a DETACH, not an abort: the + * concurrency contract gives every session its own FIFO and runs + * sessions in parallel (AGENTS.md §"Concurrency contract"), so the + * turn keeps executing against its own session and saves its + * transcript there. What must not follow the operator to the new + * thread: + * + * - the abort handle — Esc in the new thread must abort nothing; + * parked in `detachedTurns` and restored on switch-back; + * - parked queue messages — aimed at the old thread; announced drop + * with previews, same shape as the abort path, never silent; + * - a pending approval — the modal closes with the transcript it + * asks about, so the request is denied at the gate with an + * explicit reason; left unresolved it would park the turn forever + * on `await request()`; + * - session grants — deferred, not dropped: the running turn keeps + * the exceptions the operator granted it, and `runOneTurn` clears + * them when the backgrounded turn finishes. Yanking them here + * would make the background turn re-prompt from off screen. + * + * With no turn of OURS in flight, the approval deny and the grant + * clear still apply — scoped to the thread being left. The deny does + * not depend on who started the turn: a scheduler/HTTP-origin turn's + * pending approval is just as unanswerable once its transcript is + * gone, and skipping it would park that turn forever. + */ + private leaveCurrentSession(): string[] { + const notices: string[] = []; + const previous = this.session; + if (!previous) return notices; + // Denied for ANY pending approval on the thread being left, not + // only when the parked turn is ours: a scheduler/HTTP/Telegram- + // origin turn parks on the same gate, and the switch drops its + // modal exactly the same way — left unanswered, that turn waits on + // `await request()` forever and its session stays busy for good. + // `denyPendingForSession` is session-scoped and a counted no-op + // when nothing is pending. + const denied = this.runtime.approvals.denyPendingForSession( + previous.id, + SWITCHED_AWAY_APPROVAL_REASON, + ); + if (denied > 0) { + notices.push( + "the pending approval was denied — you switched away while it waited for an answer", + ); + } + if (!this.currentController) { + this.runtime.approvals.clearSessionGrants(previous.id); + return notices; + } + const dropped = [...this.queue]; + if (dropped.length > 0) { + this.queue.length = 0; + this.droppedWhileFull = 0; + this.emitQueue(); + notices.push(formatDroppedQueueOnSwitchNotice(dropped)); + } + this.steeredAhead = 0; + this.detachedTurns.park(previous.id, this.currentController); + this.currentController = null; + this.reattachedMidTurn = false; + notices.push(formatDetachedTurnNotice(previous.id)); + return notices; } dumpProfile(): void { @@ -337,18 +734,13 @@ export class ChatOrchestrator { newSession(): void { if (this.quitting) return; - if (this.currentController) { - this.bus.emit({ - type: "runtime_info", - line: "cannot create a new session while a turn is running — press Ctrl+C first", - }); - return; - } + // A running turn keeps running in its old thread — see + // `leaveCurrentSession`. A fresh session starts with no point + // exceptions of its own; the previous thread's grants are cleared + // on leave (or, if its turn is still running, when that turn ends). + const notices = this.leaveCurrentSession(); this.session = this.runtime.createSession(); - this.queue.length = 0; - // A fresh session starts with no point exceptions: grants never - // outlive the session that created them. - this.runtime.approvals.clearSessionGrants(); + this.clearQueue(); clearTtyScreen(process.stdout); this.bus.emit({ type: "session_switched", @@ -361,45 +753,323 @@ export class ChatOrchestrator { type: "runtime_info", line: `new session ${this.session.id} created`, }); + for (const notice of notices) this.notify(notice); } sendMessage(text: string): void { if (this.quitting) return; this.ensureSession(); if (this.currentController) { + if (this.queue.length >= MAX_QUEUED_MESSAGES) { + this.droppedWhileFull += 1; + // Re-publish an unchanged queue on purpose: the reducer already + // inserted this message optimistically on `message_queued`, and + // only an authoritative `queue_changed` takes it back off the + // strip. Skipping the emit here would leave the operator looking + // at a parked message that is never going to run. + this.emitQueue(); + // The optimistic `message_queued` already cleared the editor, so + // a refusal that only warned would lose the typed text entirely. + // Hand it back to the buffer instead. + this.bus.emit({ type: "input_changed", value: text }); + this.notify( + `queue: full at ${MAX_QUEUED_MESSAGES} — dropped ${this.droppedWhileFull} message${ + this.droppedWhileFull === 1 ? "" : "s" + } (returned to the editor); Esc stops the run, /queue clear empties it`, + ); + return; + } + this.droppedWhileFull = 0; this.queue.push(text); + this.emitQueue(); return; } void this.runOneTurn(text); } - private async runOneTurn(text: string): Promise { + /** + * Fold a message into the turn already running on this session + * (Enter in `steer` mode, and `/steer ` one-shots). + * + * Steer first; on refusal the message must still go somewhere — and + * not behind ordinary backlog: `currentController` is set strictly + * earlier than the loop opens its window, so a refusal can mean "not + * yet" as well as "too late" or "full". `queueAsSteer` splices it + * ahead of backlog, behind steers already re-routed for the same + * turn, so typing order survives. `steer`'s answer is the only fact + * consulted — see §"Mid-turn steering" in AGENTS.md. + */ + steerMessage(text: string): void { + if (this.quitting) return; + const session = this.ensureSession(); + // Offered to the inbox unconditionally: `steer`'s return value is + // the one authoritative fact (AGENTS.md §"Mid-turn steering"), and + // since threads stay switchable mid-run, the turn running on this + // session is not necessarily one this orchestrator started — a + // scheduled task's or Telegram's turn is just as steerable. + if (this.runtime.steer(session.id, text)) { + this.bus.emit({ + type: "runtime_info", + line: "steering the running turn — the agent reads it at the next step", + }); + return; + } + if (this.currentController) { + if (this.queue.length >= MAX_QUEUED_MESSAGES) { + this.droppedWhileFull += 1; + this.emitQueue(); + this.bus.emit({ type: "input_changed", value: text }); + this.notify( + `queue: full at ${MAX_QUEUED_MESSAGES} — the steer could not be parked (returned to the editor)`, + ); + return; + } + this.droppedWhileFull = 0; + this.queueAsSteer(text); + this.emitQueue(); + this.bus.emit({ + type: "runtime_info", + line: "steering the running turn — it cannot take this one, so it runs as the next turn", + }); + return; + } + void this.runOneTurn(text); + } + + /** + * Queue a message that was meant as a steer but could not be folded + * into the running turn — ahead of ordinary backlog, behind steers + * already re-routed for the same turn. + */ + private queueAsSteer(text: string): void { + this.queue.splice(this.steeredAhead, 0, text); + this.steeredAhead += 1; + } + + /** + * Re-route steering messages the turn accepted but never delivered. + * + * `RunTurnResult.undelivered` carries anything pushed after the loop's + * last step boundary — during the final inference, or into a turn + * cancelled before it stepped. AGENTS.md makes re-routing the caller's + * job: `steer` already answered "yes" to whoever sent these, so + * dropping them here would lose a message the operator watched being + * accepted. They go to the FRONT of the queue — ahead of + * `queueAsSteer`'s entries too: they are corrections aimed at the turn + * that just ran, and anything already queued was typed after `steer` + * had refused it. + */ + private rerouteUndelivered(undelivered: readonly string[] | undefined): void { + if (undelivered === undefined || undelivered.length === 0) return; + this.queue.unshift(...undelivered); + this.emitQueue(); + this.notify( + `${undelivered.length} message${ + undelivered.length === 1 ? "" : "s" + } arrived too late for that turn — sending ${ + undelivered.length === 1 ? "it" : "them" + } next`, + ); + } + + /** + * Drop every parked message without touching the running turn + * (`/queue clear`). No-op on an empty queue so the TUI is not spammed + * with redundant `queue_changed` frames. + */ + clearQueue(): void { + // Even an empty queue can carry a stale steer watermark. + this.steeredAhead = 0; + if (this.queue.length === 0) return; + this.queue.length = 0; + this.emitQueue(); + } + + /** + * Re-publish the pending-message queue to the TUI. The orchestrator is + * the source of truth — the reducer mirrors this list rather than + * tracking pushes and drains on its own, so an optimistic UI insert can + * never drift from what will actually run. + * + * The whole-array copy is deliberate and now bounded: the action must + * not hand subscribers a live reference to `this.queue`, and + * `MAX_QUEUED_MESSAGES` caps the copy at 20 elements per emit. Trading + * it for a push/shift/clear delta would put queue arithmetic back in + * the reducer — the exact drift this design removed. + */ + private emitQueue(): void { + this.bus.emit({ type: "queue_changed", queued: [...this.queue] }); + } + + /** + * Operator-facing notice about the queue: an event-feed line plus the + * same sentence as a warn message in the transcript, because the feed + * is not on screen in chat mode and these two events (an abort binning + * parked work, a refused submission) are things the operator typed and + * must not lose silently. + */ + private notify(line: string): void { + this.bus.emit({ type: "runtime_info", line }); + this.bus.emit({ type: "system_message", text: line, variant: "warn" }); + } + + /** + * Issue #121: a long session was killed by the V8 heap ceiling with no + * warning, losing ~40 minutes of work. V8 cannot raise its own ceiling + * after startup, so the best available remedy is to say so while there + * is still headroom to save work and restart with a bigger heap. + * Checked at turn boundaries — the crash grew across turns, including + * long idle gaps between them. + */ + private readonly heapGuard = createHeapGuard(); + + private announceHeapPressure(): void { + const status = this.heapGuard.check(); + if (status?.message) this.notify(status.message); + } + + private async runOneTurn(text: string, fromQueue = false): Promise { if (!this.session) return; + this.announceHeapPressure(); + // Pre-turn gate: a managed local model that is not on disk cannot + // serve this turn, so fail fast with the real fix instead of + // burning the transport retry budget against a daemon that cannot + // exist. Judged at turn START (not enqueue) so a message parked + // behind a running pull is re-checked when it actually runs. With a + // fallback chain of >1 link the turn still runs — failing over is + // exactly what the chain is for — and the gate only leaves a notice. + const gate = evaluateLocalTurnGate( + (this.options.readGateFacts ?? readLocalTurnGateFacts)(), + this.chatPull.current, + ); + if (gate.kind === "block") { + if (fromQueue) { + // A drained queue message has no editor to go back to (the + // operator may be mid-draft), so it is dropped — announced with + // a preview, like the abort path, never silently. + this.bus.emit({ + type: "turn_gate_blocked", + text: `${gate.text}\n dropped: ${droppedPreview(text)}`, + }); + return; + } + this.bus.emit({ + type: "turn_gate_blocked", + text: `${gate.text} (message returned to the editor)`, + }); + // Same rescue as the queue-full refusal: the optimistic submit + // already cleared the editor, so hand the text back. + this.bus.emit({ type: "input_changed", value: text }); + return; + } + if (gate.kind === "notice") this.notify(gate.text); + // The operator can switch threads while this runs; every + // this-session decision below re-checks against the id the turn + // started on rather than trusting the live pointer. + const turnSessionId = this.session.id; + this.noteFirstPrompt(text); const controller = new AbortController(); this.currentController = controller; + // Start the replay log for this turn now, before any event lands: + // a switch-back rebuilds the transcript from the STORE, which will + // not carry this turn until it finishes, so the events are the only + // way to repaint it (see `TurnEventBuffer`). + this.turnEvents.begin(turnSessionId); + // A new turn is in flight: whatever is still queued was aimed at an + // earlier one and is ordinary backlog now. + this.steeredAhead = 0; try { const result = await this.runtime.runTurn(this.session, text, { maxSteps: this.options.maxSteps, signal: controller.signal, origin: "tui", }); - this.session = result.session; - if (this.session.status === "failed") this.exitCode = 1; + const attached = this.session?.id === turnSessionId; + // A detached turn's result must not clobber the thread the + // operator switched to — its state is already saved to its own + // session by `executeTurn`. + if (attached) this.session = result.session; + // A cancelled turn means the operator stopped the agent — Esc, + // Ctrl+C or /abort. Re-queueing its undelivered steers here would + // make the post-abort drain START a turn out of them: the exact + // "Esc launches the next parked message" trap the abort path + // exists to close. Announce the drop instead, like the queue drop. + if (result.reason === "cancelled") { + const dropped = result.undelivered ?? []; + if (dropped.length > 0) { + this.notify( + [ + `aborted: dropped ${dropped.length} undelivered steer${dropped.length === 1 ? "" : "s"}`, + ...dropped.map((text, i) => ` ${i + 1}. ${droppedPreview(text)}`), + ].join("\n"), + ); + } + } else if (attached) { + this.rerouteUndelivered(result.undelivered); + } else if (result.undelivered !== undefined && result.undelivered.length > 0) { + // Detached: the visible queue feeds another thread now, so + // re-queueing would aim old-thread corrections at the new one. + // Announced with previews — never silent. + this.notify(formatDroppedSteersNotice(turnSessionId, result.undelivered)); + } + if (isFailedSessionStatus(result.session.status)) this.exitCode = 1; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - this.bus.emit({ type: "runtime_info", line: `turn error: ${msg}` }); - this.bus.emit({ - type: "system_message", - text: formatAgentErrorForChat("runtime", msg), - variant: "warn", - }); + if (this.session?.id === turnSessionId) { + this.bus.emit({ type: "runtime_info", line: `turn error: ${msg}` }); + this.bus.emit({ + type: "system_message", + text: formatAgentErrorForChat("runtime", msg), + variant: "warn", + }); + } else { + // The failure belongs to a thread that is off screen; a bare + // "turn error" would read as the visible thread's. Name it. + this.notify(formatBackgroundTurnFailed(turnSessionId, msg)); + } this.exitCode = 1; } finally { if (this.currentController === controller) this.currentController = null; + // The turn saved its session, which answers for the transcript + // now — the replay log has nothing left to add. + this.turnEvents.end(turnSessionId); + if (this.detachedTurns.release(turnSessionId, controller)) { + // End of the backgrounded turn ends its session grants — the + // deferred half of the switch-time clear (see + // `leaveCurrentSession`). + this.runtime.approvals.clearSessionGrants(turnSessionId); + } + // The turn wrote the session back, so the stored row can now + // answer for itself and the stand-in retires. + this.refreshRecentSessions(); + } + if (this.session?.id !== turnSessionId) { + // Finished in the background: the reply is saved in its own + // session (the rail just refreshed). The visible thread's queue + // is not this turn's to drain. + this.notify(formatBackgroundTurnFinished(turnSessionId)); + return; + } + if (this.reattachedMidTurn) { + // Events emitted while the operator was away were dropped by the + // reducer's session filter, so the on-screen transcript has a + // hole where this turn's tail should be. The turn just saved + // authoritative state — re-emit it the way a switch does. + this.reattachedMidTurn = false; + this.bus.emit({ + type: "session_switched", + sessionId: turnSessionId, + workingDir: this.session.workingDir, + messages: turnsToMessages(this.session.turns), + }); } const next = this.queue.shift(); + // Unconditional: the idle boundary re-syncs the strip even when + // nothing drained, so an optimistic UI insert can never outlive the + // turn it was parked behind. + this.emitQueue(); if (next !== undefined && !this.quitting) { - void this.runOneTurn(next); + void this.runOneTurn(next, true); } } @@ -468,19 +1138,48 @@ export class ChatOrchestrator { return ids.slice(0, DEBUG_BUNDLE_TRACE_LIMIT); } + /** + * Esc / Ctrl+C / `/abort` — stop the agent, not merely this turn. + * + * Discarding the queue is the whole point. `runOneTurn` catches the + * abort rejection and falls straight through to `this.queue.shift()`, + * so an intact backlog turned Esc into "start the next parked + * message"; stopping a wrong run cost one Esc per parked message. + * Clear first, then abort — the same order `quit()` uses below. + * + * The drop is announced: the operator typed those messages, so binning + * N of them silently is worse than one line in the transcript. + */ abortCurrentTurn(): void { + const dropped = [...this.queue]; + if (dropped.length > 0) { + this.queue.length = 0; + this.droppedWhileFull = 0; + this.emitQueue(); + // The operator typed those messages; a bare count would bin their + // words with no way back. The transcript line carries a preview of + // each so anything worth keeping can be copied out. + this.notify( + [ + `aborted: dropped ${dropped.length} parked message${dropped.length === 1 ? "" : "s"}`, + ...dropped.map((text, i) => ` ${i + 1}. ${droppedPreview(text)}`), + ].join("\n"), + ); + } this.currentController?.abort(); } quit(): void { if (this.quitting) return; this.quitting = true; - this.queue.length = 0; + this.clearQueue(); this.currentController?.abort(); + this.detachedTurns.abortAll(); } async shutdown(): Promise { this.abortCurrentTurn(); + this.detachedTurns.abortAll(); this.tasks.shutdown(); this.skills.shutdown(); this.memory.shutdown(); @@ -499,6 +1198,25 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } +/** + * Has anyone spoken to this session? The rail and the picker list only + * threads that have a first user turn — that turn is what gives a + * session its name, and an unnamed row is indistinguishable from every + * other unnamed row. + */ +/** Rows the rail and the picker show. */ +const RAIL_SESSION_LIMIT = 25; +/** + * How deep to read before filtering. Generous rather than exact: unnamed + * sessions accumulate (one per `+ new`, one per scheduled task) and each + * one would otherwise cost a real thread its place in the list. + */ +const RAIL_SCAN_LIMIT = 200; + +function hasFirstPrompt(state: SessionState): boolean { + return state.turns.some((turn) => turn.kind === "user"); +} + function toPickerEntry(state: SessionState): SessionPickerEntry { const firstUser = state.turns.find((t) => t.kind === "user"); const preview = firstUser && firstUser.kind === "user" ? firstUser.text : ""; diff --git a/src/tui/clipboard/clipboard-context.tsx b/src/tui/clipboard/clipboard-context.tsx new file mode 100644 index 00000000..873974e1 --- /dev/null +++ b/src/tui/clipboard/clipboard-context.tsx @@ -0,0 +1,98 @@ +/** + * React access to the clipboard writer. + * + * Shaped like `mouse-context.tsx` and for the same reason: the chat + * bubbles are presentational and prop-drilling a writer down through + * `ChatLog` → `FinalisedMessage` → every bubble would be a bigger change + * than the feature earns. + * + * Unlike the mouse context there is a **default** when no provider is + * mounted, because a copy button with no clipboard is not a degraded + * button, it is a broken one. The default is created lazily and shared, + * so the common case — the real TUI, which mounts no provider — needs no + * wiring at all. `createClipboardWriter` refuses to act on a non-TTY + * stdout, which is what keeps that default from touching a real human's + * clipboard when a component test happens to render a copy button. + * + * Tests that want to *observe* a copy mount `ClipboardProvider` with a + * fake and get an exact record of what was copied. + */ +import { createContext, useContext, type ReactElement, type ReactNode } from "react"; +import { + createClipboardWriter, + type ClipboardWriter, +} from "./copy-to-clipboard.js"; +import { + createClipboardReader, + type ClipboardReader, +} from "./read-clipboard.js"; + +const ClipboardContext = createContext(null); +const ClipboardReaderContext = createContext(null); + +let defaultWriter: ClipboardWriter | null = null; +let defaultReader: ClipboardReader | null = null; + +/** + * The process-wide writer used when no provider is mounted. Lazy so that + * merely importing a chat component does not read `process.platform` or + * capture a `process.stdout` that a harness may still replace. + */ +export function getDefaultClipboardWriter(): ClipboardWriter { + defaultWriter ??= createClipboardWriter(); + return defaultWriter; +} + +export interface ClipboardProviderProps { + readonly writer: ClipboardWriter; + readonly children: ReactNode; +} + +export function ClipboardProvider({ + writer, + children, +}: ClipboardProviderProps): ReactElement { + return ( + + {children} + + ); +} + +/** The active clipboard writer — the provider's, or the shared default. */ +export function useClipboard(): ClipboardWriter { + return useContext(ClipboardContext) ?? getDefaultClipboardWriter(); +} + +/** + * The reader mirrors the writer's wiring exactly, and for the same + * reasons: a lazy shared default so the real TUI needs no provider, a + * non-TTY guard inside `createClipboardReader` so a component test that + * happens to trigger a paste never reads the developer's actual + * clipboard, and a provider for tests that want to hand paste a text. + */ +export function getDefaultClipboardReader(): ClipboardReader { + defaultReader ??= createClipboardReader(); + return defaultReader; +} + +export interface ClipboardReaderProviderProps { + readonly reader: ClipboardReader; + readonly children: ReactNode; +} + +export function ClipboardReaderProvider({ + reader, + children, +}: ClipboardReaderProviderProps): ReactElement { + return ( + + {children} + + ); +} + +/** The active clipboard reader — the provider's, or the shared default. */ +export function useClipboardReader(): ClipboardReader { + return useContext(ClipboardReaderContext) ?? getDefaultClipboardReader(); +} diff --git a/src/tui/clipboard/copy-to-clipboard.test.ts b/src/tui/clipboard/copy-to-clipboard.test.ts new file mode 100644 index 00000000..1c5fa595 --- /dev/null +++ b/src/tui/clipboard/copy-to-clipboard.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { + createClipboardWriter, + createNullClipboardWriter, + fitsInOsc52, + osc52Sequence, + platformClipboardCommand, + OSC52_MAX_BASE64_CHARS, + type ClipboardCommandRunner, +} from "./copy-to-clipboard.js"; + +interface FakeStdout { + isTTY: boolean; + writes: string[]; + write(chunk: string): boolean; +} + +function makeStdout(isTty: boolean): FakeStdout { + const writes: string[] = []; + return { + isTTY: isTty, + writes, + write(chunk: string): boolean { + writes.push(chunk); + return true; + }, + }; +} + +interface RunLog { + runner: ClipboardCommandRunner; + calls: Array<{ command: string; args: readonly string[]; text: string }>; +} + +function makeRunner(result: boolean): RunLog { + const calls: RunLog["calls"] = []; + return { + calls, + runner: async (command, args, text) => { + calls.push({ command, args, text }); + return result; + }, + }; +} + +describe("osc52Sequence", () => { + it("wraps base64 in the OSC 52 clipboard sequence", () => { + expect(osc52Sequence("hi")).toBe("\u001B]52;c;aGk=\u0007"); + }); + + it("encodes non-ASCII as UTF-8 bytes, not UTF-16 units", () => { + // A terminal decodes the payload as bytes; encoding "é" as its + // UTF-16 code unit would paste a replacement character. + expect(osc52Sequence("é")).toBe( + `\u001B]52;c;${Buffer.from("é", "utf8").toString("base64")}\u0007`, + ); + }); + + it("carries newlines through untouched", () => { + const decoded = Buffer.from( + osc52Sequence("a\nb").slice("\u001B]52;c;".length, -1), + "base64", + ).toString("utf8"); + expect(decoded).toBe("a\nb"); + }); +}); + +describe("fitsInOsc52", () => { + it("accepts an ordinary chat message", () => { + expect(fitsInOsc52("a normal reply")).toBe(true); + }); + + it("rejects a payload past the terminal-safe ceiling", () => { + const tooBig = "x".repeat(OSC52_MAX_BASE64_CHARS); + expect(fitsInOsc52(tooBig)).toBe(false); + }); +}); + +describe("platformClipboardCommand", () => { + it("uses pbcopy on macOS", () => { + expect(platformClipboardCommand("darwin", {})).toEqual({ + command: "pbcopy", + args: [], + }); + }); + + it("uses clip on Windows", () => { + expect(platformClipboardCommand("win32", {})?.command).toBe("clip"); + }); + + it("prefers wl-copy over xclip when both sessions advertise themselves", () => { + const command = platformClipboardCommand("linux", { + WAYLAND_DISPLAY: "wayland-0", + DISPLAY: ":0", + }); + expect(command?.command).toBe("wl-copy"); + }); + + it("falls back to xclip under X11", () => { + expect(platformClipboardCommand("linux", { DISPLAY: ":0" })).toEqual({ + command: "xclip", + args: ["-selection", "clipboard"], + }); + }); + + it("has nothing to offer on a headless box", () => { + // Not a failure: OSC 52 is the correct — and only — route back to + // the clipboard of whoever is on the other end of the ssh pipe. + expect(platformClipboardCommand("linux", {})).toBeNull(); + }); +}); + +describe("createClipboardWriter", () => { + it("emits OSC 52 and runs the platform command for one copy", () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + return writer.copy("hello").then((ok) => { + expect(ok).toBe(true); + expect(stdout.writes).toEqual([osc52Sequence("hello")]); + expect(run.calls).toEqual([ + { command: "pbcopy", args: [], text: "hello" }, + ]); + }); + }); + + it("still reports success when the platform command fails but OSC 52 went out", () => { + // The SSH case: pbcopy would target the wrong machine anyway, and a + // terminal that honoured OSC 52 has the text. + const stdout = makeStdout(true); + const run = makeRunner(false); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + return writer.copy("hello").then((ok) => expect(ok).toBe(true)); + }); + + it("reports success from the platform command alone when the payload is too big for OSC 52", async () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + const huge = "x".repeat(OSC52_MAX_BASE64_CHARS); + expect(await writer.copy(huge)).toBe(true); + expect(stdout.writes).toEqual([]); + expect(run.calls[0]?.text).toBe(huge); + }); + + it("reports failure when there is no platform command and the payload is too big", async () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "linux", + env: {}, + }); + expect(await writer.copy("x".repeat(OSC52_MAX_BASE64_CHARS))).toBe(false); + expect(run.calls).toEqual([]); + }); + + it("does nothing at all when stdout is not a TTY", async () => { + // This guard is what keeps `npx vitest` from overwriting the + // clipboard of whoever is running the suite. + const stdout = makeStdout(false); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + expect(await writer.copy("hello")).toBe(false); + expect(stdout.writes).toEqual([]); + expect(run.calls).toEqual([]); + }); + + it("falls back to the platform command when the stdout write throws", async () => { + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout: { + isTTY: true, + write: () => { + throw new Error("EIO"); + }, + }, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + expect(await writer.copy("hello")).toBe(true); + expect(run.calls).toHaveLength(1); + }); +}); + +describe("createNullClipboardWriter", () => { + it("always reports failure", async () => { + expect(await createNullClipboardWriter().copy("hello")).toBe(false); + }); +}); diff --git a/src/tui/clipboard/copy-to-clipboard.ts b/src/tui/clipboard/copy-to-clipboard.ts new file mode 100644 index 00000000..fe2fdd62 --- /dev/null +++ b/src/tui/clipboard/copy-to-clipboard.ts @@ -0,0 +1,211 @@ +/** + * Writing to the *user's* clipboard from a TUI. + * + * There is no single mechanism that works everywhere, and the two that + * exist fail in exactly opposite situations — so this module runs both + * and reports success if either one landed. + * + * - **OSC 52** (`ESC ] 52 ; c ; BEL`) asks the terminal + * emulator itself to set the clipboard. It is the only mechanism + * that survives SSH: the bytes travel back up the same pty the + * frames come down, so the text lands on the machine the human is + * sitting at rather than on the box the agent happens to run on. + * Its weakness is that it is advisory — the terminal may ignore it + * (Apple Terminal does), gate it behind a preference (iTerm2's + * "Applications in terminal may access clipboard"), or swallow it + * in a multiplexer (tmux needs `set -g set-clipboard on`; GNU screen + * needs DCS wrapping we do not emit). Crucially, **there is no + * reply**: a terminal that ignores 52 is indistinguishable from one + * that honoured it, so we can never report "OSC 52 worked". + * - **The platform clipboard command** (`pbcopy`, `wl-copy`, `xclip`, + * `clip.exe`) is authoritative — it either exits 0 or it does not — + * but it writes to the clipboard of the machine the *process* runs + * on, which is the wrong machine over SSH, and it does not exist at + * all on a headless box. + * + * Doing both is not belt-and-braces sloppiness; it is the only way to + * cover Apple Terminal (native only) and a remote session (OSC 52 only) + * with one code path. Writing the same string twice is harmless: the + * clipboard ends up holding that string either way. + * + * Safety of interleaving OSC 52 with Ink's frames: the sequence moves no + * cursor, sets no mode, and paints no cell, so a terminal that + * understands it consumes it invisibly wherever it lands between Ink's + * writes, and one that does not silently drops an unknown OSC. That is + * why it can be written straight to the same stdout Ink is rendering to + * without coordinating with the renderer or leaving the alt screen. + * + * Everything the writer touches — stdout, process spawning, platform, + * env — is injected, so tests exercise the real decision logic without + * going anywhere near the developer's actual clipboard. + */ +import { spawn } from "node:child_process"; + +export interface ClipboardWriter { + /** + * Copies `text`. Resolves `true` when at least one mechanism is + * believed to have worked — see {@link createClipboardWriter} for what + * "believed" can and cannot mean. + */ + copy(text: string): Promise; +} + +/** Minimal shape of the stream OSC 52 is written to. */ +export interface ClipboardStdout { + write(chunk: string): unknown; + readonly isTTY?: boolean; +} + +/** Runs a clipboard command with `text` on stdin; resolves `true` on exit 0. */ +export type ClipboardCommandRunner = ( + command: string, + args: readonly string[], + text: string, +) => Promise; + +export interface ClipboardWriterOptions { + readonly stdout?: ClipboardStdout; + readonly runCommand?: ClipboardCommandRunner; + readonly platform?: NodeJS.Platform; + readonly env?: Readonly>; +} + +export interface ClipboardCommand { + readonly command: string; + readonly args: readonly string[]; +} + +/** + * Terminals differ on how much base64 they will accept in one OSC 52, + * and the ones that dislike a long payload tend to drop it *silently* + * rather than truncate — which would leave the user with a stale + * clipboard and a cheerful "copied!". Past this size we skip OSC 52 and + * let the platform command carry the copy alone; a paste that big is + * overwhelmingly a local one anyway. + */ +export const OSC52_MAX_BASE64_CHARS = 100_000; + +/** BEL terminator: accepted everywhere `ESC \` is, and by a few terminals that mis-parse ST. */ +const BEL = "\u0007"; + +/** The OSC 52 sequence that sets the system clipboard (`c`) to `text`. */ +export function osc52Sequence(text: string): string { + const payload = Buffer.from(text, "utf8").toString("base64"); + return `\u001B]52;c;${payload}${BEL}`; +} + +/** `true` when `text` is small enough to be worth sending as OSC 52. */ +export function fitsInOsc52(text: string): boolean { + // 4 base64 chars per 3 input bytes, rounded up — cheaper than encoding + // a megabyte of transcript just to find out it is too big. + const bytes = Buffer.byteLength(text, "utf8"); + return Math.ceil(bytes / 3) * 4 <= OSC52_MAX_BASE64_CHARS; +} + +/** + * The platform's clipboard command, or `null` when there is none worth + * trying. On Linux the answer depends on the *session*, not the OS: + * `wl-copy` under Wayland, `xclip` under X11, and nothing at all on a + * headless box — where returning `null` is the honest answer and OSC 52 + * is the only route back to the human's clipboard. + */ +export function platformClipboardCommand( + platform: NodeJS.Platform, + env: Readonly>, +): ClipboardCommand | null { + if (platform === "darwin") return { command: "pbcopy", args: [] }; + if (platform === "win32") return { command: "clip", args: [] }; + if (env.WAYLAND_DISPLAY) return { command: "wl-copy", args: [] }; + if (env.DISPLAY) { + return { command: "xclip", args: ["-selection", "clipboard"] }; + } + return null; +} + +/** + * Default runner: spawns the command, feeds `text` on stdin, resolves on + * the exit code. A missing binary surfaces as an `error` event rather + * than a non-zero exit, so both collapse to `false` — the caller only + * ever needs "did the clipboard change". + */ +const spawnClipboardCommand: ClipboardCommandRunner = ( + command, + args, + text, +) => + new Promise((resolve) => { + let settled = false; + const done = (ok: boolean): void => { + if (settled) return; + settled = true; + resolve(ok); + }; + try { + const child = spawn(command, [...args], { + stdio: ["pipe", "ignore", "ignore"], + }); + child.on("error", () => done(false)); + child.on("close", (code) => done(code === 0)); + // EPIPE here means the child died before reading — `close` already + // has that case covered, so the write error is not interesting. + child.stdin?.on("error", () => {}); + child.stdin?.end(text); + } catch { + done(false); + } + }); + +/** + * Builds the clipboard writer used by the TUI. + * + * `copy` resolves `true` if the platform command succeeded, **or** if we + * emitted OSC 52 to a TTY. The second half is optimism, and deliberately + * so: OSC 52 never answers, so the alternative is to report failure on + * every terminal that only supports OSC 52 (i.e. every SSH session), + * which would be wrong far more often than the optimism is. A stale + * clipboard is recoverable; a "copy failed" badge on a copy that worked + * teaches the user the button is broken. + * + * When stdout is not a TTY nothing is attempted at all. There is no + * terminal to talk to, and — the reason this guard matters in practice — + * it keeps every non-interactive run, the test suite included, from + * reaching out and overwriting a real human's clipboard. + */ +export function createClipboardWriter( + options: ClipboardWriterOptions = {}, +): ClipboardWriter { + const stdout = options.stdout ?? process.stdout; + const runCommand = options.runCommand ?? spawnClipboardCommand; + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + return { + async copy(text: string): Promise { + if (stdout.isTTY !== true) return false; + let claimed = false; + if (fitsInOsc52(text)) { + try { + stdout.write(osc52Sequence(text)); + claimed = true; + } catch { + // A stdout that rejects a write is a dead terminal; the + // platform command may still be able to do the job. + } + } + const command = platformClipboardCommand(platform, env); + if (command) { + const ok = await runCommand(command.command, command.args, text); + claimed = claimed || ok; + } + return claimed; + }, + }; +} + +/** + * A writer that does nothing and reports failure. Used where a clipboard + * is structurally unavailable, and as the explicit stand-in in tests + * that must not touch a real one. + */ +export function createNullClipboardWriter(): ClipboardWriter { + return { copy: async () => false }; +} diff --git a/src/tui/clipboard/index.ts b/src/tui/clipboard/index.ts new file mode 100644 index 00000000..332a69af --- /dev/null +++ b/src/tui/clipboard/index.ts @@ -0,0 +1,31 @@ +export { + createClipboardWriter, + createNullClipboardWriter, + fitsInOsc52, + osc52Sequence, + platformClipboardCommand, + OSC52_MAX_BASE64_CHARS, + type ClipboardCommand, + type ClipboardCommandRunner, + type ClipboardStdout, + type ClipboardWriter, + type ClipboardWriterOptions, +} from "./copy-to-clipboard.js"; +export { + ClipboardProvider, + ClipboardReaderProvider, + getDefaultClipboardReader, + getDefaultClipboardWriter, + useClipboard, + useClipboardReader, + type ClipboardProviderProps, + type ClipboardReaderProviderProps, +} from "./clipboard-context.js"; +export { + createClipboardReader, + createNullClipboardReader, + createStaticClipboardReader, + type ClipboardReader, + type ClipboardReaderOptions, + type ClipboardReadFn, +} from "./read-clipboard.js"; diff --git a/src/tui/clipboard/read-clipboard.ts b/src/tui/clipboard/read-clipboard.ts new file mode 100644 index 00000000..312a2cc4 --- /dev/null +++ b/src/tui/clipboard/read-clipboard.ts @@ -0,0 +1,66 @@ +/** + * Reading the system clipboard for the TUI's paste actions. + * + * Writing (`copy-to-clipboard.ts`) hand-rolls OSC 52 + platform + * commands because a write must survive SSH. Reading cannot: OSC 52 + * reads are disabled by default in every terminal that matters + * (arbitrary clipboard exfiltration), so the only honest source is the + * machine the process runs on — which is exactly what `clipboardy` + * (already a direct dependency, used by the agent's `os.clipboard` + * tool) implements per platform. The import is dynamic for the same + * reason it is in `src/tools/os/clipboard.ts`: clipboardy is ESM-only + * and probes the session (X11/Wayland) at load time. + */ + +export interface ClipboardReader { + /** Resolves the clipboard text, or `""` when nothing readable exists. */ + read(): Promise; +} + +/** Injection point for tests: whatever supplies the clipboard text. */ +export type ClipboardReadFn = () => Promise; + +export interface ClipboardReaderOptions { + /** + * Same guard the writer uses: on a non-TTY stdout (the test runner, + * a piped run) the reader answers `""` instead of touching the real + * clipboard of whoever happens to be running the process. + */ + readonly stdout?: { readonly isTTY?: boolean }; + readonly readText?: ClipboardReadFn; +} + +const clipboardyRead: ClipboardReadFn = async () => { + const mod = await import("clipboardy"); + return mod.default.read(); +}; + +/** The reader the real TUI uses. */ +export function createClipboardReader( + options: ClipboardReaderOptions = {}, +): ClipboardReader { + const stdout = options.stdout ?? process.stdout; + const readText = options.readText ?? clipboardyRead; + return { + async read(): Promise { + if (stdout.isTTY !== true) return ""; + try { + return await readText(); + } catch { + // No clipboard (headless box, missing xclip): paste is simply + // empty rather than an error the operator cannot act on. + return ""; + } + }, + }; +} + +/** A reader with nothing in it — the stand-in where paste must no-op. */ +export function createNullClipboardReader(): ClipboardReader { + return { read: async () => "" }; +} + +/** A reader that always answers `text` — for tests that observe a paste. */ +export function createStaticClipboardReader(text: string): ClipboardReader { + return { read: async () => text }; +} diff --git a/src/tui/coding-mode-menu.test.tsx b/src/tui/coding-mode-menu.test.tsx new file mode 100644 index 00000000..a95b13b5 --- /dev/null +++ b/src/tui/coding-mode-menu.test.tsx @@ -0,0 +1,185 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; + +import { CODING_MODES, codingModeLook } from "./coding-mode.js"; +import { CodingModePopup } from "./components/coding-mode-popup.js"; +import { reduceUiAction } from "./reduce-ui-actions.js"; +import { createInitialTuiState, type TuiSessionInfo, type TuiState } from "./tui-state.js"; + +function session(): TuiSessionInfo { + return { + sessionId: "s1", + workingDir: "/tmp/w", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chromium", + browserHeadless: true, + approvalLevel: 1, + maxSteps: 8, + skillCount: 0, + }; +} + +function stateWith(overrides: Partial = {}): TuiState { + return { ...createInitialTuiState(session()), ...overrides }; +} + +function apply(state: TuiState, action: Parameters[1]): TuiState { + return reduceUiAction(state, action) ?? state; +} + +/** + * The popup is absolutely positioned, so it needs a `position="relative"` + * pane with a real height to sit inside — exactly the geometry + * `tui-app.tsx` gives it, and the same wrapper + * `composer-switch-popup.test.tsx` uses. Rendered bare in a plain Box it + * measures to nothing and every assertion below would read an empty + * string. + */ +function frame(cursor: number, columns = 76, rows = 12): string { + const { lastFrame, unmount } = render( + + {Array.from({ length: rows }, (_unused, row) => ( + {"·".repeat(columns)} + ))} + {}} + /> + , + ); + const out = (lastFrame() ?? "").replace(/\[[0-9;]*m/g, ""); + unmount(); + return out; +} + +/** + * The chip used to advance the ring on click. That made the one control + * that changes what the agent is *allowed to do* the only one with no + * confirmation and no explanation — two stray clicks took you from + * `plan` to `auto` with nothing on screen saying what either + * meant. + */ +describe("the coding-mode menu", () => { + it("shows every description in full, never truncated", () => { + // The whole reason the menu exists. A description with its end + // shaved off reads as a rendering bug and still does not answer the + // question — so the menu is sized from its content rather than the + // content being cut to fit the menu. + const body = frame(0); + for (const mode of CODING_MODES) { + const look = codingModeLook(mode); + expect(body, `${mode} label`).toContain(look.label); + expect(body, `${mode} detail`).toContain(look.detail); + } + expect(body).not.toContain("…"); + }); + + it("stacks the description rather than cutting it when the pane is narrow", () => { + // Too narrow for two columns is not a licence to truncate: the + // detail moves to its own indented line and stays whole. + const body = frame(0, 40, 14); + for (const mode of CODING_MODES) { + expect(body, `${mode} detail`).toContain(codingModeLook(mode).detail); + } + expect(body).not.toContain("…"); + }); + + it("hangs off the right edge, where the chip is", () => { + // The chip sits at the far end of the composer's bar. A menu that + // dropped from the opposite corner would read as belonging to + // something else. + const columns = 90; + const lines = frame(0, columns, 12).split("\n"); + const top = lines.find((l) => l.includes("╭")); + expect(top).toBeDefined(); + const right = (top as string).indexOf("╮"); + expect(right).toBeGreaterThanOrEqual(columns - 2); + }); + + it("marks the mode in force", () => { + expect(frame(0)).toMatch(/✓\s*default/); + }); + + it("keeps the four rows even when the pane is too short for chrome", () => { + // Title and footer are ornament; the rows are the content. + const short = frame(0, 76, 6); + for (const mode of CODING_MODES) { + expect(short, `${mode} survived`).toContain(codingModeLook(mode).label); + } + }); + + it("never draws wider or taller than the pane it was given", () => { + for (const [columns, rows] of [[70, 12], [40, 8], [30, 6]] as const) { + const lines = frame(0, columns, rows).split("\n"); + const widest = lines.reduce((a, l) => Math.max(a, l.length), 0); + expect(widest, `${columns}x${rows} width`).toBeLessThanOrEqual(columns); + } + }); +}); + +describe("driving the menu", () => { + it("opens seeded on the mode in force", () => { + // The menu opens as a statement of where you are before it is a list + // of where you could go. + const state = apply( + stateWith({ codingMode: "auto" }), + { type: "coding_mode_menu_opened" }, + ); + expect(state.codingModeMenu?.cursor).toBe( + CODING_MODES.indexOf("auto"), + ); + }); + + it("wraps the cursor in both directions", () => { + let state = apply(stateWith(), { type: "coding_mode_menu_opened" }); + expect(state.codingModeMenu?.cursor).toBe(0); + state = apply(state, { type: "coding_mode_menu_cursor_moved", delta: -1 }); + expect(state.codingModeMenu?.cursor).toBe(CODING_MODES.length - 1); + state = apply(state, { type: "coding_mode_menu_cursor_moved", delta: 1 }); + expect(state.codingModeMenu?.cursor).toBe(0); + }); + + it("applies a mode and closes", () => { + let state = apply(stateWith(), { type: "coding_mode_menu_opened" }); + state = apply(state, { type: "coding_mode_cycled", mode: "plan" }); + expect(state.codingMode).toBe("plan"); + expect(state.codingModeMenu).toBeNull(); + }); + + it("closes even when the row picked is the one already in force", () => { + // Picking the row you are on is a decision too; leaving the popup up + // would read as the click not landing. + let state = apply( + stateWith({ codingMode: "plan" }), + { type: "coding_mode_menu_opened" }, + ); + state = apply(state, { type: "coding_mode_cycled", mode: "plan" }); + expect(state.codingMode).toBe("plan"); + expect(state.codingModeMenu).toBeNull(); + }); + + it("cancels without changing the mode", () => { + let state = apply( + stateWith({ codingMode: "default" }), + { type: "coding_mode_menu_opened" }, + ); + state = apply(state, { type: "coding_mode_menu_cursor_moved", delta: 1 }); + state = apply(state, { type: "coding_mode_menu_closed" }); + expect(state.codingModeMenu).toBeNull(); + // Moving the cursor previews nothing — unlike the theme picker, the + // mode only changes when a row is actually chosen. + expect(state.codingMode).toBe("default"); + }); + + it("ignores a cursor move when the menu is shut", () => { + const state = apply(stateWith(), { + type: "coding_mode_menu_cursor_moved", + delta: 1, + }); + expect(state.codingModeMenu).toBeNull(); + }); +}); diff --git a/src/tui/coding-mode.test.ts b/src/tui/coding-mode.test.ts new file mode 100644 index 00000000..ce8151ce --- /dev/null +++ b/src/tui/coding-mode.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + CODING_MODES, + codingModeLook, + cycleCodingMode, + resolveCodingMode, +} from "./coding-mode.js"; + +/** How many presses apart two modes are, going the short way round. */ +function ringDistance(a: string, b: string): number { + const raw = Math.abs(CODING_MODES.indexOf(a as never) - CODING_MODES.indexOf(b as never)); + return Math.min(raw, CODING_MODES.length - raw); +} + +describe("the coding-mode ring", () => { + it("puts plan next to default, and bypass as far from plan as the ring allows", () => { + expect([...CODING_MODES]).toEqual([ + "default", + "plan", + "auto", + "bypass", + ]); + // The ring *wraps*, which is what makes severity order wrong: it + // would leave `bypass` one backward press from `plan`, and `plan` + // is exactly where a careful operator parks. Measured the way the + // ring is actually walked. + expect(ringDistance("plan", "bypass")).toBe(2); + expect(ringDistance("default", "plan")).toBe(1); + // Two is the maximum separation available on a four-mode ring. + expect(ringDistance("plan", "bypass")).toBe( + Math.floor(CODING_MODES.length / 2), + ); + }); + + it("cycles both ways and wraps", () => { + expect(cycleCodingMode("default")).toBe("plan"); + expect(cycleCodingMode("bypass")).toBe("default"); + expect(cycleCodingMode("default", true)).toBe("bypass"); + expect(cycleCodingMode("plan", true)).toBe("default"); + }); + + it("never lands on bypass by one press from plan, in either direction", () => { + // The property the order exists for, stated directly. + expect(cycleCodingMode("plan")).not.toBe("bypass"); + expect(cycleCodingMode("plan", true)).not.toBe("bypass"); + }); + + it("recovers from a mode that is not in the ring", () => { + expect(cycleCodingMode("nonsense" as never)).toBe("plan"); + }); + + it("gives every mode a label and a tone", () => { + for (const mode of CODING_MODES) { + const look = codingModeLook(mode); + expect(look.label.length).toBeGreaterThan(0); + expect(look.summary.length).toBeGreaterThan(0); + } + // Plan is the *safest* mode; painting the careful choice in a + // hazard colour would be backwards. + expect(codingModeLook("plan").tone).toBe("accent"); + expect(codingModeLook("bypass").tone).toBe("error"); + }); +}); + +describe("what a mode means to the runtime", () => { + it("restores the configured level on the way back to default", () => { + // The reason `baseLevel` is a parameter rather than a constant: an + // operator who configured level 3, visited bypass and came back + // must land on 3, not on a hardcoded 1. + for (const base of [1, 2, 3, 4, 5] as const) { + expect(resolveCodingMode("default", base)).toEqual({ + approvalLevel: base, + planMode: false, + }); + } + }); + + it("raises to workspace writes for auto, and never lowers", () => { + expect(resolveCodingMode("auto", 1).approvalLevel).toBe(2); + expect(resolveCodingMode("auto", 2).approvalLevel).toBe(2); + // Someone already at 4 asking for auto is asking for at + // least that. Clamping them down to 2 would surprise them in the + // direction that costs prompts. + expect(resolveCodingMode("auto", 4).approvalLevel).toBe(4); + expect(resolveCodingMode("auto", 5).approvalLevel).toBe(5); + }); + + it("opens the ladder all the way for bypass", () => { + expect(resolveCodingMode("bypass", 1)).toEqual({ + approvalLevel: 5, + planMode: false, + }); + }); + + it("leaves the ladder exactly where it was for plan", () => { + // Plan mode refuses mutations outright, so the level it would have + // asked at is moot — and not touching it is what lets `default` + // restore without remembering anything extra. + for (const base of [1, 3, 5] as const) { + expect(resolveCodingMode("plan", base)).toEqual({ + approvalLevel: base, + planMode: true, + }); + } + }); + + it("turns plan mode off for every other mode", () => { + for (const mode of CODING_MODES) { + expect(resolveCodingMode(mode, 1).planMode).toBe(mode === "plan"); + } + }); +}); diff --git a/src/tui/coding-mode.ts b/src/tui/coding-mode.ts new file mode 100644 index 00000000..c998cd26 --- /dev/null +++ b/src/tui/coding-mode.ts @@ -0,0 +1,154 @@ +import { + clampApprovalLevel, + MAX_APPROVAL_LEVEL, + type ApprovalLevel, +} from "../approval/approval-level.js"; + +/** + * The stance the operator is working in, as one control. + * + * The machinery for three of these already existed and was spread across + * two places that do not look like each other: the five-step approval + * ladder on the Privacy tab, and (as of this change) plan mode in the + * agent loop. Neither is somewhere you go mid-thought. "Let it edit + * without asking for the next ten minutes" and "read only, tell me what + * you would do" are decisions made *while typing*, and a decision made + * while typing needs to be one keystroke from the composer. + * + * So this is a projection, not a new subsystem. Each mode resolves to an + * approval level and a plan-mode flag; nothing else in the app learns a + * new concept, and the Privacy tab keeps working exactly as it did. + */ +export type CodingMode = "default" | "plan" | "auto" | "bypass"; + +/** + * Cycle order, and it is not the order of severity. + * + * Severity order — `plan, default, auto, bypass` — reads well + * and is wrong, because the ring *wraps*: on four modes it leaves + * `bypass` one backward press from `plan`, which is exactly where a + * careful operator parks. Two keys apart is the most a four-ring + * allows, and putting `plan` at index 1 and `bypass` at index 3 gets + * it while keeping `default` and `plan` adjacent — the pair people + * actually move between — and keeping the first forward press from + * `default` the *safe* one. + */ +export const CODING_MODES: readonly CodingMode[] = [ + "default", + "plan", + "auto", + "bypass", +]; + +export interface CodingModeLook { + /** What the chip prints. */ + readonly label: string; + /** + * The second column of the menu: what picking this row would mean. + * + * Kept short enough that the menu can be sized to show all four in + * full — the popup measures these rather than truncating them, so a + * long one here does not get an ellipsis, it makes the whole menu + * wider. An explanation with its end cut off is worse than no + * explanation: it reads as a bug and it still does not answer the + * question. + * + * Separate from `summary` on purpose. The summary is a sentence the + * chat log prints once, after the fact; this is a label read *while + * deciding*, next to three alternatives. + */ + readonly detail: string; + /** Which palette role paints the chip's ground. */ + readonly tone: "accent" | "success" | "warn" | "error"; + /** One line for the system message on switching. */ + readonly summary: string; +} + +const LOOKS: Readonly> = { + plan: { + label: "plan", + detail: "reads only, then proposes", + tone: "accent", + summary: + "plan mode — the agent reads and proposes, and every tool that would change something is refused", + }, + default: { + label: "default", + detail: "asks before risky steps", + tone: "success", + summary: "default — approvals follow the level set on the Privacy tab", + }, + auto: { + label: "auto", + detail: "edits this folder freely", + tone: "warn", + summary: + "auto — file writes inside this workspace stop asking; everything else still does", + }, + bypass: { + label: "bypass permissions", + detail: "never asks at all", + tone: "error", + summary: + "bypass permissions — nothing asks, for the rest of this session. Hardline shell-guard rules still block.", + }, +}; + +export function codingModeLook(mode: CodingMode): CodingModeLook { + return LOOKS[mode]; +} + +export interface ResolvedCodingMode { + readonly approvalLevel: ApprovalLevel; + readonly planMode: boolean; +} + +/** + * What a mode means to the runtime. + * + * `baseLevel` is the level the operator actually configured — the one + * the Privacy tab shows and `config.json` holds. It is a parameter + * rather than a constant because `default` has to *restore* it: a + * session that went to `bypass` and back must land on the level it + * started from, not on a hardcoded 1, or the control would quietly + * tighten every operator who had chosen otherwise. + * + * `auto` raises to level 2 (workspace file writes stop asking) but never + * *lowers*: an operator already at 4 who asks for auto is asking for at + * least that, and clamping them down to 2 would be a surprise in the + * direction that costs them prompts. + */ +export function resolveCodingMode( + mode: CodingMode, + baseLevel: ApprovalLevel, +): ResolvedCodingMode { + switch (mode) { + case "plan": + // The ladder is left exactly where it was. Plan mode refuses + // mutations outright, so the level it would have asked at is + // moot — and leaving it alone is what lets `default` restore it + // without remembering anything extra. + return { approvalLevel: baseLevel, planMode: true }; + case "auto": + return { + approvalLevel: clampApprovalLevel(Math.max(baseLevel, 2)), + planMode: false, + }; + case "bypass": + return { approvalLevel: MAX_APPROVAL_LEVEL, planMode: false }; + case "default": + return { approvalLevel: baseLevel, planMode: false }; + } +} + +/** The next mode in the ring; `back` walks it the other way. */ +export function cycleCodingMode( + mode: CodingMode, + back = false, +): CodingMode { + const index = CODING_MODES.indexOf(mode); + const from = index === -1 ? 0 : index; + const step = back ? -1 : 1; + const next = (from + step + CODING_MODES.length) % CODING_MODES.length; + return CODING_MODES[next]!; +} diff --git a/src/tui/commands/slash-command-handler.test.ts b/src/tui/commands/slash-command-handler.test.ts index 98563a41..ae4d5c46 100644 --- a/src/tui/commands/slash-command-handler.test.ts +++ b/src/tui/commands/slash-command-handler.test.ts @@ -33,6 +33,11 @@ describe("dispatchSlashCommand", () => { expect(exit.triggerQuit).toBe(true); }); + /** + * Off the menu, still a command: the row read badly among entries that + * name a destination, but typing it is an explicit act and the muscle + * memory is real. + */ it("toggles ui mode for /debug", () => { const result = dispatchSlashCommand("/debug"); expect(result.actions).toEqual([{ type: "ui_mode_toggled" }]); @@ -86,6 +91,18 @@ describe("dispatchSlashCommand", () => { expect(result.triggerSessionPicker).toBe(false); }); + it("signals triggerNewWindow for /window and its alias", () => { + // `/new` restarts the session in place; `/window` is the OS-level + // sibling of Ctrl+N — the two must never be confused. + for (const buffer of ["/window", "/newwindow"]) { + const result = dispatchSlashCommand(buffer); + expect(result.triggerNewWindow).toBe(true); + expect(result.triggerSessionNew).toBe(false); + expect(result.forwardAsMessage).toBe(false); + } + expect(dispatchSlashCommand("/new").triggerNewWindow).toBe(false); + }); + it("opens the Memory tab for bare /memory", () => { const result = dispatchSlashCommand("/memory"); expect(result.triggerMemoryDump).toBe(false); @@ -224,6 +241,19 @@ describe("dispatchSlashCommand", () => { ]); }); + it("deep-links /llm fallback straight to the Fallback pane", () => { + const result = dispatchSlashCommand("/llm fallback"); + expect(result.actions).toEqual([ + { type: "ui_mode_set", mode: "debug" }, + { type: "tab_changed", tab: "llm" }, + { type: "llm_mode_set", mode: "fallback" }, + // Same refresh as bare /llm: the deep link reaches the tab via + // reducer actions (not onProvidersTabRefresh), so it must request + // its own re-read or the chain mirror can arrive stale. + { type: "providers_refresh_requested" }, + ]); + }); + it("signals triggerLocalModelsStatus for /models status", () => { const result = dispatchSlashCommand("/models status"); expect(result.triggerLocalModelsStatus).toBe(true); @@ -286,16 +316,16 @@ describe("dispatchSlashCommand", () => { it("lists available themes for /theme list", () => { const result = dispatchSlashCommand("/theme list"); expect(result.systemMessage).toContain("available themes:"); - expect(result.systemMessage).toContain("dracula"); + expect(result.systemMessage).toContain("khorne-red"); expect(result.setThemeName).toBeUndefined(); expect(result.actions).toEqual([]); }); it("switches the theme for a known /theme ", () => { - const result = dispatchSlashCommand("/theme dracula"); - expect(result.setThemeName).toBe("dracula"); - expect(result.actions).toEqual([{ type: "theme_set", name: "dracula" }]); - expect(result.systemMessage).toContain("theme set to dracula"); + const result = dispatchSlashCommand("/theme khorne-red"); + expect(result.setThemeName).toBe("khorne-red"); + expect(result.actions).toEqual([{ type: "theme_set", name: "khorne-red" }]); + expect(result.systemMessage).toContain("theme set to khorne-red"); }); it("rejects an unknown /theme without switching", () => { @@ -372,4 +402,54 @@ describe("dispatchSlashCommand", () => { expect(result.analyticsVerb).toBe("disable"); expect(result.approvalLevelSet).toBeUndefined(); }); + + it("asks for the new default to be persisted on bare /steer and /queue", () => { + const steer = dispatchSlashCommand("/steer"); + expect(steer.setWhileBusyMode).toBe("steer"); + expect(steer.actions).toEqual([ + { type: "while_busy_mode_changed", mode: "steer" }, + ]); + + // Bare /queue stays a side-effect-free listing — the menu node and + // the parked chip both invite running it just to look. + const queue = dispatchSlashCommand("/queue"); + expect(queue.setWhileBusyMode).toBeUndefined(); + expect(queue.queueVerb).toBe("list"); + expect(queue.actions).toEqual([]); + + const queueMode = dispatchSlashCommand("/queue mode"); + expect(queueMode.setWhileBusyMode).toBe("queue"); + expect(queueMode.actions).toEqual([ + { type: "while_busy_mode_changed", mode: "queue" }, + ]); + }); + + it("leaves the persisted default alone for the message-carrying forms", () => { + const steer = dispatchSlashCommand("/steer use the staging db"); + expect(steer.submitWhileBusy).toEqual({ + mode: "steer", + text: "use the staging db", + }); + expect(steer.setWhileBusyMode).toBeUndefined(); + + const queue = dispatchSlashCommand("/queue then deploy"); + expect(queue.submitWhileBusy).toEqual({ + mode: "queue", + text: "then deploy", + }); + expect(queue.setWhileBusyMode).toBeUndefined(); + + expect(dispatchSlashCommand("/queue clear").setWhileBusyMode).toBeUndefined(); + }); + + it("/uninstall opens the ladder and asks for a plan — it removes nothing", () => { + const result = dispatchSlashCommand("/uninstall"); + expect(result.actions).toEqual([{ type: "uninstall_opened" }]); + expect(result.triggerUninstallPlan).toBe(true); + // Nothing here quits, aborts or otherwise acts: every decision is + // the dialog's, and this command only opens it. + expect(result.triggerQuit).toBe(false); + expect(result.triggerAbort).toBe(false); + }); + }); diff --git a/src/tui/components/assistant-bubble.tsx b/src/tui/components/assistant-bubble.tsx index 78a8fa88..fb77d880 100644 --- a/src/tui/components/assistant-bubble.tsx +++ b/src/tui/components/assistant-bubble.tsx @@ -12,12 +12,14 @@ interface AssistantBubbleProps { } /** - * opencode-style assistant reply bubble. Mirrors `UserBubble` (left - * coloured border + vertical padding + `marginTop=1`) so the chat - * surface reads as a two-colour ribbon instead of a labelled list. - * Tool-step count and a final `●` glyph land in a meta-row **below** - * the bubble (outside the border), again copied from opencode — the - * label is the colour, not the inline text. + * The assistant reply. Mirrors `UserBubble`: an `AGENT` label over a + * coloured left border, with the tool-step count and a final `●` glyph + * in a meta-row **below** the bubble, outside the border. + * + * The label is the word AND the colour. Colour on its own carries + * nothing under NO_COLOR or in a pipe, and a reply that is only + * distinguishable by hue from the message that prompted it is a + * transcript nobody can skim. * * Markdown rendering is gated on `!streaming`: partial markdown * (half-opened `**`, fenced block missing its closing ```, dangling @@ -32,6 +34,9 @@ export function AssistantBubble({ const showFooter = !streaming && toolSteps !== undefined && toolSteps > 0; return ( + + {" AGENT"} + => + new Promise((resolve) => realSetTimeout(resolve, ms)); + +/** Fakes the badge window only. See {@link realSetTimeout}. */ +function fakeBadgeTimerOnly(): void { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); +} + +/** + * Ink commits a frame and React flushes the effect that registers the + * click target on its own schedule, so a freshly rendered button is not + * clickable for a tick or two. Everything here polls rather than + * sleeping a fixed interval. + */ +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +interface Harness { + frame: () => string; + clickAt: (needle: string) => void; + clickCell: (x: number, y: number) => void; + unmount: () => void; +} + +function noopCallbacks(): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: () => {}, + }; +} + +function mount( + writer: ClipboardWriter, + children: ReactNode, + { withMouse = true }: { withMouse?: boolean } = {}, +): Harness { + const registry = new MouseTargetRegistry(); + const state = createInitialTuiState(SESSION); + const tree: ReactElement = withMouse ? ( + {}} + callbacks={noopCallbacks()} + getState={() => state} + > + {children} + + ) : ( + <>{children} + ); + const { lastFrame, unmount } = render( + {tree}, + ); + const frame = (): string => strip(lastFrame() ?? ""); + return { + frame, + clickAt: (needle) => { + const at = locate(frame(), needle); + registry.dispatch(click(at.x, at.y)); + }, + clickCell: (x, y) => registry.dispatch(click(x, y)), + unmount, + }; +} + +/** + * Clicks `[copy]` until the click actually lands. The target is + * registered by an effect that runs after the frame the label first + * appears in, so the first click can fall on a cell nothing owns yet — + * the same reason `mouse-app.test.tsx` re-sends its clicks. + */ +async function clickCopy(app: Harness, copied: readonly string[]): Promise { + await waitUntil(() => app.frame().includes("[copy]"), "the idle label"); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (copied.length > 0) return; + app.clickAt("[copy]"); + await delay(25); + } + throw new Error("click never took effect on the copy button"); +} + +function recordingWriter(result = true): { + writer: ClipboardWriter; + copied: string[]; +} { + const copied: string[] = []; + return { + copied, + writer: { + copy: async (text: string) => { + copied.push(text); + return result; + }, + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ChatCopyButton", () => { + it("renders the quiet idle label", () => { + const app = mount(recordingWriter().writer, ); + expect(app.frame()).toContain("[copy]"); + app.unmount(); + }); + + it("still renders without a mouse provider", () => { + // `useMouseCommands()` is null under `--no-mouse` and in every + // component test; the button must degrade to a label, not vanish. + const app = mount(recordingWriter().writer, , { + withMouse: false, + }); + expect(app.frame()).toContain("[copy]"); + app.unmount(); + }); + + it("copies the message text and flips the label when clicked", async () => { + const { writer, copied } = recordingWriter(); + const app = mount(writer, ); + await clickCopy(app, copied); + expect(copied).toEqual(["the exact reply"]); + await waitUntil( + () => app.frame().includes("[copied!]"), + "the copied badge", + ); + app.unmount(); + }); + + it("reports a refused copy instead of claiming success", async () => { + const { writer, copied } = recordingWriter(false); + const app = mount(writer, ); + await clickCopy(app, copied); + await waitUntil( + () => app.frame().includes("[copy failed]"), + "the failure badge", + ); + app.unmount(); + }); + + it("copies the message its own button belongs to, not a neighbour's", async () => { + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + <> + + + , + ); + await waitUntil(() => app.frame().split("[copy]").length === 3, "both buttons"); + // The second button is the second `[copy]` on screen — one row down. + const first = locate(app.frame(), "[copy]"); + for (let attempt = 0; attempt < 40 && copied.length === 0; attempt += 1) { + app.clickCell(first.x, first.y + 1); + await delay(25); + } + expect(copied).toEqual(["second message"]); + app.unmount(); + }); + + it("does not leave a timer behind when unmounted mid-badge", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount(writer, ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + // Only the badge window is faked, so this count is the component's + // pending revert and nothing else. + expect(vi.getTimerCount()).toBe(1); + app.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("ChatCopyButton label timer", () => { + it("reverts to the idle label once the badge window elapses", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + , + ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + vi.advanceTimersByTime(4_999); + expect(app.frame()).toContain("[copied!]"); + vi.advanceTimersByTime(1); + await waitUntil( + () => app.frame().includes("[copy]") && !app.frame().includes("[copied!]"), + "the label reverting on its own", + ); + app.unmount(); + }); + + it("a second click restarts the window instead of letting the first timer clear it", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + , + ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + vi.advanceTimersByTime(4_000); + const seen = copied.length; + app.clickAt("[copied!]"); + await waitUntil(() => copied.length > seen, "the second copy"); + // `copied` grows inside `copy()`; the badge timer is only restarted + // in the `.then` after it. Give that microtask a real tick. + await delay(25); + // The first click's timeout is due 1s from here. If it had not been + // cleared, the badge would blink off a second after the re-click. + vi.advanceTimersByTime(2_000); + expect(vi.getTimerCount()).toBe(1); + expect(app.frame()).toContain("[copied!]"); + app.unmount(); + }); +}); diff --git a/src/tui/components/chat-copy-button.tsx b/src/tui/components/chat-copy-button.tsx new file mode 100644 index 00000000..208ccbb9 --- /dev/null +++ b/src/tui/components/chat-copy-button.tsx @@ -0,0 +1,95 @@ +import { Box, Text } from "ink"; +import { useCallback, type ReactElement } from "react"; +import { useClipboard } from "../clipboard/clipboard-context.js"; +import { useTransientStatus } from "../hooks/use-transient-status.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { theme } from "../theme/theme.js"; + +interface ChatCopyButtonProps { + /** Exactly the text that lands on the clipboard — no markdown, no borders. */ + readonly text: string; + /** How long `copied!` stays up before the label reverts. */ + readonly revertAfterMs?: number; +} + +/** Idle / just-copied / copy-was-refused. Drives the label and nothing else. */ +type CopyStatus = "idle" | "copied" | "failed"; + +const DEFAULT_REVERT_MS = 2_000; + +const LABELS: Readonly> = { + idle: "[copy]", + copied: "[copied!]", + failed: "[copy failed]", +}; + +/** + * The per-message copy affordance in the chat log. + * + * **Why a button at all.** Mouse reporting takes the terminal's own + * drag-to-select away (see `mouse-tracking.ts`), and "I want that reply + * on my clipboard" is overwhelmingly the reason anyone selects text in a + * chat TUI. A button answers that intent directly and, unlike a + * selection, copies the message *source* — the raw text, not the + * markdown-rendered, border-decorated, hard-wrapped thing on screen, + * which is what a drag would have given you. + * + * **Why brackets and no colour.** `[copy]` in the palette's `muted` + * grey, dimmed, is the quietest thing that still reads as a control. + * There is one of these under every message; anything with hue would + * turn the transcript into a column of badges. "Dark grey" is expressed + * as a theme token rather than a literal because the four light palettes + * would swallow a literal `#555` whole — `muted` + `dimColor` is the + * darkest grey each palette actually has. + * + * **Without a mouse provider** (component tests, `--no-mouse`) the + * button still renders — it is a legible hint that the message has a + * copy affordance when the mouse is on — but registers no target. + */ +export function ChatCopyButton({ + text, + revertAfterMs = DEFAULT_REVERT_MS, +}: ChatCopyButtonProps): ReactElement { + const clipboard = useClipboard(); + const mouse = useMouseCommands(); + const [status, flash] = useTransientStatus("idle", revertAfterMs); + + const copy = useCallback(() => { + // Fire-and-forget: the click handler runs outside React's render + // pass and the clipboard write can outlive the frame. `flash` is the + // only thing that touches state, and it no-ops after unmount. + void clipboard + .copy(text) + .then((ok) => flash(ok ? "copied" : "failed")) + .catch(() => flash("failed")); + }, [clipboard, text, flash]); + + const label = ( + + {LABELS[status]} + + ); + + // A row wrapper, not a column child: in a column Yoga stretches the + // target to the full chat width and every click on the line would + // copy. In a row it hugs the six cells the label actually occupies. + return ( + + {mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + copy(); + return true; + }} + > + {label} + + ) : ( + label + )} + + ); +} diff --git a/src/tui/components/chat-log.test.tsx b/src/tui/components/chat-log.test.tsx index 8cd25f51..f9ecc30d 100644 --- a/src/tui/components/chat-log.test.tsx +++ b/src/tui/components/chat-log.test.tsx @@ -25,7 +25,11 @@ describe("ChatLog", () => { const state = createInitialTuiState(BASE_SESSION); const { lastFrame } = render(); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Local-First AI Agent"); + // Every size is its own drawing now, and the splash draws the ASCII + // stroke, so assert that *some* mark is present rather than a + // wordmark only a tall terminal earns. See + // `splash-fit.render.test.tsx`. + expect(text).toMatch(/#{4}|[█▀▄]/u); expect(text).toContain("/help"); }); diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx index 09f0e081..0458f78c 100644 --- a/src/tui/components/chat-log.tsx +++ b/src/tui/components/chat-log.tsx @@ -1,10 +1,15 @@ import { Box, Text, measureElement, type DOMElement } from "ink"; import { useEffect, useRef, useState, type ReactElement } from "react"; import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { computeChatViewportRows } from "../layout.js"; import type { TuiAction } from "../tui-action.js"; import type { ChatMessage, TuiState } from "../tui-state.js"; import { theme } from "../theme/theme.js"; import { AssistantBubble } from "./assistant-bubble.js"; +import type { CodingMode } from "../coding-mode.js"; +import { ChatCopyButton } from "./chat-copy-button.js"; +import { PlanHandoff } from "./plan-handoff.js"; +import { ChatTryAgainButton } from "./chat-try-again-button.js"; import { estimateMessageHeight, estimateStreamingTailHeight, @@ -16,17 +21,15 @@ import { ThinkingIndicator } from "./thinking-indicator.js"; import { ToolCard } from "./tool-card.js"; import { UserBubble } from "./user-bubble.js"; -/** - * Rows of "chrome" outside the chat surface: status bar + prompt - * meta-row + prompt input + prompt tail-cap + hotkey hint + a small - * safety pad. Used to convert `terminal.rows` into the chat-area - * viewport height. Slightly conservative — better to leave one empty - * row than to clip the prompt. - */ -const CHROME_ROWS = 8; - interface ChatLogProps { state: TuiState; + /** + * Runs the drafted plan under `mode`, and puts it away. Both optional + * so the many tests that render a log need not supply them; without + * them the plan simply carries no buttons. + */ + onPlanExecute?: (mode: CodingMode) => void; + onPlanDismiss?: () => void; /** * Optional dispatcher used to self-correct an over-scrolled state. * Whenever `state.chatScrollOffset` exceeds the visually allowed @@ -76,7 +79,12 @@ function isVisibleToolCard(card: { tool: string }): boolean { * Empty surface → centred splash banner; everything else falls * through into the bottom-anchored column. */ -export function ChatLog({ state, dispatch }: ChatLogProps): ReactElement { +export function ChatLog({ + state, + dispatch, + onPlanExecute, + onPlanDismiss, +}: ChatLogProps): ReactElement { const terminalSize = useTerminalSize(); const finalised = state.messages; const hasStreamingTail = @@ -89,7 +97,10 @@ export function ChatLog({ state, dispatch }: ChatLogProps): ReactElement { // All hooks must run unconditionally — only the JSX branches on // `isEmpty`. Compute viewport / measured-K / clamp regardless, // even when the early return for the splash branch fires below. - const viewport = Math.max(5, terminalSize.rows - CHROME_ROWS); + const viewport = computeChatViewportRows( + terminalSize.rows, + terminalSize.columns, + ); // First-frame fallback for `K` until the post-mount `measureElement` // call returns the truth. Estimates are unreliable (text wraps, Yoga // collapses some margins, reasoning blocks expand mid-turn) so we @@ -149,11 +160,24 @@ export function ChatLog({ state, dispatch }: ChatLogProps): ReactElement { > - {finalised.map((message) => ( + {finalised.map((message, idx) => ( ))} {hasStreamingTail ? : null} @@ -168,14 +192,28 @@ export function ChatLog({ state, dispatch }: ChatLogProps): ReactElement { interface FinalisedMessageProps { message: ChatMessage; toolsExpandedById: Readonly>; + /** Present only on the message that *is* the plan. */ + planHandoff: { + onExecute: (mode: CodingMode) => void; + onDismiss: () => void; + } | null; } function FinalisedMessage({ message, toolsExpandedById, + planHandoff, }: FinalisedMessageProps): ReactElement { if (message.role === "user") { - return ; + return ( + + + + + + + + ); } if (message.role === "assistant") { return ( @@ -207,14 +245,24 @@ function FinalisedMessage({ text={message.text} toolSteps={message.toolSteps ?? 0} /> + + {planHandoff ? ( + + ) : null} ); } return ( - + + + + ); } diff --git a/src/tui/components/chat-message-height.test.ts b/src/tui/components/chat-message-height.test.ts index 218fe73a..1e2b5f0a 100644 --- a/src/tui/components/chat-message-height.test.ts +++ b/src/tui/components/chat-message-height.test.ts @@ -28,8 +28,8 @@ function assistantMsg( describe("estimateMessageHeight", () => { it("counts a single-line user message as body + bubble overhead", () => { const h = estimateMessageHeight(userMsg("u1", "hello")); - // 1 body + 3 overhead (margin + 2 padding) - expect(h).toBe(4); + // 1 body + 3 overhead (margin + 2 padding) + 1 copy-button row + expect(h).toBe(5); }); it("counts assistant footer when toolSteps > 0", () => { @@ -70,9 +70,9 @@ describe("selectVisibleMessages", () => { userMsg("u3", "c"), userMsg("u4", "d"), ]; - // Each 1-line user message costs 4 rows. Budget for 2 messages - // exactly: 8 rows. - const slice = selectVisibleMessages(msgs, 0, 8); + // Each 1-line user message costs 5 rows (4 of bubble + the copy + // button under it). Budget for 2 messages exactly: 10 rows. + const slice = selectVisibleMessages(msgs, 0, 10); expect(slice.visible.map((m) => m.id)).toEqual(["u3", "u4"]); expect(slice.hiddenAbove).toBe(2); }); @@ -92,8 +92,8 @@ describe("selectVisibleMessages", () => { it("respects multiline body length", () => { const longMsg = userMsg("u1", "line1\nline2\nline3\nline4\nline5"); - // 5 body + 3 overhead = 8 rows. - expect(estimateMessageHeight(longMsg)).toBe(8); + // 5 body + 3 overhead + 1 copy button = 9 rows. + expect(estimateMessageHeight(longMsg)).toBe(9); const slice = selectVisibleMessages( [longMsg, userMsg("u2", "tail")], 0, diff --git a/src/tui/components/chat-message-height.ts b/src/tui/components/chat-message-height.ts index d9236814..e837a9c0 100644 --- a/src/tui/components/chat-message-height.ts +++ b/src/tui/components/chat-message-height.ts @@ -25,6 +25,17 @@ const BUBBLE_OVERHEAD_ROWS = 3; // marginTop + paddingTop + paddingBottom const REASONING_BUBBLE_OVERHEAD_ROWS = 5; // marginTop + paddingTop + 1-line header + paddingBottom + safety const TOOL_CARD_BASE_ROWS = 2; const ASSISTANT_FOOTER_ROWS = 1; +/** + * `FinalisedMessage` hangs a button footer under every finalised bubble, + * whatever the role: `[copy]` everywhere, `[try again]` beside it on + * user messages. The two share a row, so this stays one row and + * unconditional — the day a role earns a second footer line this + * estimate has to learn about roles, and an under-count is not cosmetic: + * Ink 7 paints an over-tall frame's later lines over its earlier ones + * instead of clipping. The streaming tail has no footer at all, which is + * why the row is charged here and not in `estimateStreamingTailHeight`. + */ +const MESSAGE_FOOTER_ROWS = 1; function bodyLines(text: string): number { if (text.length === 0) return 1; @@ -33,7 +44,7 @@ function bodyLines(text: string): number { export function estimateMessageHeight(message: ChatMessage): number { const bodyRows = bodyLines(message.text); - let total = bodyRows + BUBBLE_OVERHEAD_ROWS; + let total = bodyRows + BUBBLE_OVERHEAD_ROWS + MESSAGE_FOOTER_ROWS; if (message.role === "assistant") { if (message.reasoningBlocks && message.reasoningBlocks.length > 0) { total += REASONING_BUBBLE_OVERHEAD_ROWS + 1; diff --git a/src/tui/components/chat-try-again-button.test.tsx b/src/tui/components/chat-try-again-button.test.tsx new file mode 100644 index 00000000..3462644a --- /dev/null +++ b/src/tui/components/chat-try-again-button.test.tsx @@ -0,0 +1,237 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import type { ReactElement, ReactNode } from "react"; +import { reduceTuiState } from "../agent-event-reducer.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import { createInitialTuiState, type TuiSessionInfo, type TuiState } from "../tui-state.js"; +import { ChatTryAgainButton } from "./chat-try-again-button.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "again", + workingDir: "/tmp/again", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function strip(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + +/** Screen cell of `needle` — the position a terminal reports for a click. */ +function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of frame.split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Ink commits frames on a throttle and the effect that registers a click + * target runs after the frame the label first appears in, so nothing + * here sleeps a fixed interval — it polls. Same reason + * `chat-copy-button.test.tsx` and `mouse-app.test.tsx` re-send clicks. + */ +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +interface Harness { + frame: () => string; + /** Clicks the label until the registry actually owns those cells. */ + clickUntil: (needle: string, landed: () => boolean) => Promise; + clickOnce: (needle: string) => void; + state: () => TuiState; + actions: TuiAction[]; + submitted: string[]; + steered: string[]; + unmount: () => void; +} + +function mount( + children: ReactNode, + { withMouse = true, initial }: { withMouse?: boolean; initial?: TuiState } = {}, +): Harness { + const registry = new MouseTargetRegistry(); + // A real reducer behind the provider: the point of these tests is what + // the submit path does to `TuiState`, and a stub dispatch would assert + // only that the component called something. + let state = initial ?? createInitialTuiState(SESSION); + const actions: TuiAction[] = []; + const dispatch = (action: TuiAction): void => { + actions.push(action); + state = reduceTuiState(state, action); + }; + const submitted: string[] = []; + const steered: string[] = []; + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: (text) => submitted.push(text), + onMessageSteered: (text) => steered.push(text), + }; + const tree: ReactElement = withMouse ? ( + state} + > + {children} + + ) : ( + <>{children} + ); + const { lastFrame, unmount } = render(tree); + const frame = (): string => strip(lastFrame() ?? ""); + const clickOnce = (needle: string): void => { + const at = locate(frame(), needle); + registry.dispatch(click(at.x, at.y)); + }; + return { + frame, + clickOnce, + clickUntil: async (needle, landed) => { + await waitUntil(() => frame().includes(needle), `the ${needle} label`); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (landed()) return; + clickOnce(needle); + await delay(25); + } + throw new Error(`click never took effect on ${needle}`); + }, + state: () => state, + actions, + submitted, + steered, + unmount, + }; +} + +describe("ChatTryAgainButton", () => { + it("renders the quiet idle label", () => { + const app = mount(); + expect(app.frame()).toContain("[try again]"); + app.unmount(); + }); + + it("still renders without a mouse provider", () => { + const app = mount(, { withMouse: false }); + expect(app.frame()).toContain("[try again]"); + app.unmount(); + }); + + it("re-sends the message through the normal submit path", async () => { + const app = mount(); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.submitted).toEqual(["list the files"]); + // `message_submitted` is what Enter dispatches — the re-run starts a + // real turn rather than poking the orchestrator behind the reducer. + expect(app.actions.map((a) => a.type)).toContain("message_submitted"); + await waitUntil(() => app.frame().includes("[sent]"), "the sent badge"); + app.unmount(); + }); + + it("keeps an unsent draft in the composer", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + inputValue: "half-written thought", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.submitted).toEqual(["run that again"]); + // Submitting blanks `inputValue` (`startNewRun`); the draft is put + // back afterwards, so the re-run costs a turn and not the operator's + // half-typed message. + expect(app.state().inputValue).toBe("half-written thought"); + app.unmount(); + }); + + it("steers into the running turn when that is what Enter would do", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + status: "running", + whileBusyMode: "steer", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.steered.length > 0); + expect(app.steered).toEqual(["try that again"]); + // Not a second turn: the routing is `handleEditorSubmit`'s, not ours. + expect(app.submitted).toEqual([]); + expect(app.state().status).toBe("running"); + app.unmount(); + }); + + it("queues into the running turn when that is what Enter would do", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + status: "running", + whileBusyMode: "queue", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.steered).toEqual([]); + expect(app.state().queuedMessages).toEqual(["and again"]); + app.unmount(); + }); + + it("ignores the second press of a double-click, then re-arms", async () => { + const app = mount( + , + ); + // The first send starts a turn, so the second one steers into it — + // count both landings, since which one fires is the submit path's + // decision and this test is about how many times it was asked. + const sends = (): number => app.submitted.length + app.steered.length; + await app.clickUntil("[try again]", () => sends() > 0); + await waitUntil(() => app.frame().includes("[sent]"), "the sent badge"); + // A terminal reports a double-click as two presses; a turn is not + // free, so the badge window swallows the second one. + app.clickOnce("[sent]"); + await delay(50); + expect(sends()).toBe(1); + // The guard is a window, not a latch. + await waitUntil( + () => app.frame().includes("[try again]"), + "the label re-arming", + ); + await app.clickUntil("[try again]", () => sends() > 1); + expect(app.submitted).toEqual(["expensive turn"]); + expect(app.steered).toEqual(["expensive turn"]); + app.unmount(); + }); +}); diff --git a/src/tui/components/chat-try-again-button.tsx b/src/tui/components/chat-try-again-button.tsx new file mode 100644 index 00000000..20855b96 --- /dev/null +++ b/src/tui/components/chat-try-again-button.tsx @@ -0,0 +1,141 @@ +import { Box, Text } from "ink"; +import { type ReactElement } from "react"; +import { useTransientStatus } from "../hooks/use-transient-status.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { + MouseTarget, + useMouseCommands, + type MouseContextValue, +} from "../mouse/mouse-context.js"; +import { handleEditorSubmit } from "../submit-handler.js"; +import { theme } from "../theme/theme.js"; + +interface ChatTryAgainButtonProps { + /** The message source, resent verbatim — byte for byte what was sent before. */ + readonly text: string; + /** How long `sent` stays up before the label reverts. */ + readonly revertAfterMs?: number; +} + +/** Idle / just-resent. The badge is also the double-click guard. */ +type TryAgainStatus = "idle" | "sent"; + +const DEFAULT_REVERT_MS = 2_000; + +const LABELS: Readonly> = { + idle: "[try again]", + sent: "[sent]", +}; + +/** + * Re-run `text` exactly as if it had been typed into the composer and + * submitted with Enter. + * + * **One submit path.** Everything goes through `handleEditorSubmit`, the + * function Enter calls, so a re-run inherits whatever routing the + * operator has configured instead of inventing a third behaviour: idle + * starts a turn; while a turn is running `tui.whileBusySubmit` (Ctrl+T) + * decides between steering the text into the turn in flight and parking + * it in the queue. The same rule covers the odd cases for free — a + * message that happens to read as a slash command runs as one, because + * that is what typing it would do, and a second interpretation of the + * same text is exactly how two submit paths drift apart. + * + * **The composer draft survives.** Every landing that path dispatches + * blanks `inputValue` — `startNewRun`, `message_queued` and + * `message_steered` all do — which would silently eat a half-written + * message the operator had not sent yet. The draft is snapshotted before + * the submit and written back after it, so a re-run costs a turn and + * nothing else. Restoring the buffer alone is enough: a draft that would + * also need slash-palette state restored cannot reach this handler at + * all, because `TuiApp` raises the mouse floor to `MOUSE_LAYER_MODAL` + * while the palette is open and this button sits on the base layer. + */ +export function resubmitChatMessage( + text: string, + mouse: MouseContextValue, +): void { + // Read state at click time, not render time: the handler fires outside + // React's render pass and the turn may have started or finished since + // the frame that painted the button. + const state = mouse.getState(); + const draft = state.inputValue; + handleEditorSubmit(text, state, mouse.dispatch, mouse.callbacks); + if (draft.length > 0) { + mouse.dispatch({ type: "input_changed", value: draft }); + } +} + +/** + * The per-message "run that again" affordance, beside `[copy]`. + * + * **Only user messages get one**, which is `chat-log.tsx`'s call to + * make, not this component's — but the reasoning belongs next to the + * code it explains. A user message is a command someone gave the agent, + * so re-running it is a real intent: the model wandered off, a file + * changed, a tool was down. An assistant message is the agent's own + * prose; sending it back would open a turn whose prompt is the previous + * answer, which is not "try again" in any sense an operator means. A + * system message is TUI runtime output — queue listings, turn-failed + * lines — and re-sending one as a prompt is worse than nonsense. Asking + * the model to have another go at the *same* question is a different + * feature (it has to drop the last turn, not append one) and it is not + * this button. + * + * **Why a badge when the click already changes the screen.** Often it + * does not. A steered message is not rendered until the loop applies it + * at the next step boundary (`steer_applied`), which can be seconds + * away, so a click with no feedback reads as a dead button and gets + * clicked again. `[sent]` closes that gap and doubles as the guard: + * clicks are ignored while it is up, so the double-click a terminal + * reports as two presses cannot open two turns. + * + * **Without a mouse provider** (component tests, `--no-mouse`) it still + * renders, exactly like `[copy]` — a legible hint that the affordance is + * there when the mouse is on — but registers no target. + */ +export function ChatTryAgainButton({ + text, + revertAfterMs = DEFAULT_REVERT_MS, +}: ChatTryAgainButtonProps): ReactElement { + const mouse = useMouseCommands(); + const [status, flash] = useTransientStatus( + "idle", + revertAfterMs, + ); + + const label = ( + + {LABELS[status]} + + ); + + // One space off `[copy]`, on the same row: the footer stays a single + // line whatever the role, so `estimateMessageHeight` does not have to + // branch — and an under-counted row is not a cosmetic bug in Ink 7, + // which paints an over-tall frame's later lines over its earlier ones + // rather than clipping. + return ( + + {mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + // Claim the press either way — the click landed on this + // button, and letting it fall through would hand it to the + // viewport wheel target behind the chat log. + if (status !== "idle") return true; + resubmitChatMessage(text, mouse); + flash("sent"); + return true; + }} + > + {label} + + ) : ( + label + )} + + ); +} diff --git a/src/tui/components/chip.tsx b/src/tui/components/chip.tsx new file mode 100644 index 00000000..f12ec538 --- /dev/null +++ b/src/tui/components/chip.tsx @@ -0,0 +1,67 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; +import { theme } from "../theme/theme.js"; + +/** + * A raised control: `+ new`, `≡ Menu`, `send →`. + * + * The design draws these as DOS-style buttons — a near-white face, dark + * text, a light bevel on the top/left and a dark one on the bottom/right. + * A terminal cell has no sub-cell bevel to draw, so the raised look falls + * back to what raised meant before bevels existed: a light face under + * dark text. `chipBackground` / `chipForeground` carry that pair per + * palette, so a chip stays legible on all twelve — including this one, + * where the rail it sits on is itself a colour. + * + * The padding spaces are part of the control: a chip with no ground + * either side of its label reads as highlighted text rather than as a + * button. + */ +export function Chip({ + label, + tone = "raised", +}: { + label: string; + /** + * `raised` is the button face. `badge` is the flatter, accent-tinted + * variant used for status — the `RUN` pill — which the design tints + * rather than raises. + */ + tone?: "raised" | "badge"; +}): ReactElement { + if (tone === "badge") { + return ( + + {` ${label} `} + + ); + } + return ( + + {` ${label} `} + + ); +} + +/** + * The design letter-spaces the `RUN` badge. A terminal grid cannot do + * fractional tracking, so the one honest approximation is a full space + * between letters — which doubles the word's width. That is affordable + * for a three-letter status word and absurd for `OBSERVE`, so anything + * longer than four characters is only upper-cased. + */ +const MAX_TRACKED_LENGTH = 4; + +export function tracked(label: string): string { + const upper = label.toUpperCase(); + if (upper.length > MAX_TRACKED_LENGTH) return upper; + return upper.split("").join(" "); +} diff --git a/src/tui/components/cloud-provider-onboarding-mouse.test.tsx b/src/tui/components/cloud-provider-onboarding-mouse.test.tsx new file mode 100644 index 00000000..45ec0fd3 --- /dev/null +++ b/src/tui/components/cloud-provider-onboarding-mouse.test.tsx @@ -0,0 +1,150 @@ +/** + * Row clicks on this screen act on ITS wizard — the one in component + * state — never on `providersPanel.wizard`. The store slice stays + * `null` for every test here, so any movement on screen can only have + * come through the threaded `WizardMouseRoute`; the old handlers read + * the store at click time, found `null`, and silently did nothing. + */ + +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { render } from "ink-testing-library"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resetConfigCache } from "../../config/index.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import { visibleKindRows } from "../providers/providers-wizard-phases.js"; +import { fakeSession } from "../test-fixtures.js"; +import type { TuiAction } from "../tui-action.js"; +import { createInitialTuiState } from "../tui-state.js"; +import { CloudProviderOnboarding } from "./cloud-provider-onboarding.js"; + +const STATE_DIR_ENV = "ATOMIC_AGENT_STATE_DIR"; +const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, ""); +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +interface Mounted { + frame(): string; + /** Everything that leaked into the store — must stay wizard-free. */ + actions: TuiAction[]; + registry: MouseTargetRegistry; + unmount(): void; +} + +function mountWithMouse(): Mounted { + const actions: TuiAction[] = []; + const registry = new MouseTargetRegistry(); + // The store's wizard slice is null and stays null: this screen's + // wizard lives in its own useState, which is the whole point. + const state = createInitialTuiState(fakeSession(), 50); + const view = render( + actions.push(action)} + callbacks={{}} + getState={() => state} + > + {}} onBack={() => {}} /> + , + ); + return { + frame: () => strip(view.lastFrame() ?? ""), + actions, + registry, + unmount: view.unmount, + }; +} + +function mouseEvent(over: Partial): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x: 0, + y: 0, + shift: false, + alt: false, + ctrl: false, + ...over, + }; +} + +/** Screen cell of `label`'s first character, off the rendered frame. */ +function pointOf(view: Mounted, label: string): { x: number; y: number } { + const lines = view.frame().split("\n"); + for (const [y, line] of lines.entries()) { + const x = line.indexOf(label); + if (x !== -1) return { x, y }; + } + throw new Error(`"${label}" is not on screen:\n${view.frame()}`); +} + +/** Retries until a commit has registered the row targets (see the twin). */ +async function sendUntilClaimed(view: Mounted, label: string): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const point = pointOf(view, label); + if (view.registry.dispatch(mouseEvent(point))) return; + await delay(25); + } + throw new Error(`the surface never claimed an event at "${label}"`); +} + +describe("CloudProviderOnboarding mouse", () => { + let stateDir: string; + let originalEnv: string | undefined; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "cloud-onboarding-mouse-")); + mkdirSync(stateDir, { recursive: true }); + originalEnv = process.env[STATE_DIR_ENV]; + process.env[STATE_DIR_ENV] = stateDir; + resetConfigCache(); + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env[STATE_DIR_ENV]; + else process.env[STATE_DIR_ENV] = originalEnv; + resetConfigCache(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("a click on an unselected kind row moves the local wizard's cursor", async () => { + const second = visibleKindRows(null)[1]; + if (!second) throw new Error("the kind list has fewer than two rows"); + const view = mountWithMouse(); + await sendUntilClaimed(view, second.label); + await delay(60); + // The frame's cursor moved, driven by the local useState wizard... + const row = view + .frame() + .split("\n") + .find((line) => line.includes(second.label)); + expect(row).toContain(`> ${second.label}`); + // ...and nothing wizard-shaped leaked into the store's slice. + expect( + view.actions.every((action) => action.type !== "providers_wizard_updated"), + ).toBe(true); + view.unmount(); + }); + + it("a click on the selected row presses the local wizard's Enter", async () => { + const first = visibleKindRows(null)[0]; + if (!first) throw new Error("the kind list is empty"); + const view = mountWithMouse(); + await sendUntilClaimed(view, first.label); + await delay(60); + // Enter on the selected kind row advances the wizard off the + // provider list — rendered from local state, so the title change is + // the proof the click reached this screen's own wizard. + expect(view.frame()).not.toContain("LLM provider — add provider"); + expect( + view.actions.every((action) => action.type !== "providers_wizard_updated"), + ).toBe(true); + view.unmount(); + }); +}); diff --git a/src/tui/components/cloud-provider-onboarding.test.tsx b/src/tui/components/cloud-provider-onboarding.test.tsx new file mode 100644 index 00000000..f90697f1 --- /dev/null +++ b/src/tui/components/cloud-provider-onboarding.test.tsx @@ -0,0 +1,323 @@ +/** + * First-run onboarding, from the angle the sibling wizard already has + * covered: what a cancelled key check is allowed to do afterwards. + * + * `verifyProviderKey` samples the abort signal at the top of each probe + * and in the fetch catch, so an abort that lands while the response body + * is being read produces an ordinary verdict, not `"cancelled"`. Every + * test here drives that interleaving through real key bindings and the + * real verify path, stubbing only the disk write, the config read and + * the network. The one exception is the gate rejection, which no real + * provider answer produces — `verifyProviderKey` returns a verdict for + * every transport failure it meets. + */ + +import { render } from "ink-testing-library"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AtomicAgentConfig } from "../../config/index.js"; +import type { WizardVerifyGate } from "../providers/verify-wizard-before-save.js"; +import { CloudProviderOnboarding } from "./cloud-provider-onboarding.js"; +import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js"; +import { saveProviderWizardToConfig } from "../providers/save-provider-wizard.js"; + +vi.mock("../../config/index.js", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, getConfig: () => currentConfig }; +}); + +vi.mock("../providers/save-provider-wizard.js", () => ({ + saveProviderWizardToConfig: vi.fn(() => ({ + entry: { id: "openrouter", kind: "openrouter" }, + })), +})); + +/** + * Answers queued here stand in for the next checks; anything not queued + * runs the real gate against the stubbed fetch. `verifyProviderKey` + * turns every transport failure into a verdict rather than a rejection, + * so a rejected gate is the only way to reach the component's `catch`. + */ +const gateOverrides: ((signal?: AbortSignal) => Promise)[] = + []; + +vi.mock("../providers/verify-wizard-before-save.js", async (importOriginal) => { + const original = + await importOriginal< + typeof import("../providers/verify-wizard-before-save.js") + >(); + return { + ...original, + verifyWizardBeforeSave: ( + wizard: Parameters[0], + opts: Parameters[1] = {}, + ) => { + const queued = gateOverrides.shift(); + return queued + ? queued(opts.signal) + : original.verifyWizardBeforeSave(wizard, opts); + }, + }; +}); + +const currentConfig = { + llm: { + activeTextProvider: "local-llama", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto", + providers: [], + }, +} as unknown as AtomicAgentConfig; + +const saveMock = vi.mocked(saveProviderWizardToConfig); + +/** Written out so an editor cannot quietly eat the control character. */ +const ESC = "\u001b"; +const CTRL_C = "\u0003"; + +/** One in-flight probe, with its body read parked until the test says so. */ +interface ProbeGate { + /** Resolves once `verifyProviderKey` has started reading the body. */ + readonly bodyRequested: Promise; + /** Hands the body over, which lets `classifyVerifyResponse` run. */ + releaseBody(body: string): void; + readonly calls: () => number; +} + +/** + * A fetch that answers instantly but hands its body over only on demand. + * The window between the two is where the reviewer's race lives: the + * response has arrived, so the abort no longer reaches any of the + * signal checks inside `verifyProviderKey`. + */ +function stubGatedProbe(status = 429): ProbeGate { + let requested = () => {}; + const bodyRequested = new Promise((resolve) => { + requested = resolve; + }); + let release: (body: string) => void = () => {}; + const bodyReady = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown) => { + // Model-catalog reads share this stub; only the probe completions + // say anything about how many checks were started. + if (String(url).includes("/chat/completions")) calls += 1; + return { + ok: false, + status, + text: () => { + requested(); + return bodyReady; + }, + } as unknown as Response; + }), + ); + return { + bodyRequested, + releaseBody: (body: string) => { + release(body); + }, + calls: () => calls, + }; +} + +async function flush(times = 6): Promise { + for (let i = 0; i < times; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +/** Long enough for Ink's 20 ms pending-escape flush to fire. */ +async function settleInput(): Promise { + await new Promise((resolve) => setTimeout(resolve, 40)); + await flush(); +} + +/** + * Walks the wizard from the provider list to the last screen before the + * save: OpenRouter → key → chat model → embedding. The next Enter is the + * one that starts the credential check. + */ +async function mountAtSubmitPoint(): Promise<{ + stdin: { write(data: string): void }; + onFinished: ReturnType; + frame: () => string; + unmount: () => void; +}> { + const onFinished = vi.fn(); + const { stdin, lastFrame, unmount } = render( + {}} />, + ); + await settleInput(); + // The CLI-backed rows sit at the head of the list; walk down to + // OpenRouter by its registry position instead of assuming row 0. One + // settle per keypress: the component reads the wizard from a state + // closure, so two arrows in one tick would collapse into one step. + for (let i = 0; i < KIND_ROW_ORDER.indexOf("openrouter"); i += 1) { + stdin.write("\u001b[B"); + await settleInput(); + } + stdin.write("\r"); // OpenRouter + await settleInput(); + stdin.write("sk-onboarding-test-key"); + await settleInput(); + stdin.write("\r"); // key accepted → chat model list + await settleInput(); + stdin.write("\r"); // chat model → embedding list + await settleInput(); + return { + stdin, + onFinished, + frame: () => (lastFrame() ?? "").replace(/\[[0-9;]*m/g, ""), + unmount, + }; +} + +describe("CloudProviderOnboarding cancellation", () => { + beforeEach(() => { + saveMock.mockClear(); + gateOverrides.length = 0; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("drops a verdict that arrives after Esc cancelled the check", async () => { + const probe = stubGatedProbe(); + const { stdin, onFinished, frame, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); // Enter on the embedding list starts the check + await probe.bodyRequested; + await flush(); + expect(frame()).toContain("checking the key with the provider"); + + stdin.write(ESC); // Esc cancels while the body is still unread + await settleInput(); + expect(frame()).toContain("Key check cancelled"); + + // The response was already on the wire, so the check finishes with an + // ordinary verdict rather than "cancelled". + probe.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + + expect(saveMock).not.toHaveBeenCalled(); + expect(onFinished).not.toHaveBeenCalled(); + // The screen the operator was handed back is still the one on show. + expect(frame()).toContain("Key check cancelled"); + unmount(); + }); + + it("keeps the cancelled check from overwriting the retry started after it", async () => { + const first = stubGatedProbe(); + const { stdin, onFinished, frame, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); + await first.bodyRequested; + await flush(); + + stdin.write(ESC); + await settleInput(); + + // Enter after Esc: the operator takes the screen's own advice. + const second = stubGatedProbe(200); + stdin.write("\r"); + await second.bodyRequested.catch(() => {}); + await flush(); + expect(frame()).toContain("checking the key with the provider"); + + // The abandoned check answers last, and its verdict would have saved. + first.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + expect(saveMock).not.toHaveBeenCalled(); + expect(onFinished).not.toHaveBeenCalled(); + + // The retry is still the live one, and it is the one that saves. + second.releaseBody("{}"); + await flush(12); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(onFinished).toHaveBeenCalledTimes(1); + expect(onFinished.mock.calls[0]?.[0]).toBe("saved_cloud"); + unmount(); + }); + + it("starts one check when two Enters are drained from stdin in one turn", async () => { + const probe = stubGatedProbe(); + const { stdin, onFinished, unmount } = await mountAtSubmitPoint(); + + // Two key events in the same turn — what a buffered stdin hands Ink + // in one `readable` drain. Neither is cancelled, so the post-await + // abort check cannot separate them; only the in-flight ref can. + stdin.write("\r"); + stdin.write("\r"); + await probe.bodyRequested; + await flush(); + expect(probe.calls()).toBe(1); + + probe.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + // One check, one save, one exit — not two of each. + expect(saveMock).toHaveBeenCalledTimes(1); + expect(onFinished).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("keeps a cancelled check's failure off the retry that replaced it", async () => { + let failFirst: (err: Error) => void = () => {}; + gateOverrides.push( + () => + new Promise((_resolve, reject) => { + failFirst = reject; + }), + ); + const { stdin, onFinished, frame, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); + await settleInput(); + stdin.write(ESC); // Esc + await settleInput(); + expect(frame()).toContain("Key check cancelled"); + + const retry = stubGatedProbe(200); + stdin.write("\r"); + await retry.bodyRequested; + await flush(); + + // The abandoned run blows up after the retry took the screen. Its + // message must not land there, and it must not free `submitting`. + failFirst(new Error("openrouter provider network error: socket hang up")); + await flush(12); + expect(frame()).not.toContain("socket hang up"); + expect(frame()).toContain("checking the key with the provider"); + expect(saveMock).not.toHaveBeenCalled(); + + retry.releaseBody("{}"); + await flush(12); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(onFinished).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("drops a verdict that arrives after Ctrl+C left onboarding", async () => { + const probe = stubGatedProbe(); + const { stdin, onFinished, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); + await probe.bodyRequested; + await flush(); + + stdin.write(CTRL_C); // Ctrl+C + await settleInput(); + expect(onFinished).toHaveBeenCalledWith("aborted"); + + probe.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + expect(saveMock).not.toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalledTimes(1); + unmount(); + }); +}); diff --git a/src/tui/components/cloud-provider-onboarding.tsx b/src/tui/components/cloud-provider-onboarding.tsx index fbb57fb3..7fcf3b90 100644 --- a/src/tui/components/cloud-provider-onboarding.tsx +++ b/src/tui/components/cloud-provider-onboarding.tsx @@ -1,67 +1,178 @@ -import { Box, Text, useInput } from "ink"; -import { useCallback, useState, type ReactElement } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; +import { returnKey } from "../mouse/synthetic-key.js"; import { handleProvidersWizardKey } from "../providers/providers-wizard-key-bindings.js"; +import type { WizardMouseRoute } from "../providers/route-wizard-key.js"; import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; import type { ProvidersWizardState } from "../providers/providers-wizard-state.js"; import { saveProviderWizardToConfig } from "../providers/save-provider-wizard.js"; +import { verifyWizardBeforeSave } from "../providers/verify-wizard-before-save.js"; import { theme } from "../theme/theme.js"; import { ProvidersWizard } from "./providers-wizard.js"; export type CloudProviderOnboardingOutcome = "saved_cloud" | "aborted"; export function CloudProviderOnboarding(props: { - onFinished(outcome: CloudProviderOnboardingOutcome): void; + /** `notice` carries a key that was saved without a completed check. */ + onFinished(outcome: CloudProviderOnboardingOutcome, notice?: string): void; onBack(): void; }): ReactElement { const [wizard, setWizard] = useState(() => createProvidersWizardState("add"), ); const [submitting, setSubmitting] = useState(false); + const verifyAbort = useRef(null); + const alive = useRef(true); + useEffect(() => { + return () => { + alive.current = false; + verifyAbort.current?.abort(); + }; + }, []); + + /** + * Whether the check this controller belongs to may still touch the + * screen or the config. Asked again after every await, at both exits. + * + * `alive` alone answers a different question: Esc and Ctrl+C both end + * a check without unmounting anything, so a mounted component says + * nothing about whether its operator still wants the answer. Nor does + * a verdict — `verifyProviderKey` samples the signal at the top of + * each probe and in the fetch catch, so an abort landing between the + * response arriving and `classifyVerifyResponse` returning still comes + * back as an ordinary `ok`/`rate_limited`. The Providers-tab twin + * carries this guard in `completeWizard` for the same reason. + * + * A run superseded by the operator's retry is covered because + * `cancelSubmit` is the only thing that frees the wizard for a second + * check and it aborts first: no cancel, no second run. + */ + const checkStillWanted = useCallback( + (abort: AbortController): boolean => + alive.current && !abort.signal.aborted, + [], + ); const submit = useCallback( - (nextWizard: ProvidersWizardState) => { - if (submitting) return; + async (nextWizard: ProvidersWizardState) => { + // Re-entry is guarded on the ref, not on `submitting`: that state + // is captured in this closure, and the cancel handler resets it + // while the check it cancelled is still resolving, so Enter after + // Esc read a stale `false`. So did two key events drained from + // stdin in one turn, which started two checks racing to save the + // same wizard — and neither was cancelled, so no post-await check + // could tell them apart. The ref is written before the first await + // and cleared only by the run that owns it, or by a cancel. + if (verifyAbort.current) return; setSubmitting(true); + const abort = new AbortController(); + verifyAbort.current = abort; try { + // First run goes through the same gate as the Providers tab, so + // a dead key cannot be the one the agent starts life with. + const gate = await verifyWizardBeforeSave(nextWizard, { + signal: abort.signal, + }); + if (!checkStillWanted(abort)) return; + if (!gate.proceed) { + setWizard({ ...nextWizard, error: gate.error, submitting: false }); + setSubmitting(false); + return; + } saveProviderWizardToConfig(nextWizard); - props.onFinished("saved_cloud"); + props.onFinished("saved_cloud", gate.warning ?? undefined); } catch (err) { + // An abandoned run does not get to report a failure either: it + // would paint over the screen the operator was handed back, and + // free `submitting` under a check that is still running. + if (!checkStillWanted(abort)) return; const message = err instanceof Error ? err.message : String(err); setWizard({ ...nextWizard, error: message, submitting: false }); setSubmitting(false); + } finally { + if (verifyAbort.current === abort) verifyAbort.current = null; } }, - [props, submitting], + [checkStillWanted, props], + ); + + /** + * The one key-routing path, for both drivers: `useInput` passes the + * live keystroke, a row click passes the Enter it stands for. The + * wizard to act on is an argument rather than the closure's state + * because the click path must act on the wizard its frame drew (see + * `WizardMouseRoute`) — which here is `{ ...wizard, submitting }`, + * the object the render below hands `ProvidersWizard`. + */ + const routeKey = useCallback( + (input: string, key: Key, activeWizard: ProvidersWizardState): void => { + const result = handleProvidersWizardKey(input, key, activeWizard); + if (!result.handled) return; + if ("closed" in result) { + props.onBack(); + return; + } + if ("cancelSubmit" in result && result.cancelSubmit) { + verifyAbort.current?.abort(); + verifyAbort.current = null; + setSubmitting(false); + setWizard({ + ...activeWizard, + submitting: false, + error: "Key check cancelled — press Enter to try again.", + }); + return; + } + if ("submit" in result && result.submit) { + void submit(result.wizard); + return; + } + setWizard(result.wizard); + }, + [props, submit], ); useInput((input, key) => { if (key.ctrl && input === "c") { + verifyAbort.current?.abort(); props.onFinished("aborted"); return; } - const activeWizard = { ...wizard, submitting }; - const result = handleProvidersWizardKey(input, key, activeWizard); - if (!result.handled) return; - if ("closed" in result) { - props.onBack(); - return; - } - if (result.submit) { - submit(result.wizard); - return; - } - setWizard(result.wizard); + routeKey(input, key, { ...wizard, submitting }); }); + /** + * Row clicks act on this screen's wizard, which lives in the state + * above — the default store route would target `providersPanel.wizard`, + * a slice this screen never writes, and every click would be a no-op + * or drive somebody else's wizard. + */ + const mouseRoute = useMemo( + () => ({ + select: (_mouse, frameWizard, cursor) => + setWizard({ ...frameWizard, cursor }), + activate: (_mouse, frameWizard) => routeKey("", returnKey(), frameWizard), + }), + [routeKey], + ); + return ( - + {/* Ink, not a ground: `accent`, for the reason `renderLineField` gives. */} + Cloud LLM provider setup Configure a cloud text provider now. Esc returns to backend choice. - + ); } diff --git a/src/tui/components/coding-mode-chip.tsx b/src/tui/components/coding-mode-chip.tsx new file mode 100644 index 00000000..e9ed0360 --- /dev/null +++ b/src/tui/components/coding-mode-chip.tsx @@ -0,0 +1,78 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; + +import { codingModeLook, type CodingMode } from "../coding-mode.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { readableOn } from "../theme/readable-foreground.js"; +import { theme } from "../theme/theme.js"; + +/** + * The stance the session is in, at the right end of the composer's bar. + * + * Placed after the context chip on purpose. The bar reads left to right + * as *where the work goes* — backend, model, how full the window is — + * and this is the last thing in that sentence: under what rules. It is + * also the one control on the bar that changes what the agent is allowed + * to do, so it wants the end position, where the eye stops. + * + * **Colour carries the meaning, not just the word.** `default` is the + * palette's success tone, `auto` its warn, `bypass permissions` + * its error. That is not decoration: a chip a person stops reading after + * the first week still has to say "you are not in normal mode" from + * across the room, and on a strip this dense the word alone does not. + * `plan` takes the accent instead of a hazard colour — it is the + * *safest* mode, and painting the careful choice in a warning tone would + * be exactly backwards. + * + * Ink drops a colour under `NO_COLOR`, so the label always spells the + * mode out rather than relying on the tone. + */ +export function CodingModeChip({ + mode, + layer, +}: { + mode: CodingMode; + /** + * Mouse layer for the click target. Rendered inside the composer + * overlay, which floats over the chat log, so it registers above the + * base layer — otherwise a covered chat control could win the click. + */ + layer?: number; +}): ReactElement { + const look = codingModeLook(mode); + const background = + look.tone === "accent" + ? theme.colors.accent + : look.tone === "success" + ? theme.colors.success + : look.tone === "warn" + ? theme.colors.warn + : theme.colors.error; + const chip = ( + + {` ${look.label} `} + + ); + const mouse = useMouseCommands(); + // No provider (component tests, the wizard's separate Ink tree): + // render the label and stop. A target that swallowed the click + // without acting would be worse than no target. + if (!mouse) return chip; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + // Opens the menu; it does not cycle. Advancing the ring on a + // bare click made the one control that changes what the agent + // may do the only one with no confirmation and no explanation. + mouse.dispatch({ type: "coding_mode_menu_opened" }); + return true; + }} + > + {chip} + + ); +} diff --git a/src/tui/components/coding-mode-popup.tsx b/src/tui/components/coding-mode-popup.tsx new file mode 100644 index 00000000..1c2e8b2f --- /dev/null +++ b/src/tui/components/coding-mode-popup.tsx @@ -0,0 +1,258 @@ +import { Box, Text } from "ink"; +import type { ReactElement, ReactNode } from "react"; + +import { + CODING_MODES, + codingModeLook, + type CodingMode, +} from "../coding-mode.js"; +import { + MouseTarget, + useMouseCommands, + useMouseTarget, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { chromeTheme } from "../theme/theme.js"; +import { fitToWidth } from "./fit-to-width.js"; + +/** + * Width of the label column: the longest mode name plus the marker, + * the check and the two gutter spaces around them. Measured rather + * than guessed, so renaming a mode cannot silently clip it. + */ +const LABEL_WIDTH = + Math.max(...CODING_MODES.map((mode) => codingModeLook(mode).label.length)) + 6; + +/** + * The narrowest the menu may get before it stops laying the detail + * beside the label. Below this the two columns are fighting, and a + * stacked row reads better than a squeezed one. + */ +const MIN_TWO_COLUMN_WIDTH = 34; + +/** + * Columns the menu needs to show every row in full: the label column + * plus the longest detail plus its leading space. + * + * The menu is sized from its content instead of the content being cut to + * a fixed width. An explanation with its end shaved off is worse than no + * explanation — it reads as a rendering bug, and it still does not answer + * the question the menu exists to answer. + */ +export function codingMenuContentWidth(): number { + // +1 for the space before the detail, +1 for a trailing gutter so the + // longest line does not sit flush against the right border, +2 for + // the border columns themselves. + const detail = + Math.max(...CODING_MODES.map((mode) => codingModeLook(mode).detail.length)) + + 2; + return LABEL_WIDTH + detail + 2; +} + +export interface CodingModePopupProps { + /** Highlighted row, an index into {@link CODING_MODES}. */ + cursor: number; + /** The mode actually in force, marked with a check. */ + active: CodingMode; + /** Rows available in the pane the menu floats over. */ + availableRows: number; + /** Columns available in that pane. */ + availableColumns: number; + /** + * Applies a mode. The same callback Enter fires — passed down rather + * than reached through the mouse context, so a click and a keypress + * cannot drift into two different activation paths. + */ + onActivate: (mode: CodingMode) => void; +} + +/** + * The menu behind the composer's mode chip. + * + * The chip used to cycle the ring on click. That made the one control in + * the app that changes what the agent is *allowed to do* also the only + * one with no confirmation and no explanation: two stray clicks took you + * from `plan` to `auto`, and nothing on screen said what either + * of them meant. A menu costs one extra click and buys the four + * sentences that make the choice a choice. + * + * Drawn the way `composer-switch-popup.tsx` is, and for the same reasons: + * absolutely positioned inside the content pane so nothing below it + * reflows, hung at the bottom so it sits directly above the control that + * opened it, and every interior line padded to the exact inner width — + * a terminal has no compositing, so a row that stops at its content lets + * the chat log show through it. + */ +export function CodingModePopup({ + cursor, + active, + availableRows, + availableColumns, + onActivate, +}: CodingModePopupProps): ReactElement { + // Content-sized, then clamped to the pane. Wanting more room than the + // terminal has is the one case the detail cannot be shown beside the + // label, and `stacked` is what handles it — by giving the detail its + // own line, never by truncating it. + const wanted = codingMenuContentWidth(); + const width = Math.max(24, Math.min(wanted, availableColumns - 2)); + const stacked = width < Math.min(wanted, MIN_TWO_COLUMN_WIDTH) + || width < wanted; + // Interior columns between the two border columns. Ink's `paddingX` is + // not painted by our rows — it leaves real gaps — so the one-column + // gutter is baked into every string instead. + const inner = width - 2; + // Title and footer are ornament: on a pane too short for them the four + // rows are what has to survive, because they are the actual content. + const bodyRows = CODING_MODES.length * (stacked ? 2 : 1); + const chromeSlots = Math.min(2, Math.max(0, availableRows - 2 - bodyRows)); + const showTitle = chromeSlots >= 1; + const showFooter = chromeSlots >= 2; + const height = 2 + chromeSlots + bodyRows; + return ( + + {showTitle ? ( + + {fitToWidth(" CODING MODE", inner)} + + ) : null} + {CODING_MODES.map((mode, idx) => ( + + ))} + {showFooter ? ( + + {fitToWidth(" ↑↓ move · enter apply · esc cancel", inner)} + + ) : null} + + ); +} + +/** + * The popup's own box. It claims presses that land on its border, title + * or footer: a click inside the panel must not fall through to the + * backdrop, which closes it. + */ +function PopupFrame({ + offsetTop, + offsetLeft, + width, + children, +}: { + offsetTop: number; + offsetLeft: number; + width: number; + children: ReactNode; +}): ReactElement { + // The ref goes on the popup box itself rather than on a `MouseTarget` + // wrapper: the box is absolutely positioned, and an extra Box between + // it and the pane would take the offset with it. + const ref = useMouseTarget((hit) => isPrimaryPress(hit.event), { + layer: MOUSE_LAYER_MODAL, + }); + return ( + + {children} + + ); +} + +function ModeRow({ + mode, + inner, + selected, + active, + stacked, + onActivate, +}: { + mode: CodingMode; + inner: number; + selected: boolean; + active: boolean; + /** Detail on its own line, for a pane too narrow for two columns. */ + stacked: boolean; + onActivate: (mode: CodingMode) => void; +}): ReactElement { + const look = codingModeLook(mode); + const marker = selected ? chromeTheme.glyphs.menuCursor : " "; + const check = active ? `${chromeTheme.glyphs.check} ` : ""; + const labelText = ` ${marker} ${check}${look.label}`; + /* + Selection is weight plus the marker, not a second colour: on a + painted panel a colour swap either fights the ground or is too faint + to see, and the marker is the part that survives NO_COLOR. + */ + const body = stacked ? ( + + + {fitToWidth(labelText, inner)} + + {/* + Indented under the label rather than beside it. The detail is + still shown in full — that is the whole point of stacking rather + than truncating — and `fitToWidth` here only pads it out to the + panel's ground. + */} + + {fitToWidth(` ${look.detail}`, inner)} + + + ) : ( + <> + + {fitToWidth(labelText, Math.min(LABEL_WIDTH, inner))} + + + {fitToWidth( + ` ${look.detail}`, + Math.max(0, inner - Math.min(LABEL_WIDTH, inner)), + )} + + + ); + const mouse = useMouseCommands(); + if (!mouse) return {body}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + // One click applies. A first click that only moved the cursor + // would make the menu a two-click control for no gain — the row + // under the pointer is already the one being read. + onActivate(mode); + return true; + }} + > + {body} + + ); +} diff --git a/src/tui/components/composer-overlay.mouse.test.tsx b/src/tui/components/composer-overlay.mouse.test.tsx new file mode 100644 index 00000000..71f416df --- /dev/null +++ b/src/tui/components/composer-overlay.mouse.test.tsx @@ -0,0 +1,244 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; + +import { ClipboardProvider } from "../clipboard/clipboard-context.js"; +import { makeMouseSource } from "../mouse/mouse-source.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js"; +import type { TuiSessionInfo } from "../tui-state.js"; + +/** + * The composer overlay versus the mouse registry. + * + * The expanded composer paints over live chat controls (`[copy]` under + * every reply). Terminals have no compositing and the registry resolves + * clicks by painted rectangles, so the covered control's rectangle + * still contains the click — the overlay's `MOUSE_LAYER_PANEL` + * backstop is the only thing standing between a click on composer + * pixels and a copy nobody asked for. These cases pin that down from + * the outside: through `TuiApp`, real Ink layout, real hit-testing. + */ + +const SESSION: TuiSessionInfo = { + sessionId: "s1", + workingDir: "/tmp/overlay-mouse", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +const strip = (value: string): string => + value.replace(/\[[0-9;]*m/g, ""); + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +/** Screen cell of the LAST line containing `needle`. */ +function locateLast( + frame: string, + needle: string, +): { x: number; y: number } { + const lines = frame.split("\n"); + for (let y = lines.length - 1; y >= 0; y -= 1) { + const x = (lines[y] ?? "").indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function mountApp() { + const bus = makeTuiEventBus(); + const mouse = makeMouseSource(); + const copied: string[] = []; + const submitted: string[] = []; + const clipboard = { + copy: async (text: string) => { + copied.push(text); + return true; + }, + }; + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: (message) => { + submitted.push(message); + }, + }; + const app = render( + + + , + ); + const reply = (text: string): void => + bus.emit({ + type: "agent_event", + event: { type: "llm_event", event: { type: "assistant_reply", text } }, + }); + return { + ...app, + mouse, + copied, + submitted, + reply, + frame: () => strip(app.lastFrame() ?? ""), + }; +} + +const copyRows = (frame: string): number => + frame.split("\n").filter((line) => line.includes("[copy]")).length; + +describe("composer overlay mouse", () => { + it("claims clicks over covered chat controls and releases them on shrink", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("send"), "composer on screen"); + app.reply("REF-ALPHA anchor line"); + app.reply("OMEGA-COVERED bottom line"); + await waitUntil(() => copyRows(app.frame()) === 2, "two copy rows"); + + // Sanity: with the composer collapsed the second reply's `[copy]` + // is clickable. Re-click until it lands — targets register a frame + // after they first paint. + const spot = locateLast(app.frame(), "[copy]"); + await waitUntil(() => { + app.mouse.emit(click(spot.x + 1, spot.y)); + return app.copied.length > 0; + }, "copy click to land while uncovered"); + expect(app.copied[0]).toBe("OMEGA-COVERED bottom line"); + const copiesBefore = app.copied.length; + + // Grow the composer over that control. + app.stdin.write("abc"); + await waitUntil(() => app.frame().includes("abc"), "typed text"); + app.stdin.write("\n\n\n"); + await waitUntil( + () => copyRows(app.frame()) === 1, + "second copy row covered by the overlay", + ); + + // The control is still laid out under the overlay, its rectangle + // still contains the click — the backstop must eat it. + for (let i = 0; i < 5; i += 1) { + app.mouse.emit(click(spot.x + 1, spot.y)); + await delay(40); + } + expect(app.copied.length).toBe(copiesBefore); + + // The composer's own controls stay clickable THROUGH the overlay: + // Send sits inside the expanded frame and must win against the + // backstop (smaller box, same layer). + const send = locateLast(app.frame(), "send →"); + await waitUntil(() => { + app.mouse.emit(click(send.x + 1, send.y)); + return app.submitted.length > 0; + }, "send click to land on the expanded composer"); + expect(app.submitted[0]).toContain("abc"); + + // Submit cleared the buffer, so the composer is collapsed again and + // the covered control is back — intact and clickable. + await waitUntil(() => copyRows(app.frame()) === 2, "copy rows restored"); + const restored = locateLast(app.frame(), "[copy]"); + await waitUntil(() => { + app.mouse.emit(click(restored.x + 1, restored.y)); + return app.copied.length > copiesBefore; + }, "copy click to land again after shrink"); + app.unmount(); + }); + + it("keeps the see-through row above the frame clickable while expanded", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("send"), "composer on screen"); + app.reply("REF-ALPHA anchor line"); + app.reply("OMEGA-COVERED bottom line"); + await waitUntil(() => copyRows(app.frame()) === 2, "two copy rows"); + const collapsedTop = locateLast(app.frame(), "\u256d").y; + + // A two-line draft grows the frame exactly one row, which parks the + // second reply's `[copy]` in the see-through spacer row directly + // above the top border — live pixels, so they must take clicks. + app.stdin.write("abc\n"); + await waitUntil( + () => locateLast(app.frame(), "\u256d").y === collapsedTop - 1, + "composer one row taller", + ); + const spot = locateLast(app.frame(), "[copy]"); + expect(spot.y).toBe(collapsedTop - 2); + + // The backstop hugs the frame, not the whole overlay: a rectangle + // that included the spacer row would eat this click at + // MOUSE_LAYER_PANEL and the visible control would go dead. + await waitUntil(() => { + app.mouse.emit(click(spot.x + 1, spot.y)); + return app.copied.length > 0; + }, "copy click to land in the spacer row"); + expect(app.copied[0]).toBe("OMEGA-COVERED bottom line"); + app.unmount(); + }); + + it("keeps the open menu's rows visible and clickable over a tall draft", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("send"), "composer on screen"); + app.stdin.write("abc"); + await waitUntil(() => app.frame().includes("abc"), "typed text"); + const collapsedTop = locateLast(app.frame(), "\u256d").y; + app.stdin.write("\n".repeat(9)); + await waitUntil( + () => locateLast(app.frame(), "\u256d").y === collapsedTop - 9, + "expanded composer", + ); + + // Ctrl+P. The menu owns input; the overlay clamps to its slot, so + // every menu row is painted — including `Manage`, which the + // ten-line frame's rectangle used to bury. + app.stdin.write("\u0010"); + await waitUntil(() => app.frame().includes("enter go"), "menu open"); + // The row is visible at all — this locate is the half a regression + // breaks first: un-clamped, `Manage` is overpainted and not on + // screen, so there is nothing honest to click. + const manage = locateLast(app.frame(), "Manage"); + // Wait out the backdrop's click grace so a click cannot be read as + // "clicked outside" while the menu's own targets register. + await delay(200); + await waitUntil(() => { + // Re-emit only while still on the root menu: once MANAGE opens, + // more clicks at these coordinates would drill into its rows. + if (!app.frame().includes("MANAGE")) { + app.mouse.emit(click(manage.x + 1, manage.y)); + } + return app.frame().includes("MANAGE"); + }, "the visible Manage row to take the click"); + // The click drove the menu, not the composer behind it: nothing + // was submitted and the draft is intact for when the menu closes. + expect(app.submitted.length).toBe(0); + app.unmount(); + }); +}); diff --git a/src/tui/components/composer-overlay.test.tsx b/src/tui/components/composer-overlay.test.tsx new file mode 100644 index 00000000..41be03ac --- /dev/null +++ b/src/tui/components/composer-overlay.test.tsx @@ -0,0 +1,250 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; + +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js"; +import type { TuiSessionInfo } from "../tui-state.js"; +import { COMPOSER_ROWS } from "./debug-pane.js"; +import { + COMPOSER_CHROME_ROWS, + maxComposerEditorLines, +} from "./composer-overlay.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "s1", + workingDir: "/tmp/overlay", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function callbacks(): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: () => {}, + }; +} + +const strip = (value: string): string => + value.replace(/\u001B\[[0-9;]*m/g, ""); + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Frames commit on Ink's own throttle, so nothing here asserts against + * a fixed wait: poll the frame until the condition holds, then assert. + */ +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +/** Frame row of the first line containing `needle`, or -1. */ +function rowOf(frame: string, needle: string): number { + return frame.split("\n").findIndex((line) => line.includes(needle)); +} + +/** Frame row of the LAST line containing `needle` — the composer's edge. */ +function lastRowOf(frame: string, needle: string): number { + const lines = frame.split("\n"); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (line !== undefined && line.includes(needle)) return i; + } + return -1; +} + +function mount() { + const bus = makeTuiEventBus(); + const app = render( + , + ); + return { bus, ...app, frame: () => strip(app.lastFrame() ?? "") }; +} + +/** + * Two finalised replies fill the bottom of the chat viewport, so the + * transcript has a line high in the pane (the reference that must not + * move) and a line at the pane's bottom edge (the one the expanded + * composer must occlude). + */ +function seedChat(bus: ReturnType): void { + const reply = (text: string): void => + bus.emit({ + type: "agent_event", + event: { type: "llm_event", event: { type: "assistant_reply", text } }, + }); + reply("REF-ALPHA anchor line"); + reply("OMEGA-COVERED bottom line"); +} + +describe("maxComposerEditorLines", () => { + const CASES: ReadonlyArray<{ stageRows: number; expected: number }> = [ + // The default test terminal: 24 rows → an 11-row pane + a 9-row slot. + { stageRows: 20, expected: 10 }, + // A 30-row terminal. + { stageRows: 27, expected: 17 }, + // Boundary where the cap meets its floor of three lines. + { stageRows: 13, expected: 3 }, + { stageRows: 14, expected: 4 }, + // Degenerate stages never push the floor below three. + { stageRows: 10, expected: 3 }, + { stageRows: 0, expected: 3 }, + ]; + for (const { stageRows, expected } of CASES) { + it(`caps a ${stageRows}-row stage at ${expected} editor lines`, () => { + expect(maxComposerEditorLines(stageRows)).toBe(expected); + }); + } + + it("the slot never exceeds the layout budget reserved for it", () => { + // `COMPOSER_ROWS` (the debug-pane budget) errs generous on purpose; + // the painted slot must fit inside it or the budgets upstream + // (`appChromeRows`, `computeChatViewportRows`) stop covering us. + expect(COMPOSER_CHROME_ROWS + 1).toBeLessThanOrEqual(COMPOSER_ROWS); + }); +}); + +describe("composer overlay growth", () => { + it("grows upward over a still transcript and shrinks back", async () => { + const app = mount(); + await waitUntil(() => app.frame().includes("send"), "composer on screen"); + seedChat(app.bus); + await waitUntil( + () => + app.frame().includes("REF-ALPHA") && + app.frame().includes("OMEGA-COVERED"), + "seeded transcript", + ); + + app.stdin.write("abc"); + await waitUntil(() => app.frame().includes("abc"), "typed text"); + const before = app.frame(); + const refRowBefore = rowOf(before, "REF-ALPHA"); + const topBorderBefore = lastRowOf(before, "╭"); + const bottomBorderBefore = lastRowOf(before, "╰"); + expect(refRowBefore).toBeGreaterThanOrEqual(0); + expect(topBorderBefore).toBeGreaterThan(refRowBefore); + + // Three newlines: the composer must grow three rows upward while + // everything above it stays painted on the same rows. + app.stdin.write("\n\n\n"); + await waitUntil( + () => lastRowOf(app.frame(), "╭") === topBorderBefore - 3, + "composer top border three rows higher", + ); + const grown = app.frame(); + // The reference line did not move: the transcript was not reflowed + // and its scroll position did not jump. + expect(rowOf(grown, "REF-ALPHA")).toBe(refRowBefore); + // The bottom edge did not move either — growth is upward only, the + // hint strip under the composer stays where it is. + expect(lastRowOf(grown, "╰")).toBe(bottomBorderBefore); + // The frame is opaque: the second message's `[copy]` control sat on + // a row the expanded frame now covers, so only the first message's + // copy row survives. (The row directly above the frame still shows + // through — that is the overlay's spacer row, deliberately open.) + const copyRows = (frame: string): number => + frame.split("\n").filter((line) => line.includes("[copy]")).length; + expect(copyRows(before)).toBe(2); + expect(copyRows(grown)).toBe(1); + + // Delete the newlines: the original frame comes back byte for byte + // — transcript, borders, everything. One backspace per write: Ink + // folds a burst of DEL bytes into a single keypress, so a batched + // write would only delete one character. + for (let i = 0; i < 3; i += 1) { + app.stdin.write("\u007F"); + await delay(30); + } + await waitUntil( + () => app.frame() === before, + "original frame after shrink", + ); + app.unmount(); + }); + + it("clamps to its slot while the menu is open and re-expands on close", async () => { + const app = mount(); + await waitUntil(() => app.frame().includes("send"), "composer on screen"); + seedChat(app.bus); + await waitUntil( + () => app.frame().includes("OMEGA-COVERED"), + "seeded transcript", + ); + app.stdin.write("abc"); + await waitUntil(() => app.frame().includes("abc"), "typed text"); + const collapsedTop = lastRowOf(app.frame(), "╭"); + + // Nine newlines: a ten-line draft, deep enough that the expanded + // frame's rectangle overlaps the rows where the menu paints. + app.stdin.write("\n".repeat(9)); + await waitUntil( + () => lastRowOf(app.frame(), "╭") === collapsedTop - 9, + "expanded composer", + ); + + // Ctrl+P: the menu owns the keyboard, so the overlay must stop + // fighting it for the stage — the composer paints after the menu, + // and un-clamped it would bury the menu's bottom rows while the + // raised mouse floor kept routing clicks there. + app.stdin.write("\u0010"); + await waitUntil(() => app.frame().includes("enter go"), "menu open"); + const withMenu = app.frame(); + // The composer fell back to its collapsed slot… + expect(lastRowOf(withMenu, "╭")).toBe(collapsedTop); + // …so the whole menu is on screen: its bottom border (the first ╰ + // from the top — the composer's own is the last) closes strictly + // above the composer's frame instead of vanishing under it. + const menuBottom = rowOf(withMenu, "╰"); + expect(menuBottom).toBeGreaterThan(0); + expect(menuBottom).toBeLessThan(lastRowOf(withMenu, "╭")); + + // Esc: the modal is gone, the untouched draft re-expands. + app.stdin.write("\u001b"); + await waitUntil( + () => lastRowOf(app.frame(), "╭") === collapsedTop - 9, + "composer re-expanded after the menu closed", + ); + app.unmount(); + }); + + it("never grows past its cap however many lines are typed", async () => { + const app = mount(); + await waitUntil(() => app.frame().includes("send"), "composer on screen"); + app.stdin.write("x"); + await waitUntil(() => app.frame().includes("x"), "typed text"); + const statusRow = 0; + + // Far more newlines than the 24-row test terminal can seat: the + // cap (10 lines here) must hold the frame's shape steady. + app.stdin.write("\n".repeat(30)); + await delay(300); + const frame = app.frame(); + const topBorder = lastRowOf(frame, "╭"); + // The status bar row is untouched and the composer's top border + // sits strictly below it — the overlay stopped at its cap instead + // of climbing the whole stage. + expect(topBorder).toBeGreaterThan(statusRow + 1); + // Frame height itself did not change: growth happened inside the + // stage, not by pushing the root taller. + const linesTyped = frame.split("\n").length; + app.stdin.write("\n"); + await delay(200); + expect(app.frame().split("\n").length).toBe(linesTyped); + app.unmount(); + }); +}); diff --git a/src/tui/components/composer-overlay.tsx b/src/tui/components/composer-overlay.tsx new file mode 100644 index 00000000..e3cfa836 --- /dev/null +++ b/src/tui/components/composer-overlay.tsx @@ -0,0 +1,128 @@ +import { Box } from "ink"; +import type { ReactElement, ReactNode } from "react"; + +import { useMouseTarget } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; +/** + * The composer's chrome: every row the overlay costs besides the + * editor lines themselves — the see-through spacer above the frame + * (1), the frame's two borders, the blank row above and below the + * buffer, and the meta bar with its own padding row above and below + * (3). Counted off the rendered component and pinned by the + * collapsed-shape frame test in `composer-overlay.test.tsx`. + * + * Deliberately NOT derived from `debug-pane.tsx`'s `COMPOSER_ROWS`: + * that is a *budget* and errs one row generous on purpose (the same + * philosophy as its `RENDER_SAFETY_ROWS`), while the slot below must be + * the exact height the collapsed composer paints — a slot one row too + * tall leaves a permanent blank stripe over the transcript. + */ +export const COMPOSER_CHROME_ROWS = 8; + +/** + * Rows the collapsed composer actually paints: the chrome plus its one + * editor line. This is what the flex column reserves. + */ +export const COMPOSER_COLLAPSED_ROWS = COMPOSER_CHROME_ROWS + 1; + +/** + * Rows of the pane the expanded composer must always leave visible + * under the hairline. Two rows keep the top of the transcript (or the + * splash) peeking out, which is what tells the operator the content is + * covered rather than gone. + */ +const CONTEXT_ROWS_KEPT = 2; + +/** The editor never windows below this, however short the terminal. */ +const MIN_EDITOR_LINES = 3; + +/** + * The growth cap: how many editor lines the composer may show at once + * given the rows of the stage it floats in (content pane + its own + * reserved slot). Derived from the stage rather than the terminal so + * the overlay can never climb past the stage's top edge — i.e. never + * under the status bar, whose rows are outside the stage by + * construction. + */ +export function maxComposerEditorLines(stageRows: number): number { + return Math.max( + MIN_EDITOR_LINES, + stageRows - COMPOSER_CHROME_ROWS - CONTEXT_ROWS_KEPT, + ); +} + +/** + * The fixed-height slot the composer owns in the flex column. It never + * changes size — which is the whole point: a growing buffer must not + * reflow the chat log, the queued strip or the modals, so the flex + * column only ever sees the collapsed height while the real composer + * paints over the slot from {@link ComposerOverlay}. Same rows the + * in-flow `PromptShell` used to take, so `computeChatViewportRows`'s + * chrome budget still holds. + */ +export function ComposerSlot(): ReactElement { + return ; +} + +/** + * Floats the composer over the content pane, bottom-anchored. + * + * The house overlay technique is `menu-popup.tsx`'s: absolute + * positioning inside a relative pane, opaque because terminals have no + * compositing. Two deltas from the menu: + * + * - Anchoring is by inset (`bottom/left/right: 0`), not by a computed + * `marginTop`: Yoga resolves the insets against the parent's final + * size, so the box hugs the stage's bottom edge and grows *upward* + * with its content — no measurement pass, no height arithmetic that + * a wrapped meta-row could put off by one. + * - Opacity comes from `PromptShell`'s own frame: Ink 7 paints a box's + * `backgroundColor` across its full interior (`render-background.js` + * fills every row with spaces), so the frame occludes edge to edge + * without per-line padding. The one see-through row is the spacer + * above the frame (the top margin the shell used to carry), which is + * meant to show the content behind. + * + * The mouse backstop is the popup-frame pattern: a box claims every + * press at `MOUSE_LAYER_PANEL`, so a click on composer pixels can never + * fall through to the chat controls painted beneath. The backstop + * hugs the *frame*, not the whole overlay: the see-through spacer row + * shows live content, so a control painted there must keep receiving + * its clicks — which is why the spacer is a sibling row outside the + * backstop box rather than a margin inside it (a child's margin counts + * into the parent's rectangle). + * The composer's own targets (editor body, Send, the context chip) + * register on the same layer as smaller boxes, so the registry offers + * them the press first. Wheel is declined on purpose — the whole-app + * wheel target at the base layer owns transcript scrolling, exactly as + * it did when the composer sat in the flex column. + */ +export function ComposerOverlay({ + children, +}: { + children: ReactNode; +}): ReactElement { + const backstopRef = useMouseTarget((hit) => isPrimaryPress(hit.event), { + layer: MOUSE_LAYER_PANEL, + }); + return ( + + {/* + The shell's old `marginTop`, hoisted here so the click-dead + backstop rectangle starts at the frame's top border instead of + one row above it. + */} + + + {children} + + + ); +} diff --git a/src/tui/components/composer-send-button.tsx b/src/tui/components/composer-send-button.tsx new file mode 100644 index 00000000..581687cf --- /dev/null +++ b/src/tui/components/composer-send-button.tsx @@ -0,0 +1,74 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { theme } from "../theme/theme.js"; + +/** The label carries its own padding so the chip's ground reads as a button. */ +const SEND_LABEL = " send → "; + +export interface ComposerSendButtonProps { + /** A disabled button still renders: it says the affordance exists. */ + enabled: boolean; + onPress: () => void; + /** + * Mouse layer for the click target. The composer overlay floats over + * the chat log, so its button registers above the base layer — + * otherwise a covered chat control could win the click. + */ + layer?: number; +} + +/** + * The composer's one button, drawn as a chip inside the input field. + * + * It used to sit on the action bar under the field, at the far right. + * That put the app's primary verb a row away from the text it acts on, + * and it spent the one slot on the bar that a status readout wants. In + * the field it stays at the same column and lands next to the caret. + * + * Every colour here is a *pair* taken from the theme rather than a + * literal, and the pair is one the palette already guarantees to be + * opposite: `chipBackground` against `chipForeground`. That is what + * keeps the chip legible across all eleven palettes without a per-theme + * table — the tokens flip polarity with the theme. + * + * A disabled Send drops to `badgeBackground` / `muted`, which is the + * terminal's version of a ghost button: still there, still labelled, + * visibly not pressable. + */ +export function ComposerSendButton({ + enabled, + onPress, + layer, +}: ComposerSendButtonProps): ReactElement { + const background = enabled + ? theme.colors.chipBackground + : theme.colors.badgeBackground; + const foreground = enabled + ? theme.colors.chipForeground + : theme.colors.muted; + const chip = ( + + {SEND_LABEL} + + ); + const mouse = useMouseCommands(); + // No provider (component tests, the wizard's separate Ink tree) or + // nothing to do: render the label and stop. Registering a target that + // swallows the click without acting would be worse than no target. + if (!mouse || !enabled) return chip; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onPress(); + return true; + }} + > + {chip} + + ); +} diff --git a/src/tui/components/context-chip.test.tsx b/src/tui/components/context-chip.test.tsx new file mode 100644 index 00000000..d9fd16a0 --- /dev/null +++ b/src/tui/components/context-chip.test.tsx @@ -0,0 +1,225 @@ +import { render } from "ink-testing-library"; +import { afterAll, describe, expect, it } from "vitest"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import type { ContextUsageView } from "../select-context-usage.js"; +import { mixColor } from "../theme/mix-color.js"; +import { getActiveTheme, setActiveTheme, THEMES, theme } from "../theme/theme.js"; +import { ContextChip, groundFor } from "./context-chip.js"; + +const original = getActiveTheme(); +afterAll(() => setActiveTheme(original)); + +const SGR = new RegExp("\\u001b\\[[0-9;]*m", "g"); + +function usage(overrides: Partial = {}): ContextUsageView { + return { + tokens: 14_100, + contextWindow: 1_000_000, + percent: 1, + conversationTokens: 6400, + conversationCap: 32_000, + conversationPercent: 20, + capSource: "config", + droppedTurns: 0, + sections: [], + ...overrides, + }; +} + +/** + * The chip's own text, minus colour. Ink drops the trailing pad cell + * when the chip is the whole frame; inside the composer the bar's own + * ground paints it, so the expectations here stop at the last glyph. + */ +function label(view: ContextUsageView): string { + const { lastFrame, unmount } = render(); + const text = (lastFrame() ?? "").replace(SGR, ""); + unmount(); + return text; +} + +describe("ContextChip", () => { + /** + * The bar and the numbers are the same quantity: how full the model's + * real context window is. + * + * It used to gauge the transcript against the packer's own ceiling, + * which is a real number and the wrong one to lead with. That ceiling + * is internal, it moves for reasons the operator did not cause, and it + * answers neither of the questions actually being asked at the + * composer — is there room for what I am about to send, and has + * anything already been forgotten? + */ + it("gauges the prompt against the model's window, and prints both", () => { + expect(label(usage())).toBe(" context [ ] 14.1k/1M"); + }); + + it("fills as the window fills", () => { + expect(label(usage({ percent: 0 }))).toContain("[ ]"); + expect(label(usage({ percent: 50 }))).toContain("[==== ]"); + expect(label(usage({ percent: 100 }))).toContain("[========]"); + }); + + /** + * The whole point of the change. Dropped turns are the moment the + * agent stops knowing things it knew a minute ago and the answers + * quietly start getting worse — so the chip says it in words. A + * colour alone was never going to carry that. + */ + it("says out loud when history has been dropped", () => { + expect(label(usage({ droppedTurns: 3 }))).toContain("3 lost"); + expect(label(usage())).not.toContain("lost"); + }); + + it("counts the loss even with nothing else to gauge", () => { + const bare = usage({ + contextWindow: null, + percent: null, + conversationCap: null, + conversationPercent: null, + droppedTurns: 2, + }); + expect(label(bare)).toContain("2 lost"); + }); + + /** + * The bar sits left of the numbers and the chip is right-anchored, so + * a tail that grew a cell at 10k would shove the gauge sideways + * mid-session. + */ + it("holds a steady width as the numbers grow", () => { + const widths = new Set( + [90, 6400, 31_900].map((tokens) => label(usage({ tokens })).length), + ); + expect(widths.size).toBe(1); + }); + + /** + * With no window published there is no honest scale for it, so the + * transcript's own cap is the only one left — and it is labelled, so + * the number cannot be mistaken for a window. + */ + it("falls back to the transcript cap when the window is unknown", () => { + expect(label(usage({ percent: null, contextWindow: null }))).toBe( + " context [== ] 6.4k/32k cap", + ); + }); + + it("shows the raw count when there is no scale at all", () => { + expect( + label( + usage({ + contextWindow: null, + percent: null, + conversationCap: null, + conversationPercent: null, + tokens: 34_812, + }), + ), + ).toBe(" context 34.8k"); + }); +}); + +describe("the chip's ground", () => { + it("steps through three shades of the palette's accent", () => { + setActiveTheme(THEMES["classic-dark"]); + const ground = theme.colors.railBackground; + const accent = theme.colors.accent; + // The ramp follows the bar, and the bar follows the window now. + const at = (percent: number): string => groundFor(usage({ percent })); + expect(at(32)).toBe(mixColor(accent, ground, 0.6)); + expect(at(33)).toBe(mixColor(accent, ground, 0.3)); + expect(at(65)).toBe(mixColor(accent, ground, 0.3)); + expect(at(66)).toBe(accent); + expect(at(100)).toBe(accent); + }); + + /** + * Trimming is the packer working as designed, not a fault, so the + * state gets its own hue rather than a warn colour — and it outranks + * the fill, because "some of this conversation is gone" is the more + * important of the two facts. + */ + it("turns violet once the transcript has been trimmed, at any fill", () => { + setActiveTheme(THEMES["classic-dark"]); + expect(groundFor(usage({ conversationPercent: 12, droppedTurns: 3 }))).toBe( + theme.colors.accentAlt, + ); + expect(groundFor(usage({ conversationPercent: 100, droppedTurns: 3 }))).toBe( + theme.colors.accentAlt, + ); + }); + + it("sits at the quiet end when the fill is unknown", () => { + setActiveTheme(THEMES["classic-dark"]); + expect( + groundFor(usage({ conversationPercent: null, conversationCap: null })), + ).toBe(mixColor(theme.colors.accent, theme.colors.railBackground, 0.6)); + }); +}); + +function press(x: number, y: number, button: "left" | "right" = "left"): TuiMouseEvent { + return { + kind: "press", + button, + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +describe("clicking the chip", () => { + /** + * Mounted in a real registry so the click goes through genuine Yoga + * hit-testing rather than a hand-fed rectangle — the same shape as + * `prompt-meta-bar.test.tsx`. + */ + async function mount(): Promise<{ + registry: MouseTargetRegistry; + actions: TuiAction[]; + frame: () => string; + unmount: () => void; + }> { + const registry = new MouseTargetRegistry(); + const actions: TuiAction[] = []; + const { lastFrame, unmount } = render( + actions.push(action)} + callbacks={{} as TuiAppCallbacks} + getState={() => ({}) as TuiState} + > + + , + ); + // Ink commits on its own throttle and React registers the target in + // the effect after that commit, so a freshly mounted chip is not + // hit-testable on the very first tick. + await new Promise((resolve) => setTimeout(resolve, 120)); + return { registry, actions, frame: () => (lastFrame() ?? "").replace(SGR, ""), unmount }; + } + + it("opens the detail panel", async () => { + const { registry, actions, frame, unmount } = await mount(); + const x = frame().indexOf("context"); + expect(registry.dispatch(press(x, 0))).toBe(true); + expect(actions).toEqual([{ type: "context_panel_toggled" }]); + unmount(); + }); + + it("ignores a right-button press", async () => { + const { registry, actions, frame, unmount } = await mount(); + const x = frame().indexOf("context"); + expect(registry.dispatch(press(x, 0, "right"))).toBe(false); + expect(actions).toEqual([]); + unmount(); + }); +}); diff --git a/src/tui/components/context-chip.tsx b/src/tui/components/context-chip.tsx new file mode 100644 index 00000000..ce32c71d --- /dev/null +++ b/src/tui/components/context-chip.tsx @@ -0,0 +1,199 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import type { ContextUsageView } from "../select-context-usage.js"; +import { mixColor } from "../theme/mix-color.js"; +import { readableOn } from "../theme/readable-foreground.js"; +import { theme } from "../theme/theme.js"; +import { formatTokens } from "./format-tokens.js"; +import { renderProgressBar } from "./render-progress-bar.js"; + +/** Cells of gauge. Eight reads as a bar and still fits a 56-column bar. */ +const GAUGE_WIDTH = 8; + +/** + * Share of the toolbar's own ground mixed into the accent at each step. + * + * Fading *toward the ground the chip sits on* is what makes one rule + * work on a light palette and a dark one: `classic-light`'s deep blue + * pulled most of the way to its pale rail is literally pale blue, + * `toxic-green`'s acid green pulled to its dark rail is a quiet dimmed + * green, and both say the same thing — this control is not asking for + * attention yet. The chip gets louder as the window fills. + * + * The two values are not eyeballed. `readable-foreground.test.ts` walks + * every palette and fails if either mixed step drops below a 4.5:1 + * contrast ratio against the ink `readableOn` picks for it; these are + * the largest fades that clear it on all six. + */ +const FADE_LOW = 0.6; +const FADE_MID = 0.3; + +/** Percent boundaries between the three blues. */ +const STEP_LOW = 33; +const STEP_MID = 66; + +/** + * The composer's context readout: how full the model's window is, drawn + * as a button because it behaves like one. + * + * **What the gauge measures.** The transcript against the ceiling it is + * packed to, not the prompt against the model's window. The window is + * the wrong scale for a bar: a 1M-token model sits at 1% all session and + * the gauge never says anything. The transcript's cap is the number that + * moves, and reaching it is precisely when `packConversation` starts + * dropping the oldest turns — so the bar filling up *is* the warning. + * + * It is also the only scale that always exists. The window is unknown on + * any cloud model nobody has published a context length for; + * `conversationCapEffective` is on every built prompt, falling back to + * the configured cap when there is no window to clamp against. + * + * Both numbers are printed beside the bar. Nothing here is measured + * against a scale the operator cannot see. + * + * **Why the colour ramp.** Three steps of the palette's own accent, then + * violet once the transcript has been trimmed. Violet rather than a warn + * colour on purpose: trimming is the design working, not a fault, and + * `warn` would send an operator looking for the error that is not there. + * It is also the only signal for that state — no counter, no glyph. The + * detail view says how many turns went. + * + * **Why occupancy and not spend.** Context here is not monotonic: it + * falls when the packer trims and when the memory fabric lifts facts out + * of the transcript. A cumulative token counter would climb past 100% + * and answer a question nobody asked. + * + * Clicking it opens the breakdown — see `context-panel.tsx`. That is + * what makes the button ground honest rather than decorative. + */ +export function ContextChip({ + usage, + layer, +}: { + usage: ContextUsageView; + /** + * Mouse layer for the click target. Rendered inside the composer + * overlay, which floats above the chat log, so the chat surface + * passes the overlay's raised layer — otherwise a covered chat + * control could win the click. + */ + layer?: number; +}): ReactElement { + const background = groundFor(usage); + const label = ` context ${chipBody(usage)} `; + const chip = ( + + {label} + + ); + const mouse = useMouseCommands(); + // No provider (component tests, the wizard's separate Ink tree): + // render the label and stop. A target that swallows the click without + // acting would be worse than no target. + if (!mouse) return chip; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "context_panel_toggled" }); + return true; + }} + > + {chip} + + ); +} + +/** + * What the chip prints after the word `context`. + * + * The window first, whenever anything knows it. The gauge used to + * measure the *transcript against its own cap*, which is a real number + * and the wrong one to lead with: it is a budget internal to the + * packer, it moves for reasons the operator did not cause, and it says + * nothing about the question actually being asked at the composer — + * *is there room for what I am about to send, and has anything already + * been forgotten?* + * + * So: `39.9k/48k`, prompt against the model's real window, gauged. + * Where turns have already been dropped the chip says so in words, + * because that is the moment the agent stops knowing things it knew a + * minute ago and the answers start quietly getting worse. A colour + * alone was never going to carry that. + * + * With no window known — a cloud model nobody published a length for — + * it falls back to the transcript gauge, which is the only scale that + * still exists. A bar drawn against a window nobody knows would be a + * fabrication. + */ +export function chipBody(usage: ContextUsageView): string { + // Tasks, not rows. "12 lost" says nothing about how far back the agent + // can still see; "3 tasks" is the unit the operator set the limit in + // and the one they can act on. + const lost = + usage.droppedPairs > 0 + ? ` · ${usage.droppedPairs} task${usage.droppedPairs === 1 ? "" : "s"} lost` + : usage.droppedTurns > 0 + ? ` · ${usage.droppedTurns} lost` + : ""; + const tasks = usage.pairsCap > 0 ? `${usage.pairs}/${usage.pairsCap} tasks · ` : ""; + if (usage.contextWindow !== null && usage.percent !== null) { + return `[${renderProgressBar(usage.percent, GAUGE_WIDTH)}] ${tasks}${pair( + usage.tokens, + usage.contextWindow, + )}${lost}`; + } + if (usage.conversationCap === null || usage.conversationPercent === null) { + // Nothing has set a scale yet. The total is still worth showing — it + // is the only number that says whether this session is big. + return `${formatTokens(usage.tokens)}${lost}`; + } + return `[${renderProgressBar( + usage.conversationPercent, + GAUGE_WIDTH, + )}] ${pair(usage.conversationTokens, usage.conversationCap)} cap${lost}`; +} + +/** The chip's ground: three steps of accent, then violet once trimmed. */ +export function groundFor(usage: ContextUsageView): string { + if (usage.droppedPairs > 0 || usage.droppedTurns > 0) { + return theme.colors.accentAlt; + } + const ground = theme.colors.railBackground; + const accent = theme.colors.accent; + // The ramp follows the same number the bar does — how full the window + // is — so the chip gets louder as room runs out. Unknown fill sits at + // the quiet end: that is a readout of a session which has barely + // started, not a warning about one that has not. + const fill = usage.percent ?? usage.conversationPercent; + if (fill === null || fill < STEP_LOW) return mixColor(accent, ground, FADE_LOW); + if (fill < STEP_MID) return mixColor(accent, ground, FADE_MID); + return accent; +} + +/** + * `6400 / 32000` -> ` 6.4k/32k`, right-aligned in a fixed field. + * + * The padding is not cosmetic: the bar sits to the left of this text and + * the chip is right-anchored on the toolbar, so a tail that grew a cell + * as the transcript crossed 10k would shift the whole gauge sideways on + * an ordinary turn. + */ +function pair(tokens: number, cap: number): string { + return `${formatTokens(tokens)}/${formatTokens(cap)}`.padStart(PAIR_WIDTH); +} + +/** + * Fits `115.3k/131.1k` — a full 128k window, which is the widest pair + * an ordinary session produces. It was 10 while the right-hand number + * was the transcript's own cap and never had a `k` on both sides; + * gauging the window put one there, and a field that was too short + * would let the pair grow a cell as the prompt crossed 100k and shove + * the whole gauge sideways mid-session. + */ +const PAIR_WIDTH = 13; + diff --git a/src/tui/components/context-panel.test.tsx b/src/tui/components/context-panel.test.tsx new file mode 100644 index 00000000..d404e290 --- /dev/null +++ b/src/tui/components/context-panel.test.tsx @@ -0,0 +1,323 @@ +import { Box } from "ink"; +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import type { ContextUsageView } from "../select-context-usage.js"; +import { ContextPanel } from "./context-panel.js"; + +const SGR = new RegExp("\\u001b\\[[0-9;]*m", "g"); + +const SECTIONS = [ + { label: "prompt scaffold", tokens: 5240 }, + { label: "conversation", tokens: 31_880 }, + { label: "recalled memory", tokens: 2150 }, + { label: "session facts", tokens: 610 }, +]; + +function usage(overrides: Partial = {}): ContextUsageView { + return { + tokens: 39_880, + contextWindow: 131_072, + percent: 30, + conversationTokens: 31_880, + conversationCap: 32_000, + conversationPercent: 100, + capSource: "config", + droppedTurns: 0, + pairs: 8, + pairsCap: 20, + droppedPairs: 0, + // Eight tasks at a flat 4k each, so a projection is easy to predict: + // N tasks costs `overhead + N * 4000`. + pairCosts: [4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000], + sections: SECTIONS, + ...overrides, + }; +} + +function lines( + view: ContextUsageView | null, + columns = 100, + rows = 24, + reserved: number | null = 4096, + pairsDraft: number | null = null, + onStepPairs?: (delta: number) => void, +): string[] { + const { lastFrame, unmount } = render( + + + , + ); + const out = (lastFrame() ?? "") + .replace(SGR, "") + .split("\n") + .filter((line) => line.trim().length > 0); + unmount(); + return out; +} + +describe("ContextPanel", () => { + it("titles itself with the prompt total and the window", () => { + expect(lines(usage())[1]).toContain("context · 39.9k of 131.1k window · 30%"); + }); + + it("lists every section with its tokens and share", () => { + const body = lines(usage()).join("\n"); + expect(body).toContain("conversation 31.9k 24%"); + expect(body).toContain("prompt scaffold 5.2k 4%"); + }); + + /** + * Scaled against the window, every bar but the transcript's rounds to + * nothing. Scaled against the largest section they say what the panel + * exists to say — where the tokens went, relative to each other. + */ + it("scales the row gauges to the largest section", () => { + const body = lines(usage()); + const conversation = body.find((l) => l.includes("conversation")) ?? ""; + const recalled = body.find((l) => l.includes("recalled memory")) ?? ""; + expect(conversation).toContain("=========="); + expect(recalled).toContain(" ="); + expect(recalled).not.toContain("=="); + }); + + /** A section that rounds to nothing still cost something. */ + it("writes <1% rather than 0% for a section that rounds away", () => { + expect(lines(usage()).join("\n")).toContain("session facts 610 <1%"); + }); + + it("accounts for the reply reservation and what is left", () => { + const body = lines(usage()).join("\n"); + expect(body).toContain("reserved for reply 4.1k"); + // 131072 − 39880 − 4096 = 87096 + expect(body).toContain("free 87.1k"); + }); + + /** + * The estimator over-counts, so a prompt can measure larger than the + * window it fit into. Negative free space would read as a bug. + */ + it("floors free space at zero when the estimate overshoots", () => { + const body = lines(usage({ tokens: 140_000, percent: 100 })).join("\n"); + expect(body).toContain("free 0 0%"); + }); + + it("drops the window accounting entirely when the window is unknown", () => { + const body = lines( + usage({ contextWindow: null, percent: null }), + 100, + 24, + null, + ).join("\n"); + expect(body).toContain("window unknown"); + expect(body).not.toContain("free"); + expect(body).not.toContain("%"); + // The selector never depended on the window: how many tasks to send + // is a choice you can still make when nobody published a length. + expect(body).toContain("tasks per turn"); + }); + + /** + * The chip's violet is the only signal that the transcript was + * trimmed. Without this line, "why did it change colour" has no answer + * anywhere in the app. + */ + it("says how many tasks were dropped", () => { + // Tasks, not rows: rows are what the packer counts, tasks are what + // the operator set the limit in and the only unit that answers "how + // far back can it still see". + const footer = lines(usage({ droppedPairs: 12 })).at(-2) ?? ""; + expect(footer).toContain("12 earlier tasks dropped"); + expect(lines(usage({ droppedPairs: 1 })).at(-2) ?? "").toContain( + "1 earlier task dropped", + ); + expect(lines(usage()).at(-2) ?? "").toContain("esc to close"); + }); + + /** + * Terminals have no z-index: an overlay hides what is under it only by + * painting every one of its own cells. A row that stops at its content + * lets the chat show through. + */ + it("pads every interior row to the panel's full width", () => { + const body = lines(usage()); + const width = body[0]?.trimStart().length ?? 0; + for (const line of body) { + expect(line.trimStart().length, line).toBe(width); + } + }); + + it("clamps to a narrow pane without spilling out of it", () => { + for (const columns of [40, 60, 100]) { + for (const line of lines(usage(), columns)) { + expect(line.length, `${columns}: ${line}`).toBeLessThanOrEqual(columns); + } + } + }); + + it("never grows taller than the pane it floats over", () => { + for (const rows of [8, 12, 24]) { + expect(lines(usage(), 100, rows).length).toBeLessThanOrEqual(rows); + } + }); +}); + +describe("before anything has been measured", () => { + /** + * The panel is reachable from the menu and from `/context` on a fresh + * session, where no prompt has been built yet. It takes the keyboard + * either way, so it has to paint something — an invisible modal is a + * stuck terminal from the operator's side. + */ + it("says so rather than rendering nothing", () => { + const body = lines(null, 100, 24, null); + expect(body.join("\n")).toContain("not measured yet"); + expect(body.join("\n")).toContain("esc to close"); + }); + + it("still paints every cell of its own box", () => { + const body = lines(null, 100, 24, null); + const width = body[0]?.trimStart().length ?? 0; + for (const line of body) { + expect(line.trimStart().length, line).toBe(width); + } + }); +}); + + +/** + * What stood below the rule was three lines of prose about a token + * ceiling — a `transcript` measurement, a sentence naming + * `agent.conversationMaxTokens`, and a button that set it to auto. All + * of it asked the operator to reason in tokens about a limit nobody + * pictures in tokens. + * + * One control replaces the lot: the number of tasks the next prompt will + * carry, with the cost of that choice recalculated above it as they + * move. + */ +describe("the task selector", () => { + it("shows how many tasks the next prompt will carry", () => { + const body = lines(usage()).join("\n"); + expect(body).toContain("tasks per turn"); + expect(body).toContain("20"); + }); + + it("offers a button either side of the number", () => { + const body = lines(usage()).join("\n"); + expect(body).toContain("−"); + expect(body).toContain("+"); + }); + + it("has nothing left of the token ceiling it replaced", () => { + const body = lines(usage()).join("\n"); + expect(body).not.toContain("set auto"); + expect(body).not.toContain("capped by"); + expect(body).not.toContain("conversationMaxTokens"); + expect(body).not.toContain("before older turns go"); + expect(body).not.toContain("transcript"); + }); + + it("says which keys work it", () => { + expect(lines(usage()).join("\n")).toContain("- / + to change"); + }); + + it("shows the selection being made, not the one last measured", () => { + const body = lines(usage(), 100, 24, 4096, 4).join("\n"); + expect(body).toContain(" 4 "); + }); +}); + +/** + * The point of the control: the numbers above it are the consequence of + * the choice, so they have to move with it. + */ +describe("recalculating as the selector moves", () => { + const percentOf = (body: string): number => + Number(/window · (\d+)%/.exec(body)?.[1] ?? "-1"); + + it("recalculates the whole readout, not one line of it", () => { + // overhead 8000 + 4 tasks x 4000 = 24000 of 131072 = 18%. + const body = lines(usage(), 100, 24, 4096, 4).join("\n"); + expect(percentOf(body)).toBe(18); + expect(body).toContain("24k of 131.1k window"); + }); + + it("moves the conversation row with it", () => { + const body = lines(usage(), 100, 24, 4096, 2).join("\n"); + // Two tasks at 4k. The row the transcript lives in must follow the + // selector, or the breakdown contradicts the total above it. + expect(body).toMatch(/conversation\s+8k/); + }); + + it("gives the window back as tasks come off", () => { + const freeOf = (draft: number | null): string => + lines(usage(), 100, 24, 4096, draft).find((l) => l.includes("free")) ?? ""; + expect(freeOf(8)).not.toBe(freeOf(2)); + expect(freeOf(2)).toContain("%"); + }); + + it("shrinks monotonically as the operator asks for less", () => { + const at = (draft: number): number => + percentOf(lines(usage(), 100, 24, 4096, draft).join("\n")); + expect(at(8)).toBeGreaterThan(at(4)); + expect(at(4)).toBeGreaterThan(at(1)); + }); + + it("shows the measured figures until the selector is touched", () => { + // Untouched, the panel must report what the prompt actually did — + // projecting the same number would re-round it and show a total that + // disagrees with the one the last turn was built against. + const body = lines(usage()).join("\n"); + expect(percentOf(body)).toBe(30); + }); + + it("never prices more tasks than the session holds", () => { + const all = lines(usage(), 100, 24, 4096, 8).join("\n"); + const more = lines(usage(), 100, 24, 4096, 50).join("\n"); + expect(percentOf(all)).toBe(percentOf(more)); + }); +}); + +/** + * `menuPaneRows` floors at 6, so that is the shortest pane the panel + * will ever be handed. It has to fit — a panel two rows taller than its + * pane paints over the composer, and terminals have no z-index to sort + * it out afterwards. + */ +describe("on the shortest pane the app can hand it", () => { + const drawn = (rows: number): string[] => + lines(usage(), 100, rows).filter((l) => l.trim().length > 0); + + it("fits a six-row pane", () => { + expect(drawn(6).length).toBeLessThanOrEqual(6); + }); + + it("keeps the selector — the reason the panel was opened", () => { + expect(drawn(6).join("\n")).toContain("tasks per turn"); + }); + + it("keeps the total, which the breakdown only itemises", () => { + expect(drawn(6).join("\n")).toContain("window"); + }); + + it("draws no rule with nothing left to separate", () => { + // Two rules and nothing between them reads as a rendering fault. + // Interior rules only — the frame's own top and bottom are drawn + // from the same glyph and are not what this is about. + const rules = drawn(6).filter( + (l) => /│[─—]+│/.test(l.replace(/\s/g, "")), + ); + expect(rules.length).toBeLessThanOrEqual(1); + }); + + it("brings the breakdown back when there is room", () => { + expect(drawn(24).join("\n")).toContain("prompt scaffold"); + }); +}); diff --git a/src/tui/components/context-panel.tsx b/src/tui/components/context-panel.tsx new file mode 100644 index 00000000..6c0eaabb --- /dev/null +++ b/src/tui/components/context-panel.tsx @@ -0,0 +1,404 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { + MouseTarget, + useMouseCommands, + useMouseTarget, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { + usageAtPairs, + type ContextUsageView, +} from "../select-context-usage.js"; +import { readableOn } from "../theme/readable-foreground.js"; +import { chromeTheme } from "../theme/theme.js"; +import { fitToWidth } from "./fit-to-width.js"; +import { formatTokens } from "./format-tokens.js"; +import { renderProgressBar } from "./render-progress-bar.js"; + +/** Panel width, clamped to the pane on narrow terminals. */ +const PREFERRED_WIDTH = 58; +/** + * The panel at its smallest: border (2) + title + hairline + selector + + * footer. Everything else is sheddable. + * + * `menuPaneRows` floors at 6, so this is exactly what has to fit on the + * shortest pane the app will ever hand it. The breakdown rows and the + * rule above them are dropped together when they do not fit — a rule + * separating nothing is worse than no rule — because the selector is + * the reason to open the panel and the title still carries the total. + */ +const CHROME_ROWS = 6; +/** Columns held for the section name, so the numbers form a column. */ +const LABEL_WIDTH = 20; +/** Columns held for the token count. */ +const TOKENS_WIDTH = 8; +/** Columns held for the share. */ +const PERCENT_WIDTH = 5; +/** Cells of mini-gauge on each row. */ +const ROW_GAUGE = 10; + +export interface ContextPanelProps { + /** + * `null` before the first prompt of the session has been built. The + * panel still renders: it is reachable from the menu and from + * `/context`, and a surface that takes the keyboard and then paints + * nothing is worse than one that says it has nothing yet. + */ + usage: ContextUsageView | null; + /** + * Task count currently selected. `null` means the selector has not + * been touched this visit and the panel shows what the last prompt + * actually measured. + */ + pairsDraft?: number | null; + /** Step the selector by `delta`, clamped to 1..100 by the reducer. */ + onStepPairs?: (delta: number) => void; + /** Rows available in the pane the panel floats over. */ + availableRows: number; + /** Columns available in that pane. */ + availableColumns: number; + /** + * Tokens the runtime holds back for the model's own reply. Rendered as + * its own line under the rule: it is not in the prompt, but it is the + * reason the prompt cannot grow into the last of the window. + */ + reservedForReply: number | null; + /** + * Switches `agent.conversationMaxTokens` to auto. Absent hides the + * button — the panel is also rendered by tests and by surfaces with + * no way to write config, and a button that did nothing when pressed + * would be worse than no button. + */ +} + +/** + * Where the context window went, opened by clicking the composer's + * context chip (or `/context`). + * + * The chip answers "how full"; this answers "with what", which is the + * question an operator actually acts on — a session that is 80% full of + * conversation wants `/clear`, one that is 80% full of loaded tools and + * recalled memory wants a different fix entirely, and no other surface + * in the app distinguishes them. + * + * Rendered as a true overlay, the same way `MenuPopup` is: absolutely + * positioned inside the content pane so nothing below it reflows, and + * every interior line padded to the panel's exact inner width, because a + * terminal has no z-index and occlusion has to be painted. + */ +export function ContextPanel({ + usage: measured, + availableRows, + availableColumns, + reservedForReply, + pairsDraft = null, + onStepPairs, +}: ContextPanelProps): ReactElement { + const width = Math.max(32, Math.min(PREFERRED_WIDTH, availableColumns - 2)); + const inner = width - 2; + if (measured === null) { + return ( + + + {fitToWidth(" context · not measured yet", inner)} + + + {chromeTheme.glyphs.toolBoxHorizontal.repeat(Math.max(0, inner))} + + + {fitToWidth(" send a message — the breakdown comes from the", inner)} + + + {fitToWidth(" prompt the agent actually builds", inner)} + + + {fitToWidth(" esc to close", inner)} + + + ); + } + // Every figure below is read off one view, so they cannot disagree. + // Only project once the operator has actually moved the selector: + // until then the panel should show what was measured, not an estimate + // of the same thing that rounds a few tokens differently. + const selected = pairsDraft ?? measured.pairsCap; + const usage = + pairsDraft === null || pairsDraft === measured.pairsCap + ? measured + : usageAtPairs(measured, pairsDraft); + const rows = buildRows(usage, reservedForReply); + // Row gauges are scaled to the biggest section, not to the window. + // Against the window every bar but one rounds to nothing — the + // transcript is 24% and the rest are noise — and a chart where every + // bar is empty is a worse answer than no chart. The percentage column + // still carries the absolute share. + const largest = Math.max(1, ...usage.sections.map((s) => s.tokens)); + // The breakdown costs its own rule as well as its rows, so it needs + // two spare lines before the first one is worth drawing. + const roomForRows = availableRows - CHROME_ROWS - 1; + const bodyRows = + roomForRows >= 1 ? Math.min(rows.length, roomForRows) : 0; + const visible = rows.slice(0, bodyRows); + const height = CHROME_ROWS + (visible.length > 0 ? visible.length + 1 : 0); + const offsetTop = Math.max(0, Math.floor((availableRows - height) / 2)); + const offsetLeft = Math.max(0, Math.floor((availableColumns - width) / 2)); + return ( + + + {fitToWidth(` ${title(usage)}`, inner)} + + {visible.length === 0 ? null : ( + + {chromeTheme.glyphs.toolBoxHorizontal.repeat(Math.max(0, inner))} + + )} + {visible.map((row) => ( + + {fitToWidth(renderRow(row, usage, largest), inner)} + + ))} + + {chromeTheme.glyphs.toolBoxHorizontal.repeat(Math.max(0, inner))} + + {/* + The one control, where three lines of prose used to be. + + Those lines named `agent.conversationMaxTokens` and offered to + set it to auto — a token ceiling, described in tokens, for a + limit nobody reasons about in tokens. The selector says the same + thing in the unit the operator thinks in, and every number above + it recalculates as they work it, so the consequence of the + choice is on screen while the choice is being made rather than + one turn later. + */} + + {fitToWidth(` ${footer(usage)}`, inner)} + + ); +} + +interface PanelRow { + label: string; + tokens: number; + /** Accounting rather than content: reserved headroom and free space. */ + dim?: boolean; +} + +/** + * The prompt's sections, then the two lines that account for the rest of + * the window. Free space is what is left after the prompt and the + * reply's reservation — it can go negative on an over-count, and is + * floored at zero rather than shown as a negative, which would read as a + * bug rather than as a full window. + */ +function buildRows( + usage: ContextUsageView, + reservedForReply: number | null, +): readonly PanelRow[] { + const rows: PanelRow[] = usage.sections.map((section) => ({ + label: section.label, + tokens: section.tokens, + })); + if (usage.contextWindow === null) return rows; + if (reservedForReply !== null && reservedForReply > 0) { + rows.push({ label: "reserved for reply", tokens: reservedForReply, dim: true }); + } + const free = + usage.contextWindow - usage.tokens - (reservedForReply ?? 0); + rows.push({ label: "free", tokens: Math.max(0, free), dim: true }); + return rows; +} + +function renderRow( + row: PanelRow, + usage: ContextUsageView, + largest: number, +): string { + const label = ` ${row.label}`.padEnd(LABEL_WIDTH); + const tokens = formatTokens(row.tokens).padStart(TOKENS_WIDTH); + if (usage.contextWindow === null) return `${label}${tokens}`; + const share = (row.tokens / usage.contextWindow) * 100; + // A section that rounds to nothing still cost something. `0%` claims + // it was free. + const rounded = Math.round(share); + const percent = (rounded === 0 && row.tokens > 0 ? "<1%" : `${rounded}%`).padStart( + PERCENT_WIDTH, + ); + // Accounting rows get their share but no gauge: a bar for "free" would + // compete with the bars above it for the same eye, and it is the one + // quantity the reader can infer from the others. + if (row.dim) return `${label}${tokens}${percent}`; + const relative = (row.tokens / largest) * 100; + return `${label}${tokens}${percent} ${renderProgressBar(relative, ROW_GAUGE)}`; +} + +function title(usage: ContextUsageView): string { + if (usage.contextWindow === null) { + return `context · ${formatTokens(usage.tokens)} · window unknown`; + } + return `context · ${formatTokens(usage.tokens)} of ${formatTokens( + usage.contextWindow, + )} window · ${usage.percent}%`; +} + +/** + * How many tasks the next prompt will carry, and the two buttons that + * change it. + * + * A selector rather than a sentence. What stood here named a token + * ceiling and offered to lift it, which asked the operator to reason in + * a unit they do not think in about a limit they cannot picture. This + * is the number they set, in the unit they set it in, with the cost of + * every value visible above it as they move. + */ +function TaskSelector({ + selected, + inner, + onStep, +}: { + selected: number; + inner: number; + onStep?: (delta: number) => void; +}): ReactElement { + const label = " tasks per turn".padEnd(LABEL_WIDTH); + const value = String(selected).padStart(3); + return ( + + {label} + onStep(-1) } : {})} + /> + + {` ${value} `} + + = PAIRS_MAX} + {...(onStep ? { onPress: () => onStep(1) } : {})} + /> + + {fitToWidth( + ` sent each turn (${PAIRS_MIN}-${PAIRS_MAX})`, + Math.max(0, inner - LABEL_WIDTH - 11), + )} + + + ); +} + +/** The bounds the schema enforces, mirrored so the UI cannot offer more. */ +const PAIRS_MIN = 1; +const PAIRS_MAX = 100; + +function StepButton({ + glyph, + disabled, + onPress, +}: { + glyph: string; + disabled: boolean; + onPress?: () => void; +}): ReactElement { + const ground = disabled + ? chromeTheme.colors.badgeBackground + : chromeTheme.colors.accent; + const face = ( + + {glyph} + + ); + const mouse = useMouseCommands(); + // No mouse provider: still draw the face. `-` and `+` work from the + // keyboard either way, and a control that vanished without a mouse + // would take the only hint that the keys exist with it. + if (!mouse || disabled || !onPress) return face; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onPress(); + return true; + }} + > + {face} + + ); +} + +/** + * The footer explains the chip's violet, which is the state's only other + * signal. Without it "why did it change colour" has no answer anywhere + * in the app. + */ +function footer(usage: ContextUsageView): string { + const keys = "- / + to change · esc to close"; + if (usage.droppedPairs > 0) { + return `${usage.droppedPairs} earlier task${ + usage.droppedPairs === 1 ? "" : "s" + } dropped · ${keys}`; + } + return keys; +} + +/** + * The panel's own box. Claims presses so a click on the border or the + * footer cannot fall through to the backdrop and close the thing the + * operator just opened. + */ +function PanelFrame({ + offsetTop, + offsetLeft, + width, + children, +}: { + offsetTop: number; + offsetLeft: number; + width: number; + children: React.ReactNode; +}): ReactElement { + const mouse = useMouseCommands(); + const ref = useMouseTarget( + (hit) => (mouse ? isPrimaryPress(hit.event) : false), + { layer: MOUSE_LAYER_MODAL }, + ); + return ( + + {children} + + ); +} diff --git a/src/tui/components/debug-pane-budget.test.ts b/src/tui/components/debug-pane-budget.test.ts new file mode 100644 index 00000000..00006299 --- /dev/null +++ b/src/tui/components/debug-pane-budget.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + APP_CHROME_ROWS_BASE, + appChromeRows, + steppedPanelRendered, + steppedPanelRows, +} from "./debug-pane.js"; + +/** + * The LLM and Models panels collapse in two steps, not continuously: a + * fixed short form up to 15 rows and a fixed tall form from 16. So the + * budget they are handed is not a ceiling they respect — offer them 17 + * and they render 20. Ink 7 does not clip an over-tall frame; it paints + * over the rows above, which on a default 80x24 terminal means the + * status bar and the hairline get overwritten by panel content. + * + * Reclaiming the composer's rows on the Manage tabs walked straight into + * that band, so those two panels keep the pre-reclaim budget. This sweep + * is the guard. + */ +describe("stepped panel budget", () => { + it("never offers the tall form more rows than the pane can hold", () => { + // The band that bites: a budget of 16..19 flips the panel to its + // 20-row form. That is only safe when the pane really has 20 rows. + for (let rows = 18; rows <= 60; rows += 1) { + const offered = steppedPanelRows(rows, false); + const available = rows - appChromeRows(false) - 3 - 1; + if (steppedPanelRendered(offered) === 20) { + expect( + available, + `terminal ${rows} rows: tall panel (20) offered into ${available}`, + ).toBeGreaterThanOrEqual(20); + } + } + }); + + it("never renders taller without the composer than with it", () => { + // Reclaiming the composer's six rows must not make a screen worse + // than it was when the composer was still taking them. (Panels that + // already overflowed a very short terminal keep doing so — that is + // an older bug, and this sweep pins that it is not made worse.) + for (let rows = 18; rows <= 60; rows += 1) { + const withComposer = steppedPanelRendered(steppedPanelRows(rows, true)); + const without = steppedPanelRendered(steppedPanelRows(rows, false)); + expect(without, `terminal ${rows} rows`).toBeLessThanOrEqual(withComposer); + } + }); + + it("counts the composer's rows only when the composer is on screen", () => { + expect(appChromeRows(false)).toBe(APP_CHROME_ROWS_BASE); + expect(appChromeRows(true)).toBeGreaterThan(appChromeRows(false)); + }); +}); diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index 0946055c..eaf5cbf0 100644 --- a/src/tui/components/debug-pane.tsx +++ b/src/tui/components/debug-pane.tsx @@ -1,6 +1,9 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; import { EventFeed } from "../event-feed.js"; import { LogsTab } from "../logs-tab.js"; import { ReasoningTab } from "../reasoning-tab.js"; @@ -29,6 +32,12 @@ import { ProvidersPanel } from "./providers-panel.js"; interface DebugPaneProps { state: TuiState; maxVisible: number; + /** + * Whether the composer is on screen below the pane. It is not, on the + * Manage tabs — so those panels really do have six more rows to spend, + * and budgeting as if it were there leaves them dead. + */ + composerVisible: boolean; onMcpAddJsonChange?: (json: string) => void; onMcpAddSubmit?: (json: string) => void; onMcpAddCancel?: () => void; @@ -46,6 +55,7 @@ interface DebugPaneProps { export function DebugPane({ state, maxVisible, + composerVisible, onMcpAddJsonChange, onMcpAddSubmit, onMcpAddCancel, @@ -58,6 +68,7 @@ export function DebugPane({ - {tabs.map((tab, idx) => { - const active = tab.id === state.activeTab; - return ( - - - {active ? `${theme.glyphs.chevronRight} ` : " "} - {tab.label} + + {tabs.map((tab, idx) => ( + + + {idx < tabs.length - 1 ? ( + + {" "} + {theme.glyphs.pipeSeparator} + {" "} - {idx < tabs.length - 1 ? ( - - {" "} - {theme.glyphs.pipeSeparator} - {" "} - - ) : null} - - ); - })} + ) : null} + + ))} ); } +/** + * One sub-tab. Split out of the strip so each label owns a measurable + * box the mouse layer can hit — clicking a tab performs the same + * dispatch Tab-cycling does. + */ +function SubTabLabel({ + tab, + active, +}: { + tab: SubTab; + active: boolean; +}): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {active ? `${theme.glyphs.chevronRight} ` : " "} + {tab.label} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + if (!active) mouse.dispatch({ type: "tab_changed", tab: tab.id }); + return true; + }} + > + {label} + + ); +} + interface SubTab { id: TuiTab; label: string; @@ -131,14 +170,33 @@ function buildManageTabs(state: TuiState): SubTab[] { } /** - * Height consumed by the always-on app frame OUTSIDE the debug pane: - * the top `StatusBar` (1 row) + the `PromptShell` (≈6 rows: top margin, - * padding, the editor line, the meta-row, and the `╹` cap) + the - * `HotkeyHint` (1 row). Ink 7 does NOT clip a frame taller than the - * terminal — it overlaps/garbles earlier lines instead (verified) — so - * the per-tab budget must subtract this accurately and err generous. + * Height consumed by the always-on app frame OUTSIDE the debug pane + * when the composer is NOT on screen: the top `StatusBar` (1 row), the + * hairline under it (1) and the `HotkeyHint` (1). + */ +export const APP_CHROME_ROWS_BASE = 3; +/** + * Rows the composer overlay costs when it is mounted: the see-through + * spacer above the frame, the rounded frame's two border rows, a blank + * row above and below the buffer, the editor line, and the action bar + * with a blank row above and below it too. */ -const APP_CHROME_ROWS = 9; +export const COMPOSER_ROWS = 10; +/** + * Height consumed by the always-on app frame OUTSIDE the debug pane. + * Ink 7 does NOT clip a frame taller than the terminal — it overlaps / + * garbles earlier lines instead (verified) — so the per-tab budget must + * subtract this accurately and err generous. + * + * The composer is only on screen on the Run screen, so its rows are + * conditional: a Manage tab really does have six more rows to spend, and + * budgeting as if the composer were still there leaves them dead. + */ +export function appChromeRows(composerVisible: boolean): number { + return APP_CHROME_ROWS_BASE + (composerVisible ? COMPOSER_ROWS : 0); +} +/** Back-compat alias: the chat-screen total. */ +export const APP_CHROME_ROWS = APP_CHROME_ROWS_BASE + COMPOSER_ROWS; /** * Height consumed INSIDE the debug pane above the active tab: the * `SubTabBar` (1 row) + the `DebugDiagnosticsLine`. The diagnostics line @@ -169,28 +227,78 @@ const MIN_LIST_ROWS = 3; * Total number of rows available for an active tab's own content * (panel chrome + its list), derived from the live terminal height. */ -function tabContentBudget(terminalRows: number): number { +/** + * Rows offered to the panels that collapse in STEPS rather than + * continuously (LLM / Models): they render a fixed short form up to 15 + * rows and a fixed tall form from 16, so a budget of 16..19 makes them + * overshoot the pane — and Ink 7 paints an over-tall frame over the rows + * above instead of clipping it. + * + * Exported for the test that sweeps every terminal height: the invariant + * is "whatever we offer, the panel's rendered height still fits". + */ +export function steppedPanelRows( + terminalRows: number, + composerVisible: boolean, +): number { + return Math.min( + tabContentBudget(terminalRows, composerVisible), + tabContentBudget(terminalRows, true), + ); +} + +/** Height the stepped panels actually render at a given budget. */ +export function steppedPanelRendered(maxRows: number): number { + return maxRows >= STEPPED_PANEL_TALL_ROWS ? STEPPED_PANEL_TALL_ROWS : STEPPED_PANEL_SHORT_ROWS; +} + +const STEPPED_PANEL_SHORT_ROWS = 9; +const STEPPED_PANEL_TALL_ROWS = 20; + +function tabContentBudget(terminalRows: number, composerVisible: boolean): number { return Math.max( MIN_LIST_ROWS, - terminalRows - APP_CHROME_ROWS - DEBUG_TAB_CHROME_ROWS - RENDER_SAFETY_ROWS, + terminalRows - + appChromeRows(composerVisible) - + DEBUG_TAB_CHROME_ROWS - + RENDER_SAFETY_ROWS, ); } function ActiveDebugTab({ state, maxVisible, + composerVisible, onMcpAddJsonChange, onMcpAddSubmit, onMcpAddCancel, }: { state: TuiState; maxVisible: number; + composerVisible: boolean; onMcpAddJsonChange?: (json: string) => void; onMcpAddSubmit?: (json: string) => void; onMcpAddCancel?: () => void; }): ReactElement { const { rows: terminalRows } = useTerminalSize(); - const tabBudget = tabContentBudget(terminalRows); + const tabBudget = tabContentBudget(terminalRows, composerVisible); + /** + * The budget the LLM and Models panels are told about. + * + * Those two do not scale continuously: they render a fixed ~9 rows up + * to `maxRows` 15 and jump to a fixed ~20 the moment they are offered + * 16, so any budget in 16..19 makes them overshoot — and Ink 7 paints + * an over-tall frame over the rows above rather than clipping it. The + * composer's six reclaimed rows land a default 80x24 / 100x24 terminal + * squarely in that band, which turned a clean frame into a garbled one + * on exactly the screens most people run. + * + * Until those panels collapse smoothly, they keep the pre-reclaim + * budget: the extra rows go unused rather than overlapping the status + * bar. The compact panels (Tasks / Skills / Memory / MCP) window a + * list row by row and take the real budget. + */ + const steppedPanelBudget = steppedPanelRows(terminalRows, composerVisible); // Compact panels have a tiny fixed header, so they get the list slice // directly. LLM / Models own large fixed chrome (RouteCard / status // footer) that they collapse themselves, so they receive the full @@ -229,9 +337,14 @@ function ActiveDebugTab({ case "providers": return ; case "llm": - return ; + return ; case "models": - return ; + return ( + + ); case "llm-logs": return ; case "telegram": diff --git a/src/tui/components/download-chip.test.tsx b/src/tui/components/download-chip.test.tsx new file mode 100644 index 00000000..a3baee83 --- /dev/null +++ b/src/tui/components/download-chip.test.tsx @@ -0,0 +1,122 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { DownloadChip } from "./download-chip.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); + +function pull(over: Partial = {}): LocalModelsPullState { + return { + kind: "chat", + modelId: "gemma-4-e4b", + label: "Gemma 4 E4B", + percent: 61, + transferredBytes: 2_600_000_000, + totalBytes: 4_220_000_000, + error: null, + ...over, + }; +} + +describe("DownloadChip", () => { + it("names the model and its progress in one row", () => { + const view = render(); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("gemma-4-e4b"); + expect(frame).toContain("61%"); + expect(frame.split("\n").filter((line) => line.trim().length > 0)).toHaveLength(1); + }); + + /** Two samples, so the rate — and therefore the ETA — exists. */ + async function frameAt(budget: number): Promise { + const view = render( + , + ); + view.rerender(); + await new Promise((resolve) => setTimeout(resolve, 60)); + return strip(view.lastFrame() ?? ""); + } + + it("sheds the ETA, then the bar, as the row fills up", async () => { + const wide = await frameAt(60); + expect(wide).toContain("█"); + expect(wide).toMatch(/minute|second/); + + const medium = await frameAt(30); + expect(medium).toContain("█"); + expect(medium).not.toMatch(/minute|second/); + + const tight = await frameAt(14); + expect(tight).toContain("61%"); + expect(tight).not.toContain("█"); + }); + + it("disappears rather than wrapping the one-row bar", () => { + const view = render(); + expect(strip(view.lastFrame() ?? "").trim()).toBe(""); + }); + + it("names the runtime rather than a model id during the backend pull", () => { + const view = render(); + expect(strip(view.lastFrame() ?? "")).toContain("llama.cpp"); + }); + + describe("an 87-char custom Hugging Face id", () => { + // The worst case `buildCustomModelId` can emit: `custom-` plus an + // 80-char slug. Uncapped, this alone out-spent every row budget. + const LONG_ID = `custom-${"unsloth-qwen3-coder-30b-a3b-instruct-gguf-q4-k-m".padEnd(80, "x")}`; + // The displayed cap: 29 chars of the id, then an ellipsis. + const SHOWN = `${LONG_ID.slice(0, 29)}…`; + + function longFrame(budget: number): string { + const view = render(); + return strip(view.lastFrame() ?? ""); + } + + it("really is the id builder's worst case", () => { + expect(LONG_ID).toHaveLength(87); + }); + + it("draws at most 30 label columns, ellipsis included, in the full form", async () => { + // Two renders so the rate — and therefore the ETA — exists. + const view = render( + , + ); + view.rerender(); + await new Promise((resolve) => setTimeout(resolve, 60)); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain(SHOWN); + expect(frame).not.toContain(LONG_ID); + expect(frame).toContain("█"); + expect(frame).toMatch(/minute|second/); + expect(frame.length).toBeLessThanOrEqual(100); + }); + + it("keeps the bar form inside a 60-column budget instead of overflowing", () => { + const frame = longFrame(60); + expect(frame).toContain(SHOWN); + expect(frame).toContain("█"); + expect(frame).not.toMatch(/minute|second/); + expect(frame.length).toBeLessThanOrEqual(60); + }); + + it("sheds to percent-only at a budget where a short id still gets its bar", () => { + // Budget 30 is the medium form for `gemma-4-e4b` above; the + // capped label prices the bar form at 49 columns, so the chip + // drops the name rather than pushing the header onto a second row. + const frame = longFrame(30); + expect(frame).not.toContain("█"); + expect(frame).not.toContain(SHOWN); + expect(frame).toContain("61%"); + expect(frame.length).toBeLessThanOrEqual(30); + }); + + it("still disappears under the minimal budget", () => { + expect(longFrame(6).trim()).toBe(""); + }); + }); +}); diff --git a/src/tui/components/download-chip.tsx b/src/tui/components/download-chip.tsx new file mode 100644 index 00000000..275040b8 --- /dev/null +++ b/src/tui/components/download-chip.tsx @@ -0,0 +1,86 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; +import { formatEta, useTransferRate } from "../hooks/use-transfer-rate.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import { theme } from "../theme/theme.js"; + +const BAR_WIDTH = 10; + +/** + * Columns each form needs. The status bar is one row and Ink wraps + * rather than clips, so a chip that does not fit does not get cut off — + * it turns the header into a paragraph and pushes the whole app down. + * Hence forms, and a budget, in the same spirit as `hotkey-hint`'s chip + * shedding. + * + * The label-bearing forms price themselves off the label actually + * drawn instead of off a fixed guess: custom Hugging Face ids run to + * 87 chars (`custom--`, `buildCustomModelId`), and fixed + * thresholds tuned for `gemma-4-e4b` let those blow the one-row budget. + */ +const MINIMAL_COLUMNS = 12; +/** `" ⇣ "` — the leading glyph and its breathing room. */ +const PREFIX_COLUMNS = 4; +/** Worst-case `" "` + `formatEta` text ("less than a minute left"). */ +const ETA_COLUMNS = 25; +/** What an unbudgeted caller gets: the old FULL-form allowance. */ +const DEFAULT_BUDGET = 46; + +/** + * Longest label the chip draws, ellipsis included. Display only — the + * id the download actions use is never the truncated string. + */ +const MAX_LABEL_COLUMNS = 30; + +function capLabel(label: string): string { + if (label.length <= MAX_LABEL_COLUMNS) return label; + return `${label.slice(0, MAX_LABEL_COLUMNS - 1)}…`; +} + +/** + * A model pull, reported from the one row that is always on screen. + * + * The download survives the screen that started it — the orchestrator is + * session-scoped — but until now it was only ever drawn inside the LLM + * panel, so an operator who left that tab (or who jumped straight to the + * agent from setup) had a multi-gigabyte transfer running with nothing + * anywhere saying so. + */ +export function DownloadChip({ + pull, + budget = DEFAULT_BUDGET, +}: { + pull: LocalModelsPullState; + /** Columns left on the status-bar row. Under 12 the chip is dropped. */ + budget?: number; +}): ReactElement | null { + const { etaSeconds } = useTransferRate(pull.transferredBytes, pull.totalBytes); + const percent = Math.min(100, Math.max(0, Math.round(pull.percent))); + const filled = Math.round((percent / 100) * BAR_WIDTH); + const label = capLabel(pull.kind === "backend" ? "llama.cpp" : String(pull.modelId)); + if (budget < MINIMAL_COLUMNS) return null; + const percentText = `${percent}%`; + // prefix + label + space + bar + space + percent — what the BAR form + // costs with THIS label, so a long-but-capped name sheds to the + // percent-only form on narrow rows instead of overflowing them. + const barColumns = + PREFIX_COLUMNS + label.length + 1 + BAR_WIDTH + 1 + percentText.length; + const withBar = budget >= barColumns; + const withEta = budget >= barColumns + ETA_COLUMNS && etaSeconds !== null; + return ( + + {" ⇣ "} + {withBar ? {label} : null} + {withBar ? ( + <> + {"█".repeat(filled)} + {"░".repeat(BAR_WIDTH - filled)} + + ) : null} + {withBar ? ` ${percentText}` : percentText} + {withEta ? ( + {` ${formatEta(etaSeconds)}`} + ) : null} + + ); +} diff --git a/src/tui/components/fit-to-width.ts b/src/tui/components/fit-to-width.ts new file mode 100644 index 00000000..9cbc78b5 --- /dev/null +++ b/src/tui/components/fit-to-width.ts @@ -0,0 +1,17 @@ +/** + * Pad or ellipsise a string to exactly `width` cells. + * + * Load-bearing for every floating panel in the app. Terminals have no + * compositing and Ink has no z-index, so an overlay occludes what is + * under it only by painting every one of its own cells — a row that + * stops at its content lets the backdrop show through the gap. Ink's own + * `paddingX` does not help: it leaves real gaps rather than painted + * ones, which is why the gutter is baked into the string instead. + */ +export function fitToWidth(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text.padEnd(width); +} diff --git a/src/tui/components/format-tokens.ts b/src/tui/components/format-tokens.ts new file mode 100644 index 00000000..a3db8009 --- /dev/null +++ b/src/tui/components/format-tokens.ts @@ -0,0 +1,18 @@ +/** + * Token counts at terminal width: `6400` -> `6.4k`, `32000` -> `32k`, + * `1000000` -> `1.0M`. + * + * Six significant digits are noise in a chip that has twenty-odd cells + * to spend, and a round thousand reads better without the `.0` it would + * otherwise carry. Shared by the composer's chip and its detail panel so + * the same number never appears in two forms one keystroke apart. + */ +export function formatTokens(tokens: number): string { + if (tokens < 1000) return String(tokens); + if (tokens < 1_000_000) { + const k = tokens / 1000; + return Number.isInteger(k) ? `${k}k` : `${k.toFixed(1)}k`; + } + const m = tokens / 1_000_000; + return Number.isInteger(m) ? `${m}M` : `${m.toFixed(1)}M`; +} diff --git a/src/tui/components/hf-pick-list.tsx b/src/tui/components/hf-pick-list.tsx new file mode 100644 index 00000000..d8dce2db --- /dev/null +++ b/src/tui/components/hf-pick-list.tsx @@ -0,0 +1,123 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import type { HuggingFaceRepoChoices } from "../../local-llm/index.js"; +import { ramWarningFor } from "../../local-llm/index.js"; +import type { MouseContextValue } from "../mouse/mouse-context.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { theme } from "../theme/theme.js"; + +/** Rows drawn at once, matching the curated picker's window. */ +export const HF_PICK_WINDOW = 6; + +/** + * "Which quantisation to pull", as one list two flows share: the + * first-run screen (`onboarding-hf-pick-step.tsx`) and the Models pane's + * own add-a-model branch (`local-models-hf-panel.tsx`). + * + * Presentational on purpose — cursor movement and activation arrive as + * callbacks — because the two flows keep their cursor in different + * slices and route Enter through different key tables. What they must + * not disagree about is what the list *says*: which files are offered, + * what the line under it means when a repo looks half-empty, and when + * the RAM warning appears. + * + * The RAM line warns and nothing more. Weights larger than physical + * memory still load — llama.cpp maps the file and the machine pages it + * — and an operator who knows their swap situation is allowed to decide + * that is fine. + */ +export function HfPickList(props: { + repo: HuggingFaceRepoChoices; + cursor: number; + ramGb: number; + error: string | null; + /** A click on a row that is not the current one. */ + onSelect(index: number, mouse: MouseContextValue): void; + /** + * A click on the row that already holds the cursor. Shaped like + * `MouseListRow`'s own callback so a caller can hand it + * `pressEnter()` and get the keyboard's Enter path for + * free — which is the point: the mouse must not grow a second way to + * start a download. + */ + onActivate(mouse: MouseContextValue): void; +}): ReactElement { + const { choices } = props.repo; + const cursor = Math.min(props.cursor, Math.max(0, choices.length - 1)); + const { visible, below, start } = windowHfChoices(props.repo, props.cursor); + const selected = choices[cursor]; + const warning = selected ? ramWarningFor(selected.fileSizeGb, props.ramGb) : null; + return ( + + {props.repo.repoId} + {visible.map((choice, index) => { + const rowIndex = start + index; + const active = rowIndex === cursor; + return ( + // First click selects, second downloads — the same two steps + // the keyboard takes. + props.onSelect(rowIndex, mouse)} + onActivate={props.onActivate} + > + + {hfChoiceLine(choice, active)} + + + ); + })} + {below > 0 ? ( + {`${" ".repeat(3)}↓ ${below} more`} + ) : null} + {props.repo.hidden ? ( + + {` ${props.repo.hidden}`} + + ) : null} + {props.repo.mmproj ? ( + + {HF_MMPROJ_LINE} + + ) : null} + {warning ? ( + {` ⚠ ${warning}`} + ) : null} + {props.error ? ( + {` ${props.error}`} + ) : null} + + ); +} + +export const HF_MMPROJ_LINE = + " vision projector in this repo — it is pulled alongside"; + +/** One row's text, shared with the onboarding block's width measure. */ +export function hfChoiceLine( + choice: HuggingFaceRepoChoices["choices"][number], + active: boolean, +): string { + return `${active ? "› " : " "}${choice.filename.padEnd(44)}${choice.sizeLabel.padStart(9)}`; +} + +/** The rows actually on screen, shared between the render and the measure. */ +export function windowHfChoices( + repo: HuggingFaceRepoChoices, + rawCursor: number, +): { visible: HuggingFaceRepoChoices["choices"]; below: number; start: number } { + const { choices } = repo; + const cursor = Math.min(rawCursor, Math.max(0, choices.length - 1)); + const start = Math.max( + 0, + Math.min(cursor - HF_PICK_WINDOW + 2, choices.length - HF_PICK_WINDOW), + ); + const visible = choices.slice(start, start + HF_PICK_WINDOW); + return { visible, below: choices.length - (start + visible.length), start }; +} diff --git a/src/tui/components/hf-reference-editor.tsx b/src/tui/components/hf-reference-editor.tsx new file mode 100644 index 00000000..37967ba4 --- /dev/null +++ b/src/tui/components/hf-reference-editor.tsx @@ -0,0 +1,98 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { MouseTarget } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; +import { theme } from "../theme/theme.js"; +import { MultiLineEditor } from "./multi-line-editor.js"; + +export const HF_REF_TITLE_LINE = "Which model? (it has to be a GGUF build)"; +export const HF_REF_EXAMPLES_LINE = + "unsloth/Qwen3.5-4B-GGUF · https://huggingface.co/owner/repo · a link to one .gguf"; +/** The error box is capped at this measure so long messages wrap. */ +export const HF_REF_ERROR_COLUMNS = 72; + +/** + * "Name a model on Hugging Face" — one editor the first-run flow and + * the Models pane both mount. + * + * Whatever the operator has on the clipboard — the repo page, a link + * straight to one `.gguf`, or the id on its own — should work, so the + * examples show all three rather than teaching one canonical form. + * + * Everything that can go wrong (a repo with no GGUF in it, a gated one, + * a typo) is reported here, because this is the screen that asked the + * question. The two flows differ only in where Escape goes, which is + * why that is a prop and nothing else is. + */ +export function HfReferenceEditor(props: { + value: string; + busy: boolean; + error: string | null; + onChange(value: string): void; + onSubmit(value: string): void; + /** Empty the reference AND drop its error — `[ clear ]` / ctrl+l. */ + onClear(): void; + onEscape(): void; + mouseLayer?: number; +}): ReactElement { + const layer = props.mouseLayer ?? MOUSE_LAYER_PANEL; + return ( + + + Which model? (it has to be a GGUF build) + + {HF_REF_EXAMPLES_LINE} + + + + {/* + Below the input, above the error box. Hidden while the lookup + runs (the editor is read-only then and Esc already cancels) and + while there is nothing to clear. A row wrapper so the target hugs + the label instead of claiming the whole line — see + `ChatCopyButton` for the precedent. The chord lives in the + footer; the click and ctrl+l share one handler upstream. + */} + {!props.busy && props.value.length > 0 ? ( + + { + if (!isPrimaryPress(hit.event)) return false; + props.onClear(); + return true; + }} + > + + [ clear ] + + + + ) : null} + {props.busy ? ( + asking huggingface.co… + ) : null} + {props.error ? ( + + + {props.error} + + + ) : null} + + ); +} diff --git a/src/tui/components/hotkey-chips.ts b/src/tui/components/hotkey-chips.ts new file mode 100644 index 00000000..b947e48f --- /dev/null +++ b/src/tui/components/hotkey-chips.ts @@ -0,0 +1,320 @@ +import { MENU_LEADER_LABEL } from "../menu/menu-keys.js"; +import { + APPROVAL_CHORDS, + PLAN_CHORDS, + applyNavSlot, + decideApproval, +} from "../app-key-bindings.js"; +import type { MouseContextValue } from "../mouse/mouse-context.js"; +import { cycleNavSlot } from "../section.js"; +import { hasShiftEnterNewline } from "../shift-enter-support.js"; +import { theme } from "../theme/theme.js"; +import type { TuiState } from "../tui-state.js"; + +/** + * The hint strip's chip model: which chips the current state earns and + * which of them survive a narrow row. Split from `hotkey-hint.tsx` so + * the strip's policy (this file) and its rendering (that one) each stay + * a readable size — the policy is where every new chip lands. + */ +export interface HotkeyChip { + readonly key: string; + readonly label: string; + /** + * Position in the shedding queue when the row does not fit `width`: + * chip `1` is dropped first, then `2`, and so on. A chip with no rank + * is essential — it stays even if the row still overflows (and is then + * clipped by `truncate-end` rather than wrapped). + */ + readonly shed?: number; + + /** + * What a click on this chip does. Only chips with one unambiguous + * meaning get one — "alt+enter newline" or "↑↓ select" describe a + * gesture, not a command, so they stay plain text rather than + * pretending to be buttons. + */ + readonly onClick?: (mouse: MouseContextValue) => void; +} + +/** + * Platform-aware label for the chat-scroll key. The physical key is + * PageUp; Mac keyboards reach it via Fn+Up, and that is the spelling + * Mac users actually recognise. + */ +const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgdn"; + +/** + * A live composer selection flips what Ctrl+C will actually do (copy, + * not abort/quit — see `composerOwnsCtrlC` in app-key-bindings) and + * gives Ctrl+X a meaning (cut). The strip must say so, but only while + * the editor really has the keyboard: the selection flag survives Tab + * into the sidebar and an open menu/panel, where Ctrl+C keeps its + * global meaning. + */ +function composerSelectionActive(state: TuiState): boolean { + return ( + state.composerHasSelection && + state.chatFocus === "editor" && + !state.menuOpen && + !state.contextPanelOpen + ); +} + + +export function resolveChips( + state: TuiState, + ctrlCArmed: boolean, + menuLeaderArmed: boolean, +): HotkeyChip[] { + const hasDraft = state.inputValue.length > 0; + if (state.pendingApproval) { + const approval = state.pendingApproval; + // The chords, not the bare letters. The chat composer stays live + // while a prompt is up, so `approvalHotkey` only answers to a + // *modified* key — a bare `y` is text and lands in the draft. This + // strip used to advertise `y` / `n`, which meant the two things on + // screen telling the operator how to answer disagreed, and the one + // in the larger type was the one that did nothing. `n` was wrong on + // both counts: deny is `d`, because `n` is one keystroke from the + // newline the editor below is still listening for. + return [ + { + key: `ctrl+${APPROVAL_CHORDS.approve}`, + label: "approve", + onClick: (mouse) => decideApproval(approval, true, mouse), + }, + { + key: `ctrl+${APPROVAL_CHORDS.deny}`, + label: "deny", + onClick: (mouse) => decideApproval(approval, false, mouse), + }, + { key: "esc", label: "abort run" }, + ]; + } + // The plan hand-off, same shape as the approval strip above and for + // the same reason: the buttons under the plan are drawn once, in the + // transcript, and scroll away with it, while this row stays put. It + // is also the only place the chords are written down — the buttons + // carry their full labels and adding `· ctrl+y` to each one pushed + // the third button onto a second line at 92 columns. + if (state.planHandoff) { + return [ + { key: `ctrl+${PLAN_CHORDS.auto}`, label: "run it · auto" }, + { key: `ctrl+${PLAN_CHORDS.bypass}`, label: "run it · bypass", shed: 2 }, + { key: `ctrl+${PLAN_CHORDS.dismiss}`, label: "dismiss plan", shed: 1 }, + { key: "esc", label: "menu" }, + ]; + } + // An armed leader owns the very next keystroke and unfocuses the editor + // while it waits, so it takes the whole strip: the row the operator is + // already looking at is where "the app is mid-gesture" belongs. Ordered + // to match key precedence — a pending approval still outranks it. + if (menuLeaderArmed) { + return [ + { key: MENU_LEADER_LABEL, label: "waiting for a chord" }, + { key: "ctrl+p", label: "full menu" }, + { key: "esc", label: "cancel" }, + ]; + } + if (state.slashPaletteOpen) { + return [ + { key: "↑↓", label: "select" }, + { key: "tab/enter", label: "accept" }, + { key: "esc", label: "close" }, + ]; + } + if (state.status === "running") { + // Esc has exactly one meaning during a turn — abort — because abort + // deliberately wins over clear-draft (`handleAppKey` claims the key; + // see `onEscape` in `tui-app.tsx`). Say so when a draft exists: an + // operator who typed while the agent worked otherwise has nothing on + // screen telling him whether Esc also eats what he typed. The editor + // stays live during a run, so the strip also advertises what Enter + // does now — and how many messages are already parked behind the + // turn. Scroll sheds first (the wheel already does it), then the + // parked counter, then the Enter hint. + // An armed Ctrl+C is the one state where a mispress quits the whole + // app — it takes the row for itself so nothing dilutes the warning. + if (ctrlCArmed) { + return [{ key: "ctrl+c", label: "press again to quit" }]; + } + const steering = state.whileBusyMode === "steer"; + const chips: HotkeyChip[] = [ + { key: SCROLL_KEY, label: "scroll", shed: 1 }, + { key: "⏎", label: steering ? "steer" : "queue message", shed: 3 }, + { + key: "ctrl+t", + label: steering ? "queue mode" : "steer mode", + shed: 4, + }, + { key: "esc", label: hasDraft ? "abort, draft kept" : "abort" }, + ...(composerSelectionActive(state) + ? [ + { key: "ctrl+x", label: "cut", shed: 5 }, + { key: "ctrl+c", label: "copy" }, + ] + : [ + { + key: "ctrl+c", + label: ctrlCArmed ? "press again to quit" : "abort", + }, + ]), + ]; + if (state.queuedMessages.length > 0) { + chips.push({ + key: "/queue", + label: `${state.queuedMessages.length} parked`, + shed: 2, + }); + } + return chips; + } + if (state.uiMode === "debug") { + // Ctrl+B still cycles panels but is unadvertised: it duplicated the + // Tab chip word-for-word, and the freed slot pays for the one hint + // panels actually lacked — the way back to Run. Shift+Tab sheds + // first because "prev panel" is guessable from "next panel". + return [ + { + key: "tab", + label: "next panel", + onClick: (mouse) => + applyNavSlot(mouse.dispatch, cycleNavSlot(mouse.getState(), 1)), + }, + { + key: "shift+tab", + label: "prev panel", + shed: 1, + onClick: (mouse) => + applyNavSlot(mouse.dispatch, cycleNavSlot(mouse.getState(), -1)), + }, + { + key: "esc", + label: "back to Run", + onClick: (mouse) => mouse.dispatch({ type: "ui_mode_set", mode: "chat" }), + }, + { key: "ctrl+p", label: "menu", shed: 2 }, + { + key: "ctrl+c", + label: ctrlCArmed ? "press again to quit" : "quit", + }, + ]; + } + if (state.chatFocus === "sidebar") { + return [ + { key: "↑↓", label: "select", shed: 2 }, + { key: "enter", label: "open" }, + { key: "tab", label: "next pane", shed: 1 }, + { key: "esc", label: "back to editor" }, + { + key: "ctrl+c", + label: ctrlCArmed ? "press again to quit" : "quit", + }, + ]; + } + // The strip fits one row by shedding, not by a fixed cap. `ctrl+p` + // holds the slot `/` used to: the menu contains every slash command + // as well as every destination, and `/` keeps working for anyone who + // already reaches for it. Shedding order: scroll (the wheel already + // does it), then the sidebar (narrow terminals collapse it anyway — + // see `SIDEBAR_MIN_COLUMNS`), then the route chip (the route line + // itself is clickable, so the keyboard hint is the first luxury), + // then the newline key, then the menu chip. A draft adds an + // `esc / clear draft` chip so the affordance is on screen exactly + // when it applies — `/` no longer opens the palette with a non-empty + // buffer, so nothing usable is displaced. + return [ + { key: "enter", label: "send" }, + // Shift+Enter only exists as a keystroke where the terminal speaks + // the kitty keyboard protocol; everywhere else it is byte-identical + // to Enter and would submit. Alt+Enter works in both worlds, so it + // is what the strip promises when the protocol is absent. + { + key: hasShiftEnterNewline() ? "shift+enter" : "alt+enter", + label: "newline", + shed: 4, + }, + { + key: "tab", + label: "sidebar", + shed: 2, + onClick: (mouse) => + mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }), + }, + { key: SCROLL_KEY, label: "scroll", shed: 1 }, + // The composer's three route controls: the only keyboard way in, + // and until this chip the only place it was written down was the + // popup it opens. + { + key: "ctrl+r", + label: "route", + shed: 3, + onClick: (mouse) => + mouse.dispatch({ type: "composer_switch_opened", kind: "backend" }), + }, + // Esc opens the menu only on an empty buffer — with a draft it + // clears the draft — so the strip advertises whichever one the next + // press will actually do. + ...(hasDraft + ? [{ key: "esc", label: "clear draft" }] + : [{ key: "esc", label: "menu", shed: 6 }]), + { key: "ctrl+p", label: "menu", shed: 5 }, + // A selection can only exist over a non-empty buffer, so the + // clear-draft Esc chip is always alongside these two. + ...(composerSelectionActive(state) + ? [ + { key: "ctrl+x", label: "cut" }, + { key: "ctrl+c", label: "copy" }, + ] + : [ + { + key: "ctrl+c", + label: ctrlCArmed ? "press again to quit" : "quit", + }, + ]), + ]; +} + +/** + * Drop chips — lowest `shed` rank first — until the row fits `width`. + * Stops once only essential (rank-less) chips remain; those overflow + * into `truncate-end` rather than silently disappearing. + */ +export function fitChips(chips: HotkeyChip[], width: number): HotkeyChip[] { + let kept = chips; + while (stripWidth(kept) > width) { + const next = nextToShed(kept); + if (next < 0) break; + kept = kept.filter((_, idx) => idx !== next); + } + return kept; +} + +function nextToShed(chips: readonly HotkeyChip[]): number { + let best = -1; + let bestRank = Number.POSITIVE_INFINITY; + chips.forEach((chip, idx) => { + if (chip.shed === undefined || chip.shed >= bestRank) return; + best = idx; + bestRank = chip.shed; + }); + return best; +} + +/** + * Rendered columns of the whole strip. Every key and label we ship is + * single-width (ASCII plus `↑`, `↓`, `·`), so `String.length` is the + * rendered width and we do not need a `string-width` dependency here — + * keep new chips inside that alphabet. + */ +function stripWidth(chips: readonly HotkeyChip[]): number { + if (chips.length === 0) return 0; + const separator = 4 + theme.glyphs.dotSeparator.length; + const chipWidths = chips.reduce( + // "[" + key + "] " + label + (acc, chip) => acc + chip.key.length + chip.label.length + 3, + 0, + ); + return chipWidths + (chips.length - 1) * separator; +} diff --git a/src/tui/components/hotkey-hint-modes.test.tsx b/src/tui/components/hotkey-hint-modes.test.tsx new file mode 100644 index 00000000..0a7ff3e5 --- /dev/null +++ b/src/tui/components/hotkey-hint-modes.test.tsx @@ -0,0 +1,106 @@ +import { Box } from "ink"; +import { render } from "ink-testing-library"; +import { afterEach, describe, expect, it } from "vitest"; +import React from "react"; + +import { setShiftEnterNewline } from "../shift-enter-support.js"; +import { fakeSession } from "../test-fixtures.js"; +import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { HotkeyHint } from "./hotkey-hint.js"; + +/** + * The strip must only promise keys the current terminal can deliver. + * Two things vary at runtime: the newline chip (Shift+Enter exists as a + * keystroke only under the kitty keyboard protocol — everywhere else it + * is byte-identical to Enter), and the Ctrl+C/Ctrl+X pair (copy/cut + * while the composer holds a live selection, abort/quit otherwise). + */ +const ANSI = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; +const WIDE = 200; + +function renderHint(state: TuiState): string { + const { lastFrame, unmount } = render( + + + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + return out; +} + +function chatState(overrides: Partial = {}): TuiState { + return { + ...createInitialTuiState(fakeSession()), + uiMode: "chat" as const, + ...overrides, + }; +} + +afterEach(() => { + // The flag is process-global; a test must never leak "kitty" into the + // suites that render the strip with the default expectation. + setShiftEnterNewline(false); +}); + +describe("newline chip vs terminal protocol", () => { + it("promises alt+enter when the terminal has no kitty protocol", () => { + setShiftEnterNewline(false); + const out = renderHint(chatState()); + expect(out).toContain("[alt+enter] newline"); + expect(out).not.toContain("shift+enter"); + }); + + it("promises shift+enter when the kitty protocol was detected", () => { + setShiftEnterNewline(true); + const out = renderHint(chatState()); + expect(out).toContain("[shift+enter] newline"); + expect(out).not.toContain("alt+enter"); + }); +}); + +describe("copy/cut chips vs composer selection", () => { + it("advertises copy and cut while a selection is live in the editor", () => { + const out = renderHint( + chatState({ composerHasSelection: true, inputValue: "hello" }), + ); + expect(out).toContain("[ctrl+c] copy"); + expect(out).toContain("[ctrl+x] cut"); + expect(out).not.toContain("quit"); + }); + + it("keeps ctrl+c as quit with no selection", () => { + const out = renderHint(chatState({ inputValue: "hello" })); + expect(out).toContain("[ctrl+c] quit"); + expect(out).not.toContain("ctrl+x"); + }); + + it("keeps ctrl+c as quit when focus moved to the sidebar", () => { + // The selection flag survives Tab into the sidebar, but the editor + // no longer receives keys there — Ctrl+C reverts to its global + // meaning and the strip must not claim otherwise. + const out = renderHint( + chatState({ + composerHasSelection: true, + inputValue: "hello", + chatFocus: "sidebar", + }), + ); + expect(out).toContain("quit"); + expect(out).not.toContain("ctrl+x"); + }); + + it("turns ctrl+c into copy during a running turn with a selection", () => { + const out = renderHint( + chatState({ + status: "running", + composerHasSelection: true, + inputValue: "hello", + }), + ); + expect(out).toContain("[ctrl+c] copy"); + expect(out).toContain("[ctrl+x] cut"); + // Esc still aborts and must say so. + expect(out).toContain("abort"); + }); +}); diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx index 0c5b131a..691c105a 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -1,8 +1,10 @@ +import { Box } from "ink"; import { render } from "ink-testing-library"; import { afterEach, describe, expect, it, vi } from "vitest"; import React from "react"; import type { ApprovalRequest } from "../../approval/approval-gate.js"; +import { APPROVAL_CHORDS } from "../app-key-bindings.js"; import { fakeSession } from "../test-fixtures.js"; import { createInitialTuiState, type TuiState } from "../tui-state.js"; import { HotkeyHint } from "./hotkey-hint.js"; @@ -16,8 +18,24 @@ const MAC_SCROLL_KEY = "fn+↑↓"; const OTHER_SCROLL_KEY = "pgup/pgdn"; const SCROLL_KEY_PATTERN = /fn\+↑↓|pgup\/pgdn/; -function renderHint(state: TuiState): string { - const { lastFrame, unmount } = render(); +/** + * Wide enough that no chip is shed — these cases assert *which* chips a + * state offers, not how the row degrades. Narrow behaviour has its own + * describe block below. + */ +const WIDE = 200; + +/** + * The strip is rendered inside a column-direction Box exactly as + * `TuiApp` renders it, so Ink resolves the same width and the frame we + * assert on is the frame the operator sees at `columns`. + */ +function renderHint(state: TuiState, columns: number = WIDE): string { + const { lastFrame, unmount } = render( + + + , + ); const out = (lastFrame() ?? "").replace(ANSI, ""); unmount(); return out; @@ -31,6 +49,15 @@ function chatState(overrides: Partial = {}): TuiState { }; } +/** Chips are `[key] label`; counting the brackets counts the chips. */ +function chipCount(frame: string): number { + return (frame.match(/\[/g) ?? []).length; +} + +function widest(frame: string): number { + return Math.max(0, ...frame.split("\n").map((line) => line.length)); +} + function fakeApproval(): ApprovalRequest { return { approvalId: "appr-1", @@ -61,7 +88,7 @@ describe("HotkeyHint scroll chip", () => { expect(out).not.toContain("scroll"); }); - it("keeps the approval footer (y/n/esc) free of the scroll chip", () => { + it("keeps the approval footer free of the scroll chip", () => { const out = renderHint(chatState({ pendingApproval: fakeApproval() })); expect(out).toContain("approve"); expect(out).toContain("deny"); @@ -69,6 +96,154 @@ describe("HotkeyHint scroll chip", () => { expect(out).not.toContain("scroll"); expect(out).not.toMatch(SCROLL_KEY_PATTERN); }); + + it("advertises the chords the approval prompt actually answers to", () => { + // This strip and the modal above it are the two places that tell an + // operator how to answer. They disagreed: the modal said `ctrl+y` / + // `ctrl+d` (what `approvalHotkey` accepts, because the composer + // stays live and a bare letter is text) while the strip said `y` / + // `n`. Pressing what the strip advertised typed into the draft. + const out = renderHint(chatState({ pendingApproval: fakeApproval() })); + expect(out).toContain(`ctrl+${APPROVAL_CHORDS.approve}`); + expect(out).toContain(`ctrl+${APPROVAL_CHORDS.deny}`); + // Not the bare letters — `[y]` is the chip shape, so this catches a + // regression to the old keys without tripping on the word "approve". + expect(out).not.toContain("[y]"); + expect(out).not.toContain("[n]"); + }); +}); + +// Renders are the expensive part of this file (~2s each under Ink), so +// each case below is one render and asserts everything it can from it. +describe("HotkeyHint draft chips", () => { + it("adds an esc / clear-draft chip once a draft exists", () => { + // The ctrl+p chip is not inert with a draft (the menu opens either + // way), so nothing is swapped out: the affordance rides as an extra + // chip on a wide surface and the shed ranks pay for it when the row + // narrows. Eight with the ctrl+r route chip. + const out = renderHint(chatState({ inputValue: "half a thought" })); + expect(out).toContain("[esc]"); + expect(out).toContain("clear draft"); + expect(out).toContain("[ctrl+p]"); + expect(chipCount(out)).toBe(8); + }); + + it("keeps the empty idle footer free of the clear-draft chip", () => { + const out = renderHint(chatState()); + expect(out).not.toContain("clear draft"); + expect(out).toContain("[ctrl+p]"); + expect(out).toContain("menu"); + // On an empty buffer Esc opens the menu, so the strip says so — the + // same slot that carries `clear draft` once there is a draft. + expect(out).toContain("[esc]"); + expect(chipCount(out)).toBe(8); + // The keyboard route to the composer's three controls is written + // down here, not only inside the popup it opens. + expect(out).toContain("[ctrl+r]"); + expect(out).toContain("route"); + }); + + it("tells the operator the draft survives an abort mid-turn", () => { + const out = renderHint( + chatState({ status: "running", inputValue: "half a thought" }), + ); + expect(out).toContain("[esc]"); + expect(out).toContain("abort, draft kept"); + // Clearing is not on offer while a turn is in flight — abort wins. + expect(out).not.toContain("clear draft"); + }); + + it("leaves the running label plain when there is no draft to keep", () => { + const out = renderHint(chatState({ status: "running" })); + expect(out).toContain("[esc]"); + expect(out).toContain("abort"); + expect(out).not.toContain("draft"); + }); +}); + +/** + * Ink wraps an over-wide row instead of clipping it, which both costs a + * row `debug-pane` budgeted away (`APP_CHROME_ROWS` counts the strip as + * 1) and smears chips across two lines with their separators stranded. + * The strip must therefore stay exactly one row, shedding whole chips + * rather than letting Yoga chop them. + */ +describe("HotkeyHint narrow-width degradation", () => { + it("sheds the scroll hint before send / clear-draft / quit at 80 columns", () => { + const out = renderHint(chatState({ inputValue: "half a thought" }), 80); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(80); + expect(out).not.toMatch(SCROLL_KEY_PATTERN); + expect(out).toContain("send"); + expect(out).toContain("clear draft"); + expect(out).toContain("quit"); + }); + + it("keeps the running strip and its abort label on one row at 80 columns", () => { + const out = renderHint( + chatState({ status: "running", inputValue: "half a thought" }), + 80, + ); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(80); + expect(out).toContain("abort, draft kept"); + }); + + it("keeps the debug footer on one row at 80 columns", () => { + const out = renderHint(chatState({ uiMode: "debug" }), 80); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(80); + expect(out).toContain("back to Run"); + }); + + it("clips instead of wrapping once only essential chips are left", () => { + // 40 columns cannot hold even the essentials; `truncate-end` must + // take the overflow rather than Ink adding a second row. + const out = renderHint(chatState({ inputValue: "half a thought" }), 40); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(40); + expect(out).toContain("[enter]"); + }); +}); + +describe("HotkeyHint debug footer", () => { + it("advertises the way back to Run and drops the duplicate ctrl+b chip", () => { + const out = renderHint(chatState({ uiMode: "debug" })); + expect(out).toContain("[esc]"); + expect(out).toContain("back to Run"); + expect(out).toContain("next panel"); + expect(out).toContain("prev panel"); + // Ctrl+B still cycles panels, but its chip repeated the tab chip + // word-for-word; the freed slot now pays for the esc hint. + expect(out).not.toContain("ctrl+b"); + }); +}); + +describe("HotkeyHint pending ctrl+g leader", () => { + it("says the leader is waiting instead of showing the idle chips", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("ctrl+g"); + expect(out).toContain("waiting for a chord"); + expect(out).toContain("[esc]"); + expect(out).toContain("cancel"); + // The armed leader unfocuses the editor and eats the next key, so the + // strip must not keep advertising chips that no longer apply. + expect(out).not.toContain("send"); + }); + + it("keeps the approval footer, which outranks the leader on keys", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("approve"); + expect(out).not.toContain("waiting for a chord"); + }); }); describe("HotkeyHint scroll key spelling per platform", () => { @@ -85,7 +260,11 @@ describe("HotkeyHint scroll key spelling per platform", () => { Object.defineProperty(process, "platform", { value: platform }); vi.resetModules(); const fresh = await import("./hotkey-hint.js"); - const { lastFrame, unmount } = render(); + const { lastFrame, unmount } = render( + + + , + ); const out = (lastFrame() ?? "").replace(ANSI, ""); unmount(); return out; @@ -103,3 +282,56 @@ describe("HotkeyHint scroll key spelling per platform", () => { expect(out).not.toContain(MAC_SCROLL_KEY); }); }); + +describe("HotkeyHint queue affordances", () => { + it("advertises what Enter does now that the editor stays live mid-run", () => { + const steering = renderHint(chatState({ status: "running" })); + expect(steering).toContain("⏎"); + expect(steering).toContain("steer"); + const queueing = renderHint( + chatState({ status: "running", whileBusyMode: "queue" }), + ); + expect(queueing).toContain("queue"); + }); + + it("offers ctrl+t as the way to flip to the other mode", () => { + const steering = renderHint(chatState({ status: "running" })); + expect(steering).toMatch(/ctrl\+t\]\s*queue/); + const queueing = renderHint( + chatState({ status: "running", whileBusyMode: "queue" }), + ); + expect(queueing).toMatch(/ctrl\+t\]\s*steer/); + }); + + it("shows how many messages are parked behind the turn", () => { + const out = renderHint( + chatState({ status: "running", queuedMessages: ["a", "b"] }), + ); + expect(out).toContain("parked"); + expect(out).toContain("2"); + }); + + it("hides the parked chip when the queue is empty", () => { + const out = renderHint(chatState({ status: "running" })); + expect(out).not.toContain("queued"); + }); + + it("stays on one row at 80 columns while running with a full queue", () => { + // The strip is a single-row affordance; wrapping pushes the prompt + // down and reads as a layout bug. + const out = renderHint( + chatState({ status: "running", queuedMessages: ["a", "b", "c"] }), + ); + expect(out.split("\n").filter((l) => l.trim().length > 0)).toHaveLength(1); + }); + + it("gives an armed ctrl+c the whole row", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("press again to quit"); + expect(out).not.toContain("ctrl+t"); + }); +}); diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 1737510d..d812e08d 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -1,42 +1,52 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; +import { fitChips, resolveChips, type HotkeyChip } from "./hotkey-chips.js"; interface HotkeyHintProps { state: TuiState; /** Whether a Ctrl+C was recently pressed and is armed for exit. */ ctrlCArmed?: boolean; + /** Whether a `ctrl+g` leader is waiting for its chord key. */ + menuLeaderArmed?: boolean; + /** + * Columns the strip may occupy. This is the **chat column**, not the + * terminal: the caller subtracts the root gutter and the sidebar, + * because the strip shares a flex row with them. Required so a new + * call site cannot forget it and silently reintroduce the wrap. + */ + width: number; } -interface HotkeyChip { - readonly key: string; - readonly label: string; -} - -/** - * Platform-aware label for the chat-scroll key. The physical key is - * PageUp; Mac keyboards reach it via Fn+Up, and that is the spelling - * Mac users actually recognise. - */ -const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgdn"; - /** * Bottom hint strip: surfaces the keybindings that are meaningful in - * the current state so the user never has to guess. We cap to ~6 chips - * to fit one terminal row and let slash commands take care of the long - * tail. + * the current state so the user never has to guess. + * + * The strip is budgeted to **one row**. Ink does not clip an over-wide + * row, it wraps it — and a wrapped strip both costs a row the debug + * pane already budgeted away (`APP_CHROME_ROWS`) and splits chips from + * their separators into an unreadable two-line smear. So chips are shed + * in a declared order until the row fits, and `truncate-end` clips the + * essential remainder on a terminal too narrow even for those. */ -export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement { - const chips = resolveChips(state, ctrlCArmed ?? false); +export function HotkeyHint({ + state, + ctrlCArmed, + menuLeaderArmed, + width, +}: HotkeyHintProps): ReactElement { + const chips = fitChips( + resolveChips(state, ctrlCArmed ?? false, menuLeaderArmed ?? false), + width, + ); return ( - + {chips.map((chip, idx) => ( - - - [{chip.key}] - - {chip.label} + + {idx < chips.length - 1 ? ( {" "} @@ -44,75 +54,33 @@ export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement {" "} ) : null} - + ))} ); } -function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { - if (state.pendingApproval) { - return [ - { key: "y", label: "approve" }, - { key: "n", label: "deny" }, - { key: "esc", label: "abort run" }, - ]; - } - if (state.slashPaletteOpen) { - return [ - { key: "↑↓", label: "select" }, - { key: "tab/enter", label: "accept" }, - { key: "esc", label: "close" }, - ]; - } - if (state.status === "running") { - // A long streaming answer is exactly when the operator wants to - // scroll back, so the hint rides along with abort. - return [ - { key: SCROLL_KEY, label: "scroll" }, - { key: "esc", label: "abort" }, - { - key: "ctrl+c", - label: ctrlCArmed ? "press again to quit" : "abort", - }, - ]; - } - if (state.uiMode === "debug") { - return [ - { key: "tab", label: "next panel" }, - { key: "shift+tab", label: "prev panel" }, - { key: "ctrl+b", label: "next panel" }, - { key: "/", label: "commands" }, - { - key: "ctrl+c", - label: ctrlCArmed ? "press again to quit" : "quit", - }, - ]; - } - if (state.chatFocus === "sidebar") { - return [ - { key: "↑↓", label: "select" }, - { key: "enter", label: "open" }, - { key: "tab", label: "next pane" }, - { key: "esc", label: "back to editor" }, - { - key: "ctrl+c", - label: ctrlCArmed ? "press again to quit" : "quit", - }, - ]; - } - // Six chips is the cap for one row on narrow terminals. The scroll - // hint replaces ctrl+b: Observe stays reachable via /observe, while - // scrolling had no visible entry point at all. - return [ - { key: "enter", label: "send" }, - { key: "alt+enter", label: "newline" }, - { key: "tab", label: "sidebar" }, - { key: SCROLL_KEY, label: "scroll" }, - { key: "/", label: "commands" }, - { - key: "ctrl+c", - label: ctrlCArmed ? "press again to quit" : "quit", - }, - ]; +function Chip({ chip }: { chip: HotkeyChip }): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + + [{chip.key}] + + {chip.label} + + ); + if (!mouse || !chip.onClick) return label; + const onClick = chip.onClick; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onClick(mouse); + return true; + }} + > + {label} + + ); } diff --git a/src/tui/components/llm-fallback-rows.test.tsx b/src/tui/components/llm-fallback-rows.test.tsx index ab28a881..1b61ec58 100644 --- a/src/tui/components/llm-fallback-rows.test.tsx +++ b/src/tui/components/llm-fallback-rows.test.tsx @@ -2,8 +2,14 @@ import { render } from "ink-testing-library"; import { describe, expect, it } from "vitest"; import type { FallbackLinkRow } from "../llm-panel/fallback/fallback-panel-state.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; import { fakeSession } from "../test-fixtures.js"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { FallbackRows } from "./llm-fallback-rows.js"; import { LlmModeRows } from "./llm-mode-rows.js"; function strip(value: string): string { @@ -63,6 +69,26 @@ describe("FallbackRows", () => { expect(out).toContain("No chain configured"); }); + it("still renders the add row on an empty chain (cursor row 0 is visible)", () => { + // The row MODEL has an add row whenever something is addable, chain + // or no chain — the renderer must agree, or the cursor sits on a row + // the screen never drew and Enter looks like it does nothing. + const state = fallbackState({ links: [], addableProviderIds: ["cloud-a"] }); + const { lastFrame } = render(); + const out = strip(lastFrame() ?? ""); + expect(out).toContain("+ add link"); + // Cursor 0 = the add row; it renders selected. + expect(out).toContain("> + add link"); + }); + + it("renders neither hint marker nor add row when nothing is addable and no chain", () => { + const state = fallbackState({ links: [], addableProviderIds: [] }); + const { lastFrame } = render(); + const out = strip(lastFrame() ?? ""); + expect(out).toContain("No chain configured"); + expect(out).not.toContain("+ add link"); + }); + it("surfaces the last fallover as a live status line (no invented countdown)", () => { const state = fallbackState({ links: [link("cloud-a", { isActive: true })], @@ -103,3 +129,154 @@ describe("FallbackRows", () => { expect(strip(lastFrame() ?? "")).toContain("append local as last resort: off"); }); }); + +/** + * Click plumbing. Rendered under a real `MouseProvider` + registry, the + * same harness as `chat-copy-button.test.tsx`: locate the row's text in + * the frame, dispatch a press at that cell, and assert what the row + * dispatched / which callback fired. The activation path is + * `pressEnter(handleLlmPanelKey)`, so a click on the selected row must do + * exactly what Enter does — no second implementation to drift. + */ +describe("FallbackRows clicks", () => { + function press(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; + } + + function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of frame.split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); + } + + const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + + /** Pane state ready for clicking: debug mode, LLM tab, fallback pane. */ + function clickableState( + patch: Partial = {}, + cursor = 0, + ): TuiState { + const base = fallbackState(patch); + return { + ...base, + uiMode: "debug", + activeTab: "llm", + llmPanel: { ...base.llmPanel, fallbackCursor: cursor }, + }; + } + + interface ClickHarness { + frame: () => string; + clickAt: (needle: string) => Promise; + dispatched: TuiAction[]; + calls: unknown[][]; + unmount: () => void; + } + + function mount(state: TuiState): ClickHarness { + const registry = new MouseTargetRegistry(); + const dispatched: TuiAction[] = []; + const calls: unknown[][] = []; + const callbacks: TuiAppCallbacks = { + onFallbackAddRequested: (providerId) => calls.push(["add", providerId]), + }; + const { lastFrame, unmount } = render( + dispatched.push(action)} + callbacks={callbacks} + getState={() => state} + > + + , + ); + const frame = (): string => strip(lastFrame() ?? ""); + return { + frame, + // Targets register in an effect after the first frame, so the + // first press can land on unowned cells — re-send until something + // records, the same loop as the sibling click tests. + clickAt: async (needle) => { + for (let attempt = 0; attempt < 40; attempt += 1) { + if (dispatched.length > 0 || calls.length > 0) return; + const at = locate(frame(), needle); + registry.dispatch(press(at.x, at.y)); + await delay(25); + } + }, + dispatched, + calls, + unmount, + }; + } + + it("a click on an unselected chain row moves the cursor there", async () => { + const app = mount( + clickableState( + { + links: [link("cloud-a", { isActive: true }), link("cloud-b")], + addableProviderIds: ["cloud-c"], + }, + 0, + ), + ); + await app.clickAt("2. cloud-b"); + expect(app.dispatched).toEqual([{ type: "llm_cursor_set", cursor: 1 }]); + expect(app.calls).toEqual([]); + app.unmount(); + }); + + it("a click on the selected add row replays Enter (opens the picker)", async () => { + const state = clickableState( + { + links: [link("cloud-a", { isActive: true })], + addableProviderIds: ["cloud-b"], + }, + 1, // cursor on the add row + ); + const app = mount(state); + await app.clickAt("+ add link"); + expect(app.dispatched).toEqual([{ type: "fallback_add_picker_opened" }]); + app.unmount(); + }); + + it("a click on the picker's selected row adds that provider via the callback", async () => { + const state = clickableState({ + links: [link("cloud-a", { isActive: true })], + addableProviderIds: ["cloud-b", "cloud-c"], + addPicker: { cursor: 1 }, + }); + const app = mount(state); + await app.clickAt("cloud-c"); + expect(app.calls).toEqual([["add", "cloud-c"]]); + expect(app.dispatched).toEqual([{ type: "fallback_add_picker_closed" }]); + app.unmount(); + }); + + it("a click on an unselected picker row moves the picker cursor", async () => { + const state = clickableState({ + links: [link("cloud-a", { isActive: true })], + addableProviderIds: ["cloud-b", "cloud-c"], + addPicker: { cursor: 1 }, + }); + const app = mount(state); + await app.clickAt("cloud-b"); + expect(app.dispatched).toEqual([ + { type: "fallback_add_picker_cursor_set", cursor: 0 }, + ]); + expect(app.calls).toEqual([]); + app.unmount(); + }); +}); diff --git a/src/tui/components/llm-fallback-rows.tsx b/src/tui/components/llm-fallback-rows.tsx index 91f4c9fd..37f6ab62 100644 --- a/src/tui/components/llm-fallback-rows.tsx +++ b/src/tui/components/llm-fallback-rows.tsx @@ -5,6 +5,8 @@ import { type FallbackPaneRow, } from "../llm-panel/fallback/fallback-rows.js"; import type { FallbackLinkRow } from "../llm-panel/fallback/fallback-panel-state.js"; +import { handleLlmPanelKey } from "../llm-panel/llm-panel-key-bindings.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; @@ -15,6 +17,10 @@ import type { TuiState } from "../tui-state.js"; * and `llmPanel.fallbackCursor`. The chain shown is the *effective* order * — the active text provider is always the head, and the auto-appended * local last resort is flagged. + * + * Rows are `selectFallbackPaneRows` verbatim — including the `+ add + * link` row on an EMPTY chain, where the cursor's row model would + * otherwise point at something the screen never drew. */ export function FallbackRows({ state }: { state: TuiState }): ReactElement { const panel = state.fallbackPanel; @@ -35,17 +41,16 @@ export function FallbackRows({ state }: { state: TuiState }): ReactElement { {panel.links.length === 0 ? ( {" "}No chain configured. Falls back to the active provider only. - Press a to add a link to fail over to. - ) : ( - rows.map((row, index) => ( - - )) - )} + ) : null} + {rows.map((row, index) => ( + + ))} @@ -106,34 +111,49 @@ function StatusLine({ state }: { state: TuiState }): ReactElement { ); } +/** + * One pane row. `MouseListRow` gives it the TUI-wide click contract — + * first click moves the cursor here, a second click on the selected row + * replays Enter through `handleLlmPanelKey`, so the mouse runs the exact + * key path (open the add picker) and can never drift from the keyboard. + */ function FallbackRow({ row, + index, selected, }: { row: FallbackPaneRow; + index: number; selected: boolean; }): ReactElement { - if (row.kind === "add") { - return ( - - {selected ? ">" : " "} + add link{" "} - · Enter or a to choose a provider - - ); - } return ( - + mouse.dispatch({ type: "llm_cursor_set", cursor: index }) + } + onActivate={pressEnter(handleLlmPanelKey)} > - {selected ? ">" : " "} {formatLink(row.link, row.index)} - · {linkNote(row.link)} - + {row.kind === "add" ? ( + + {selected ? ">" : " "} + add link{" "} + · Enter or a to choose a provider + + ) : ( + + {selected ? ">" : " "} {formatLink(row.link, row.index)} + · {linkNote(row.link)} + + )} + ); } @@ -162,14 +182,25 @@ function AddLinkPicker({ state }: { state: TuiState }): ReactElement { ) : ( addableProviderIds.map((id, index) => ( - + mouse.dispatch({ + type: "fallback_add_picker_cursor_set", + cursor: index, + }) + } + onActivate={pressEnter(handleLlmPanelKey)} > - {index === cursor ? ">" : " "} {id} - + + {index === cursor ? ">" : " "} {id} + + )) )} {" "}↑/↓ move · Enter add · Esc cancel diff --git a/src/tui/components/llm-health-badge.tsx b/src/tui/components/llm-health-badge.tsx index a025bfb9..3efbf082 100644 --- a/src/tui/components/llm-health-badge.tsx +++ b/src/tui/components/llm-health-badge.tsx @@ -1,7 +1,10 @@ import { Text } from "ink"; import type { ReactElement } from "react"; -import type { LlmHealthState } from "../llm-health/llm-health-state.js"; +import type { + LlmHealthState, + LlmHealthStatus, +} from "../llm-health/llm-health-state.js"; import { theme } from "../theme/theme.js"; /** @@ -19,7 +22,7 @@ export interface LlmHealthBadgeProps { } export function LlmHealthBadge({ health }: LlmHealthBadgeProps): ReactElement { - const { color, glyph, label } = resolveBadge(health); + const { color, glyph, label } = llmHealthLook(health.status); return ( @@ -30,24 +33,49 @@ export function LlmHealthBadge({ health }: LlmHealthBadgeProps): ReactElement { ); } -interface BadgeLook { +export interface LlmHealthLook { color: string; glyph: string; label: string; } -function resolveBadge(health: LlmHealthState): BadgeLook { - switch (health.status) { +/** + * Which ground the dot is about to be painted on. + * + * The same status is drawn in two places — this badge, on the terminal's + * own page, and the composer's backend control, on the rail — and + * contrast is a property of the pair, not of the colour. Asking the + * caller which ground it owns is what lets one glyph table serve both + * without either surface inventing a second one. + */ +export type LlmHealthGround = "page" | "rail"; + +/** + * The ●/◐/○/✕/· vocabulary, resolved from a status and the ground it + * lands on, so the composer's backend control can paint the same dot + * this badge does. + */ +export function llmHealthLook( + status: LlmHealthStatus, + ground: LlmHealthGround = "page", +): LlmHealthLook { + const c = theme.colors; + const onRail = ground === "rail"; + const success = onRail ? c.railSuccess : c.success; + const warn = onRail ? c.railWarn : c.warn; + const error = onRail ? c.railError : c.error; + const muted = onRail ? c.railMuted : c.muted; + switch (status) { case "healthy": - return { color: theme.colors.success, glyph: "●", label: "healthy" }; + return { color: success, glyph: "●", label: "healthy" }; case "probing": - return { color: theme.colors.warn, glyph: "◐", label: "probing" }; + return { color: warn, glyph: "◐", label: "probing" }; case "unreachable": - return { color: theme.colors.muted, glyph: "○", label: "down" }; + return { color: muted, glyph: "○", label: "down" }; case "error": - return { color: theme.colors.error, glyph: "✕", label: "error" }; + return { color: error, glyph: "✕", label: "error" }; case "unknown": default: - return { color: theme.colors.muted, glyph: "·", label: "unknown" }; + return { color: muted, glyph: "·", label: "unknown" }; } } diff --git a/src/tui/components/llm-mode-rows-cloud.test.tsx b/src/tui/components/llm-mode-rows-cloud.test.tsx index 6aa5cf78..9a88998e 100644 --- a/src/tui/components/llm-mode-rows-cloud.test.tsx +++ b/src/tui/components/llm-mode-rows-cloud.test.tsx @@ -160,3 +160,21 @@ describe("CloudRows inline model section", () => { expect(frame).toContain("nous/m-000 [text]"); }); }); + +describe("CloudRows empty provider list", () => { + // Styling is deliberately not asserted here: ink-testing-library renders at + // chalk level 0, so every SGR sequence is dropped before `lastFrame()` sees + // it — which is why every test in this file strips ANSI anyway. What is + // pinned is that the call to action reaches the user in the state where it + // is the only thing telling them what to do. + it("tells the user how to add a provider when none are configured", () => { + const frame = renderRows(cloudState([]), 30); + expect(frame).toContain("No cloud providers configured. Press n to add one."); + }); + + it("drops the hint once a provider exists", async () => { + await seedCompatCache("https://render.nous.example", ["m-000"]); + const frame = renderRows(cloudState([compatProvider()]), 30); + expect(frame).not.toContain("No cloud providers configured"); + }); +}); diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx index 89371029..6043651b 100644 --- a/src/tui/components/llm-mode-rows.tsx +++ b/src/tui/components/llm-mode-rows.tsx @@ -1,12 +1,17 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { PasteFieldTarget } from "../context-menu/paste-field-target.js"; +import { pasteIntoCloudModelFilter } from "../llm-panel/llm-panel-paste.js"; import { selectCloudModelSection } from "../llm-panel/llm-panel-row-builders.js"; import { activeCursor, selectLlmPanelRows, type LlmPanelRow } from "../llm-panel/llm-panel-selectors.js"; import { classifyRamFit, classifyVramFit } from "../local-models/local-models-panel-state.js"; import { computeRowWindow } from "../row-window.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleLlmPanelKey } from "../llm-panel/llm-panel-key-bindings.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { FallbackRows } from "./llm-fallback-rows.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; export function LlmModeRows({ rows, @@ -208,6 +213,7 @@ function CloudRows({ rows={providerRows} state={state} empty="No cloud providers configured. Press n to add one." + emphasiseEmpty /> @@ -219,16 +225,20 @@ function CloudRows({ {section.provider?.id ?? "none"} - - {"filter: "} - - {filter} + {/* Right-click paste lands in the filter (focusing it first), + through the same key path typing takes. */} + + + {"filter: "} + + {filter} + + {filterFocused ? : null} + {!filterFocused && filter.length === 0 ? ( + f to filter + ) : null} - {filterFocused ? : null} - {!filterFocused && filter.length === 0 ? ( - f to filter - ) : null} - + {section.status === "loading" ? ( fetching model list… ) : null} @@ -282,11 +292,20 @@ function RowsSection({ rows, state, empty = "No rows in this section yet.", + emphasiseEmpty = false, }: { title: string; rows: readonly LlmPanelRow[]; state: TuiState; empty?: string; + /** + * Render the empty hint bold in the terminal's default foreground instead of + * muted grey. For an empty state that is really a call to action — the pane + * is useless until you act on it — muted grey reads as "nothing to see here" + * and the instruction gets skipped. Left unset elsewhere: a section that is + * merely empty should stay quiet. + */ + emphasiseEmpty?: boolean; }): ReactElement { return ( @@ -294,7 +313,13 @@ function RowsSection({ {title} {rows.length === 0 ? ( - {empty} + + {" "} + {empty} + ) : ( rows.map((row) => ) )} @@ -334,15 +359,23 @@ function Row({ row, state }: { row: LlmPanelRow; state: TuiState }): ReactElemen // see `LlmModeRows` — but never guarded the horizontal axis), which is // what garbles adjacent rows and drags rendering on a narrow window. return ( - - {mark} {renderRowText(row, state)} - {insufficient ? ( - Not enough VRAM - ) : ramFit === "tight" ? ( - RAM tight - ) : null} - · {row.enterEffect} - + + mouse.dispatch({ type: "llm_cursor_set", cursor: idx }) + } + onActivate={pressEnter(handleLlmPanelKey)} + > + + {mark} {renderRowText(row, state)} + {insufficient ? ( + Not enough VRAM + ) : ramFit === "tight" ? ( + RAM tight + ) : null} + · {row.enterEffect} + + ); } @@ -357,7 +390,13 @@ function renderRowText(row: LlmPanelRow, state: TuiState): string { case "localBackend": return `llama.cpp backend [${state.localModelsPanel.backend.currentTag ?? "not installed"}]`; case "cloudProvider": - return `${row.provider.id} [${row.provider.kind}] ${row.provider.hasApiKey ? "key ok" : "missing key"}`; + return `${row.provider.id} [${row.provider.kind}] ${ + row.provider.kind === SUBSCRIPTION_CLI_KIND + ? "cli auth" + : row.provider.hasApiKey + ? "key ok" + : "missing key" + }`; case "cloudChatModel": return `${row.providerId}/${row.modelId} [text]`; case "cloudEmbeddingModel": diff --git a/src/tui/components/llm-panel-modals.tsx b/src/tui/components/llm-panel-modals.tsx index be15965d..5e621e4f 100644 --- a/src/tui/components/llm-panel-modals.tsx +++ b/src/tui/components/llm-panel-modals.tsx @@ -1,5 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement, ReactNode } from "react"; +import { PasteFieldTarget } from "../context-menu/paste-field-target.js"; +import { pasteIntoLlmModalField } from "../llm-panel/llm-panel-paste.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; @@ -36,6 +38,30 @@ function blankRows(count: number): ReactElement[] { )); } +/** + * True when one of the boxes below owns the screen. + * + * `handleLlmModalKey` returns non-null for exactly these states, i.e. + * the panel behind a modal cannot be driven while one is open. It must + * therefore not be DRAWN either: the panel already spends the whole tab + * budget, so drawing a modal on top of it is a frame taller than the + * terminal, and Ink 7 resolves that by overwriting earlier lines rather + * than clipping. Callers use this to hand the modal the full budget and + * render nothing else. + */ +export function hasLlmModal(state: TuiState): boolean { + return ( + state.providersPanel.wizard !== null || + state.providersPanel.removeConfirm !== null || + state.localModelsPanel.embeddingOnboardingPrompt !== null || + state.localModelsPanel.removeConfirmId !== null || + state.localModelsPanel.embeddingRemoveConfirmId !== null || + state.providersPanel.chatModelPicker !== null || + state.llmPanel.externalUrlDraft !== null || + state.llmPanel.stopLocalDaemonsPrompt !== null + ); +} + export function LlmPanelModals({ state, maxRows, @@ -44,7 +70,12 @@ export function LlmPanelModals({ maxRows?: number; }): ReactElement | null { if (state.providersPanel.wizard) { - return ; + return ( + + ); } if (state.providersPanel.removeConfirm) { return ( @@ -135,11 +166,15 @@ export function LlmPanelModals({ }`; return ( - - {"filter: "} - {queryLine} - - + {/* Right-click paste appends to the query through the modal's + own key layer. */} + + + {"filter: "} + {queryLine} + + + {visible.map((id: string, i: number) => { const idx = start + i; const selected = idx === picker.cursor; @@ -175,10 +210,14 @@ export function LlmPanelModals({ const valid = parseExternalUrl(draft) !== null; return ( - - {draft} - - + {/* A URL is the paste case — right-click routes the clipboard + through the same modal key layer typing uses. */} + + + {draft} + + + {valid ? null : invalid URL} Saved after a /health probe succeeds. Enter save · Esc cancel diff --git a/src/tui/components/llm-panel.test.tsx b/src/tui/components/llm-panel.test.tsx index 22b4584a..1b77db0d 100644 --- a/src/tui/components/llm-panel.test.tsx +++ b/src/tui/components/llm-panel.test.tsx @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { createInitialTuiState, type TuiState } from "../tui-state.js"; import { fakeSession } from "../test-fixtures.js"; import type { ProvidersChatModelPickerState } from "../providers/providers-panel-state.js"; +import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js"; +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; import { LlmPanel } from "./llm-panel.js"; function stateWithPicker( @@ -115,6 +117,63 @@ describe("LlmPanel", () => { }); }); +/** + * Reported as "there is only aimlapi in the provider list" and "I don't + * see OpenRouter on some screen sizes". + * + * Neither was a missing row: `KIND_ROW_ORDER` has always had all of + * them. The wizard was drawn ON TOP of the whole LLM panel, so the frame + * ran ~16 rows past the tab budget, and Ink 7 answers an over-tall frame + * by painting later lines over earlier ones instead of clipping. Half + * the provider rows arrived on screen wearing the tail of the row below + * them. The budgets below are what `tabContentBudget` hands the tab at + * 120x40, 100x30 and 80x24 — the three sizes the reports came from. + */ +describe("the add-provider wizard fits the terminal", () => { + function stateWithWizard(): TuiState { + const base = createInitialTuiState(fakeSession()); + return { + ...base, + uiMode: "debug" as const, + activeTab: "llm" as const, + llmPanel: { ...base.llmPanel, mode: "cloud" as const }, + providersPanel: { + ...base.providersPanel, + wizard: createProvidersWizardState("add"), + }, + }; + } + + for (const budget of [27, 17, 11]) { + it(`never exceeds a ${budget}-row budget`, () => { + const { lastFrame } = render( + , + ); + expect((lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(budget); + }); + } + + it("still shows OpenRouter and the full-list counter on a short terminal", () => { + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("OpenRouter"); + expect(text).toContain(`(1/${KIND_ROW_ORDER.length})`); + }); + + it("draws the modal alone, not stacked over the panel it covers", () => { + // The panel is unreachable while the wizard owns the keyboard, and + // drawing it was what spent the row budget twice. + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).not.toContain("Active chat route"); + expect(text).not.toContain("n add provider"); + }); +}); + describe("model picker fixed height", () => { const MAX_ROWS = 20; @@ -154,3 +213,58 @@ describe("model picker fixed height", () => { ); }); }); + +describe("the status line's pane routing", () => { + function stateWithStatus( + mode: "external" | "cloud", + source: "cloud" | "external", + line: string, + ): TuiState { + const base = createInitialTuiState(fakeSession()); + return { + ...base, + uiMode: "debug" as const, + activeTab: "llm" as const, + llmPanel: { ...base.llmPanel, mode }, + providersPanel: { + ...base.providersPanel, + statusLine: line, + statusLineSource: source, + }, + }; + } + + it("shows an external-save verdict, unprefixed, on the External pane", () => { + const { lastFrame } = render( + , + ); + const frame = stripAnsi(lastFrame() ?? ""); + expect(frame).toContain("probing http://10.0.0.5:8080…"); + expect(frame).not.toContain("cloud providers:"); + }); + + it("keeps a cloud-provider line off the External pane", () => { + // The regression: a catalog refresh reporting through the same slot + // rendered bare on the External pane and read as a URL verdict. + const { lastFrame } = render( + , + ); + expect(stripAnsi(lastFrame() ?? "")).not.toContain("updating model catalog"); + }); + + it("keeps an external verdict off the cloud pane's prefixed slot", () => { + const { lastFrame } = render( + , + ); + expect(stripAnsi(lastFrame() ?? "")).not.toContain("probing http://10.0.0.5:8080…"); + }); +}); diff --git a/src/tui/components/llm-panel.tsx b/src/tui/components/llm-panel.tsx index b0f80a5e..3af40fcb 100644 --- a/src/tui/components/llm-panel.tsx +++ b/src/tui/components/llm-panel.tsx @@ -8,8 +8,11 @@ import { } from "../llm-panel/llm-panel-selectors.js"; import type { LocalModelsPanelState } from "../local-models/local-models-panel-state.js"; import { LLM_PANEL_MODES, type LlmPanelMode } from "../llm-panel/llm-panel-state.js"; +import { isLocalModelsHfOpen } from "../local-models/local-models-hf-keys.js"; import { LlmModeRows } from "./llm-mode-rows.js"; -import { LlmPanelModals } from "./llm-panel-modals.js"; +import { LocalModelsHuggingFaceBranch } from "./local-models-hf-branch.js"; +import { hasLlmModal, LlmPanelModals } from "./llm-panel-modals.js"; +import { renderProgressBar } from "./render-progress-bar.js"; /** * Rows consumed by the full fixed chrome: RouteCard (~7) + ModeHeader (3) @@ -47,9 +50,33 @@ export function LlmPanel({ const useFull = maxRows >= FULL_HEADER_ROWS + FULL_HEADER_MIN_LIST; const headerRows = useFull ? FULL_HEADER_ROWS : COMPACT_HEADER_ROWS; const listBudget = Math.max(1, maxRows - headerRows); + // A modal takes the whole budget and the panel behind it is not drawn. + // The two used to be stacked, which spent the budget twice over: Ink 7 + // does not clip an over-tall frame, it paints later lines over earlier + // ones, so the add-provider list arrived on screen with most of its + // rows overwritten by the panel underneath (reports #1 and #2). The + // panel is unreachable while a modal is open anyway — + // `handleLlmModalKey` claims every key — so nothing is lost by hiding + // it, and the modal finally gets a height it can size itself against. + if (hasLlmModal(state)) { + return ( + + + + ); + } + // "Add a model from Hugging Face" takes the whole pane, for the same + // reason the modals above do: it owns every key while it is open, so + // drawing the model list behind it would be a list nothing can reach. + if (isLocalModelsHfOpen(state)) { + return ( + + + + ); + } return ( - {/* The starting banner and active-download banners are important feedback — keep them visible regardless of the compact/full header decision. */} @@ -161,6 +188,11 @@ function footerHint(mode: LlmPanelMode, useFull: boolean): string { ? "j/k move · < > reorder · a add link · d remove · l toggle local · ←/→ switch pane · r refresh" : "j/k · < > reorder · a add · d remove · l local · ←/→ pane"; } + if (mode === "local") { + return useFull + ? "j/k move · Enter selected action · a add from hugging face · ←/→ switch Local/Cloud/External/Fallback · s start/stop · r refresh" + : "j/k · Enter · a add · ←/→ mode · r"; + } return useFull ? "j/k move · Enter selected action · ←/→ switch Local/Cloud/External/Fallback · f filter · n add provider · c configure · r refresh" : "j/k · Enter · ←/→ mode · f filter · r"; @@ -215,9 +247,24 @@ function StatusLines({ if (state.localModelsPanel.daemonError) { lines.push(`local daemon: ${state.localModelsPanel.daemonError}`); } + } else if (state.llmPanel.mode === "external") { + // The External pane's status messages (probe verdicts from the URL + // save) describe an external llama.cpp, so the "cloud providers:" + // prefix would mislabel exactly the line the operator must act on. + // Only external-sourced lines render here: a cloud catalog refresh + // reporting on this pane, unprefixed, read as a verdict on the URL. + if ( + state.providersPanel.statusLine && + state.providersPanel.statusLineSource === "external" + ) { + lines.push(state.providersPanel.statusLine); + } } else { if (state.providersPanel.busy) lines.push("cloud providers: updating"); - if (state.providersPanel.statusLine) { + if ( + state.providersPanel.statusLine && + state.providersPanel.statusLineSource === "cloud" + ) { lines.push(`cloud providers: ${state.providersPanel.statusLine}`); } } @@ -310,11 +357,6 @@ function DownloadBanner({ ); } -function renderProgressBar(percent: number, width: number): string { - const filled = Math.min(width, Math.round((percent / 100) * width)); - return "=".repeat(filled) + " ".repeat(Math.max(0, width - filled)); -} - function formatDownloadBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; diff --git a/src/tui/components/local-models-config-wizard.test.tsx b/src/tui/components/local-models-config-wizard.test.tsx deleted file mode 100644 index b8ec2a6a..00000000 --- a/src/tui/components/local-models-config-wizard.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { render } from "ink-testing-library"; -import { describe, expect, it, vi } from "vitest"; - -import { LocalModelsConfigWizard } from "./local-models-config-wizard.js"; - -vi.mock("../../llm/llama-server-health.js", () => ({ - checkLlamaServer: vi.fn(async () => ({ - reachable: true, - status: 200, - error: null, - latencyMs: 1, - })), -})); - -function stripAnsi(value: string): string { - return value.replace(/\u001b\[[0-9;]*m/g, ""); -} - -describe("LocalModelsConfigWizard", () => { - it("renders onboarding choices in local, cloud, remote llama.cpp order", () => { - const { lastFrame } = render( - {}} - />, - ); - - const text = stripAnsi(lastFrame() ?? ""); - const local = "[1] Local models (llama.cpp)"; - const cloud = "[2] Cloud models"; - const remote = "[3] Remote llama.cpp"; - - expect(text).toContain(local); - expect(text).toContain(cloud); - expect(text).toContain(remote); - expect(text.indexOf(local)).toBeLessThan(text.indexOf(cloud)); - expect(text.indexOf(cloud)).toBeLessThan(text.indexOf(remote)); - }); - - it("opens the remote llama.cpp flow on chat URL first", async () => { - const { lastFrame, stdin } = render( - {}} - />, - ); - - stdin.write("3"); - await new Promise((resolve) => setTimeout(resolve, 10)); - - const text = stripAnsi(lastFrame() ?? ""); - expect(text).toContain("Set the HTTP base URL of your chat llama-server"); - expect(text).toContain("Enter: test & continue to embedding URL"); - }); - - it("shows the remote embedding URL step as optional", async () => { - const { lastFrame, stdin } = render( - {}} - />, - ); - - stdin.write("3"); - await new Promise((resolve) => setTimeout(resolve, 10)); - stdin.write("\r"); - await new Promise((resolve) => setTimeout(resolve, 10)); - - const text = stripAnsi(lastFrame() ?? ""); - expect(text).toContain("Set the HTTP base URL of your embedding-only llama-server"); - expect(text).toContain("Optional: leave empty"); - expect(text).toContain("empty Enter skips embeddings"); - }); -}); diff --git a/src/tui/components/local-models-config-wizard.tsx b/src/tui/components/local-models-config-wizard.tsx deleted file mode 100644 index 6e0cf616..00000000 --- a/src/tui/components/local-models-config-wizard.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { Box, Text, useApp, useInput } from "ink"; -import { useCallback, useState, type ReactElement } from "react"; -import { checkLlamaServer } from "../../llm/llama-server-health.js"; -import { - normalizeLocalLlmBaseUrl, - persistUserLocalModelsConfig, - persistUserRemoteLlmUrls, -} from "../persist-user-local-models-config.js"; -import { theme } from "../theme/theme.js"; -import { CloudProviderOnboarding } from "./cloud-provider-onboarding.js"; -import { MultiLineEditor } from "./multi-line-editor.js"; - -export type LocalModelsWizardOutcome = - | "saved_external" - | "saved_managed" - | "saved_cloud" - | "skipped" - | "aborted"; - -export interface LocalModelsConfigWizardProps { - initialUrl: string; - probeError: string | null; - onFinished(outcome: LocalModelsWizardOutcome): void; -} - -type WizardPhase = "pick" | "remote-chat-url" | "remote-embedding-url" | "cloud"; - -interface WizardOption { - label: string; - action: "cloud" | "external" | "managed"; -} - -const PICK_OPTIONS: readonly WizardOption[] = [ - { - label: "Local models (llama.cpp) — download and run locally", - action: "managed", - }, - { label: "Cloud models — configure API key and pick a model", action: "cloud" }, - { - label: "Remote llama.cpp — enter an existing llama-server URL", - action: "external", - }, -]; - -/** - * First-run Ink screen when llama-server `/health` is unreachable. - * Pick a local, cloud, or remote llama.cpp flow, then exit. - */ -export function LocalModelsConfigWizard({ - initialUrl, - probeError, - onFinished, -}: LocalModelsConfigWizardProps): ReactElement { - const app = useApp(); - const [phase, setPhase] = useState("pick"); - const [cursor, setCursor] = useState(0); - const [chatUrlLine, setChatUrlLine] = useState(initialUrl); - const [embeddingUrlLine, setEmbeddingUrlLine] = useState(""); - const [busy, setBusy] = useState(false); - const [hint, setHint] = useState(null); - - const finish = useCallback( - (outcome: LocalModelsWizardOutcome) => { - onFinished(outcome); - app.exit(); - }, - [app, onFinished], - ); - - const commitPick = useCallback( - (action: WizardOption["action"]) => { - if (action === "cloud") { - setPhase("cloud"); - return; - } - if (action === "external") { - setPhase("remote-chat-url"); - return; - } - persistUserLocalModelsConfig({ mode: "managed" }); - finish("saved_managed"); - }, - [finish], - ); - - useInput( - (input, key) => { - if (phase !== "pick") return; - if (key.ctrl && input === "c") { - finish("aborted"); - return; - } - if (key.escape) { - finish("skipped"); - return; - } - if (key.upArrow || input === "k") { - setCursor((c) => (c - 1 + PICK_OPTIONS.length) % PICK_OPTIONS.length); - return; - } - if (key.downArrow || input === "j") { - setCursor((c) => (c + 1) % PICK_OPTIONS.length); - return; - } - if (key.return) { - const opt = PICK_OPTIONS[cursor]; - if (opt) commitPick(opt.action); - return; - } - if (input === "1" || input === "2" || input === "3") { - const idx = Number(input) - 1; - const opt = PICK_OPTIONS[idx]; - if (opt) { - setCursor(idx); - commitPick(opt.action); - } - } - }, - { isActive: phase === "pick" }, - ); - - const trySaveChatUrl = useCallback( - async (bufferOverride?: string) => { - if (busy) return; - setBusy(true); - setHint(null); - const source = bufferOverride ?? chatUrlLine; - try { - const base = normalizeLocalLlmBaseUrl(source); - const health = await checkLlamaServer({ - url: base, - retries: 0, - backoffMs: 0, - timeoutMs: 5000, - }); - if (!health.reachable) { - setHint(health.error ?? "health check failed"); - return; - } - setChatUrlLine(base); - setPhase("remote-embedding-url"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - setHint(msg); - } finally { - setBusy(false); - } - }, - [busy, chatUrlLine], - ); - - const trySaveEmbeddingUrl = useCallback( - async (bufferOverride?: string) => { - if (busy) return; - setBusy(true); - setHint(null); - const source = bufferOverride ?? embeddingUrlLine; - try { - const chatUrl = normalizeLocalLlmBaseUrl(chatUrlLine); - if (source.trim().length === 0) { - persistUserRemoteLlmUrls({ chatUrl }); - finish("saved_external"); - return; - } - const embeddingUrl = normalizeLocalLlmBaseUrl(source); - const health = await checkLlamaServer({ - url: embeddingUrl, - retries: 0, - backoffMs: 0, - timeoutMs: 5000, - }); - if (!health.reachable) { - setHint(health.error ?? "health check failed"); - return; - } - persistUserRemoteLlmUrls({ chatUrl, embeddingUrl }); - finish("saved_external"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - setHint(msg); - } finally { - setBusy(false); - } - }, - [busy, chatUrlLine, embeddingUrlLine, finish], - ); - - if (phase === "pick") { - return ( - - - llama-server not reachable - - {probeError ? ( - last error: {probeError} - ) : null} - - {PICK_OPTIONS.map((opt, idx) => { - const selected = idx === cursor; - return ( - - {selected ? "› " : " "} - [{idx + 1}] {opt.label} - - ); - })} - - - - ↑/↓ (j/k) move · Enter select · 1-3 shortcut · Esc skip · Ctrl+C exit - - - - ); - } - - if (phase === "cloud") { - return ( - { - setPhase("pick"); - }} - /> - ); - } - - const isEmbeddingStep = phase === "remote-embedding-url"; - const editorValue = isEmbeddingStep ? embeddingUrlLine : chatUrlLine; - const setEditorValue = isEmbeddingStep ? setEmbeddingUrlLine : setChatUrlLine; - const submitEditor = isEmbeddingStep ? trySaveEmbeddingUrl : trySaveChatUrl; - - return ( - - - llama-server not reachable - - {probeError ? ( - last error: {probeError} - ) : null} - - {isEmbeddingStep - ? "Set the HTTP base URL of your embedding-only " - : "Set the HTTP base URL of your chat "} - llama-server (must answer GET /health). - - {isEmbeddingStep ? ( - - Optional: leave empty to continue without hybrid embedding recall. - - ) : null} - - { - void submitEditor(buffer); - }} - onEscape={() => finish("skipped")} - onInterrupt={() => { - if (!busy) finish("aborted"); - }} - /> - - {busy ? probing /health… : null} - {hint ? {hint} : null} - - - {isEmbeddingStep - ? "Enter: test & save, empty Enter skips embeddings · Esc: skip · Ctrl+C: exit" - : "Enter: test & continue to embedding URL · Esc: skip · Ctrl+C: exit"} - - - - ); -} diff --git a/src/tui/components/local-models-hf-branch.tsx b/src/tui/components/local-models-hf-branch.tsx new file mode 100644 index 00000000..a0c87b29 --- /dev/null +++ b/src/tui/components/local-models-hf-branch.tsx @@ -0,0 +1,76 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { handleLocalModelsHfKey } from "../local-models/local-models-hf-keys.js"; +import type { LocalModelsPanelState } from "../local-models/local-models-panel-state.js"; +import { useMouseCommands } from "../mouse/mouse-context.js"; +import { pressEnter } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; +import { theme } from "../theme/theme.js"; +import { HfPickList } from "./hf-pick-list.js"; +import { HfReferenceEditor } from "./hf-reference-editor.js"; + +/** + * "Add a model from Hugging Face", inside the local-models pane. The screens + * are the same two the first-run flow draws — `HfReferenceEditor` then + * `HfPickList` — so a repo reference behaves identically whether the + * operator names it during onboarding or a month later when they want a + * second model. What differs is the slice the state lives on and the + * key table Enter goes through (`local-models-hf-keys.ts`). + * + * Rendered without the pane's usual list footer: the hotkeys it + * advertises are all inert here, and the branch states its own two. + */ +export function LocalModelsHuggingFaceBranch({ + panel, +}: { + panel: LocalModelsPanelState; +}): ReactElement { + const mouse = useMouseCommands(); + const dispatch = mouse?.dispatch; + const { hf } = panel; + if (panel.mode === "hfPick" && hf.repo) { + return ( + + + m.dispatch({ type: "local_models_hf_cursor_set", cursor }) + } + onActivate={pressEnter(handleLocalModelsHfKey)} + /> + + j/k move · Enter download · esc back + + + ); + } + return ( + + + dispatch?.({ type: "local_models_hf_reference_changed", value }) + } + onSubmit={(value) => + mouse?.callbacks.onLocalModelsHfResolveRequested?.(value) + } + onClear={() => + dispatch?.({ type: "local_models_hf_reference_changed", value: "" }) + } + onEscape={() => dispatch?.({ type: "local_models_hf_closed" })} + mouseLayer={MOUSE_LAYER_PANEL} + /> + + {hf.busy + ? "esc cancel" + : "enter look it up · ctrl+l clear · esc back to the list"} + + + ); +} diff --git a/src/tui/components/local-models-panel.tsx b/src/tui/components/local-models-panel.tsx index d0776572..ec02e874 100644 --- a/src/tui/components/local-models-panel.tsx +++ b/src/tui/components/local-models-panel.tsx @@ -1,5 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { LocalModelsHuggingFaceBranch } from "./local-models-hf-branch.js"; +import { handleLocalModelsTabKey } from "../local-models/local-models-key-bindings.js"; import { theme } from "../theme/theme.js"; import { computeRowWindow } from "../row-window.js"; import { @@ -14,6 +17,7 @@ import { type RamFit, } from "../local-models/local-models-panel-state.js"; import type { LocalModelDef } from "../../local-llm/index.js"; +import { renderProgressBar } from "./render-progress-bar.js"; /** * Render the per-row availability badge that combines GGUF + mmproj @@ -152,11 +156,6 @@ function renderEmbeddingDaemonLine( ); } -function renderProgressBar(percent: number, width: number): string { - const filled = Math.min(width, Math.round((percent / 100) * width)); - return "=".repeat(filled) + " ".repeat(Math.max(0, width - filled)); -} - function ramFitColor(fit: RamFit): string { switch (fit) { case "ok": @@ -238,6 +237,9 @@ export function LocalModelsPanel({ ); } + if (panel.mode === "hfRef" || panel.mode === "hfPick") { + return ; + } if (panel.mode === "detail") { const ref = resolveRowAt(panel); // Detail view is chat-only — embedding rows are intentionally @@ -247,7 +249,20 @@ export function LocalModelsPanel({ } const row = ref.row; const m = row.def; - const enterHint = !row.downloaded + // A pull for this very row is in flight: offering "Enter — download" + // again is both wrong and re-triggerable. + const rowPull = + panel.pull && + !panel.pull.error && + panel.pull.kind === "chat" && + panel.pull.modelId === row.id + ? panel.pull + : null; + const enterHint = rowPull + ? rowPull.totalBytes > 0 + ? `downloading… ${rowPull.percent}%` + : "downloading…" + : !row.downloaded ? row.def.supportsVision ? "Enter — download (gguf + mmproj)" : "Enter — download" @@ -426,10 +441,11 @@ export function LocalModelsPanel({ data dir: {panel.dataDir} · backend{" "} {panel.backend.currentTag ?? "—"} {panel.backend.updateAvailable === true ? " (update available)" : ""} + {panel.backend.autoUpdate ? "" : " · auto-update off"} ) : null} - j/k move · Enter pull/activate (embedding: *row + Enter starts server) · g gguf · i info · d remove · s chat+embedding · E embeddings on/off · G gpu · B · r · L + j/k move · Enter pull/activate (embedding: *row + Enter starts server) · a add from hugging face · g gguf · i info · d remove · s chat+embedding · E embeddings on/off · G gpu · U auto-update · B · r · L ) : ( @@ -440,7 +456,7 @@ export function LocalModelsPanel({ ) : null} {renderDaemonLine(panel)} - j/k · Enter · d remove · s start · r + j/k · Enter · a add · d remove · s start · r )} @@ -582,7 +598,15 @@ function renderChatRow( // their individual colors; the badges that fall off the edge are // informational and reappear once the window is widened. return ( - + + mouse.dispatch({ type: "local_models_cursor_set", row: index }) + } + onActivate={pressEnter(handleLocalModelsTabKey)} + > + ) : null} + ); } @@ -664,7 +689,18 @@ function renderEmbeddingRow( // See renderChatRow: nowrap + per-fragment truncate-end so a narrow // window clips the row instead of wrapping and overlapping the next. return ( - + + mouse.dispatch({ + type: "local_models_cursor_set", + row: embOffset + index, + }) + } + onActivate={pressEnter(handleLocalModelsTabKey)} + > + {isCursor ? "> " : " "} {r.active ? "* " : ""} @@ -685,7 +721,9 @@ function renderEmbeddingRow( ) : null} + ); } + diff --git a/src/tui/components/logo-art.generated.test.ts b/src/tui/components/logo-art.generated.test.ts new file mode 100644 index 00000000..8a408a99 --- /dev/null +++ b/src/tui/components/logo-art.generated.test.ts @@ -0,0 +1,81 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { CROSS_MARKS, type MarkScale } from "./logo-art.js"; + +/** + * `logo-art.ts` is generated from `assets/logo.svg`. Hand-editing it is + * how the old three hand-drawn copies drifted apart in the first place, + * so re-run the generator and fail if the checked-in file has moved. + */ +describe("logo-art.ts", () => { + it("is in sync with assets/logo.svg", () => { + expect(() => + execFileSync("node", ["scripts/generate-logo-art.mjs", "--check"], { + cwd: new URL("../../../", import.meta.url).pathname, + stdio: "pipe", + }), + ).not.toThrow(); + }); + + const scales: readonly MarkScale[] = ["lg", "md", "sm", "xs"]; + + it.each(scales)("draws %s the same size in both strokes", (scale) => { + const block = CROSS_MARKS.block[scale]; + const ascii = CROSS_MARKS.ascii[scale]; + const measure = (rows: readonly string[]) => ({ + width: rows.reduce((acc, row) => Math.max(acc, row.length), 0), + height: rows.length, + }); + expect(measure(ascii)).toEqual(measure(block)); + }); + + it("orders the scales strictly smallest-last", () => { + const widths = scales.map((scale) => + CROSS_MARKS.ascii[scale].reduce((acc, r) => Math.max(acc, r.length), 0), + ); + expect(widths[0]).toBeGreaterThan(widths[1]!); + expect(widths[1]).toBeGreaterThan(widths[2]!); + expect(widths[2]).toBeGreaterThan(widths[3]!); + }); + + it("draws SM as the three-row sign, fillets included", () => { + // The rail uses this verbatim and `SIDEBAR_CHROME_ROWS` counts its + // rows, so a change here is a change to the rail's budget. + expect(CROSS_MARKS.block.sm).toHaveLength(3); + expect( + CROSS_MARKS.block.sm.reduce((acc, row) => Math.max(acc, row.length), 0), + ).toBe(6); + // The concave pair, and only the concave pair: filleting the hard + // corners too would make the mark 4-fold symmetric. + expect(CROSS_MARKS.block.sm[0]).toContain("▗"); + expect(CROSS_MARKS.block.sm[2]).toContain("▘"); + expect(CROSS_MARKS.block.sm.join("")).not.toContain("▖"); + expect(CROSS_MARKS.block.sm.join("")).not.toContain("▝"); + }); + + it("draws XS as the two-row half-cell sign", () => { + // The onboarding header's minimal tier and the splash's shortest + // band both budget for exactly this footprint: 4 columns, 2 rows. + const xs = CROSS_MARKS.block.xs; + expect(xs).toHaveLength(2); + expect(xs.reduce((acc, row) => Math.max(acc, row.length), 0)).toBe(4); + // Same concave pair as SM — the corners that keep the sign + // 180°-symmetric instead of collapsing into a generic 4-fold plus. + expect(xs[0]).toContain("▗"); + expect(xs[1]).toContain("▘"); + expect(xs.join("")).not.toContain("▖"); + expect(xs.join("")).not.toContain("▝"); + // Face, half-cell face, shade — no wall tone at this size. + for (const row of xs) { + expect(row).toMatch(/^[ █░▗▘▄▀]*$/u); + } + }); + + it("uses only ASCII in the ascii stroke", () => { + for (const scale of scales) { + for (const row of CROSS_MARKS.ascii[scale]) { + expect(row).toMatch(/^[ #+.]*$/u); + } + } + }); +}); diff --git a/src/tui/components/logo-art.ts b/src/tui/components/logo-art.ts new file mode 100644 index 00000000..7a2cdfaa --- /dev/null +++ b/src/tui/components/logo-art.ts @@ -0,0 +1,184 @@ +/** + * Brand-mark artwork: the Atomic cross at four scales, in two stroke + * systems, plus a dedicated rail mark. + * + * GENERATED FROM `assets/logo.svg` by `scripts/generate-logo-art.mjs`. + * Do not hand-edit — redraw the SVG and regenerate. + * `logo-art.generated.test.ts` fails if this file drifts from the source. + * + * **Why separate drawings instead of one scaled at runtime.** These + * marks carry depth in up to three tones — face, extruded wall, cast + * shadow. The rasteriser this replaced scaled one drawing by first + * flattening it to a boolean ink mask, in which every non-space glyph + * counts as ink; run these through it and `#`, `+` and `.` collapse + * into one solid blob with the depth gone. Tone has to be re-decided per + * size, not resampled. + * + * The ladder is quantized rather than continuous anyway: the arm is + * exactly a quarter of the bounding box and must be a whole number of + * cells, so the usable sizes are fixed points with nothing to + * interpolate between. + * + * Geometry rules the artwork obeys, should the SVG ever be redrawn: + * + * - The concave fillet is in the **top-left** and **bottom-right** + * quadrants only. Top-right and bottom-left are straight segments + * meeting at a hard 90°. The mark is 180°-symmetric, not 4-fold, so + * mirroring or v-flipping it yields a *different* logo. + * - The fillets leave each arm edge tangentially: the arms stay + * parallel-sided near the tips and flare only toward the centre. + * - Depth sweeps bottom-right (observer there, light from the top-left) + * at a true 45° *on screen* — which at a ~2.2:1 cell aspect means + * ~2.2 columns per row, not one. + */ + +/** Which drawing to use. A bigger scale is not a scaled-up smaller one. */ +export type MarkScale = "lg" | "md" | "sm" | "xs"; + +/** + * Glyph system. `block` uses Unicode block elements; `ascii` stays in + * plain ASCII so it survives `TERM=dumb`, CI log scrapes and non-UTF-8 + * locales. + */ +export type MarkStroke = "block" | "ascii"; + +export type MarkArt = Readonly>; + +/** + * Glyphs that draw a mark's front plane, sub-cell face ink included — + * SM's fillets, XS's half-cell bar. Everything else in the art is + * depth (extruded wall, cast shadow) or blank. Exported from here so + * every renderer colours the same glyphs as face instead of keeping a + * private copy that drifts when the art gains a glyph. + */ +export const FACE_GLYPHS: ReadonlySet = new Set([ + "#", + "\u2588", // █ full block + "\u2597", // ▗ SM/XS concave fillet, top-left + "\u2598", // ▘ SM/XS concave fillet, bottom-right + "\u2584", // ▄ lower half block — XS bar, top row + "\u2580", // ▀ upper half block — XS bar, bottom row +]); + +/** `█` face, `▓` wall, `░` shadow. */ +const BLOCK: MarkArt = { + // 51 x 24 + lg: [ + " ███████████▓", + " ███████████▓▓▓", + " ████████████▓▓▓▓", + " █████████████▓▓▓▓░░", + " ██████████████▓▓▓▓░░", + " ████████████████▓▓▓▓░░", + " ██████████████████▓▓▓▓░░", + " ██████████████████████▓▓▓▓░░", + "█████████████████████████████████████████████▓", + "█████████████████████████████████████████████▓▓▓", + "█████████████████████████████████████████████▓▓▓▓", + "█████████████████████████████████████████████▓▓▓▓░░", + "█████████████████████████████████████████████▓▓▓▓░░", + " ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████████████████▓▓▓▓▓▓▓▓▓▓░░", + " ▓▓▓▓▓▓▓▓▓▓▓▓▓██████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░", + " ░░░░░░░░░░░████████████████▓▓▓▓▓▓▓▓▓▓░░░░░░░░", + " ██████████████▓▓▓▓▓▓▓▓░░░░░░", + " █████████████▓▓▓▓▓▓▓░░░░", + " ████████████▓▓▓▓▓▓░░░░", + " ███████████▓▓▓▓▓▓░░░", + " ███████████▓▓▓▓▓░░░", + " ▓▓▓▓▓▓▓▓▓▓▓▓▓░░░", + " ▓▓▓▓▓▓▓▓▓▓▓░░", + " ░░░░░░░░░░░", + ], + // 31 x 14 + md: [ + " ███████░", + " ████████░░", + " █████████░░", + " ██████████░░", + " █████████████░░", + "█████████████████████████████░", + "█████████████████████████████░░", + "█████████████████████████████░░", + " ░░░░░░░░░█████████████░░░░░░░", + " ██████████░░░░░", + " █████████░░░", + " ████████░░░", + " ███████░░░", + " ░░░░░░░", + ], + // 6 x 3 + sm: [ + " ▗█░", + "█████░", + " █▘░", + ], + // 4 x 2 + xs: [ + "▗█▄░", + "▀█▘░", + ], +}; + +/** `#` face, `+` wall, `.` shadow. */ +const ASCII: MarkArt = { + // 51 x 24 + lg: [ + " ###########+", + " ###########+++", + " ############++++", + " #############++++..", + " ##############++++..", + " ################++++..", + " ##################++++..", + " ######################++++..", + "#############################################+", + "#############################################+++", + "#############################################++++", + "#############################################++++..", + "#############################################++++..", + " +++++++++++++++######################++++++++++..", + " +++++++++++++##################++++++++++++++..", + " ...........################++++++++++........", + " ##############++++++++......", + " #############+++++++....", + " ############++++++....", + " ###########++++++...", + " ###########+++++...", + " +++++++++++++...", + " +++++++++++..", + " ...........", + ], + // 31 x 14 + md: [ + " #######+", + " ########++", + " #########++", + " ##########++", + " #############++", + "#############################+", + "#############################++", + "#############################++", + " +++++++++#############+++++++", + " ##########+++++", + " #########+++", + " ########+++", + " #######+++", + " +++++++", + ], + // 6 x 3 + sm: [ + " #.", + "#####.", + " #.", + ], + // 4 x 2 + xs: [ + " #.", + "###.", + ], +}; + +export const CROSS_MARKS: Readonly> = { + block: BLOCK, + ascii: ASCII, +}; diff --git a/src/tui/components/logo-fit.test.ts b/src/tui/components/logo-fit.test.ts new file mode 100644 index 00000000..9fc7f4ed --- /dev/null +++ b/src/tui/components/logo-fit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { LOGO_ART, TAGLINE, WORDMARK_ROWS } from "./logo.js"; +import { LOGO_METRICS, WORDMARK_WIDTH, type LogoVariant } from "./splash-fit.js"; + +function measure(rows: readonly string[]): { width: number; height: number } { + return { + width: rows.reduce((acc, row) => Math.max(acc, row.length), 0), + height: rows.length, + }; +} + +/** + * `splash-fit.ts` picks a mark from numbers it keeps in `LOGO_METRICS`; + * the artwork itself lives in `logo.tsx`. If the two ever drift the + * breakpoints silently start lying, so measure the real rows here. + */ +describe("logo artwork", () => { + const variants: readonly LogoVariant[] = ["full", "small", "mini", "tiny"]; + + it.each(variants)("matches the declared metrics for %s", (variant) => { + expect(measure(LOGO_ART[variant])).toEqual(LOGO_METRICS[variant]); + }); + + it("orders the variants strictly smallest-last", () => { + expect(LOGO_METRICS.full.width).toBeGreaterThan(LOGO_METRICS.small.width); + expect(LOGO_METRICS.small.width).toBeGreaterThan(LOGO_METRICS.mini.width); + expect(LOGO_METRICS.mini.width).toBeGreaterThan(LOGO_METRICS.tiny.width); + expect(LOGO_METRICS.full.height).toBeGreaterThan(LOGO_METRICS.small.height); + expect(LOGO_METRICS.small.height).toBeGreaterThan(LOGO_METRICS.mini.height); + expect(LOGO_METRICS.mini.height).toBeGreaterThan(LOGO_METRICS.tiny.height); + }); + + it("matches the declared wordmark width and keeps the tagline narrower", () => { + expect(measure(WORDMARK_ROWS).width).toBe(WORDMARK_WIDTH); + expect(TAGLINE.length).toBeLessThanOrEqual(WORDMARK_WIDTH); + }); +}); diff --git a/src/tui/components/logo.test.tsx b/src/tui/components/logo.test.tsx index da58608c..ae26ce62 100644 --- a/src/tui/components/logo.test.tsx +++ b/src/tui/components/logo.test.tsx @@ -12,16 +12,16 @@ describe("Logo", () => { it("renders the plus-mark middle bar and the wordmark by default", () => { const { lastFrame } = render(); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain("::::::::::::::::::::::::::::::::::"); - expect(frame).toContain("▄▀█ ▀█▀ █▀█"); + expect(frame).toContain("#".repeat(45)); + expect(frame).toContain("\u2584\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588"); expect(frame).toContain("Local AI-First Agent"); }); it("hides the wordmark in compact mode", () => { const { lastFrame } = render(); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain("::::::::::::::::::::::::::::::::::"); - expect(frame).not.toContain("▄▀█ ▀█▀ █▀█"); + expect(frame).toContain("#".repeat(45)); + expect(frame).not.toContain("\u2584\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588"); expect(frame).not.toContain("Local AI-First Agent"); }); }); diff --git a/src/tui/components/logo.tsx b/src/tui/components/logo.tsx index 42fd4015..88cda60d 100644 --- a/src/tui/components/logo.tsx +++ b/src/tui/components/logo.tsx @@ -1,83 +1,177 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { CROSS_MARKS, FACE_GLYPHS } from "./logo-art.js"; +import type { LogoVariant, WordmarkPlacement } from "./splash-fit.js"; /** - * Atomic-plus mark + `ATOMIC AGENT` wordmark, rendered side-by-side and + * Atomic cross + `ATOMIC AGENT` wordmark, rendered side-by-side and * vertically centred. Extracted from `SplashBanner` so the same artwork * can be reused in any centered "home" layout (e.g. the empty-chat * landing surface) without copying the row data. * - * Rendered as plain Ink primitives — no animations, no alpha. Use the - * `compact` variant in narrow layouts where the wordmark would wrap. + * Rendered as plain Ink primitives — no animations, no alpha. The mark + * comes in four sizes so the same component can serve a 200-column + * desktop terminal and a 40-column SSH window: `full` (51×24), `small` + * (31×14), `mini` (6×3) and `tiny` (4×2). `SplashBanner` picks one via + * `computeSplashFit`. + * + * **Every size is its own drawing** — see `logo-art.ts`. They used to be + * measured off one source at load time, which cannot work now that the + * marks carry depth: the scaler flattens its input to a boolean ink + * mask, so the three tones would collapse into one solid silhouette. + * + * The home surface draws the **ascii** stroke and the rail draws + * **block**. That split is deliberate: the splash is the one screen a + * first run is guaranteed to hit, including over a serial console or a + * CI log scrape where block elements arrive as mojibake, whereas the + * rail only exists in a session already rendering box-drawing chrome. */ export interface LogoProps { + /** Which mark to draw. Defaults to the full 34×20 artwork. */ + variant?: LogoVariant; + /** + * Legacy switch for "mark only, no wordmark". Still honoured so + * existing callers keep working; prefer `wordmark={false}`. + */ compact?: boolean; + /** Draw the `ATOMIC AGENT` wordmark beside the mark. */ + wordmark?: boolean; + /** Draw the "Local AI-First Agent" tagline under the wordmark. */ + tagline?: boolean; + /** + * Where the wordmark sits. `"below"` stacks it under the mark, which + * is what lets the 51-column `full` mark keep its name on a terminal + * too narrow to park them side by side. + */ + placement?: WordmarkPlacement; } -export function Logo({ compact = false }: LogoProps): ReactElement { +/** + * Splash artwork, one purpose-drawn asset per scale. `splash-fit.ts` + * mirrors these dimensions in `LOGO_METRICS`; `logo-fit.test.ts` + * re-measures the rows and fails if the two ever drift apart. + */ +export const LOGO_ART: Readonly> = { + full: CROSS_MARKS.ascii.lg, + small: CROSS_MARKS.ascii.md, + mini: CROSS_MARKS.ascii.sm, + tiny: CROSS_MARKS.ascii.xs, +}; + +/** + * The rail's brand mark: the guideline's SM glyph, block stroke, 9x5. + * `sidebar.tsx` keeps {@link MARK_COLUMNS} in step with its width, and + * `SIDEBAR_CHROME_ROWS` counts its five rows. + */ +export const RAIL_MARK: readonly string[] = CROSS_MARKS.block.sm; + +/** + * `ATOMIC AGENT` — the original half-block lockup, restored. + * + * It is two rows of `▀`/`▄`, which is what gives it its weight at two + * rows tall. Those glyphs need the terminal to split a cell at an + * integer pixel row, so they are the first thing to look wrong when a + * font substitutes for the block range or a line height does not divide + * evenly — see the note on `WORDMARK_STACK_ROWS` in `splash-fit.ts` for + * what the layout guarantees and what it cannot. + */ +export const WORDMARK_ROWS: readonly string[] = [ + "\u2584\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588 \u2588\u2580\u2584\u2580\u2588 \u2588 \u2588\u2580\u2580 \u2584\u2580\u2588 \u2588\u2580\u2580 \u2588\u2580\u2580 \u2588\u2584 \u2588 \u2580\u2588\u2580", + "\u2588\u2580\u2588 \u2588 \u2588\u2584\u2588 \u2588 \u2580 \u2588 \u2588 \u2588\u2584\u2584 \u2588\u2580\u2588 \u2588\u2584\u2588 \u2588\u2588\u2584 \u2588 \u2580\u2588 \u2588 ", +]; + +export const TAGLINE = "Local AI-First Agent"; + +export function Logo({ + variant = "full", + compact = false, + wordmark, + tagline, + placement = "beside", +}: LogoProps): ReactElement { + const showWordmark = wordmark ?? !compact; + const showTagline = tagline ?? showWordmark; + if (placement === "below" && (showWordmark || showTagline)) { + return ( + + + {showWordmark ? ( + + + + ) : null} + {showTagline ? ( + + {TAGLINE} + + ) : null} + + ); + } return ( - - {compact ? null : ( + + {showWordmark || showTagline ? ( - - - - Local AI-First Agent - - + {showWordmark ? : null} + {showTagline ? ( + + + {TAGLINE} + + + ) : null} - )} + ) : null} ); } -function LogoMark(): ReactElement { - // Leading padding has been uniformly trimmed so the middle bar sits - // at column 0 — keeps the art within ~34 columns for narrow terminals. - const rows: readonly string[] = [ - " -:::::::--", - " -::::::::-", - " -:::::::::-", - " -::::::::::-", - " -:::::::::::-", - " -:::::::::::::-", - " -::::::::::::::::-", - "-::::::::::::::::::::::::::::::::-", - "::::::::::::::::::::::::::::::::::", - "::::::::::::::::::::::::::::::::::", - "-:::::::::::::::::::::::::::::::::", - "=------------:::::::::::::::::---=", - " @@@@@@@@@@@*-::::::::::::-=+#%%@", - " -:::::::::::-+#@", - " -::::::::::=#@", - " -:::::::::=#", - " -::::::::-*", - " -::::::::=", - " +--------*", - " %%%%%%", - ]; +function LogoMark({ variant }: { variant: LogoVariant }): ReactElement { return ( - {rows.map((row, idx) => ( - - {row} - + {LOGO_ART[variant].map((row, idx) => ( + ))} ); } +/** + * One row of the mark, split into runs of face and depth so the two can + * be painted apart. Colour carries the front/side distinction better + * than glyph density does; the density ramp is still there underneath + * for terminals with no colour to spend. + */ +function MarkRow({ row }: { row: string }): ReactElement { + const runs: { text: string; face: boolean }[] = []; + for (const ch of row) { + const face = FACE_GLYPHS.has(ch); + const last = runs[runs.length - 1]; + if (last && last.face === face) last.text += ch; + else runs.push({ text: ch, face }); + } + return ( + + {runs.map((run, idx) => ( + + {run.text} + + ))} + + ); +} + function WordMark(): ReactElement { - const rows: readonly string[] = [ - "▄▀█ ▀█▀ █▀█ █▀▄▀█ █ █▀▀ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀", - "█▀█ █ █▄█ █ ▀ █ █ █▄▄ █▀█ █▄█ ██▄ █ ▀█ █ ", - ]; return ( - {rows.map((row, idx) => ( - + {WORDMARK_ROWS.map((row, idx) => ( + {row} ))} diff --git a/src/tui/components/mcp-add-modal.tsx b/src/tui/components/mcp-add-modal.tsx index 3647ffce..fec99f67 100644 --- a/src/tui/components/mcp-add-modal.tsx +++ b/src/tui/components/mcp-add-modal.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; import { theme } from "../theme/theme.js"; import type { McpAddModalState } from "../mcp/mcp-panel-state.js"; import { MultiLineEditor } from "./multi-line-editor.js"; @@ -93,6 +94,10 @@ export function McpAddModal({ onSubmit={(buffer) => onSubmit(buffer)} onEscape={onCancel} onInterrupt={onCancel} + // The modal raises the mouse floor; the field registers on the + // modal layer so caret clicks and the right-click paste menu + // still reach it. + mouseLayer={MOUSE_LAYER_MODAL} /> {state.error ? ( diff --git a/src/tui/components/mcp-list.tsx b/src/tui/components/mcp-list.tsx index e8fb3e05..2c09dbb0 100644 --- a/src/tui/components/mcp-list.tsx +++ b/src/tui/components/mcp-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleMcpTabKey } from "../mcp/mcp-key-bindings.js"; import type { McpPanelState, McpServerRow, @@ -30,11 +32,16 @@ export function McpList(props: McpListProps): ReactElement { return ( {slice.map((row, idx) => ( - + selected={start + idx === panel.cursor} + onSelect={(mouse) => + mouse.dispatch({ type: "mcp_cursor_set", row: start + idx }) + } + onActivate={pressEnter(handleMcpTabKey)} + > + + ))} ); diff --git a/src/tui/components/memory-list.tsx b/src/tui/components/memory-list.tsx index 90d07f6f..3ac2380d 100644 --- a/src/tui/components/memory-list.tsx +++ b/src/tui/components/memory-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleMemoryTabKey } from "../memory/memory-key-bindings.js"; import type { MemoryPanelState } from "../memory/memory-panel-state.js"; import type { MemorySummaryRow } from "../memory/memory-panel-state.js"; @@ -44,11 +46,16 @@ export function MemoryList(props: MemoryListProps): ReactElement { ↑ {hiddenBefore} above ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ type: "memory_cursor_set", row: idx + windowStart }) + } + onActivate={pressEnter(handleMemoryTabKey)} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/multi-line-editor-body.tsx b/src/tui/components/multi-line-editor-body.tsx index d7d5f5a6..1267c87c 100644 --- a/src/tui/components/multi-line-editor-body.tsx +++ b/src/tui/components/multi-line-editor-body.tsx @@ -1,13 +1,60 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import { useRef, type ReactElement } from "react"; +import { useMouseCommands, useMouseTarget } from "../mouse/mouse-context.js"; +import { isPrimaryPress, isSecondaryPress } from "../mouse/mouse-event.js"; +import { computeRowWindow } from "../row-window.js"; import { theme } from "../theme/theme.js"; import type { Cursor } from "./multi-line-editor-cursor.js"; +/** Width of the `❯ ` / ` ` gutter in front of every editor line. */ +const GUTTER_COLUMNS = 2; + export interface EditorBodyProps { value: string; cursor: Cursor; placeholder: string; + /** Ink for the buffer text; inherits the terminal default when absent. */ + textColor?: string; focus: boolean; + /** + * Selected span as buffer offsets, `[start, end)`, or `null`. Painted + * in inverse video — the same mark the caret uses, because a terminal + * has exactly one way to say "this text is picked out" and no colour + * that survives every palette. + */ + selection?: readonly [number, number] | null; + /** Buffer offset of the first character of each rendered line. */ + onDragStart?: (row: number, col: number) => void; + onDragMove?: (row: number, col: number) => void; + onDragEnd?: () => void; + /** + * Move the caret to a clicked cell. `row`/`col` are already relative + * to the text, gutter excluded; the owner clamps and converts them to + * a buffer offset. + */ + onClickCursor?: (row: number, col: number) => void; + /** + * Right button pressed on the buffer. `cell` is the ABSOLUTE screen + * cell (not text-relative like the click callbacks): it anchors the + * context menu, which floats in screen space. Return whether the + * press was consumed — the owner declines when no menu can open. + */ + onSecondaryPress?: (cell: { x: number; y: number }) => boolean; + /** + * Most buffer lines painted at once. Beyond it the body renders a + * cursor-tracking window (`computeRowWindow`, the same mechanism the + * manage panels use) instead of every line: the composer overlay this + * body sits in must stop growing before it climbs under the status + * bar. Omitted (component tests, the wizard) means unbounded. + */ + maxVisibleLines?: number; + /** + * Mouse layer for the body's click target. The composer overlay + * paints over the chat log, so its editor registers above + * `MOUSE_LAYER_BASE` — otherwise a small chat control underneath + * would win the innermost-first sort and steal the click. + */ + mouseLayer?: number; } /** @@ -20,52 +67,191 @@ export function EditorBody({ value, cursor, placeholder, + textColor, focus, + selection = null, + onClickCursor, + onSecondaryPress, + onDragStart, + onDragMove, + onDragEnd, + maxVisibleLines, + mouseLayer, }: EditorBodyProps): ReactElement { + // One target for the whole buffer: the click's local row is the line, + // its local column minus the gutter is the character. Lines are not + // soft-wrapped here, so the mapping is exact. + const mouse = useMouseCommands(); + /** + * Whether the drag in progress started here. Motion and release are + * hit-tested by position like any other event, so a drag that began in + * the chat log and merely passes over the composer would otherwise + * move its caret — and, with a selection live, silently re-point one + * end of it. Only a press on this target opens the gesture. + */ + const draggingRef = useRef(false); + const lines = value.split("\n"); + // The visible slice of the buffer. `computeRowWindow` keeps the + // cursor's line in view, which is the line every keystroke edits, so + // typing at the cap scrolls the window rather than the composer. + const lineWindow = computeRowWindow( + lines.length, + cursor.row, + maxVisibleLines ?? lines.length, + ); + const bodyRef = useMouseTarget((hit) => { + // Local rows are window rows: the body only paints the slice, so a + // click's line index is offset by everything scrolled off above. + const row = lineWindow.start + hit.localY; + const col = hit.localX - GUTTER_COLUMNS; + if (isSecondaryPress(hit.event)) { + // The menu anchors at the clicked SCREEN cell, so the local + // coordinates are folded back into absolutes here — the one place + // that has both the rect and the local offsets. + return ( + onSecondaryPress?.({ + x: hit.rect.left + hit.localX, + y: hit.rect.top + hit.localY, + }) ?? false + ); + } + // A press starts a drag AND places the caret: press-move-release is + // one gesture, and a press that turns out to be a plain click has + // already done the right thing by the time the release arrives. + if (isPrimaryPress(hit.event)) { + onClickCursor?.(row, col); + onDragStart?.(row, col); + // Take the pointer for the gesture: hit-testing routes by + // position, so a drag that wanders out of the composer would + // otherwise deliver its motion — and its release — to whatever + // sits under the cursor, leaving the selection neither extended + // nor ended. + draggingRef.current = true; + mouse?.registry.capturePointer(bodyRef); + return true; + } + if (!draggingRef.current) return false; + if (hit.event.kind === "motion" && hit.event.button === "left") { + onDragMove?.(row, col); + return true; + } + if (hit.event.kind === "release") { + draggingRef.current = false; + mouse?.registry.releasePointer(); + onDragEnd?.(); + return true; + } + return false; + }, { layer: mouseLayer }); if (value.length === 0) { return ( - + {theme.glyphs.promptCaret} {focus ? : null} {placeholder} ); } - const lines = value.split("\n"); + // Buffer offset of each line's first character; +1 per newline. + const lineStarts: number[] = []; + let offset = 0; + for (const line of lines) { + lineStarts.push(offset); + offset += line.length + 1; + } + const visible = lines.slice(lineWindow.start, lineWindow.start + lineWindow.count); return ( - - {lines.map((line, idx) => ( - - - {idx === 0 ? `${theme.glyphs.promptCaret} ` : " "} - - {renderLineWithCursor( - line, - idx === cursor.row ? cursor.col : -1, - focus, - )} - - ))} + + {visible.map((line, sliceIdx) => { + // Everything buffer-relative — the caret glyph, the cursor row, + // the selection clip — keys off the real line index, not the + // slice position, or scrolling the window would move them all. + const idx = lineWindow.start + sliceIdx; + return ( + + + {idx === 0 ? `${theme.glyphs.promptCaret} ` : " "} + + {renderLine({ + line, + cursorCol: idx === cursor.row ? cursor.col : -1, + focus, + ...(textColor !== undefined ? { textColor } : {}), + // Offsets of this line within the buffer, so the selection + // (which is buffer-relative) can be clipped to it. + lineStart: lineStarts[idx] ?? 0, + selection, + })} + + ); + })} ); } -function renderLineWithCursor( - line: string, - cursorCol: number, - focus: boolean, -): ReactElement { +/** + * One rendered line: the selected span in inverse video, and the caret + * as an inverse cell. When both want the same cell the selection wins — + * a caret drawn inside a highlighted run would be an inverse cell on an + * inverse ground, i.e. invisible. + */ +function renderLine({ + line, + cursorCol, + focus, + lineStart, + selection, + textColor, +}: { + line: string; + cursorCol: number; + focus: boolean; + lineStart: number; + selection: readonly [number, number] | null; + textColor?: string; +}): ReactElement { + // One `color` on the wrapping ``: the inverse runs (selection, + // caret) inherit it and swap it against the ground themselves, so the + // highlight keeps working without a second colour to keep in sync. + const ink = textColor !== undefined ? { color: textColor } : {}; + const span = selection ? clipToLine(selection, lineStart, line.length) : null; + if (span) { + const [from, to] = span; + return ( + + {line.slice(0, from)} + {line.slice(from, to)} + {line.slice(to)} + + ); + } if (cursorCol < 0 || !focus) { - return {line}; + return {line}; } const before = line.slice(0, cursorCol); const atCursor = line[cursorCol] ?? " "; const after = line.slice(cursorCol + 1); return ( - + {before} {atCursor} {after} ); } + +/** + * Intersect a buffer-relative selection with one line, returning + * line-relative columns, or `null` when the line is outside it. + */ +function clipToLine( + selection: readonly [number, number], + lineStart: number, + lineLength: number, +): [number, number] | null { + const lineEnd = lineStart + lineLength; + const from = Math.max(selection[0], lineStart); + const to = Math.min(selection[1], lineEnd); + if (to <= from) return null; + return [from - lineStart, to - lineStart]; +} diff --git a/src/tui/components/multi-line-editor-clipboard.ts b/src/tui/components/multi-line-editor-clipboard.ts new file mode 100644 index 00000000..314056dd --- /dev/null +++ b/src/tui/components/multi-line-editor-clipboard.ts @@ -0,0 +1,99 @@ +import { useCallback, useRef } from "react"; +import { useClipboardReader } from "../clipboard/clipboard-context.js"; +import { + openContextMenu, + useContextMenuHandle, +} from "../context-menu/context-menu-context.js"; +import { useMouseCommands } from "../mouse/mouse-context.js"; +import { plainKey } from "../mouse/synthetic-key.js"; +import { deleteSelection, insertText, type EditContext } from "./multi-line-editor-edits.js"; + +/** + * The editor's clipboard-read side: the paste routine (shared by the + * Ctrl+V/Cmd+V chord and the right-click menu — one implementation) and + * the right-press opener for the context menu. Split from + * `multi-line-editor.tsx` for the size budget, colocated because these + * are the component's own handlers over its own private state. + * + * Everything is read through a deps ref refreshed each render: the menu + * runs its verbs from a click that arrives frames after it opened, and + * paste resolves a promise later still — closures frozen at open time + * would edit a buffer that no longer exists. + */ +export interface EditorClipboardDeps { + readonly disabled: boolean; + readonly hasSelection: boolean; + /** This render's buffer/caret/selection plus the setters. `key` is + * supplied here (a paste burst carries none), so the component hands + * over exactly what it has. */ + readonly edit: Omit; + /** The editor's own copy — the one Ctrl+C uses, notices included. */ + readonly copySelection: () => void; +} + +export interface EditorClipboard { + /** Paste the system clipboard at the caret / over the selection. */ + readonly pasteClipboard: () => void; + /** Open the context menu at an absolute screen cell. */ + readonly openMenuAt: (cell: { x: number; y: number }) => boolean; +} + +export function useEditorClipboard(deps: EditorClipboardDeps): EditorClipboard { + const reader = useClipboardReader(); + const mouse = useMouseCommands(); + const handle = useContextMenuHandle(); + const depsRef = useRef(deps); + depsRef.current = deps; + const readerRef = useRef(reader); + readerRef.current = reader; + const liveEdit = (): EditContext => ({ + key: plainKey(), + ...depsRef.current.edit, + }); + + const pasteClipboard = useCallback(() => { + void readerRef.current.read().then((text) => { + if (text.length === 0 || depsRef.current.disabled) return; + // `insertText` is the same routine typing uses: it sanitises the + // burst and replaces the selection, so paste-over-selection works + // exactly like type-over-selection. + insertText(liveEdit(), text); + }); + }, []); + + const openMenuAt = useCallback( + (cell: { x: number; y: number }): boolean => { + const current = depsRef.current; + // No mouse layer or no provider (component tests, the setup + // wizard's separate Ink tree): decline the press rather than + // open a menu nothing can render. + if (current.disabled || !mouse || !handle) return false; + return openContextMenu(handle, mouse, { + menu: { + x: cell.x, + y: cell.y, + target: { kind: "editor", hasSelection: current.hasSelection }, + }, + actions: { + copy: () => { + depsRef.current.copySelection(); + // Match the Ctrl+C chord: a completed copy collapses the + // selection so the next keystroke types, not replaces. + depsRef.current.edit.setAnchor(null); + }, + cut: () => { + // Copy first, then remove — the same order and the same + // helpers as the Ctrl+X chord, so cut can never mean + // something different by mouse than by keyboard. + depsRef.current.copySelection(); + deleteSelection(liveEdit()); + }, + paste: pasteClipboard, + }, + }); + }, + [mouse, handle, pasteClipboard], + ); + + return { pasteClipboard, openMenuAt }; +} diff --git a/src/tui/components/multi-line-editor-edits.ts b/src/tui/components/multi-line-editor-edits.ts new file mode 100644 index 00000000..90d3c465 --- /dev/null +++ b/src/tui/components/multi-line-editor-edits.ts @@ -0,0 +1,75 @@ +import type { Key } from "ink"; +import { cursorToRowCol, rowColToCursor } from "./multi-line-editor-cursor.js"; +import { normalizeInsertText } from "./multi-line-editor-input.js"; + +/** + * The slice of the editor's key context that buffer edits need. A + * structural subset of `KeyContext` (multi-line-editor-keys.ts) so these + * helpers can live in their own file without a circular import. + */ +export interface EditContext { + key: Key; + value: string; + cursor: number; + setBuffer: (next: string, cursor: number) => void; + /** Selected span in buffer offsets, or `null`. */ + selection: readonly [number, number] | null; + /** Where the selection was started; `null` means none is active. */ + anchor: number | null; + setAnchor: (anchor: number | null) => void; +} + +export function insertText(ctx: EditContext, text: string): void { + const { value, cursor, setBuffer, selection } = ctx; + const clean = normalizeInsertText(text); + if (clean.length === 0) return; + // Typing over a selection replaces it, which is what every editor + // does and what makes select-then-retype work. + if (selection) { + const [from, to] = selection; + ctx.setAnchor(null); + const next = value.slice(0, from) + clean + value.slice(to); + setBuffer(next, from + clean.length); + return; + } + const next = value.slice(0, cursor) + clean + value.slice(cursor); + setBuffer(next, cursor + clean.length); +} + +/** Remove the selected span and put the caret where it started. */ +export function deleteSelection(ctx: EditContext): void { + const { value, setBuffer, selection } = ctx; + if (!selection) return; + const [from, to] = selection; + ctx.setAnchor(null); + setBuffer(value.slice(0, from) + value.slice(to), from); +} + +/** + * Called before every caret move. Shift keeps (or drops) an anchor so + * the move extends a selection; an unshifted move collapses it. Holding + * the anchor rather than a range is what lets one rule cover every + * movement key. + */ +export function updateAnchorForMove(ctx: EditContext): void { + if (ctx.key.shift) { + if (ctx.anchor === null) ctx.setAnchor(ctx.cursor); + return; + } + if (ctx.anchor !== null) ctx.setAnchor(null); +} + +export function moveCursorVertically( + ctx: EditContext, + direction: -1 | 1, +): void { + const { value, cursor, setBuffer } = ctx; + const { row, col } = cursorToRowCol(value, cursor); + const lines = value.split("\n"); + const nextRow = row + direction; + if (nextRow < 0 || nextRow >= lines.length) return; + const nextLine = lines[nextRow] ?? ""; + const nextCol = Math.min(col, nextLine.length); + const nextOffset = rowColToCursor(lines, nextRow, nextCol); + setBuffer(value, nextOffset); +} diff --git a/src/tui/components/multi-line-editor-keys.test.ts b/src/tui/components/multi-line-editor-keys.test.ts new file mode 100644 index 00000000..3aece7c5 --- /dev/null +++ b/src/tui/components/multi-line-editor-keys.test.ts @@ -0,0 +1,197 @@ +import type { Key } from "ink"; +import { describe, expect, it, vi } from "vitest"; + +import { handleKey, type KeyContext } from "./multi-line-editor-keys.js"; + +/** + * Table tests for the editor's keystroke grammar, at the `handleKey` + * level: no Ink render, no terminal encoding — the component-level + * suites (selection / newline) drive real byte sequences through Ink's + * parser for the encoding half. + */ +function k(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + super: false, + hyper: false, + capsLock: false, + numLock: false, + ...overrides, + } as Key; +} + +interface Harness { + ctx: KeyContext; + setBuffer: ReturnType; + setAnchor: ReturnType; + copySelection: ReturnType; + onInterrupt: ReturnType; +} + +function press( + input: string, + key: Key, + state: { value: string; cursor: number; anchor?: number | null }, +): Harness { + const setBuffer = vi.fn(); + const setAnchor = vi.fn(); + const copySelection = vi.fn(); + const onInterrupt = vi.fn(); + const anchor = state.anchor ?? null; + const selection: readonly [number, number] | null = + anchor === null || anchor === state.cursor + ? null + : [Math.min(anchor, state.cursor), Math.max(anchor, state.cursor)]; + const ctx: KeyContext = { + input, + key, + value: state.value, + cursor: state.cursor, + setBuffer, + selection, + anchor, + setAnchor, + copySelection, + onSubmit: vi.fn(), + onInterrupt, + }; + handleKey(ctx); + return { ctx, setBuffer, setAnchor, copySelection, onInterrupt }; +} + +describe("selection extension via shift+arrows", () => { + // "ab\ncd", cursor mid-buffer: every direction must drop the anchor at + // the starting cursor and then move the caret. + const value = "ab\ncd"; + const cases: ReadonlyArray<{ + name: string; + key: Key; + cursor: number; + expectedCursor: number; + }> = [ + { name: "shift+left", key: k({ leftArrow: true, shift: true }), cursor: 4, expectedCursor: 3 }, + { name: "shift+right", key: k({ rightArrow: true, shift: true }), cursor: 3, expectedCursor: 4 }, + { name: "shift+up", key: k({ upArrow: true, shift: true }), cursor: 4, expectedCursor: 1 }, + { name: "shift+down", key: k({ downArrow: true, shift: true }), cursor: 1, expectedCursor: 4 }, + // Line-boundary variants: shift+home / shift+end pick to the edges + // of the current line. + { name: "shift+home", key: k({ home: true, shift: true }), cursor: 4, expectedCursor: 3 }, + { name: "shift+end", key: k({ end: true, shift: true }), cursor: 3, expectedCursor: 5 }, + // Buffer-boundary variants: shift+up on the first line goes to 0 + // (never history), shift+down on the last line goes to the end. + { name: "shift+up on first line", key: k({ upArrow: true, shift: true }), cursor: 1, expectedCursor: 0 }, + { name: "shift+down on last line", key: k({ downArrow: true, shift: true }), cursor: 4, expectedCursor: 5 }, + ]; + for (const c of cases) { + it(`${c.name} anchors at the cursor and moves`, () => { + const h = press("", c.key, { value, cursor: c.cursor }); + expect(h.setAnchor).toHaveBeenCalledWith(c.cursor); + expect(h.setBuffer).toHaveBeenCalledWith(value, c.expectedCursor); + }); + } + + it("a plain arrow collapses an existing selection", () => { + const h = press("", k({ leftArrow: true }), { + value, + cursor: 4, + anchor: 2, + }); + expect(h.setAnchor).toHaveBeenCalledWith(null); + expect(h.setBuffer).toHaveBeenCalledWith(value, 3); + }); + + it("plain home/end move without leaving an anchor behind", () => { + const h = press("", k({ end: true }), { value, cursor: 3, anchor: 1 }); + expect(h.setAnchor).toHaveBeenCalledWith(null); + expect(h.setBuffer).toHaveBeenCalledWith(value, 5); + }); +}); + +describe("cut (ctrl+x / kitty cmd+x)", () => { + it("copies then removes the range in a single buffer edit", () => { + const h = press("x", k({ ctrl: true }), { + value: "hello world", + cursor: 11, + anchor: 6, + }); + expect(h.copySelection).toHaveBeenCalledTimes(1); + // One setBuffer call = one undoable edit; caret lands where the + // removed range started. + expect(h.setBuffer).toHaveBeenCalledTimes(1); + expect(h.setBuffer).toHaveBeenCalledWith("hello ", 6); + expect(h.setAnchor).toHaveBeenCalledWith(null); + }); + + it("kitty-reported cmd+x cuts too", () => { + const h = press("x", k({ super: true }), { + value: "abc", + cursor: 3, + anchor: 1, + }); + expect(h.copySelection).toHaveBeenCalledTimes(1); + expect(h.setBuffer).toHaveBeenCalledWith("a", 1); + }); + + it("does nothing without a selection", () => { + const h = press("x", k({ ctrl: true }), { value: "abc", cursor: 3 }); + expect(h.copySelection).not.toHaveBeenCalled(); + expect(h.setBuffer).not.toHaveBeenCalled(); + expect(h.setAnchor).not.toHaveBeenCalled(); + }); +}); + +describe("copy chords", () => { + it("ctrl+c with a selection copies and keeps the interrupt for later", () => { + const h = press("c", k({ ctrl: true }), { + value: "abc", + cursor: 3, + anchor: 0, + }); + expect(h.copySelection).toHaveBeenCalledTimes(1); + expect(h.onInterrupt).not.toHaveBeenCalled(); + }); + + it("ctrl+c without a selection still interrupts", () => { + const h = press("c", k({ ctrl: true }), { value: "abc", cursor: 3 }); + expect(h.onInterrupt).toHaveBeenCalledTimes(1); + expect(h.copySelection).not.toHaveBeenCalled(); + }); + + it("kitty-reported cmd+c copies a selection", () => { + const h = press("c", k({ super: true }), { + value: "abc", + cursor: 3, + anchor: 0, + }); + expect(h.copySelection).toHaveBeenCalledTimes(1); + expect(h.onInterrupt).not.toHaveBeenCalled(); + }); + + it("cmd+c without a selection neither interrupts nor types the letter", () => { + const h = press("c", k({ super: true }), { value: "abc", cursor: 3 }); + expect(h.onInterrupt).not.toHaveBeenCalled(); + expect(h.setBuffer).not.toHaveBeenCalled(); + }); + + it("any other cmd+letter is dropped, never inserted", () => { + // Under kitty, a forwarded Cmd+V arrives with printable input "v" — + // inserting it would type a stray letter on every native paste. + const h = press("v", k({ super: true }), { value: "abc", cursor: 3 }); + expect(h.setBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tui/components/multi-line-editor-keys.ts b/src/tui/components/multi-line-editor-keys.ts new file mode 100644 index 00000000..1a0cf5ba --- /dev/null +++ b/src/tui/components/multi-line-editor-keys.ts @@ -0,0 +1,297 @@ +import type { Key } from "ink"; +import { + findWordStart, + isOnFirstLine, + isOnLastLine, + lineEnd, + lineStart, +} from "./multi-line-editor-cursor.js"; +import { + deleteSelection, + insertText, + moveCursorVertically, + updateAnchorForMove, +} from "./multi-line-editor-edits.js"; + +/** + * Everything `handleKey` may read or call, handed in by the component. + * Pure by construction — the editor's keystroke grammar lives here so it + * can be table-tested without rendering Ink. + */ +export interface KeyContext { + input: string; + key: Key; + value: string; + cursor: number; + setBuffer: (next: string, cursor: number) => void; + /** Selected span in buffer offsets, or `null`. */ + selection: readonly [number, number] | null; + /** Where the selection was started; `null` means none is active. */ + anchor: number | null; + setAnchor: (anchor: number | null) => void; + /** Copy the current selection; the caller keeps or clears it. */ + copySelection: () => void; + /** + * Paste the system clipboard at the caret (replacing any selection). + * Owned by the component because reading the clipboard is async and + * this grammar is synchronous by design. + */ + onPaste?: () => void; + onSubmit: (value: string) => void; + onEscape?: () => void; + onInterrupt?: () => void; + onTab?: () => void; + onShiftTab?: () => void; + onAutocomplete?: () => void; + onHistoryPrev?: () => void; + onHistoryNext?: () => void; +} + +/** + * Copy/cut chords accept Ctrl and — where the kitty protocol reports it — + * the macOS Cmd key (`key.super`). On most macOS terminals Cmd+C/Cmd+X + * never reach stdin at all (the emulator owns them as its native + * copy/paste); when a kitty-protocol terminal *does* forward them, they + * must mean copy/cut here and must never fall through and type the + * letter. + */ +function isCopyChord(input: string, key: Key): boolean { + return (key.ctrl || key.super) && input === "c"; +} + +function isCutChord(input: string, key: Key): boolean { + return (key.ctrl || key.super) && input === "x"; +} + +/** + * Ctrl+V / Cmd+V. This chord exists because the right-click menu cannot + * be the only route to paste: Terminal.app and default iTerm2 swallow + * the right button for their own menus and the TUI never sees it. + */ +function isPasteChord(input: string, key: Key): boolean { + return (key.ctrl || key.super) && input === "v"; +} + +export function handleKey(ctx: KeyContext): void { + const { input, key, value, cursor, setBuffer, selection } = ctx; + if (isCopyChord(input, key)) { + // Selected text turns Ctrl+C into copy, the way it behaves in every + // editor people arrive from. With nothing selected it is still the + // interrupt — `app-key-bindings` stands down for the first case, so + // one keystroke never means both. + if (selection) { + ctx.copySelection(); + ctx.setAnchor(null); + return; + } + // Cmd+C carries no interrupt meaning anywhere; only Ctrl+C does. + if (key.ctrl && ctx.onInterrupt) { + ctx.onInterrupt(); + return; + } + if (key.super) return; + } + if (isCutChord(input, key)) { + if (selection) { + // Copy first, then remove. The removal is a single `setBuffer` + // call, so the whole cut is one buffer edit — nothing can observe + // a copied-but-not-yet-deleted intermediate state. + ctx.copySelection(); + deleteSelection(ctx); + } + // Without a selection the chord means nothing in the editor. + // Returning without acting does not starve other layers: Ink hands + // every keypress to every subscription, so a future global claim on + // Ctrl+X would still see it. + return; + } + if (isPasteChord(input, key)) { + ctx.onPaste?.(); + return; + } + // Ignore keys owned by the global app-level handler so the editor + // never inserts Ctrl+C as "c" or swallows F-key escape sequences. + if (isGlobalHotkey(input, key)) return; + if (key.escape) { + ctx.onEscape?.(); + return; + } + if (key.tab && key.shift) { + ctx.onShiftTab?.(); + return; + } + if (key.tab) { + ctx.onTab?.(); + return; + } + // Ctrl+J is a documented newline binding. In the legacy encoding it + // arrives as a literal "\n" and falls through to the text-insert path + // below; under the kitty protocol it arrives as `ctrl` + `j` and would + // otherwise be dropped by the catch-all, silently losing the binding. + if (key.ctrl && input === "j") { + insertText(ctx, "\n"); + return; + } + if (key.return) { + const newline = key.meta || key.shift || key.ctrl; + const trailingBackslash = value.endsWith("\\") && cursor === value.length; + if (newline) { + insertText(ctx, "\n"); + return; + } + if (trailingBackslash) { + const withoutSlash = value.slice(0, -1); + setBuffer(`${withoutSlash}\n`, withoutSlash.length + 1); + return; + } + ctx.onSubmit(value); + return; + } + if (key.upArrow) { + // Shift+Up on the first line extends to the start of the buffer + // instead of recalling history — history would replace the very + // text being selected. + if (isOnFirstLine(value, cursor) && !key.shift) { + ctx.onHistoryPrev?.(); + return; + } + updateAnchorForMove(ctx); + if (isOnFirstLine(value, cursor)) { + setBuffer(value, 0); + return; + } + moveCursorVertically(ctx, -1); + return; + } + if (key.downArrow) { + if (isOnLastLine(value, cursor) && !key.shift) { + ctx.onHistoryNext?.(); + return; + } + updateAnchorForMove(ctx); + if (isOnLastLine(value, cursor)) { + setBuffer(value, value.length); + return; + } + moveCursorVertically(ctx, 1); + return; + } + if (key.leftArrow) { + updateAnchorForMove(ctx); + setBuffer(value, Math.max(0, cursor - 1)); + return; + } + if (key.rightArrow) { + // Shift+Right extends the selection to the end of the buffer rather + // than accepting a completion: the operator is picking text, not + // asking for the rest of a command. + if (cursor >= value.length && ctx.onAutocomplete && !key.shift) { + ctx.onAutocomplete(); + return; + } + updateAnchorForMove(ctx); + setBuffer(value, Math.min(value.length, cursor + 1)); + return; + } + // Home/End move within the current line, and extend the selection when + // shifted — same anchor rule as the arrows, so Shift+End picks to the + // end of the line the way it does in a GUI editor. (Ctrl+A/Ctrl+E stay + // the emacs moves below: they deliberately collapse the selection.) + if (key.home) { + updateAnchorForMove(ctx); + setBuffer(value, lineStart(value, cursor)); + return; + } + if (key.end) { + updateAnchorForMove(ctx); + setBuffer(value, lineEnd(value, cursor)); + return; + } + if (key.backspace || key.delete) { + if (selection) { + deleteSelection(ctx); + return; + } + if (key.delete && !key.backspace) { + // Forward delete + if (cursor < value.length) { + const next = value.slice(0, cursor) + value.slice(cursor + 1); + setBuffer(next, cursor); + } + return; + } + if (cursor > 0) { + const next = value.slice(0, cursor - 1) + value.slice(cursor); + setBuffer(next, cursor - 1); + } + return; + } + // The emacs bindings all move the caret or shorten the buffer, and a + // selection cannot survive either: an anchor left behind points into + // text that has moved (so Ctrl+C copies the wrong span) or past the + // end of a shorter buffer (so the next character replaces everything + // from the anchor onwards). Each one collapses it first. + if (key.ctrl && input === "a") { + ctx.setAnchor(null); + setBuffer(value, lineStart(value, cursor)); + return; + } + if (key.ctrl && input === "e") { + ctx.setAnchor(null); + setBuffer(value, lineEnd(value, cursor)); + return; + } + if (key.ctrl && input === "u") { + if (selection) { + deleteSelection(ctx); + return; + } + const start = lineStart(value, cursor); + setBuffer(value.slice(0, start) + value.slice(cursor), start); + return; + } + if (key.ctrl && input === "k") { + if (selection) { + deleteSelection(ctx); + return; + } + const end = lineEnd(value, cursor); + setBuffer(value.slice(0, cursor) + value.slice(end), cursor); + return; + } + if (key.ctrl && input === "w") { + if (selection) { + deleteSelection(ctx); + return; + } + const wordStart = findWordStart(value, cursor); + setBuffer(value.slice(0, wordStart) + value.slice(cursor), wordStart); + return; + } + // Drop any other modifier chord (Ctrl/Meta, and the kitty-only + // Super/Hyper) so the editor does not insert it as literal text — + // under the kitty protocol a forwarded Cmd+letter arrives with + // printable `input` and would otherwise type the letter. + if (key.ctrl || key.meta || key.super || key.hyper) return; + if (input.length === 0) return; + // A single control char pressed on its own is ignored — but a + // multi-char paste burst is always sanitised and inserted, even when + // its first byte is a CR/control, because `normalizeInsertText` strips + // the offending bytes. + if ( + input.length === 1 && + input.charCodeAt(0) < 0x20 && + input !== "\n" && + input !== "\t" + ) { + return; + } + insertText(ctx, input); +} + +function isGlobalHotkey(input: string, key: Key): boolean { + if (key.ctrl && (input === "c" || input === "o" || input === "t")) return true; + // F-keys and other multi-byte escape sequences we don't handle locally. + if (input.startsWith("\u001b") && input.length > 1) return true; + return false; +} diff --git a/src/tui/components/multi-line-editor-newline.test.tsx b/src/tui/components/multi-line-editor-newline.test.tsx new file mode 100644 index 00000000..89ca4974 --- /dev/null +++ b/src/tui/components/multi-line-editor-newline.test.tsx @@ -0,0 +1,66 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; + +import { MultiLineEditor } from "./multi-line-editor.js"; + +/** + * Enter versus newline, at the byte level. + * + * The distinction is a terminal-protocol fact, not an app choice: in the + * legacy encoding Enter and Shift+Enter are the same byte (`\r`), so the + * modifier is invisible and Shift+Enter cannot mean anything. Under the + * kitty keyboard protocol (which `tui-command` negotiates at startup + * when the terminal answers `CSI ? u`) Shift+Enter arrives as + * `ESC [ 13 ; 2 u` and the composer's existing `key.shift` branch fires. + * + * These cases drive real byte sequences through Ink's own parser, which + * is the only way to tell the two encodings apart from a test. + */ +const CSI = String.fromCharCode(27) + "["; + +const CASES: ReadonlyArray<{ + name: string; + bytes: string; + expect: "submit" | "newline"; +}> = [ + { name: "legacy Enter", bytes: "\r", expect: "submit" }, + // The one that cannot be fixed in JS: identical bytes to Enter. + { name: "legacy Shift+Enter", bytes: "\r", expect: "submit" }, + { name: "legacy Alt+Enter", bytes: String.fromCharCode(27) + "\r", expect: "newline" }, + { name: "legacy Ctrl+J", bytes: "\n", expect: "newline" }, + { name: "kitty Enter", bytes: `${CSI}13u`, expect: "submit" }, + { name: "kitty Shift+Enter", bytes: `${CSI}13;2u`, expect: "newline" }, + { name: "kitty Alt+Enter", bytes: `${CSI}13;3u`, expect: "newline" }, + { name: "kitty Ctrl+Enter", bytes: `${CSI}13;5u`, expect: "newline" }, + // Under kitty this stops being a literal "\n" and becomes ctrl+j. + { name: "kitty Ctrl+J", bytes: `${CSI}106;5u`, expect: "newline" }, +]; + +describe("composer newline encoding", () => { + for (const testCase of CASES) { + it(`${testCase.name} → ${testCase.expect}`, async () => { + const onSubmit = vi.fn(); + const onChange = vi.fn(); + const { stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 30)); + stdin.write(testCase.bytes); + await new Promise((r) => setTimeout(r, 60)); + + if (testCase.expect === "submit") { + expect(onSubmit).toHaveBeenCalledWith("hi"); + expect(onChange).not.toHaveBeenCalled(); + } else { + expect(onSubmit).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith("hi\n"); + } + unmount(); + }); + } +}); diff --git a/src/tui/components/multi-line-editor-paste.test.tsx b/src/tui/components/multi-line-editor-paste.test.tsx new file mode 100644 index 00000000..1d3a2890 --- /dev/null +++ b/src/tui/components/multi-line-editor-paste.test.tsx @@ -0,0 +1,77 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; + +import { + ClipboardReaderProvider, + createStaticClipboardReader, +} from "../clipboard/index.js"; +import { MultiLineEditor } from "./multi-line-editor.js"; + +/** + * The Ctrl+V / Cmd+V paste chord. It exists because the right-click + * menu cannot be the only route to paste: Terminal.app and default + * iTerm2 swallow the right button for their own menus. The chord and + * the menu's paste row share one implementation (`useEditorClipboard`), + * so this exercises the insertion semantics for both. + */ +const CSI = String.fromCharCode(27) + "["; +const SHIFT_LEFT = CSI + "1;2D"; +const CTRL_V = String.fromCharCode(22); +/** Kitty encoding of Cmd+V: codepoint ; 1+super(8) u. */ +const KITTY_SUPER_V = CSI + "118;9u"; + +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, 40)); + +function mount(value: string, clipboardText: string) { + const onChange = vi.fn(); + const app = render( + + + , + ); + return { ...app, onChange }; +} + +describe("composer paste chord", () => { + it("pastes the clipboard at the caret with ctrl+v", async () => { + const app = mount("hello ", "world"); + await settle(); + app.stdin.write(CTRL_V); + await settle(); + expect(app.onChange).toHaveBeenLastCalledWith("hello world"); + }); + + it("replaces the selection, like type-over", async () => { + const app = mount("hello world", "RLD"); + await settle(); + for (let i = 0; i < 3; i += 1) { + app.stdin.write(SHIFT_LEFT); + await settle(); + } + app.stdin.write(CTRL_V); + await settle(); + expect(app.onChange).toHaveBeenLastCalledWith("hello woRLD"); + }); + + it("accepts the kitty-forwarded Cmd+V and never types the letter", async () => { + const app = mount("abc", "XY"); + await settle(); + app.stdin.write(KITTY_SUPER_V); + await settle(); + expect(app.onChange).toHaveBeenLastCalledWith("abcXY"); + }); + + it("does nothing on an empty clipboard", async () => { + const app = mount("abc", ""); + await settle(); + app.stdin.write(CTRL_V); + await settle(); + expect(app.onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tui/components/multi-line-editor-pointer.ts b/src/tui/components/multi-line-editor-pointer.ts new file mode 100644 index 00000000..aba6929a --- /dev/null +++ b/src/tui/components/multi-line-editor-pointer.ts @@ -0,0 +1,84 @@ +import { rowColToCursor } from "./multi-line-editor-cursor.js"; + +/** + * Mouse handling for the editor: click-to-place-caret and drag-to-select. + * Split from `multi-line-editor.tsx` purely to keep that file inside the + * size budget — these are the component's own handlers, built fresh each + * render over its live state setters. + */ +export interface EditorPointerDeps { + readonly value: string; + readonly cursorPos: number; + readonly disabled: boolean; + readonly setCursorPos: (pos: number) => void; + /** Functional form required: `endDrag` folds over the current anchor. */ + readonly setAnchor: ( + update: number | null | ((current: number | null) => number | null), + ) => void; + readonly onClickFocus?: () => void; +} + +export interface EditorPointerHandlers { + readonly placeCursorAt: (row: number, col: number) => void; + readonly beginDrag: (row: number, col: number) => void; + readonly extendDrag: (row: number, col: number) => void; + readonly endDrag: () => void; +} + +export function createEditorPointer( + deps: EditorPointerDeps, +): EditorPointerHandlers { + const { value, cursorPos, disabled, setCursorPos, setAnchor, onClickFocus } = + deps; + + /** + * Buffer offset for a clicked cell, clamped to the line. + * `rowColToCursor` does not clamp, so a click past the end of a short + * line would otherwise run the offset into the following line; + * clamping here keeps a click in the empty space to the right of a + * line meaning "end of this line", which is what every editor does. + */ + const offsetAt = (row: number, col: number): number => { + const lines = value.split("\n"); + const safeRow = Math.max(0, Math.min(row, lines.length - 1)); + const safeCol = Math.max(0, Math.min(col, (lines[safeRow] ?? "").length)); + return rowColToCursor(lines, safeRow, safeCol); + }; + + /** + * Press: drop the anchor and take the pointer. Capture matters because + * hit-testing routes by position — without it, a drag that wanders out + * of the composer would deliver its motion, and its release, to + * whatever sits under the cursor, and the selection would neither + * extend nor end. + */ + const beginDrag = (row: number, col: number): void => { + if (disabled) return; + setAnchor(offsetAt(row, col)); + }; + + const extendDrag = (row: number, col: number): void => { + if (disabled) return; + setCursorPos(offsetAt(row, col)); + }; + + const endDrag = (): void => { + // A drag that never moved is a click, not a selection. + setAnchor((current) => + current === null || current === cursorPos ? null : current, + ); + }; + + const placeCursorAt = (row: number, col: number): void => { + if (disabled) return; + // Ask for focus first: a click that moves a caret the operator + // cannot then type into is a click that did nothing. + onClickFocus?.(); + // A fresh press collapses whatever was selected — `beginDrag` sets + // the new anchor immediately afterwards. + setAnchor(null); + setCursorPos(offsetAt(row, col)); + }; + + return { placeCursorAt, beginDrag, extendDrag, endDrag }; +} diff --git a/src/tui/components/multi-line-editor-selection-flag.test.tsx b/src/tui/components/multi-line-editor-selection-flag.test.tsx new file mode 100644 index 00000000..d13320d4 --- /dev/null +++ b/src/tui/components/multi-line-editor-selection-flag.test.tsx @@ -0,0 +1,111 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; +import { useState, type ReactElement } from "react"; + +import { MultiLineEditor } from "./multi-line-editor.js"; + +/** + * The selection flag the app keeps is what makes Ctrl+C mean "copy" + * instead of "stop". If the editor unmounts while a selection is live + * — which it does on every Observe / Manage tab, since the composer is + * Run-only — a stranded `true` leaves Ctrl+C claimed by nobody: no + * abort, no quit, for the rest of the session. + */ +const SHIFT_LEFT = String.fromCharCode(27) + "[1;2D"; +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, 40)); + +describe("composer selection flag", () => { + it("clears when the editor unmounts with a live selection", async () => { + const onSelectionChange = vi.fn(); + const app = render( + {}} + onSubmit={() => {}} + onSelectionChange={onSelectionChange} + />, + ); + await settle(); + app.stdin.write(SHIFT_LEFT); + await settle(); + expect(onSelectionChange).toHaveBeenLastCalledWith(true); + + app.unmount(); + await settle(); + expect(onSelectionChange).toHaveBeenLastCalledWith(false); + }); + + it("clears when the buffer is replaced from outside", async () => { + // History recall, an Esc that cleared the draft, a seeded slash + // command: the selected text no longer exists, and an anchor left + // pointing into it makes the next keystroke replace a span the + // operator cannot see. + const onSelectionChange = vi.fn(); + const app = render( + {}} + onSubmit={() => {}} + onSelectionChange={onSelectionChange} + />, + ); + await settle(); + app.stdin.write(SHIFT_LEFT); + await settle(); + expect(onSelectionChange).toHaveBeenLastCalledWith(true); + + app.rerender( + {}} + onSubmit={() => {}} + onSelectionChange={onSelectionChange} + />, + ); + // Poll rather than assume one tick: the effect that clears the + // anchor runs after the rerender commits, and a loaded suite makes + // that gap wider than a single settle. + for (let attempt = 0; attempt < 20; attempt += 1) { + if (onSelectionChange.mock.calls.at(-1)?.[0] === false) break; + await settle(); + } + expect(onSelectionChange).toHaveBeenLastCalledWith(false); + app.unmount(); + }); + + it("reports each transition once even with a fresh callback every render", async () => { + // Recreates the app wiring: tui-app passes an inline arrow whose + // identity changes every render AND whose call re-renders the app. + // With the callback in the selection effect deps, the first `true` + // re-ran the effect, whose cleanup reported `false`, re-rendering + // again — a dispatch ping-pong that hit React's "Maximum update + // depth exceeded" the moment a selection existed in the real TUI. + const calls: boolean[] = []; + function AppLike(): ReactElement { + const [, setFlag] = useState(false); + return ( + {}} + onSubmit={() => {}} + onSelectionChange={(has) => { + calls.push(has); + setFlag(has); + }} + /> + ); + } + const app = render(); + await settle(); + app.stdin.write(SHIFT_LEFT); + // Give a would-be loop ample time to blow past React's budget. + for (let i = 0; i < 5; i += 1) await settle(); + expect(calls.filter((c) => c)).toHaveLength(1); + app.unmount(); + }); +}); diff --git a/src/tui/components/multi-line-editor-selection.test.tsx b/src/tui/components/multi-line-editor-selection.test.tsx new file mode 100644 index 00000000..29ee392a --- /dev/null +++ b/src/tui/components/multi-line-editor-selection.test.tsx @@ -0,0 +1,255 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; + +import { ClipboardProvider } from "../clipboard/clipboard-context.js"; +import type { ClipboardWriter } from "../clipboard/copy-to-clipboard.js"; +import { MultiLineEditor } from "./multi-line-editor.js"; + +/** + * Selecting text in the composer, and copying it. + * + * The terminal takes its own drag-to-select away the moment mouse + * reporting is on, so the composer has to provide the gesture itself: + * Shift+arrows from the keyboard, click-drag from the mouse, and + * Ctrl+C to copy what is picked. + */ +const CSI = String.fromCharCode(27) + "["; +/** xterm modifier encoding: 1 + 1 = shift. */ +const SHIFT_LEFT = CSI + "1;2D"; +const SHIFT_RIGHT = CSI + "1;2C"; +const SHIFT_UP = CSI + "1;2A"; +const SHIFT_DOWN = CSI + "1;2B"; +const CTRL_C = String.fromCharCode(3); +const CTRL_X = String.fromCharCode(24); +/** Kitty encoding of Cmd+C / Cmd+X: codepoint ; 1+super(8) u. */ +const KITTY_SUPER_C = CSI + "99;9u"; +const KITTY_SUPER_X = CSI + "120;9u"; + +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, 40)); + +function writer(): ClipboardWriter & { copied: string[] } { + const copied: string[] = []; + return { + copied, + copy: async (text: string) => { + copied.push(text); + return true; + }, + }; +} + +function mount(value: string, clipboard: ClipboardWriter) { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const onInterrupt = vi.fn(); + const onSelectionChange = vi.fn(); + const app = render( + + + , + ); + return { ...app, onChange, onSubmit, onInterrupt, onSelectionChange }; +} + +describe("composer selection", () => { + it("extends a selection with shift+arrows and copies it with ctrl+c", async () => { + const clipboard = writer(); + const app = mount("hello world", clipboard); + await settle(); + // The caret starts at the end; three shift+lefts pick "rld". + for (let i = 0; i < 3; i += 1) { + app.stdin.write(SHIFT_LEFT); + // One keystroke per tick: `handleKey` reads the caret from the + // render closure, so a burst written into a single tick would all + // move from the same starting point. + await settle(); + } + expect(app.onSelectionChange).toHaveBeenLastCalledWith(true); + + app.stdin.write(CTRL_C); + await settle(); + expect(clipboard.copied).toEqual(["rld"]); + // Copy is not an interrupt while text is picked. + expect(app.onInterrupt).not.toHaveBeenCalled(); + app.unmount(); + }); + + it("still interrupts on ctrl+c when nothing is selected", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + app.stdin.write(CTRL_C); + await settle(); + expect(app.onInterrupt).toHaveBeenCalled(); + expect(clipboard.copied).toEqual([]); + app.unmount(); + }); + + it("collapses the selection on an unshifted move", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + app.stdin.write(SHIFT_LEFT); + await settle(); + expect(app.onSelectionChange).toHaveBeenLastCalledWith(true); + app.stdin.write(CSI + "D"); + await settle(); + expect(app.onSelectionChange).toHaveBeenLastCalledWith(false); + app.unmount(); + }); + + it("replaces the selection when the operator types over it", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + for (let i = 0; i < 2; i += 1) { + app.stdin.write(SHIFT_LEFT); + await settle(); + } + app.stdin.write("y"); + await settle(); + expect(app.onChange).toHaveBeenLastCalledWith("hely"); + app.unmount(); + }); + + it("deletes the selection on backspace", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + for (let i = 0; i < 2; i += 1) { + app.stdin.write(SHIFT_LEFT); + await settle(); + } + app.stdin.write(String.fromCharCode(127)); + await settle(); + expect(app.onChange).toHaveBeenLastCalledWith("hel"); + app.unmount(); + }); + + it("paints the selected span in inverse video", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + for (let i = 0; i < 2; i += 1) { + app.stdin.write(SHIFT_LEFT); + await settle(); + } + // ink-testing-library strips colour, so assert on the buffer text + // staying intact rather than on the escape codes. + expect(app.lastFrame() ?? "").toContain("hello"); + app.unmount(); + }); + + it("extends across lines with shift+up and copies the span", async () => { + const clipboard = writer(); + const app = mount("ab\ncd", clipboard); + await settle(); + // Caret starts at the end (row 1, col 2); shift+up lands on row 0 + // col 2 = offset 2, selecting "\ncd". + app.stdin.write(SHIFT_UP); + await settle(); + expect(app.onSelectionChange).toHaveBeenLastCalledWith(true); + app.stdin.write(CTRL_C); + await settle(); + expect(clipboard.copied).toEqual(["\ncd"]); + app.unmount(); + }); + + it("shift+down re-extends towards the end of the buffer", async () => { + const clipboard = writer(); + const app = mount("ab\ncd", clipboard); + await settle(); + app.stdin.write(SHIFT_UP); + await settle(); + // Same anchor, opposite direction: the selection collapses through + // the anchor and lands empty at the end again. + app.stdin.write(SHIFT_DOWN); + await settle(); + expect(app.onSelectionChange).toHaveBeenLastCalledWith(false); + app.unmount(); + }); +}); + +describe("composer cut", () => { + it("ctrl+x copies the selection and removes it in one edit", async () => { + const clipboard = writer(); + const app = mount("hello world", clipboard); + await settle(); + for (let i = 0; i < 5; i += 1) { + app.stdin.write(SHIFT_LEFT); + await settle(); + } + app.stdin.write(CTRL_X); + await settle(); + expect(clipboard.copied).toEqual(["world"]); + expect(app.onChange).toHaveBeenLastCalledWith("hello "); + expect(app.onSelectionChange).toHaveBeenLastCalledWith(false); + app.unmount(); + }); + + it("ctrl+x without a selection neither edits nor copies", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + app.stdin.write(CTRL_X); + await settle(); + expect(clipboard.copied).toEqual([]); + expect(app.onChange).not.toHaveBeenCalled(); + app.unmount(); + }); +}); + +describe("kitty-reported cmd chords", () => { + // On most macOS terminals Cmd+C/Cmd+X never reach stdin (the emulator + // owns them). These cases cover the kitty-protocol terminals that DO + // forward them, where they must mean copy/cut — and never type "c". + it("cmd+c copies a selection", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + for (let i = 0; i < 2; i += 1) { + app.stdin.write(SHIFT_LEFT); + await settle(); + } + app.stdin.write(KITTY_SUPER_C); + await settle(); + expect(clipboard.copied).toEqual(["lo"]); + expect(app.onInterrupt).not.toHaveBeenCalled(); + app.unmount(); + }); + + it("cmd+x cuts a selection", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + for (let i = 0; i < 2; i += 1) { + app.stdin.write(SHIFT_LEFT); + await settle(); + } + app.stdin.write(KITTY_SUPER_X); + await settle(); + expect(clipboard.copied).toEqual(["lo"]); + expect(app.onChange).toHaveBeenLastCalledWith("hel"); + app.unmount(); + }); + + it("cmd+c without a selection does not interrupt and types nothing", async () => { + const clipboard = writer(); + const app = mount("hello", clipboard); + await settle(); + app.stdin.write(KITTY_SUPER_C); + await settle(); + expect(clipboard.copied).toEqual([]); + expect(app.onInterrupt).not.toHaveBeenCalled(); + expect(app.onChange).not.toHaveBeenCalled(); + app.unmount(); + }); +}) diff --git a/src/tui/components/multi-line-editor.test.tsx b/src/tui/components/multi-line-editor.test.tsx new file mode 100644 index 00000000..b3600439 --- /dev/null +++ b/src/tui/components/multi-line-editor.test.tsx @@ -0,0 +1,46 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; +import { MultiLineEditor } from "./multi-line-editor.js"; + +describe("MultiLineEditor", () => { + function editor(focus: boolean, onChange: (v: string) => void) { + return ( + {}} + /> + ); + } + + it("accepts input while focused", async () => { + const onChange = vi.fn(); + const { stdin, unmount } = render(editor(true, onChange)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + stdin.write("h"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(onChange).toHaveBeenCalledWith("h"); + unmount(); + }); + + it("ignores input while unfocused", async () => { + const onChange = vi.fn(); + const { stdin, unmount } = render(editor(false, onChange)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + stdin.write("h"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(onChange).not.toHaveBeenCalled(); + unmount(); + }); + + // The stale-subscription window this component guards against (focus + // flipped by a render, unsubscribe effect not yet flushed, keypress + // arrives in between) cannot be reproduced through ink-testing-library's + // `rerender`, which flushes effects before returning. The regression + // test for that window lives at the app level: see "a keypress landing + // between a tab switch and its focus teardown stays out of the chat + // buffer" in ../tui-app.test.tsx. +}); diff --git a/src/tui/components/multi-line-editor.tsx b/src/tui/components/multi-line-editor.tsx index 31c3d22f..d8944c4f 100644 --- a/src/tui/components/multi-line-editor.tsx +++ b/src/tui/components/multi-line-editor.tsx @@ -1,21 +1,29 @@ import { Box, useInput, type Key } from "ink"; import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; +import { useClipboard } from "../clipboard/clipboard-context.js"; import { theme } from "../theme/theme.js"; import { EditorBody } from "./multi-line-editor-body.js"; -import { - cursorToRowCol, - findWordStart, - isOnFirstLine, - isOnLastLine, - lineEnd, - lineStart, - rowColToCursor, -} from "./multi-line-editor-cursor.js"; -import { normalizeInsertText } from "./multi-line-editor-input.js"; +import { useEditorClipboard } from "./multi-line-editor-clipboard.js"; +import { cursorToRowCol } from "./multi-line-editor-cursor.js"; +import { handleKey } from "./multi-line-editor-keys.js"; +import { createEditorPointer } from "./multi-line-editor-pointer.js"; export interface MultiLineEditorProps { value: string; placeholder?: string; + /** + * Ink colour for the buffer's own text. + * + * Absent means "inherit the terminal's default foreground", which is + * right for every field drawn straight on the page — and wrong for + * any field sitting on a ground the *app* painted, because the two + * have no relationship. The composer is the second kind: it sits on + * `badgeBackground`, and on a light palette that is a light panel, + * so a terminal whose default ink is light (i.e. any dark terminal + * running `classic-light`) rendered light text on it. See + * `prompt-shell.tsx`. + */ + textColor?: string; focus: boolean; /** Disable interaction (reject keys silently) — keeps focus state intact. */ disabled?: boolean; @@ -47,6 +55,42 @@ export interface MultiLineEditorProps { * editor body. */ bare?: boolean; + /** + * Consulted before every keystroke: `true` means another layer owns + * this key and the editor must not type it. Ink delivers a keypress + * to every subscription, so a focused editor and a global hotkey + * handler would otherwise both act on it — the approval prompt uses + * this so `y` decides the prompt instead of landing in the buffer. + */ + claimKey?: (input: string, key: Key) => boolean; + /** + * The operator clicked into the buffer. Fired even when the editor is + * not focused — clicking an input is how every other application is + * told "put the keyboard here", and the editor cannot move focus + * itself because focus lives in the app's state. + */ + onClickFocus?: () => void; + /** + * The selection appeared or disappeared. The app lifts this into its + * own state because Ctrl+C means "copy" while text is selected and + * "stop / quit" otherwise, and those two handlers live in different + * key layers. + */ + onSelectionChange?: (hasSelection: boolean) => void; + /** Text was copied to the clipboard, so the app can say so. */ + onCopy?: (text: string) => void; + /** + * Growth cap, in buffer lines painted at once — see + * `EditorBodyProps.maxVisibleLines`. The buffer itself is unbounded; + * only the paint is windowed. + */ + maxVisibleLines?: number; + /** + * Mouse layer for the editor's click target — see + * `EditorBodyProps.mouseLayer`. The composer overlay passes + * `MOUSE_LAYER_PANEL` so its clicks beat the chat controls it covers. + */ + mouseLayer?: number; } /** @@ -67,6 +111,7 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { const { value, placeholder, + textColor, focus, disabled = false, onChange, @@ -79,8 +124,23 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { onShiftTab, onAutocomplete, bare = false, + claimKey, + onClickFocus, + onSelectionChange, + onCopy, + maxVisibleLines, + mouseLayer, } = props; const [cursorPos, setCursorPos] = useState(value.length); + /** + * Where the current selection was started, or `null` when there is + * none. The other end is always the caret, so extending a selection is + * just moving the caret and leaving the anchor where it was — the same + * model every text editor uses, and the reason Shift+arrow needs no + * separate bookkeeping. + */ + const [anchor, setAnchor] = useState(null); + const clipboard = useClipboard(); // Distinguish our own edits (keystrokes routed through `setBuffer`) // from external buffer replacements: slash-seeding from panel hotkeys // (the LLM tab dispatches `input_changed "/"` on `/`), history recall, @@ -95,8 +155,43 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { if (value === lastInternalValue.current) return; lastInternalValue.current = value; setCursorPos(value.length); + // The buffer was replaced from outside — history recall, an Esc that + // cleared the draft, a seeded slash command, a submit. Whatever was + // selected no longer exists, and an anchor left pointing into the old + // text makes the next keystroke replace a span the operator cannot + // see (and can point past the end of a shorter buffer). + setAnchor(null); }, [value]); + /** `[start, end)` in buffer offsets, or `null` when nothing is picked. */ + const selection: readonly [number, number] | null = + anchor === null || anchor === cursorPos + ? null + : [Math.min(anchor, cursorPos), Math.max(anchor, cursorPos)]; + const hasSelection = selection !== null; + // Same render-phase-ref idiom as `activeRef` below, and load-bearing: + // `tui-app` passes an inline arrow, so the prop has a new identity + // every render. With the callback in the deps, the first `true` this + // effect reports re-rendered the app, which re-ran the effect, whose + // CLEANUP reported `false`, which re-rendered the app… a dispatch + // ping-pong that hit React's "Maximum update depth exceeded" the + // moment a selection existed in the real TUI. Depending only on + // `hasSelection` reports each transition exactly once, whatever the + // parent does with the prop's identity. + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; + useEffect(() => { + onSelectionChangeRef.current?.(hasSelection); + }, [hasSelection]); + useEffect(() => { + // Unmounting with a live selection strands the app's copy of the + // flag, and the flag is what makes Ctrl+C mean "copy": the global + // layer would stand down for an editor that no longer exists, so + // Ctrl+C would abort nothing and quit nothing for the rest of the + // session. The composer unmounts on every Observe / Manage tab. + return () => onSelectionChangeRef.current?.(false); + }, []); + const setBuffer = useCallback( (next: string, nextCursor: number) => { lastInternalValue.current = next; @@ -106,15 +201,39 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { [onChange], ); + // Ink tears the `isActive` subscription down in a passive effect, one + // frame after the render that flipped `focus`. A keypress that arrives in + // that gap — always the case when the flip and the key are processed in + // the same stdin batch, e.g. Tab into a panel followed by the panel's + // hotkey — is still delivered here and lands in the chat buffer of an + // editor that is no longer focused. The ref is written during render, so + // the callback checks the *current* focus, not the focus the subscription + // was created with. (Render-phase write is safe: the value is derived + // from props, never from state updated here.) + const activeRef = useRef(focus && !disabled); + activeRef.current = focus && !disabled; + // Same render-phase-ref treatment as `activeRef`: the predicate reads + // live TUI state, and a stale closure would type a key the prompt had + // already claimed. + const claimKeyRef = useRef(claimKey); + claimKeyRef.current = claimKey; + useInput( (input, key) => { + if (!activeRef.current) return; if (disabled) return; + if (claimKeyRef.current?.(input, key)) return; handleKey({ input, key, value, cursor: cursorPos, setBuffer, + selection, + anchor, + setAnchor, + copySelection, + onPaste: pasteClipboard, onSubmit, onEscape, onInterrupt, @@ -129,16 +248,58 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { ); const cursor = cursorToRowCol(value, cursorPos); - if (bare) { - return ( - - ); - } + + /** + * Copy the selection. Both mechanisms in `copy-to-clipboard` are + * advisory in their own way — OSC 52 has no reply and the platform + * command may not exist — so the app is told what was copied and lets + * the operator judge; a silent failure would be worse than a claim. + */ + const copySelection = (): void => { + if (!selection) return; + const text = value.slice(selection[0], selection[1]); + if (text.length === 0) return; + void clipboard.copy(text); + onCopy?.(text); + }; + + // The async clipboard side: the paste chord and the right-click menu + // (whose verbs land frames after the gesture, so the hook re-reads + // this render's context through a render-refreshed ref). + const { pasteClipboard, openMenuAt } = useEditorClipboard({ + disabled, + hasSelection, + edit: { value, cursor: cursorPos, setBuffer, selection, anchor, setAnchor }, + copySelection, + }); + + const { placeCursorAt, beginDrag, extendDrag, endDrag } = + createEditorPointer({ + value, + cursorPos, + disabled, + setCursorPos, + setAnchor, + onClickFocus, + }); + const body = ( + + ); + if (bare) return body; return ( - + {body} ); } - -interface KeyContext { - input: string; - key: Key; - value: string; - cursor: number; - setBuffer: (next: string, cursor: number) => void; - onSubmit: (value: string) => void; - onEscape?: () => void; - onInterrupt?: () => void; - onTab?: () => void; - onShiftTab?: () => void; - onAutocomplete?: () => void; - onHistoryPrev?: () => void; - onHistoryNext?: () => void; -} - -function handleKey(ctx: KeyContext): void { - const { input, key, value, cursor, setBuffer } = ctx; - if (key.ctrl && input === "c" && ctx.onInterrupt) { - ctx.onInterrupt(); - return; - } - // Ignore keys owned by the global app-level handler so the editor - // never inserts Ctrl+C as "c" or swallows F-key escape sequences. - if (isGlobalHotkey(input, key)) return; - if (key.escape) { - ctx.onEscape?.(); - return; - } - if (key.tab && key.shift) { - ctx.onShiftTab?.(); - return; - } - if (key.tab) { - ctx.onTab?.(); - return; - } - if (key.return) { - const newline = key.meta || key.shift || key.ctrl; - const trailingBackslash = value.endsWith("\\") && cursor === value.length; - if (newline) { - insertText(ctx, "\n"); - return; - } - if (trailingBackslash) { - const withoutSlash = value.slice(0, -1); - setBuffer(`${withoutSlash}\n`, withoutSlash.length + 1); - return; - } - ctx.onSubmit(value); - return; - } - if (key.upArrow) { - if (isOnFirstLine(value, cursor)) { - ctx.onHistoryPrev?.(); - return; - } - moveCursorVertically(ctx, -1); - return; - } - if (key.downArrow) { - if (isOnLastLine(value, cursor)) { - ctx.onHistoryNext?.(); - return; - } - moveCursorVertically(ctx, 1); - return; - } - if (key.leftArrow) { - setBuffer(value, Math.max(0, cursor - 1)); - return; - } - if (key.rightArrow) { - if (cursor >= value.length && ctx.onAutocomplete) { - ctx.onAutocomplete(); - return; - } - setBuffer(value, Math.min(value.length, cursor + 1)); - return; - } - if (key.backspace || key.delete) { - if (key.delete && !key.backspace) { - // Forward delete - if (cursor < value.length) { - const next = value.slice(0, cursor) + value.slice(cursor + 1); - setBuffer(next, cursor); - } - return; - } - if (cursor > 0) { - const next = value.slice(0, cursor - 1) + value.slice(cursor); - setBuffer(next, cursor - 1); - } - return; - } - if (key.ctrl && input === "a") { - setBuffer(value, lineStart(value, cursor)); - return; - } - if (key.ctrl && input === "e") { - setBuffer(value, lineEnd(value, cursor)); - return; - } - if (key.ctrl && input === "u") { - const start = lineStart(value, cursor); - setBuffer(value.slice(0, start) + value.slice(cursor), start); - return; - } - if (key.ctrl && input === "k") { - const end = lineEnd(value, cursor); - setBuffer(value.slice(0, cursor) + value.slice(end), cursor); - return; - } - if (key.ctrl && input === "w") { - const wordStart = findWordStart(value, cursor); - setBuffer(value.slice(0, wordStart) + value.slice(cursor), wordStart); - return; - } - // Drop any other modifier chord (Ctrl+, Meta+) so the - // editor does not insert it as literal text. - if (key.ctrl || key.meta) return; - if (input.length === 0) return; - // A single control char pressed on its own is ignored — but a - // multi-char paste burst is always sanitised and inserted, even when - // its first byte is a CR/control, because `normalizeInsertText` strips - // the offending bytes. - if ( - input.length === 1 && - input.charCodeAt(0) < 0x20 && - input !== "\n" && - input !== "\t" - ) { - return; - } - insertText(ctx, input); -} - -function isGlobalHotkey(input: string, key: Key): boolean { - if (key.ctrl && (input === "c" || input === "o")) return true; - // F-keys and other multi-byte escape sequences we don't handle locally. - if (input.startsWith("\u001b") && input.length > 1) return true; - return false; -} - -function insertText(ctx: KeyContext, text: string): void { - const { value, cursor, setBuffer } = ctx; - const clean = normalizeInsertText(text); - if (clean.length === 0) return; - const next = value.slice(0, cursor) + clean + value.slice(cursor); - setBuffer(next, cursor + clean.length); -} - -function moveCursorVertically(ctx: KeyContext, direction: -1 | 1): void { - const { value, cursor, setBuffer } = ctx; - const { row, col } = cursorToRowCol(value, cursor); - const lines = value.split("\n"); - const nextRow = row + direction; - if (nextRow < 0 || nextRow >= lines.length) return; - const nextLine = lines[nextRow] ?? ""; - const nextCol = Math.min(col, nextLine.length); - const nextOffset = rowColToCursor(lines, nextRow, nextCol); - setBuffer(value, nextOffset); -} - diff --git a/src/tui/components/onboarding-atom-field.test.tsx b/src/tui/components/onboarding-atom-field.test.tsx new file mode 100644 index 00000000..a030dba8 --- /dev/null +++ b/src/tui/components/onboarding-atom-field.test.tsx @@ -0,0 +1,77 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { ATOM_COLLISION_COLOR, OnboardingAtomField } from "./onboarding-atom-field.js"; +import { + ATOM_COLLISION_GLYPH, + ATOM_GLYPH, + COLLISION_STEPS, + type Atom, + type AtomFieldState, +} from "../onboarding/atom-field.js"; +import { THEMES, THEME_NAMES } from "../theme/theme.js"; + +const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, ""); + +function field(over: Partial = {}): AtomFieldState { + const atom: Atom = { + id: 1, + column: 6, + row: 2, + columnVelocity: 0.6, + rowVelocity: 0.27, + hotSteps: 0, + lifeSteps: 30, + dormantSteps: 0, + ...over, + }; + return { atoms: [atom], seed: 1, step: 0, nextId: 2 }; +} + +describe("OnboardingAtomField", () => { + it("draws exactly the rows it was given, blank ones included", () => { + const view = render(); + expect(strip(view.lastFrame() ?? "").split("\n")).toHaveLength(6); + view.unmount(); + }); + + it("puts the atom on its own row and leaves the others empty", () => { + const view = render(); + const rows = strip(view.lastFrame() ?? "").split("\n"); + expect(rows[2]).toContain(ATOM_GLYPH); + expect(rows.filter((row) => row.trim().length > 0)).toHaveLength(1); + view.unmount(); + }); + + it("shows a collision as a different glyph in the same cells", () => { + // `ink-testing-library` renders with colour off, and so do NO_COLOR + // terminals and monochrome ones — which is exactly why the stripped + // frame has to carry the collision by itself. Shape changes, cells + // do not: swapping the marker back yields the resting frame. + const cold = render(); + const hot = render( + , + ); + const coldFrame = strip(cold.lastFrame() ?? ""); + const hotFrame = strip(hot.lastFrame() ?? ""); + expect(coldFrame).toContain(ATOM_GLYPH); + expect(hotFrame).toContain(ATOM_COLLISION_GLYPH); + expect(hotFrame).not.toContain(ATOM_GLYPH); + expect(hotFrame.replace(ATOM_COLLISION_GLYPH, ATOM_GLYPH)).toBe(coldFrame); + cold.unmount(); + hot.unmount(); + }); + + it("keeps the collision colour out of every palette, on purpose", () => { + // The one deliberate exception to the theme tokens. If some palette + // ever adopts this green, the collision stops reading as an event + // and starts reading as a state. + for (const name of THEME_NAMES) { + expect(Object.values(THEMES[name].colors)).not.toContain(ATOM_COLLISION_COLOR); + } + }); +}); diff --git a/src/tui/components/onboarding-atom-field.tsx b/src/tui/components/onboarding-atom-field.tsx new file mode 100644 index 00000000..ed086fab --- /dev/null +++ b/src/tui/components/onboarding-atom-field.tsx @@ -0,0 +1,57 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { buildAtomRows } from "../onboarding/atom-field-rows.js"; +import type { AtomFieldState } from "../onboarding/atom-field.js"; +import { theme } from "../theme/theme.js"; + +/** + * The colour two atoms turn when they touch. Deliberately not a theme + * token and deliberately outside every palette: the design asks for one + * jolt of toxic green in an otherwise muted pane, and no palette owns a + * colour whose whole job is to not belong. It is never used for state, + * so it carries no meaning a themed colour would have to preserve. The + * colour is emphasis only — the collision's load-bearing signal is the + * glyph swap in `atom-field-rows.ts`, which survives NO_COLOR. + */ +export const ATOM_COLLISION_COLOR = "#39ff14"; + +/** + * The atoms, drawn into the rows the caller has reserved for them. + * + * Exactly as many `` rows as the field is tall, and the Box is + * pinned to that height: Ink 7 overlaps rather than clips, so a field + * that grew by one row would paint over the progress bars above it + * rather than being cropped. + * + * The resting colour is `border`, the dimmest token there is. This is + * the least important thing on the screen and has to read that way next + * to a progress bar the operator is actually waiting on. + */ +export function OnboardingAtomField(props: { + field: AtomFieldState; + columns: number; + rows: number; +}): ReactElement { + const rows = buildAtomRows(props.field, { + columns: props.columns, + rows: props.rows, + }); + return ( + + {rows.map((runs, rowIndex) => ( + + {runs.length === 0 + ? " " + : runs.map((run, runIndex) => ( + + {run.text} + + ))} + + ))} + + ); +} diff --git a/src/tui/components/onboarding-choose-step.tsx b/src/tui/components/onboarding-choose-step.tsx new file mode 100644 index 00000000..1969ebdb --- /dev/null +++ b/src/tui/components/onboarding-choose-step.tsx @@ -0,0 +1,111 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import type { OnboardingFit } from "../onboarding/onboarding-fit.js"; +import { handleOnboardingStepKey } from "../onboarding/onboarding-step-keys.js"; +import { ROW_MARKER, rowPrefix } from "../onboarding/onboarding-rows.js"; +import { ONBOARDING_CHOICES } from "../onboarding/onboarding-state.js"; +import { theme } from "../theme/theme.js"; + +/** + * The one decision the flow actually needs: where the model runs. The + * copy describes a choice rather than reporting the failed health probe + * that used to bring this screen up — a fresh install has nothing broken + * about it, and "llama-server not reachable" as the first line a new user + * reads says otherwise. + */ +/** + * Label column. Wide enough for `Custom endpoint` plus a gap, so the + * three details line up as a column of their own — a ragged left edge + * there makes three comparable options read as three unrelated ones. + */ +const LABEL_COLUMNS = 20; + +/** + * Hand-wrapped rather than left to Ink: the block is centred on its + * measured width, and a line that rewraps at a width the measure did + * not predict would move the whole box. + */ +const EXPLAINER: readonly string[] = [ + "atomic-agent can drive models three ways. Nothing here is permanent — you", + "can add the others at any time from the menu.", +]; + +/** Where a choice row's detail column starts. */ +const DETAIL_COLUMN = ROW_MARKER.length + LABEL_COLUMNS; + +/** + * The marker-and-label cell exactly as the row draws it. Shared by the + * measure and the render so the two cannot disagree: the label is padded + * out to the detail column only when a detail actually follows it — + * blocks are centred on their measured width, and padding a line the + * measure trims makes Ink wrap the invisible pad cells instead of + * clipping them, growing the block taller than it was measured. + */ +function labelCell(selected: boolean, label: string, fit: OnboardingFit): string { + return `${rowPrefix(selected)}${fit.rowDetails ? label.padEnd(LABEL_COLUMNS) : label}`; +} + +/** Widest line this step draws, for the block that centres it. */ +export function measureOnboardingChooseStep(fit: OnboardingFit): number { + const lines: string[] = fit.explainer ? [...EXPLAINER] : []; + for (const choice of ONBOARDING_CHOICES) { + // Measured as selected: the marker and the indent are the same width. + lines.push( + `${labelCell(true, choice.label, fit)}${fit.rowDetails ? choice.detail[0] : ""}`, + ); + if (fit.rowDetails) lines.push(`${" ".repeat(DETAIL_COLUMN)}${choice.detail[1]}`); + } + return widestLine(lines); +} + +export function OnboardingChooseStep(props: { + cursor: number; + fit: OnboardingFit; +}): ReactElement { + return ( + + {props.fit.explainer ? ( + + {EXPLAINER.map((line) => ( + + {line} + + ))} + + ) : null} + {ONBOARDING_CHOICES.map((choice, idx) => { + const selected = idx === props.cursor; + return ( + // First click selects, second activates — the same Enter the + // keyboard sends, routed through the flow's own key table. + + mouse.dispatch({ type: "onboarding_cursor_set", cursor: idx }) + } + onActivate={pressEnter(handleOnboardingStepKey)} + > + + + + {labelCell(selected, choice.label, props.fit)} + + {props.fit.rowDetails ? ( + {choice.detail[0]} + ) : null} + + {props.fit.rowDetails ? ( + + {`${" ".repeat(DETAIL_COLUMN)}${choice.detail[1]}`} + + ) : null} + + + ); + })} + + ); +} diff --git a/src/tui/components/onboarding-download-ambient.test.tsx b/src/tui/components/onboarding-download-ambient.test.tsx new file mode 100644 index 00000000..5dea3cb9 --- /dev/null +++ b/src/tui/components/onboarding-download-ambient.test.tsx @@ -0,0 +1,221 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + downloadAmbientRows, + MIN_ATOM_ROWS, + OnboardingDownloadAmbient, +} from "./onboarding-download-ambient.js"; +import { ATOM_COLLISION_GLYPH, ATOM_GLYPH } from "../onboarding/atom-field.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; + +type View = ReturnType; + +// Every mounted field owns a running interval. Left alive, they pile up +// across the file and starve Ink's commits, which is enough to make a +// frame that should have moved look frozen. +const mounted: View[] = []; + +afterEach(() => { + while (mounted.length > 0) mounted.pop()?.unmount(); +}); + +function mount(node: React.ReactElement): View { + const view = render(node); + mounted.push(view); + return view; +} + +const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, ""); + +function pull(over: Partial = {}): LocalModelsPullState { + return { + kind: "chat", + modelId: "gemma-4-e4b", + label: "Gemma 4 E4B", + percent: 38, + transferredBytes: 1_600_000_000, + totalBytes: 4_220_000_000, + error: null, + ...over, + }; +} + +function ambient( + props: Partial> = {}, +) { + return ( + + ); +} + +describe("downloadAmbientRows", () => { + /** + * Expected values are `floor((viewportRows − block)/2) − 1`, the + * bottom spacer's share of the free rows minus the one-row gap that + * keeps the atoms off the offer. Blocks: sm mark 17 rows with the + * offer (13 without), xs mark 16 — pinned by the download step's own + * row-count test. The skip row costs the block three rows, which is + * what pushed the 80×24 fallback under `MIN_ATOM_ROWS`: decoration + * yields to content there now. + */ + const table: { + name: string; + input: Parameters[0]; + rows: number; + }[] = [ + { + name: "a full-size terminal with the offer showing", + input: { viewportRows: 28, mark: "sm", offerCloud: true }, + rows: 4, + }, + { + name: "the same terminal once the offer is spent", + input: { viewportRows: 28, mark: "sm", offerCloud: false }, + rows: 6, + }, + { + name: "a header that dropped its mark", + input: { viewportRows: 28, mark: "xs", offerCloud: true }, + rows: 5, + }, + { + name: "the smallest viewport that still draws a field", + input: { viewportRows: 25, mark: "sm", offerCloud: true }, + rows: 3, + }, + { + name: "the 80×24 fallback terminal — a gap now, not a field", + input: { viewportRows: 22, mark: "sm", offerCloud: true }, + rows: 1, + }, + { + name: "a terminal smaller than the block itself", + input: { viewportRows: 10, mark: "sm", offerCloud: true }, + rows: 0, + }, + ]; + + for (const row of table) { + it(`gives ${row.rows} rows to ${row.name}`, () => { + expect(downloadAmbientRows(row.input)).toBe(row.rows); + }); + } +}); + +describe("OnboardingDownloadAmbient", () => { + it("draws no more rows than the placement's budget allows", () => { + const view = mount(ambient()); + const lines = strip(view.lastFrame() ?? "").split("\n"); + const budget = downloadAmbientRows({ + viewportRows: 28, + mark: "sm", + offerCloud: true, + }); + expect(lines.length).toBe(budget); + expect(lines.some((line) => line.includes(ATOM_GLYPH))).toBe(true); + }); + + it("stays out when the budget dips under the minimum", () => { + // 18 viewport rows leave one free row below the block: a gap, not a + // field, so nothing mounts at all. + const view = mount(ambient({ viewportRows: 18 })); + expect(downloadAmbientRows({ viewportRows: 18, mark: "sm", offerCloud: true })) + .toBeLessThan(MIN_ATOM_ROWS); + expect(strip(view.lastFrame() ?? "")).not.toContain(ATOM_GLYPH); + }); + + /** + * Polls for a frame that differs, rather than sleeping for one step + * and asserting. Ink commits at its own pace under the testing + * library, far slower than any step interval, so the deadline is + * generous and the assertion is on progress, never on timing. + */ + async function frameMoves(view: View, deadlineMs: number): Promise { + const first = strip(view.lastFrame() ?? ""); + const until = Date.now() + deadlineMs; + while (Date.now() < until) { + await new Promise((resolve) => setTimeout(resolve, 40)); + if (strip(view.lastFrame() ?? "") !== first) return true; + } + return false; + } + + it("drifts on its own while the download runs", async () => { + expect(await frameMoves(mount(ambient({ atomStepMs: 20 })), 4000)).toBe(true); + }); + + it("clears out once the weights are all the way down", () => { + const frame = strip(mount(ambient({ pull: pull({ percent: 100 }) })).lastFrame() ?? ""); + expect(frame).not.toContain(ATOM_GLYPH); + }); + + it("keeps drifting while the runtime phase reports 100%", () => { + // Only finished weights end the wait: the runtime zip landing at + // 100% just means the weights are about to start. + const view = mount( + ambient({ pull: pull({ kind: "backend", modelId: "_backend", percent: 100 }) }), + ); + expect(strip(view.lastFrame() ?? "")).toContain(ATOM_GLYPH); + }); + + it("goes still the moment the pull fails", async () => { + // Driven the way a real failure arrives, not hand-built: the pull + // runs, then `local_models_pull_failed` nulls it and sets the + // panel's error line. The field must leave with it — no atoms, and + // no interval repainting a screen under a bar that will never move + // again. + const view = mount(ambient({ atomStepMs: 20 })); + expect(strip(view.lastFrame() ?? "")).toContain(ATOM_GLYPH); + view.rerender(ambient({ pull: null, pullError: "connection reset", atomStepMs: 20 })); + expect(strip(view.lastFrame() ?? "")).not.toContain(ATOM_GLYPH); + expect(await frameMoves(view, 500)).toBe(false); + }); + + /** Atoms visible in a frame, hot or cold — a collision is still an atom. */ + const atomsDrawn = (frame: string): number => + frame.split(ATOM_GLYPH).length + frame.split(ATOM_COLLISION_GLYPH).length - 2; + + it("thins the population when the pane is only just tall enough", () => { + // 25 viewport rows budget three rows of field, the smallest that + // draws at all (the table above). A full population there is hot 22% + // of the time; two keep the collision an event (measured 2% — see + // atom-field.test.ts). The default geometry's 97×4 pane earns more. + const small = atomsDrawn(strip(mount(ambient({ viewportRows: 25 })).lastFrame() ?? "")); + const full = atomsDrawn(strip(mount(ambient()).lastFrame() ?? "")); + expect(small).toBeGreaterThan(0); + expect(small).toBeLessThanOrEqual(2); + expect(full).toBeGreaterThan(2); + }); + + it("re-fits the population when the terminal shrinks mid-download", async () => { + // The interval survives a resize by design; the population must + // not. The step is parked hours out so the only thing that can + // change the frame is the resize rebuild itself: the settled frame + // must be the very placement a fresh mount at the small geometry + // draws — same seed, same count arithmetic — not the old field + // clipped to fewer rows. + const PARKED_STEP_MS = 3_600_000; + const fresh = strip( + mount(ambient({ viewportRows: 25, atomStepMs: PARKED_STEP_MS })).lastFrame() ?? "", + ); + expect(atomsDrawn(fresh)).toBe(2); + const view = mount(ambient({ atomStepMs: PARKED_STEP_MS })); + expect(atomsDrawn(strip(view.lastFrame() ?? ""))).toBe(3); + view.rerender(ambient({ viewportRows: 25, atomStepMs: PARKED_STEP_MS })); + // The rebuild lands in an effect, one Ink commit after the resize. + const until = Date.now() + 4000; + while (strip(view.lastFrame() ?? "") !== fresh && Date.now() < until) { + await new Promise((resolve) => setTimeout(resolve, 40)); + } + expect(strip(view.lastFrame() ?? "")).toBe(fresh); + }); +}); diff --git a/src/tui/components/onboarding-download-ambient.tsx b/src/tui/components/onboarding-download-ambient.tsx new file mode 100644 index 00000000..0324f16c --- /dev/null +++ b/src/tui/components/onboarding-download-ambient.tsx @@ -0,0 +1,92 @@ +import type { ReactElement } from "react"; +import { useAtomField } from "../hooks/use-atom-field.js"; +import { atomPopulation } from "../onboarding/atom-field.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import type { OnboardingMark } from "../onboarding/onboarding-fit.js"; +import { countOnboardingDownloadBlockRows } from "./onboarding-download-step.js"; +import { OnboardingAtomField } from "./onboarding-atom-field.js"; + +/** + * Fixed rather than drawn from the clock: the field is ambience, so + * there is nothing to gain from a different arrangement each launch, and + * a reproducible one can be asserted in a test and described in a bug + * report. + */ +const ATOM_SEED = 20260821; + +/** Below this the free space is a gap, not a field, and stays empty. */ +export const MIN_ATOM_ROWS = 3; + +/** + * Rows the ambient field may fill, from the placement's own arithmetic + * rather than a hand-counted sum of the host's chrome: the viewport the + * placement budgets (terminal minus the surface's padding and footer) + * splits its free rows evenly around the centred block, the field lives + * in the bottom half, and one row is held back so the atoms never touch + * the offer above them. + */ +export function downloadAmbientRows(input: { + /** `placement.rows` — the viewport between the padding and the footer. */ + viewportRows: number; + mark: OnboardingMark; + offerCloud: boolean; +}): number { + const free = + input.viewportRows - + countOnboardingDownloadBlockRows({ mark: input.mark, offerCloud: input.offerCloud }); + return Math.max(0, Math.floor(free / 2) - 1); +} + +/** + * The download screen's ambience: the atom field, drifting below the + * centred text block at the full width of the terminal. + * + * Mounted by `OnboardingScreen` in the surface's bottom spacer rather + * than inside the download step — the step's block is centred to its + * own text now, and a full-width field cannot live inside a box that + * narrow. Renders nothing unless a pull is genuinely in flight and the + * budget clears `MIN_ATOM_ROWS`. + */ +export function OnboardingDownloadAmbient(props: { + pull: LocalModelsPullState | null; + /** The panel's `errorLine` — how a failed pull actually arrives. */ + pullError: string | null; + /** Columns the surface can spare: the terminal minus its root inset. */ + columns: number; + /** `placement.rows`, so the budget shares the placement's arithmetic. */ + viewportRows: number; + mark: OnboardingMark; + /** Whether the meanwhile offer is on screen, which costs the block rows. */ + offerCloud: boolean; + /** Test seam: the field's step interval. Defaults to the ambient rate. */ + atomStepMs?: number; +}): ReactElement | null { + const rows = downloadAmbientRows({ + viewportRows: props.viewportRows, + mark: props.mark, + offerCloud: props.offerCloud, + }); + const phase = props.pull?.kind === "backend" ? "runtime" : "weights"; + // Stopped when there is nothing left to wait for: a field still + // drifting under a stalled bar would suggest work is happening. The + // failure signal is `pullError` — a failed pull nulls `pull` itself, + // and `pull.error` is never set by any event the app emits. + const waiting = + props.pullError == null && + !(phase === "weights" && (props.pull?.percent ?? 0) >= 100); + // One column short of the terminal: a run that fills the last cell + // wraps on some terminals, which would cost a row the budget has + // already spent. + const fieldColumns = Math.max(0, props.columns - 1); + const active = waiting && rows >= MIN_ATOM_ROWS; + const field = useAtomField({ + active, + columns: fieldColumns, + rows, + count: atomPopulation({ columns: fieldColumns, rows }), + seed: ATOM_SEED, + ...(props.atomStepMs === undefined ? {} : { stepMs: props.atomStepMs }), + }); + if (!active) return null; + return ; +} diff --git a/src/tui/components/onboarding-download-frame.test.tsx b/src/tui/components/onboarding-download-frame.test.tsx new file mode 100644 index 00000000..6108ec89 --- /dev/null +++ b/src/tui/components/onboarding-download-frame.test.tsx @@ -0,0 +1,198 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OnboardingScreen } from "./onboarding-screen.js"; +import { + downloadAmbientRows, + MIN_ATOM_ROWS, +} from "./onboarding-download-ambient.js"; +import { countOnboardingDownloadBlockRows } from "./onboarding-download-step.js"; +import { resetConfigCache } from "../../config/index.js"; +import { ATOM_GLYPH } from "../onboarding/atom-field.js"; +import { createOnboardingState } from "../onboarding/onboarding-state.js"; +import { createInitialTuiState } from "../tui-state.js"; +import { fakeSession } from "../test-fixtures.js"; +import { renderAtSize, type SizedRenderResult } from "../test-sized-render.js"; +import { FOOTER_ROWS, SURFACE_PADDING_TOP } from "./onboarding-surface-layout.js"; + +const STATE_DIR_ENV = "ATOMIC_AGENT_STATE_DIR"; +const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, ""); + +/** + * Counters chosen to fill `PROGRESS_TEMPLATE_LINE` exactly — the + * runtime phase at 100% with three-digit gigabyte counts — so the + * drawn bar row is as wide as the width the block was measured at and + * the balance can be asserted to the cell. The weights have not + * started, so the field is still live. + */ +const TEMPLATE_WIDE_PULL = { + kind: "backend", + modelId: "_backend", + label: "llama.cpp runtime", + percent: 100, + transferredBytes: 400_100_000_000, + totalBytes: 999_900_000_000, + error: null, +} as const; + +function downloadScreen() { + const onboarding = { + ...createOnboardingState("http://127.0.0.1:8080"), + step: "local_download" as const, + localModelId: "gemma-4-e4b", + }; + const base = createInitialTuiState(fakeSession(), 50); + const state = { + ...base, + localModelsPanel: { ...base.localModelsPanel, pull: TEMPLATE_WIDE_PULL }, + onboarding, + }; + return ( + {}} + callbacks={{}} + /> + ); +} + +describe("the download screen's frame", () => { + let stateDir: string; + let originalEnv: string | undefined; + const views: SizedRenderResult[] = []; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "onboarding-download-frame-")); + mkdirSync(stateDir, { recursive: true }); + originalEnv = process.env[STATE_DIR_ENV]; + process.env[STATE_DIR_ENV] = stateDir; + resetConfigCache(); + }); + + afterEach(() => { + while (views.length > 0) views.pop()?.unmount(); + if (originalEnv === undefined) delete process.env[STATE_DIR_ENV]; + else process.env[STATE_DIR_ENV] = originalEnv; + resetConfigCache(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + function frameAt(size: { columns: number; rows: number }): string[] { + const view = renderAtSize(downloadScreen(), size); + views.push(view); + return strip(view.lastFrame() ?? "").split("\n"); + } + + // Both target sizes keep the sm mark, and a fresh state dir means no + // cloud provider — the meanwhile offer is on screen. + const BLOCK_ROWS = countOnboardingDownloadBlockRows({ mark: "sm", offerCloud: true }); + + for (const size of [ + { name: "full 100×30", columns: 100, rows: 30 }, + { name: "fallback 80×24", columns: 80, rows: 24 }, + ]) { + it(`centres the text and keeps the atoms ambient at ${size.name}`, () => { + const lines = frameAt(size); + + // The footer is pinned to the true last row of the terminal. + expect(lines.length).toBe(size.rows); + expect(lines.at(-1)).toContain("ctrl+c"); + + // The bars sit balanced: their leading space matches the space + // their row leaves on the right, within the odd-column cell. The + // pull's counters fill the measured template, so the drawn row IS + // the measured width (clamped by the terminal at 80 columns). + const bars = lines.find((line) => line.includes("llama.cpp runtime")) ?? ""; + const leading = bars.length - bars.trimStart().length; + const width = bars.trimEnd().length - leading; + expect(width).toBeGreaterThan(0); + expect(Math.abs(leading - (size.columns - width) / 2)).toBeLessThanOrEqual(1); + expect(leading).toBeGreaterThan(0); + + // The block is centred vertically too: the first drawn row sits a + // spacer's share below the surface padding (±1 for Yoga's split of + // an odd remainder). + const viewportRows = size.rows - SURFACE_PADDING_TOP - FOOTER_ROWS; + const firstDrawn = lines.findIndex((line) => line.trim().length > 0); + const expectedTop = + SURFACE_PADDING_TOP + Math.floor((viewportRows - BLOCK_ROWS) / 2); + expect(Math.abs(firstDrawn - expectedTop)).toBeLessThanOrEqual(1); + + // The skip row is the block's last line, under the cloud offer. + const offerRow = lines.findIndex((line) => line.includes("press c")); + const skipRow = lines.findIndex((line) => line.includes("press s")); + expect(offerRow).toBeGreaterThan(0); + expect(skipRow).toBeGreaterThan(offerRow); + + // The atoms drift below the text, never over it — when the budget + // clears the field's minimum at all. The skip row costs the block + // three rows, and at 80×24 that squeezes the ambience below + // `MIN_ATOM_ROWS`: decoration yields to content, so the frame is + // asserted against the same budget the field reads. + const budget = downloadAmbientRows({ + viewportRows: size.rows - SURFACE_PADDING_TOP - FOOTER_ROWS, + mark: "sm", + offerCloud: true, + }); + const atomRows = lines + .map((line, index) => ({ line, index })) + .filter((row) => row.line.includes(ATOM_GLYPH)); + if (budget >= MIN_ATOM_ROWS) { + expect(atomRows.length).toBeGreaterThan(0); + } else { + expect(atomRows.length).toBe(0); + } + for (const row of atomRows) { + expect(row.index).toBeGreaterThan(skipRow); + expect(row.line).not.toContain("█"); + expect(row.line).not.toContain("░"); + expect(row.line).not.toContain("Downloading"); + expect(row.line).not.toContain("press c"); + expect(row.line).not.toContain("press s"); + } + }); + } + + it("keeps the block centred once the download is done and the field has left", () => { + const view = renderAtSize( + (() => { + const onboarding = { + ...createOnboardingState("http://127.0.0.1:8080"), + step: "local_download" as const, + localModelId: "gemma-4-e4b", + }; + const base = createInitialTuiState(fakeSession(), 50); + const state = { + ...base, + localModelsPanel: { + ...base.localModelsPanel, + pull: { ...TEMPLATE_WIDE_PULL, kind: "chat" as const, modelId: "gemma-4-e4b" }, + }, + onboarding, + }; + return ( + {}} + callbacks={{}} + /> + ); + })(), + { columns: 100, rows: 30 }, + ); + views.push(view); + const lines = strip(view.lastFrame() ?? "").split("\n"); + // Finished weights: no atoms, and the block has not moved for it. + expect(lines.join("\n")).not.toContain(ATOM_GLYPH); + const bars = lines.find((line) => line.includes("llama.cpp runtime")) ?? ""; + const leading = bars.length - bars.trimStart().length; + expect(leading).toBeGreaterThan(0); + expect(lines.length).toBe(30); + expect(lines.at(-1)).toContain("ctrl+c"); + }); +}); diff --git a/src/tui/components/onboarding-download-progress.tsx b/src/tui/components/onboarding-download-progress.tsx new file mode 100644 index 00000000..eb00ca1b --- /dev/null +++ b/src/tui/components/onboarding-download-progress.tsx @@ -0,0 +1,109 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { formatBytes, formatEta, useTransferRate } from "../hooks/use-transfer-rate.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import { theme } from "../theme/theme.js"; + +const BAR_WIDTH = 36; +/** Phase-name column, so the two bars start on the same cell. */ +const PHASE_LABEL_COLUMNS = 20; + +/** + * The widest a phase line ever gets, as a template rather than the live + * counters: the screens that centre on this measure would otherwise walk + * left and right as the byte counts gain digits. + */ +export const PROGRESS_TEMPLATE_LINE = `${" ".repeat( + PHASE_LABEL_COLUMNS + BAR_WIDTH, +)} 100% 999.9 GB / 999.9 GB`; + +/** + * A running local pull, drawn the same way wherever it appears. + * + * Two screens report the same download — the download step, and the + * "almost there" screen that a mid-download cloud setup returns to — so + * they share one component rather than each inventing its own summary. + * + * Two phases share one progress slot in state (the llama.cpp runtime + * zip, then the weights), so the checklist is derived from which one is + * currently reporting. Rate and ETA come from the same events: a + * percentage cannot answer "how long", which is the question a + * multi-gigabyte pull actually raises. + * + * A failure never arrives inside `pull` — the reducer nulls the pull and + * moves the message to the panel's `errorLine` — so the error comes in + * as its own prop, and it replaces the bars rather than joining them: + * a 0% bar under an error would claim a download that is not running, + * and the extra rows would blow the step's budget on a short terminal. + */ +export function OnboardingDownloadProgress(props: { + pull: LocalModelsPullState | null; + /** The pull's failure, from the panel's `errorLine`. */ + error: string | null; +}): ReactElement { + const pull = props.pull; + const { bytesPerSecond, etaSeconds } = useTransferRate( + pull?.transferredBytes ?? 0, + pull?.totalBytes ?? 0, + ); + if (pull === null && props.error !== null) { + return ( + + + {`${theme.glyphs.cross} ${props.error}`} + + + ); + } + const phase = pull?.kind === "backend" ? "runtime" : "weights"; + + return ( + + + + + {pull ? ( + + {bytesPerSecond ? `${formatBytes(bytesPerSecond)}/s · ` : ""} + {formatEta(etaSeconds)} + + ) : ( + starting… + )} + + + ); +} + +function PhaseLine(props: { + label: string; + state: "active" | "done" | "pending"; + pull: LocalModelsPullState | null; +}): ReactElement { + const percent = props.state === "done" ? 100 : (props.pull?.percent ?? 0); + const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * BAR_WIDTH); + const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled); + const trailing = + props.state === "done" + ? "done" + : props.pull + ? `${Math.round(percent)}% ${formatBytes(props.pull.transferredBytes)} / ${formatBytes(props.pull.totalBytes)}` + : "waiting"; + return ( + + {props.label.padEnd(PHASE_LABEL_COLUMNS)} + + {bar} + + {` ${trailing}`} + + ); +} diff --git a/src/tui/components/onboarding-download-step.test.tsx b/src/tui/components/onboarding-download-step.test.tsx new file mode 100644 index 00000000..bb48a1c6 --- /dev/null +++ b/src/tui/components/onboarding-download-step.test.tsx @@ -0,0 +1,154 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + countOnboardingDownloadBlockRows, + OnboardingDownloadStep, +} from "./onboarding-download-step.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; + +type View = ReturnType; + +const mounted: View[] = []; + +afterEach(() => { + while (mounted.length > 0) mounted.pop()?.unmount(); +}); + +function mount(node: React.ReactElement): View { + const view = render(node); + mounted.push(view); + return view; +} + +const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, ""); + +function pull(over: Partial = {}): LocalModelsPullState { + return { + kind: "chat", + modelId: "gemma-4-e4b", + label: "Gemma 4 E4B", + percent: 38, + transferredBytes: 1_600_000_000, + totalBytes: 4_220_000_000, + error: null, + ...over, + }; +} + +function step(props: Partial> = {}) { + return ( + + ); +} + +describe("OnboardingDownloadStep", () => { + it("says what is happening before the first progress event lands", () => { + const view = mount(step({ pull: null })); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("Downloading gemma-4-e4b"); + expect(frame).toContain("starting"); + }); + + it("reports bytes and percent for the phase in flight", () => { + const view = mount(step()); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("model weights"); + expect(frame).toContain("38%"); + expect(frame).toContain("1.6 GB / 4.2 GB"); + }); + + it("shows the runtime phase as done once the weights start", () => { + const view = mount(step()); + const line = strip(view.lastFrame() ?? "") + .split("\n") + .find((row) => row.includes("llama.cpp runtime")); + expect(line).toContain("done"); + }); + + it("marks the weights as waiting while the runtime is still coming down", () => { + const view = mount( + step({ pull: pull({ kind: "backend", modelId: "_backend", percent: 6 }) }), + ); + const frame = strip(view.lastFrame() ?? ""); + const weights = frame.split("\n").find((row) => row.includes("model weights")); + expect(weights).toContain("waiting"); + expect(frame).toContain("6%"); + }); + + it("surfaces a failed pull instead of a silent stall", () => { + // The state a real failure leaves behind: `local_models_pull_failed` + // nulls the pull and parks the message on the panel's error line. + const view = mount(step({ pull: null, pullError: "connection reset" })); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("connection reset"); + expect(frame).toContain("download failed"); + // No claim of a download that is not running. + expect(frame).not.toContain("starting"); + expect(frame).not.toContain("keeps running"); + expect(frame).not.toContain("░"); + // The cloud offer survives the failure — it is the working way out. + expect(frame).toContain("press c"); + // So does the skip exit, honest about what it leaves behind. + expect(frame).toContain("without a local model"); + expect(frame).toContain("press s"); + }); + + it("offers the skip exit while the download runs, top bar promise included", () => { + const view = mount(step()); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("Or skip the wait"); + expect(frame).toContain("progress shows in the top bar"); + expect(frame).toContain("press s"); + }); + + it("keeps the skip exit even when there is no cloud left to offer", () => { + const view = mount(step({ offerCloudMeanwhile: false })); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).not.toContain("press c"); + expect(frame).toContain("press s"); + }); + + it("estimates a rate once a second sample arrives", async () => { + const view = mount(step({ pull: pull({ transferredBytes: 1_000_000_000 }) })); + expect(strip(view.lastFrame() ?? "")).toContain("estimating"); + view.rerender(step({ pull: pull({ transferredBytes: 1_400_000_000 }) })); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(strip(view.lastFrame() ?? "")).toMatch(/\/s /); + }); +}); + +describe("countOnboardingDownloadBlockRows", () => { + /** + * The count is what the ambient field's budget subtracts from the + * placement, so the step's share is pinned against the drawn frame + * rather than trusted: the count minus the header rows (3 for the sm + * mark, 2 for xs — pinned by the full-surface frame test) and the gap + * under the header must equal the lines the step actually renders. + */ + it("counts the step's own rows the way the frame draws them", () => { + const view = mount(step()); + const lines = strip(view.lastFrame() ?? "").split("\n"); + // headline + margin + 2 bars + margin + rate + 2-row margin + offer + // + the skip row's margin and two lines + expect(lines.length).toBe(13); + const withMark = countOnboardingDownloadBlockRows({ mark: "sm", offerCloud: true }); + const noMark = countOnboardingDownloadBlockRows({ mark: "xs", offerCloud: true }); + expect(withMark).toBe(lines.length + 3 + 1); + expect(noMark).toBe(lines.length + 2 + 1); + }); + + it("gives the offer's four rows back once it is spent", () => { + const withOffer = countOnboardingDownloadBlockRows({ mark: "sm", offerCloud: true }); + const without = countOnboardingDownloadBlockRows({ mark: "sm", offerCloud: false }); + expect(withOffer - without).toBe(4); + // The skip row stays: 6 progress rows plus its margin and two lines. + const view = mount(step({ offerCloudMeanwhile: false })); + expect(strip(view.lastFrame() ?? "").split("\n").length).toBe(9); + }); +}); diff --git a/src/tui/components/onboarding-download-step.tsx b/src/tui/components/onboarding-download-step.tsx new file mode 100644 index 00000000..a3759af4 --- /dev/null +++ b/src/tui/components/onboarding-download-step.tsx @@ -0,0 +1,213 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; +import { plainKey } from "../mouse/synthetic-key.js"; +import { handleOnboardingStepKey } from "../onboarding/onboarding-step-keys.js"; +import type { OnboardingMark } from "../onboarding/onboarding-fit.js"; +import { theme } from "../theme/theme.js"; +import { countOnboardingHeaderRows } from "./onboarding-header.js"; +import { + OnboardingDownloadProgress, + PROGRESS_TEMPLATE_LINE, +} from "./onboarding-download-progress.js"; + +const CLOUD_OFFER = [ + "┃ Don’t want to wait? Set up a cloud model in the meantime —", + "┃ it takes about a minute, and the download keeps running.", +] as const; +/** The failed variant: one line, because there is no download to keep. */ +const CLOUD_OFFER_FAILED = "┃ Set up a cloud model instead — it takes about a minute."; +/** Set bold on the second offer line: it is the key, not the sentence. */ +const CLOUD_OFFER_KEY = " press c"; + +const SKIP_OFFER = [ + "┃ Or skip the wait — start using the agent now. The download", + "┃ keeps running; progress shows in the top bar.", +] as const; +/** + * The failed variant promises nothing about a download that is not + * running — leaving with no working local model is still legitimate, + * and the turn gate explains the state if they try to chat before + * fixing it. + */ +const SKIP_OFFER_FAILED = "┃ Or skip — start using the agent without a local model."; +const SKIP_OFFER_KEY = " press s"; + +/** + * Widest line this step draws, for the block that centres it. + * + * The error line is left out on purpose: it carries whatever the pull + * failed with, and sizing the surface to an arbitrary string would + * resize the screen around a message. It wraps inside the block instead. + */ +export function measureOnboardingDownloadStep(props: { + modelLabel: string; + offerCloudMeanwhile?: boolean; +}): number { + return widestLine([ + headingLine(props.modelLabel), + PROGRESS_TEMPLATE_LINE, + ...(props.offerCloudMeanwhile === false + ? [] + : [CLOUD_OFFER[0], `${CLOUD_OFFER[1]}${CLOUD_OFFER_KEY}`]), + SKIP_OFFER[0], + `${SKIP_OFFER[1]}${SKIP_OFFER_KEY}`, + // Unlike the error line, the failed skip row is a fixed string, so + // measuring it cannot resize the screen around a message — and + // leaving it out would wrap it when the cloud offer is hidden. + `${SKIP_OFFER_FAILED}${SKIP_OFFER_KEY}`, + ]); +} + +/** + * Rows the centred download block spends while a pull is running: the + * header and its gap (drawn by `OnboardingStepBody`), the headline, the + * bars, the rate line, their margins, and the meanwhile offer. The + * ambient atom field sizes itself from what the placement leaves after + * these — see `OnboardingDownloadAmbient` — and the full-screen frame + * test is what keeps the count honest against the JSX below. Only the + * running shape is counted: a failed pull swaps the bars for an error + * line, and the field has already stopped by then. + */ +export function countOnboardingDownloadBlockRows(input: { + mark: OnboardingMark; + offerCloud: boolean; +}): number { + // The gap under the header (1), the headline (1), the progress top + // margin (1), the two bars (2), the rate line and its margin (2), the + // skip row's margin plus its two lines (3, always drawn), and the + // cloud offer's top margin plus two lines when it shows. + return countOnboardingHeaderRows(input.mark) + 10 + (input.offerCloud ? 4 : 0); +} + +function headingLine(modelLabel: string): string { + return `Downloading ${modelLabel}. You can leave this running.`; +} + +/** + * The download, as its own screen. + * + * The bars, the rate and the ETA are shared with the "almost there" + * screen — see {@link OnboardingDownloadProgress}. What this screen adds + * is the offer to spend the wait setting up a cloud model instead. The + * atom field that used to live here is the surface's ambience now + * (`OnboardingDownloadAmbient`): it spans the full terminal below this + * block, which a block centred to its own text cannot contain. + */ +export function OnboardingDownloadStep(props: { + pull: LocalModelsPullState | null; + /** + * The panel's `errorLine`. This — not `pull.error` — is how a failed + * pull actually arrives: `local_models_pull_failed` nulls the pull + * and leaves the message here, and the next `pull_started` clears it. + */ + pullError: string | null; + modelLabel: string; + /** Hidden once a cloud provider is configured — nothing left to offer. */ + offerCloudMeanwhile?: boolean; +}): ReactElement { + const pull = props.pull; + const mouse = useMouseCommands(); + // A failed pull nulls itself and reports through `errorLine`; the + // headline and the offer must not keep claiming a running download. + const failed = pull === null && props.pullError !== null; + const offerCloud = props.offerCloudMeanwhile !== false; + + return ( + + + {failed + ? `The ${props.modelLabel} download failed.` + : headingLine(props.modelLabel)} + + + + + {offerCloud ? ( + + {/* + Accent-marked because it is an offer, not a status line: the + wait is measured in minutes and a cloud model takes about + one. The download is owned by the orchestrator, so setting + one up does not pause or restart it. Clicking the block + sends the same `c` it advertises, through the flow's own key + table — the row wrapper keeps the target hugging the text + instead of claiming the whole terminal width. + */} + { + if (!mouse || !isPrimaryPress(hit.event)) return false; + handleOnboardingStepKey("c", plainKey(), { + state: mouse.getState(), + dispatch: mouse.dispatch, + callbacks: mouse.callbacks, + }); + return true; + }} + > + + {failed ? ( + + {CLOUD_OFFER_FAILED} + {CLOUD_OFFER_KEY} + + ) : ( + <> + {CLOUD_OFFER[0]} + + {CLOUD_OFFER[1]} + {CLOUD_OFFER_KEY} + + + )} + + + + ) : null} + {/* + Always drawn, cloud offer or not: leaving for the agent is + legitimate in every state of this screen, failed pull included. + Muted rather than accent — it is the quieter second exit, under + the offer that actually adds a backend. The click synthesises + the same `s` the row advertises, through the flow's key table, + so the mouse and the keyboard cannot drift apart. + */} + + { + if (!mouse || !isPrimaryPress(hit.event)) return false; + handleOnboardingStepKey("s", plainKey(), { + state: mouse.getState(), + dispatch: mouse.dispatch, + callbacks: mouse.callbacks, + }); + return true; + }} + > + + {failed ? ( + + {SKIP_OFFER_FAILED} + {SKIP_OFFER_KEY} + + ) : ( + <> + {SKIP_OFFER[0]} + + {SKIP_OFFER[1]} + {SKIP_OFFER_KEY} + + + )} + + + + + ); +} diff --git a/src/tui/components/onboarding-header.test.tsx b/src/tui/components/onboarding-header.test.tsx new file mode 100644 index 00000000..f9f6581f --- /dev/null +++ b/src/tui/components/onboarding-header.test.tsx @@ -0,0 +1,33 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { computeOnboardingFit } from "../onboarding/onboarding-fit.js"; +import { OnboardingHeader } from "./onboarding-header.js"; + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); + +describe("OnboardingHeader", () => { + it("draws the XS sign — not a bare wordmark — on a tiny terminal", () => { + // 60×14 is below both minimal thresholds; the tier used to shed + // the mark entirely here. + const fit = computeOnboardingFit({ columns: 60, rows: 14 }); + expect(fit.mark).toBe("xs"); + const { lastFrame } = render( + , + ); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("▗█▄░"); + expect(frame).toContain("▀█▘░"); + expect(frame).toContain("atomic"); + expect(frame).toContain("step 1 of 3"); + }); + + it("keeps the three-row SM mark at roomier tiers", () => { + const { lastFrame } = render( + , + ); + const frame = strip(lastFrame() ?? ""); + // SM's middle bar — five face cells — only exists at three rows. + expect(frame).toContain("█████"); + expect(frame).toContain("atomic"); + }); +}); diff --git a/src/tui/components/onboarding-header.tsx b/src/tui/components/onboarding-header.tsx new file mode 100644 index 00000000..48f00c41 --- /dev/null +++ b/src/tui/components/onboarding-header.tsx @@ -0,0 +1,100 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import type { OnboardingMark } from "../onboarding/onboarding-fit.js"; +import { theme } from "../theme/theme.js"; +import { CROSS_MARKS, FACE_GLYPHS } from "./logo-art.js"; + +/** The product name, set beside the mark. */ +const WORDMARK = "atomic"; +/** Blank columns between the mark and the wordmark column. */ +const MARK_GAP_COLUMNS = 2; + +/** + * Widest line the lockup draws, so the block it heads can be centred + * without the header pulling the measure out from under it. + */ +export function measureOnboardingHeader( + subtitle: string, + mark: OnboardingMark, +): number { + const text = widestLine([WORDMARK, subtitle]); + return widestLine(CROSS_MARKS.block[mark]) + MARK_GAP_COLUMNS + text; +} + +/** + * Rows the lockup spends: the mark column, or the two-line + * wordmark-plus-subtitle beside it, whichever is taller. Derived from + * the same art the render maps over, so the count cannot drift from + * the drawing the way a hand-written number would. + */ +export function countOnboardingHeaderRows(mark: OnboardingMark): number { + return Math.max(CROSS_MARKS.block[mark].length, 2); +} + +/** + * Brand lockup for the first-run screens: the mark, the product name, + * and where in the flow the operator is. Deliberately not the + * `StatusBar` — during setup there is no session, no breadcrumb and no + * tab to name, and borrowing the app's chrome would advertise + * navigation that does not exist yet. + */ +export function OnboardingHeader(props: { + subtitle: string; + /** + * Which mark to draw, from the fit tiers. The minimal tier passes + * `xs` rather than dropping the mark: the two-row sign costs no more + * height than the bare text lockup it replaced, so even the tiniest + * terminal keeps the brand. + */ + mark?: OnboardingMark; +}): ReactElement { + const rows = CROSS_MARKS.block[props.mark ?? "sm"]; + return ( + + + {rows.map((row, i) => ( + + ))} + + + + {WORDMARK} + + {props.subtitle} + + + ); +} + +/** + * One row of the mark, split into face and depth runs so colour carries + * the depth. The glyph ramp underneath (`█ ▓ ░`) still encodes it on its + * own, which is what keeps the mark readable with colour stripped. + */ +function MarkRow({ row }: { row: string }): ReactElement { + const runs: { text: string; face: boolean }[] = []; + for (const ch of row) { + const face = FACE_GLYPHS.has(ch); + const last = runs[runs.length - 1]; + if (last && last.face === face) last.text += ch; + else runs.push({ text: ch, face }); + } + return ( + + {runs.map((run, i) => ( + + {run.text} + + ))} + + ); +} diff --git a/src/tui/components/onboarding-hf-flow.test.tsx b/src/tui/components/onboarding-hf-flow.test.tsx new file mode 100644 index 00000000..13935cce --- /dev/null +++ b/src/tui/components/onboarding-hf-flow.test.tsx @@ -0,0 +1,150 @@ +import { render } from "ink-testing-library"; +import React, { useState, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { + resolveHuggingFaceGgufChoices, + type HuggingFaceRepoChoices, +} from "../../local-llm/index.js"; +import { reduceTuiState } from "../agent-event-reducer.js"; +import { createOnboardingState } from "../onboarding/onboarding-state.js"; +import { fakeSession } from "../test-fixtures.js"; +import type { TuiAction } from "../tui-action.js"; +import { createInitialTuiState } from "../tui-state.js"; +import { OnboardingHuggingFaceFlow } from "./onboarding-hf-flow.js"; + +vi.mock("../../local-llm/index.js", async (importOriginal) => { + const original = + await importOriginal(); + return { ...original, resolveHuggingFaceGgufChoices: vi.fn() }; +}); + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); +const GB = 1024 * 1024 * 1024; + +const CHOICES: HuggingFaceRepoChoices = { + repoId: "unsloth/Qwen3-0.6B-GGUF", + revision: "main", + choices: [ + { + path: "Qwen3-0.6B-UD-Q4_K_XL.gguf", + filename: "Qwen3-0.6B-UD-Q4_K_XL.gguf", + sizeBytes: 0.38 * GB, + fileSizeGb: 0.38, + sizeLabel: "387 MB", + }, + ], + mmproj: null, + hidden: null, +}; + +/** + * The flow against the real reducer, the way `OnboardingScreen` mounts + * it — dispatched actions are both recorded and folded, so what the + * frames show is what an operator would see. + */ +function Harness(props: { actions: TuiAction[] }): ReactElement | null { + const [state, setState] = useState(() => + reduceTuiState( + createInitialTuiState(fakeSession(), 50, { + onboarding: createOnboardingState("http://127.0.0.1:8080"), + }), + { type: "onboarding_step_set", step: "local_hf_ref" }, + ), + ); + if (!state.onboarding) return null; + return ( + { + props.actions.push(action); + setState((s) => reduceTuiState(s, action)); + }} + ramGb={16} + /> + ); +} + +/** + * Frames land at ~4 fps and a lone esc is held back ~20 ms by Ink's + * escape-sequence parser — assert the settled outcome, never the clock. + */ +async function until(what: string, predicate: () => boolean): Promise { + const deadline = Date.now() + 4000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`never settled: ${what}`); + await new Promise((resolve) => setTimeout(resolve, 15)); + } +} + +describe("OnboardingHuggingFaceFlow", () => { + it("esc cancels a lookup in flight and hands the editor back, reference intact", async () => { + let signal: AbortSignal | undefined; + vi.mocked(resolveHuggingFaceGgufChoices).mockImplementation( + (_ref, opts) => { + signal = opts?.signal; + // Never settles on its own — the 15 s Hugging Face timeout, + // from the operator's side of the keyboard. + return new Promise(() => {}); + }, + ); + const actions: TuiAction[] = []; + const view = render(); + view.stdin.write("unsloth/Qwen3-0.6B-GGUF"); + await until("reference typed", () => + strip(view.lastFrame() ?? "").includes("unsloth/Qwen3-0.6B-GGUF"), + ); + view.stdin.write("\r"); + await until("lookup started", () => + strip(view.lastFrame() ?? "").includes("asking huggingface.co"), + ); + view.stdin.write("\u001b"); + await until("lookup cancelled", () => + !strip(view.lastFrame() ?? "").includes("asking huggingface.co"), + ); + expect(signal?.aborted).toBe(true); + // What was typed survives the cancel — the point of cancelling is + // usually to fix it. + expect(strip(view.lastFrame() ?? "")).toContain("unsloth/Qwen3-0.6B-GGUF"); + view.unmount(); + }); + + it("drops a resolution that limps home after the cancel", async () => { + let settle!: (repo: HuggingFaceRepoChoices) => void; + vi.mocked(resolveHuggingFaceGgufChoices).mockImplementation( + () => new Promise((resolve) => (settle = resolve)), + ); + const actions: TuiAction[] = []; + const view = render(); + view.stdin.write("unsloth/Qwen3-0.6B-GGUF"); + view.stdin.write("\r"); + await until("lookup started", () => + strip(view.lastFrame() ?? "").includes("asking huggingface.co"), + ); + view.stdin.write("\u001b"); + await until("lookup cancelled", () => + !strip(view.lastFrame() ?? "").includes("asking huggingface.co"), + ); + settle(CHOICES); + // Give a wrongly-surviving dispatch every chance to land. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect( + actions.some((action) => action.type === "onboarding_hf_repo_resolved"), + ).toBe(false); + expect(strip(view.lastFrame() ?? "")).toContain("Which model?"); + view.unmount(); + }); + + it("still lands on the file list when the lookup wins the race", async () => { + vi.mocked(resolveHuggingFaceGgufChoices).mockResolvedValue(CHOICES); + const actions: TuiAction[] = []; + const view = render(); + view.stdin.write("unsloth/Qwen3-0.6B-GGUF"); + view.stdin.write("\r"); + await until("file list shown", () => + strip(view.lastFrame() ?? "").includes("Qwen3-0.6B-UD-Q4_K_XL.gguf"), + ); + expect(strip(view.lastFrame() ?? "")).toContain("387 MB"); + view.unmount(); + }); +}); diff --git a/src/tui/components/onboarding-hf-flow.tsx b/src/tui/components/onboarding-hf-flow.tsx new file mode 100644 index 00000000..837dfa79 --- /dev/null +++ b/src/tui/components/onboarding-hf-flow.tsx @@ -0,0 +1,58 @@ +import type { ReactElement } from "react"; + +import { useOnboardingHuggingFace } from "../hooks/use-onboarding-huggingface.js"; +import type { OnboardingUiState } from "../onboarding/onboarding-state.js"; +import type { TuiAction } from "../tui-action.js"; +import { OnboardingHuggingFacePickStep } from "./onboarding-hf-pick-step.js"; +import { OnboardingHuggingFaceRefStep } from "./onboarding-hf-ref-step.js"; + +/** + * The whole "add a model from Hugging Face" branch — the reference + * editor, the file list, and the hook that owns the lookup effect. The + * file list's keys (and the download its Enter starts) live in the + * flow-wide key table, `onboarding-hf-keys.ts`, where the mouse can + * reach them too. + * + * Its own module rather than two more branches in `OnboardingScreen`: + * that file predates the 300-line budget and every slice touching it + * collides with every other, so the branch keeps its render and its + * lookup wiring in one place the screen only mounts. + * + * Mounted on every step and rendering `null` off its own two — the hook + * inside subscribes to `useInput`, and hooks cannot sit behind an early + * return in the parent. + */ +export function OnboardingHuggingFaceFlow(props: { + onboarding: OnboardingUiState; + dispatch(action: TuiAction): void; + ramGb: number; +}): ReactElement | null { + const { onboarding, dispatch } = props; + const huggingFace = useOnboardingHuggingFace({ onboarding, dispatch }); + if (onboarding.step === "local_hf_ref") { + return ( + + dispatch({ type: "onboarding_hf_reference_changed", value }) + } + onSubmit={huggingFace.resolveReference} + onClear={huggingFace.clearReference} + onBack={() => dispatch({ type: "onboarding_step_set", step: "local_pick" })} + /> + ); + } + if (onboarding.step === "local_hf_pick" && onboarding.hfRepo) { + return ( + + ); + } + return null; +} diff --git a/src/tui/components/onboarding-hf-pick-step.tsx b/src/tui/components/onboarding-hf-pick-step.tsx new file mode 100644 index 00000000..20f5b063 --- /dev/null +++ b/src/tui/components/onboarding-hf-pick-step.tsx @@ -0,0 +1,59 @@ +import type { ReactElement } from "react"; +import { pressEnter } from "../mouse/mouse-list-row.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import { handleOnboardingStepKey } from "../onboarding/onboarding-step-keys.js"; +import type { OnboardingHuggingFaceRepo } from "../onboarding/onboarding-state.js"; +import { + hfChoiceLine, + HF_MMPROJ_LINE, + HF_PICK_WINDOW, + HfPickList, + windowHfChoices, +} from "./hf-pick-list.js"; + +export { HF_PICK_WINDOW }; + +/** + * Widest of the deterministic lines this step draws, for the block that + * centres it. The RAM warning and the error line are left out: both are + * transient, and a block that re-centres itself when one appears would + * jump under the cursor. + */ +export function measureOnboardingHfPickStep( + repo: OnboardingHuggingFaceRepo | null, + cursor: number, +): number { + if (!repo) return 0; + const { visible, below } = windowHfChoices(repo, cursor); + const lines = [repo.repoId, ...visible.map((choice) => hfChoiceLine(choice, true))]; + if (below > 0) lines.push(` ↓ ${below} more`); + if (repo.hidden) lines.push(` ${repo.hidden}`); + if (repo.mmproj) lines.push(HF_MMPROJ_LINE); + return widestLine(lines); +} + +/** + * The first-run flow's quantisation picker: `HfPickList` wired to the + * onboarding slice's cursor and to the flow's own key table, so a click + * and the Enter key land on exactly the same catalog write. The Models + * pane mounts the same list against its own state. + */ +export function OnboardingHuggingFacePickStep(props: { + repo: OnboardingHuggingFaceRepo; + cursor: number; + ramGb: number; + error: string | null; +}): ReactElement { + return ( + + mouse.dispatch({ type: "onboarding_cursor_set", cursor }) + } + onActivate={pressEnter(handleOnboardingStepKey)} + /> + ); +} diff --git a/src/tui/components/onboarding-hf-ref-step.tsx b/src/tui/components/onboarding-hf-ref-step.tsx new file mode 100644 index 00000000..c58e780f --- /dev/null +++ b/src/tui/components/onboarding-hf-ref-step.tsx @@ -0,0 +1,45 @@ +import type { ReactElement } from "react"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import { + HfReferenceEditor, + HF_REF_ERROR_COLUMNS, + HF_REF_EXAMPLES_LINE, + HF_REF_TITLE_LINE, +} from "./hf-reference-editor.js"; + +/** Widest line this step draws, for the block that centres it. */ +export function measureOnboardingHfRefStep(error: string | null): number { + const lines = [HF_REF_TITLE_LINE, HF_REF_EXAMPLES_LINE]; + if (error) lines.push(" ".repeat(Math.min(HF_REF_ERROR_COLUMNS, error.length))); + return widestLine(lines); +} + +/** + * The first-run flow's Hugging Face reference editor: the shared + * `HfReferenceEditor` with Escape wired back to the local-model pick + * step. The Models pane mounts the same editor with Escape wired to its + * own list. + */ +export function OnboardingHuggingFaceRefStep(props: { + value: string; + busy: boolean; + error: string | null; + onChange(value: string): void; + onSubmit(value: string): void; + onClear(): void; + onBack(): void; +}): ReactElement { + return ( + + ); +} diff --git a/src/tui/components/onboarding-hf-steps.test.tsx b/src/tui/components/onboarding-hf-steps.test.tsx new file mode 100644 index 00000000..dc2d25fc --- /dev/null +++ b/src/tui/components/onboarding-hf-steps.test.tsx @@ -0,0 +1,210 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; + +import { OnboardingHuggingFacePickStep } from "./onboarding-hf-pick-step.js"; +import { OnboardingHuggingFaceRefStep } from "./onboarding-hf-ref-step.js"; +import { OnboardingLocalPickStep } from "./onboarding-local-pick-step.js"; +import { + buildLocalModelPicks, + orderLocalModelPicks, +} from "../onboarding/local-model-picks.js"; +import { computeOnboardingFit } from "../onboarding/onboarding-fit.js"; +import type { OnboardingHuggingFaceRepo } from "../onboarding/onboarding-state.js"; + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); +const FULL = computeOnboardingFit({ columns: 100, rows: 30 }); +const GB = 1024 * 1024 * 1024; + +function picks() { + return orderLocalModelPicks(buildLocalModelPicks(16)); +} + +function repo( + overrides: Partial = {}, +): OnboardingHuggingFaceRepo { + return { + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + choices: [ + { + path: "Qwen3.5-4B-UD-Q4_K_XL.gguf", + filename: "Qwen3.5-4B-UD-Q4_K_XL.gguf", + sizeBytes: 2.7 * GB, + fileSizeGb: 2.7, + sizeLabel: "2.7 GB", + }, + { + path: "Qwen3.5-4B-Q8_0.gguf", + filename: "Qwen3.5-4B-Q8_0.gguf", + sizeBytes: 40 * GB, + fileSizeGb: 40, + sizeLabel: "40.0 GB", + }, + ], + mmproj: null, + hidden: null, + ...overrides, + }; +} + +describe("OnboardingLocalPickStep", () => { + it("calls the curated list a recommendation and offers the way past it", () => { + const view = render( + , + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("Recommended models"); + expect(frame).toContain("Add a model from Hugging Face"); + expect(frame).toContain("paste an owner/repo id"); + }); + + it("keeps the Hugging Face row on screen when the list has scrolled past it", () => { + const rows = picks(); + const view = render( + , + ); + const frame = strip(view.lastFrame() ?? ""); + const hf = frame.split("\n").find((line) => line.includes("Hugging Face")) ?? ""; + expect(hf.trimStart().startsWith("›")).toBe(true); + // The cursor is off the end of the curated rows, so none of them + // may claim the marker as well. + expect(frame.split("\n").filter((line) => line.includes("›"))).toHaveLength(1); + }); + + it("marks a curated row, not the Hugging Face one, while the cursor is in the list", () => { + const view = render( + , + ); + const lines = strip(view.lastFrame() ?? "").split("\n"); + const marked = lines.filter((line) => line.includes("›")); + expect(marked).toHaveLength(1); + expect(marked[0]).not.toContain("Hugging Face"); + }); +}); + +describe("OnboardingHuggingFaceRefStep", () => { + it("asks for a reference and shows the forms it accepts", () => { + const view = render( + {}} + onSubmit={() => {}} + onClear={() => {}} + onBack={() => {}} + />, + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("Which model?"); + expect(frame).toContain("it has to be a GGUF build"); + expect(frame).toContain("unsloth/Qwen3.5-4B-GGUF"); + expect(frame).toContain("https://huggingface.co/owner/repo"); + // Nothing typed yet, so there is nothing to clear. + expect(frame).not.toContain("[ clear ]"); + }); + + it("prints the refusal on the screen that asked the question", () => { + const view = render( + {}} + onSubmit={() => {}} + onClear={() => {}} + onBack={() => {}} + />, + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("no repo or revision by that name"); + // The control sits between the editor and the error box, offering + // to drop both the reference and the refusal it earned. + const lines = frame.split("\n"); + const clearRow = lines.findIndex((line) => line.includes("[ clear ]")); + const errorRow = lines.findIndex((line) => line.includes("no repo or revision")); + expect(clearRow).toBeGreaterThan(-1); + expect(clearRow).toBeLessThan(errorRow); + }); + + it("says what it is waiting for while the lookup is in flight", () => { + const view = render( + {}} + onSubmit={() => {}} + onClear={() => {}} + onBack={() => {}} + />, + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("asking huggingface.co"); + // Esc owns the busy screen; a clear control there would fight the + // read-only editor. + expect(frame).not.toContain("[ clear ]"); + }); +}); + +describe("OnboardingHuggingFacePickStep", () => { + it("names the repo and lists every servable file with its size", () => { + const view = render( + , + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("unsloth/Qwen3.5-4B-GGUF"); + expect(frame).toContain("Qwen3.5-4B-UD-Q4_K_XL.gguf"); + expect(frame).toContain("2.7 GB"); + expect(frame).toContain("Qwen3.5-4B-Q8_0.gguf"); + }); + + it("warns about a model larger than this machine's RAM without hiding it", () => { + const view = render( + , + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("40.0 GB model, 16 GB of RAM"); + expect(frame).toContain("run from disk"); + // Warned about, still listed, still under the cursor: nothing here + // takes the choice away. + expect(frame).toContain("\u203a Qwen3.5-4B-Q8_0.gguf"); + }); + + it("stays quiet about RAM when the file fits", () => { + const view = render( + , + ); + expect(strip(view.lastFrame() ?? "")).not.toContain("of RAM"); + }); + + it("accounts for the files it left out", () => { + const view = render( + , + ); + expect(strip(view.lastFrame() ?? "")).toContain("2 more files hidden"); + }); + + it("shows a failed download start on the list that started it", () => { + const view = render( + , + ); + expect(strip(view.lastFrame() ?? "")).toContain("permission denied"); + }); +}); diff --git a/src/tui/components/onboarding-intro-step.test.tsx b/src/tui/components/onboarding-intro-step.test.tsx new file mode 100644 index 00000000..c266445d --- /dev/null +++ b/src/tui/components/onboarding-intro-step.test.tsx @@ -0,0 +1,146 @@ +import chalk from "chalk"; +import { render } from "ink-testing-library"; +import React from "react"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { computeOnboardingFit } from "../onboarding/onboarding-fit.js"; +import { parseHexColor } from "../theme/parse-hex-color.js"; +import { theme } from "../theme/theme.js"; +import { STAR_GLYPHS, starTierOfGlyph } from "../onboarding/star-tiers.js"; +import { OnboardingIntroStep } from "./onboarding-intro-step.js"; + +/** One token of a frame: an SGR sequence, or a single character. */ +const TOKEN = /\u001B\[[0-9;]*m|[^]/gu; +const SGR = /^\u001B\[[0-9;]*m$/u; +/** Sets a foreground colour, as opposed to bold, dim or a background. */ +const FOREGROUND = /^\u001B\[(?:38;[25];[\d;]+|3[0-7]|9[0-7])m$/u; +const FOREGROUND_OFF = /^\u001B\[(?:0|39)m$/u; + +const strip = (frame: string): string => frame.replace(/\u001B\[[0-9;]*m/gu, ""); + +/** The truecolor SGR Ink emits for a hex foreground, e.g. `ESC[38;2;r;g;bm`. */ +function foregroundSgr(hex: string): string { + const rgb = parseHexColor(hex); + if (!rgb) throw new Error(`unparseable palette colour: ${hex}`); + return `\u001b[38;2;${rgb.r};${rgb.g};${rgb.b}m`; +} + +/** Rendering an Ink tree is slow enough that sizes are worth reusing. */ +const frames = new Map(); + +function frameAt(columns: number, rows: number): string { + const key = `${columns}x${rows}`; + const cached = frames.get(key); + if (cached !== undefined) return cached; + const view = render( + , + ); + const frame = view.lastFrame() ?? ""; + view.unmount(); + frames.set(key, frame); + return frame; +} + +/** The foreground each star glyph is actually painted in. */ +function starColours(frame: string): Set { + const colours = new Set(); + let current = ""; + for (const [token] of frame.matchAll(TOKEN)) { + if (SGR.test(token)) { + if (FOREGROUND.test(token)) current = token; + else if (FOREGROUND_OFF.test(token)) current = ""; + continue; + } + if (starTierOfGlyph(token)) colours.add(current); + } + return colours; +} + +describe("OnboardingIntroStep", () => { + let level: typeof chalk.level; + + beforeAll(() => { + // ink-testing-library renders at chalk level 0, which drops every SGR + // sequence before `lastFrame()` sees it. What this file is for is + // that brightness reaches the operator as colour, so it has to ask + // for a terminal that has some. + level = chalk.level; + chalk.level = 3; + frames.clear(); + }); + + afterAll(() => { + chalk.level = level; + }); + + it("paints a sky of several brightnesses, in a colour each", () => { + const frame = frameAt(100, 30); + for (const glyph of Object.values(STAR_GLYPHS)) { + expect(strip(frame)).toContain(glyph); + } + const colours = starColours(frame); + // Four tiers, four foregrounds, and none of them left in the + // terminal's default — a sky in one colour is the diagram this + // screen is getting away from. + expect(colours.size).toBe(Object.keys(STAR_GLYPHS).length); + expect(colours.has("")).toBe(false); + }); + + it("paints the wordmark in the text-safe accent, not the fill", () => { + // The wordmark is the product's name — text, so it must clear the + // ramp text clears. `accentSoft` here was the unreadable ~2:1. + const frame = frameAt(100, 30); + const row = frame + .split("\n") + .find((line) => strip(line).includes("\u2584\u2580\u2588 \u2580\u2588\u2580")); + if (row === undefined) throw new Error("no frame line carries the wordmark"); + expect(row).toContain(foregroundSgr(theme.colors.accent)); + expect(row).not.toContain(foregroundSgr(theme.colors.accentSoft)); + }); + + it("still draws the mark, the wordmark and the invitation", () => { + const plain = strip(frameAt(100, 30)); + expect(plain).toContain("█"); + expect(plain).toContain("[ press any key to continue ]"); + expect(plain).toContain("Local AI-First Agent"); + }); + + /** + * Two rows belong to the screen around this block — its own top + * padding and the pinned footer — and `frameAt` already keeps them + * back, so the step must fit inside the viewport it was handed. + * Anything past that paints over the rows above it, because Ink 7 + * overlaps rather than clips — and what it paints over first is the + * footer. + */ + const fitsIn = (columns: number, rows: number): void => { + const lines = strip(frameAt(columns, rows)).split("\n"); + const overflow = Math.max(0, lines.length - (rows - 2)); + expect({ columns, rows, overflow }).toEqual({ columns, rows, overflow: 0 }); + }; + + it("fits its rows, mark and all, at the sizes the flow opens at", () => { + fitsIn(120, 40); + fitsIn(100, 30); + fitsIn(80, 24); + }); + + it("fits them on a terminal too small for the full treatment", () => { + fitsIn(72, 18); + fitsIn(64, 16); + }); + + it("thins the sky rather than dropping it on a small terminal", () => { + const count = (frame: string): number => + [...strip(frame)].filter((glyph) => starTierOfGlyph(glyph)).length; + expect(count(frameAt(72, 18))).toBeGreaterThan(0); + expect(count(frameAt(120, 40))).toBeGreaterThan(count(frameAt(72, 18)) * 2); + }); +}); diff --git a/src/tui/components/onboarding-intro-step.tsx b/src/tui/components/onboarding-intro-step.tsx new file mode 100644 index 00000000..1b2131d7 --- /dev/null +++ b/src/tui/components/onboarding-intro-step.tsx @@ -0,0 +1,211 @@ +import { Box, Text } from "ink"; +import { useMemo, type ReactElement } from "react"; +import { useTypewriter } from "../hooks/use-typewriter.js"; +import { buildIntroArt } from "../onboarding/intro-art.js"; +import type { OnboardingFit } from "../onboarding/onboarding-fit.js"; +import { starTierOfGlyph, type StarTier } from "../onboarding/star-tiers.js"; +import { theme } from "../theme/theme.js"; +import { CROSS_MARKS, FACE_GLYPHS } from "./logo-art.js"; +import { WORDMARK_ROWS, TAGLINE } from "./logo.js"; + +/** Milliseconds per revealed character. ~0.9s for the whole tagline. */ +export const TAGLINE_MS_PER_CHAR = 45; +/** + * Rows the intro spends on everything that is not the sky: two of + * wordmark, the tagline, the "press any key" line and their margins. + * The art gets what is left of the budget it is handed, and nothing + * more — Ink 7 overlaps rather than clips, so one row over budget + * costs the footer. + * + * The pinned footer and the surface's top padding are not counted here + * any more — `OnboardingScreen` takes both off the budget before it + * passes it down. What is left is exactly the rows this component draws + * below the art: three one-row gaps, two rows of wordmark, the tagline, + * and the press-any-key line — seven. The eighth this constant used to + * carry was the surface padding counted a second time, and it cost the + * 60×11 intro its mark: the xs ladder rung needs two rows, and the + * double count left it one. (The sky pays for every row it is given: + * the old ring's blank top and bottom lines measured zero rows in Ink + * and quietly absorbed miscounts like this one — a star field does not.) + */ +export const INTRO_CHROME_ROWS = 7; +/** `ATOMIC` is the first 23 columns of the shipped `ATOMIC AGENT` wordmark. */ +const WORDMARK_ATOMIC_COLUMNS = 23; + +interface SkyTier { + /** Multiplier on the field's designed star density. */ + density: number; + /** Stars in the arc around the mark. Zero drops the arc. */ + halo: number; +} + +/** + * How much sky each size tier gets. A smaller terminal is thinned rather + * than emptied: the count already scales with the canvas, and cutting it + * further is what keeps a cramped screen from reading as noise. + */ +const SKY_BY_TIER: Readonly> = { + full: { density: 1, halo: 26 }, + reduced: { density: 0.75, halo: 14 }, + minimal: { density: 0.5, halo: 0 }, +}; + +const PRESS_ANY_KEY = "[ press any key to continue ]"; +/** + * The no-mark fallback must be this one shared instance: `markRows` sits + * in the sky's `useMemo` dependency list, and a fresh `[]` per render + * would re-run `buildIntroArt` on every tagline tick — the exact churn + * that memo exists to prevent. + */ +const NO_MARK: readonly string[] = []; + +/** + * The first screen of a first run: the mark in a field of stars, the + * wordmark, and the tagline typing itself in. + * + * The animation is a courtesy, not a gate — any key completes it, and a + * second key moves on. Everything but the tagline paints instantly, so + * the screen is legible from frame one. + */ +export function OnboardingIntroStep(props: { + columns: number; + rows: number; + fit: OnboardingFit; + /** True once a key has been pressed: finish the reveal immediately. */ + skipAnimation: boolean; +}): ReactElement { + const { fit } = props; + // The mark is chosen by the rows actually left over, not by the tier + // alone: Ink 7 overlaps rather than clips, so a mark one row too tall + // does not get cropped — it pushes the tagline and the footer off the + // screen and paints over whatever was there. + const budget = props.rows - INTRO_CHROME_ROWS; + const markRows = + fit.tier !== "minimal" && budget >= CROSS_MARKS.block.md.length + ? CROSS_MARKS.block.md + : budget >= CROSS_MARKS.block.sm.length + ? CROSS_MARKS.block.sm + : budget >= CROSS_MARKS.block.xs.length + ? CROSS_MARKS.block.xs + : NO_MARK; + const sky = SKY_BY_TIER[fit.tier]; + const columns = Math.max(20, props.columns); + const rows = Math.max(markRows.length, budget); + // The tagline re-renders this component every few dozen milliseconds. + // The field is seeded, so recomputing it would give the same stars + // back — but there is no reason to redraw a few hundred of them per + // keystroke of an animation. + const art = useMemo( + () => + buildIntroArt({ + columns, + rows, + markRows, + density: sky.density, + haloCount: sky.halo, + }), + [columns, rows, markRows, sky.density, sky.halo], + ); + const { revealed, done } = useTypewriter(TAGLINE, { + active: true, + msPerChar: TAGLINE_MS_PER_CHAR, + skip: props.skipAnimation, + }); + const wordmark = WORDMARK_ROWS.map((row) => + row.slice(0, WORDMARK_ATOMIC_COLUMNS), + ); + + // The art rows already carry their own centring, so the block is laid + // out left-aligned and everything below it is padded to the same + // measure. Centring each row on its own would make them jitter as the + // tagline grows. + const pad = (text: string): string => + " ".repeat(Math.max(0, Math.floor((props.columns - text.length) / 2))) + text; + const cursor = done ? "" : "▌"; + + return ( + + {art.map((row, index) => ( + + ))} + + {/* + The wordmark is read, not looked at — it is the product's name + in letterforms — so it takes the text-safe `accent`. The + `accentSoft` fill lands near 2:1 as ink on a dark page. + */} + {wordmark.map((row, index) => ( + + {pad(row)} + + ))} + + + {/* + Padded by the *finished* tagline's width, so the line is + anchored where it will end up instead of sliding left as each + character lands. + */} + + {`${" ".repeat(Math.max(0, Math.floor((props.columns - TAGLINE.length) / 2)))}${revealed}${cursor}`} + + + {/* + One row of air, not two. The second was the row the footer needed + back once the art started filling its whole budget. + */} + + + {pad(PRESS_ANY_KEY)} + + + + ); +} + +/** A run of the art row: part of the mark, or a star of one brightness. */ +type RunKind = "face" | "depth" | StarTier; + +/** + * One row of the art. Split into runs so each brightness carries its own + * colour and the mark's face and depth keep theirs; the glyph ramp + * underneath encodes the same thing, which is what keeps the sky + * readable with colour stripped. + */ +function ArtRow({ row }: { row: string }): ReactElement { + const runs: { text: string; kind: RunKind }[] = []; + for (const glyph of row) { + const kind: RunKind = + starTierOfGlyph(glyph) ?? (FACE_GLYPHS.has(glyph) ? "face" : "depth"); + const last = runs[runs.length - 1]; + if (last && last.kind === kind) last.text += glyph; + else runs.push({ text: glyph, kind }); + } + return ( + + {runs.map((run, index) => ( + + {run.text} + + ))} + + ); +} + +/** The mark is solid, and the brightest stars are the ones that glare. */ +const BOLD_KINDS: ReadonlySet = new Set(["face", "depth", "bright"]); + +function colorFor(kind: RunKind): string { + switch (kind) { + case "face": + case "bright": + return theme.colors.brandFace; + case "depth": + case "mid": + return theme.colors.brandMark; + case "dim": + return theme.colors.accent; + case "faint": + return theme.colors.accentSoft; + } +} diff --git a/src/tui/components/onboarding-local-pick-step.tsx b/src/tui/components/onboarding-local-pick-step.tsx new file mode 100644 index 00000000..8ad9a30f --- /dev/null +++ b/src/tui/components/onboarding-local-pick-step.tsx @@ -0,0 +1,185 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import { + HUGGING_FACE_ROW_LABEL, + HUGGING_FACE_ROW_NOTE, + type LocalModelPick, +} from "../onboarding/local-model-picks.js"; +import type { OnboardingFit } from "../onboarding/onboarding-fit.js"; +import { handleOnboardingStepKey } from "../onboarding/onboarding-step-keys.js"; +import { ROW_INDENT, rowPrefix } from "../onboarding/onboarding-rows.js"; +import { theme } from "../theme/theme.js"; + +/** Rows drawn at once; the rest are counted in a trailing line. */ +export const LOCAL_PICK_WINDOW = 6; + +/** Model-name column, wide enough for the catalog's longest id plus a gap. */ +const LABEL_COLUMNS = 18; +/** Size column, right-aligned so the numbers compare down the column. */ +const SIZE_COLUMNS = 8; +/** Gap between the size and the note that follows it. */ +const NOTE_GAP = " "; + +function explainerLine(ramGb: number): string { + return `One download, then it runs offline. This machine reports ${ramGb} GB of RAM.`; +} + +function pickRow(pick: LocalModelPick, selected: boolean, fit: OnboardingFit): string { + return ( + `${rowPrefix(selected)}${pick.label.padEnd(LABEL_COLUMNS)}` + + `${pick.sizeLabel.padStart(SIZE_COLUMNS)}${NOTE_GAP}${note(pick, fit)}` + ); +} + +function moreLine(below: number): string { + return `${ROW_INDENT}↓ ${below} more`; +} + +/** + * The rows actually on screen, and how many are left below them. Shared + * with the measure so the block is never sized for a row the list is + * not drawing. + */ +export function windowLocalPicks( + picks: readonly LocalModelPick[], + cursor: number, +): { visible: readonly LocalModelPick[]; below: number; start: number } { + const start = Math.max( + 0, + Math.min(cursor - LOCAL_PICK_WINDOW + 2, picks.length - LOCAL_PICK_WINDOW), + ); + const visible = picks.slice(start, start + LOCAL_PICK_WINDOW); + return { visible, below: picks.length - (start + visible.length), start }; +} + +/** The pinned last row: the door out of the curated set. */ +function huggingFaceRow(selected: boolean, fit: OnboardingFit): string { + const note = fit.rowDetails ? ` ${HUGGING_FACE_ROW_NOTE}` : ""; + return `${rowPrefix(selected)}${HUGGING_FACE_ROW_LABEL}${note}`; +} + +/** Widest line this step draws, for the block that centres it. */ +export function measureOnboardingLocalPickStep(props: { + picks: readonly LocalModelPick[]; + cursor: number; + ramGb: number; + fit: OnboardingFit; +}): number { + const { visible, below } = windowLocalPicks(props.picks, props.cursor); + const lines: string[] = props.fit.explainer ? [explainerLine(props.ramGb)] : []; + lines.push("Recommended models"); + // Measured as if every row were selected: the marker is the same width + // as the blank indent, so this only spares the caller a cursor lookup. + for (const pick of visible) lines.push(pickRow(pick, true, props.fit)); + if (below > 0) lines.push(moreLine(below)); + lines.push(huggingFaceRow(true, props.fit)); + return widestLine(lines); +} + +/** + * Pick a model to download. This used to be the Manage ▸ LLM panel — + * tab strip, `kv —`, `tools 0ok/0err` and a `status: ready` header over + * an install with nothing on disk. What a first run needs from that + * screen is one decision, so this is that decision and nothing else. + * + * The curated list is titled as a recommendation because that is what it + * is; the row under it opens the whole of Hugging Face. That row is + * pinned outside the scrolling window, since an operator who scrolled + * past it would never learn it was there. + */ +export function OnboardingLocalPickStep(props: { + picks: readonly LocalModelPick[]; + /** Index over the picks plus the trailing Hugging Face row. */ + cursor: number; + ramGb: number; + fit: OnboardingFit; +}): ReactElement { + const onHuggingFace = props.cursor >= props.picks.length; + const { visible, below, start } = windowLocalPicks(props.picks, props.cursor); + return ( + + {props.fit.explainer ? ( + + {explainerLine(props.ramGb)} + + ) : null} + Recommended models + {visible.map((pick, index) => { + const selected = !onHuggingFace && start + index === props.cursor; + return ( + // First click selects, second starts the download — the same + // Enter the keyboard sends, through the flow's own key table. + + mouse.dispatch({ + type: "onboarding_cursor_set", + cursor: start + index, + }) + } + onActivate={pressEnter(handleOnboardingStepKey)} + > + + {`${rowPrefix(selected)}${pick.label.padEnd(LABEL_COLUMNS)}${pick.sizeLabel.padStart(SIZE_COLUMNS)}${NOTE_GAP}`} + {note(pick, props.fit)} + + + ); + })} + {below > 0 ? ( + {moreLine(below)} + ) : null} + + // The pinned row sits past the curated picks in cursor space. + mouse.dispatch({ type: "onboarding_cursor_set", cursor: props.picks.length }) + } + onActivate={pressEnter(handleOnboardingStepKey)} + > + + {`${rowPrefix(onHuggingFace)}${HUGGING_FACE_ROW_LABEL}`} + {props.fit.rowDetails ? ( + {` ${HUGGING_FACE_ROW_NOTE}`} + ) : null} + + + + ); +} + +/** + * What the row says after the size. RAM comes before the description + * because it is the part that decides whether the model will run here — + * and because the description is what truncation should eat first. + */ +function note(pick: LocalModelPick, fit: OnboardingFit): string { + const parts: string[] = []; + if (pick.recommended) parts.push("★ recommended"); + parts.push(pick.fit === "over" ? `needs ${pick.ramLabel}` : pick.ramLabel); + if (fit.rowDetails) parts.push(pick.description); + return parts.join(" · "); +} + +/** + * Colour says whether the machine can run it, so the row does not have + * to be read twice: a model over the host's RAM is dimmed to the warn + * tone rather than hidden — an operator who knows their swap situation + * is allowed to pick it. + */ +function noteColour(pick: LocalModelPick): string { + if (pick.fit === "over") return theme.colors.warn; + if (pick.recommended) return theme.colors.success; + return theme.colors.muted; +} diff --git a/src/tui/components/onboarding-mouse.test.tsx b/src/tui/components/onboarding-mouse.test.tsx new file mode 100644 index 00000000..bd15503a --- /dev/null +++ b/src/tui/components/onboarding-mouse.test.tsx @@ -0,0 +1,417 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { render } from "ink-testing-library"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resetConfigCache } from "../../config/index.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import { + createOnboardingState, + type OnboardingHuggingFaceRepo, + type OnboardingStep, +} from "../onboarding/onboarding-state.js"; +import { onboardingPickRows } from "../onboarding/onboarding-step-keys.js"; +import { visibleKindRows } from "../providers/providers-wizard-phases.js"; +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; +import { fakeSession } from "../test-fixtures.js"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { OnboardingScreen } from "./onboarding-screen.js"; + +const STATE_DIR_ENV = "ATOMIC_AGENT_STATE_DIR"; +const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, ""); +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +const GB = 1024 * 1024 * 1024; + +/** A resolved repo for the Hugging Face file picker, two files deep. */ +const HF_REPO: OnboardingHuggingFaceRepo = { + repoId: "unsloth/Qwen3-0.6B-GGUF", + revision: "main", + choices: [ + { + path: "Qwen3-0.6B-Q4_K_M.gguf", + filename: "Qwen3-0.6B-Q4_K_M.gguf", + sizeBytes: 0.38 * GB, + fileSizeGb: 0.38, + sizeLabel: "387 MB", + }, + { + path: "Qwen3-0.6B-Q8_0.gguf", + filename: "Qwen3-0.6B-Q8_0.gguf", + sizeBytes: 0.62 * GB, + fileSizeGb: 0.62, + sizeLabel: "637 MB", + }, + ], + mmproj: null, + hidden: null, +}; + +/** A pull in flight, for the steps that draw a bar. */ +const PULL = { + kind: "chat", + modelId: "gemma-4-e4b", + label: "Gemma 4 E4B", + percent: 61, + transferredBytes: 2_600_000_000, + totalBytes: 4_220_000_000, + error: null, +} as const; + +function mouseEvent(over: Partial): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x: 0, + y: 0, + shift: false, + alt: false, + ctrl: false, + ...over, + }; +} + +interface Mounted { + frame(): string; + actions: TuiAction[]; + pulls: string[]; + registry: MouseTargetRegistry; + stdin: { write(data: string): void }; + unmount(): void; +} + +function mount( + step: OnboardingStep, + over: Partial> = {}, + patchState: (state: TuiState) => void = () => {}, +): Mounted { + const actions: TuiAction[] = []; + const pulls: string[] = []; + const registry = new MouseTargetRegistry(); + const onboarding = { + ...createOnboardingState("http://127.0.0.1:8080"), + step, + ...over, + }; + const state = { ...createInitialTuiState(fakeSession(), 50), onboarding }; + patchState(state); + const dispatch = (action: TuiAction): void => { + actions.push(action); + }; + const callbacks: TuiAppCallbacks = { + onLocalModelsPullRequested: (modelId) => pulls.push(modelId), + }; + const view = render( + state} + > + + , + ); + return { + frame: () => strip(view.lastFrame() ?? ""), + actions, + pulls, + registry, + stdin: view.stdin, + unmount: view.unmount, + }; +} + +/** Screen cell of `label`'s first character, off the rendered frame. */ +function pointOf(view: Mounted, label: string): { x: number; y: number } { + const lines = view.frame().split("\n"); + for (const [y, line] of lines.entries()) { + const x = line.indexOf(label); + if (x !== -1) return { x, y }; + } + throw new Error(`"${label}" is not on screen:\n${view.frame()}`); +} + +/** + * Ink commits on its own throttle and targets register in effects after + * the commit, so the surface is not clickable for a frame or two. + * Row targets and the whole-surface backstop register in the same + * commit (children's effects run first), so the first CLAIMED event + * already saw every target — retries of an unclaimed one cannot land + * twice. + */ +async function sendUntilClaimed( + view: Mounted, + label: string, + over: Partial = {}, +): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const point = pointOf(view, label); + if (view.registry.dispatch(mouseEvent({ ...point, ...over }))) return; + await delay(25); + } + throw new Error(`the surface never claimed an event at "${label}"`); +} + +describe("onboarding mouse", () => { + let stateDir: string; + let originalEnv: string | undefined; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "onboarding-mouse-")); + mkdirSync(stateDir, { recursive: true }); + originalEnv = process.env[STATE_DIR_ENV]; + process.env[STATE_DIR_ENV] = stateDir; + resetConfigCache(); + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env[STATE_DIR_ENV]; + else process.env[STATE_DIR_ENV] = originalEnv; + resetConfigCache(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("choose: a click on an unselected row moves the cursor there", async () => { + const view = mount("choose"); + await sendUntilClaimed(view, "Cloud models"); + expect(view.actions).toEqual([ + { type: "onboarding_cursor_set", cursor: 1 }, + ]); + view.unmount(); + }); + + it("choose: a click on the selected row activates it, exactly like Enter", async () => { + const view = mount("choose", { cursor: 1 }); + await sendUntilClaimed(view, "Cloud models"); + // The same two actions the keyboard's Enter dispatches on this row. + expect(view.actions.map((action) => action.type)).toEqual([ + "providers_wizard_opened", + "onboarding_step_set", + ]); + expect(view.actions[1]).toMatchObject({ step: "cloud" }); + view.unmount(); + }); + + it("choose: a wheel notch walks the list and never reaches the chat behind", async () => { + const view = mount("choose"); + await sendUntilClaimed(view, "Local models", { + kind: "wheel", + button: "none", + wheel: "down", + }); + expect(view.actions).toContainEqual({ type: "onboarding_cursor_moved", delta: 1 }); + // Claimed at the flow's layer, so the app's viewport-wide wheel + // target — the one that scrolls the invisible transcript — is never + // consulted; nothing chat-shaped may leak out of the flow. + expect(view.actions.every((action) => action.type !== "chat_scrolled")).toBe(true); + view.unmount(); + }); + + it("local_pick: clicking the pinned Hugging Face row selects, then opens it", async () => { + const hfIndex = onboardingPickRows().findIndex( + (row) => row.kind === "hugging_face", + ); + const unselected = mount("local_pick", { cursor: 0 }); + await sendUntilClaimed(unselected, "Add a model from Hugging Face"); + expect(unselected.actions).toEqual([ + { type: "onboarding_cursor_set", cursor: hfIndex }, + ]); + unselected.unmount(); + + const selected = mount("local_pick", { cursor: hfIndex }); + await sendUntilClaimed(selected, "Add a model from Hugging Face"); + expect(selected.actions).toContainEqual({ + type: "onboarding_step_set", + step: "local_hf_ref", + }); + selected.unmount(); + }); + + it("wait_or_jump: click selects a row, click again leaves for the agent", async () => { + const select = mount( + "wait_or_jump", + { localModelId: "gemma-4-e4b" }, + (state) => { + state.localModelsPanel = { ...state.localModelsPanel, pull: { ...PULL } }; + }, + ); + await sendUntilClaimed(select, "Add another cloud provider"); + expect(select.actions).toEqual([ + { type: "onboarding_cursor_set", cursor: 1 }, + ]); + select.unmount(); + + const activate = mount( + "wait_or_jump", + { localModelId: "gemma-4-e4b" }, + (state) => { + state.localModelsPanel = { ...state.localModelsPanel, pull: { ...PULL } }; + }, + ); + await sendUntilClaimed(activate, "Start using the agent now"); + expect(activate.actions).toContainEqual({ + type: "onboarding_finished", + outcome: "cloud", + }); + activate.unmount(); + }); + + it("propose_second: the rows answer to clicks like every other list", async () => { + const view = mount("propose_second", { offer: "local" }); + await sendUntilClaimed(view, "Skip — take me to the agent"); + expect(view.actions).toEqual([ + { type: "onboarding_cursor_set", cursor: 1 }, + ]); + view.unmount(); + }); + + it("download: clicking the offer block sends the c it advertises", async () => { + const view = mount( + "local_download", + { localModelId: "gemma-4-e4b" }, + (state) => { + state.localModelsPanel = { ...state.localModelsPanel, pull: { ...PULL } }; + }, + ); + await sendUntilClaimed(view, "Don’t want to wait?"); + expect(view.actions.map((action) => action.type)).toEqual([ + "providers_wizard_opened", + "onboarding_cloud_meanwhile_opened", + ]); + view.unmount(); + }); + + it("download: clicking the skip row sends the s it advertises", async () => { + const view = mount( + "local_download", + { localModelId: "gemma-4-e4b" }, + (state) => { + state.localModelsPanel = { ...state.localModelsPanel, pull: { ...PULL } }; + }, + ); + await sendUntilClaimed(view, "Or skip the wait"); + // The click reaches the step-key router as a plain `s`, so it + // dispatches exactly what the keyboard test pins for that key. + expect(view.actions).toEqual([ + { type: "onboarding_finished", outcome: "local", skipSecondOffer: true }, + ]); + view.unmount(); + }); + + it("download: a wheel notch is claimed and dropped — no list, no chat scroll", async () => { + const view = mount( + "local_download", + { localModelId: "gemma-4-e4b" }, + (state) => { + state.localModelsPanel = { ...state.localModelsPanel, pull: { ...PULL } }; + }, + ); + await sendUntilClaimed(view, "Downloading", { + kind: "wheel", + button: "none", + wheel: "down", + }); + expect(view.actions).toEqual([]); + view.unmount(); + }); + + it("cloud: the wizard's rows select on one click and activate on the second", async () => { + const second = visibleKindRows(null)[1]; + if (!second) throw new Error("the kind list has fewer than two rows"); + const select = mount("cloud", {}, (state) => { + state.providersPanel = { + ...state.providersPanel, + wizard: createProvidersWizardState("add"), + }; + }); + await sendUntilClaimed(select, second.label); + expect(select.actions).toHaveLength(1); + const updated = select.actions[0]; + if (updated?.type !== "providers_wizard_updated") { + throw new Error(`expected a wizard update, got ${updated?.type}`); + } + expect(updated.wizard.cursor).toBe(1); + select.unmount(); + + const first = visibleKindRows(null)[0]; + if (!first) throw new Error("the kind list is empty"); + const activate = mount("cloud", {}, (state) => { + state.providersPanel = { + ...state.providersPanel, + wizard: createProvidersWizardState("add"), + }; + }); + await sendUntilClaimed(activate, first.label); + // Enter on the selected kind row advances the wizard — the same + // routing the keyboard uses, so the phase moves off pick_kind. + const advanced = activate.actions.find( + (action) => action.type === "providers_wizard_updated", + ); + if (!advanced || advanced.type !== "providers_wizard_updated") { + throw new Error("the click did not reach the wizard's Enter"); + } + expect(advanced.wizard.phase).not.toBe("pick_kind"); + activate.unmount(); + }); + + it("hf_pick: a click on an unselected file row moves the cursor there", async () => { + const view = mount("local_hf_pick", { hfRepo: HF_REPO, cursor: 0 }); + await sendUntilClaimed(view, "Qwen3-0.6B-Q8_0.gguf"); + expect(view.actions).toEqual([{ type: "onboarding_cursor_set", cursor: 1 }]); + expect(view.pulls).toEqual([]); + view.unmount(); + }); + + it("hf_pick: a click on the selected row writes the catalog entry and pulls", async () => { + const view = mount("local_hf_pick", { hfRepo: HF_REPO, cursor: 0 }); + await sendUntilClaimed(view, "Qwen3-0.6B-Q4_K_M.gguf"); + // The same effects the router test asserts for Enter on this step: + // the catalog write's minted id is dispatched, and the pull it is + // handed to is the one the curated rows use. + const picked = view.actions.find( + (action) => action.type === "onboarding_local_model_picked", + ); + expect(picked).toBeDefined(); + expect(view.pulls).toHaveLength(1); + expect(view.pulls[0]).toContain("custom"); + view.unmount(); + }); + + it("hf_ref: [ clear ] empties the buffer and the error in one click", async () => { + const view = mount("local_hf_ref", { + hfReference: "owner/repo", + error: "Hugging Face returned 404: no repo or revision by that name.", + }); + await sendUntilClaimed(view, "[ clear ]"); + expect(view.actions).toEqual([ + { type: "onboarding_hf_reference_changed", value: "" }, + { type: "onboarding_error_set", error: null }, + ]); + view.unmount(); + }); + + it("hf_ref: ctrl+l clears through the same handler as the click", async () => { + const view = mount("local_hf_ref", { hfReference: "owner/repo" }); + view.stdin.write("\f"); + await delay(60); + expect(view.actions).toContainEqual({ + type: "onboarding_hf_reference_changed", + value: "", + }); + expect(view.actions).toContainEqual({ type: "onboarding_error_set", error: null }); + view.unmount(); + }); +}); diff --git a/src/tui/components/onboarding-propose-step.test.tsx b/src/tui/components/onboarding-propose-step.test.tsx new file mode 100644 index 00000000..57344e29 --- /dev/null +++ b/src/tui/components/onboarding-propose-step.test.tsx @@ -0,0 +1,37 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { OnboardingProposeStep } from "./onboarding-propose-step.js"; + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); + +describe("OnboardingProposeStep", () => { + it("offers local to a cloud operator, and says what it buys them", () => { + const view = render( + , + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("Cloud model ready"); + expect(frame).toContain("Set up local models too"); + expect(frame).toContain("offline"); + expect(frame).toContain("Skip"); + }); + + it("mirrors for a local operator", () => { + const view = render( + , + ); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("Local model ready"); + expect(frame).toContain("Set up a cloud model too"); + }); + + it("points the cursor at the row it is on", () => { + const view = render( + , + ); + const lines = strip(view.lastFrame() ?? "").split("\n"); + const skip = lines.find((line) => line.includes("Skip")); + expect(skip?.trimStart().startsWith("\u203a")).toBe(true); + }); +}); diff --git a/src/tui/components/onboarding-propose-step.tsx b/src/tui/components/onboarding-propose-step.tsx new file mode 100644 index 00000000..6037bec2 --- /dev/null +++ b/src/tui/components/onboarding-propose-step.tsx @@ -0,0 +1,115 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import { handleOnboardingStepKey } from "../onboarding/onboarding-step-keys.js"; +import { ROW_INDENT, rowPrefix } from "../onboarding/onboarding-rows.js"; +import type { SecondBackendOffer } from "../onboarding/propose-second-backend.js"; +import { theme } from "../theme/theme.js"; + +/** Hand-wrapped, so the measured block width matches what is drawn. */ +const EXPLAINER: readonly string[] = [ + "atomic-agent runs both side by side — local for private or offline work,", + "cloud for the heavy turns, switchable mid-session. You have one of the two.", +]; + +const SKIP_ROW = { + label: "Skip — take me to the agent", + detail: "you can add it later from the menu (ctrl+p)", +} as const; + +function acceptRow(offer: NonNullable): { + label: string; + detail: string; +} { + return offer === "local" + ? { + label: "Set up local models too", + detail: "one download, then it runs offline and costs nothing per token", + } + : { + label: "Set up a cloud model too", + detail: "an API key and a model — about a minute, for the heavy turns", + }; +} + +/** Widest line this step draws, for the block that centres it. */ +export function measureOnboardingProposeStep(props: { + offer: NonNullable; + configuredLabel: string; +}): number { + const rows = [acceptRow(props.offer), SKIP_ROW]; + return widestLine([ + `${theme.glyphs.check} ${props.configuredLabel}`, + ...EXPLAINER, + ...rows.flatMap((row) => [ + `${ROW_INDENT}${row.label}`, + `${ROW_INDENT}${row.detail}`, + ]), + ]); +} + +/** + * "You have one — want the other too?", shown once, after the first + * backend actually works. + * + * The pitch is the product's actual shape: local and cloud are not + * alternatives here, they run side by side and switch mid-session. An + * operator who set up one usually does not know that. + */ +export function OnboardingProposeStep(props: { + offer: NonNullable; + configuredLabel: string; + cursor: number; +}): ReactElement { + const accept = acceptRow(props.offer); + return ( + + + {`${theme.glyphs.check} `} + {props.configuredLabel} + + + {EXPLAINER.map((line) => ( + + {line} + + ))} + + + + + ); +} + +function Row(props: { + selected: boolean; + /** This row's place in the two-row cursor space, for click-to-select. */ + index: number; + label: string; + detail: string; +}): ReactElement { + return ( + // First click selects, second activates — the same Enter the + // keyboard sends, through the flow's own key table. + + mouse.dispatch({ type: "onboarding_cursor_set", cursor: props.index }) + } + onActivate={pressEnter(handleOnboardingStepKey)} + > + + + {`${rowPrefix(props.selected)}${props.label}`} + + {`${ROW_INDENT}${props.detail}`} + + + ); +} diff --git a/src/tui/components/onboarding-screen.test.tsx b/src/tui/components/onboarding-screen.test.tsx new file mode 100644 index 00000000..2b3069b6 --- /dev/null +++ b/src/tui/components/onboarding-screen.test.tsx @@ -0,0 +1,550 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { render } from "ink-testing-library"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { OnboardingScreen } from "./onboarding-screen.js"; +import { getConfig, resetConfigCache } from "../../config/index.js"; +import { ROOT_PADDING_LEFT } from "../layout.js"; +import { createOnboardingState } from "../onboarding/onboarding-state.js"; +import { decideSecondBackendOffer } from "../onboarding/propose-second-backend.js"; +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; +import type { ProvidersWizardState } from "../providers/providers-wizard-state.js"; +import { createInitialTuiState } from "../tui-state.js"; +import type { TuiAction } from "../tui-action.js"; +import { fakeSession } from "../test-fixtures.js"; +import { renderAtSize } from "../test-sized-render.js"; + +vi.mock("../../llm/llama-server-health.js", () => ({ + checkLlamaServer: vi.fn(async () => ({ + reachable: false, + status: null, + kind: "unknown", + error: "connect ECONNREFUSED 127.0.0.1:8080", + latencyMs: 1, + })), +})); + +const STATE_DIR_ENV = "ATOMIC_AGENT_STATE_DIR"; +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); +const ESCAPE_KEY = "\u001b"; + +/** A pull in flight, so the wait-or-jump step has a bar to draw. */ +const PULL = { + kind: "chat", + modelId: "gemma-4-e4b", + label: "Gemma 4 E4B", + percent: 61, + transferredBytes: 2_600_000_000, + totalBytes: 4_220_000_000, + error: null, +} as const; + +type Step = + | "intro" + | "choose" + | "local_pick" + | "custom_chat_url" + | "propose_second" + | "wait_or_jump"; + +interface FlowOptions { + ctrlCArmed?: boolean; + cursor?: number; + /** The wait-or-jump screens read the pull off the panel state. */ + panel?: { pull?: typeof PULL | null; errorLine?: string | null }; +} + +function flowElement( + step: Step, + actions: TuiAction[], + options: FlowOptions = {}, + pullRequests: string[] = [], +) { + const onboarding = { + ...createOnboardingState("http://127.0.0.1:8080"), + step, + offer: "local" as const, + cursor: options.cursor ?? 0, + localModelId: step === "wait_or_jump" ? "gemma-4-e4b" : null, + }; + const base = createInitialTuiState(fakeSession(), 50); + const panel = options.panel; + const state = { + ...base, + localModelsPanel: + panel === undefined + ? base.localModelsPanel + : { + ...base.localModelsPanel, + pull: panel.pull === undefined ? PULL : panel.pull, + errorLine: panel.errorLine ?? null, + }, + onboarding, + }; + return ( + actions.push(action)} + callbacks={{ + onLocalModelsPullRequested: (modelId) => pullRequests.push(modelId), + }} + {...(options.ctrlCArmed === undefined + ? {} + : { ctrlCArmed: options.ctrlCArmed })} + /> + ); +} + +function renderFlow(step: Step = "choose", options: FlowOptions = {}) { + const actions: TuiAction[] = []; + const pullRequests: string[] = []; + const view = render(flowElement(step, actions, options, pullRequests)); + return { view, actions, pullRequests }; +} + +/** The same flow in a terminal whose stdout reports both dimensions. */ +function renderFlowAt(step: Step, size: { columns: number; rows: number }) { + const actions: TuiAction[] = []; + const view = renderAtSize(flowElement(step, actions), size); + return { view, actions }; +} + +/** + * One size per tier, plus the exact 100×16 the review broke the block + * at: `useTerminalSize` falls back to 80×24 when stdout carries no + * `rows`, so every ink-testing-library frame is the reduced tier and a + * minimal-tier defect is invisible to it by construction. + */ +const SIZES = [ + { name: "full 120×34", columns: 120, rows: 34 }, + { name: "reduced 100×24", columns: 100, rows: 24 }, + { name: "minimal 100×16", columns: 100, rows: 16 }, + { name: "minimal 60×20", columns: 60, rows: 20 }, +] as const; + +/** + * The cloud step is the providers wizard mounted inside the onboarding + * frame — the step machine only opens it together with a wizard state, + * so the render mirrors that pairing. + */ +function renderCloud(wizard: ProvidersWizardState) { + const onboarding = { + ...createOnboardingState("http://127.0.0.1:8080"), + step: "cloud" as const, + }; + const state = { ...createInitialTuiState(fakeSession(), 50), onboarding }; + state.providersPanel = { ...state.providersPanel, wizard }; + return render( + {}} + callbacks={{}} + />, + ); +} + +// Effects fire after commit, so a persisted side effect is awaited by +// polling config — never by trusting how fast a frame landed. +async function untilStamped(read: () => boolean, timeoutMs = 1000): Promise { + const start = Date.now(); + while (!read()) { + if (Date.now() - start > timeoutMs) throw new Error("stamp never persisted"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe("OnboardingScreen", () => { + let stateDir: string; + let originalEnv: string | undefined; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "onboarding-screen-")); + mkdirSync(stateDir, { recursive: true }); + originalEnv = process.env[STATE_DIR_ENV]; + process.env[STATE_DIR_ENV] = stateDir; + resetConfigCache(); + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env[STATE_DIR_ENV]; + else process.env[STATE_DIR_ENV] = originalEnv; + resetConfigCache(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("draws the three choices, the brand lockup and nothing of the app chrome", () => { + const { view } = renderFlow(); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("atomic"); + expect(frame).toContain("setup \u00b7 step 1 of 2"); + expect(frame).toContain("Local models"); + expect(frame).toContain("Cloud models"); + expect(frame).toContain("Custom endpoint"); + // What each one costs the operator, on the screen rather than behind it. + expect(frame).toContain("Private, free per token"); + expect(frame).toContain("needs an API key"); + expect(frame).toContain("Nothing is downloaded"); + // The copy describes a choice, not the health probe that used to + // bring this screen up. + expect(frame).not.toContain("not reachable"); + expect(frame).not.toContain("ECONNREFUSED"); + // The chrome the flow deliberately does not borrow. + expect(frame).not.toContain("R U N"); + expect(frame).not.toContain("SESSIONS"); + expect(frame).not.toContain("Ask anything"); + }); + + it("keeps the hint strip as the last row", () => { + const { view } = renderFlow(); + const lines = strip(view.lastFrame() ?? "").split("\n"); + const last = lines.filter((line) => line.trim().length > 0).at(-1) ?? ""; + expect(last).toContain("move"); + expect(last).toContain("ctrl+c quit"); + }); + + it("mirrors chat's armed Ctrl+C hint so the first press is visible", () => { + // Same flip the chat hint strip makes: without it the first press + // looks like a no-op, the second lands after the window disarmed, + // and "ctrl+c quit" reads as broken during setup. + const idle = renderFlow("intro"); + expect(strip(idle.view.lastFrame() ?? "")).toContain("ctrl+c quit"); + const armed = renderFlow("intro", { ctrlCArmed: true }); + const frame = strip(armed.view.lastFrame() ?? ""); + expect(frame).toContain("ctrl+c press again to quit"); + expect(frame).not.toContain("ctrl+c quit"); + }); + + for (const size of SIZES) { + it(`centres the block on both axes at ${size.name}, hints pinned to the last row`, () => { + const { view } = renderFlowAt("choose", size); + const lines = strip(view.lastFrame() ?? "").split("\n"); + view.unmount(); + // The frame is exactly the terminal: a taller one means the block + // outgrew the measure and pushed the strip off the last row. + expect(lines.length).toBe(size.rows); + // "ctrl+c", not "ctrl+c quit": the surface draws the root inset + // itself now, and at 60 columns those two cells truncate the + // strip's tail. The pinning is what this asserts, not the copy. + expect(lines.at(-1)).toContain("ctrl+c"); + const body = lines.slice(0, -1); + const drawn = body + .map((line, index) => ({ line, index })) + .filter((row) => row.line.trim().length > 0); + // Every tier keeps all three choices on screen. + const frame = body.join("\n"); + expect(frame).toContain("Local models"); + expect(frame).toContain("Cloud models"); + expect(frame).toContain("Custom endpoint"); + // The block's own left edge, not the widest line's: the widest + // line is often an option row, and its three-cell marker column is + // part of the block rather than space around it. The surface now + // draws the root inset itself, so the frame's leading whitespace + // is the whole story. + const leading = Math.min( + ...drawn.map((row) => row.line.length - row.line.trimStart().length), + ); + const width = + Math.max(...drawn.map((row) => row.line.trimEnd().length)) - leading; + const balance = (size.columns - width) / 2; + expect(Math.abs(leading - balance)).toBeLessThanOrEqual(1); + // One row of the gap above is the surface's own top padding, and + // the gap below carries the last option row's bottom margin, so + // the two halves land within a row of each other rather than dead + // equal. + const above = (drawn[0]?.index ?? 1) - 1; + const below = body.length - 1 - (drawn.at(-1)?.index ?? 0); + expect(Math.abs(above - below)).toBeLessThanOrEqual(1); + }); + } + + it("centres the offer screen horizontally too", () => { + const { view } = renderFlowAt("propose_second", { columns: 120, rows: 30 }); + const lines = strip(view.lastFrame() ?? "").split("\n"); + view.unmount(); + expect(lines.at(-1)).toContain("ctrl+c quit"); + const drawn = lines.slice(0, -1).filter((line) => line.trim().length > 0); + const leading = Math.min( + ...drawn.map((line) => line.length - line.trimStart().length), + ); + const width = Math.max(...drawn.map((line) => line.trimEnd().length)) - leading; + const balance = (120 - width) / 2; + expect(Math.abs(leading - balance)).toBeLessThanOrEqual(1); + }); + + it("keeps each minimal-tier option to one row: the measure and the render agree", () => { + // The regression the review caught: the un-detailed rows padded out + // to the detail column, the measure trimmed the pads, and Ink wrapped + // the invisible cells — one blank row per option became two and the + // block ran 50% taller than measured. + const { view } = renderFlowAt("choose", { columns: 100, rows: 16 }); + const lines = strip(view.lastFrame() ?? "").split("\n"); + view.unmount(); + const optionRows = ["Local models", "Cloud models", "Custom endpoint"].map( + (label) => lines.findIndex((line) => line.includes(label)), + ); + expect(optionRows[1]).toBe((optionRows[0] ?? 0) + 2); + expect(optionRows[2]).toBe((optionRows[1] ?? 0) + 2); + }); + + it("collapses the spacers instead of dropping options when the block barely fits", () => { + // 10 rows leave an 8-row viewport for a 9-row block: the spacers go + // to zero and only the block's own trailing margin is clipped. Before + // the fix the wrapped pads pushed the third option past the viewport. + const { view } = renderFlowAt("choose", { columns: 100, rows: 10 }); + const lines = strip(view.lastFrame() ?? "").split("\n"); + view.unmount(); + expect(lines.length).toBe(10); + expect(lines.at(-1)).toContain("ctrl+c quit"); + const frame = lines.join("\n"); + expect(frame).toContain("Local models"); + expect(frame).toContain("Cloud models"); + expect(frame).toContain("Custom endpoint"); + }); + + it("names the step it is on", () => { + const { view } = renderFlow("custom_chat_url"); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("custom endpoint \u00b7 step 2 of 2"); + expect(frame).toContain("must answer GET /health"); + }); + + it("treats Esc as a recorded skip rather than a silent one", async () => { + const { view, actions } = renderFlow(); + view.stdin.write(ESCAPE_KEY); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(actions).toContainEqual({ type: "onboarding_finished", outcome: "skipped" }); + }); + + it("opens on the splash: mark, wordmark, tagline and the promise it makes", () => { + const { view } = renderFlow("intro"); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("\u2588"); // the mark + expect(frame).toContain("press any key to continue"); + // The wordmark's first row, `ATOMIC` only — not `ATOMIC AGENT`. + expect(frame).toContain("\u2584\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588"); + expect(frame).not.toContain("setup \u00b7 step 1 of 2"); + }); + + it("takes two keys off the splash: one to finish the reveal, one to move on", async () => { + const { view, actions } = renderFlow("intro"); + view.stdin.write("x"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(actions).toEqual([]); + view.stdin.write("x"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(actions).toContainEqual({ type: "onboarding_step_set", step: "choose" }); + }); + + it("does not let Esc skip setup from a screen that has not offered it yet", async () => { + const { view, actions } = renderFlow("intro"); + view.stdin.write(ESCAPE_KEY); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(actions).not.toContainEqual({ type: "onboarding_finished", outcome: "skipped" }); + }); + + it("fits the provider list plus its search line in 24 rows, footer intact", () => { + // ink-testing-library's stdout is not a TTY, so useTerminalSize + // reports the 80x24 fallback — exactly the terminal the pick box + // outgrew by one row when the always-drawn search line was added on + // top of the fixed 12-option viewport instead of inside it. + const view = renderCloud(createProvidersWizardState("add")); + const lines = strip(view.lastFrame() ?? "").split("\n"); + // The search line is on screen… + expect(lines.join("\n")).toContain("/ to search"); + // …and the whole stack still fits the 24-row terminal, so the + // spacer-pinned footer survives as the bottom row. + expect(lines.length).toBeLessThanOrEqual(24); + const last = lines.filter((line) => line.trim().length > 0).at(-1) ?? ""; + expect(last).toContain("/ search"); + expect(last).toContain("ctrl+c quit"); + }); + + it("advertises / search only on the wizard's list screens", () => { + const wizard = { + ...createProvidersWizardState("add", { kind: "openrouter" }), + phase: "api_key" as const, + }; + const view = renderCloud(wizard); + const frame = strip(view.lastFrame() ?? ""); + // On the key screen `/` is just a character typed into the buffer. + expect(frame).toContain("API key"); + expect(frame).not.toContain("/ search"); + expect(frame).toContain("ctrl+c quit"); + }); + + describe("the almost-there screen", () => { + it("draws the bar it promises and drops the row that only waits", () => { + const { view } = renderFlow("wait_or_jump", { panel: {} }); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("almost there"); + expect(frame).toContain("Still downloading gemma-4-e4b"); + expect(frame).toContain("61%"); + expect(frame).toContain("2.6 GB / 4.2 GB"); + expect(frame).toContain("\u2588"); + expect(frame).toContain("Start using the agent now"); + expect(frame).toContain("Add another cloud provider"); + expect(frame).not.toContain("Wait here"); + const last = frame.split("\n").filter((line) => line.trim().length > 0).at(-1) ?? ""; + expect(last).toContain("start or add a provider"); + }); + + it("leaves for the agent on the first row", async () => { + const { view, actions } = renderFlow("wait_or_jump", { panel: {} }); + view.stdin.write("\r"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(actions).toContainEqual({ type: "onboarding_finished", outcome: "cloud" }); + }); + + it("opens the providers wizard again on the second row", async () => { + const { view, actions } = renderFlow("wait_or_jump", { cursor: 1, panel: {} }); + view.stdin.write("\r"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(actions.map((action) => action.type)).toEqual([ + "providers_wizard_opened", + "onboarding_cloud_meanwhile_opened", + ]); + }); + + it("moves between the two rows", async () => { + const { view, actions } = renderFlow("wait_or_jump", { panel: {} }); + view.stdin.write("j"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(actions).toContainEqual({ + type: "onboarding_cursor_moved", + delta: 1, + length: 2, + }); + }); + + it("says the local model landed once the pull is gone without an error", () => { + const { view } = renderFlow("wait_or_jump", { panel: { pull: null } }); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("local model is ready"); + expect(frame).not.toContain("Still downloading"); + expect(frame).not.toContain("starting"); + expect(frame).not.toContain("waiting"); + }); + + it("offers a third row after a failed pull, and enter on it re-runs the pull", async () => { + const { view, actions, pullRequests } = renderFlow("wait_or_jump", { + cursor: 2, + panel: { pull: null, errorLine: "connection reset" }, + }); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("download failed"); + expect(frame).toContain("connection reset"); + expect(frame).toContain("Retry the download"); + view.stdin.write("j"); + await new Promise((resolve) => setTimeout(resolve, 30)); + // Three rows now, and the keyboard knows it. + expect(actions).toContainEqual({ + type: "onboarding_cursor_moved", + delta: 1, + length: 3, + }); + view.stdin.write("\r"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(pullRequests).toEqual(["gemma-4-e4b"]); + }); + }); + + it("moves the cursor on a keypress", async () => { + const { view, actions } = renderFlow(); + view.stdin.write("j"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(actions).toContainEqual({ type: "onboarding_cursor_moved", delta: 1 }); + }); + + describe("the download screen's skip exit", () => { + /** + * The finished step with the bypass flag as the skip exit leaves it: + * outcome local, no cloud provider on disk (fresh state dir), so + * `decideSecondBackendOffer` WOULD pitch cloud — the flag is the + * only thing standing between the operator and a second pitch. + */ + function renderFinished(skipSecondOffer: boolean) { + const actions: TuiAction[] = []; + const onboarding = { + ...createOnboardingState("http://127.0.0.1:8080"), + step: "finished" as const, + outcome: "local" as const, + localModelId: "gemma-4-e4b", + skipSecondOffer, + }; + const state = { ...createInitialTuiState(fakeSession(), 50), onboarding }; + const view = render( + actions.push(action)} + callbacks={{}} + />, + ); + return { view, actions }; + } + + it("closes straight to the agent: completed, no second pitch, no stamp", async () => { + const { actions } = renderFinished(true); + // Skip = completing setup with a download in flight, not + // abandoning it — so the flow stamps completedAt, not skippedAt. + await untilStamped(() => getConfig().tui.onboarding.completedAt !== null); + expect(actions).toContainEqual({ type: "onboarding_set", onboarding: null }); + expect( + actions.every((action) => action.type !== "onboarding_second_backend_offered"), + ).toBe(true); + // The bypass must not masquerade as "the offer was made": the + // propose screen was never shown, so its stamp stays unset. + expect(getConfig().tui.onboarding.proposedSecondBackendAt).toBeNull(); + expect(getConfig().tui.onboarding.skippedAt).toBeNull(); + }); + + it("a plain local finish still gets the cloud pitch", async () => { + const { actions } = renderFinished(false); + await untilStamped( + () => getConfig().tui.onboarding.proposedSecondBackendAt !== null, + ); + expect(actions).toContainEqual({ + type: "onboarding_second_backend_offered", + offer: "cloud", + }); + }); + }); + + it("stamps localSetupSeenAt the moment the model list is reached", async () => { + expect(getConfig().tui.onboarding.localSetupSeenAt).toBeNull(); + renderFlow("local_pick"); + await untilStamped(() => getConfig().tui.onboarding.localSetupSeenAt !== null); + // The stamp is the exact input the next decision reads: with it, + // the "set up local models too" pitch stays away for good. + expect( + decideSecondBackendOffer({ + outcome: "cloud", + cloudReady: true, + localReady: false, + alreadyProposed: false, + localSetupSeen: getConfig().tui.onboarding.localSetupSeenAt !== null, + }), + ).toBeNull(); + }); + + it("leaves localSetupSeenAt null off the local branch, so the offer stands", async () => { + renderFlow("choose"); + // Long enough for the stamping effect to have fired were it going to. + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(getConfig().tui.onboarding.localSetupSeenAt).toBeNull(); + expect( + decideSecondBackendOffer({ + outcome: "cloud", + cloudReady: true, + localReady: false, + alreadyProposed: false, + localSetupSeen: getConfig().tui.onboarding.localSetupSeenAt !== null, + }), + ).toBe("local"); + }); +}); diff --git a/src/tui/components/onboarding-screen.tsx b/src/tui/components/onboarding-screen.tsx new file mode 100644 index 00000000..7354598e --- /dev/null +++ b/src/tui/components/onboarding-screen.tsx @@ -0,0 +1,285 @@ +import { Box, Text } from "ink"; +import { useCallback, useMemo, type ReactElement } from "react"; +import { isCloudTextProviderReady } from "../local-backend-readiness.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { ROOT_PADDING_LEFT } from "../layout.js"; +import { useOnboardingInputs } from "../hooks/use-onboarding-inputs.js"; +import { useOnboardingLifecycle } from "../hooks/use-onboarding-lifecycle.js"; +import { useOnboardingUrlActions } from "../hooks/use-onboarding-url-actions.js"; +import { + buildLocalModelPicks, + buildLocalPickRows, + describeDownloadingModel, + hostRamGb, + orderLocalModelPicks, +} from "../onboarding/local-model-picks.js"; +import { + ONBOARDING_SUBTITLES, + onboardingFooterFor, +} from "../onboarding/onboarding-chrome.js"; +import { + computeOnboardingFit, + ONBOARDING_SIZE_ADVICE, +} from "../onboarding/onboarding-fit.js"; +import { handleOnboardingStepKey } from "../onboarding/onboarding-step-keys.js"; +import { useIntroInput } from "../onboarding/use-intro-input.js"; +import { arrowKey } from "../mouse/synthetic-key.js"; +import type { + OnboardingOutcome, + OnboardingUiState, +} from "../onboarding/onboarding-state.js"; +import { persistOnboardingState } from "../persist-onboarding-state.js"; +import { theme } from "../theme/theme.js"; +import type { TuiAction } from "../tui-action.js"; +import type { LocalModelId } from "../../local-llm/index.js"; +import type { TuiState } from "../tui-state.js"; +import { OnboardingDownloadAmbient } from "./onboarding-download-ambient.js"; +import { OnboardingStepBody } from "./onboarding-step-body.js"; +import { + layOutOnboardingSurface, + SURFACE_PADDING_TOP, +} from "./onboarding-surface-layout.js"; + +export interface OnboardingScreenCallbacks { + /** Verify + save + hot-swap the cloud provider (the panel's own path). */ + onProvidersWizardSubmit?( + wizard: import("../providers/providers-wizard-state.js").ProvidersWizardState, + ): void; + onProvidersWizardSubmitCancel?(): void; + /** Reload the runtime's providers once the flow has written config. */ + onOnboardingFinished?(outcome: OnboardingOutcome): void; + /** Report the screen the first-run flow just reached (analytics). */ + onOnboardingStep?(step: string, outcome?: string): void; + /** Start a model pull. Owned by `LocalModelsOrchestrator`. */ + onLocalModelsPullRequested?(modelId: LocalModelId): void; +} + +/** Named once — the offer screens quote it back at the operator. */ +const CLOUD_READY_LABEL = "Cloud model ready"; + + +/** + * The whole first-run surface. It owns the terminal while it is mounted: + * no status bar, no rail, no composer, no hint strip but its own, and + * that strip is pinned to the real last row rather than trailing the + * content. + * + * The screen itself is the flow's shell — placement and the footer. The + * keys live in `useOnboardingInputs`, the endpoint writes in + * `useOnboardingUrlActions`, the persistence effects in + * `useOnboardingLifecycle`, and the step switch in `OnboardingStepBody`; + * the reducer stays pure so the step machine can be tested as a table. + */ +export function OnboardingScreen(props: { + state: TuiState; + onboarding: OnboardingUiState; + dispatch(action: TuiAction): void; + callbacks: OnboardingScreenCallbacks; + /** + * Whether the app-level Ctrl+C quit chord is armed. The flow draws its + * own footer instead of the chat hint strip, so it must make the same + * "press again to quit" promise the strip makes — without it the first + * press looks like it did nothing and the second lands after the + * 1.5s window has disarmed it, which reads as "Ctrl+C is broken". + */ + ctrlCArmed?: boolean; +}): ReactElement { + const { onboarding, dispatch, callbacks } = props; + const size = useTerminalSize(); + const fit = computeOnboardingFit(size); + const ramGb = useMemo(() => hostRamGb(), []); + // Read once per step change rather than per render: it only moves when + // the flow itself writes config. + const cloudAlreadyConfigured = useMemo( + () => isCloudTextProviderReady(), + [onboarding.step], + ); + + const dismissIntro = useCallback(() => { + // Recorded as it is dismissed, not at the end of the flow: an + // operator who quits at the backend choice has still seen the + // splash, and a later release may want to know that. + persistOnboardingState({ introSeenAt: new Date().toISOString() }); + dispatch({ type: "onboarding_step_set", step: "choose" }); + }, [dispatch]); + // The splash answers to keys, clicks, the wheel and pastes alike, so + // all four live in one hook rather than in the flow's key hook. The + // same hook keeps the whole-surface mouse target registered on every + // step; a wheel notch outside the splash walks the current list + // through the same key table the arrows use. + const intro = useIntroInput({ + onboarding, + onDismiss: dismissIntro, + onSurfaceWheel: (direction) => + handleOnboardingStepKey("", arrowKey(direction), { + state: props.state, + dispatch, + callbacks, + }), + }); + + const finish = useCallback( + (outcome: OnboardingOutcome) => { + dispatch({ type: "onboarding_finished", outcome }); + }, + [dispatch], + ); + + const picks = useMemo( + () => orderLocalModelPicks(buildLocalModelPicks(ramGb)), + [ramGb], + ); + const pickRows = useMemo(() => buildLocalPickRows(picks), [picks]); + const pickCursor = onboarding.cursor % Math.max(1, pickRows.length); + const wizardState = props.state.providersPanel.wizard; + + useOnboardingInputs({ state: props.state, dispatch, callbacks }); + const { probeAndAdvance, saveEmbeddingUrl } = useOnboardingUrlActions({ + onboarding, + dispatch, + finish, + }); + + useOnboardingLifecycle({ + onboarding, + dispatch, + ...(callbacks.onOnboardingFinished === undefined + ? {} + : { onFinished: callbacks.onOnboardingFinished }), + ...(callbacks.onOnboardingStep === undefined + ? {} + : { onStep: callbacks.onOnboardingStep }), + }); + + // Both axes are centred on the block as a whole, never line by line: + // a column of options whose rows each find their own centre is ragged + // to scan, and every row would move whenever its text changed. + // The Hugging Face file list runs its own cursor over the repo's + // choices; every other list shares the pick rows' modulus. + const blockCursor = + onboarding.step === "local_hf_pick" && onboarding.hfRepo + ? onboarding.cursor % Math.max(1, onboarding.hfRepo.choices.length) + : pickCursor; + const placement = layOutOnboardingSurface({ + columns: size.columns, + rows: size.rows, + step: onboarding.step, + fit, + subtitle: ONBOARDING_SUBTITLES[onboarding.step], + picks, + cursor: blockCursor, + ramGb, + offer: onboarding.offer, + configuredLabel: configuredLabel(onboarding.outcome), + modelLabel: describeDownloadingModel(onboarding.localModelId), + offerCloudMeanwhile: !cloudAlreadyConfigured, + pull: props.state.localModelsPanel.pull, + pullError: props.state.localModelsPanel.errorLine, + cloudLabel: CLOUD_READY_LABEL, + hfRepo: onboarding.hfRepo, + hfError: onboarding.step === "local_hf_ref" ? onboarding.error : null, + }); + + return ( + // The root gutter is padding on THIS box, not on the app frame the + // screen mounts into: padding sits inside the border box, so the + // splash's mouse target measures the full terminal width and a + // click in the two inset columns counts like any other. + + {/* + Two spacers rather than `justifyContent="center"`: flex hands out + free space only when there is some, so a step taller than the + budget collapses them and starts at the top instead of hanging + equally off both ends. `overflow` then keeps its tail away from + the hint strip — Ink 7 paints over earlier rows rather than + clipping a frame that does not fit. + */} + + + + void probeAndAdvance(value)} + onEmbeddingUrlSubmit={(value) => void saveEmbeddingUrl(value)} + /> + + {/* + The download's ambient atoms live in the bottom spacer, not in + the centred block: the block owns only its text now, and a + full-terminal-width field cannot fit inside it. `flexBasis` is + pinned to zero on both spacers so the field's own height never + counts as this spacer's base size — with `auto` it would, and + the block would ride up off centre by half the field. + */} + + {onboarding.step === "local_download" ? ( + + ) : null} + + + {/* + The budgeted viewport above is what pins the hints to the true + bottom: the root Box is sized to the terminal, the viewport takes + every row but this one, and the strip lands on the last row + instead of trailing the content. + */} + + + {onboardingFooterFor(onboarding, props.ctrlCArmed ?? false, wizardState)} + {fit.sizeAdvice ? ` · ${ONBOARDING_SIZE_ADVICE}` : ""} + + + + ); +} + +/** What the flow just finished setting up, named on the offer screen. */ +function configuredLabel(outcome: OnboardingOutcome | null): string { + if (outcome === "local") return "Local model ready"; + if (outcome === "cloud") return CLOUD_READY_LABEL; + return "Backend ready"; +} diff --git a/src/tui/components/onboarding-step-body.tsx b/src/tui/components/onboarding-step-body.tsx new file mode 100644 index 00000000..9ffc099c --- /dev/null +++ b/src/tui/components/onboarding-step-body.tsx @@ -0,0 +1,161 @@ +import { Box } from "ink"; +import type { ReactElement } from "react"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import type { LocalModelPick } from "../onboarding/local-model-picks.js"; +import type { OnboardingFit } from "../onboarding/onboarding-fit.js"; +import type { OnboardingUiState } from "../onboarding/onboarding-state.js"; +import type { ProvidersWizardState } from "../providers/providers-wizard-state.js"; +import type { TuiAction } from "../tui-action.js"; +import { describeDownloadingModel } from "../onboarding/local-model-picks.js"; +import { OnboardingChooseStep } from "./onboarding-choose-step.js"; +import { OnboardingDownloadStep } from "./onboarding-download-step.js"; +import { OnboardingHuggingFaceFlow } from "./onboarding-hf-flow.js"; +import { OnboardingHeader } from "./onboarding-header.js"; +import { OnboardingIntroStep } from "./onboarding-intro-step.js"; +import { OnboardingLocalPickStep } from "./onboarding-local-pick-step.js"; +import { OnboardingProposeStep } from "./onboarding-propose-step.js"; +import { OnboardingUrlStep } from "./onboarding-url-step.js"; +import { + OnboardingWaitOrJumpStep, + waitOrJumpPullStatus, + waitOrJumpRowCount, +} from "./onboarding-wait-or-jump-step.js"; +import { ProvidersWizard } from "./providers-wizard.js"; + +/** + * The header plus the step-to-screen switch, extracted whole so + * `OnboardingScreen` stays the flow's shell — placement, effects and the + * footer — instead of also being its longest render function. Purely + * presentational: every decision was made by the caller, this maps the + * current step to the component that draws it. + */ +export function OnboardingStepBody(props: { + onboarding: OnboardingUiState; + fit: OnboardingFit; + /** Full terminal width; the splash sizes its art from it. */ + columns: number; + /** Rows the surface's viewport allows the block. */ + viewportRows: number; + subtitle: string; + picks: readonly LocalModelPick[]; + pickCursor: number; + ramGb: number; + offerCloudMeanwhile: boolean; + pull: LocalModelsPullState | null; + /** The local-models panel's `errorLine` — how a failed pull arrives. */ + pullError: string | null; + wizardState: ProvidersWizardState | null; + introSkipped: boolean; + configuredLabel: string; + cloudLabel: string; + dispatch(action: TuiAction): void; + onChatUrlSubmit(value: string): void; + onEmbeddingUrlSubmit(value: string): void; +}): ReactElement { + const { onboarding, dispatch } = props; + return ( + + {onboarding.step === "intro" ? null : ( + + )} + {/* + The gap under the header, which the splash does not draw one + of. Spending the row anyway cost the intro its last line. + */} + + {onboarding.step === "intro" ? ( + + ) : null} + {onboarding.step === "choose" ? ( + + ) : null} + {onboarding.step === "local_pick" ? ( + + ) : null} + {/* + Mounted on every step and rendering `null` off its own two — + the hook inside subscribes to `useInput`, and hooks cannot sit + behind an early return in the parent. + */} + + {onboarding.step === "propose_second" && onboarding.offer ? ( + + ) : null} + {onboarding.step === "wait_or_jump" ? ( + + ) : null} + {onboarding.step === "local_download" ? ( + + ) : null} + {onboarding.step === "cloud" && props.wizardState ? ( + + ) : null} + {onboarding.step === "custom_chat_url" ? ( + + dispatch({ type: "onboarding_url_changed", field: "chat", value }) + } + onSubmit={props.onChatUrlSubmit} + onBack={() => dispatch({ type: "onboarding_step_set", step: "choose" })} + /> + ) : null} + {onboarding.step === "custom_embedding_url" ? ( + + dispatch({ type: "onboarding_url_changed", field: "embedding", value }) + } + onSubmit={props.onEmbeddingUrlSubmit} + onBack={() => + dispatch({ type: "onboarding_step_set", step: "custom_chat_url" }) + } + /> + ) : null} + + + ); +} diff --git a/src/tui/components/onboarding-surface-layout.test.tsx b/src/tui/components/onboarding-surface-layout.test.tsx new file mode 100644 index 00000000..fa13b4ed --- /dev/null +++ b/src/tui/components/onboarding-surface-layout.test.tsx @@ -0,0 +1,258 @@ +import { Box } from "ink"; +import { render } from "ink-testing-library"; +import React, { type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; + +import { ROOT_PADDING_LEFT } from "../layout.js"; +import { buildLocalModelPicks, orderLocalModelPicks } from "../onboarding/local-model-picks.js"; +import { computeOnboardingFit } from "../onboarding/onboarding-fit.js"; +import { measureOnboardingChooseStep, OnboardingChooseStep } from "./onboarding-choose-step.js"; +import { + measureOnboardingDownloadStep, + OnboardingDownloadStep, +} from "./onboarding-download-step.js"; +import { measureOnboardingHeader, OnboardingHeader } from "./onboarding-header.js"; +import { + measureOnboardingLocalPickStep, + OnboardingLocalPickStep, +} from "./onboarding-local-pick-step.js"; +import { + measureOnboardingProposeStep, + OnboardingProposeStep, +} from "./onboarding-propose-step.js"; +import { measureOnboardingUrlStep, OnboardingUrlStep } from "./onboarding-url-step.js"; +import { layOutOnboardingSurface } from "./onboarding-surface-layout.js"; +import { + measureOnboardingWaitOrJumpStep, + OnboardingWaitOrJumpStep, +} from "./onboarding-wait-or-jump-step.js"; + +/** `ink-testing-library` renders into a fixed 100-column stdout. */ +const TEST_COLUMNS = 100; + +const FULL = computeOnboardingFit({ columns: 100, rows: 30 }); +const MINIMAL = computeOnboardingFit({ columns: 60, rows: 14 }); +const PICKS = orderLocalModelPicks(buildLocalModelPicks(16)); + +function drawnLines(element: ReactElement, width?: number): string[] { + const view = render( + width === undefined ? element : {element}, + ); + const frame = (view.lastFrame() ?? "").replace(/\[[0-9;]*m/g, ""); + view.unmount(); + return frame.split("\n"); +} + +function widestDrawn(lines: readonly string[]): number { + return lines.reduce((max, line) => Math.max(max, line.trimEnd().length), 0); +} + +/** + * Every measure is checked against the step it claims to measure, by + * rendering that step and reading the widest line back off the frame. + * A measure that drifts from its own copy is the one failure mode this + * whole mechanism has, and only the render can catch it. + * + * `exact` is off for the screens that deliberately reserve room for + * counters that have not arrived yet — there the measure is an upper + * bound, and the test says so rather than pinning the slack. Exact + * cases are compared against the test terminal's own width as well: a + * model row carrying a long description runs past 100 columns, and the + * frame reports the truncated row rather than the row that was asked + * for. + */ +const cases: { name: string; measured: number; element: ReactElement; exact: boolean }[] = [ + { + name: "the brand lockup with its mark", + measured: measureOnboardingHeader("setup · step 1 of 2", "sm"), + element: , + exact: true, + }, + { + name: "the brand lockup with the two-row XS sign", + measured: measureOnboardingHeader("setup · step 1 of 2", "xs"), + element: , + exact: true, + }, + { + name: "the backend choice at full size", + measured: measureOnboardingChooseStep(FULL), + element: , + exact: true, + }, + { + name: "the backend choice stripped to its labels", + measured: measureOnboardingChooseStep(MINIMAL), + element: , + exact: true, + }, + { + name: "the model list", + measured: measureOnboardingLocalPickStep({ + picks: PICKS, + cursor: 0, + ramGb: 16, + fit: FULL, + }), + element: , + exact: true, + }, + { + name: "the model list scrolled to its last row", + measured: measureOnboardingLocalPickStep({ + picks: PICKS, + cursor: PICKS.length - 1, + ramGb: 8, + fit: MINIMAL, + }), + element: ( + + ), + exact: true, + }, + { + name: "the second-backend offer", + measured: measureOnboardingProposeStep({ + offer: "local", + configuredLabel: "Cloud model ready", + }), + element: ( + + ), + exact: true, + }, + { + name: "the chat endpoint box", + measured: measureOnboardingUrlStep("chat"), + element: ( + {}} + onSubmit={() => {}} + onBack={() => {}} + /> + ), + exact: true, + }, + { + name: "the embedding endpoint box", + measured: measureOnboardingUrlStep("embedding"), + element: ( + {}} + onSubmit={() => {}} + onBack={() => {}} + /> + ), + exact: true, + }, + { + name: "the wait-or-jump question once the pull has landed", + measured: measureOnboardingWaitOrJumpStep({ + pull: null, + pullError: null, + cloudLabel: "Cloud model ready", + modelLabel: "qwen3-4b-instruct", + fit: FULL, + }), + element: ( + + ), + exact: true, + }, + { + name: "the download, which reserves room for its counters", + measured: measureOnboardingDownloadStep({ + modelLabel: "qwen3-4b-instruct", + offerCloudMeanwhile: true, + }), + element: ( + + ), + exact: false, + }, +]; + +describe("the download step's placement", () => { + // The regression this pins: `local_download` used to answer the + // measure with the whole terminal, so `placeOnboardingBlock` clamped + // `left` to zero and the one screen sat hard against the margin while + // every other step centred. The measure the width test above pins is + // now the one production actually uses. + it("centres the download block instead of handing it the terminal", () => { + const measured = measureOnboardingDownloadStep({ + modelLabel: "qwen3-4b-instruct", + offerCloudMeanwhile: true, + }); + const placement = layOutOnboardingSurface({ + columns: 100, + rows: 30, + step: "local_download", + fit: FULL, + subtitle: "local models · downloading", + picks: PICKS, + cursor: 0, + ramGb: 16, + offer: null, + configuredLabel: "Backend ready", + modelLabel: "qwen3-4b-instruct", + offerCloudMeanwhile: true, + pull: null, + cloudLabel: "Cloud model ready", + hfRepo: null, + }); + expect(measured).toBeLessThan(98); + expect(placement.width).toBe(measured); + expect(placement.left).toBe(Math.floor((100 - measured) / 2) - ROOT_PADDING_LEFT); + expect(placement.left).toBeGreaterThan(0); + }); +}); + +describe("the per-step block measures", () => { + for (const testCase of cases) { + it(`measures ${testCase.name}`, () => { + const drawn = widestDrawn(drawnLines(testCase.element)); + expect(drawn).toBeGreaterThan(0); + if (testCase.exact) expect(Math.min(testCase.measured, TEST_COLUMNS)).toBe(drawn); + else expect(testCase.measured).toBeGreaterThanOrEqual(drawn); + }); + + // The failure the width check alone cannot see: a step whose lines + // carry trailing pad cells measures narrow but draws wide, and once + // the block is pinned to `width={measured}` Ink wraps the invisible + // cells into extra rows instead of clipping them. Same line count + // pinned and free means nothing wrapped inside the measure. + it(`draws ${testCase.name} without wrapping inside its own measure`, () => { + const free = drawnLines(testCase.element); + const pinned = drawnLines( + testCase.element, + Math.min(testCase.measured, TEST_COLUMNS), + ); + expect(pinned.length).toBe(free.length); + }); + } +}); diff --git a/src/tui/components/onboarding-surface-layout.ts b/src/tui/components/onboarding-surface-layout.ts new file mode 100644 index 00000000..7d63716e --- /dev/null +++ b/src/tui/components/onboarding-surface-layout.ts @@ -0,0 +1,150 @@ +/** + * Where the first-run surface puts its content, step by step. + * + * `OnboardingScreen` centres a left-aligned block, and a block can only + * be centred against a width somebody measured. Each step answers for + * its own widest line next to the strings it draws, so the two cannot + * drift; this module is the switch between them — plus the header that + * sits above every one of them — and hands the result to the placement + * arithmetic. + */ + +import { ROOT_PADDING_LEFT } from "../layout.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import { + placeOnboardingBlock, + type OnboardingBlockPlacement, +} from "../onboarding/centre-onboarding-block.js"; +import type { LocalModelPick } from "../onboarding/local-model-picks.js"; +import type { OnboardingFit } from "../onboarding/onboarding-fit.js"; +import type { + OnboardingHuggingFaceRepo, + OnboardingStep, +} from "../onboarding/onboarding-state.js"; +import type { SecondBackendOffer } from "../onboarding/propose-second-backend.js"; +import { measureOnboardingChooseStep } from "./onboarding-choose-step.js"; +import { measureOnboardingDownloadStep } from "./onboarding-download-step.js"; +import { measureOnboardingHeader } from "./onboarding-header.js"; +import { measureOnboardingHfPickStep } from "./onboarding-hf-pick-step.js"; +import { measureOnboardingHfRefStep } from "./onboarding-hf-ref-step.js"; +import { measureOnboardingLocalPickStep } from "./onboarding-local-pick-step.js"; +import { measureOnboardingProposeStep } from "./onboarding-propose-step.js"; +import { measureProvidersWizard } from "./providers-wizard-measure.js"; +import { measureOnboardingUrlStep } from "./onboarding-url-step.js"; +import { measureOnboardingWaitOrJumpStep } from "./onboarding-wait-or-jump-step.js"; + +/** Rows the surface spends above the block; matches its own `paddingTop`. */ +export const SURFACE_PADDING_TOP = 1; +/** The hint strip: one row, pinned to the last line of the terminal. */ +export const FOOTER_ROWS = 1; + +export interface OnboardingBlockInput { + step: OnboardingStep; + fit: OnboardingFit; + /** Line under the wordmark, e.g. `setup · step 1 of 2`. */ + subtitle: string; + picks: readonly LocalModelPick[]; + cursor: number; + ramGb: number; + offer: SecondBackendOffer; + /** What the flow just finished setting up, named on the offer screen. */ + configuredLabel: string; + modelLabel: string; + offerCloudMeanwhile: boolean; + pull: LocalModelsPullState | null; + cloudLabel: string; + /** The pull's failure, from the panel's `errorLine`. */ + pullError?: string | null; + /** The resolved Hugging Face repo, while the flow is on its file list. */ + hfRepo: OnboardingHuggingFaceRepo | null; + /** The reference screen's error, which widens its block when long. */ + hfError?: string | null; +} + +export function layOutOnboardingSurface( + input: OnboardingBlockInput & { columns: number; rows: number }, +): OnboardingBlockPlacement { + return placeOnboardingBlock({ + columns: input.columns, + rows: input.rows, + blockWidth: measureOnboardingBlock({ + ...input, + available: Math.max(0, input.columns - ROOT_PADDING_LEFT), + }), + paddingLeft: ROOT_PADDING_LEFT, + paddingTop: SURFACE_PADDING_TOP, + footerRows: FOOTER_ROWS, + }); +} + +/** The same input, plus the columns the root inset leaves behind. */ +interface MeasureInput extends OnboardingBlockInput { + available: number; +} + +export function measureOnboardingBlock(input: MeasureInput): number { + // The splash pads its own art to the full measure it is handed, so it + // is already centred and the container must not move it again. + if (input.step === "intro") return input.available; + return Math.max( + measureOnboardingHeader(input.subtitle, input.fit.mark), + measureStepBody(input), + ); +} + +function measureStepBody(input: MeasureInput): number { + switch (input.step) { + case "choose": + return measureOnboardingChooseStep(input.fit); + case "local_pick": + return measureOnboardingLocalPickStep({ + picks: input.picks, + cursor: input.cursor, + ramGb: input.ramGb, + fit: input.fit, + }); + case "local_download": + // The text block centres like any other step. The atom field is + // the surface's ambience now — drawn outside this block, at full + // terminal width, by `OnboardingDownloadAmbient` — so the measure + // is the text's own rather than the whole terminal. + return measureOnboardingDownloadStep({ + modelLabel: input.modelLabel, + offerCloudMeanwhile: input.offerCloudMeanwhile, + }); + case "cloud": + // The wizard's deterministic lines, measured beside the strings it + // draws; its live catalog rows truncate inside the box by design. + return Math.min(input.available, measureProvidersWizard()); + case "custom_chat_url": + return measureOnboardingUrlStep("chat"); + case "custom_embedding_url": + return measureOnboardingUrlStep("embedding"); + case "local_hf_ref": + return Math.min(input.available, measureOnboardingHfRefStep(input.hfError ?? null)); + case "local_hf_pick": + return Math.min( + input.available, + measureOnboardingHfPickStep(input.hfRepo, input.cursor), + ); + case "propose_second": + return input.offer + ? measureOnboardingProposeStep({ + offer: input.offer, + configuredLabel: input.configuredLabel, + }) + : 0; + case "wait_or_jump": + return measureOnboardingWaitOrJumpStep({ + pull: input.pull, + pullError: input.pullError ?? null, + cloudLabel: input.cloudLabel, + modelLabel: input.modelLabel, + fit: input.fit, + }); + // The flow is closing down and draws nothing but its own footer. + case "finished": + case "intro": + return 0; + } +} diff --git a/src/tui/components/onboarding-url-step.tsx b/src/tui/components/onboarding-url-step.tsx new file mode 100644 index 00000000..63a44535 --- /dev/null +++ b/src/tui/components/onboarding-url-step.tsx @@ -0,0 +1,90 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import { theme } from "../theme/theme.js"; +import { MultiLineEditor } from "./multi-line-editor.js"; + +const HEALTH_NOTE = "(must answer GET /health)"; +const TITLES = { + chat: "Base URL of your chat llama-server ", + embedding: "Base URL of your embedding llama-server ", +} as const; +const EMBEDDING_NOTE = + "Optional — leave it empty to continue without hybrid embedding recall."; +const PLACEHOLDERS = { + chat: "http://127.0.0.1:8080", + embedding: "http://127.0.0.1:19092", +} as const; +const PROBING = "probing /health…"; +/** The editor's rounded border plus its one column of padding, both sides. */ +const EDITOR_CHROME_COLUMNS = 4; + +/** + * Widest line this step draws, for the block that centres it. + * + * The editor is measured at its placeholder, not at what has been + * typed: the box grows with the buffer, and centring on that would slide + * the whole screen left one column per character. The error line is left + * out for the same reason the download screen leaves its own out — it + * carries arbitrary text and wraps inside the block instead. + * + * The editor's chrome is added as a number, not as trailing spaces: + * `widestLine` trims trailing pads (they are invisible, so counting + * them would centre the block on cells nobody sees), which would strip + * the reservation straight back off. + */ +export function measureOnboardingUrlStep(kind: "chat" | "embedding"): number { + return Math.max( + widestLine([ + `${TITLES[kind]}${HEALTH_NOTE}`, + ...(kind === "embedding" ? [EMBEDDING_NOTE] : []), + PROBING, + ]), + PLACEHOLDERS[kind].length + EDITOR_CHROME_COLUMNS, + ); +} + +/** + * The custom-endpoint branch: a llama-server the operator already runs. + * Two screens — the chat server, then an optional embedding server — + * each probing `GET /health` before it is written, so a typo is caught + * here instead of surfacing as a dead agent on the first message. + */ +export function OnboardingUrlStep(props: { + kind: "chat" | "embedding"; + value: string; + busy: boolean; + error: string | null; + onChange(value: string): void; + onSubmit(value: string): void; + onBack(): void; +}): ReactElement { + const embedding = props.kind === "embedding"; + return ( + + + {TITLES[props.kind]} + {HEALTH_NOTE} + + {embedding ? {EMBEDDING_NOTE} : null} + + + + {props.busy ? {PROBING} : null} + {props.error ? {props.error} : null} + + ); +} diff --git a/src/tui/components/onboarding-wait-or-jump-step.test.tsx b/src/tui/components/onboarding-wait-or-jump-step.test.tsx new file mode 100644 index 00000000..f6f16309 --- /dev/null +++ b/src/tui/components/onboarding-wait-or-jump-step.test.tsx @@ -0,0 +1,142 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { OnboardingWaitOrJumpStep } from "./onboarding-wait-or-jump-step.js"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import { computeOnboardingFit } from "../onboarding/onboarding-fit.js"; + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); + +function pull(over: Partial = {}): LocalModelsPullState { + return { + kind: "chat", + modelId: "gemma-4-e4b", + label: "Gemma 4 E4B", + percent: 61, + transferredBytes: 2_600_000_000, + totalBytes: 4_220_000_000, + error: null, + ...over, + }; +} + +function frameOf( + options: { + cursor?: number; + pull?: LocalModelsPullState | null; + pullError?: string | null; + size?: { columns: number; rows: number }; + } = {}, +): string { + const view = render( + , + ); + return strip(view.lastFrame() ?? ""); +} + +describe("OnboardingWaitOrJumpStep", () => { + it("draws the download it says is still running", () => { + const frame = frameOf(); + expect(frame).toContain("Cloud model ready"); + expect(frame).toContain("Still downloading gemma-4-e4b"); + const weights = frame.split("\n").find((row) => row.includes("model weights")) ?? ""; + // The same bar the download screen draws: percent and bytes, not a + // sentence claiming progress the screen never shows. + expect(weights).toContain("█"); + expect(weights).toContain("░"); + expect(weights).toContain("61%"); + expect(weights).toContain("2.6 GB / 4.2 GB"); + expect(frame).toContain("llama.cpp runtime"); + }); + + it("offers leaving and one more provider, and never offers waiting", () => { + const frame = frameOf(); + expect(frame).toContain("Start using the agent now"); + expect(frame).toContain("top bar"); + expect(frame).toContain("Add another cloud provider"); + expect(frame).not.toContain("Wait here"); + expect(frame).not.toContain("Retry the download"); + }); + + it("defaults to jumping and moves the marker to the second row", () => { + const rowMarker = (frame: string, label: string): boolean => + (frame.split("\n").find((row) => row.includes(label)) ?? "") + .trimStart() + .startsWith("›"); + const first = frameOf({ cursor: 0 }); + expect(rowMarker(first, "Start using the agent now")).toBe(true); + expect(rowMarker(first, "Add another cloud provider")).toBe(false); + const second = frameOf({ cursor: 1 }); + expect(rowMarker(second, "Start using the agent now")).toBe(false); + expect(rowMarker(second, "Add another cloud provider")).toBe(true); + }); + + it("says the model landed instead of drawing a bar for a finished pull", () => { + // A pull that ended cleanly while the second wizard covered this + // screen: the reducer nulled it, so a bar here would be fabricated. + const frame = frameOf({ pull: null, pullError: null }); + expect(frame).toContain("Cloud model ready"); + expect(frame).toContain("gemma-4-e4b downloaded"); + expect(frame).toContain("local model is ready"); + expect(frame).not.toContain("Still downloading"); + expect(frame).not.toContain("starting"); + expect(frame).not.toContain("waiting"); + expect(frame).not.toContain("░"); + // The jump row must not promise a download that is over. + expect(frame).not.toContain("top bar"); + expect(frame).toContain("Start using the agent now"); + expect(frame).toContain("Add another cloud provider"); + expect(frame).not.toContain("Retry the download"); + }); + + it("says a dead pull failed and offers to run it again", () => { + const frame = frameOf({ pull: null, pullError: "connection reset" }); + expect(frame).toContain("download failed"); + expect(frame).toContain("connection reset"); + expect(frame).toContain("Retry the download"); + // No bar for a download that is not running. + expect(frame).not.toContain("Still downloading"); + expect(frame).not.toContain("starting"); + expect(frame).not.toContain("waiting"); + expect(frame).not.toContain("░"); + }); + + it("moves the marker onto the retry row", () => { + const frame = frameOf({ cursor: 2, pull: null, pullError: "connection reset" }); + const retry = + frame.split("\n").find((row) => row.includes("Retry the download")) ?? ""; + expect(retry.trimStart().startsWith("›")).toBe(true); + }); + + it("keeps the bars and the rows when a short terminal costs it the prose", () => { + const frame = frameOf({ size: { columns: 70, rows: 17 } }); + expect(frame).not.toContain("Still downloading"); + expect(frame).not.toContain("top bar"); + expect(frame).toContain("61%"); + expect(frame).toContain("Start using the agent now"); + expect(frame).toContain("Add another cloud provider"); + // Ink overlaps rather than clips, so the whole step has to fit in + // the rows the surface has left over at the minimal tier. + expect(frame.split("\n").length).toBeLessThanOrEqual(12); + }); + + it("stays inside the same budget when the failure adds its row", () => { + const frame = frameOf({ + pull: null, + pullError: "connection reset", + size: { columns: 70, rows: 17 }, + }); + expect(frame).toContain("Retry the download"); + // The error line replaces the two bars and the rate line, so even + // with a third row the failed layout must not outgrow the running + // one — the tier's budget was measured against the bars. + expect(frame.split("\n").length).toBeLessThanOrEqual(12); + }); +}); diff --git a/src/tui/components/onboarding-wait-or-jump-step.tsx b/src/tui/components/onboarding-wait-or-jump-step.tsx new file mode 100644 index 00000000..fc2048f1 --- /dev/null +++ b/src/tui/components/onboarding-wait-or-jump-step.tsx @@ -0,0 +1,211 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import type { OnboardingFit } from "../onboarding/onboarding-fit.js"; +import { handleOnboardingStepKey } from "../onboarding/onboarding-step-keys.js"; +import { ROW_INDENT, rowPrefix } from "../onboarding/onboarding-rows.js"; +import { theme } from "../theme/theme.js"; +import { + OnboardingDownloadProgress, + PROGRESS_TEMPLATE_LINE, +} from "./onboarding-download-progress.js"; + +/** + * What the pull is actually doing, which is the one thing this screen + * is allowed to claim. The pull can end — cleanly or not — while the + * second cloud wizard hides this screen, and the flow still returns + * here; a screen that assumed "running" would then draw a 0% bar for a + * download that is over. Failure is read from the panel's `errorLine` + * because the reducer nulls the pull itself when it fails. + */ +export type WaitOrJumpPullStatus = "running" | "ready" | "failed"; + +export function waitOrJumpPullStatus( + pull: LocalModelsPullState | null, + errorLine: string | null, +): WaitOrJumpPullStatus { + if (pull !== null) return "running"; + return errorLine !== null ? "failed" : "ready"; +} + +/** A failed pull adds the retry row; the keyboard has to agree. */ +export function waitOrJumpRowCount(status: WaitOrJumpPullStatus): number { + return status === "failed" ? 3 : 2; +} + +const ROW_COPY = { + jump: { + label: "Start using the agent now", + details: { + running: "the download keeps running; progress shows in the top bar", + ready: "local and cloud are both set up", + failed: "the cloud model is ready to use", + }, + }, + add: { + label: "Add another cloud provider", + detail: "one more key or endpoint, then straight back to this screen", + }, + retry: { + label: "Retry the download", + detail: "starts the same download again", + }, +} as const; + +/** Widest line this step draws, for the block that centres it. */ +export function measureOnboardingWaitOrJumpStep(props: { + pull: LocalModelsPullState | null; + pullError: string | null; + cloudLabel: string; + modelLabel: string; + fit: OnboardingFit; +}): number { + const status = waitOrJumpPullStatus(props.pull, props.pullError); + const lines: string[] = [`${theme.glyphs.check} ${props.cloudLabel}`]; + if (status === "running" && props.fit.explainer) { + lines.push( + `Still downloading ${props.modelLabel} — it keeps running whichever row you pick.`, + ); + } + if (status === "ready") { + lines.push( + `${theme.glyphs.check} ${props.modelLabel} downloaded — the local model is ready too`, + ); + } + if (status === "failed") { + lines.push( + `The ${props.modelLabel} download failed — the cloud model still works.`, + ); + // The progress slot draws the error instead of the bars. Left out of + // the measure like every other error line: the block must not resize + // itself around an arbitrary message. + } + if (status === "running") lines.push(PROGRESS_TEMPLATE_LINE); + lines.push(`${ROW_INDENT}${ROW_COPY.jump.label}`, `${ROW_INDENT}${ROW_COPY.add.label}`); + if (status === "failed") lines.push(`${ROW_INDENT}${ROW_COPY.retry.label}`); + if (props.fit.rowDetails) { + lines.push( + `${ROW_INDENT}${ROW_COPY.jump.details[status]}`, + `${ROW_INDENT}${ROW_COPY.add.detail}`, + ); + if (status === "failed") lines.push(`${ROW_INDENT}${ROW_COPY.retry.detail}`); + } + return widestLine(lines); +} + +/** + * Reached only from the "set up cloud while this downloads" path: the + * cloud model is ready and the local one was still coming down when the + * screen was last on top. + * + * Everything above the rows is derived from the pull's real state — + * running draws the same bars the download step draws, finished says + * the model landed, failed says so and offers to run the pull again. + * + * Waiting is not a row. The pull is owned by the orchestrator and the + * top bar reports it, so sitting on this screen buys nothing the agent + * does not already give; the things worth doing here are leaving, + * adding one more cloud provider, and — after a failure — retrying. + * + * The bars cost five rows the old summary line did not, so the prose + * around them is what gets shed on a short terminal — Ink overlaps the + * rows above rather than clipping, and the rows themselves have to + * survive that. The ready and failed layouts are strictly shorter than + * the running one, so the budget is set by the bars. + */ +export function OnboardingWaitOrJumpStep(props: { + pull: LocalModelsPullState | null; + /** The pull's failure, from the panel's `errorLine`. */ + pullError: string | null; + cloudLabel: string; + modelLabel: string; + cursor: number; + fit: OnboardingFit; +}): ReactElement { + const status = waitOrJumpPullStatus(props.pull, props.pullError); + const cursor = props.cursor % waitOrJumpRowCount(status); + const jumpDetail = ROW_COPY.jump.details[status]; + const addDetail = ROW_COPY.add.detail; + return ( + + + {`${theme.glyphs.check} `} + {props.cloudLabel} + + {status === "running" && props.fit.explainer ? ( + + {`Still downloading ${props.modelLabel} — it keeps running whichever row you pick.`} + + ) : null} + {status === "ready" ? ( + + {`${theme.glyphs.check} `} + {`${props.modelLabel} downloaded — the local model is ready too`} + + ) : null} + {status === "failed" ? ( + + {`The ${props.modelLabel} download failed — the cloud model still works.`} + + ) : null} + {status === "ready" ? null : ( + + + + )} + + + + {status === "failed" ? ( + + ) : null} + + + ); +} + +function Row(props: { + selected: boolean; + /** This row's place in the screen's cursor space, for click-to-select. */ + index: number; + label: string; + detail: string | null; +}): ReactElement { + return ( + // First click selects, second activates — the same Enter the + // keyboard sends, through the flow's own key table. + + mouse.dispatch({ type: "onboarding_cursor_set", cursor: props.index }) + } + onActivate={pressEnter(handleOnboardingStepKey)} + > + + + {`${rowPrefix(props.selected)}${props.label}`} + + {props.detail ? ( + {`${ROW_INDENT}${props.detail}`} + ) : null} + + + ); +} diff --git a/src/tui/components/plan-handoff.tsx b/src/tui/components/plan-handoff.tsx new file mode 100644 index 00000000..bc7a583b --- /dev/null +++ b/src/tui/components/plan-handoff.tsx @@ -0,0 +1,164 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import type { CodingMode } from "../coding-mode.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; +import { readableOn } from "../theme/readable-foreground.js"; +import { theme } from "../theme/theme.js"; + +/** + * What the operator does with a plan once it exists. + * + * Plan mode ends with a proposal and a dead end. The agent has said what + * it would do and is forbidden from doing any of it, so carrying it out + * meant two separate moves — find the mode control, change it, then + * remember what you were going to say — with nothing on screen + * connecting the plan to either. The plan is the *only* moment the next + * step is obvious, and it was the one moment the app said nothing. + * + * Two buttons and a sentence, and the sentence matters as much as the + * buttons: the third option is to keep planning, and an operator looking + * at two "execute" buttons needs telling that typing is still allowed. + * + * **Why two rather than one plus a mode picker.** The choice at this + * moment is not "which of four modes" — it is how much rope to give the + * run that is about to start, and there are exactly two honest answers: + * let it edit here and keep asking about everything else, or stop asking + * altogether. Offering `default` would be offering to approve every step + * of a plan already read and approved as a whole. + */ +export interface PlanHandoffProps { + /** Runs the plan under `mode`. */ + onExecute: (mode: CodingMode) => void; + /** + * Puts the plan away without running it and without leaving plan + * mode. + * + * The bar had two buttons and a sentence, and the sentence carried + * the whole of the third option — which made "I do not want this + * plan" the only choice with no control attached to it. Typing does + * revise a plan, but it is not how you *drop* one, and an offer that + * cannot be declined keeps sitting there. + */ + onDismiss: () => void; +} + +export function PlanHandoff({ + onExecute, + onDismiss, +}: PlanHandoffProps): ReactElement { + return ( + + {/* + `flexWrap` rather than a width breakpoint. The bar sits inside + the chat log now, in ordinary flow — so it is the log's column + that decides how much room there is, and Yoga already knows + that number. Measuring the terminal here and guessing a + threshold was how the old version ended up painting its own + second line over itself. + */} + + + + + + {/* + Last, and in the quiet tone. It is the one button here that + does nothing irreversible, and putting it first would give the + least consequential choice the position the eye lands on. + */} + + + + ); +} + +function ExecuteButton({ + mode, + label, + tone, + onExecute, +}: { + mode: CodingMode; + label: string; + tone: string; + onExecute: (mode: CodingMode) => void; +}): ReactElement { + const face = ( + + {` ${label} `} + + ); + const mouse = useMouseCommands(); + // No mouse provider (tests, the wizard's separate tree): still draw + // the face. It is the only thing on screen naming what happens next, + // and a button that vanished without a mouse would take the + // explanation with it. + if (!mouse) return face; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onExecute(mode); + return true; + }} + > + {face} + + ); +} + +function DismissButton({ + onDismiss, +}: { + onDismiss: () => void; +}): ReactElement { + const face = ( + + {" ✕ dismiss plan "} + + ); + const mouse = useMouseCommands(); + if (!mouse) return face; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onDismiss(); + return true; + }} + > + {face} + + ); +} + +/** + * The message the buttons send. + * + * Written as an instruction rather than a bare "go", because the model + * has just been told — by every refusal in the turn behind it — that its + * tools do not work. Naming the change explicitly is what closes that + * out; without it the likeliest next step is another plan. + */ +export const EXECUTE_PLAN_MESSAGE = + "Carry out the plan you just described. Plan mode is off now, so your tools work again — go ahead and make the changes."; diff --git a/src/tui/components/prompt-meta-bar.test.tsx b/src/tui/components/prompt-meta-bar.test.tsx new file mode 100644 index 00000000..24cd8ba5 --- /dev/null +++ b/src/tui/components/prompt-meta-bar.test.tsx @@ -0,0 +1,188 @@ +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { PromptShell } from "./prompt-shell.js"; + +function strip(value: string): string { + return value + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); +} + +/** + * Screen position of `needle`'s first cell. Stripping SGR codes leaves + * the visual grid intact, so the column/row returned here are the same + * cells a terminal would report for a click on that label. + */ +function locate(frame: string, needle: string): { x: number; y: number } { + const lines = strip(frame).split("\n"); + for (const [y, line] of lines.entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${strip(frame)}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const noopCallbacks = {} as TuiAppCallbacks; + +/** + * `PromptShell` inside a real registry. The buttons only need dispatch + * to exist — they act through their own props — but the registry is the + * real one so the click goes through genuine Yoga hit-testing rather + * than a hand-fed rectangle. + */ +async function mountWithMouse(node: ReactElement): Promise<{ + registry: MouseTargetRegistry; + frame: () => string; + unmount: () => void; +}> { + const registry = new MouseTargetRegistry(); + const { lastFrame, unmount } = render( + {}} + callbacks={noopCallbacks} + getState={() => ({}) as TuiState} + > + {node} + , + ); + // Ink commits on its own throttle and React registers the click + // targets in the effect after that commit, so a freshly mounted + // button is not hit-testable on the very first tick. + await new Promise((resolve) => setTimeout(resolve, 120)); + return { registry, frame: () => lastFrame() ?? "", unmount }; +} + +describe("composer buttons", () => { + it("submits the live buffer when Send is clicked", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(true); + expect(sent).toEqual(["ship it"]); + unmount(); + }); + + it("stays inert while the buffer is blank", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + it("stays inert while the editor is disabled", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + + it("ignores a right-button press on Send", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect( + registry.dispatch({ ...click(x, y), button: "right" }), + ).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + it("renders without a mouse provider at all", () => { + const { lastFrame, unmount } = render( + {}} onSubmit={() => {}} />, + ); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("send"); + unmount(); + }); +}); + +describe("the model label", () => { + const renderModel = (model: string): string => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const frame = strip(lastFrame() ?? ""); + unmount(); + return frame; + }; + + /** + * Fusion names both legs. Spending the whole budget left-to-right ate + * the local half outright — "vendor/some-very-long-name ⇄ q…" — which + * hides the model that actually executes most of the steps. + */ + it("keeps both fusion legs identifiable", () => { + const frame = renderModel( + "vendor/some-very-long-cloud-model ⇄ qwen3-4b-instruct-q4.gguf", + ); + expect(frame).toContain("vendor/some-v…"); + expect(frame).toContain("qwen3-4b-inst…"); + }); + + it("still trims a single long name the way it always did", () => { + expect(renderModel("vendor/an-extremely-long-single-model-name")).toContain( + "vendor/an-extremely-long-single…", + ); + }); +}); diff --git a/src/tui/components/prompt-meta-bar.tsx b/src/tui/components/prompt-meta-bar.tsx new file mode 100644 index 00000000..d44c5e78 --- /dev/null +++ b/src/tui/components/prompt-meta-bar.tsx @@ -0,0 +1,220 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { ComposerMetaControls } from "../composer-switch/composer-meta-controls.js"; +import type { ComposerBackendMeta } from "../composer-switch/composer-switch-rows.js"; +import { theme } from "../theme/theme.js"; + +/** + * The composer's status bar: the chat route on the left, the live + * readouts on the right, drawn on the same inverted ground as the rail. + * + * **Why its own ground.** The bar is the composer's chrome, not its + * content. A terminal has no borders-and-shadows to say "this strip is + * a toolbar", so it borrows the one device the rail already + * established: its own ground, one step off the page rather than an + * inversion of it. Reading the composer as "a field with a toolbar + * under it" instead of "two lines of text" is the whole point of the + * change. + * + * The ground is one `backgroundColor` on the bar container, which Ink 7 + * paints across the empty space between the meta text and the readouts — + * no filler cells, and no risk of the row growing taller than it looks. + * + * Send used to live here, at the far right. It moved into the field + * itself (`composer-send-button.tsx`): the bar's right end is where a + * status readout belongs, and the app's primary verb belongs next to + * the text it submits. + * + * **About the slots.** `leftSlot` / `rightSlot` arrive from the chat + * surface already coloured, so this file cannot check them — but they + * land on the rail ground, which means the caller has to paint them in + * `rail*` tokens rather than page ones. It used not to: the composer + * notice came in as `success` and the while-busy hint as `accentSoft` + * plus `muted`, all three picked to be read on the terminal's own + * background, and on the palettes whose rail was drawn *inverted* that + * put light text on a light ground. `tui-app.tsx` now hands over rail + * tokens, and `theme-contrast.test.ts` holds every one of them to AA + * against `railBackground`. + */ +export interface PromptMetaBarProps { + /** + * Chat-surface content rendered first — today only the transient + * composer notice. The LLM health pill that used to live here folded + * into the backend control, which now carries the same dot. + */ + leftSlot: ReactElement | null; + /** The route's backend kind and its health dot; `null` hides it. */ + backend: ComposerBackendMeta | null; + model: string | null; + provider: string | null; + /** Turns the model slot into a `download model` call to action. */ + needsModelDownload?: boolean; + /** Chat-surface content rendered at the bar's right end. */ + rightSlot: ReactElement | null; + /** + * The context readout, rendered at the bar's right end. Its own prop + * rather than part of `rightSlot` because the two coexist: while a + * turn runs `rightSlot` carries the Enter-routing hint, and the window + * is exactly as worth watching then as when the composer is idle. + */ + contextSlot: ReactElement | null; + /** + * The coding-mode chip, at the very end of the bar. Its own prop + * rather than part of `rightSlot` for the same reason `contextSlot` + * is: the three coexist, and the bar's right end is an ordered + * sentence — how full the window is, then under what rules. + */ + modeSlot: ReactElement | null; + /** + * Layer the route controls register their click targets on. The + * composer floats over the chat log behind a raised mouse backstop + * (see `composer-overlay.tsx`); controls left on the base layer would + * lose every click to it. + */ + mouseLayer?: number; +} + +const MODEL_LABEL_MAX_LEN = 32; + +/** + * Separator `runModeModelSummary` puts between the two fusion legs. + * Matched here rather than imported as a run-mode concept: this file + * only needs to know that a label can be a pair, so that it can spend + * its budget on both halves instead of on the first one. + */ +const PAIR_SEPARATOR = " ⇄ "; + +export function PromptMetaBar({ + leftSlot, + backend, + model, + provider, + needsModelDownload, + rightSlot, + contextSlot, + modeSlot, + mouseLayer, +}: PromptMetaBarProps): ReactElement { + return ( + + {/* + The meta group is the only thing allowed to give up columns: at + 60 the right-hand readout must survive intact, because a + half-drawn chip is worse than a truncated model name. + */} + + + + + {rightSlot ? ( + + {rightSlot} + + ) : null} + {contextSlot ?? null} + {modeSlot ? ( + + {modeSlot} + + ) : null} + + + ); +} + +interface MetaLeftProps { + leftSlot: ReactElement | null; + backend: ComposerBackendMeta | null; + model: string | null; + provider: string | null; + needsModelDownload: boolean; + mouseLayer?: number; +} + +/** + * A row of Boxes rather than one `` of spans, because the three + * route labels are clickable and a click target is a Box — Ink cannot + * nest one inside a ``. + * + * That costs the free truncation the single `` + * used to give the whole group, so the row has to fit by shrinking: the + * notice and its separator never give a column, and the route labels + * truncate in the order `ComposerMetaControls` sets. Every `` in + * here is `truncate` for the same reason — one that wrapped would take + * the composer's bottom border down a line with it. + */ +function MetaLeft({ + leftSlot, + backend, + model, + provider, + needsModelDownload, + mouseLayer, +}: MetaLeftProps): ReactElement { + if (!leftSlot && !backend && !model && !provider && !needsModelDownload) { + return ; + } + const cleanModel = model ? formatModel(model) : null; + const hasRoute = Boolean(backend || provider || cleanModel || needsModelDownload); + return ( + + {leftSlot ? ( + + {leftSlot} + + ) : null} + {leftSlot && hasRoute ? ( + + + {" "} + {theme.glyphs.dotSeparator}{" "} + + + ) : null} + + + ); +} + +function formatModel(model: string): string { + // Fusion names both legs. Truncating the joined string would eat the + // local half whole and leave "anthropic/claude-sonnet-4.5 ⇄ q…", which + // says less than either name alone would: the reader can no longer + // tell which local model is executing. Each side gets half the budget + // so both stay identifiable at the width the row already had. + const [cloud, local] = model.split(PAIR_SEPARATOR); + if (cloud !== undefined && local !== undefined) { + const half = Math.floor((MODEL_LABEL_MAX_LEN - PAIR_SEPARATOR.length) / 2); + return `${shorten(cloud, half)}${PAIR_SEPARATOR}${shorten(local, half)}`; + } + return shorten(model, MODEL_LABEL_MAX_LEN); +} + +function shorten(label: string, max: number): string { + const stripped = label.replace(/\.gguf$/i, ""); + if (stripped.length <= max) return stripped; + return `${stripped.slice(0, max - 1)}…`; +} diff --git a/src/tui/components/prompt-shell.test.tsx b/src/tui/components/prompt-shell.test.tsx index a354b95f..42b38515 100644 --- a/src/tui/components/prompt-shell.test.tsx +++ b/src/tui/components/prompt-shell.test.tsx @@ -1,5 +1,7 @@ +import { Box, Text } from "ink"; import { render } from "ink-testing-library"; import { describe, expect, it } from "vitest"; +import { ContextChip } from "./context-chip.js"; import { PromptShell } from "./prompt-shell.js"; function strip(value: string): string { @@ -9,7 +11,7 @@ function strip(value: string): string { } describe("PromptShell", () => { - it("renders the left tail cap (╹) below the editor", () => { + it("closes a frame around the editor and the action bar", () => { const { lastFrame, unmount } = render( { />, ); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain("╹"); + expect(frame).toContain("╭"); + expect(frame).toContain("╰"); expect(frame).toContain("hello"); + // The tail cap the frame replaced. + expect(frame).not.toContain("╹"); + unmount(); + }); + + it("shows the send button inside the field", () => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("send"); + unmount(); + }); + + /** + * The composer's whole height budget: four rows of chrome plus the + * buffer. If this grows, the chat viewport shrinks — and Ink 7 will + * overlap the lines above rather than clip, so a drift here is not a + * cosmetic one. + */ + it("spends eight rows on chrome regardless of the buffer", () => { + const heightOf = (value: string): number => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const rows = strip(lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0).length; + unmount(); + return rows; + }; + expect(heightOf("one")).toBe(8); + expect(heightOf("one\ntwo\nthree")).toBe(10); + }); + + /** + * 60 columns is the narrowest terminal the composer has to survive: + * the chat column is 56 wide once the root padding is taken, and the + * rail is already hidden at that width. The meta group is the only + * thing allowed to give up columns — a clipped button reads as a + * rendering bug, a clipped model name reads as a long model name. + */ + it("keeps the send button whole, on the buffer row, at 56 columns", () => { + const { lastFrame, unmount } = render( + // A column, like the chat surface: the composer takes the + // column's full width rather than its own intrinsic one. + + {"● healthy"}} + contextSlot={ + + } + onChange={() => {}} + onSubmit={() => {}} + /> + , + ); + const lines = strip(lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0); + expect(lines).toHaveLength(8); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(56); + } + // [0] border, [1] pad, [2] buffer, [3] pad, [4] bar pad, + // [5] status bar, [6] bar pad, [7] border. + expect(lines[2] ?? "").toContain(" send → "); + // The bar is where Send used to live; the readout owns that end now. + expect(lines[5] ?? "").not.toContain("send"); + // The readout keeps its full gauge; the model name is what gives. + expect(lines[5] ?? "").toContain("context [======= ] 115.3k/131.1k"); + unmount(); + }); + + /** + * Send rides the *last* line of a multi-line buffer, not the first: + * it is the verb for the message being typed, and the caret is at the + * bottom by the time the buffer has grown. + */ + it("drops the send button to the last row of a multi-line buffer", () => { + const { lastFrame, unmount } = render( + + {}} + onSubmit={() => {}} + /> + , + ); + const lines = strip(lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0); + // [0] border, [1] pad, [2..4] buffer, [5] pad, … + expect(lines[2] ?? "").not.toContain("send"); + expect(lines[3] ?? "").not.toContain("send"); + expect(lines[4] ?? "").toContain(" send → "); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(56); + } unmount(); }); @@ -107,7 +237,12 @@ describe("PromptShell", () => { unmount(); }); - it("omits the meta-row when neither model nor right-slot is set", () => { + /** + * The bar is unconditional now — it carries the buttons, so it cannot + * come and go with the model label the way the old meta-row did + * without the composer changing height mid-session. + */ + it("keeps the action bar with no model and no slots", () => { const { lastFrame, unmount } = render( { ); const frame = strip(lastFrame() ?? ""); expect(frame).not.toContain("llama.cpp"); + expect(frame).toContain("send"); unmount(); }); }); diff --git a/src/tui/components/prompt-shell.tsx b/src/tui/components/prompt-shell.tsx index 18a31934..8ecc150d 100644 --- a/src/tui/components/prompt-shell.tsx +++ b/src/tui/components/prompt-shell.tsx @@ -1,21 +1,41 @@ -import { Box, Text } from "ink"; +import { Box } from "ink"; import type { ReactElement } from "react"; +import type { ComposerBackendMeta } from "../composer-switch/composer-switch-rows.js"; import { useRotatingPlaceholder } from "../hooks/use-rotating-placeholder.js"; +import { readableOn } from "../theme/readable-foreground.js"; import { theme } from "../theme/theme.js"; +import { ComposerSendButton } from "./composer-send-button.js"; import { MultiLineEditor, type MultiLineEditorProps } from "./multi-line-editor.js"; +import { PromptMetaBar } from "./prompt-meta-bar.js"; /** - * Visual shell around `MultiLineEditor` modelled after the opencode - * prompt: a left "tail" column terminated by a `╹` cap, optional - * rotating placeholder, and a meta-row underneath that surfaces the - * active model. The editor itself runs in `bare` mode so the chrome is - * fully owned here. + * The composer: a framed input field with Send in it and a toolbar under + * it. + * + * It used to be an opencode-style left "tail" — a single border column + * down the left of the editor, capped by a `╹`. That reads as a quote + * block, not as a place you type into, and it gave the two things the + * composer needs to advertise (send, reference a file) nowhere to live. + * A closed frame plus an action bar is the shape every operator already + * knows from every other message box they have used, and it costs one + * row *less* than the tail did: border, editor, bar, border — where the + * tail spent a top pad, a blank row above the meta and the cap glyph. + * + * Send sits **inside** the field, on the right of the buffer row, rather + * than on the bar under it: it is the verb for the text beside it, and + * keeping it out of the bar leaves that row free for the status readouts + * the chat surface passes in. + * + * The frame is deliberately the app's only fully-boxed surface besides + * modals. Bounded height matters: Ink 7 does not clip a frame taller + * than the terminal, it overlaps the lines above it (the hazard + * `splash-fit.ts` exists to document), so the composer grows only with + * the buffer the operator typed and never with its own chrome. * * Out-of-scope (deferred for parity with opencode): * - bracketed paste with image bytes (Ink delivers cooked stdin) - * - mouse interactions / hover (Ink has no mouse layer) * - extmark "chips" inside the textarea (e.g. coloured `@file.ts`) - * - alpha / fade-in animations on the meta-row + * - alpha / fade-in animations on the action bar * * The shell does **not** open the autocomplete popup — slash-palette * stays where it lived before, rendered by the parent above the editor. @@ -33,7 +53,12 @@ export interface PromptShellProps rotatingPlaceholders?: readonly string[]; /** Rotation period in milliseconds. Defaults to 4000. */ placeholderRotationMs?: number; - /** Active model alias rendered into the meta-row (e.g. `qwen3-30b`). */ + /** + * The route's backend kind (cloud / local / custom) and its health + * dot, rendered as the first of the action bar's three controls. + */ + backend?: ComposerBackendMeta | null; + /** Active model alias rendered into the action bar (e.g. `qwen3-30b`). */ model?: string | null; /** * Optional provider hint shown after the model (e.g. `llama.cpp`). @@ -41,14 +66,22 @@ export interface PromptShellProps */ provider?: string | null; /** - * Optional content rendered at the start of the meta-row, before the + * Turns the model slot into a `download model` call to action — + * managed-local route with nothing on disk to run. + */ + needsModelDownload?: boolean; + /** + * Optional content rendered at the start of the action bar, before the * model/provider labels. Used by the chat surface to show the live * LLM health pill. Separated by a dot from the model when both are * present. */ leftSlot?: ReactElement | null; - /** Optional content rendered on the right-hand side of the meta-row. */ + /** Optional content rendered at the toolbar's right end. */ rightSlot?: ReactElement | null; + /** Optional context readout, rendered at the action bar's right end. */ + contextSlot?: ReactElement | null; + modeSlot?: ReactElement | null; } export function PromptShell(props: PromptShellProps): ReactElement { @@ -56,118 +89,142 @@ export function PromptShell(props: PromptShellProps): ReactElement { placeholder, rotatingPlaceholders, placeholderRotationMs = 4000, + backend, model, provider, + needsModelDownload, leftSlot, rightSlot, + contextSlot, + modeSlot, focus, disabled, value, + onChange, + onSubmit, + mouseLayer, ...editorProps } = props; + // Rotate only while the phrase is on screen. `effectivePlaceholder` + // below already blanks it for a non-empty buffer; without the same + // condition on the timer, typing left a four-second full-frame repaint + // running behind the composer for the rest of the session. + const placeholderVisible = value.length === 0; const rotated = useRotatingPlaceholder( rotatingPlaceholders ?? [], placeholderRotationMs, + placeholderVisible, ); - const effectivePlaceholder = - value.length === 0 ? (rotated ?? placeholder ?? "") : ""; + const effectivePlaceholder = placeholderVisible + ? (rotated ?? placeholder ?? "") + : ""; const accent = focus && !disabled ? theme.colors.accent : theme.colors.border; - // Render the meta-row whenever any slot is occupied. With the live - // LLM-health pill being a permanent left-slot tenant, this means the - // row is effectively always rendered after mount — keeping the - // layout stable so the input does not jump up by one cell the moment - // `/props` lands. - const showMeta = - Boolean(model) || - Boolean(provider) || - Boolean(leftSlot) || - Boolean(rightSlot); + // Measured, not assumed: `readableOn` weighs the panel's ground + // against both ends of the palette's chip pair and takes the better + // one, so the buffer stays legible whichever side of the line the + // active theme sits on. + const composerInk = readableOn(theme.colors.badgeBackground); + // Send is live on exactly the condition Enter is: a non-blank buffer + // in an editor that is accepting input. `handleEditorSubmit` drops a + // blank buffer anyway, but a button that visibly does nothing when + // pressed is a bug report waiting to happen. + const canSend = !disabled && value.trim().length > 0; return ( - + // The breathing row that used to be `marginTop={1}` here lives in + // `ComposerOverlay` now: a margin inside the overlay's mouse + // backstop would count into its rectangle and turn the one + // see-through row above the frame click-dead. + + {/* + The design seats the composer on its own panel rather than on the + page. `badgeBackground` is the palette's one-step-off-the-ground + surface, so the panel reads on every theme. + + It used to rely on the buffer being *uncoloured* — inheriting the + terminal's default ink — and that assumption only holds while the + panel and the terminal are on the same side of the light/dark + line. They need not be: `classic-light` paints `#dde4f4` here, so + anyone running a light palette in a dark terminal typed light + text onto a light panel and could not read what they were + writing. The ink is measured against the ground now, the same way + every chip does it. + */} - - {showMeta ? ( - - + {/* + `minWidth={0}` is what lets the editor actually give up the + columns the button takes: a Yoga flex child defaults to its + content's min-width, so without this the row would overflow + the frame instead of the text rewrapping. + */} + + + + + onSubmit(value)} /> - {rightSlot ? {rightSlot} : null} - ) : null} + + - ); } - -interface MetaLeftProps { - leftSlot: ReactElement | null; - model: string | null; - provider: string | null; -} - -const MODEL_LABEL_MAX_LEN = 32; - -function MetaLeft({ - leftSlot, - model, - provider, -}: MetaLeftProps): ReactElement { - if (!leftSlot && !model && !provider) { - return ; - } - const cleanModel = model ? formatModel(model) : null; - // Wrap the optional `leftSlot` in a `` so neighbouring spans - // (a leading dot separator before the model) stay on the same line - // without Yoga inserting an inline break between Box children. - return ( - - {leftSlot ? {leftSlot} : null} - {leftSlot && (cleanModel || provider) ? ( - - {" "} - {theme.glyphs.dotSeparator}{" "} - - ) : null} - {cleanModel ? ( - - {cleanModel} - - ) : null} - {cleanModel && provider ? ( - - {" "} - {theme.glyphs.dotSeparator}{" "} - - ) : null} - {provider ? ( - {provider} - ) : null} - - ); -} - -function formatModel(model: string): string { - const stripped = model.replace(/\.gguf$/i, ""); - if (stripped.length <= MODEL_LABEL_MAX_LEN) return stripped; - return `${stripped.slice(0, MODEL_LABEL_MAX_LEN - 1)}…`; -} diff --git a/src/tui/components/providers-panel.tsx b/src/tui/components/providers-panel.tsx index ebea65e2..fa4a757c 100644 --- a/src/tui/components/providers-panel.tsx +++ b/src/tui/components/providers-panel.tsx @@ -1,8 +1,10 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; import { theme } from "../theme/theme.js"; import type { ProvidersPanelState } from "../providers/providers-panel-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; export function ProvidersPanel(props: { panel: ProvidersPanelState; @@ -30,19 +32,31 @@ export function ProvidersPanel(props: { ); } - const lines: string[] = ["Providers (text LLM + embeddings)", ""]; + // Each line is its own element rather than one joined string: the + // provider rows have to be individually measurable for the mouse + // layer, and a column of one-line Texts renders identically. + const lines: PanelLine[] = [ + { text: "Providers (text LLM + embeddings)" }, + { text: "" }, + ]; if (props.panel.statusLine) { - lines.push(props.panel.statusLine, ""); + lines.push({ text: props.panel.statusLine }, { text: "" }); } if (props.panel.rows.length === 0) { - lines.push("(no providers — press n to add OpenRouter or OpenAI-compatible)"); + lines.push({ + text: "(no providers — press n to add OpenRouter or OpenAI-compatible)", + }); } else { props.panel.rows.forEach((row, i) => { const mark = i === props.panel.cursor ? ">" : " "; const flags = [ row.isActiveText ? "TEXT*" : "", row.isActiveEmbedding ? "EMB*" : "", - row.hasApiKey ? "key" : "no-key", + row.kind === SUBSCRIPTION_CLI_KIND + ? "cli auth" + : row.hasApiKey + ? "key" + : "no-key", ] .filter(Boolean) .join(" "); @@ -52,20 +66,44 @@ export function ProvidersPanel(props: { ] .filter(Boolean) .join(" "); - lines.push( - `${mark} ${row.id} [${row.kind}] ${flags}${models ? ` · ${models}` : ""}`, - ); + lines.push({ + text: `${mark} ${row.id} [${row.kind}] ${flags}${models ? ` · ${models}` : ""}`, + rowIndex: i, + }); }); } lines.push( - "", - "j/k move · n add · c configure cloud · d remove", - "t active text · e active embedding · r refresh", + { text: "" }, + { text: "j/k move · n add · c configure cloud · d remove" }, + { text: "t active text · e active embedding · r refresh" }, ); return ( - {lines.join("\n")} + {lines.map((line, idx) => + line.rowIndex === undefined ? ( + {line.text} + ) : ( + + mouse.dispatch({ + type: "providers_cursor_set", + row: line.rowIndex as number, + }) + } + > + {line.text} + + ), + )} ); } + +/** One rendered line; `rowIndex` marks the clickable provider rows. */ +interface PanelLine { + text: string; + rowIndex?: number; +} diff --git a/src/tui/components/providers-wizard-measure.test.tsx b/src/tui/components/providers-wizard-measure.test.tsx new file mode 100644 index 00000000..6186a180 --- /dev/null +++ b/src/tui/components/providers-wizard-measure.test.tsx @@ -0,0 +1,56 @@ +import { Box } from "ink"; +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; + +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; +import { + KIND_OPTIONS, + measureProvidersWizard, +} from "./providers-wizard-measure.js"; +import { ProvidersWizard } from "./providers-wizard.js"; + +const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, ""); + +/** + * The measure is held against the rendered wizard, so the number the + * onboarding surface centres on cannot drift from what the wizard + * draws — the same contract every other setup step's measure test + * makes, which is what replaced the hardcoded 96-column guess. + */ +describe("measureProvidersWizard", () => { + const widestOption = KIND_OPTIONS.reduce((a, b) => + b.label.length > a.label.length ? b : a, + ); + + it("gives the widest provider row exactly the room it draws in", () => { + const width = measureProvidersWizard(); + // Window the pick list around the widest row so it is on screen. + const wizard = { + ...createProvidersWizardState("add"), + cursor: KIND_OPTIONS.findIndex((o) => o.label === widestOption.label), + }; + const view = render( + + + , + ); + const lines = strip(view.lastFrame() ?? "").split("\n"); + view.unmount(); + // Un-truncated: the row that decides the measure survives it whole. + expect(lines.some((line) => line.includes(widestOption.label))).toBe(true); + // And the box spends exactly the measured width, no more. + const widest = lines.reduce( + (max, line) => Math.max(max, line.trimEnd().length), + 0, + ); + expect(widest).toBe(width); + }); + + it("stays inside the 100-column terminal the flow asks for", () => { + // The cap the guess used to encode, now a consequence of measuring: + // everything deterministic fitted a 100-column terminal before this + // slice, and the measure must keep saying so. + expect(measureProvidersWizard()).toBeLessThanOrEqual(100); + }); +}); diff --git a/src/tui/components/providers-wizard-measure.ts b/src/tui/components/providers-wizard-measure.ts new file mode 100644 index 00000000..d8fb03e5 --- /dev/null +++ b/src/tui/components/providers-wizard-measure.ts @@ -0,0 +1,102 @@ +/** + * How wide the providers wizard's box needs to be, measured from the + * strings it actually draws. + * + * The wizard renders `width="100%"` panels, so on its own it has no + * opinion about width — but the onboarding surface centres it as a + * block, and a centred block needs a measured width the same way every + * other setup step does. The deterministic content lives here, beside + * the measure, so the two cannot drift: the kind list and the embedding + * catalogs are compiled into the binary, and the hint lines are + * composed from fixed templates plus counters whose maximum is the + * static list's own length. + * + * The live chat catalogs are the one thing deliberately not measured: + * their rows arrive from the network mid-wizard, and every row and hint + * on those screens is drawn `wrap="truncate-end"`, so an over-long + * model id truncates inside the box instead of demanding a wider one — + * sizing the whole surface to a string that may never arrive would let + * the network move the screen. + */ + +import { widestLine } from "../onboarding/centre-onboarding-block.js"; +import { findProviderPreset } from "../providers/provider-presets.js"; +import { + listAimlapiEmbeddingModels, + listOpenRouterEmbeddingModels, +} from "../providers/providers-model-options.js"; +import { + KIND_ROW_ORDER, + type ProvidersWizardKindRow, +} from "../providers/providers-wizard-phases.js"; +import type { ProvidersWizardKind } from "../providers/providers-wizard-state.js"; + +const KIND_LABELS: Record = { + "claude-cli": + "Claude Code subscription (drives your signed-in `claude` CLI — no API key)", + "codex-cli": + "OpenAI Codex subscription (drives your signed-in `codex` CLI — no API key)", + openrouter: "OpenRouter (cloud chat + optional cloud embed)", + aimlapi: "AI/ML API (1000+ models, OpenAI-compatible)", + gemini: "Gemini (Google AI)", + "openai-compatible": "OpenAI-compatible API (custom base URL)", +}; + +function labelForKindRow(row: ProvidersWizardKindRow): string { + if (typeof row !== "object") return KIND_LABELS[row]; + const preset = findProviderPreset(row.presetId); + if (!preset) return row.presetId; + return preset.note ? `${preset.label} — ${preset.note}` : preset.label; +} + +/** + * One flat provider list, matching what other agent CLIs present: the + * two kinds with built-in catalogs, then every known service (#69), then + * the manual entry for anything not listed. Derived from + * `KIND_ROW_ORDER` — the key bindings walk that same list, so a row's + * label and its Enter action can never drift apart. + */ +export const KIND_OPTIONS: readonly { label: string }[] = KIND_ROW_ORDER.map( + (row) => ({ label: labelForKindRow(row) }), +); + +/** Shown while a save is off verifying the key with the provider. */ +export const CHECKING_KEY_HINT = + "checking the key with the provider… (Esc cancels)"; + +/** Two border cells plus one cell of padding either side of the box. */ +const WIZARD_CHROME_COLUMNS = 4; +/** The cursor mark and its trailing gap before every pick-list row. */ +const OPTION_MARK_COLUMNS = 2; + +/** A pick list's hint line at the widest its counter can reach. */ +function hintLine(moveHint: string, count: number, actionsHint: string): string { + return `${moveHint} (${count}/${count}) · ${actionsHint}`; +} + +export function measureProvidersWizard(): number { + const openRouterRows = listOpenRouterEmbeddingModels(); + const aimlapiRows = listAimlapiEmbeddingModels(); + // A counter never exceeds its own list's length, so the widest hint a + // screen can draw uses that screen's count, not the union's. + const embeddingCount = Math.max(openRouterRows.length, aimlapiRows.length); + const optionRows = [...KIND_OPTIONS, ...openRouterRows, ...aimlapiRows].map( + (option) => OPTION_MARK_COLUMNS + option.label.length, + ); + const textLines = widestLine([ + // The wizard opens the flow in `add` mode; a configure title carries + // a provider id, which is operator data and wraps inside the box. + "LLM provider — add provider", + "Chat model (OpenRouter)", + "Chat model (AI/ML API)", + "Embedding backend", + hintLine("j/k move", KIND_OPTIONS.length, "Enter pick · Esc cancel"), + hintLine( + "j/k move", + embeddingCount, + "PgUp/PgDn jump · Enter finish · Esc back", + ), + hintLine("j/k move", embeddingCount, CHECKING_KEY_HINT), + ]); + return WIZARD_CHROME_COLUMNS + Math.max(...optionRows, textLines); +} diff --git a/src/tui/components/providers-wizard.test.tsx b/src/tui/components/providers-wizard.test.tsx index 7406e08b..e810f3db 100644 --- a/src/tui/components/providers-wizard.test.tsx +++ b/src/tui/components/providers-wizard.test.tsx @@ -1,11 +1,15 @@ import { render } from "ink-testing-library"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { refreshAimlapiChatCatalogFromApi } from "../../llm/provider/aimlapi/fetch-aimlapi-chat-catalog.js"; import { refreshOpenRouterChatCatalogFromApi } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; +import { OPENAI_COMPAT_DEFAULT_CHAT_MODEL } from "../providers/providers-model-options.js"; import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js"; import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; -import type { ProvidersWizardKind } from "../providers/providers-wizard-state.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "../providers/providers-wizard-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; function stripAnsi(value: string): string { @@ -89,6 +93,34 @@ describe("ProvidersWizard chat model step", () => { expect(countRows(text, "model-")).toBeLessThanOrEqual(12); }); + it("shows a rejected-submit error alongside the discovered model list", async () => { + // A rejected submit (empty or non-ASCII key) leaves the wizard on the + // chat-model step with `error` set, but the pick list has no error slot + // of its own. Without surfacing it here, the operator's Enter reads as + // doing nothing. + const ids = ["model-a", "model-b"]; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: ids.map((id) => ({ id })) }), + })), + ); + + const wizard = { + ...chatModelStep("https://listed.example/v1"), + error: "API key contains non-ASCII characters. Use a plain ASCII key.", + }; + const { lastFrame } = render(); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + // The model list still renders... + expect(text).toContain("model-a"); + // ...and the submit error is visible under it. + expect(text).toContain("non-ASCII characters"); + }); + it("explains a refused key instead of showing the raw status", async () => { vi.stubGlobal( "fetch", @@ -127,6 +159,57 @@ describe("ProvidersWizard chat model step", () => { }); }); +describe("ProvidersWizard CLI-backed configure step", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens a claude-cli row on its model, not on a key screen", async () => { + // What `c` now reaches. A CLI-backed provider has no key and no + // endpoint, so anything but the model id would be a dead end — and + // the openai-compat placeholder would name a model `claude` rejects. + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const { lastFrame } = render( + , + ); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("Chat model id — claude CLI"); + expect(text).toContain("opus"); + expect(text).toContain("the CLI uses its own session"); + // The key screen's own copy, absent because that phase is skipped. + expect(text).not.toContain("Saved to"); + expect(text).not.toContain(OPENAI_COMPAT_DEFAULT_CHAT_MODEL); + // No endpoint exists behind the CLI; listing must not be attempted. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("tells a codex-cli operator that an empty line is the answer", async () => { + const { lastFrame } = render( + , + ); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("Chat model id — codex CLI"); + expect(text).toContain("the CLI resolves the model"); + }); +}); + describe("ProvidersWizard pick list counter", () => { it("shows a clear Gemini provider row", () => { const { lastFrame } = render( @@ -145,8 +228,55 @@ describe("ProvidersWizard pick list counter", () => { ); const text = stripAnsi(lastFrame() ?? ""); expect(text).toContain( - `j/k move (1/${KIND_ROW_ORDER.length}) · Enter pick · Esc cancel`, + `j/k move (1/${KIND_ROW_ORDER.length}) · Enter pick · / search · Esc cancel`, + ); + }); +}); + +describe("ProvidersWizard search box", () => { + function providerList(search: string | null, cursor = 0) { + return { ...createProvidersWizardState("add"), search, cursor }; + } + + it("advertises the search box on the closed provider list", () => { + const { lastFrame } = render(); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("search: / to search"); + }); + + it("shows the query, the surviving rows, and a counter over the filtered set", () => { + const { lastFrame } = render(); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("search: cli"); + // Both subscription CLI rows survive "cli"; nothing else does. + expect(text).toContain("Claude Code subscription"); + expect(text).toContain("OpenAI Codex subscription"); + expect(text).not.toContain("OpenRouter (cloud chat"); + // The counter names the filtered list, not the 25 rows behind it. + expect(text).toContain("(1/2)"); + // With the box open, j/k are characters and Esc empties it first. + expect(text).toContain("↑/↓ move (1/2) · Enter pick · Esc clears search"); + }); + + it("says so instead of drawing an empty box when nothing matches", () => { + const { lastFrame } = render( + , ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain('no match for "no-such-provider"'); + expect(text).toContain("Backspace to widen it"); + expect(text).toContain("(0/0)"); + expect(text).not.toContain("Gemini (Google AI)"); + }); + + it("highlights the clamped row when the cursor outlives the rows", () => { + // Nothing in the flow leaves a cursor past the end, but a catalog + // refresh landing between two keypresses can, and the highlight has + // to stay on a row that exists — it is what Enter selects. + const { lastFrame } = render(); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("> OpenAI Codex subscription"); + expect(text).toContain("(2/2)"); }); }); @@ -287,3 +417,69 @@ describe("ProvidersWizard cloud model pickers", () => { expect(text).not.toContain("vendor/model-000"); }); }); + +/** + * Reported as "I added a random key and got stuck on embedding + * selection". The key check did fire and did refuse the save — nothing + * was written — but a list screen had nowhere to print `wizard.error` + * and nowhere to say a check was running, so Enter looked like a key + * that did nothing, forever. + */ +describe("ProvidersWizard surfaces the key check on list screens", () => { + // The chat-model case mounts `CatalogChatModelStep`, which fires a + // live catalog refresh on mount. Keep it offline: a real response + // would replace the module cache the windowing tests assert against. + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("offline"); + }), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function embeddingStep(overrides: Partial) { + return { + ...createProvidersWizardState("add", { kind: "openrouter" }), + phase: "pick_embedding" as const, + cursor: 0, + ...overrides, + }; + } + + it("prints the refusal on the embedding screen", () => { + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("OpenRouter does not recognize this key"); + }); + + it("says a check is in flight while the save waits on the provider", () => { + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("checking the key with the provider"); + expect(text).toContain("Esc cancels"); + }); + + it("prints the refusal on the chat-model screen too", () => { + const { lastFrame } = render( + , + ); + expect(stripAnsi(lastFrame() ?? "")).toContain("no balance"); + }); +}); diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index c713409d..1ddbb112 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -10,69 +10,32 @@ import { getCachedOpenRouterChatPicks, refreshOpenRouterChatCatalogFromApi, } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; +import { listCompatChatModelPicks } from "../providers/providers-wizard-key-bindings.js"; import { apiKeyForWizard, baseUrlForWizard, - listCompatChatModelPicks, -} from "../providers/providers-wizard-key-bindings.js"; + emptyKeyMeaningForWizard, + envHintForWizard, +} from "../providers/providers-wizard-target.js"; +import { PasteFieldTarget } from "../context-menu/paste-field-target.js"; +import { pasteIntoProvidersWizard } from "../providers/providers-wizard-paste.js"; import { theme } from "../theme/theme.js"; import { findProviderPreset } from "../providers/provider-presets.js"; import { - KIND_ROW_ORDER, - listChatModelsForKind, - type ProvidersWizardKindRow, + visibleKindRows, + visibleRowsForPhase, } from "../providers/providers-wizard-phases.js"; import { GEMINI_DEFAULT_CHAT_MODEL, - listAimlapiEmbeddingModels, - listOpenRouterEmbeddingModels, OPENAI_COMPAT_DEFAULT_BASE_URL, OPENAI_COMPAT_DEFAULT_CHAT_MODEL, } from "../providers/providers-model-options.js"; -import type { - ProvidersWizardKind, - ProvidersWizardState, -} from "../providers/providers-wizard-state.js"; -import { renderPickList } from "./wizard-pick-list.js"; - -const KIND_LABELS: Record = { - openrouter: "OpenRouter (cloud chat + optional cloud embed)", - aimlapi: "AI/ML API (aimlapi.com — 500+ models, OpenAI-compatible)", - gemini: "Gemini (Google AI)", - "openai-compatible": "OpenAI-compatible API (custom base URL)", -}; - -function labelForKindRow(row: ProvidersWizardKindRow): string { - if (typeof row !== "object") return KIND_LABELS[row]; - const preset = findProviderPreset(row.presetId); - if (!preset) return row.presetId; - return preset.note ? `${preset.label} — ${preset.note}` : preset.label; -} - -/** - * One flat provider list, matching what other agent CLIs present: the - * two kinds with built-in catalogs, then every known service (#69), then - * the manual entry for anything not listed. Derived from - * `KIND_ROW_ORDER` — the key bindings walk that same list, so a row's - * label and its Enter action can never drift apart. - */ -const KIND_OPTIONS = KIND_ROW_ORDER.map((row) => ({ - label: labelForKindRow(row), -})); - -/** - * Env var named on the key screen. A preset names its own variable; - * naming the shared compat one there would promise Groq's key a home it - * does not use. - */ -function envHintForWizard(w: ProvidersWizardState): string { - const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; - if (preset) return preset.envVar; - if (w.kind === "openrouter") return "OPENROUTER_API_KEY"; - if (w.kind === "aimlapi") return "AIMLAPI_API_KEY"; - if (w.kind === "gemini") return "GEMINI_API_KEY"; - return "OPENAI_COMPAT_API_KEY"; -} +import { CLAUDE_CLI_DEFAULT_CHAT_MODEL } from "../../llm/provider/subscription-cli/claude-cli-models.js"; +import { subscriptionCliForWizardKind } from "../providers/providers-wizard-state.js"; +import type { ProvidersWizardState } from "../providers/providers-wizard-state.js"; +import type { WizardMouseRoute } from "../providers/route-wizard-key.js"; +import { CHECKING_KEY_HINT } from "./providers-wizard-measure.js"; +import { pickListHints, renderPickList } from "./wizard-pick-list.js"; /** Service name for headings: the preset label wins over the raw kind. */ function providerLabelForWizard(w: ProvidersWizardState): string { @@ -102,6 +65,32 @@ function maskedKey(buffer: string): string { return masked + extra; } +/** + * Actions hint for a list screen, with the key check folded in. + * + * A pick screen is where the save happens for the curated kinds, so it + * is also where the operator waits on the provider answering. Saying + * nothing for those seconds is what made a refused key read as a frozen + * wizard. While the check runs the normal actions are REPLACED rather + * than appended to: every key but Esc is swallowed until it settles, so + * listing them would be a lie, and the combined line was long enough to + * lose "(Esc cancels)" off the right edge of a 100-column terminal. + */ +function listActionsHint(base: string, submitting: boolean): string { + return submitting ? CHECKING_KEY_HINT : base; +} + +/** + * One labelled single-line field. + * + * The text in this file — titles, the typed value, the masked key — + * reads `accent`. `accentSoft` is the house palette's fill (`#294793`), + * which the design lifts to `accent` the moment the same hue has to be + * read rather than sat on; painting text with it put these screens at + * roughly 2:1 against the terminal. Box borders keep the fill tone: + * the brief fences the lift to text, and a frame is chrome — looked + * at, not read. + */ function renderLineField(props: { title: string; value: string; @@ -120,15 +109,17 @@ function renderLineField(props: { marginY={1} width="100%" > - + {props.title} - + {/* Right-click paste on the value line: every wizard mount routes + the clipboard through the wizard's own key grammar. */} + {"> "} - + {display} - + {props.error ? ( ! {props.error} ) : null} @@ -139,6 +130,8 @@ function renderLineField(props: { function CompatChatModelStep(props: { wizard: ProvidersWizardState; + maxRows?: number; + route?: WizardMouseRoute; }): ReactElement { const w = props.wizard; const baseUrl = baseUrlForWizard(w); @@ -157,9 +150,13 @@ function CompatChatModelStep(props: { let alive = true; setStatus({ loading: true, error: null }); const apiKey = apiKeyForWizard(w); + // A preset knows how its service wants credentials presented; without + // it this probe would 401 for a vendor that is not Bearer-authenticated + // and the operator would be told their valid key was rejected. + const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; const fetchModels = isGemini ? fetchGeminiModels(apiKey) - : fetchOpenAiCompatModels(baseUrl, apiKey); + : fetchOpenAiCompatModels(baseUrl, apiKey, preset); fetchModels.then( () => { if (alive) setStatus({ loading: false, error: null }); @@ -180,6 +177,24 @@ function CompatChatModelStep(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [baseUrl, isCompat, isGemini]); + // A CLI-backed provider has no endpoint to list and no key screen + // behind it, so this is the whole configure flow for one: the id the + // CLI's own `--model` accepts. Naming the openai-compat placeholder + // here would suggest `gpt-5.4-mini` is a valid answer for `claude`. + const cli = w.kind ? subscriptionCliForWizardKind(w.kind) : null; + if (cli) { + return renderLineField({ + title: `Chat model id — ${cli} CLI`, + value: w.chatModelLine, + placeholder: + cli === "claude" + ? CLAUDE_CLI_DEFAULT_CHAT_MODEL + : "(empty — the CLI resolves the model)", + hint: "Enter to save · Esc back · no API key: the CLI uses its own session", + error: w.error, + }); + } + const picks = listCompatChatModelPicks(w); if (picks.length > 0) { const source = isGemini @@ -189,13 +204,24 @@ function CompatChatModelStep(props: { title: `Chat model — ${picks.length} ${source}`, options: picks.map((id) => ({ label: id })), cursor: w.cursor, + wizard: w, + ...(props.route === undefined ? {} : { route: props.route }), moveHint: "↑/↓ move", - actionsHint: + actionsHint: listActionsHint( "PgUp/PgDn jump · Enter select · type to enter an id by hand · Esc back", + w.submitting, + ), + ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), + // A rejected submit (empty or non-ASCII key) leaves the wizard on + // this step with `error` set; the pick list renders it inside the + // box, so Enter never reads as doing nothing. + error: w.error, }); } - const hint = !canList + const hint = w.submitting + ? CHECKING_KEY_HINT + : !canList ? "Enter to save · Esc back" : status.loading ? isGemini @@ -230,6 +256,8 @@ function CompatChatModelStep(props: { function CatalogChatModelStep(props: { wizard: ProvidersWizardState; kind: "openrouter" | "aimlapi"; + maxRows?: number; + route?: WizardMouseRoute; }): ReactElement { const { wizard: w, kind } = props; const getCached = @@ -264,45 +292,82 @@ function CatalogChatModelStep(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [kind]); - const title = + const service = kind === "openrouter" ? "Chat model (OpenRouter)" : "Chat model (AI/ML API)"; - const actionsHint = loading - ? "PgUp/PgDn jump · Enter select · Esc back · updating model list from API…" - : "PgUp/PgDn jump · Enter select · Esc back"; + // The refresh notice rides on the title rather than the hint line: it + // describes the list, not a key, and the hint already runs to the edge + // of a 100-column terminal once the search box has had its say. + const title = loading + ? `${service} · updating model list from API…` + : service; + const hints = pickListHints( + w.search, + "PgUp/PgDn jump · Enter select", + "Esc back", + "Esc clears search, again backs out", + ); return renderPickList({ title, - options: listChatModelsForKind(kind), + options: visibleRowsForPhase(w), cursor: w.cursor, - moveHint: "j/k move", - actionsHint, + wizard: w, + ...(props.route === undefined ? {} : { route: props.route }), + moveHint: hints.moveHint, + actionsHint: listActionsHint(hints.actionsHint, w.submitting), + search: w.search, + ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), + error: w.error, }); } +/** + * `maxRows` is the terminal budget the wizard must fit in, not a + * preference. The wizard is a modal: `LlmPanel` hands it the whole tab + * budget and renders nothing behind it, and every box below sizes + * itself so the frame cannot outgrow the terminal. It used to be drawn + * on top of the full LLM panel with no budget at all, and Ink 7 answers + * an over-tall frame by painting later lines over earlier ones — which + * is how a 24-row provider list arrived on screen as seven half-eaten + * rows with OpenRouter's row wearing Codex's tail (reports #1 and #2). + */ export function ProvidersWizard(props: { wizard: ProvidersWizardState; + maxRows?: number; + /** + * How row clicks reach `wizard`. Omitted by the store-backed mounts, + * whose wizard lives at `providersPanel.wizard` (the default route); + * `CloudProviderOnboarding` keeps its wizard in component state and + * must pass its own, or clicks would act on the wrong wizard slice. + */ + mouseRoute?: WizardMouseRoute; }): ReactElement { const w = props.wizard; + const maxRows = props.maxRows === undefined ? {} : { maxRows: props.maxRows }; + const route = props.mouseRoute === undefined ? {} : { route: props.mouseRoute }; const modeLabel = w.mode === "configure" ? `configure ${w.providerId}` : "add provider"; if (w.phase === "pick_kind") { return renderPickList({ title: `LLM provider — ${modeLabel}`, - options: KIND_OPTIONS, + options: visibleKindRows(w.search), cursor: w.cursor, - moveHint: "j/k move", - actionsHint: "Enter pick · Esc cancel", + wizard: w, + ...route, + ...pickListHints( + w.search, + "Enter pick", + "Esc cancel", + "Esc clears search, again cancels", + ), + search: w.search, + ...maxRows, + error: w.error, }); } if (w.phase === "api_key") { const envHint = envHintForWizard(w); - const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; - // Local servers and keyless-listing services save with an empty key; - // promising ".env only" here would contradict their own list rows. - const emptyMeans = - preset && (preset.local || preset.listsModelsWithoutKey) - ? "Optional for this service — leave empty to connect without a key." - : "Leave empty only if the key is already in .env."; + const emptyMeans = emptyKeyMeaningForWizard(w); return ( - + API key — {providerLabelForWizard(w)} - Saved to {".env"} as{" "} + Saved to {".env"} as{" "} {envHint} (mode 0600). {emptyMeans} - + {/* The api_key screen is where paste matters most: keys are + never typed by hand. Same adapter, same burst path. */} + {"> "} - {maskedKey(w.apiKeyBuffer)} - + {maskedKey(w.apiKeyBuffer)} + {w.error ? ( ! {w.error} ) : null} Enter to continue · Esc back · Backspace edit - {w.submitting ? " · saving…" : ""} + {w.submitting ? ` · ${CHECKING_KEY_HINT}` : ""} ); @@ -338,26 +405,32 @@ export function ProvidersWizard(props: { w.phase === "pick_chat_model" && (w.kind === "openrouter" || w.kind === "aimlapi") ) { - return ; + return ; } - if (w.phase === "pick_embedding" && w.kind === "openrouter") { - return renderPickList({ - title: "Embedding backend", - options: listOpenRouterEmbeddingModels(), - cursor: w.cursor, - moveHint: "j/k move", - actionsHint: "PgUp/PgDn jump · Enter finish · Esc back", - }); - } - - if (w.phase === "pick_embedding" && w.kind === "aimlapi") { + if ( + w.phase === "pick_embedding" && + (w.kind === "openrouter" || w.kind === "aimlapi") + ) { + // This is the last screen of the curated flow, so Enter here is the + // save — and the save is what runs the key check. + const hints = pickListHints( + w.search, + "PgUp/PgDn jump · Enter finish", + "Esc back", + "Esc clears search, again backs out", + ); return renderPickList({ title: "Embedding backend", - options: listAimlapiEmbeddingModels(), + options: visibleRowsForPhase(w), cursor: w.cursor, - moveHint: "j/k move", - actionsHint: "PgUp/PgDn jump · Enter finish · Esc back", + wizard: w, + ...route, + moveHint: hints.moveHint, + actionsHint: listActionsHint(hints.actionsHint, w.submitting), + search: w.search, + ...maxRows, + error: w.error, }); } @@ -372,7 +445,7 @@ export function ProvidersWizard(props: { } if (w.phase === "chat_model_line") { - return ; + return ; } return ( diff --git a/src/tui/components/queued-messages.test.tsx b/src/tui/components/queued-messages.test.tsx new file mode 100644 index 00000000..c2ab959a --- /dev/null +++ b/src/tui/components/queued-messages.test.tsx @@ -0,0 +1,36 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { previewOf, QueuedMessages } from "./queued-messages.js"; + +describe("QueuedMessages", () => { + it("renders nothing when the queue is empty", () => { + const { lastFrame } = render(); + expect(lastFrame()?.trim()).toBe(""); + }); + + it("lists parked messages one per row", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("run the tests"); + expect(frame).toContain("then deploy"); + }); + + it("collapses everything past the third row into a counter", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("and 2 more queued"); + expect(frame).not.toContain("queued: d"); + }); + + it("flattens newlines so a multi-line message stays one row", () => { + expect(previewOf("first\nsecond", 40)).toBe("first second"); + }); + + it("elides a preview past the width budget", () => { + expect(previewOf("x".repeat(50), 10)).toBe(`${"x".repeat(9)}…`); + }); +}); diff --git a/src/tui/components/queued-messages.tsx b/src/tui/components/queued-messages.tsx new file mode 100644 index 00000000..6e4017b8 --- /dev/null +++ b/src/tui/components/queued-messages.tsx @@ -0,0 +1,61 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { theme } from "../theme/theme.js"; + +interface QueuedMessagesProps { + /** Messages the operator submitted while a turn was still running. */ + queued: readonly string[]; + /** Terminal width available to the strip; used to elide long previews. */ + width?: number; +} + +/** How many rows we render before collapsing the rest into a counter. */ +const MAX_VISIBLE_ROWS = 3; +/** Fallback preview width when the caller does not know the terminal size. */ +const DEFAULT_PREVIEW_WIDTH = 60; + +/** + * Dim strip rendered directly above the prompt listing messages that are + * parked behind the running turn. It exists because the queue used to be + * invisible: `ChatOrchestrator` has always buffered submissions made while + * a turn was in flight, but nothing on screen told the operator that their + * message had been accepted rather than swallowed. + * + * Renders nothing when the queue is empty so the prompt does not jump by a + * row on every turn boundary. + */ +export function QueuedMessages({ + queued, + width, +}: QueuedMessagesProps): ReactElement | null { + if (queued.length === 0) return null; + const previewWidth = Math.max(20, (width ?? DEFAULT_PREVIEW_WIDTH) - 8); + const visible = queued.slice(0, MAX_VISIBLE_ROWS); + const hidden = queued.length - visible.length; + return ( + + {visible.map((text, idx) => ( + + {" "} + {theme.glyphs.dotSeparator} queued: {previewOf(text, previewWidth)} + + ))} + {hidden > 0 ? ( + + {" "} + {theme.glyphs.dotSeparator} …and {hidden} more queued + + ) : null} + + ); +} + +/** + * Single-line preview: newlines become spaces (the strip is one row per + * message) and anything past `max` is elided. + */ +export function previewOf(text: string, max: number): string { + const flat = text.replace(/\s+/g, " ").trim(); + if (flat.length <= max) return flat; + return `${flat.slice(0, Math.max(1, max - 1))}…`; +} diff --git a/src/tui/components/render-progress-bar.test.ts b/src/tui/components/render-progress-bar.test.ts new file mode 100644 index 00000000..656df44a --- /dev/null +++ b/src/tui/components/render-progress-bar.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { renderProgressBar } from "./render-progress-bar.js"; + +describe("renderProgressBar", () => { + it("is always exactly `width` cells wide", () => { + for (const percent of [0, 1, 37, 99, 100]) { + expect(renderProgressBar(percent, 8)).toHaveLength(8); + } + }); + + it("fills proportionally, rounding to the nearest cell", () => { + expect(renderProgressBar(0, 8)).toBe(" "); + expect(renderProgressBar(50, 8)).toBe("==== "); + expect(renderProgressBar(100, 8)).toBe("========"); + // 37% of 8 is 2.96 cells. + expect(renderProgressBar(37, 8)).toBe("=== "); + }); + + it("clamps a percentage past the end instead of overflowing the row", () => { + expect(renderProgressBar(140, 8)).toBe("========"); + }); +}); diff --git a/src/tui/components/render-progress-bar.ts b/src/tui/components/render-progress-bar.ts new file mode 100644 index 00000000..08b632a5 --- /dev/null +++ b/src/tui/components/render-progress-bar.ts @@ -0,0 +1,17 @@ +/** + * The app's one progress bar: `==== ` at a fixed width. + * + * It was written twice, byte for byte, in `llm-panel.tsx` and + * `local-models-panel.tsx` — and the composer's context chip would have + * made three. A terminal has one honest way to draw a fill, and every + * surface that draws one should draw the same one, or the panels start + * to look like screenshots from different applications. + * + * `=` rather than a block glyph on purpose: the panels ship inside a + * `[...]` bracket, and box-drawing blocks render at inconsistent widths + * on the terminals this runs on. + */ +export function renderProgressBar(percent: number, width: number): string { + const filled = Math.min(width, Math.round((percent / 100) * width)); + return "=".repeat(filled) + " ".repeat(Math.max(0, width - filled)); +} diff --git a/src/tui/components/session-delete-modal.tsx b/src/tui/components/session-delete-modal.tsx new file mode 100644 index 00000000..f25c2cd4 --- /dev/null +++ b/src/tui/components/session-delete-modal.tsx @@ -0,0 +1,194 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { + MouseTarget, + useMouseCommands, + useMouseTarget, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { chromeTheme } from "../theme/theme.js"; +import type { SessionDeleteConfirm } from "../tui-state.js"; + +/** Popup width, clamped to the pane on narrow windows. */ +const PREFERRED_WIDTH = 48; + +interface SessionDeleteModalProps { + confirm: SessionDeleteConfirm; + /** Rows available in the pane the dialog floats over. */ + availableRows: number; + /** Columns available in that pane. */ + availableColumns: number; + onConfirm: (sessionId: string) => void; + onCancel: () => void; + onFocus: (cursor: "yes" | "cancel") => void; +} + +/** + * "Delete the session?" — the same surface as the operator menu: a + * painted panel centred in the content pane, the app dimmed behind it, + * and the same mouse contract (click a control, click outside to + * dismiss, wheel swallowed rather than scrolling the transcript + * underneath). + * + * The two controls are deliberately unlike each other. `Yes` is the + * raised chip the composer uses for Send — the affirmative control + * everywhere else in the app. `Cancel` is the same footprint drawn as + * an outline: a frame in the panel's own foreground with no fill, which + * is what "secondary" looks like when a terminal has no greys to spend. + * The cursor still starts on Cancel: Enter on a dialog nobody read must + * not delete a thread. + */ +export function SessionDeleteModal({ + confirm, + availableRows, + availableColumns, + onConfirm, + onCancel, + onFocus, +}: SessionDeleteModalProps): ReactElement { + const width = Math.max(24, Math.min(PREFERRED_WIDTH, availableColumns - 2)); + const inner = width - 2; + // Title, blank, preview, blank, the button row, plus two border rows. + const height = 7; + const offsetTop = Math.max(0, Math.floor((availableRows - height) / 2)); + const offsetLeft = Math.max(0, Math.floor((availableColumns - width) / 2)); + // Same trick as the menu: the ref rides the absolutely-positioned + // panel itself, so no wrapper Box can displace the offsets. Presses + // are claimed and dropped so a click on the panel's own chrome cannot + // fall through to the backdrop and dismiss it. + const ref = useMouseTarget( + (hit) => { + if (hit.event.kind === "wheel") return true; + return isPrimaryPress(hit.event); + }, + { layer: MOUSE_LAYER_MODAL }, + ); + return ( + + + {fit(" DELETE THE SESSION?", inner)} + + {fit("", inner)} + + {/* + One line, always: the preview is the thread's first prompt, and + a pasted multi-line one would grow a panel whose height is a + constant — pushing Yes and Cancel out of the pane the dialog is + centred in. + */} + {fit(` ${oneLine(confirm.preview)}`, inner)} + + {fit("", inner)} + + {" "} + onConfirm(confirm.sessionId)} + onFocus={() => onFocus("yes")} + /> + {" "} + onFocus("cancel")} + /> + + + ); +} + +/** + * One dialog control. + * + * `primary` is the composer's Send chip — a light face under dark text, + * the affirmative control everywhere else in the app. `outline` is the + * same label inside a bracket frame with no fill: brackets rather than + * a bordered Box because Ink draws a border on its own rows, which + * would make a two-button row three rows tall and push the dialog out + * of shape. On one line, `[ Cancel ]` is what a frame looks like. + * + * Focus is a leading chevron, not a colour: the panel is painted, so a + * colour change is quiet against it, and under NO_COLOR it says nothing + * at all. The chevron is the same cursor mark the rail and the menu use. + */ +function ConfirmButton({ + label, + tone, + focused, + onPress, + onFocus, +}: { + label: string; + tone: "primary" | "outline"; + focused: boolean; + onPress: () => void; + onFocus: () => void; +}): ReactElement { + const marker = focused ? chromeTheme.glyphs.chevronRight : " "; + const body = ( + <> + + {marker} + + {tone === "primary" ? ( + + {` ${label} `} + + ) : ( + + {`[ ${label} ]`} + + )} + + ); + const mouse = useMouseCommands(); + if (!mouse) return {body}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onFocus(); + onPress(); + return true; + }} + > + {body} + + ); +} + +/** Collapse every newline and run of blanks into single spaces. */ +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +/** Pad or truncate to exactly `width` columns, so the panel is opaque. */ +function fit(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text.padEnd(width); +} diff --git a/src/tui/components/session-picker.tsx b/src/tui/components/session-picker.tsx index dd85cc8a..126c913d 100644 --- a/src/tui/components/session-picker.tsx +++ b/src/tui/components/session-picker.tsx @@ -2,6 +2,9 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import type { SessionPickerEntry } from "../tui-state.js"; import { theme } from "../theme/theme.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { handleEditorSubmit } from "../submit-handler.js"; export interface SessionPickerProps { sessions: readonly SessionPickerEntry[]; @@ -45,12 +48,31 @@ export function SessionPicker(props: SessionPickerProps): ReactElement { ↑ {hiddenBefore} above ) : null} {visible.map((entry, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ + type: "session_picker_cursor_set", + row: windowStart + idx, + }) + } + onActivate={(mouse) => + handleEditorSubmit( + "", + mouse.getState(), + mouse.dispatch, + mouse.callbacks, + ) + } + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/session-title.test.ts b/src/tui/components/session-title.test.ts new file mode 100644 index 00000000..96031404 --- /dev/null +++ b/src/tui/components/session-title.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { sessionTitleLine } from "./session-title.js"; + +describe("sessionTitleLine", () => { + it("collapses a multi-line prompt into one line", () => { + expect(sessionTitleLine("ONE\none\n1\n1\n1\n1\n1", 32)).toBe( + "ONE one 1 1 1 1 1", + ); + }); + + it("collapses CRLF, tabs and runs of blank lines too", () => { + expect(sessionTitleLine("first\r\n\r\n\tsecond", 32)).toBe("first second"); + }); + + it("trims leading and trailing whitespace", () => { + expect(sessionTitleLine("\n\n hello \n\n", 32)).toBe("hello"); + }); + + it("keeps a line that exactly fills the width", () => { + const exact = "x".repeat(32); + expect(sessionTitleLine(exact, 32)).toBe(exact); + }); + + it("ellipsises one cell early so the mark fits inside the width", () => { + const long = "x".repeat(40); + const title = sessionTitleLine(long, 32); + expect(title).toHaveLength(32); + expect(title.endsWith("…")).toBe(true); + }); + + it("measures the collapsed length, not the raw one", () => { + // Ten cells of newline are worth nine cells of text once collapsed, + // so this fits and must not be cut. + expect(sessionTitleLine("a\n\n\n\n\n\n\n\n\n\nb", 5)).toBe("a b"); + }); + + it("returns empty for a preview that is only whitespace", () => { + expect(sessionTitleLine("\n \t\n", 32)).toBe(""); + }); + + it("returns empty when there is no room to draw", () => { + expect(sessionTitleLine("something", 0)).toBe(""); + }); +}); diff --git a/src/tui/components/session-title.ts b/src/tui/components/session-title.ts new file mode 100644 index 00000000..adf1b8bf --- /dev/null +++ b/src/tui/components/session-title.ts @@ -0,0 +1,22 @@ +/** + * A session preview — the thread's first user prompt, stored verbatim — + * rendered as one line of at most `max` cells. + * + * Verbatim is the point: the preview has to keep the prompt as it was + * typed, because the session picker's search and the delete dialog's + * "is this the thread I mean?" both read it. Every *display* of it, + * though, sits in a fixed-height row, and Ink honours a `\n` inside a + * `` by growing that row — a pasted prompt turns a one-row bar + * into an N-row one and reflows everything below it. + * + * So the newlines collapse to spaces rather than being cut at the first + * one: the rail already renders previews this way, and a title that + * agrees with the rail is worth more than the handful of extra + * characters a first-line-only rule would save. + */ +export function sessionTitleLine(preview: string, max: number): string { + const oneLine = preview.replace(/\s+/g, " ").trim(); + if (max <= 0 || oneLine.length === 0) return ""; + if (oneLine.length <= max) return oneLine; + return `${oneLine.slice(0, Math.max(1, max - 1))}…`; +} diff --git a/src/tui/components/sidebar-fit.test.tsx b/src/tui/components/sidebar-fit.test.tsx new file mode 100644 index 00000000..d358d24e --- /dev/null +++ b/src/tui/components/sidebar-fit.test.tsx @@ -0,0 +1,117 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { + computeSidebarRowBudget, + isSidebarVisible, + SIDEBAR_CHROME_ROWS, + SIDEBAR_MIN_COLUMNS, + SIDEBAR_MIN_ROWS, +} from "../layout.js"; +import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; +import type { SessionPickerEntry } from "../tui-state.js"; +import { Sidebar } from "./sidebar.js"; + +/** Colour codes never carry a newline, so the raw frame counts fine. */ +function frameRows(frame: string): number { + return frame.split("\n").length; +} + +/** Long enough that both panes always hide a tail and draw a footer. */ +const SESSIONS: readonly SessionPickerEntry[] = Array.from( + { length: 40 }, + (_, idx) => ({ + sessionId: `s-${idx}`, + workingDir: "/tmp/fit", + turnCount: 1, + stepCount: 1, + updatedAt: 0, + preview: `session ${idx}`, + }), +); + +const TASKS: readonly TaskSummaryRow[] = Array.from( + { length: 40 }, + (_, idx) => ({ + id: `t-${idx}`, + status: "pending", + origin: "tui", + triggerSource: "user", + sessionId: null, + userMessage: `task ${idx}`, + scheduleKind: null, + scheduleLabel: "-", + recurring: false, + scheduledFor: null, + createdAt: 0, + updatedAt: 0, + startedAt: null, + completedAt: null, + attempts: 0, + maxAttempts: 3, + lastError: null, + }), +); + +function renderRail(rows: number): number { + const budget = computeSidebarRowBudget(rows); + const { lastFrame } = render( + , + ); + return frameRows(lastFrame() ?? ""); +} + +/** + * Regression guard for "a wide but short terminal garbles the rail", + * the sibling of `splash-fit.render.test.tsx`. Ink 7 does NOT clip a + * frame taller than the terminal — it overlaps earlier lines — so the + * row budget has to be a promise the rendered component keeps, and a + * window too short to keep it must lose the rail entirely. + * + * The rail is drawn under a one-row status bar, so its own frame gets + * `rows - 1` at most. + */ +describe("Sidebar fit", () => { + it("renders inside every terminal height that still draws it", () => { + // 24 rows is where the budget saturates at its 10/5 caps, so every + // distinct split the arithmetic can produce is covered here. The + // exact cost — two section headers, the blank row between the + // panes and a "↓ N more" footer per pane — is asserted alongside + // the fit so `SIDEBAR_CHROME_ROWS` cannot drift away from the + // component it describes. The left border is the only border edge + // the rail draws, so it costs columns, not rows. + for (let rows = SIDEBAR_MIN_ROWS; rows <= 24; rows += 1) { + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS, rows)).toBe(true); + const budget = computeSidebarRowBudget(rows); + const rendered = renderRail(rows); + expect(rendered).toBe( + budget.sessions + budget.tasks + SIDEBAR_CHROME_ROWS, + ); + expect(rendered).toBeLessThanOrEqual(rows - 1); + } + }); + + it("is dropped rather than squeezed in a wide but short window", () => { + // 100x8 is a split tmux pane; 100x5 is a terminal docked under an + // editor. Both used to budget three list rows into a rail that + // rendered ten rows deep. + for (const [columns, rows] of [ + [100, 8], + [100, 5], + [1, 1], + [0, 0], + ] as const) { + expect(isSidebarVisible(columns, rows)).toBe(false); + } + }); +}); diff --git a/src/tui/components/sidebar.test.tsx b/src/tui/components/sidebar.test.tsx index 89ec7b05..f0587641 100644 --- a/src/tui/components/sidebar.test.tsx +++ b/src/tui/components/sidebar.test.tsx @@ -72,8 +72,11 @@ describe("Sidebar", () => { />, ); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Sessions"); - expect(text).toContain("Tasks"); + // Upper-case since the rail became the app frame — it carries the + // brand, the version and the menu button now, so its own headings + // read as labels rather than as content. + expect(text).toContain("SESSIONS"); + expect(text).toContain("TASKS"); expect(text).not.toContain("Workspace"); expect(text).not.toContain("LLM"); }); @@ -130,6 +133,39 @@ describe("Sidebar", () => { expect(chevronCount).toBe(1); }); + it("puts the close mark on the selected session row only", () => { + // An `x` on every row is a mis-click waiting to happen, so it rides + // the row the cursor is already on. + const focused = render( + , + ); + const text = strip(focused.lastFrame() ?? ""); + expect((text.match(/\[x\]/g) ?? []).length).toBe(1); + + const blurred = render( + , + ); + expect(strip(blurred.lastFrame() ?? "")).not.toContain("[x]"); + }); + it("renders task rows with status badges", () => { const { lastFrame } = render( { expect(text).toContain("running task"); expect(text).toContain("pending task"); }); + it("honours the per-pane row budget instead of a fixed 10/5 split", () => { + const manySessions = Array.from({ length: 12 }, (_, idx) => ({ + ...SESSIONS[0]!, + sessionId: `s-${idx}`, + preview: `session number ${idx}`, + })); + const manyTasks = Array.from({ length: 8 }, (_, idx) => + taskRow({ id: `t-${idx}`, userMessage: `task number ${idx}` }), + ); + const { lastFrame } = render( + , + ); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("session number 2"); + expect(text).not.toContain("session number 3"); + expect(text).toContain("task number 1"); + expect(text).not.toContain("task number 2"); + // Both panes admit what they are hiding. + expect(text).toContain("9 more"); + expect(text).toContain("6 more"); + // Two headers + 3 sessions + 2 tasks + 2 "more" rows + spacers, plus + // the brand block (mark, wordmark, version), the menu button and the + // breadcrumb slot the rail gained when it replaced the top bar. + expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(22); + }); + + it("scrolls the Tasks pane to keep the cursor visible", () => { + const manyTasks = Array.from({ length: 8 }, (_, idx) => + taskRow({ id: `t-${idx}`, userMessage: `task number ${idx}` }), + ); + const { lastFrame } = render( + , + ); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("task number 7"); + expect(text).not.toContain("task number 0"); + // The chevron sits on the selected row, not on whatever row 0 is. + expect(text).toMatch(/▸ [^\n]*task number 7/); + }); + + it("narrows the previews with the rail rather than overflowing it", () => { + const long = [{ ...SESSIONS[0]!, preview: "a very long session preview indeed" }]; + const { lastFrame } = render( + , + ); + const widest = strip(lastFrame() ?? "") + .split("\n") + .reduce((acc, line) => Math.max(acc, line.replace(/\s+$/, "").length), 0); + expect(widest).toBeLessThanOrEqual(24); + expect(strip(lastFrame() ?? "")).toContain("…"); + }); }); diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index f7ce46b0..ae3fdde2 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -1,8 +1,19 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; +import { + MouseTarget, + useMouseCommands, + useMouseTarget, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_BASE } from "../mouse/mouse-registry.js"; +import { computeRowWindow } from "../row-window.js"; import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; import { theme } from "../theme/theme.js"; import type { SessionPickerEntry } from "../tui-state.js"; +import { getAppVersion } from "../../version.js"; +import { RAIL_MARK } from "./logo.js"; +import { Chip } from "./chip.js"; export type SidebarSection = "sessions" | "tasks"; @@ -17,23 +28,57 @@ export interface SidebarProps { activeSection: SidebarSection; /** Whether the sidebar owns keyboard focus right now. */ focused: boolean; + /** Short session id, shown under the wordmark. */ + sessionId?: string | null; + /** + * Row budget for each pane, normally derived from the terminal height + * by `computeSidebarRowBudget` in `../layout.ts`. The defaults keep + * the pre-adaptive behaviour for callers that do not measure. + */ + maxSessionRows?: number; + maxTaskRows?: number; } -const MAX_SESSION_ROWS = 10; -const MAX_TASK_ROWS = 5; +const DEFAULT_MAX_SESSION_ROWS = 10; +const DEFAULT_MAX_TASK_ROWS = 5; /** - * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and - * Tasks (bottom) — both navigable when the sidebar has focus. Tab - * cycles editor → sessions → tasks → editor (handled by - * `app-key-bindings.ts`); the sidebar component itself is purely - * presentational and never measures the terminal directly so the - * same component works under ink-testing-library's static viewport. + * Cells each list row spends before the preview text: the border, the + * two padding columns, the selection chevron and the status marker, + * plus the spaces between them. + */ +const ROW_CHROME_COLUMNS = 7; +/** Never squeeze a preview below this — an ellipsis alone helps nobody. */ +const MIN_PREVIEW_COLUMNS = 6; + +/** + * The app rail: brand mark, menu button, where you are, then Sessions + * and Tasks. Always on screen, on the **left**, drawn on its own + * inverted ground. + * + * It used to be a plain right-hand list of sessions with the app title + * on a separate bar across the top. That is two pieces of chrome doing + * one job. Everything that says "which app, which version, where am I, + * what else is there" now lives in one column, which is where a reader + * coming from any normal application will look for it — and the top bar + * is gone entirely. * - * Focus is layered: `focused` toggles the section header colour for - * the active pane, and `activeSection` decides which pane gets the - * cursor highlight. When `focused` is false, both panes render in - * their muted resting state. + * **Why the inverted ground.** A terminal has no borders-and-shadows to + * separate regions, so two columns of the same text on the same ground + * read as one wrapped document. Giving the rail its own ground is the + * cheapest honest way to say "this is chrome, that is content". It is + * per-palette rather than literally white: `#fff` would vanish on the + * four light themes, and the property that has to hold is inversion. + * + * The ground is one `backgroundColor` on the rail container, so it fills + * the column's whole height on its own. Painting it line by line instead + * needs filler rows to reach the bottom, and a rail taller than the + * terminal makes Ink 7 overlap earlier lines rather than clip — the same + * trap `splash-fit.ts` exists to avoid. + * + * Purely presentational: it never measures the terminal, so the same + * component works under ink-testing-library's static viewport. Width and + * per-pane row budgets arrive as props from `TuiApp`. */ export function Sidebar(props: SidebarProps): ReactElement { const { @@ -45,59 +90,314 @@ export function Sidebar(props: SidebarProps): ReactElement { tasksCursor, activeSection, focused, + sessionId = null, + maxSessionRows = DEFAULT_MAX_SESSION_ROWS, + maxTaskRows = DEFAULT_MAX_TASK_ROWS, } = props; const sessionsActive = focused && activeSection === "sessions"; const tasksActive = focused && activeSection === "tasks"; + const inner = Math.max(1, width - 2); + const previewWidth = Math.max( + MIN_PREVIEW_COLUMNS, + width - ROW_CHROME_COLUMNS, + ); + const mouse = useMouseCommands(); + // Wheel over the rail walks the pane that owns the cursor, so the + // gesture matches what ↑/↓ do once the rail has focus. + const wheelRef = useMouseTarget((hit) => { + if (hit.event.kind !== "wheel" || !mouse) return false; + const delta = hit.event.wheel === "up" ? -1 : 1; + mouse.dispatch( + activeSection === "tasks" + ? { type: "sidebar_tasks_cursor_moved", delta } + : { type: "sidebar_cursor_moved", delta }, + ); + return true; + }); + // `flexShrink={0}`: Yoga shrinks flex children by default, so a wide + // chat column used to steal columns back from the rail — which made + // the width the splash was told to plan for a lie. return ( - + + {/* + Three rows, not one. Sessions and Tasks read as a list the rail + holds rather than as a continuation of the lockup, and the gap + below the mark matches the one the composer now sets. + */} + + + + } + /> + + - - - + {/* + The menu sits at the foot of the rail, the way an application + parks its account or settings control: it is the thing you reach + for occasionally, and the lists above it are what you look at. + The spacer pushes it down however tall the terminal is. + */} + + + {/* + The design seats the Menu control above the rail's bottom edge + rather than on it — a control flush against the edge of its own + panel reads as part of the frame instead of as a button. + */} + + + ); +} + +/** Clip to `width` columns; the ground is painted by the container. */ +function clip(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text; +} + +/** + * One rail line. The text is clipped to the rail width but not padded — + * the container's `backgroundColor` paints the rest of the row. + */ +function RailLine({ + inner, + children, + color, + bold, +}: { + inner: number; + children: string; + color?: string; + bold?: boolean; +}): ReactElement { + return ( + + {clip(children, inner)} + + ); +} + +/** + * One row of breathing space. An empty `` collapses to zero height + * in Ink, so the spacer has to be a sized Box. + */ +function RailBlank(): ReactElement { + return ; +} + +/** + * Mark, wordmark, version — the mark on the left with the text beside + * it, the way a product lockup is normally set. Stacked, it spent six of + * the rail's rows on branding before the first useful line. + * + * The session id keeps its own full-width row underneath: it is the one + * piece here that can be long, and squeezing it into the column beside a + * six-column mark would truncate it to nothing. + */ +function RailBrand({ + inner, + sessionId, +}: { + inner: number; + sessionId: string | null; +}): ReactElement { + const art = RAIL_MARK; + const textWidth = Math.max(0, inner - MARK_COLUMNS - 1); + return ( + + + + + {art.map((row, idx) => ( + + {row} + + ))} + + + {/* + One blank: the mark is three rows and the text is two, so + this seats the pair from the mark's centre row — the cross's + own bar — rather than riding high against its top arm. + */} + + + {clip("atomic-agent", textWidth)} + + + {clip(`v${getAppVersion()}`, textWidth)} + + + + {sessionId ? ( + + {shortenId(sessionId)} + + ) : null} ); } +/** Width of {@link RAIL_MARK}, kept beside it so the lockup can measure. */ +const MARK_COLUMNS = 6; + +/** + * Starts a fresh thread. It sits on the Sessions header because that is + * the list the thread joins — once it has been spoken to. A brand-new + * session shows no row: it has no name yet, and an unnamed row is + * indistinguishable from every other unnamed row. The row appears with + * the first prompt, named by it. + * + * `/new` does the same thing, and used to be the only way to reach it — + * which is not a thing a first-time operator knows. + */ +function NewSessionButton(): ReactElement { + const mouse = useMouseCommands(); + const label = ; + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.callbacks.onSessionNewRequested?.(); + return true; + }} + > + {label} + + ); +} + +/** + * The one control on the rail. `ctrl+p` opens the same menu; this is + * what makes it reachable without knowing that, which was the whole + * complaint about the old top bar — nothing on screen said the menu + * existed. + */ +function MenuButton({ inner }: { inner: number }): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + + + + ctrl+p + + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "menu_opened" }); + return true; + }} + > + {label} + + ); +} + +function shortenId(value: string): string { + if (value.length <= 8) return value; + return `${value.slice(0, 8)}…`; +} + interface SectionHeaderProps { title: string; active: boolean; + inner: number; + /** Right-aligned status, e.g. `0 running`. */ + counter?: string; + /** Right-aligned control, e.g. the `+ new` chip. */ + trailing?: ReactNode; } -function SectionHeader({ title, active }: SectionHeaderProps): ReactElement { +function SectionHeader({ + title, + active, + inner, + counter, + trailing, +}: SectionHeaderProps): ReactElement { + // The header is a row, not a line: the counter and the `+ new` control + // are pushed to the right edge of the rail the way the design sets + // them, which a single clipped string cannot express. return ( - - {title} - + + + {title.toUpperCase()} + + + {counter ? ( + + {counter} + + ) : null} + {trailing ?? null} + ); } +/** Tasks the design counts in the header: the ones actually running. */ +function runningCount(tasks: readonly TaskSummaryRow[]): number { + return tasks.filter((row) => row.status === "running").length; +} + interface SessionsListProps { sessions: readonly SessionPickerEntry[]; cursor: number; focused: boolean; currentSessionId: string | null; + maxRows: number; + previewWidth: number; + inner: number; } function SessionsList({ @@ -105,30 +405,43 @@ function SessionsList({ cursor, focused, currentSessionId, + maxRows, + previewWidth, + inner, }: SessionsListProps): ReactElement { if (sessions.length === 0) { return ( - (no sessions yet) + + {"(no sessions yet)"} + ); } - const clamped = Math.max(0, Math.min(cursor, sessions.length - 1)); - const windowStart = computeWindowStart(clamped, sessions.length, MAX_SESSION_ROWS); - const visible = sessions.slice(windowStart, windowStart + MAX_SESSION_ROWS); - const visibleCursor = clamped - windowStart; - const hiddenAfter = Math.max(0, sessions.length - windowStart - visible.length); + const window = computeRowWindow(sessions.length, cursor, maxRows); + const visible = sessions.slice(window.start, window.start + window.count); + const visibleCursor = + Math.max(0, Math.min(cursor, sessions.length - 1)) - window.start; return ( {visible.map((entry, idx) => ( - + onActivate={(mouse) => + mouse.callbacks.onSessionSwitchRequested?.(entry.sessionId) + } + > + + ))} - {hiddenAfter > 0 ? ( - ↓ {hiddenAfter} more - ) : null} + ); } @@ -137,45 +450,209 @@ interface SessionRowProps { entry: SessionPickerEntry; selected: boolean; current: boolean; + previewWidth: number; + inner: number; } -function SessionRow({ entry, selected, current }: SessionRowProps): ReactElement { - const preview = truncate(entry.preview, 28); - const marker = current ? theme.glyphs.assistantMarker : " "; +function SessionRow({ + entry, + selected, + current, + previewWidth, + inner, +}: SessionRowProps): ReactElement { + // Two marks, two questions: the chevron is "Enter opens this row", + // the dot is "this is the thread you are in". The ground answers the + // first one too, but only in colour — and a colour is nothing under + // NO_COLOR or in a pipe, so the glyph stays. const chevron = selected ? theme.glyphs.chevronRight : " "; + const marker = current ? theme.glyphs.assistantMarker : " "; + // The ground runs almost the full width of the rail with one column of + // air either side, so a selected row reads as a row in a list rather + // than as a highlighted word. The close affordance lives inside that + // ground, at its right edge — visible only on the selected row, + // because an `x` on every row is a mis-click waiting to happen. + const groundWidth = Math.max(4, inner - 2 * ROW_MARGIN_COLUMNS); + // The close columns are reserved on EVERY row, painted only on the + // selected one. Sizing the preview by whether the row happens to be + // selected made the mark materialise on top of text the operator was + // already pointing at: the second click of the ordinary + // select-then-open gesture landed on `[x]` and asked to delete the + // thread instead of opening it. + const preview = truncate( + entry.preview, + Math.max(1, Math.min(previewWidth, groundWidth - 5 - CLOSE_COLUMNS)), + ); + const label = `${chevron} ${marker} ${preview}`; + // Every cell width is computed here, so nothing may flex. Yoga + // shrinks text children by default and Ink re-wraps a squeezed + // `` rather than clipping it — the trap + // `MouseTargetProps.flexShrink` warns about, one row away from here. + const ground = ` ${label}`.padEnd(Math.max(0, groundWidth - CLOSE_COLUMNS)); return ( - - {chevron} {marker} {preview} + + {" ".repeat(ROW_MARGIN_COLUMNS)} + + + {ground} + + + {selected ? ( + + ) : ( + {" ".repeat(CLOSE_COLUMNS)} + )} + {" ".repeat(ROW_MARGIN_COLUMNS)} + + ); +} + +/** Air either side of a rail list row's ground. */ +const ROW_MARGIN_COLUMNS = 1; +/** Cells the close affordance occupies inside the ground: `[x]` + a pad. */ +const CLOSE_COLUMNS = 4; + +/** + * The `x` at the right edge of the selected session's ground. It opens + * the confirmation rather than deleting: this is one click away from + * losing a thread, and the rail is a place people click while looking + * somewhere else. + */ +function CloseSessionButton({ + entry, +}: { + entry: SessionPickerEntry; +}): ReactElement { + const mouse = useMouseCommands(); + // `[x]`, the same mark the design uses. It sits inside the row's + // ground, so it carries the same inverse video as the label. + const glyph = ( + + {"[x] "} ); + if (!mouse) return glyph; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ + type: "session_delete_requested", + sessionId: entry.sessionId, + preview: entry.preview, + }); + return true; + }} + > + {glyph} + + ); +} + +/** + * A rail list row: an optional 1-cell accent bar, then the label on an + * optional filled ground. + * + * The design draws the bar as a 3px rule down the left edge of the row + * and the selection as a translucent fill. Neither has a sub-cell + * equivalent, so the bar is `▎` in the accent colour and the fill is a + * real background on the label — the two marks the design uses, at the + * resolution a terminal has. + */ +function RailRow({ + inner, + children, + bar, + filled, + bold, + color, +}: { + inner: number; + children: string; + bar?: boolean; + filled?: boolean; + bold?: boolean; + color?: string; +}): ReactElement { + const label = clip(children, Math.max(0, inner - 2)); + // One column of chrome, three states. The cursor keeps a glyph rather + // than relying on the fill alone: the fill is a colour, and a colour + // is nothing under NO_COLOR, in a pipe, or in the test renderer — the + // one mark that says "Enter opens this row" has to survive all three. + const mark = filled ? theme.glyphs.chevronRight : bar ? "▎" : " "; + return ( + + + {mark} + + + {` ${label} `} + + + ); } interface TasksListProps { tasks: readonly TaskSummaryRow[]; cursor: number; focused: boolean; + maxRows: number; + previewWidth: number; + inner: number; } -function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { +function TasksList({ + tasks, + cursor, + focused, + maxRows, + previewWidth, + inner, +}: TasksListProps): ReactElement { if (tasks.length === 0) { - return (no active tasks); + return ( + + {"(no active tasks)"} + + ); } - const clamped = Math.max(0, Math.min(cursor, tasks.length - 1)); - const visible = tasks.slice(0, MAX_TASK_ROWS); - const visibleCursor = Math.min(clamped, visible.length - 1); + const window = computeRowWindow(tasks.length, cursor, maxRows); + const visible = tasks.slice(window.start, window.start + window.count); + const visibleCursor = + Math.max(0, Math.min(cursor, tasks.length - 1)) - window.start; return ( {visible.map((row, idx) => ( - + onActivate={(mouse) => + mouse.callbacks.onSidebarTaskActivated?.(row.id) + } + > + + ))} + ); } @@ -183,20 +660,46 @@ function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { interface TaskRowProps { row: TaskSummaryRow; selected: boolean; + previewWidth: number; + inner: number; } -function TaskRow({ row, selected }: TaskRowProps): ReactElement { - const preview = truncate(row.userMessage, 24); - const chevron = selected ? theme.glyphs.chevronRight : " "; +function TaskRow({ + row, + selected, + previewWidth, + inner, +}: TaskRowProps): ReactElement { + const preview = truncate(row.userMessage, previewWidth); const badge = statusBadge(row); return ( - - {chevron} {badge} {preview} - + {`${badge} ${preview}`} + + ); +} + +/** "↓ N more" footer, or nothing at all when the tail is visible. */ +function MoreRow({ + hidden, + inner, +}: { + hidden: number; + inner: number; +}): ReactElement | null { + if (hidden <= 0) return null; + return ( + + {`↓ ${hidden} more`} + ); } @@ -216,11 +719,53 @@ function truncate(text: string, max: number): string { const oneLine = text.replace(/\s+/g, " ").trim(); if (oneLine.length === 0) return "(empty)"; if (oneLine.length <= max) return oneLine; - return `${oneLine.slice(0, max - 1)}…`; + return `${oneLine.slice(0, Math.max(1, max - 1))}…`; } -function computeWindowStart(cursor: number, total: number, size: number): number { - if (total <= size) return 0; - if (cursor < size) return 0; - return Math.min(cursor - size + 1, total - size); +interface SidebarRowProps { + section: SidebarSection; + /** Absolute index into the pane's data, not the visible window. */ + row: number; + selected: boolean; + onActivate: (mouse: NonNullable>) => void; + children: ReactNode; +} + +/** + * Click behaviour shared by both rails: the first click focuses the + * rail and moves the cursor, a click on the row that is already + * selected activates it. Two deliberate clicks instead of a + * double-click — no timing window to guess, and it matches what the + * keyboard does (arrow to the row, then Enter). + */ +function SidebarRow({ + section, + row, + selected, + onActivate, + children, +}: SidebarRowProps): ReactElement { + const mouse = useMouseCommands(); + if (!mouse) return <>{children}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + if (selected) { + onActivate(mouse); + return true; + } + mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }); + mouse.dispatch({ type: "sidebar_section_focused", section }); + mouse.dispatch( + section === "tasks" + ? { type: "sidebar_tasks_cursor_set", row } + : { type: "sidebar_cursor_set", row }, + ); + return true; + }} + > + {children} + + ); } diff --git a/src/tui/components/skills-hub-list.tsx b/src/tui/components/skills-hub-list.tsx index 76908701..71005232 100644 --- a/src/tui/components/skills-hub-list.tsx +++ b/src/tui/components/skills-hub-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleSkillsTabKey } from "../skills/skills-key-bindings.js"; import { formatDownloads } from "../skills/format-downloads.js"; import type { HubSkillRow, @@ -84,11 +86,19 @@ function renderBody(panel: SkillsPanelState, maxRows: number): ReactElement { ↑ {hiddenBefore} above ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ + type: "skills_hub_cursor_set", + row: idx + windowStart, + }) + } + onActivate={pressEnter(handleSkillsTabKey)} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/skills-list.tsx b/src/tui/components/skills-list.tsx index 81978f0d..ea61e1f8 100644 --- a/src/tui/components/skills-list.tsx +++ b/src/tui/components/skills-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleSkillsTabKey } from "../skills/skills-key-bindings.js"; import type { SkillSummaryRow, SkillsPanelState, @@ -45,11 +47,16 @@ export function SkillsList(props: SkillsListProps): ReactElement { ↑ {hiddenBefore} above ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ type: "skills_cursor_set", row: idx + windowStart }) + } + onActivate={pressEnter(handleSkillsTabKey)} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/slash-palette.tsx b/src/tui/components/slash-palette.tsx index 08c567e8..ac26f61a 100644 --- a/src/tui/components/slash-palette.tsx +++ b/src/tui/components/slash-palette.tsx @@ -3,6 +3,9 @@ import type { ReactElement } from "react"; import { filterSlashCommands } from "../commands/slash-commands.js"; import type { SlashCommandDef } from "../commands/slash-commands.js"; import { theme } from "../theme/theme.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { handleEditorSubmit } from "../submit-handler.js"; interface SlashPaletteProps { query: string; @@ -51,11 +54,28 @@ export function SlashPalette(props: SlashPaletteProps): ReactElement | null { ↑ {hiddenBefore} above ) : null} {visible.map((cmd, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ + type: "slash_palette_cursor_set", + row: windowStart + idx, + }) + } + onActivate={(mouse) => { + const state = mouse.getState(); + handleEditorSubmit( + state.inputValue, + state, + mouse.dispatch, + mouse.callbacks, + ); + }} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/splash-banner.test.tsx b/src/tui/components/splash-banner.test.tsx index a96f3c9b..38724285 100644 --- a/src/tui/components/splash-banner.test.tsx +++ b/src/tui/components/splash-banner.test.tsx @@ -1,34 +1,122 @@ import { render } from "ink-testing-library"; import { describe, expect, it } from "vitest"; +import { toSlashCommands } from "../menu/menu-registry.js"; import { SplashBanner } from "./splash-banner.js"; function strip(value: string): string { return value - .replace(/\u001b\[[0-9;]*m/g, "") - .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); + .replace(/\[[0-9;]*m/g, "") + .replace(/\]8;;[^]*/g, ""); +} + +/** + * "Some brand mark is drawn." The splash uses the ASCII stroke and the + * rail the block one, so accept either — four or more `#` in a run is + * artwork, never prose. + */ +const MARK_GLYPHS = /#{4}|[█▀▄]/u; + +function frameAt(columns: number, rows: number): string { + const { lastFrame } = render(); + return strip(lastFrame() ?? ""); } describe("SplashBanner", () => { - it("renders the plus-mark middle bar and the wordmark", () => { - const { lastFrame } = render(); - const frame = strip(lastFrame() ?? ""); - // Middle bar of the plus — longest uninterrupted `:` run in the art. - expect(frame).toContain("::::::::::::::::::::::::::::::::::"); + it("renders the plus-mark middle bar and the wordmark on a roomy surface", () => { + const frame = frameAt(96, 40); + // Middle bar of the cross — the widest solid run in the art. The + // splash draws the ASCII stroke, so this is `#`, not a block glyph. + expect(frame).toContain("#".repeat(45)); // Both halves of the `ATOMIC AGENT` half-block wordmark. - expect(frame).toContain("▄▀█ ▀█▀ █▀█"); - expect(frame).toContain("▄▀█ █▀▀ █▀▀"); + expect(frame).toContain("\u2584\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588"); + expect(frame).toContain("\u2588\u2580\u2588 \u2588 \u2588\u2584\u2588"); expect(frame).toContain("Local AI-First Agent"); }); - it("advertises the core slash commands and hotkeys", () => { - const { lastFrame } = render(); - const frame = strip(lastFrame() ?? ""); + it("advertises the core slash commands", () => { + const frame = frameAt(96, 40); expect(frame).toContain("/help"); expect(frame).toContain("/sessions"); expect(frame).toContain("/new"); - expect(frame).toContain("/observe"); - expect(frame).toContain("/manage"); - expect(frame).toContain("/run"); - expect(frame).toContain("Ctrl+C"); + expect(frame).toContain("/model"); + expect(frame).toContain("/tasks"); + expect(frame).toContain("/import"); + // The two plain-hotkey rows (Enter, Ctrl+C x2) are gone: every row + // is now a command a click can put in the composer, and the hint + // strip carries both keys at the foot of the screen anyway. + expect(frame).not.toContain("Ctrl+C"); + expect(frame).not.toMatch(/•\s+Enter/u); + }); + + it("keeps the most useful tips when the surface is too short for all of them", () => { + // 18 rows is where the list actually gives way: it buys the 14-row + // `small` mark and its wordmark, leaving three rows for tips. At 16 + // rows the mark drops to `mini` and every tip fits again, which is + // the mark-over-tips priority, not a truncation. + const frame = frameAt(96, 18); + expect(frame).toContain("/help"); + expect(frame).toContain("/sessions"); + // The tail of the list is what gives way first. + expect(frame).not.toContain("/import"); + }); + + it("swaps in terse descriptions on a narrow surface", () => { + const frame = frameAt(44, 20); + expect(frame).toContain("/help"); + expect(frame).toContain("all commands"); + expect(frame).not.toContain("list all slash commands"); + }); + + it("keeps the tips and drops the mark when four rows is all there is", () => { + // Even the two-row tiny sign spends margin and slack rows, so on a + // 4-row surface it would leave nothing for the tips and Ink would + // paint it over the chat above. Tips win. + const frame = frameAt(38, 4); + expect(frame).toContain("/help"); + expect(frame).not.toMatch(MARK_GLYPHS); + }); + + it("draws the tiny sign and keeps a tip once a fifth row exists", () => { + // Five rows used to buy the three-row mini at the cost of every + // tip; the two-row xs sign leaves room for `/help` beside it. + const frame = frameAt(38, 5); + expect(frame).toContain(" #."); + expect(frame).toContain("###."); + expect(frame).toContain("/help"); + }); + + it("still shows a brand mark and a tip once there is room for both", () => { + const frame = frameAt(38, 8); + expect(frame).toMatch(MARK_GLYPHS); + expect(frame).toContain("/help"); + }); + + it("measures the terminal itself when no size is given", () => { + const { lastFrame } = render(); + const frame = strip(lastFrame() ?? ""); + expect(frame).toMatch(MARK_GLYPHS); + expect(frame).toContain("/help"); + }); + + // The banner is a short tip-list, not the full command catalogue — which + // slash commands it picks is a copy decision that changes freely. What must + // not drift is that every command it prints is a real one, so a renamed or + // deleted command cannot leave the welcome screen advertising a dead verb. + it("only advertises slash commands that exist in the menu registry", () => { + const frame = frameAt(96, 40); + // Aliases count as real: `/run` resolves to `/chat` at dispatch, so + // advertising an alias is not a dead verb. + const registered = new Set( + toSlashCommands().flatMap((c) => [c.name, ...(c.aliases ?? [])]), + ); + const advertised = [...frame.matchAll(/\/([a-z][a-z0-9-]*)/g)].map( + (m) => m[1]!, + ); + expect(advertised.length).toBeGreaterThan(0); + for (const name of advertised) { + expect(registered, `/${name} is advertised but not registered`).toContain( + name, + ); + } }); }); diff --git a/src/tui/components/splash-banner.tsx b/src/tui/components/splash-banner.tsx index 64fe5ddc..627b907a 100644 --- a/src/tui/components/splash-banner.tsx +++ b/src/tui/components/splash-banner.tsx @@ -1,49 +1,122 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { Logo } from "./logo.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { computeChatViewportRows, computeChatWidth } from "../layout.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { theme } from "../theme/theme.js"; +import { Logo } from "./logo.js"; +import { + computeSplashFit, + SPLASH_TIPS, + type SplashFit, + type SplashSize, + type SplashTip, + type TipDescriptions, +} from "./splash-fit.js"; /** * Welcome screen shown in place of an empty chat-log. Renders the brand * `Logo` (atomic-plus mark + wordmark) vertically centred via flexGrow * spacers, with a compact tip-list underneath that surfaces the most - * useful slash commands and hotkeys. + * useful slash commands. + * + * Everything on it is sized against the live terminal: the mark shrinks + * (34×20 → 17×10 → one line) as the window narrows or shortens, the tip + * list drops entries from its tail, and the tip descriptions collapse to + * terse copy before disappearing entirely. See `splash-fit.ts` for the + * breakpoints — this component only renders the plan it is handed. * * Visibility is decided by the parent (`ChatLog`) based on * `messages.length === 0`, so restoring a historical session via * `/sessions` swaps the banner out for the transcript. */ -export function SplashBanner(): ReactElement { +export interface SplashBannerProps { + /** + * Explicit surface size, bypassing the terminal measurement. Only + * used by tests — ink-testing-library's stdout stub reports a fixed + * 100×0, which would pin every rendered frame to one breakpoint. + */ + size?: SplashSize; +} + +export function SplashBanner({ size }: SplashBannerProps = {}): ReactElement { + const terminal = useTerminalSize(); + const surface: SplashSize = size ?? { + columns: computeChatWidth(terminal.columns, terminal.rows), + rows: computeChatViewportRows(terminal.rows, terminal.columns), + }; + const fit = computeSplashFit(surface); + const tips = SPLASH_TIPS.slice(0, fit.tipCount); return ( - - - - - - - - - - - + {fit.logo === "none" ? null : ( + + )} + {tips.length > 0 ? ( + + {tips.map((tip) => ( + + ))} + + ) : null} ); } interface TipProps { - left: string; - right: string; + tip: SplashTip; + fit: SplashFit; } -function Tip({ left, right }: TipProps): ReactElement { - return ( - +function Tip({ tip, fit }: TipProps): ReactElement { + const label = + fit.labelWidth > 0 ? tip.label.padEnd(fit.labelWidth, " ") : tip.label; + const mouse = useMouseCommands(); + const row = ( + {theme.glyphs.bullet} - {left.padEnd(24, " ")} - {right} + {label} + {description(tip, fit.descriptions)} ); + if (!mouse) return row; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + // Put the command in the composer rather than running it: the + // row is a suggestion, and Enter is the operator's to press. + // `/model` and friends take arguments, and a click that fired + // them outright would rob a mis-click of its undo. + if (mouse.getState().chatFocus !== "editor") { + mouse.dispatch({ type: "chat_focus_set", focus: "editor" }); + } + // Trailing space, matching the palette's own completion: it + // leaves the caret past the command and keeps `slashPrefix` + // from re-opening the palette over the buffer we just seeded. + mouse.dispatch({ type: "input_changed", value: `${tip.command} ` }); + return true; + }} + > + {row} + + ); +} + +function description(tip: SplashTip, mode: TipDescriptions): string { + if (mode === "full") return tip.description; + if (mode === "short") return tip.short; + return ""; } diff --git a/src/tui/components/splash-fit.render.test.tsx b/src/tui/components/splash-fit.render.test.tsx new file mode 100644 index 00000000..a5d1a320 --- /dev/null +++ b/src/tui/components/splash-fit.render.test.tsx @@ -0,0 +1,87 @@ +import { render } from "ink-testing-library"; +import { Box } from "ink"; +import { describe, expect, it } from "vitest"; +import { computeChatViewportRows, computeChatWidth } from "../layout.js"; +import { SplashBanner } from "./splash-banner.js"; + +function lines(frame: string): string[] { + return frame + .replace(/\[[0-9;]*m/g, "") + .split("\n") + .map((line) => line.replace(/\s+$/, "")); +} + +/** + * Regression guard for the "small window garbles the start page" bug, + * modelled on `manage-panel-fit.test.tsx`. Ink 7 does NOT clip a frame + * taller than the terminal — it overlaps earlier lines — and it wraps a + * line wider than the surface into confetti. The splash therefore has + * to plan its own size, and the plan has to survive contact with Yoga. + * + * ink-testing-library pins its stdout at 100 columns and reports no + * rows at all, so each case renders `SplashBanner` at an explicit + * surface size inside a `Box` of that width — the same geometry the + * chat column hands it in production. + */ +const TERMINALS: ReadonlyArray<{ columns: number; rows: number }> = [ + { columns: 40, rows: 12 }, + { columns: 60, rows: 20 }, + { columns: 80, rows: 24 }, + { columns: 100, rows: 30 }, + { columns: 100, rows: 50 }, +]; + +describe("SplashBanner fit", () => { + it.each(TERMINALS)("fits a $columns x $rows terminal", (terminal) => { + const size = { + columns: computeChatWidth(terminal.columns, terminal.rows), + rows: computeChatViewportRows(terminal.rows), + }; + const { lastFrame } = render( + + + , + ); + const rendered = lines(lastFrame() ?? ""); + const widest = rendered.reduce((acc, line) => Math.max(acc, line.length), 0); + expect(widest).toBeLessThanOrEqual(size.columns); + expect(rendered.length).toBeLessThanOrEqual(size.rows); + // A splash with no recognisable brand mark is not a splash — except + // on a surface with no room for one, where drawing it anyway is the + // bug this file guards against. The splash draws the ASCII stroke, + // so match `#` runs as well as the wordmark's block glyphs. + if (size.rows >= 6) { + expect(rendered.join("\n")).toMatch(/ATOMIC AGENT|#{4}|[█▀▄]/u); + } + }); + + it("renders the full artwork, wordmark and every tip when there is room", () => { + const size = { columns: 96, rows: 40 }; + const { lastFrame } = render( + + + , + ); + const frame = lines(lastFrame() ?? "").join("\n"); + expect(frame).toContain("#".repeat(45)); + expect(frame).toContain("\u2584\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588"); + expect(frame).toContain("Local AI-First Agent"); + expect(frame).toContain("/import"); + }); + + it("collapses to the smallest mark and bare labels on a tiny surface", () => { + const size = { columns: 24, rows: 10 }; + const { lastFrame } = render( + + + , + ); + const frame = lines(lastFrame() ?? "").join("\n"); + // The mini mark is its own drawing, not a text stand-in — and it is + // the ASCII stroke, so it carries no block glyphs at all. + expect(frame).toMatch(/#{4}/u); + expect(frame).not.toContain("\u2584\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588"); + expect(frame).toContain("/help"); + expect(frame).not.toContain("list all slash commands"); + }); +}); diff --git a/src/tui/components/splash-fit.test.ts b/src/tui/components/splash-fit.test.ts new file mode 100644 index 00000000..21b35de0 --- /dev/null +++ b/src/tui/components/splash-fit.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + computeSplashFit, + LOGO_METRICS, + SPLASH_TIPS, + WORDMARK_STACK_ROWS, + type LogoVariant, +} from "./splash-fit.js"; + +/** Rows the mark costs, stacked wordmark included. */ +function markRows(fit: ReturnType): number { + if (fit.logo === "none") return 0; + return ( + LOGO_METRICS[fit.logo].height + + (fit.wordmarkPlacement === "below" ? WORDMARK_STACK_ROWS : 0) + ); +} + +const SIZE_ORDER: readonly LogoVariant[] = ["tiny", "mini", "small", "full"]; + +describe("computeSplashFit", () => { + it("gives a very wide terminal the full artwork with the wordmark beside it", () => { + // The mark is 51 columns, so a side-by-side lockup wants 100 inner + // columns — roughly a 140-column terminal. + expect(computeSplashFit({ columns: 108, rows: 44 })).toEqual({ + logo: "full", + wordmarkPlacement: "beside", + wordmark: true, + tagline: true, + tipCount: SPLASH_TIPS.length, + labelWidth: 24, + descriptions: "full", + }); + }); + + it("stacks the wordmark under the mark when it will not fit beside it", () => { + // Below 100 inner columns the pair cannot share a line. Stacking + // needs only the mark's own width, so the mark keeps its name + // instead of going anonymous — at the price of four rows. + expect(computeSplashFit({ columns: 92, rows: 40 })).toEqual({ + logo: "full", + wordmarkPlacement: "below", + wordmark: true, + tagline: true, + tipCount: SPLASH_TIPS.length, + labelWidth: 24, + descriptions: "full", + }); + }); + + it("steps the mark down rather than draw it nameless", () => { + // 88 inner columns is too narrow to park the wordmark beside the + // 51-column `full` mark, and 30 rows too short to stack it under + // one. Rather than draw a nameless mark, drop to `small`, which the + // wordmark fits beside. Mark-over-tips is the documented priority; + // mark-over-wordmark is not. + const fit = computeSplashFit({ columns: 92, rows: 30 }); + expect(fit.logo).toBe("small"); + expect(fit.wordmarkPlacement).toBe("beside"); + expect(fit.wordmark).toBe(true); + }); + + it("never loses the wordmark as the surface grows taller", () => { + // Regression: `full` is 24 rows and cannot stack the wordmark until + // 32 rows of chat surface, so a naive "biggest mark that fits" drew + // a NAMELESS full mark at 28 rows while both 24 rows (small, beside) + // and 32 rows (full, below) named the app. Growing a window must + // never cost the product its name. + for (let columns = 60; columns <= 200; columns += 4) { + let seen = false; + for (let rows = 2; rows <= 60; rows += 1) { + const fit = computeSplashFit({ columns, rows }); + if (fit.wordmark) seen = true; + else if (seen) { + throw new Error( + `wordmark lost at ${columns}x${rows} (logo=${fit.logo})`, + ); + } + } + } + }); + + it("shrinks the mark when the surface is too short for the tall artwork", () => { + // `full` is 24 rows and wants 28 before the tips; 20 rows buys the + // 14-row `small` instead, which is the documented mark-over-tips + // priority working in reverse. + expect(computeSplashFit({ columns: 73, rows: 20 })).toEqual({ + logo: "small", + wordmarkPlacement: "none", + wordmark: false, + tagline: false, + // One row short of the pane by design — see SPLASH_SLACK_ROWS. + tipCount: 4, + labelWidth: 24, + descriptions: "full", + }); + }); + + it("falls back to the smallest mark and terse copy on a small window", () => { + expect(computeSplashFit({ columns: 38, rows: 12 })).toEqual({ + logo: "mini", + wordmarkPlacement: "none", + wordmark: false, + tagline: false, + // 12 rows − 5 for the mark − 1 margin − 1 slack leaves five of the + // six tips. + tipCount: 6, + labelWidth: 10, + descriptions: "short", + }); + }); + + it("keeps bare labels when there is no room for any description", () => { + const fit = computeSplashFit({ columns: 20, rows: 10 }); + expect(fit.logo).toBe("mini"); + expect(fit.descriptions).toBe("none"); + expect(fit.labelWidth).toBe(0); + expect(fit.tipCount).toBeGreaterThan(0); + }); + + it("draws the tiny sign where mini was too big to earn its rows", () => { + // Five rows used to buy mini at the cost of every tip; the two-row + // sign keeps a tip on screen beside the brand. + const short = computeSplashFit({ columns: 38, rows: 5 }); + expect(short.logo).toBe("tiny"); + expect(short.tipCount).toBe(1); + // Nine columns (five inner): mini is six wide and could not draw at + // all here — this band really did render no mark before. + const narrow = computeSplashFit({ columns: 9, rows: 24 }); + expect(narrow.logo).toBe("tiny"); + expect(narrow.wordmark).toBe(false); + }); + + it("drops the mark rather than overflow a two-row surface", () => { + // Reversed deliberately. The old floor was a one-line text mark, so + // the tips were what got dropped. The mark is real artwork at every + // size now, and on a two-row surface the tips are the half worth + // keeping — Ink paints an over-tall frame over the rows above it, so + // "draw the mark anyway" is the bug this whole module exists for. + expect(computeSplashFit({ columns: 92, rows: 2 })).toMatchObject({ + logo: "none", + tipCount: 2, + }); + }); + + it("survives a degenerate surface without going negative", () => { + const fit = computeSplashFit({ columns: 0, rows: 0 }); + expect(fit.tipCount).toBe(0); + expect(fit.labelWidth).toBe(0); + expect(fit.logo).toBe("none"); + }); + + it("plans a layout that fits the surface it was given", () => { + for (let columns = 10; columns <= 200; columns += 3) { + for (let rows = 2; rows <= 60; rows += 3) { + const fit = computeSplashFit({ columns, rows }); + const markHeight = markRows(fit); + const height = + markHeight + + (fit.tipCount > 0 ? (markHeight > 0 ? 1 : 0) + fit.tipCount : 0); + expect(height).toBeLessThanOrEqual(rows); + expect(fit.tipCount).toBeGreaterThanOrEqual(0); + expect(fit.labelWidth).toBeGreaterThanOrEqual(0); + // `mini` and `tiny` are bullet-sized; they never carry the wordmark. + if (fit.wordmark) expect(["full", "small"]).toContain(fit.logo); + } + } + }); + + it("never shrinks the mark as the terminal gets wider", () => { + let previous = -1; + for (let columns = 10; columns <= 200; columns += 1) { + const choice = computeSplashFit({ columns, rows: 60 }).logo; + const rank = choice === "none" ? -1 : SIZE_ORDER.indexOf(choice); + expect(rank).toBeGreaterThanOrEqual(previous); + previous = rank; + } + }); + + it("never shows fewer tips as the terminal grows, for a fixed lockup", () => { + // Across a change of lockup the count legitimately drops: a taller + // window buys a taller mark — or buys the stacked wordmark, which + // costs three rows — and both are paid for in tip rows. Within one + // lockup the list may only grow. Keying on the variant alone is + // what this used to assert, and it stopped being the right key when + // gaining the wordmark became something a *taller* window can do. + const perLockup = new Map(); + for (let rows = 2; rows <= 80; rows += 1) { + const { logo, wordmarkPlacement, tipCount } = computeSplashFit({ + columns: 92, + rows, + }); + const key = `${logo}:${wordmarkPlacement}`; + expect(tipCount).toBeGreaterThanOrEqual(perLockup.get(key) ?? 0); + perLockup.set(key, tipCount); + } + expect(perLockup.get("full:below")).toBe(SPLASH_TIPS.length); + }); +}); diff --git a/src/tui/components/splash-fit.ts b/src/tui/components/splash-fit.ts new file mode 100644 index 00000000..157f4242 --- /dev/null +++ b/src/tui/components/splash-fit.ts @@ -0,0 +1,337 @@ +/** + * Fit maths for the start-page splash — which brand mark to draw, how + * many tips to keep, and how wide the tip columns may be for a given + * chat-surface size. + * + * The splash used to be a fixed 83×20 mark plus the fixed tip rows, + * i.e. it needed 90 columns and ~29 rows no matter what the terminal + * offered. Ink 7 does not clip an over-tall frame — it overlaps + * earlier lines (see `../row-window.ts`) — so a short window garbled + * the whole start page, and a narrow one wrapped the artwork into + * confetti. + * + * The mark has priority over the tip list: a window that grows tall + * enough for a bigger mark spends its new rows on the artwork first, so + * the tip count can legitimately drop across a variant change. Within a + * variant the list only ever grows. + * + * This module is deliberately React-free so the breakpoints can be + * unit-tested as a table instead of through rendered frames. + */ + +export type LogoVariant = "full" | "small" | "mini" | "tiny"; + +/** + * What the splash draws for a mark. `"none"` is still a real outcome, + * not a failure: on a surface where even the two-row `tiny` sign would + * evict every tip, Ink paints the over-tall frame *over* the rows above + * it rather than clipping — so drawing it anyway is what garbled the + * start page in the first place. The tips are the useful half at that + * size. `tiny` narrows the "none" band: eight-column surfaces where + * `mini` (6×3) could not draw at all now get a mark, and five-row ones + * get a mark *and* a tip where mini used to evict the whole list. + */ +export type LogoChoice = LogoVariant | "none"; + +export interface SplashSize { + columns: number; + rows: number; +} + +export type TipDescriptions = "full" | "short" | "none"; + +/** + * Where the wordmark goes relative to the mark. `full` is 51 columns + * wide — parking a 46-column wordmark beside it needs 100 columns of + * chat surface, which is a 140-column terminal. Stacking it underneath + * needs only the mark's own width, so the big mark keeps its name on + * ordinary terminals instead of going anonymous above 100 columns. + */ +export type WordmarkPlacement = "beside" | "below" | "none"; + +export interface SplashFit { + /** Which brand mark to draw, or `"none"` when nothing fits. */ + logo: LogoChoice; + /** Where the `ATOMIC AGENT` wordmark sits, if it is drawn at all. */ + wordmarkPlacement: WordmarkPlacement; + /** Whether the `ATOMIC AGENT` wordmark is drawn. */ + wordmark: boolean; + /** Whether the "Local AI-First Agent" tagline is drawn. */ + tagline: boolean; + /** How many tips fit, taken from the head of `SPLASH_TIPS`. */ + tipCount: number; + /** Padded width of the tip label column (0 when unpadded). */ + labelWidth: number; + /** Which description text to pair with each tip label. */ + descriptions: TipDescriptions; +} + +export interface SplashTip { + label: string; + /** Roomy copy, used when the surface can carry it. */ + description: string; + /** Terse copy for narrow surfaces. */ + short: string; + /** + * What a click on the row puts in the composer. Every tip is a slash + * command now — the two rows that were plain hotkeys (`Enter`, + * `Ctrl+C ×2`) are gone, because a hint you cannot click reads as a + * broken control next to seven you can, and the hint strip already + * carries both keys at the foot of the screen. + */ + command: string; +} + +/** + * Start-page tips in priority order — the tail is dropped first when + * the surface runs out of rows, so the entries that keep a first-run + * operator moving have to come first. + */ +export const SPLASH_TIPS: readonly SplashTip[] = [ + { + label: "/help", + description: "list all slash commands", + short: "all commands", + command: "/help", + }, + { + label: "/sessions", + description: "switch to a previous thread", + short: "past threads", + command: "/sessions", + }, + { + label: "/new", + description: "start a fresh session", + short: "new session", + command: "/new", + }, + { + label: "/model", + description: "change the chat model", + short: "pick model", + command: "/model", + }, + { + label: "/tasks", + description: "jump to the Tasks tab (cron + ingress UI)", + short: "Tasks tab", + command: "/tasks", + }, + { + label: "/import", + description: "open the Import tab (Hermes migration)", + short: "Hermes import", + command: "/import", + }, +]; + +interface LogoMetrics { + width: number; + height: number; +} + +/** + * Rendered footprint of each mark, in cells. Kept beside the art in + * `logo.tsx` by `logo-fit.test.ts`, which re-measures the row data and + * fails if the two ever drift apart. + */ +export const LOGO_METRICS: Readonly> = { + full: { width: 51, height: 24 }, + small: { width: 31, height: 14 }, + mini: { width: 6, height: 3 }, + tiny: { width: 4, height: 2 }, +}; + +/** `ATOMIC AGENT` half-block wordmark, plus the gap that precedes it. */ +export const WORDMARK_WIDTH = 46; +const WORDMARK_GAP = 3; + +/** `paddingX` on the splash container. */ +const SPLASH_PADDING_X = 2; +/** `" • "` in front of every tip label. */ +const TIP_PREFIX_WIDTH = 4; +/** Roomy tip-label column, matching the pre-adaptive layout. */ +const TIP_LABEL_WIDE = 24; +/** Tips are worth keeping only if a few of them survive together. */ +const MIN_TIPS = 3; +/** One blank row separates the mark from the tip list. */ +const TIP_LIST_MARGIN_ROWS = 1; + +/** + * A row the splash never spends, so its content is always at least one + * row short of the pane it is rendered into. + * + * Without it the fit lands *exactly* on the pane height at roughly half + * of all terminal sizes — including all three at which the wordmark was + * reported truncated. Ink 7 overlaps rather than clips (see + * `../row-window.ts`), so at an exact fit any one-row disagreement + * between the budgeted viewport and the real pane — a wrapped hint + * strip, a terminal reporting one more row than it shows — is paid for + * by painting over a row that is already drawn, rather than by leaving + * a blank one empty. + * + * This is hardening, not a proven fix: the artwork itself is emitted + * intact at every size swept, so if the truncation survives it is + * downstream of the row data. + */ +const SPLASH_SLACK_ROWS = 1; + +/** + * Rows a stacked wordmark costs: one blank, its own two, and the + * tagline under it. Beside the mark all of that is free — the mark is + * taller than the wordmark and tagline together — so this is the only + * arrangement that has to pay for them. + */ +export const WORDMARK_STACK_ROWS = 4; + +const VARIANTS_WIDEST_FIRST: readonly LogoVariant[] = [ + "full", + "small", + "mini", + "tiny", +]; + +/** Width at which `variant` and the wordmark fit side by side. */ +function lockupWidth(variant: LogoVariant): number { + return LOGO_METRICS[variant].width + WORDMARK_GAP + WORDMARK_WIDTH; +} + +/** + * Marks big enough to carry the wordmark beside them. `mini` is six + * columns and `tiny` four; parked next to a 46-column wordmark either + * reads as a bullet point rather than a lockup. + */ +const WORDMARK_VARIANTS: readonly LogoVariant[] = ["full", "small"]; + +/** + * Where `variant`'s wordmark can go on this surface, if anywhere. + * Beside it when both fit a line, stacked underneath when they do not + * but the rows are there, and nowhere when neither works. + */ +function placementFor( + variant: LogoVariant, + inner: number, + rows: number, +): WordmarkPlacement { + if (!WORDMARK_VARIANTS.includes(variant)) return "none"; + if (lockupWidth(variant) <= inner) return "beside"; + if ( + WORDMARK_WIDTH <= inner && + LOGO_METRICS[variant].height + + WORDMARK_STACK_ROWS + + TIP_LIST_MARGIN_ROWS + + MIN_TIPS <= + rows + ) { + return "below"; + } + return "none"; +} + +function maxLength(values: readonly string[]): number { + return values.reduce((acc, value) => Math.max(acc, value.length), 0); +} + +/** + * Resolve the splash layout for a chat surface of `size`. + * + * `size` is the space the splash itself owns — already net of the root + * padding, the right rail and the prompt chrome (see `../layout.ts`). + * Width picks the mark, height then downgrades it until at least + * {@link MIN_TIPS} tips can sit underneath, and whatever rows are left + * decide how much of the tip list survives. + */ +export function computeSplashFit(size: SplashSize): SplashFit { + const inner = Math.max(0, size.columns - SPLASH_PADDING_X * 2); + const rows = Math.max(0, size.rows); + + let index = VARIANTS_WIDEST_FIRST.findIndex( + (variant) => LOGO_METRICS[variant].width <= inner, + ); + if (index === -1) index = VARIANTS_WIDEST_FIRST.length - 1; + while ( + index < VARIANTS_WIDEST_FIRST.length - 1 && + LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.height + + TIP_LIST_MARGIN_ROWS + + MIN_TIPS > + rows + ) { + index += 1; + } + // A bigger mark is not worth going nameless for. If the mark we + // picked cannot carry the wordmark either way but the next size down + // can, step down. Without this the start page LOSES its name as the + // window grows — `full` is 24 rows and cannot stack the wordmark until + // 32 rows of chat surface, so 28 rows drew a nameless full mark while + // both 24 (small + wordmark) and 32 (full + wordmark) named the app. + // Mark-over-tips is the documented priority; mark-over-wordmark is not. + const nextDown = VARIANTS_WIDEST_FIRST[index + 1]; + if ( + nextDown !== undefined && + placementFor(VARIANTS_WIDEST_FIRST[index]!, inner, rows) === "none" && + placementFor(nextDown, inner, rows) !== "none" + ) { + index += 1; + } + + let logo: LogoChoice = VARIANTS_WIDEST_FIRST[index]!; + // "Room for the mark" must mean a tip still survives it: any drawn + // mark also spends SPLASH_SLACK_ROWS, so leaving that out let the + // two-row tiny sign land on a four-row surface and evict every tip — + // exactly the mark-over-useful-half inversion "none" exists to stop. + // For every bigger variant the downgrade loop above already demanded + // MIN_TIPS, which is stricter, so only the smallest rung feels this. + if ( + LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.height + + TIP_LIST_MARGIN_ROWS + + SPLASH_SLACK_ROWS + + 1 > + rows || + LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.width > inner + ) { + logo = "none"; + } + + // The wordmark is a 46-column luxury; it rides along only with a mark + // wide enough to balance it. + const wordmarkPlacement: WordmarkPlacement = + logo === "none" ? "none" : placementFor(logo, inner, rows); + const wordmark = wordmarkPlacement !== "none"; + const tagline = wordmark; + + const markRows = + logo === "none" + ? 0 + : LOGO_METRICS[logo].height + + (wordmarkPlacement === "below" ? WORDMARK_STACK_ROWS : 0) + + TIP_LIST_MARGIN_ROWS; + // Only when a mark is drawn: on a surface too small for one the tips + // are all there is, and spending one of two rows on slack costs half + // the page to guard artwork that is not on it. + const spare = + rows - markRows - (logo === "none" ? 0 : SPLASH_SLACK_ROWS); + const tipCount = Math.max(0, Math.min(SPLASH_TIPS.length, spare)); + const visible = SPLASH_TIPS.slice(0, tipCount); + + if (visible.length === 0) { + return { logo, wordmarkPlacement, wordmark, tagline, tipCount: 0, labelWidth: 0, descriptions: "none" }; + } + + const longestLabel = maxLength(visible.map((tip) => tip.label)); + const longestFull = maxLength(visible.map((tip) => tip.description)); + const longestShort = maxLength(visible.map((tip) => tip.short)); + const tightLabel = longestLabel + 1; + const budget = inner - TIP_PREFIX_WIDTH; + + if (budget >= TIP_LABEL_WIDE + longestFull) { + return { logo, wordmarkPlacement, wordmark, tagline, tipCount, labelWidth: TIP_LABEL_WIDE, descriptions: "full" }; + } + if (budget >= tightLabel + longestFull) { + return { logo, wordmarkPlacement, wordmark, tagline, tipCount, labelWidth: tightLabel, descriptions: "full" }; + } + if (budget >= tightLabel + longestShort) { + return { logo, wordmarkPlacement, wordmark, tagline, tipCount, labelWidth: tightLabel, descriptions: "short" }; + } + return { logo, wordmarkPlacement, wordmark, tagline, tipCount, labelWidth: 0, descriptions: "none" }; +} diff --git a/src/tui/components/status-bar.test.tsx b/src/tui/components/status-bar.test.tsx new file mode 100644 index 00000000..e196f00e --- /dev/null +++ b/src/tui/components/status-bar.test.tsx @@ -0,0 +1,69 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; + +import { StatusBar } from "./status-bar.js"; +import { fakeSession } from "../test-fixtures.js"; +import { + createInitialTuiState, + type SessionPickerEntry, + type TuiState, +} from "../tui-state.js"; + +const SESSION_ID = "s-f134037c"; + +function entry(preview: string): SessionPickerEntry { + return { + sessionId: SESSION_ID, + workingDir: "/tmp", + turnCount: 1, + stepCount: 1, + updatedAt: Date.now(), + preview, + }; +} + +function stateWithPreview(preview: string): TuiState { + const base = createInitialTuiState(fakeSession({ sessionId: SESSION_ID })); + return { ...base, recentSessions: [entry(preview)] }; +} + +/** + * The bar's rows, colour codes and all. Nothing here matches across a + * style change, so the frame is read raw rather than stripped: the + * assertions are about how many rows there are and which run of plain + * text sits inside one of them. + */ +function rowsOf(state: TuiState): string[] { + const { lastFrame } = render(); + return (lastFrame() ?? "").split("\n"); +} + +describe("StatusBar", () => { + it("stays one row when the first prompt was multi-line", () => { + // The bug: previews are stored as typed, so the newlines reached Ink + // and it grew the bar to seven rows, pushing the rail, the chat and + // the composer down the screen. + const rows = rowsOf(stateWithPreview("ONE\none\n1\n1\n1\n1\n1")); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain("ONE one 1 1 1 1 1"); + }); + + it("still shows a single-line prompt unchanged", () => { + const rows = rowsOf(stateWithPreview("How are you?")); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain("How are you?"); + }); + + it("ellipsises a long prompt instead of running past the bar", () => { + const rows = rowsOf(stateWithPreview("word ".repeat(40))); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain("…"); + }); + + it("draws no title when the session has no readable preview", () => { + const rows = rowsOf(stateWithPreview(" \n\n ")); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain("session "); + expect(rows[0]).not.toContain("·"); + }); +}); diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 33750987..2889614b 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -1,21 +1,36 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { - getCurrentSection, - SECTION_ORDER, - type TuiSection, -} from "../section.js"; +import { getCurrentSection, type TuiSection } from "../section.js"; +import { menuPlaceByTab } from "../menu/menu-registry.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { DownloadChip } from "./download-chip.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { getAppVersion } from "../../version.js"; +import { Chip, tracked } from "./chip.js"; +import { sessionTitleLine } from "./session-title.js"; interface StatusBarProps { state: TuiState; + /** + * Draw the `atomic-agent vX.Y.Z` lockup. False when the rail is on + * screen: the rail already carries the brand and the version, and two + * copies of them read as a rendering bug rather than as chrome. + */ + brand?: boolean; } /** - * One-row operator status bar. Replaces the legacy `header-line` + + * One-row operator status bar. Shows **where you are**, not where you could + * go: the three-section pill row was a menu, and the menu now lives behind + * `ctrl+p` where it can hold every destination instead of only the top three. + * What is left is a breadcrumb — `Manage › Tasks` — which is the one thing + * the popup cannot tell you, because you have to open it to read it. + * + * Replaces the legacy `header-line` + * `status-line` + `footer-line` trio: only signal that needs to be * visible at every glance stays on screen — current section and a * short session id when one exists. Verbose details (full cwd, llama @@ -28,53 +43,141 @@ interface StatusBarProps { * between the top bar and the prompt to read the live signal. See * [src/tui/components/prompt-meta-status.tsx](src/tui/components/prompt-meta-status.tsx). */ -export function StatusBar({ state }: StatusBarProps): ReactElement { +export function StatusBar({ + state, + brand = true, +}: StatusBarProps): ReactElement { const section = getCurrentSection(state); + const title = currentSessionTitle(state); + const { columns } = useTerminalSize(); return ( - - atomic-agent - - v{getAppVersion()} - - + {brand ? ( + <> + + atomic-agent + + v{getAppVersion()} + + + ) : null} + + {state.localModelsPanel.pull ? ( + + ) : null} + {title ? ( + + {" "} + {theme.glyphs.dotSeparator}{" "} + + {title} + + + ) : null} ); } +/** + * The preview of the session being worked on, which the design puts in + * the top bar beside the id. Read from the rail's own session list so + * the two can never disagree about what the current thread is called. + */ +/** + * Columns the download chip may use: what is left of the row once the + * brand lockup, the breadcrumb, the session tag and the title have had + * theirs. Approximate on purpose — the point is to keep the bar on one + * row, and Ink wraps rather than clips, so an over-long chip would turn + * the header into a paragraph and push the whole app down the screen. + */ +function chipBudget(columns: number, brand: boolean, title: string | null): number { + const BRAND = 22; + const BREADCRUMB = 14; + const SESSION_TAG = 18; + const used = + (brand ? BRAND : 0) + BREADCRUMB + SESSION_TAG + (title ? title.length + 4 : 0); + return Math.max(0, columns - used - 2); +} + +function currentSessionTitle(state: TuiState): string | null { + const id = state.session.sessionId; + if (!id) return null; + // The rail's list, not the picker's: `sessionPickerList` is empty + // until someone opens the picker, so reading it meant the title only + // ever appeared after an unrelated detour through Ctrl+G U. + const entry = state.recentSessions.find((row) => row.sessionId === id); + // One line, always. Previews are stored as typed, so a multi-line + // first prompt used to arrive here with its newlines intact and Ink + // grew the bar to fit them — a one-row header became a paragraph and + // pushed the rail, the chat and the composer down the screen. + const title = sessionTitleLine(entry?.preview ?? "", TITLE_COLUMNS); + return title.length > 0 ? title : null; +} + +/** How much of the prompt the bar shows before it ellipsises. */ +const TITLE_COLUMNS = 32; + const SECTION_LABELS: Record = { run: "Run", observe: "Observe", manage: "Manage", }; -function SectionPills({ active }: { active: TuiSection }): ReactElement { - return ( +/** + * Where you are: `Section › Tab`. + * + * #165 originally made a Run / Observe / Manage pill strip clickable, but + * #170 replaced that strip with this breadcrumb — the menu is now the one + * navigation surface, and re-adding pills would give the same job two + * competing controls. So the breadcrumb itself takes the click and opens + * the menu, which is exactly what `ctrl+p` does. Clicking where you + * already are is still meaningful here: the menu is a destination list, + * not a reset. + */ +function Breadcrumb({ + state, + section, +}: { + state: TuiState; + section: TuiSection; +}): ReactElement { + const mouse = useMouseCommands(); + const tabLabel = + state.uiMode === "debug" ? menuPlaceByTab(state.activeTab)?.label : undefined; + const label = ( - {SECTION_ORDER.map((id, idx) => { - const isActive = id === active; - return ( - - - {isActive ? `${theme.glyphs.chevronRight} ` : " "} - {SECTION_LABELS[id]} - - {idx < SECTION_ORDER.length - 1 ? ( - - {" "} - {theme.glyphs.dotSeparator} - {" "} - - ) : null} - - ); - })} + + {tabLabel ? ( + + {/* The badge carries its own trailing pad; a second space here + would set the breadcrumb a full cell off from every other + separator in the bar. */} + {theme.glyphs.chevronRight} {tabLabel} + + ) : null} ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + // Open at the top of the list, the same state `ctrl+p` produces, + // so the keyboard and the mouse land on one menu rather than two + // subtly different ones. + mouse.dispatch({ type: "menu_path_set", path: null }); + mouse.dispatch({ type: "menu_cursor_set", cursor: 0 }); + mouse.dispatch({ type: "menu_opened" }); + return true; + }} + > + {label} + + ); } interface SessionTagProps { @@ -83,14 +186,12 @@ interface SessionTagProps { function SessionTag({ sessionId }: SessionTagProps): ReactElement | null { if (!sessionId) return null; + // The design sets this as `session ` in plain dim type, with a dot + // before the title that follows — no pipe. One separator glyph in the + // bar, used once, reads as punctuation; two read as a table. return ( - - {" "} - {theme.glyphs.pipeSeparator} - {" "} - session{" "} - + {" session "} {shortenId(sessionId)} ); diff --git a/src/tui/components/tasks-list.tsx b/src/tui/components/tasks-list.tsx index abed9ca4..ec1140b4 100644 --- a/src/tui/components/tasks-list.tsx +++ b/src/tui/components/tasks-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleTasksTabKey } from "../tasks/tasks-key-bindings.js"; import type { TaskSummaryRow, TasksPanelState, @@ -46,12 +48,20 @@ export function TasksList(props: TasksListProps): ReactElement { ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ type: "tasks_cursor_set", row: idx + windowStart }) + } + onActivate={pressEnter(handleTasksTabKey)} + > + + ))} {hiddenAfter > 0 ? ( diff --git a/src/tui/components/terminal-too-small.test.tsx b/src/tui/components/terminal-too-small.test.tsx new file mode 100644 index 00000000..b662d31a --- /dev/null +++ b/src/tui/components/terminal-too-small.test.tsx @@ -0,0 +1,100 @@ +import { Box } from "ink"; +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; + +import { + isTerminalTooSmall, + MIN_TERMINAL_COLUMNS, + MIN_TERMINAL_ROWS, +} from "../layout.js"; +import { planLines, TerminalTooSmall } from "./terminal-too-small.js"; + +function lines(frame: string): string[] { + return frame + .replace(/\[[0-9;]*m/g, "") + .split("\n") + .map((line) => line.replace(/\s+$/, "")); +} + +/** + * Every size a window can be dragged to, including the ones a person + * only reaches by accident. The card exists because Ink 7 overlaps a + * frame taller than the terminal rather than clipping it — so a + * "terminal too small" card that itself overflowed would reproduce the + * exact bug it was written to replace. + */ +const SIZES: ReadonlyArray<{ columns: number; rows: number }> = [ + { columns: 39, rows: 15 }, + { columns: 40, rows: 15 }, + { columns: 39, rows: 16 }, + { columns: 30, rows: 10 }, + { columns: 24, rows: 8 }, + { columns: 20, rows: 5 }, + { columns: 20, rows: 4 }, + { columns: 18, rows: 3 }, + { columns: 12, rows: 2 }, + { columns: 10, rows: 1 }, + { columns: 4, rows: 1 }, + { columns: 1, rows: 1 }, +]; + +describe("TerminalTooSmall", () => { + it.each(SIZES)("fits a $columns x $rows window", ({ columns, rows }) => { + const { lastFrame } = render( + + + , + ); + const rendered = lines(lastFrame() ?? ""); + const widest = rendered.reduce((acc, line) => Math.max(acc, line.length), 0); + expect(widest, `overflowed ${columns} columns`).toBeLessThanOrEqual(columns); + expect(rendered.length, `overflowed ${rows} rows`).toBeLessThanOrEqual(rows); + }); + + it("says what is needed and what there is", () => { + const { lastFrame } = render( + + + , + ); + const body = lines(lastFrame() ?? "").join("\n"); + expect(body).toContain("terminal too small"); + expect(body).toContain(`needs ${MIN_TERMINAL_COLUMNS}x${MIN_TERMINAL_ROWS}`); + expect(body).toContain("this one is 30x10"); + }); + + it("keeps the numbers longest, and the title first", () => { + // The ladder drops the least useful line at each step. On one row + // the title goes: someone staring at a single line of an app that + // has visibly stopped working already knows something is wrong, and + // the size is the part they cannot guess. + expect(planLines(4, "40x16", "20x5")).toEqual([ + "terminal too small", + "", + "needs 40x16", + "this one is 20x5", + ]); + expect(planLines(3, "40x16", "20x5")).toHaveLength(3); + expect(planLines(2, "40x16", "20x5")).toEqual([ + "terminal too small", + "needs 40x16", + ]); + expect(planLines(1, "40x16", "20x5")).toEqual(["40x16 needed"]); + }); +}); + +describe("isTerminalTooSmall", () => { + it("draws the line where the layout stops shrinking", () => { + // 16 rows is not a preference. Rendered against a mocked terminal + // size, the main screen comes out at 16 rows for a 16-row terminal + // — and at 16 rows for a 14-, 12-, 8- and 5-row one. Below the + // floor the frame does not get smaller, it gets painted over the + // top of itself. + expect(MIN_TERMINAL_ROWS).toBe(16); + expect(isTerminalTooSmall(40, 16)).toBe(false); + expect(isTerminalTooSmall(40, 15)).toBe(true); + expect(isTerminalTooSmall(39, 16)).toBe(true); + expect(isTerminalTooSmall(120, 40)).toBe(false); + expect(isTerminalTooSmall(80, 24)).toBe(false); + }); +}); diff --git a/src/tui/components/terminal-too-small.tsx b/src/tui/components/terminal-too-small.tsx new file mode 100644 index 00000000..5addc825 --- /dev/null +++ b/src/tui/components/terminal-too-small.tsx @@ -0,0 +1,94 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { + MIN_TERMINAL_COLUMNS, + MIN_TERMINAL_ROWS, +} from "../layout.js"; +import { theme } from "../theme/theme.js"; + +/** + * What the app draws when the window cannot hold it. + * + * Ink 7 does not clip a frame taller than the terminal — it overlaps + * earlier lines. So the failure mode below the floor was never "a + * cramped UI": it was two UIs painted over each other, with the + * composer somewhere inside the transcript and the status bar written + * across the middle of a tool card. There is no arrangement of the real + * screen that survives eight rows, and pretending otherwise is what + * produced the garble. + * + * So this replaces the app rather than shrinking it, and it has exactly + * one job: fit anything, and say the two numbers that let someone act — + * what is needed, and what they have. + * + * **It must never overflow, at any size.** That is the whole point, and + * it is why this component owns its own degradation ladder instead of + * borrowing the app's: a "terminal too small" card that itself garbles + * a 20×4 window would be the funniest possible bug. Four tiers, each + * dropping the least useful line first: + * + * >= 4 rows title, blank, needs, have + * 3 rows title, needs, have + * 2 rows title, needs + * 1 row the numbers alone — `40x16 needed` + * + * and every line is truncated to the width on the way out. + */ +export function TerminalTooSmall({ + columns, + rows, +}: { + columns: number; + rows: number; +}): ReactElement { + const need = `${MIN_TERMINAL_COLUMNS}x${MIN_TERMINAL_ROWS}`; + const have = `${columns}x${rows}`; + const lines = planLines(rows, need, have); + return ( + + {lines.map((line, idx) => ( + + {fit(line, columns)} + + ))} + + ); +} + +/** + * The ladder. Exported so the fit test can walk it without rendering, + * and so the tiers are a value rather than a shape buried in JSX. + */ +export function planLines( + rows: number, + need: string, + have: string, +): readonly string[] { + const title = "terminal too small"; + if (rows >= 4) return [title, "", `needs ${need}`, `this one is ${have}`]; + if (rows === 3) return [title, `needs ${need}`, `this one is ${have}`]; + if (rows === 2) return [title, `needs ${need}`]; + // One row, and the title is the least useful thing on it: somebody + // looking at a single line of an app that has visibly stopped working + // already knows something is wrong. The number is the part they cannot + // guess. + return [`${need} needed`]; +} + +/** + * Truncate rather than wrap. A wrapped line would take a row the ladder + * above has already spent, and put the card back over the edge it exists + * to stay inside. + */ +function fit(line: string, columns: number): string { + const width = Math.max(1, columns); + if (line.length <= width) return line; + if (width <= 1) return line.slice(0, width); + return `${line.slice(0, width - 1)}…`; +} diff --git a/src/tui/components/theme-picker.tsx b/src/tui/components/theme-picker.tsx index 3ad7c744..effe5f80 100644 --- a/src/tui/components/theme-picker.tsx +++ b/src/tui/components/theme-picker.tsx @@ -1,6 +1,15 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { THEME_NAMES, THEMES, theme, type ThemeName } from "../theme/theme.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { handleEditorSubmit } from "../submit-handler.js"; +import { + setActiveTheme, + THEME_NAMES, + THEMES, + theme, + type ThemeName, +} from "../theme/theme.js"; export interface ThemePickerProps { /** Highlighted row index into {@link THEME_NAMES}. */ @@ -52,12 +61,34 @@ export function ThemePicker(props: ThemePickerProps): ReactElement { ↑ {hiddenBefore} above ) : null} {visible.map((name, idx) => ( - + onSelect={(mouse) => { + // Same live preview the arrow keys give: the palette swaps + // under the cursor, Enter (or a second click) commits it. + setActiveTheme(THEMES[name]); + mouse.dispatch({ + type: "theme_picker_cursor_set", + row: windowStart + idx, + }); + }} + onActivate={(mouse) => + handleEditorSubmit( + "", + mouse.getState(), + mouse.dispatch, + mouse.callbacks, + ) + } + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/tool-card.tsx b/src/tui/components/tool-card.tsx index b27bcb7f..d3a9068e 100644 --- a/src/tui/components/tool-card.tsx +++ b/src/tui/components/tool-card.tsx @@ -1,5 +1,7 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { formatToolArgsBlock, previewToolArgs, @@ -18,6 +20,11 @@ interface ToolCardProps { * reveals the full args block and the full summary/details text. Pending * (in-flight) calls render with a spinner-less hourglass glyph and no * duration yet. + * + * Clicking the header line toggles the card. Until now the per-card + * toggle existed in the reducer but had no key binding at all — only + * `/expand` and `/collapse`, which act on every card at once — so the + * mouse is the first way to open one specific card. */ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement { const isFinalised = "status" in card; @@ -29,6 +36,7 @@ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement { ? `${card.finishedAt - card.startedAt}ms` : "…"; const header = ( + {theme.glyphs.toolBoxTopLeft} @@ -51,6 +59,7 @@ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement { ) : null} + ); if (!expanded) { return ( @@ -135,3 +144,29 @@ function toGlyph(status: "pending" | "ok" | "error"): string { function splitLines(text: string): string[] { return text.replace(/\r\n/g, "\n").split("\n"); } + +/** + * Wraps the card header so a click folds / unfolds that one card. + * Transparent when the mouse layer is absent. + */ +function ExpandToggle({ + cardId, + children, +}: { + cardId: string; + children: ReactNode; +}): ReactElement { + const mouse = useMouseCommands(); + if (!mouse) return <>{children}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "tool_expand_toggled", toolCardId: cardId }); + return true; + }} + > + {children} + + ); +} diff --git a/src/tui/components/uninstall-modal.test.tsx b/src/tui/components/uninstall-modal.test.tsx new file mode 100644 index 00000000..90f29024 --- /dev/null +++ b/src/tui/components/uninstall-modal.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { bodyLines } from "./uninstall-modal.js"; +import type { + UninstallFlowState, + UninstallPreview, +} from "../uninstall/uninstall-state.js"; + +const PREVIEW: UninstallPreview = { + rows: [ + { + path: "/Users/op/.atomic-agent", + label: "state", + size: "1.7 GB", + group: "data", + }, + { + path: "/Users/op/.local/bin/atomic-agent", + label: "the binary", + size: "135 MB", + group: "program", + }, + ], + total: "1.8 GB", + devCheckout: false, +}; + +function flow(overrides: Partial = {}): UninstallFlowState { + return { + step: "review", + preview: PREVIEW, + typed: "", + cursor: "cancel", + errors: [], + ...overrides, + }; +} + +const text = (state: UninstallFlowState): string => + bodyLines(state, 62) + .map((line) => line.text) + .join("\n"); + +describe("uninstall modal copy", () => { + it("names every path with its size before asking anything", () => { + const rendered = text(flow()); + expect(rendered).toContain("/Users/op/.atomic-agent"); + expect(rendered).toContain("1.7 GB"); + expect(rendered).toContain("total: 1.8 GB"); + }); + + it("says the removal is permanent, in the warn tone", () => { + const warnings = bodyLines(flow(), 62) + .filter((line) => line.tone === "warn") + .map((line) => line.text) + .join(" "); + expect(warnings).toMatch(/cannot be undone/i); + expect(warnings).toMatch(/no backup/i); + expect(warnings).toMatch(/memory|sessions|models/i); + }); + + it("repeats the size on the last screen, so the number is the last thing read", () => { + expect(text(flow({ step: "confirm" }))).toContain("1.8 GB"); + }); + + it("asks for the word on the last screen", () => { + expect(text(flow({ step: "confirm" }))).toContain("uninstall"); + }); + + it("says so plainly when there is nothing to remove", () => { + const rendered = text( + flow({ preview: { rows: [], total: "0 B", devCheckout: false } }), + ); + expect(rendered).toMatch(/nothing to remove/i); + // …and does not threaten the operator over an empty list. + expect(rendered).not.toMatch(/cannot be undone/i); + }); + + it("flags a dev checkout rather than implying it removed the binary", () => { + const rendered = text(flow({ preview: { ...PREVIEW, devCheckout: true } })); + expect(rendered).toMatch(/no installed binary/i); + }); + + it("explains the wait on the closing step", () => { + expect(text(flow({ step: "closing" }))).toMatch(/shutting the agent down/i); + }); + + it("shows the reason a plan could not be read", () => { + expect(text(flow({ step: "failed", errors: ["EACCES"] }))).toContain( + "EACCES", + ); + }); +}); diff --git a/src/tui/components/uninstall-modal.tsx b/src/tui/components/uninstall-modal.tsx new file mode 100644 index 00000000..328c0ecc --- /dev/null +++ b/src/tui/components/uninstall-modal.tsx @@ -0,0 +1,354 @@ +import { Box, Text } from "ink"; +import type { ReactElement, ReactNode } from "react"; + +import { fitToWidth } from "./fit-to-width.js"; +import { + MouseTarget, + useMouseCommands, + useMouseTarget, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { chromeTheme } from "../theme/theme.js"; +import { + isUninstallConfirmed, + UNINSTALL_CONFIRM_WORD, + type UninstallFlowState, +} from "../uninstall/uninstall-state.js"; + +const PREFERRED_WIDTH = 64; +/** Target rows at most before the list is summarised. */ +const MAX_ROWS = 7; + +interface UninstallModalProps { + flow: UninstallFlowState; + availableRows: number; + availableColumns: number; + onCancel: () => void; + onContinue: () => void; + onFocus: (cursor: "continue" | "cancel") => void; +} + +/** + * The uninstall ladder: one panel, three screens, and no way to reach + * the last one by holding Enter. + * + * The design brief for this dialog is the opposite of every other one + * in the app. Elsewhere the job is to get out of the way; here the job + * is to be *in* the way exactly as long as it takes for the operator to + * read what is about to be deleted off their own disk. So: the review + * screen names every path with its real size, the cursor starts on + * Cancel, continuing costs a deliberate ← → move, and the screen after + * it will not arm until the word `uninstall` has been typed out in + * full. Three separate refusals to guess what the operator meant. + */ +export function UninstallModal({ + flow, + availableRows, + availableColumns, + onCancel, + onContinue, + onFocus, +}: UninstallModalProps): ReactElement { + const width = Math.max(32, Math.min(PREFERRED_WIDTH, availableColumns - 2)); + const inner = width - 2; + const body = bodyLines(flow, inner); + const height = body.length + 4; + const offsetTop = Math.max(0, Math.floor((availableRows - height) / 2)); + const offsetLeft = Math.max(0, Math.floor((availableColumns - width) / 2)); + const ref = useMouseTarget( + (hit) => { + // Wheel is swallowed rather than scrolling the transcript behind a + // dialog that is asking a question about deleting it. + if (hit.event.kind === "wheel") return true; + return isPrimaryPress(hit.event); + }, + { layer: MOUSE_LAYER_MODAL }, + ); + return ( + + + {fitToWidth(` ${title(flow)}`, inner)} + + {body.map((line, idx) => ( + + {fitToWidth(line.text, inner)} + + ))} +