Compare commits

29 Commits
Author SHA1 Message Date
kimandClaude Sonnet 5 e572fc5d76 refactor: table-drive the built-in slash-command dispatch
handleSubmit's ~200-line if/return chain becomes a declarative
{test, run}[] list plus one dispatch loop. Same matching semantics
(exact vs. loose startsWith), same order (/permissions before /perm,
/model before /mode), same handler bodies — just uniform structure and
a single place the command set is registered. Handlers still close over
component state; extracting them into an independently testable module
is a larger follow-up.

Also drops a stale comment claiming Ollama can't report context length
for ":cloud" models — /api/show now returns it for every cloud model
tested (glm-5.2, qwen3.5, kimi-k2, minimax, gemma4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 16:05:45 +09:00
kimandClaude Sonnet 5 e6795effe1 fix: use width-stable icons in the status bar
⏱ (U+23F1) and ⏵ (U+23F5) are text-default emoji: string-width counts
them as 1 column but Windows Terminal, iTerm and others render them as
2. That makes a status-bar line drift wider than Ink's layout math
expects, so the live region below it under-erases on re-render and
leaves stranded copies of the bar in scrollback. Swap in ⏳ (a full RGI
emoji, unambiguously 2) and ▸ (a plain geometric shape, unambiguously 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 16:01:20 +09:00
kimandClaude Sonnet 5 13e6540cdf chore: allow esbuild@0.28.1 install script, note dep constraints
tsx 4.23.13 pulls its own esbuild 0.28.1; allowScripts only listed
0.27.2 (tsup/vite), so npm warned about the blocked postinstall. Also
records the marked<16 / typescript-7 / wrap-ansi-10 constraints in the
memo so a future upgrade pass doesn't have to rediscover them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:31:50 +09:00
kimandClaude Sonnet 5 870c30164b chore: upgrade typescript 5.9 -> 7 (native compiler)
tsc --noEmit passes with zero errors and runs noticeably faster. The
project only uses the tsc CLI for typechecking (no programmatic
typescript API imports); tsup/esbuild drives the build and is
unaffected. The native port pulls per-platform @typescript/typescript-*
binaries as optional deps, like esbuild already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:27:44 +09:00
kimandClaude Sonnet 5 997f2c523e chore: upgrade vitest 4 -> 5
Dev-only. Config (globals/environment/include) and the vi.* API surface
used by the suite are unchanged; all 45 files / 366 tests pass. Node 24
satisfies vitest 5's engine range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:24:48 +09:00
kimandClaude Sonnet 5 a24bc16513 chore: bump wrap-ansi to 10, plus safe minor dependency updates
- wrap-ansi 7 -> 10: matches the copy Ink already installs (shared
  string-width v8 width math) and ships its own types, so the ambient
  src/wrap-ansi.d.ts shim is gone.
- openai 7.5 -> 7.13, react/@types/react 19.2 -> 19.3, zod 4.4 -> 4.6,
  @types/node 26.2 -> 26.5, tsx + vscode-* patch bumps.

marked stays at 15 (marked-terminal@7.3 peer-caps it at <16). typescript
and vitest majors left for separate, tested upgrades.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:17:13 +09:00
kimandClaude Sonnet 5 a4da9b9cbd fix: strip stray NUL byte from renderMarkdown cache key
The previous commit's cache key used a raw NUL as the width/text separator,
which made git track src/ui/render.ts as a binary file. Use a plain-text
"<width> <text>" key instead (width is always a bare integer, so the space
delimiter is unambiguous) and add renderMarkdown width-wrapping tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:50:42 +09:00
kimandClaude Sonnet 5 c36b45e9d5 fix: hard-wrap history output to terminal width
Lines wider than the terminal (wide markdown tables, long code lines) were
soft-wrapped by the terminal but counted as one row by Ink's <Static> redraw,
desyncing its cursor math and stranding duplicate copies of the status bar and
input box in scrollback on every re-render.

renderMarkdown now wraps its output to the caller's width via wrap-ansi, and
every <Static> item (plus the live streaming line) is held within a
width-bounded Box so nothing the terminal would soft-wrap reaches Ink.

Also sets cli-table3 header color to yellow (red-on-blue themes made table
headers unreadable).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:34:47 +09:00
kimandClaude Sonnet 5 c649fdf6ed feat: task persistence, memoized UI components, hook/delete-failure safety
locode-authored (dogfooded) changes:
- TaskStore.toJSON/fromJSON: task_create/list state now survives session
  resume instead of resetting to empty (session.ts, task.ts, sessionStore.ts).
- StatusBar/ChatInput/ThinkingIndicator wrapped in memo() to skip re-renders
  during the ~30fps streaming-text update loop.
- HistoryItemView's todos list keys off t.content instead of array index
  (TodoItem has no id field; content is the best stable key available).
- sessionStore.ts read paths now log (console.warn) on corrupt/unreadable
  files instead of silently swallowing the error.

Fixes on top, found in review:
- safeRunHooksForEvent() wraps the 4 remaining unprotected hook call sites
  (PreToolUse, PermissionRequest, PostToolUse, SubagentStart) — these fire on
  every tool call/sub-agent, far more often than the 3 sites already wrapped
  (Stop/SessionStart/UserPromptSubmit), so a broken hook script was still able
  to abort an in-flight turn.
- deleteSession() no longer reports success when unlinkSync fails for a
  reason other than ENOENT (e.g. EPERM/EBUSY from a Windows file lock) — it
  used to drop the index entry and return true anyway, orphaning the file on
  disk with no way to reference it again. `sessions rm` in cli.ts now
  distinguishes "not found" from "found but couldn't delete" in its message.
- ChatInput's memo() was a no-op: onSubmit={handleSubmit} passed a fresh
  closure every render since handleSubmit isn't wrapped in useCallback.
  Rather than force a ~300-line function (which reads isThinking/streamingText
  — both changing every streaming frame) into a dependency array, added a
  handleSubmitRef + stable wrapper, the same ref-indirection this file
  already uses for phaseRef.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxiAaGhSD4DRVYYQZ5GJjm
2026-08-24 15:02:34 +09:00
kimandClaude Sonnet 5 5bf60c7921 fix: local/cloud model detection, capability cache TTL, chat cursor offset
- isSmallLocalModel(baseURL, model) replaces isLocalBackendURL() for system-prompt
  branching and context-window fallback defaults: Ollama's cloud-routed models
  (glm-5.2:cloud, qwen3.5:397b-cloud, etc.) share a localhost endpoint with
  genuinely local models, so the base URL alone can't tell them apart. Recomputed
  on /model and /backend switches too, not just at session creation.
- Fixed a latent isLocalBackendURL bug found while testing it: URL.hostname keeps
  the brackets on a literal IPv6 host ("[::1]"), so the old "::1" comparison never
  matched.
- capabilityCache entries now carry a cachedAt timestamp with a 30-day TTL
  (LOCODE_CAPABILITY_CACHE_TTL_DAYS), so a stale "fallback" verdict from a
  transient probe failure doesn't permanently disable native tool calls.
- ChatInput's cursor row offset (+2 -> +1): the extra row was empirical padding
  for a bottomSectionRef wrapper Box and virtual-scroll viewport that no longer
  exist since the Static-based rendering change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxiAaGhSD4DRVYYQZ5GJjm
2026-08-24 13:51:25 +09:00
kim 09cd992aa6 feat: normal screen architecture, burn rate by model time, dependency upgrades
Major changes:

- Replace alternate screen buffer + app-side mouse tracking/virtual scroll
  with normal screen buffer + Ink <Static> for permanent scrollback.
  Terminal's native scroll/selection/copy just works — no mouseInput.ts needed.
- Fix burn rate (🔥) to use model response time (modelTimeMs) instead of
  session elapsed time, so it reflects actual generation throughput.
- Upgrade dependencies: openai 6→7, commander 13→15, execa 9→10, vitest 3→4,
  node types 22→26, tsup target node20→node22.
- Update upgrade memo with current architecture, all completed upgrades,
  and working tree status.
2026-08-24 12:44:08 +09:00
kimandClaude Sonnet 5 c539995b19 fix: mouse-click crash, CRLF tool-call/edit matching bugs, misleading burn rate
- App.tsx: add the missing effectiveScrollTopRef the prior session's mouse-drag
  fix referenced but never declared — tsc failed (esbuild build doesn't
  type-check, so it looked fine), and any click/drag/release threw
  ReferenceError at runtime.
- partialJson.ts/nativeAdapter.ts: escape raw control characters (literal
  newline/CR/tab) found inside JSON string literals before parsing tool-call
  arguments, and add repair to the non-streaming completion path (previously
  only the streaming path had it) — local models echoing multi-line CRLF file
  content unescaped were failing JSON.parse outright.
- editFile.ts/multiEdit.ts: read_file always shows the model LF-normalized
  content regardless of the file's real line endings, but edit_file/multi_edit
  matched old_string against the raw (CRLF-preserving) file — a systematic
  "old_string not found" on every CRLF file in this project. Now detect the
  file's EOL, match/edit in LF space, and restore the original EOL on write.
- StatusBar.tsx: 🔥 burn rate now uses outputTokens/min instead of
  (inputTokens+outputTokens)/min — inputTokens sums the full resent context on
  every API call (no prompt caching), so it ballooned into misleading hundreds
  of K/min during chatty fallback-mode tool round trips.

339/339 tests passing (+13), tsc clean, build 295.77 KB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kn75SyL8t8SeyTwmPvADAS
2026-08-22 13:03:29 +09:00
kim 60c6021390 feat: split system prompt by local/cloud, improve mouse tracking, increase retries & iterations
- Separate system prompt for local vs cloud models via isLocal flag
  (isLocalBackendURL, buildSystemPrompt(..., isLocal), propagate through
  createSession/createSessionFromRecord/compactSession/spawnSubAgent)
- Increase MAX_EMPTY_RESPONSE_RETRIES from 1 to 3 for cloud model resilience
- Upgrade mouse input: full SGR-1006 parsing with col/row/pressed/modifiers,
  logicalButton() helper, and copyToClipboard() via OSC 52
- Add temporary mouse debug logging in App.tsx
- Increase DEFAULT_MAX_ITERATIONS from 100 to 300
- Update README mouse/scrollback docs, tweak diff-remove color
2026-08-21 18:01:29 +09:00
kim 47711a9600 docs: record 2026-08-22 session upgrades in memo 2026-08-21 17:11:56 +09:00
kim 506599020c feat: 15 upgrades — system prompt, multiline input, /undo, thinking tokens, and more
Major upgrades (15 items):

1. System prompt overhaul: tool usage guide, local model guidance, error recovery,
   safety guidelines, read/mutating tool categorization (12 tests)
2. Multiline input: Shift+Enter for newlines, bracketed paste support,
   Enter submits single-line, Ctrl+Enter forces submit on multiline
3. /undo command: removes last user turn + assistant/tool messages,
   preserves filesystem changes, shows count (7 tests)
4. Thinking/reasoning token support: capture `reasoning_content` from
   DeepSeek/QwQ-style models, render as dimmed collapsible block
5. @ mention improvements: fuzzy path matching (character-order matching),
   5-minute cache TTL for file list refresh
6. readFile binary guard: 10MB size limit, extension-based binary detection,
   null-byte heuristic, CRLF line-ending fix
7. writeFile atomic write: temp file + rename pattern, explicit ENOENT check
8. bash string accumulation: O(n²) → O(n) with array chunks
9. estimateTokens regex speedup: per-character loop → regex bulk counting
10. handleCompletedMessage: `any` → `Record<string, unknown>`
11. Session restore: persist `allowedTools` in SessionRecord, restore on resume
12. Context window parallel detection: Promise.allSettled for Ollama + LM Studio
13. Streaming text accumulation: O(n²) → O(n) with text chunks array
14. /dashboard & context bar already implemented (no changes needed)
15. Cost estimation already implemented (no changes needed)

Tests: 322 passing across 42 files, typecheck clean, build 289.56 KB
2026-08-21 17:07:49 +09:00
kim 5c3fcfdd57 docs: record this session's work in the upgrade memo
Append the 2026-08-20 session log: 12 commits (6fe9888..dfaf8d1) covering
LSP completion, local-model robustness trio, multi_edit, /mouse, DiffView,
notebook_edit, /export json, the task system, and the streaming-input fix —
with per-commit details, encoding/Python-editing notes, and remaining candidates.
2026-08-21 14:12:39 +09:00
kim dfaf8d1551 fix(ui): keep the input box usable while a response is streaming
While a turn was in flight (isThinking or streamingText), the ChatInput was
unmounted and replaced with a static "Waiting for response…" box — so the user
couldn't type ahead during output, the input field just vanished.

Now ChatInput stays mounted the whole time: the user can type and edit while
output streams in. A one-line status above it ("Waiting… esc to interrupt — type
ahead, Enter sends when done.") replaces the old full-width box. Submitting a
second turn mid-stream is still prevented — handleSubmit now guards on
isThinking/streamingText and returns early, so Enter is a no-op until the
in-flight turn finishes (the typed text stays in the box). The original race this
swap was protecting against (overlapping turns on the same session) is preserved
by that guard.

Verified: typecheck clean, build 274.76 KB, 301 tests pass.
2026-08-21 14:10:21 +09:00
kim dad0915d62 feat: structured task system with dependency graph (task_create/list/get/update)
Replaces the flat todo_write approach for non-trivial multi-step work with a
structured, incrementally-updated task store — dependencies, ownership, status,
metadata — ported from origin/v0.6.0 and adapted to the local Session/ctx.

- src/tools/task.ts: TaskStore (in-memory, per-session) + four read-only tools:
  task_create (returns id), task_list (summaries), task_get (full details),
  task_update (status pending|in_progress|completed|deleted, rename, claim via
  owner, addBlocks/addBlockedBy dependency edges, merge-patch metadata — null
  deletes a key). Self-refs/unknown ids ignored; direct 2-cycles skipped;
  delete prunes dangling refs. Schemas declared before use (fixes a TDZ in the
  v0.6.0 version). todo_write kept alongside for simpler cases.
- types.ts: ctx.taskStore. session.ts: TaskStore on every Session. loop.ts:
  expose session.taskStore in the tool ctx + on sub-agent sessions.
- tools/index.ts: register the four task tools.
- task.test.ts: 9 cases (create/list/get/update status, dependency edges both
  ways, self-ref/unknown/2-cycle guards, delete+prune, metadata merge-patch,
  absent-store error).
- README: document the structured tasks.

Verified: typecheck clean, build 275.10 KB, 301 tests pass (+9).
2026-08-21 14:07:16 +09:00
kim 5438780a55 feat(export): full transcript export + JSON dump (/export json)
The markdown export used to include only plain user/assistant text — every tool
call and tool result was dropped, so a shared transcript lost half the work.

- exportSession.ts: the markdown export now includes tool calls (fenced JSON),
  tool results (fenced), fallback tool_result blocks, and multimodal user
  content (text + [image attached] placeholders). Adds sessionToJson() — the
  full record (messages verbatim + meta + exportedAt) for cross-machine
  replay/sharing. exportSession() takes a format; defaultExportFilename() picks
  .md/.json.
- App.tsx: /export json [file] selects the JSON dump; /export [file] stays the
  markdown transcript. The export prompt remembers the chosen format.
- exportSession.test.ts: 8 cases (markdown includes tool calls/results, tool-
  only assistant turn, JSON shape, filename extensions, file writes, auto-name).
- /help + README: document /export json.

Verified: typecheck clean, build 268.34 KB, 292 tests pass (+8).
2026-08-21 13:56:05 +09:00
kim b2a7d1a0f0 feat: notebook_edit tool — cell-aware Jupyter (.ipynb) editing
Ported from origin/v0.6.0, adapted to resolveWithinCwd (no setLastEdit).

- src/tools/notebookEdit.ts: replace/insert/delete a cell by cell_id or
  cell_index. Converts the model's single-string new_source to/from nbformat's
  source line-array (trailing-newline convention), so the model never hand-writes
  the array quirk. Switching a code cell to markdown drops execution_count/outputs;
  switching to code adds them. Atomic temp+rename write, JSON validated.
- tools/index.ts: register notebook_edit.
- notebookEdit.test.ts: 9 cases (replace by id/index, insert at position/append,
  delete, missing-id failure, insert-without-cell_type, replace-without-
  new_source, diff preview, code→markdown field drop, path containment).

Prefer this over edit_file/write_file for .ipynb so the JSON structure stays
valid (and avoids regenerating a whole notebook JSON in the model's output —
a context-window win for local models).

Verified: typecheck clean, build 266.91 KB, 284 tests pass (+9).
2026-08-21 13:52:16 +09:00
kim ee695a64af feat(ui): color-coded diff rendering in the permission prompt
The pre-confirmation preview for mutating tools (edit_file/multi_edit/write_file/
bash/git_commit) was rendered as plain monochrome text, throwing away the most
useful signal in the thing the user evaluates before approving.

- src/utils/diff.ts: looksLikeDiff() — distinguishes a unified diff from a plain
  preview (bash command, 'Create new file ...', JSON args).
- src/ui/ink/DiffView.tsx: renders a unified diff with additions green, removals
  red, hunk/file headers dimmed. Optional side-by-side mode (pairHunk + clip,
  both exported for testing). Ported from origin/v0.6.0.
- src/ui/theme.ts: DIFF_ADD_HEX / DIFF_REMOVE_HEX.
- PermissionPrompt.tsx: use DiffView when the preview is a diff, keep the
  line-by-line render for non-diff previews.

Tests: diff.test.ts (4), DiffView.test.ts (8) for pairHunk/clip/looksLikeDiff.

Verified: typecheck clean, build 261.50 KB, 275 tests pass (+12).
2026-08-21 13:50:50 +09:00
kim e3520b8eb1 feat(ui): /mouse toggle — enable terminal text selection/copy by default
Mouse-wheel tracking (xterm ?1000h) was always on so the wheel could scroll
the transcript, but it also captures mouse events, which prevents the
terminal's native text selection/drag-to-copy — a frequent ask.

Now mouse tracking defaults to OFF, so selecting/dragging output to copy
works out of the box. PageUp/PageDown still scroll (they always did). Toggle
the wheel back on with '/mouse on' (re-captures mouse events, so selection
won't work while on); '/mouse off' restores selection. '/mouse' shows the
current state. Documented in the /help list and README.

Verified: typecheck clean, build 257.55 KB, 263 tests pass.
2026-08-21 13:48:41 +09:00
kim c8cc78e8f1 docs+test: document LSP code intelligence; cover the LSP tools
- README.md: list definition/references/diagnostics + multi_edit in the tools
  overview; add a Code intelligence (LSP) section (lazy per-language servers,
  required binaries, post-edit sync, freshness wait); add a lspServers config
  example.
- src/tools/codeIntel.test.ts: mock lspManager and assert each tool dispatches
  the right args (path/cwd, 1-indexed positions, includeDeclaration default true)
  and returns the manager result verbatim — no servers spawned. 7 tests.

Verified: typecheck clean, 263 tests pass (+7).
2026-08-21 13:45:40 +09:00
kim f2ca1543d2 feat(lsp): configurable language servers + merge C/C++ into one clangd
The built-in LANGUAGE_SPECS were hardcoded — users couldn't add Java/Ruby/Lua
servers or override a built-in's command/args, and the file's own comment
flagged this as a future extension. Also, c and cpp were separate specs both
spawning clangd, so a mixed C/C++ project ran two indexing the same headers.

- lspManager.ts: LANGUAGE_SPECS is now mutable; configureLanguageSpecs(overrides)
  merges user entries (keyed by languageId) into the built-ins. A built-in id
  override replaces command/args and, if extensions is given, rewrites routing.
  A new id adds a mapping but REQUIRES extensions (ignored otherwise — can't
  route files to it). C and C++ collapse into one 'c' clangd spec (all
  .c/.h/.cpp/.cc/.cxx/.hpp/.hh/.hxx route to a single clangd).
- config.ts: resolveLspServers() reads stored.lspServers.
- store.ts: StoredConfig.lspServers field.
- cli.ts: 'locode config set lspServers <json>' (JSON object value, validated).
- ui/ink/index.tsx: configureLanguageSpecs(resolveLspServers()) at startup.
- lspManager.test.ts: 6 tests for the merge (add, override, rewrite exts,
  ignore-without-extensions, c/cpp collapse) via _specsForTests/_resetSpecsForTests
  — no servers spawned.

Verified: typecheck clean, build 256.60 KB, 256 tests pass (+6).
2026-08-21 13:43:05 +09:00
kim b7233afd77 fix(lsp): await publishDiagnostics instead of a single event-loop turn
getDiagnostics used to sync the document then wait one macrotask
(setTimeout 0) before reading the cached snapshot. tsserver/pyright on a
large file publish asynchronously and often hadn't fired yet, so the call
returned a stale (or empty) snapshot right after an edit — exactly when the
model asks for diagnostics to verify its change.

Now: clear the stale URI snapshot, sync, then race the next
publishDiagnostics for that URI against a 1500ms timeout via a per-URI
waiter map woken by the publish handler. A slow server gets a real chance
to compute fresh diagnostics; on timeout we fall through to whatever's
cached (possibly empty). _resetForTests clears the waiter map too.

Verified: typecheck clean, 250 tests pass.
2026-08-21 13:34:51 +09:00
kim a458b3c478 feat: multi_edit tool — batched edits to one file in a single atomic write
Ported from origin/v0.6.0 and adapted to the local codebase (resolveWithinCwd
path guard, no setLastEdit). Reuses edit_file's applyEdit/countOccurrences.

- src/tools/multiEdit.ts: applies an ordered batch of {old_string, new_string,
  replace_all?} edits to one file. Each edit validates against the running
  result (an earlier edit can shift the text a later edit matches), so a
  mismatch names its edit index. One confirmation + one temp+rename atomic
  write instead of N edit_file round-trips — the biggest local-model win,
  since each edit_file is a full generation + permission prompt.
- editFile.ts: export applyEdit + countOccurrences for reuse.
- tools/index.ts: register multi_edit alongside edit_file.
- multiEdit.test.ts: 8 cases (ordered batch, chained edits, error on first
  mismatch w/ index, ambiguity guard, replace_all, path containment, diff
  preview, preview warning).

Verified: typecheck clean, build 254.81 KB, 250 tests pass (+8).
2026-08-21 13:32:51 +09:00
kim 0ddc822238 fix: local-model tool-call robustness (partial-JSON repair, fallback parser, empty-response retry)
Three upgrades targeting the most common local-model failure modes where a
tool call is intended but never executes:

#2 Partial/truncated-JSON recovery (native streaming):
- src/toolcalling/partialJson.ts: cheap structural repair for tool-call
  arguments that fail JSON.parse — close unterminated strings, balance
  open braces/brackets (max_tokens truncation), strip trailing commas and
  stray trailing tokens. Never invents keys/values; a repaired call still
  goes through schema validation.
- agent/loop.ts: on a parse failure in accumulated native tool calls, try
  repair before falling back to a full non-streaming regeneration (which
  is expensive on a local backend and fails identically when the cause was
  max_tokens). Repaired args replace the broken ones; only unrepairable
  calls trigger the retry.
- partialJson.test.ts: 10 cases (truncation, trailing comma nesting,
  braces inside strings, escaped quotes, stray trailing content).

#3 Fallback tool-call parser robustness:
- src/toolcalling/fallbackParser.ts: now accepts ```tool_call blocks
  (multiline-anchored so an inner ```json fence isn't read as the
  terminator), ```json blocks, AND bare unfenced tool-call objects in
  prose. Inner ```json fences are stripped. Every candidate runs through
  partial-JSON repair, so a truncated fence (no closing ```) still
  recovers. Only accepts bare braces that contain "name"+"arguments" keys.
- fallbackParser.test.ts: 11 cases.

#6 Empty-response retry:
- agent/loop.ts: a bare empty `stop` (no text, no tool calls — common from
  small/quantized local models) now retries once with a nudge instead of
  returning "" or hard-erroring. After the retry budget is exhausted, a
  genuine empty response throws "Empty response from model."
- loop.test.ts: retry-then-succeed and retry-exhausted-throws cases.

Verified: typecheck clean, build 251.48 KB, 242 tests pass (+22).
2026-08-21 13:31:37 +09:00
kim cb950891d5 feat: LSP code intelligence + parallel sub-agent mutation gate
Code intelligence (LSP):
- src/codeintel/lspManager.ts: lazy per-language LSP server lifecycle
  (tsserver/pyright/gopls/clangd/rust-analyzer), didOpen/didChange sync,
  definition/references/diagnostics, notifyFileChanged, shutdownAll.
  Fix stream typing (StreamMessageReader/Writer) + MarkupContent→string.
- src/tools/codeIntel.ts: definition/references/diagnostics read-only tools.
- tools/index.ts: register the three LSP tools.
- agent/loop.ts: wire notifyFileChanged into the FileChanged hook path
  (fire-and-forget, best-effort) so live servers stay in sync with disk.
- ui/ink/index.tsx: shutdownAll on exit so spawned servers aren't orphaned.

Parallel sub-agent orchestration:
- agent/session.ts: session.mutationGate promise chain serializes every
  mutating tool call across the session (incl. parallel sub-agents that
  share the parent session) so they can't race on the single permission
  slot or interleave filesystem writes. Read-only tools stay concurrent.
- agent/loop.ts: runUnderMutationGate wraps mutating tool execution;
  sub-agents inherit the parent's gate.
- tools/agentTool.ts: `tasks` array runs N sub-agents in parallel; one
  failure surfaces as that task's error, not a rejected batch.
- tools/types.ts: SubAgentResult type.

Other:
- cli.ts + mcp/client.ts: read version from package.json instead of
  hardcoding "0.3.1".
- agent/parallelAgents.test.ts: parallel batch + mutation-gate tests.
- Remove scratch guardtest2.mjs.

Verified: typecheck clean, build 245.85 KB, 219 tests pass.
2026-08-21 13:22:34 +09:00
kim 6fe98887d5 v0.6.0: complete all 12 local-model upgrades + raise maxIterations to 100
Upgrade candidates (all 12 done):
- #1 parallel read-only tool execution (runToolBatch, 4 loop sites)
- #2 configurable retry policy (maxRetries + exponential backoff via SDK)
- #3 script-aware token estimation (CJK/symbol/structure-aware heuristic)
- #4 head+tail output capping (truncate.ts), auto-applies to bash/git/etc
- #5 partial-history compaction (preserve recent tail, summarize older prefix)
- #6 dynamic max_tokens (resolveMaxTokens)
- #7 richer tool descriptions with "use when" guidance
- #8 MCP reconnect retry + /mcp reconnect command + session toolset refresh
- #9 context-window cache TTL (cachedAt timestamp, default 7 days)
- #10 edit_file similar-match suggestion on old_string miss (bounded Levenshtein)
- #11 git_status output head+tail (resolved via #4)
- #12 auto-accept now approves all mutating tools; auto-edit stays edit-only

Other:
- DEFAULT_MAX_ITERATIONS 50 -> 100 (local models issue one tool call per step)
- MaxIterationsError message guides resume + config override
- plus prior known-issues work (grep -e/--, session id sanitization, MCP content
  types, 12 hook events, plugin collisions, skill references, git ops expansion,
  configurable autoCompactThreshold, bashGuard, pathGuard, FilePanel, replay)
2026-08-20 16:52:03 +09:00
95 changed files with 10988 additions and 1290 deletions
+2095
View File
File diff suppressed because one or more lines are too long
+17 -9
View File
@@ -87,11 +87,12 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
- **Ctrl+O**: print the full text of the last `/compact` (or auto-compact) summary. The collapsed notice you see right after compacting only shows a one-line hint — press Ctrl+O any time afterward to print the whole thing.
- **Ctrl+B**: while a `bash` command is running, detaches it into the background and returns control to you immediately — the turn continues with a `bash_output`-checkable job id instead of waiting for the command to finish. A notice appears in the transcript once the backgrounded command actually completes. Only `bash` supports this today. The model can kill a still-running backgrounded job with `bash_kill`; any jobs still running when locode itself exits are killed too, so they don't outlive the process as orphans.
- **Ctrl+F**: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — **Files**, the project's collapsible file tree (directories in cyan, same `node_modules`/`.git`/`dist` exclusions as `@` mentions), and **Activity**, the files `read_file`/`write_file`/`edit_file` have touched so far this session, most recent first, with a status glyph (`·` read, `+` written, `~` edited) and a repeat count. **Ctrl+G** switches between the two tabs. While the panel is focused, `↑`/`↓` move the selection (auto-scrolling to keep it in view), `↵`/`←`/`→` expand or collapse the selected folder, and **Esc** hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history.
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write` run automatically. `write_file`, `edit_file`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write`, `task_create`, `task_list`, `task_get`, `task_update` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Permission modes**: `default` (ask before every mutating tool), `plan` (research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead), `auto-edit` (file edits auto-approved, `bash`/`git_commit` still ask), `auto-accept` (everything auto-approved — use with care). Cycle with `Shift+Tab` or set directly with `/perm <mode>`.
- **Task checklists**: for multi-step work the model can call `todo_write` to show a live checklist (`☐`/`◐`/`☑`) in the transcript instead of silently working through a list you can't see progress on.
- **Structured tasks**: for multi-step work the model can call `task_create`/`task_list`/`task_get`/`task_update` to track units of work with a dependency graph (`blocks`/`blockedBy`), ownership (`owner`), status, and free-form metadata — created incrementally rather than replaced wholesale. The older flat `todo_write` live checklist (`☐`/`◐`/`☑`) remains for simpler cases.
- **Project instructions**: a `CLAUDE.md` (or `AGENTS.md`) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told.
- **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import <path> [caption]`.
- **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text.
@@ -100,10 +101,12 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
- **MCP servers**: locode connects to any [MCP](https://modelcontextprotocol.io) servers configured via `locode mcp add` or a project's `.mcp.json` (stdio and remote/streamable-HTTP transports), and adds their tools to every session, namespaced as `mcp__<server>__<tool>`. Every MCP tool is treated as mutating (confirmation required on every call) regardless of what it reports — the MCP `readOnlyHint` annotation is advisory and could be wrong (or set by a malicious server specifically to skip confirmation), so locode never trusts it. One misconfigured server doesn't block the others — check `/mcp` for per-server connection status.
- **Claude Code plugins**: `locode plugin add <path-or-git-url>` installs a Claude Code-compatible plugin — locode reads its `.claude-plugin/plugin.json`, then loads it all directly: MCP servers (its `.mcp.json` or manifest `mcpServers`, merged in like any other MCP server), slash commands (`commands/*.md` — frontmatter `description`/`argument-hint`, body is a template expanded with `$ARGUMENTS`/`$1..$9` and submitted as your message), agents (`agents/*.md` — the body becomes a sub-agent's system prompt, exposed as a callable tool named `agent__<plugin>__<agent>`; a `tools:` frontmatter list restricts what it can use, with Claude Code's built-in tool names — Read, Grep, Edit, etc. — automatically mapped to locode's equivalents), hooks (`hooks/hooks.json`, see below), and skills (`skills/*/SKILL.md`, see below). Check `/plugins` for what's loaded.
- **Skills**: named instructions the model loads on demand rather than a hook or a sub-agent — every installed skill (`skills/<name>/SKILL.md`) is exposed through one shared `skill` tool, whose own description lists every skill's name and "use this when..." blurb so the model knows when to call it. You can also invoke one directly with `/<skill-name> [request]`, which skips the model's own judgment and submits the skill's instructions (plus your request, if any) as the turn. Check `/skills` for what's installed.
- **Hooks**: shell commands that fire on session lifecycle events — `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. Configured the same way MCP servers are — plugin-bundled (`hooks/hooks.json`), user-level (`locode hooks path`, hand-edited), and project-level (`.locode/hooks.json`) all merge together, every hook from every source runs. A hook receives a JSON payload on stdin (`session_id`, `cwd`, `hook_event_name`, plus event-specific fields like `prompt` or `tool_name`/`tool_input`); exit 0 allows (stdout becomes injected context for `SessionStart`/`UserPromptSubmit`), exit 2 blocks (stderr is the reason shown), anything else is a non-blocking warning. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `PreToolUse` fires before permission modes apply, so a hook's block can't be bypassed by auto-accept. `command` and `http` hook types are supported (`prompt` is declared in the config format but not yet executed); command hooks can opt into structured JSON output via `outputSchema: "json"`. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`). Check `/hooks` for what's configured.
- **Hooks**: shell commands that fire on session lifecycle events — `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. Configured the same way MCP servers are — plugin-bundled (`hooks/hooks.json`), user-level (`locode hooks path`, hand-edited), and project-level (`.locode/hooks.json`) all merge together, every hook from every source runs. A hook receives a JSON payload on stdin (`session_id`, `cwd`, `hook_event_name`, plus event-specific fields like `prompt` or `tool_name`/`tool_input`); exit 0 allows (stdout becomes injected context for `SessionStart`/`UserPromptSubmit`), exit 2 blocks (stderr is the reason shown), anything else is a non-blocking warning. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `PreToolUse` fires before permission modes apply, so a hook's block can't be bypassed by auto-accept. `command` and `http` hook types run an external process/request; a `prompt` hook just injects its static `message` as additional context instead. Command/http hooks can opt into structured JSON output via `outputSchema: "json"`. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`). Check `/hooks` for what's configured.
- **Tool-calling mode**: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced ` ```tool_call ``` ` JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with `--tool-mode native|fallback|auto` or the in-session `/mode` command.
- **Context tracking & compaction**: the status bar shows `ctx NN%` — context window usage, from real `usage.prompt_tokens` when the backend reports it (requested via `stream_options.include_usage`), or a `~`-prefixed char-based estimate otherwise. The window size itself is auto-detected (Ollama's `/api/show`, then LM Studio's `/api/v0/models`) and cached per backend+model; falls back to a configurable default (`locode config set contextWindow <n>`, or `$LOCODE_CONTEXT_WINDOW`) if neither responds. At 85% usage, locode automatically asks the model to summarize the conversation and replaces the history with that summary (a notice tells you when this happens) — or trigger it yourself anytime with `/compact`.
- **Code intelligence (LSP)**: `definition`, `references`, and `diagnostics` use a real language server (LSP) for go-to-definition, find-all-references, and type/syntax error checks — the same engine an editor's Problems panel uses, more precise than `grep`. A server is lazily started per language on first use and reused for the whole session: `typescript-language-server` (TypeScript/JavaScript), `pyright-langserver` (Python), `gopls` (Go), `rust-analyzer` (Rust), and `clangd` (C/C++ — one clangd covers both). The relevant server binary must be on your PATH; if it isn't, the tool returns a clear "install X" error. After any edit, locode syncs the file to the live server so a subsequent `diagnostics` call reflects the change (it waits for the server to publish fresh diagnostics rather than reading a stale snapshot). Add or override servers with `locode config set lspServers` (see Config).
Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try `/mode fallback`.
## Slash commands
@@ -112,6 +115,7 @@ Note: even models with genuine native tool-calling support occasionally emit a t
/model <name> switch the model used for the current backend
/backend <name> switch backend (ollama | lmstudio), keeps current model
/mode <name> view or force tool-call mode (native | fallback)
/mouse [on|off] toggle mouse tracking (on by default; hold Shift+click/drag for native text selection)
/perm [mode] cycle or set permission mode (default | plan | auto-edit | auto-accept)
/status show current model, backend, tool-call mode, and cwd
/dashboard show session stats: token I/O, elapsed/model time, turns, tool calls
@@ -123,7 +127,7 @@ Note: even models with genuine native tool-calling support occasionally emit a t
/hooks show configured hooks per lifecycle event
/skills show installed skills; /<skill-name> [request] invokes one directly
/compact summarize the conversation now to free up context
/export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)
/export [file] save the conversation as markdown (or /export json [file] for a full JSON dump incl. tool calls/results)
/import <path> [caption] attach a local file or image to your next message
/clear clear conversation history
/help show this help
@@ -143,6 +147,11 @@ locode config set autoCompactThreshold 0.85 # fraction of context window at whi
locode config set requestTimeoutMs 300000 # per-request timeout in ms (default 180000); raise this if
# your backend queues requests behind a concurrency limit
# (e.g. Ollama's OLLAMA_NUM_PARALLEL) under multi-session load
locode config set lspServers '{"java":{"command":"jdtls","extensions":[".java"]}}' # add a language server
# (JSON object keyed by language id; built-in ids
# override command/args, new ids add support and
# require extensions). Built-ins: typescript,
# python, go, rust, c (C/C++ share clangd).
locode config get
locode config path
```
@@ -151,13 +160,12 @@ locode config path
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script.
- Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above).
- No sandboxing beyond the confirmation prompts — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand.
- Session resume replays prior user/assistant text so you can see it, but it doesn't re-display prior tool-call/tool-result lines from before the resume (the model still has that history — it's just not re-rendered).
- No in-app scrollback — once a message scrolls off the top of the window it's gone until you resize the terminal taller (the conversation itself is still intact and sent to the model; this only affects what you can visually re-read).
- No OS-level sandboxing (no container/VM isolation) — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand. Two lightweight guardrails run unconditionally regardless of permission mode (including `auto-accept`), as a safety floor rather than a full sandbox: `write_file`/`edit_file`/`bash`'s `cwd` override can't target a path outside the working directory (`../` traversal, an absolute path elsewhere, or — on Windows — a different drive all refuse), and `bash` refuses a short list of unambiguously catastrophic commands (wiping the filesystem root or home directory, a fork bomb, formatting/wiping a whole drive, writing raw data to a block device) before they'd ever run. Neither guard stops a model from doing damage confined to *within* the project directory, or running something merely inadvisable — see `src/tools/pathGuard.ts` and `src/tools/bashGuard.ts`.
- In-app scrollback: mouse wheel scrolls the conversation view when mouse tracking is on (the default). PageUp/PageDown also scroll a page at a time. Hold Shift+click/drag for native terminal text selection and copy (when mouse tracking is on). Scrolling back up unpins the view from the latest message; scrolling back to the bottom (or sending a new message) re-pins it so new messages auto-scroll into view. Toggle mouse tracking with `/mouse on|off`.
- Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting.
- `git_commit` covers add/commit/create_branch/checkout/push/reset/stash/merge/rebase/delete_branch. Use `bash` for anything beyond that.
- MCP tool results support text, image, audio, and resource content blocks. Images are returned in the same shape as `read_file` so vision-capable models can see them; audio and binary resources are summarized. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows.
- Compaction (`/compact` or automatic) replaces history with a model-generated prose summary — it costs one extra model call and loses tool-call/tool-result detail (the model's own account of what happened survives; the raw record doesn't). The auto-compact threshold defaults to 85% and is configurable via `locode config set autoCompactThreshold` or `LOCODE_AUTO_COMPACT_THRESHOLD`.
- Plugin support (`locode plugin add`) now covers every part of a plugin: MCP servers, slash commands, agents, hooks, and skills. A plugin's `allowed-tools` restriction on a command isn't enforced (the expanded prompt just runs as a normal turn with the full toolset). Duplicate MCP server names across sources are now detected and surfaced in `/mcp` — project-level wins over user-level wins over plugin-level. Duplicate slash-command and skill names are also surfaced in `/plugins` and `/skills`.
- Plugin support (`locode plugin add`) now covers every part of a plugin: MCP servers, slash commands, agents, hooks, and skills. A slash command's `allowed-tools` frontmatter restricts that one invocation's toolset (same tool-name translation as an agent's `tools:` — see `/plugins`); a skill invoked directly via `/<skill-name>` isn't restricted this way, since skills have no `allowed-tools` field of their own. Duplicate MCP server names across sources are now detected and surfaced in `/mcp` — project-level wins over user-level wins over plugin-level. Duplicate slash-command and skill names are also surfaced in `/plugins` and `/skills`.
- Skills are exposed as one shared `skill` tool rather than one tool per skill — if two plugins install a skill with the same name, the first plugin in load order wins and the collision is shown in `/skills`. Skills can bundle sibling `references/*.md` files that are included when the skill is invoked.
- Hooks cover the most useful subset of Claude Code's lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. `CwdChanged` is declared in the config format but not yet fired anywhere — a cwd never changes mid-session in locode today, so configuring it is a no-op for now. `command` hooks and `http` hooks are supported; `prompt` hooks are declared in the config format but not yet executed. Command hooks can opt into structured JSON output via `outputSchema: "json"`. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`), so it doesn't always have a real session id to report. All hooks for an event run in parallel with no defined ordering, and every configured hook always runs — there's no way to disable one without editing the file it came from.
- Hooks cover the most useful subset of Claude Code's lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. `CwdChanged` is declared in the config format but not yet fired anywhere — a cwd never changes mid-session in locode today, so configuring it is a no-op for now. All three hook types are supported: `command` and `http` run an external process/request, and `prompt` just injects its static `message` as additional context (the same way a command/http hook's stdout does) — it has no process to fail, so it can't block an event the way a command hook's exit code 2 can. Command/http hooks can opt into structured JSON output via `outputSchema: "json"`. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`), so it doesn't always have a real session id to report. All hooks for an event run in parallel with no defined ordering, and every configured hook always runs — there's no way to disable one without editing the file it came from.
+91
View File
@@ -0,0 +1,91 @@
# locode 설계 메모
## 프로젝트 개요
**locode** — Claude Code의 설계 철학을 가져와 구현한 에이전트 코딩 CLI. TypeScript + Ink(React-for-CLI) 기반. 백엔드는 OpenAI 호환 `/v1/chat/completions` 엔드포인트 사용 (Ollama, LM Studio, 클라우드 API 모두 지원).
- 핵심 철학: "신뢰할 수 없고 느리고 비전/툴콜 지원이 불확실한 모델"이라는 현실에 맞춰 모든 가정을 비관적으로 재단. 로컬/클라우드 자동 감지로 프롬프트 분기.
- Claude Code 플러그인 포맷을 직접 소비하는 하위호환 브리지 (`.claude-plugin/plugin.json`, commands/agents/skills/hooks/MCP)
---
## 아키텍처 결정 이유
### Normal Screen + `<Static>` (alternate screen 폐지)
이전에는 alternate screen buffer + 인앱 가상 스크롤 + 마우스 트래킹을 직접 구현했으나 완전히 폐지. 이유:
- **코드 복잡도**: 마우스 SGR-1006 파싱, 선택 영역, 스크롤 상태 관리가 App.tsx의 절반을 차지
- **터미널 호환성**: alternate screen은 SSH, tmux, Windows Terminal 등에서 파편화 심함
- **버그 발생률**: 마우스 크래시, 선택 텍스트 깨짐 등 이슈가 끝없이 발생
- **현재 방식**: Ink `<Static>`으로 완료된 히스토리를 한 번만 렌더링 → 터미널 스크롤백의 영구 부분. 재렌더링 없음. 마우스는 터미널 네이티브에 의존.
### 로컬/클라우드 모델 감지: `isSmallLocalModel()`
- **이전**: `isLocalBackendURL(baseURL)` — URL이 localhost면 무조건 "로컬 모델"
- **문제**: Ollama가 클라우드 라우팅 모델(`glm-5.2:cloud`, `qwen3.5:397b-cloud`)도 같은 localhost에서 서비스함. 이 모델들은 컨텍스트 128K~1M이고 툴콜도 안정적인데 로컬용 보수적 프롬프트가 적용됨.
- **해결**: `isSmallLocalModel(baseURL, model)` = `isLocalBackendURL(baseURL) && !isCloudRoutedModelName(model)` — 모델명의 `:cloud`/`:-cloud` 태그로 구분.
- **기본값**: `isLocal`의 기본값을 `true`에서 `false`(클라우드)로 변경. 명시적 지정이 없으면 보수적이 아닌 기본 프롬프트 사용.
### 컨텍스트 윈도우 기본값 분리
- **이전**: `DEFAULT_CONTEXT_WINDOW = 8192` (단일, 로컬 모델 기준)
- **현재**: `DEFAULT_CONTEXT_WINDOW_LOCAL = 8192`, `DEFAULT_CONTEXT_WINDOW_CLOUD = 131072` — 클라우드/Ollama 클라우드 라우팅 모델은 128K~1M 컨텍스트를 가지므로 8192는 과도하게 보수적.
### 번인레이트(🔥) 계산
- **이전**: `outputTokens / 세션 경과 시간` — 사용자가 방치하면 번인레이트가 0에 수렴해서 의미 없음
- **현재**: `outputTokens / modelTimeMs` — 실제 모델 응답 시간으로 계산. "이 모델이 얼마나 빠르게 토큰을 뿜는가"를 정확히 반영.
### 파일 인코딩: CRLF/LF 혼재
- Windows 환경에서는 CRLF, Unix에서는 LF가 섞여 있음. `edit_file`/`multi_edit`은 매칭 전 LF로 정규화하고, 쓰기 전 원래 EOL을 복원. 이것 없이는 Windows에서 거의 모든 edit_file이 실패함.
---
## 핵심 파일 맵
- `src/ui/ink/index.tsx` — 진입점. alternate screen 없이 Ink render. cleanup 시 flush + 종료.
- `src/ui/ink/App.tsx` — 메인 UI 컴포넌트. `<Static>` + 라이브 영역. 상태: starting→connecting→loading-models→model-select/session-select→input.
- `src/ui/ink/ChatInput.tsx` — 커스텀 multiline 입력. Shift+Enter 줄바꿈, bracket paste, @멘션 fuzzy picker, IME 커서.
- `src/agent/loop.ts` — 메인 에이전트 루프. 턴/스트리밍/툴콜/컴팩션/서브에이전트/병렬 툴 배치/반복 루프 감지.
- `src/agent/session.ts` — Session 객체, 통계, 상태, mutation gate.
- `src/agent/systemPrompt.ts` — 시스템 프롬프트 빌더 (`isSmallLocalModel` 기반 로컬/클라우드 분기).
- `src/config/defaults.ts` — 모든 기본값. `isSmallLocalModel()`, `isCloudRoutedModelName()`, `DEFAULT_CONTEXT_WINDOW_LOCAL/CLOUD` 등.
- `src/toolcalling/` — native 어댑터, fallback 파서/프롬프트, partialJson 복구, resolve (Ollama 빈키 복구 포함).
---
## 설정 기본값
| 설정 | 기본값 | 비고 |
|---|---|---|
| `DEFAULT_CONTEXT_WINDOW_LOCAL` | 8192 | 작은 로컬 모델 폴백 |
| `DEFAULT_CONTEXT_WINDOW_CLOUD` | 131072 | 클라우드/클라우드 라우팅 폴백 |
| `DEFAULT_MAX_ITERATIONS` | **300** | 50→100→300 상향 |
| `DEFAULT_MAX_OUTPUT_TOKENS` | **131072** | 128K. GLM 등 1M 컨텍스트 모델 대응 |
| `DEFAULT_MAX_RETRIES` | 0 | SDK 지수 백오프 |
| `DEFAULT_AUTO_COMPACT_THRESHOLD` | 0.85 | |
| `DEFAULT_REQUEST_TIMEOUT_MS` | 180,000 | 3분 |
| `DEFAULT_SUBAGENT_TIMEOUT_MS` | 600,000 | 10분 |
| `MAX_EMPTY_RESPONSE_RETRIES` | **3** | 1→3 상향 |
| `MAX_SUBAGENT_DEPTH` | 1 | 서브에이전트 중첩 금지 |
| `MAX_PRESERVED_TAIL_MESSAGES` | 8 | 컴팩션 시 보존 |
| `MAX_PRESERVED_TAIL_FRACTION` | 0.3 | 컴팩션 시 보존 비율 |
| `MAX_RETAINED_IMAGES` | 2 | 히스토리 이미지 보존 |
---
## 트러블슈팅 힌트
- **"자꾸 에러"**: 주요 원인은 max_tokens 잘림 → malformed 툴콜. 동적 max_tokens + CRLF 제어문자 이스케이프로 해결됨.
- **CRLF edit_file 매칭 버그**: LF 정규화 공간에서 매칭, 쓰기 전 원래 EOL 복원으로 해결됨.
- **"Paused after N steps"**: `locode config set maxIterations <number>` (기본 300)
- **클라우드 모델 빈 응답**: `MAX_EMPTY_RESPONSE_RETRIES=3`으로 재시도
- **로컬/클라우드 프롬프트 분기**: `isSmallLocalModel(baseURL, model)` — Ollama 클라우드 라우팅 모델(`:cloud` 태그)은 localhost여도 클라우드 프롬프트 사용
- **반복 루프 감지**: `detectRepetitionLoop()` — 스트리밍 텍스트에서 짧은 반복 패턴 감지 시 중단
- **번인레이트(🔥)**: `outputTokens ÷ modelTimeMs` 기준 (세션 경과 시간이 아닌 실제 모델 응답 시간)
- **IPv6 localhost**: `isLocalBackendURL()`은 `[::1]` 형식(WHATWG URL 직렬화)도 인식
---
## 의존성 제약
- **marked는 15에 고정**. `marked-terminal@7.3.0`의 peer가 `marked >=1 <16`이고 marked-terminal 업데이트가 없음. marked 16+로 올리려면 marked-terminal을 교체하거나 peer를 강제해야 함.
- **typescript는 7.x (네이티브 컴파일러 포팅)**. 프로젝트는 `tsc` CLI로 타입체크만 하고 programmatic API를 안 씀 → 네이티브 포트로 안전하게 이전. 빌드는 tsup/esbuild라 tsc와 무관. 플랫폼별 `@typescript/typescript-*` 바이너리가 optional dep으로 붙음.
- **wrap-ansi는 10.x (ink와 동일)**. `renderMarkdown`이 히스토리 출력을 터미널 폭으로 하드랩할 때 사용 — ink 내부 래핑과 같은 string-width v8을 공유해야 폭 계산이 어긋나지 않음. v10은 타입 내장(앰비언트 선언 불필요).
- `allowScripts`에 트리에 실제로 존재하는 esbuild 버전을 모두 나열해야 함 (현재 `0.27.2` = tsup/vite, `0.28.1` = tsx). 빠지면 postinstall 경고.
## TODO
- LSP 실서버 통합 테스트 (실제 tsserver/pyright 띄워서 검증)
- task store 영속화 (세션에 task 저장)
+1408 -507
View File
File diff suppressed because it is too large Load Diff
+21 -14
View File
@@ -10,7 +10,7 @@
"dist"
],
"engines": {
"node": ">=20"
"node": ">=22.12.0"
},
"scripts": {
"build": "tsup",
@@ -20,32 +20,39 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"@vscode/ripgrep": "^1.18.0",
"commander": "^13.0.0",
"commander": "^15.0.0",
"diff": "^9.0.0",
"env-paths": "^4.0.0",
"execa": "^9.6.1",
"execa": "^10.0.1",
"fast-glob": "^3.3.3",
"ink": "^7.1.0",
"ink": "^7.1.1",
"ink-select-input": "^6.2.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0",
"openai": "^6.45.0",
"react": "^19.2.7",
"string-width": "^8.2.1",
"openai": "^7.13.0",
"react": "^19.3.0",
"string-width": "^8.2.2",
"tree-kill": "^1.2.2",
"zod": "^4.4.3"
"vscode-languageserver-protocol": "^3.18.3",
"vscode-uri": "^3.2.0",
"wrap-ansi": "^10.0.1",
"zod": "^4.6.1"
},
"devDependencies": {
"@types/marked-terminal": "^6.1.1",
"@types/node": "^22.10.0",
"@types/react": "^19.2.17",
"@types/node": "^26.5.1",
"@types/react": "^19.3.0",
"tsup": "^8.3.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
"tsx": "^4.23.13",
"typescript": "^7.0.2",
"vitest": "^5.0.0"
},
"allowScripts": {
"esbuild@0.27.2": true,
"esbuild@0.28.1": true
}
}
+9 -2
View File
@@ -3,12 +3,19 @@ import type { TodoItem } from "../tools/types.js";
export type AgentEvent =
| { type: "text_delta"; delta: string }
| { type: "text_done"; fullText: string }
| { type: "thinking_delta"; delta: string }
| { type: "thinking_done"; fullThinking: string }
/** Throw away any text streamed so far this turn without committing it as an assistant message —
* emitted when a partially-streamed native tool-call turn turns out to have malformed args and is
* retried non-streaming, so the UI doesn't carry the stale partial into the retry's output. */
| { type: "stream_discard" }
| { type: "tool_call"; label: string }
| { type: "tool_result"; summary: string; isError: boolean }
/** `name`/`args` are only populated for an actually-resolved tool call (not the "unknown tool"
* error path) — the file panel's Activity tab (App.tsx) uses them to track which files a
* read_file/write_file/edit_file call touched, without having to re-parse the display `label`. */
| { type: "tool_call"; label: string; name?: string; args?: unknown }
/** `name`/`result` mirror `tool_call`'s — only populated when a tool actually ran (not a
* hook-blocked/denied/unknown-tool result), for the same file-panel tracking purpose. */
| { type: "tool_result"; summary: string; isError: boolean; name?: string; result?: unknown }
/** A sub-agent's tool call or result, forwarded to the parent so its work is visible while it
* runs headless. Routed to the dedicated sub-agent panel below the input (not the main
* scrollback) — see App.tsx. */
+473 -1
View File
@@ -1,13 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { MaxIterationsError, runTurn, shouldAutoCompact } from "./loop.js";
import { AgentError, MaxIterationsError, compactSession, detectRepetitionLoop, runTurn, shouldAutoCompact } from "./loop.js";
import { agentTool } from "../tools/agentTool.js";
import { createSession } from "./session.js";
import { buildToolSet } from "../tools/toolset.js";
import { DEFAULT_MAX_OUTPUT_TOKENS } from "../config/defaults.js";
import type { ConfirmFn } from "../permissions/types.js";
import type { ToolDef } from "../tools/types.js";
import type { Session } from "./session.js";
describe("detectRepetitionLoop", () => {
it("returns false for short text regardless of content", () => {
expect(detectRepetitionLoop("error error error error error error")).toBe(false);
});
it("detects a short phrase repeating many times in a row", () => {
const text = "Here is the analysis: " + "error error error ".repeat(80);
expect(detectRepetitionLoop(text)).toBe(true);
});
it("detects a longer repeated unit (e.g. a repeated JSON-like fragment)", () => {
const unit = '{"status":"retry","reason":"pending"} ';
const text = "Starting work.\n" + unit.repeat(40);
expect(detectRepetitionLoop(text)).toBe(true);
});
it("does not flag normal varied prose", () => {
const paragraphs = Array.from(
{ length: 20 },
(_, i) => `Paragraph ${i}: this covers a different point each time, with enough unique wording to avoid any short repeating pattern in the tail.`,
);
expect(detectRepetitionLoop(paragraphs.join("\n"))).toBe(false);
});
it("does not flag a legitimately repetitive but non-degenerate bullet list", () => {
const items = Array.from({ length: 30 }, (_, i) => `- item ${i}: some unique detail about entry number ${i}`);
expect(detectRepetitionLoop(items.join("\n"))).toBe(false);
});
});
describe("shouldAutoCompact", () => {
it("triggers at the session's configured threshold", () => {
const session = {
@@ -22,6 +53,141 @@ describe("shouldAutoCompact", () => {
});
});
describe("runTurn / max_tokens", () => {
it("caps max_tokens independent of a large contextWindow", async () => {
// Regression: some backends advertise a huge context window but cap a single response's
// max_tokens far below it (e.g. Ollama's glm-5.2:cloud: 1,000,000-token context, 131,072-token
// max output). resolveMaxTokens used to request up to the whole remaining window, which such
// backends reject outright — worse the larger (or user-raised) contextWindow got.
let capturedMaxTokens: number | undefined;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async (params: any) => {
capturedMaxTokens = params.max_tokens;
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] } };
},
}),
};
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
session.contextWindow = 1_000_000;
session.lastContextTokens = 500;
await runTurn(session, "hi", () => {});
expect(capturedMaxTokens).toBeLessThanOrEqual(DEFAULT_MAX_OUTPUT_TOKENS);
});
});
describe("runTurn / repetition detection", () => {
it("aborts a streamed response that degenerates into a repetition loop", async () => {
const fakeClient = {
chat: {
completions: {
create: vi.fn(async (_params: any, options: any) => {
const signal: AbortSignal | undefined = options?.signal;
let count = 0;
// A real backend stuck in a loop would keep streaming the same short phrase forever;
// this fake mirrors that but stops once aborted (or hits a safety cap, so a detection
// regression fails the test instead of hanging it).
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (signal?.aborted || count >= 500) return { done: true, value: undefined };
count++;
return { done: false, value: { choices: [{ delta: { content: "loop " }, finish_reason: null }] } };
},
}),
};
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
await expect(runTurn(session, "hi", () => {})).rejects.toThrow(AgentError);
});
});
describe("runTurn / empty response retry", () => {
it("retries once on a bare empty stop, then succeeds on the next call", async () => {
// Small local models sometimes emit a stream that ends with finish_reason "stop" but no
// content and no tool calls. Without a retry this aborted the whole turn as a hard error.
// Here the first create() returns an empty stop; the second returns real text, so the turn
// should recover and return that text (with a nudge message inserted in between).
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async (_params: any) => {
call++;
const chunk = call === 1
? { choices: [{ delta: {}, finish_reason: "stop" }] } // empty stop, no content
: { choices: [{ delta: { content: "all done" }, finish_reason: "stop" }] };
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: chunk };
},
}),
};
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
const result = await runTurn(session, "hi", () => {});
expect(result).toBe("all done");
expect(call).toBe(2); // one empty + one successful
// A nudge user message must have been inserted after the empty response.
expect(session.messages.some((m) => m.role === "user" && typeof m.content === "string" && m.content.includes("empty response"))).toBe(true);
});
it("throws after the retry budget is exhausted on repeated empty responses", async () => {
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async (_params: any) => {
call++;
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: { choices: [{ delta: {}, finish_reason: "stop" }] } };
},
}),
};
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
await expect(runTurn(session, "hi", () => {})).rejects.toThrow("Empty response from model.");
// One initial empty + three retries = 4 calls total.
expect(call).toBe(4);
});
});
describe("runTurn / max iterations", () => {
it("throws MaxIterationsError (not a generic error) when a model keeps calling tools forever", async () => {
// A no-op tool the fake model calls on every single turn, forever — simulates a model that
@@ -284,6 +450,66 @@ describe("runTurn / max iterations", () => {
expect(capturedSignal).toBe(ac.signal);
});
it("stops mid-stream when the caller's signal aborts, without misreporting it as an idle timeout", async () => {
// Regression for Escape-to-interrupt (App.tsx now threads a per-turn AbortController's signal
// into the top-level runTurn call the same way sub-agents already did). This covers the other
// half of that feature from the previous test: interrupting while the model is still streaming
// plain prose, with no tool call or confirm() involved at all.
let sawSecondNext = false;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async (_params: any, options: any) => {
const signal: AbortSignal | undefined = options?.signal;
let calls = 0;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
calls++;
if (calls === 1) {
return { done: false, value: { choices: [{ delta: { content: "Hello" }, index: 0 }] } };
}
sawSecondNext = true;
// A real aborted fetch stream rejects rather than yielding forever — mimic that,
// gated on the actual signal so this test depends on abort reaching the request.
return new Promise((_resolve, reject) => {
signal?.addEventListener(
"abort",
() => {
const err = new Error("The operation was aborted.");
err.name = "AbortError";
reject(err);
},
{ once: true },
);
});
},
}),
};
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
const ac = new AbortController();
const turnPromise = runTurn(session, "say something long", () => {}, undefined, ac.signal);
// Let the first content chunk land (setting the streaming text in motion) before interrupting.
await new Promise((resolve) => setTimeout(resolve, 10));
ac.abort();
await expect(turnPromise).rejects.toThrow();
expect(sawSecondNext).toBe(true);
// The whole point of threading a real external signal (vs. relying on the idle guard alone) is
// that App.tsx can tell "the user stopped this" apart from "the backend actually hung" — which
// it does by checking its own AbortController's `.aborted` flag, not by string-matching the
// error. But the idle-timeout message specifically must not leak out here, since it would be an
// actively misleading claim (the backend didn't go silent; the user interrupted it) if App.tsx's
// fallback error-message path (or any future caller) ever surfaced it directly.
await expect(turnPromise).rejects.not.toThrow(/Backend stopped responding mid-stream/);
});
it("prunes older images from history, keeping only the most recent few at full resolution", async () => {
// Unlike text tool results, an image's base64 payload has no per-call cap and (without pruning)
// gets resent in full on every subsequent request for the rest of the session — a handful of
@@ -436,6 +662,163 @@ describe("runTurn / max iterations", () => {
expect(content).not.toContain("send another message to continue");
});
it("runs multiple read-only tools in parallel, not sequentially", async () => {
// When a model returns multiple tool calls that are all read-only, runToolBatch
// should execute them concurrently (Promise.all), not one-by-one. This test
// verifies that by checking the actual wall-clock time: 3 tools each sleeping
// 200ms should complete in well under 600ms if parallel, but ~600ms if sequential.
const SLEEP_MS = 200;
const readTool: ToolDef = {
name: "read_file",
description: "reads a file",
schema: z.object({ path: z.string() }),
mutating: false,
handler: async () => {
await new Promise((r) => setTimeout(r, SLEEP_MS));
return { content: "file contents" };
},
};
const toolset = buildToolSet([readTool]);
// Model returns 3 parallel read_file calls in one response
function threeReadCallsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_0", function: { name: "read_file", arguments: '{"path":"a.ts"}' } },
{ index: 1, id: "call_1", function: { name: "read_file", arguments: '{"path":"b.ts"}' } },
{ index: 2, id: "call_2", function: { name: "read_file", arguments: '{"path":"c.ts"}' } },
],
},
finish_reason: "tool_calls",
},
],
};
}
function finalTextChunk() {
return { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] };
}
let streamingCallCount = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
streamingCallCount++;
const chunk = streamingCallCount === 1 ? threeReadCallsChunk() : finalTextChunk();
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: chunk };
},
}),
};
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [readTool]);
session.toolset = toolset;
const start = Date.now();
const result = await runTurn(session, "read three files", () => {});
const elapsed = Date.now() - start;
expect(result).toBe("done");
// 3 × 200ms sequentially = 600ms+. In parallel = ~200ms + overhead.
// Allow generous slack but still well under the sequential floor.
expect(elapsed).toBeLessThan(SLEEP_MS * 2.5);
});
it("runs mixed read+write tool calls sequentially even when model sends them together", async () => {
// When a model returns multiple tool calls that include at least one mutating tool,
// the entire batch should run sequentially, not in parallel.
const SLEEP_MS = 150;
const readTool: ToolDef = {
name: "read_file",
description: "reads a file",
schema: z.object({}),
mutating: false,
handler: async () => {
await new Promise((r) => setTimeout(r, SLEEP_MS));
return { content: "file contents" };
},
};
const editTool: ToolDef = {
name: "edit_file",
description: "edits a file",
schema: z.object({}),
mutating: true,
handler: async () => {
await new Promise((r) => setTimeout(r, SLEEP_MS));
return { ok: true };
},
};
const toolset = buildToolSet([readTool, editTool]);
// Model sends 1 read + 1 edit together
function mixedCallsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_0", function: { name: "read_file", arguments: "{}" } },
{ index: 1, id: "call_1", function: { name: "edit_file", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
],
};
}
function finalTextChunk() {
return { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] };
}
let streamingCallCount = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
streamingCallCount++;
const chunk = streamingCallCount === 1 ? mixedCallsChunk() : finalTextChunk();
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: chunk };
},
}),
};
}),
},
},
} as any;
const confirm = vi.fn(async () => "once" as const);
const session = createSession(fakeClient, "test-model", process.cwd(), confirm, "native", [readTool, editTool]);
session.toolset = toolset;
// Auto-approve edit so we don't need user interaction
session.permissions.allowForSession("edit_file");
const start = Date.now();
const result = await runTurn(session, "read and edit", () => {});
const elapsed = Date.now() - start;
expect(result).toBe("done");
// Sequential: 2 × 150ms = 300ms minimum. Allow generous slack.
expect(elapsed).toBeGreaterThanOrEqual(SLEEP_MS * 1.5);
});
it("blocks mutating tools outright in plan mode, without ever prompting for confirmation", async () => {
const mutatingTool: ToolDef = {
name: "edit_file",
@@ -497,3 +880,92 @@ describe("runTurn / max iterations", () => {
expect(String((toolResultMessage as any).content)).toContain("plan mode is active");
});
});
describe("compactSession — partial-history preservation", () => {
function fakeClientReturning(summary: string) {
return {
chat: {
completions: {
create: vi.fn(async () => ({
choices: [{ message: { role: "assistant", content: summary } }],
usage: { prompt_tokens: 100, completion_tokens: 20 },
})),
},
},
} as any;
}
it("preserves recent tail messages verbatim and summarizes the older prefix", async () => {
const client = fakeClientReturning("SUMMARY OF EARLIER WORK");
const session = createSession(client, "test-model", process.cwd(), async () => "once", "native", []);
session.contextWindow = 4096;
// Build a history longer than the preserved tail: system + several user/assistant turns.
// Index 0 is the system prompt that createSession already added.
for (let i = 0; i < 12; i++) {
session.messages.push({ role: "user", content: `user message ${i}` } as any);
session.messages.push({ role: "assistant", content: `assistant reply ${i}` } as any);
}
const beforeLen = session.messages.length;
const summary = await compactSession(session);
expect(summary).toBe("SUMMARY OF EARLIER WORK");
// The recap must be present.
const recap = session.messages.find((m) => m.role === "assistant" && typeof m.content === "string" && (m.content as string).includes("[Earlier conversation compacted"));
expect(recap).toBeDefined();
// The most recent assistant reply must survive compaction (it is in the tail).
expect(session.messages.some((m) => m.role === "assistant" && (m.content as string) === "assistant reply 11")).toBe(true);
// The oldest user message (well before the tail) must NOT survive verbatim — it was summarized.
expect(session.messages.some((m) => m.role === "user" && (m.content as string) === "user message 0")).toBe(false);
// History shrunk but is not empty.
expect(session.messages.length).toBeLessThan(beforeLen);
expect(session.messages.length).toBeGreaterThan(1);
});
it("keeps an assistant tool_calls message grouped with its tool result messages in the tail", async () => {
const client = fakeClientReturning("SUMMARY");
const session = createSession(client, "test-model", process.cwd(), async () => "once", "native", []);
session.contextWindow = 4096;
// Add enough history that the boundary lands inside the tool-call group.
for (let i = 0; i < 10; i++) {
session.messages.push({ role: "user", content: `u${i}` } as any);
session.messages.push({ role: "assistant", content: `a${i}` } as any);
}
// Now the most recent turns: an assistant tool_calls message + its tool results.
session.messages.push({
role: "assistant",
content: null,
tool_calls: [{ id: "call_0", type: "function", function: { name: "read_file", arguments: "{}" } }],
} as any);
session.messages.push({ role: "tool", tool_call_id: "call_0", content: "file contents" } as any);
await compactSession(session);
// The tool_calls assistant message and its tool result must both be present in the tail.
const hasCall = session.messages.some(
(m) => m.role === "assistant" && Array.isArray((m as any).tool_calls) && (m as any).tool_calls.some((c: any) => c.id === "call_0"),
);
const hasResult = session.messages.some((m) => m.role === "tool" && (m as any).tool_call_id === "call_0");
// Both or neither — never one without the other (that would be a malformed history).
expect(hasCall).toBe(hasResult);
expect(hasCall).toBe(true);
});
it("does not call the model when there is no older prefix to summarize", async () => {
const client = fakeClientReturning("SHOULD NOT BE USED");
const session = createSession(client, "test-model", process.cwd(), async () => "once", "native", []);
session.contextWindow = 4096;
// Only system + a couple of messages: the tail covers everything, so no summary request.
session.messages.push({ role: "user", content: "hi" } as any);
session.messages.push({ role: "assistant", content: "hello" } as any);
const summary = await compactSession(session);
expect(summary).toBe("");
expect((client.chat.completions.create as any).mock.calls.length).toBe(0);
// The recent messages are still there verbatim.
expect(session.messages.some((m) => m.role === "assistant" && (m.content as string) === "hello")).toBe(true);
});
});
+500 -116
View File
@@ -13,10 +13,13 @@ import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
import { repairPartialJson } from "../toolcalling/partialJson.js";
import { notifyFileChanged } from "../codeintel/lspManager.js";
import { TaskStore } from "../tools/task.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
import { estimateTokens } from "../utils/tokens.js";
import { runHooksForEvent } from "../hooks/runner.js";
import { resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "../config/config.js";
import { resolveMaxOutputTokens, resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "../config/config.js";
import { buildSystemPrompt } from "./systemPrompt.js";
import type { Session } from "./session.js";
@@ -26,12 +29,37 @@ function emitHookWarnings(warnings: string[], emit: AgentEventHandler): void {
}
}
/** Wraps runHooksForEvent for the events that fire on every tool call (PreToolUse, PostToolUse,
* PermissionRequest) or every sub-agent (SubagentStart) — same "hooks must not derail things"
* reasoning as fireStopHook/fireSessionStartHook/fireUserPromptSubmitHook, but those wrap a single
* dedicated call site each; this one is shared since these fire from inline code in gateAndRun and
* runSubAgentTurn rather than their own named functions. A broken hook (bad command, malformed
* hooks.json) must not block a tool call or sub-agent it never got to properly evaluate. */
async function safeRunHooksForEvent(
...args: Parameters<typeof runHooksForEvent>
): Promise<Awaited<ReturnType<typeof runHooksForEvent>>> {
try {
return await runHooksForEvent(...args);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[${args[0]}] hook error (non-fatal):`, err);
return { blocked: false, warnings: [] };
}
}
/** Stop fires once the assistant has produced its final answer for a turn — informational only
* (e.g. a desktop notification or logging hook); unlike PreToolUse it can't block, since there's
* nothing left in this turn to prevent. */
async function fireStopHook(session: Session, emit: AgentEventHandler, finalText: string): Promise<void> {
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
emitHookWarnings(result.warnings, emit);
try {
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
emitHookWarnings(result.warnings, emit);
} catch (err) {
// Stop hooks are informational only (e.g. notifications, logging). A failing hook must never
// discard the turn's final answer — log the error and continue.
// eslint-disable-next-line no-console
console.error("[fireStopHook] hook error (non-fatal):", err);
}
}
/** Runs SessionStart hooks right after a session is created — any hook that exits 0 with stdout
@@ -47,12 +75,19 @@ function foldHookContext(systemContent: string, result: Awaited<ReturnType<typeo
}
export async function fireSessionStartHook(session: Session): Promise<string[]> {
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
if (result.additionalContext || result.jsonContext?.length) {
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
systemMessage.content = foldHookContext(systemMessage.content, result);
try {
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
if (result.additionalContext || result.jsonContext?.length) {
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
systemMessage.content = foldHookContext(systemMessage.content, result);
}
return result.warnings;
} catch (err) {
// A failing SessionStart hook must not prevent the session from starting.
// eslint-disable-next-line no-console
console.error("[fireSessionStartHook] hook error (non-fatal):", err);
return [];
}
return result.warnings;
}
export interface UserPromptSubmitResult {
@@ -66,14 +101,21 @@ export interface UserPromptSubmitResult {
/** Runs UserPromptSubmit hooks for a message the user is about to send — can block it outright
* (e.g. a policy check) or inject extra context that gets appended alongside it. */
export async function fireUserPromptSubmitHook(session: Session, prompt: string): Promise<UserPromptSubmitResult> {
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
return {
blocked: result.blocked,
reason: result.reason,
additionalContext: result.additionalContext,
jsonContext: result.jsonContext,
warnings: result.warnings,
};
try {
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
return {
blocked: result.blocked,
reason: result.reason,
additionalContext: result.additionalContext,
jsonContext: result.jsonContext,
warnings: result.warnings,
};
} catch (err) {
// A failing UserPromptSubmit hook must not block the user from sending their message.
// eslint-disable-next-line no-console
console.error("[fireUserPromptSubmitHook] hook error (non-fatal):", err);
return { blocked: false, warnings: [] };
}
}
export class AgentError extends Error {}
@@ -120,7 +162,42 @@ export function createIdleAbort(
};
}
/** Detects a degenerate repetition loop: a short substring repeating many times in a row at the
* tail of the streamed text. Known failure mode for local/quantized models, which otherwise only
* stop at max_tokens or an idle timeout — with maxOutputTokens raised for backends whose real
* ceiling is far larger, an undetected loop could run for a very long time before either kicks in.
* Only checks the tail (loops are contiguous, so recent text is sufficient and cheap to scan) and
* requires several consecutive exact repeats, so legitimately repetitive-but-valid text (a bullet
* list, a table, ASCII art) doesn't false-positive. */
export function detectRepetitionLoop(text: string): boolean {
const TAIL = 1000;
const MIN_PERIOD = 4;
const MAX_PERIOD = 200;
const MIN_REPEATS = 6;
if (text.length < TAIL) return false;
const tail = text.slice(-TAIL);
for (let period = MIN_PERIOD; period <= MAX_PERIOD; period++) {
const repeats = Math.floor(tail.length / period);
if (repeats < MIN_REPEATS) continue;
const unit = tail.slice(tail.length - period);
let matched = true;
for (let r = 2; r <= repeats; r++) {
const start = tail.length - r * period;
if (tail.slice(start, start + period) !== unit) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
const MAX_MALFORMED_RETRIES = 2;
/** Local models (especially small quantized ones) occasionally emit a bare empty `stop`
* chunk — no text, no tool calls. Rather than abort the whole turn as a hard error, retry
* once with a short nudge so the model continues, mirroring the malformed-tool-call path. */
const MAX_EMPTY_RESPONSE_RETRIES = 3;
// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't
// spawn nested sub-agents through normal tool use), but these are independent, explicit backstops
@@ -155,63 +232,187 @@ export function shouldAutoCompact(session: Session): boolean {
return contextUsageRatio(session) >= session.autoCompactThreshold;
}
/** Computes a dynamic `max_tokens` for a generation request, instead of the old hardcoded 4096
* that was far too small for local models — a full-file rewrite (common in fallback mode, where
* precise edits are hard) can easily exceed 4096 tokens and get truncated mid-tool-call, turning
* a valid call into malformed JSON the parser then rejects.
*
* Reserves `contextWindow − lastContextTokens` for the response, minus a small safety margin so
* the prompt+response never overshoots the window (a local backend will typically OOM or error
* on an oversized request rather than gracefully truncating). Clamped to [2048, contextWindow]
* so a nearly-full context still gets a usable (if small) output budget, and a huge window does
* not ask for more than the model could ever produce — but also capped at resolveMaxOutputTokens()
* independent of contextWindow, since many backends cap a single response far below their total
* context window (e.g. Ollama's glm-5.2:cloud: 1,000,000-token context, 8192-token max output).
* Without that second cap, a large (or user-raised, see `contextWindow` config) window made
* resolveMaxTokens request far more than such a backend allows, which it rejects outright as a
* context/length error even on the very first turn — raising contextWindow to fix truncation made
* this worse, not better. */
function resolveMaxTokens(session: Session): number {
const MARGIN = 512;
const MIN = 2048;
const available = session.contextWindow - session.lastContextTokens - MARGIN;
const cap = Math.min(session.contextWindow, resolveMaxOutputTokens());
return Math.min(cap, Math.max(MIN, Math.min(available, cap)));
}
/**
* Replaces the conversation history with a model-generated summary of everything so far, to free
* up context. Runs as a plain (non-tool-calling) request so the model just produces prose, not
* more tool calls. The new history is just [system, a synthetic assistant "recap"] — framing the
* summary as an assistant turn keeps proper role alternation with whatever real user message
* follows next.
* Maximum number of trailing messages to keep verbatim across a compaction. Sized so the
* preserved tail is large enough to carry a couple of recent tool calls + results (the context
* the model most needs to continue), but small enough that summarizing the older prefix actually
* frees meaningful context. The tail is also capped by a fraction of the context window so a
* handful of very large messages (e.g. big file reads) can not crowd out the summary.
*/
const MAX_PRESERVED_TAIL_MESSAGES = 8;
const MAX_PRESERVED_TAIL_FRACTION = 0.3;
/**
* Finds the index into `session.messages` at which the preserved tail should begin, so that the
* tail keeps the most recent turns verbatim while the older prefix gets summarized. The boundary
* is always placed on a safe edge — never between an assistant tool_calls message and its `tool`
* result messages, since splitting that group would leave dangling tool calls (no results) or
* orphaned results (no triggering call), which OpenAI-compatible backends reject as malformed.
*
* Returns at least 1 (never splits before the system prompt at index 0).
*/
function computeKeepBoundary(session: Session): number {
const msgs = session.messages;
if (msgs.length <= 1) return 1;
// Start from the end and walk backward, collecting messages into the tail until we hit the
// message budget. Tool-result messages must stay grouped with the assistant tool_calls message
// that precedes them, so when we reach one we keep scanning left until that call is included.
let boundary = msgs.length;
let kept = 0;
const tailTokenBudget = Math.floor(session.contextWindow * MAX_PRESERVED_TAIL_FRACTION);
let tailTokens = 0;
for (let i = msgs.length - 1; i >= 1; i--) {
const msg = msgs[i]!;
const isToolResult = msg.role === "tool" || isFallbackToolResult(msg);
const isAssistantToolCall = msg.role === "assistant" && Array.isArray((msg as any).tool_calls) && (msg as any).tool_calls.length > 0;
// If we already started a tail and the next message left is a tool result whose triggering
// assistant call is even further left, keep extending left to include that call — otherwise
// the tail would start with orphaned tool results (no triggering call), which backends reject.
if (isToolResult && boundary < msgs.length && boundary > i + 1) {
tailTokens += estimateTokens([msg]);
boundary = i;
continue;
}
if (kept >= MAX_PRESERVED_TAIL_MESSAGES || tailTokens >= tailTokenBudget) {
break;
}
tailTokens += estimateTokens([msg]);
boundary = i;
kept++;
}
// Never return 0 — the system prompt at index 0 always belongs to the summarized prefix
// (the summary request needs it as framing, and the post-compaction history rebuilds it).
return Math.max(1, boundary);
}
/** Detects a fallback-mode tool result: a user-role message whose content is a tool_result block. */
function isFallbackToolResult(msg: ChatCompletionMessageParam): boolean {
if (msg.role !== "user") return false;
const content = (msg as { content?: unknown }).content;
if (typeof content !== "string") return false;
return content.startsWith("```tool_result\n");
}
/**
* Partial-history compaction: summarizes the older prefix of the conversation and keeps the most
* recent turns verbatim, to free context without discarding the exact tool calls + results the
* model most needs to continue its task. Runs a plain (non-tool-calling) request for the summary.
* The new history is [system, a synthetic assistant "recap", ...preserved tail] — framing the
* summary as an assistant turn keeps proper role alternation with the preserved messages that
* follow (which already begin with whatever role naturally came next in the original history).
*/
export async function compactSession(session: Session): Promise<string> {
const requestMessages: ChatCompletionMessageParam[] = [
...session.messages,
{
role: "user",
content:
"Summarize this entire conversation so far, concisely but completely: the user's goals, key decisions " +
"made, files created/changed and why, current task state, and anything still outstanding. Write it as " +
"background context for continuing the conversation — plain prose, no meta-commentary about summarizing.",
},
];
// Partial-history preservation: keep the most recent turns verbatim and only summarize the
// older prefix. On a local backend a full compaction is expensive (one extra model request) and
// the freshly-discarded turns are exactly the ones the model most needs to continue its task —
// losing the last tool call + its result, for example, leaves it unable to reference what it
// just did. So split the history at a safe boundary (never mid-tool-call) and keep the tail.
const keepFromIndex = computeKeepBoundary(session);
const requestStart = Date.now();
const compactGuard = createIdleAbort(resolveRequestTimeoutMs());
let res;
try {
res = await session.client.chat.completions.create(
// The prefix to summarize is everything before the preserved tail (always including the
// system prompt at index 0, which the model needs as framing for the summary request).
const prefix = session.messages.slice(0, keepFromIndex);
const tail = session.messages.slice(keepFromIndex);
// If there's almost nothing old to summarize (e.g. a very short history or one where the tail
// already covers most of it), don't waste a model request producing a near-empty summary —
// just keep the tail verbatim. This also avoids a degenerate "[system, recap, single-message]"
// result when compaction triggers early on a small history.
const prefixHasContent = prefix.length > 1; // more than just the system prompt
let summary: string | null = null;
if (prefixHasContent) {
const requestMessages: ChatCompletionMessageParam[] = [
...prefix,
{
model: session.model,
messages: requestMessages,
stream: false,
max_tokens: 1024,
role: "user",
content:
"Summarize the conversation up to this point, concisely but completely: the user's goals, key decisions " +
"made, files created/changed and why, current task state, and anything still outstanding. Write it as " +
"background context for continuing the conversation — plain prose, no meta-commentary about summarizing. " +
"(The most recent turns are kept verbatim after this summary, so focus on the earlier history.)",
},
{ signal: compactGuard.signal },
);
} catch (err) {
if (compactGuard.didTimeOut()) {
throw new AgentError(
`Compaction failed: backend stopped responding (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s).`,
];
const requestStart = Date.now();
const compactGuard = createIdleAbort(resolveRequestTimeoutMs());
let res;
try {
res = await session.client.chat.completions.create(
{
model: session.model,
messages: requestMessages,
stream: false,
max_tokens: 1024,
},
{ signal: compactGuard.signal },
);
} catch (err) {
if (compactGuard.didTimeOut()) {
throw new AgentError(
`Compaction failed: backend stopped responding (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s).`,
);
}
throw err;
} finally {
compactGuard.dispose();
}
session.stats.modelTimeMs += Date.now() - requestStart;
recordUsage(session, res.usage);
summary = res.choices[0]?.message?.content ?? null;
if (!summary) {
throw new AgentError("Compaction failed: the model returned no summary.");
}
throw err;
} finally {
compactGuard.dispose();
}
session.stats.modelTimeMs += Date.now() - requestStart;
recordUsage(session, res.usage);
const summary = res.choices[0]?.message?.content;
if (!summary) {
throw new AgentError("Compaction failed: the model returned no summary.");
}
session.messages = [
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions) },
{ role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` },
];
if (summary) {
session.messages = [
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions, session.isLocal) },
{ role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` },
...tail,
];
} else {
// Nothing to summarize: keep the tail as-is, but still rebuild the system prompt in case
// the tail's first message was a stale system prompt we want to replace.
session.messages = [
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions, session.isLocal) },
...tail,
];
}
// res.usage describes the old (now-discarded) prompt, not the new shorter history — estimate fresh.
session.lastContextTokens = estimateTokens(session.messages);
session.lastContextTokensIsEstimate = true;
return summary;
return summary ?? "";
}
/** Accumulates streaming tool-call deltas into complete tool calls. */
@@ -358,12 +559,13 @@ async function gateAndRun(
): Promise<unknown> {
session.stats.toolCalls++;
if ("error" in resolved) {
console.error(`[gateAndRun] RESOLVE ERROR: ${resolved.error}`);
emit({ type: "tool_call", label: rawLabel });
emit({ type: "tool_result", summary: resolved.error, isError: true });
return { error: resolved.error };
}
const { tool, args } = resolved;
emit({ type: "tool_call", label: formatCallLabel(tool.name, args) });
emit({ type: "tool_call", label: formatCallLabel(tool.name, args), name: tool.name, args });
// Only `bash` is backgroundable today — the control object is how a mid-flight Ctrl+B (flipped
// on session.activeBackground by the UI) reaches into this specific call's polling loop. Reset
@@ -381,12 +583,13 @@ async function gateAndRun(
session.todos = todos;
emit({ type: "todos_update", todos });
},
taskStore: session.taskStore,
};
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
// auto-accept, same as real Claude Code hooks.
const hookCtx = { sessionId: session.id, cwd: session.cwd };
const preHook = await runHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
const preHook = await safeRunHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
emitHookWarnings(preHook.warnings, emit);
if (preHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${preHook.reason}`, isError: true });
@@ -405,48 +608,64 @@ async function gateAndRun(
return { error: message };
}
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const permissionHook = await runHooksForEvent(
"PermissionRequest",
hookCtx,
{ tool_name: tool.name, tool_input: args, preview },
tool.name,
);
emitHookWarnings(permissionHook.warnings, emit);
if (permissionHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
return { error: `Blocked by hook: ${permissionHook.reason}` };
// Serialize mutating tools across the WHOLE session — including parallel sub-agents spawned
// via the `agent` tool's `tasks` array, which share this session's permissions/confirm. Without
// this lock two sub-agents running concurrently could each reach a mutating call at once, racing
// on the single React `permission` slot (makeConfirmFn) and interleaving filesystem writes. The
// gate is a per-session promise chain: each mutating call awaits the previous one before it even
// builds its preview, so permission prompts stay one-at-a-time and edits can't overlap. Read-only
// tools skip the gate entirely and keep running concurrently (matching runToolBatch).
const runMutatingSection = async (): Promise<unknown> => {
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const permissionHook = await safeRunHooksForEvent(
"PermissionRequest",
hookCtx,
{ tool_name: tool.name, tool_input: args, preview },
tool.name,
);
emitHookWarnings(permissionHook.warnings, emit);
if (permissionHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
return { error: `Blocked by hook: ${permissionHook.reason}` };
}
const decision = await session.confirm({ toolName: tool.name, args, preview, signal });
if (decision === "deny") {
emit({ type: "tool_result", summary: "Denied by user", isError: true });
return { error: "Denied by user." };
}
if (decision === "session") {
session.permissions.allowForSession(tool.name);
}
}
const decision = await session.confirm({ toolName: tool.name, args, preview, signal });
if (decision === "deny") {
emit({ type: "tool_result", summary: "Denied by user", isError: true });
return { error: "Denied by user." };
}
if (decision === "session") {
session.permissions.allowForSession(tool.name);
}
}
const result = await runTool(tool, args, ctx);
return runTool(tool, args, ctx);
};
const result = tool.mutating ? await runUnderMutationGate(session, runMutatingSection) : await runMutatingSection();
const isError = !!(result && typeof result === "object" && "error" in (result as object));
if (isError) console.error(`[gateAndRun] RUN ERROR: ${tool.name} => ${(result as { error: unknown }).error}`);
// FileChanged fires for any mutating tool (built-in or MCP/plugin) — whether it touched the
// filesystem is left for the hook author to decide (e.g. an API-only mutating MCP tool may be
// meaningless here, but a generic mutating tool is the most useful signal locode has). It's
// informational only and fires whether the tool succeeded or failed, since the turn is done either way.
if (tool.mutating) {
// Keep any live LSP server's view of the file in sync with disk so a subsequent
// `diagnostics` / `definition` / `references` call reflects the edit. Fire-and-forget
// and best-effort (lspManager never throws from this path) — a no-op if no server is
// running for this file's language yet.
const edited = typeof args === "object" && args !== null && "path" in args ? String((args as { path?: unknown }).path ?? "") : "";
if (edited) notifyFileChanged(edited, ctx.cwd).catch(() => {});
runHooksForEvent("FileChanged", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result, isError }, tool.name)
.then((r) => emitHookWarnings(r.warnings, emit))
.catch(() => {});
}
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError });
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError, name: tool.name, result });
// PostToolUse can't *veto* a tool call that already ran (unlike PreToolUse), but it IS awaited:
// an auto-format hook that rewrites the file the tool just wrote needs to finish before the next
// tool runs or the model reads the file again. The tradeoff is that a slow logging hook will stall
// the turn up to its own timeout — that's the cost of running formatting/logging synchronously.
const postHook = await runHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
const postHook = await safeRunHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
emitHookWarnings(postHook.warnings, emit);
return result;
@@ -455,6 +674,61 @@ async function gateAndRun(
}
}
/** Runs `fn` under the session's mutation gate — a promise chain that serializes every mutating
* tool call in the session (including those from parallel sub-agents, which share the parent's
* session). The chain is advanced by chaining onto `session.mutationGate` and storing the new tail,
* so the next mutating caller waits on this one. Errors don't break the chain (the tail is still
* replaced with a settled promise) so one failed edit can't deadlock every later mutation.
* Read-only tools skip this entirely and keep running concurrently (matching runToolBatch). */
function runUnderMutationGate(session: Session, fn: () => Promise<unknown>): Promise<unknown> {
const prev = session.mutationGate;
let release!: () => void;
const gated = new Promise<void>((resolve) => {
release = resolve;
});
session.mutationGate = prev.then(() => gated);
return prev
.then(() => fn())
.finally(() => {
release();
});
}
/**
* Runs a batch of tool calls, parallelizing read-only tools for throughput (a local backend can
* serve several independent file reads / greps / web fetches concurrently, where sequential
* execution just adds latency), while keeping mutating tools strictly sequential — they share
* order-sensitive session state (the permission prompt, `activeBackground`, mutationCommitLength,
* FileChanged hooks) that concurrent execution would race on. If every call in the batch is
* read-only the whole batch runs concurrently; if any are mutating, the batch runs sequentially to
* preserve the existing ordering guarantees (and keep permission prompts in a deterministic order).
*
* Returns results in the *original* call order regardless of execution order, so callers can push
* the corresponding tool-result messages in the order the model emitted the calls.
*/
async function runToolBatch(
calls: { resolved: ResolvedToolCall; label: string }[],
session: Session,
emit: AgentEventHandler,
signal?: AbortSignal,
): Promise<unknown[]> {
if (calls.length === 0) return [];
const allReadOnly = calls.every((c) => !("error" in c.resolved) && !c.resolved.tool.mutating);
const mode = allReadOnly && calls.length > 1 ? "parallel" : "sequential";
console.error(`[runToolBatch] ${calls.length} tool(s): ${mode} | ${calls.map((c) => "error" in c.resolved ? `ERR(${c.label.slice(0, 40)})` : `${c.resolved.tool.name}${c.resolved.tool.mutating ? "*" : ""}`).join(", ")}`);
if (!allReadOnly || calls.length === 1) {
const results: unknown[] = [];
for (const c of calls) {
results.push(await gateAndRun(c.resolved, c.label, session, emit, signal));
}
return results;
}
// All read-only: run concurrently. gateAndRun's shared-state side effects are safe for read-only
// tools (no permission prompt, activeBackground stays null, no mutating hooks), and the
// tool_call/tool_result emit events interleaving is purely cosmetic.
return Promise.all(calls.map((c) => gateAndRun(c.resolved, c.label, session, emit, signal)));
}
/**
* Runs a sub-agent as a fresh, isolated turn loop that shares the parent's client/model/cwd/
* permissions, but starts with no conversation history beyond the delegated task. Runs headless —
@@ -488,7 +762,7 @@ async function runSubAgentTurn(
const subToolset = buildToolSet(restricted);
const systemPrompt = overrides?.systemPrompt
? `${overrides.systemPrompt}\n\nAvailable tools:\n${subToolset.tools.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions, parent.isLocal)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
const subMessages: ChatCompletionMessageParam[] = [{ role: "system", content: systemPrompt }];
const subSession: Session = {
id: randomUUID(),
@@ -497,6 +771,7 @@ async function runSubAgentTurn(
model: parent.model,
cwd: parent.cwd,
mode: parent.mode,
isLocal: parent.isLocal,
messages: subMessages,
maxIterations: parent.maxIterations,
permissions: parent.permissions,
@@ -519,13 +794,19 @@ async function runSubAgentTurn(
// Independent from the parent's — a sub-agent runs headless (see class doc above), so its own
// checklist has nowhere to render even if it called todo_write.
todos: [],
taskStore: new TaskStore(),
// SHARED with the parent (not independent) — this is the whole point of the mutation gate: a
// batch of parallel sub-agents all funnel their mutating calls through the parent's single
// gate, so they can't race on the one permission slot or interleave filesystem writes. A
// sub-agent that doesn't spawn siblings just inherits a resolved chain and is unaffected.
mutationGate: parent.mutationGate,
};
// Run (and honor) the SubagentStart hook before arming the timeout, so a slow hook doesn't eat
// into the turn's own time budget, and so a hook that blocks (exit 2) actually prevents the
// sub-agent from running instead of only being logged.
const hookCtx = { sessionId: parent.id, cwd: parent.cwd };
const startHook = await runHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
const startHook = await safeRunHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
if (startHook.warnings.length) {
// eslint-disable-next-line no-console
console.error(`SubagentStart hook warnings: ${startHook.warnings.join("; ")}`);
@@ -614,10 +895,20 @@ async function handleCompletedMessage(
} as ChatCompletionMessageParam);
const pendingImages: ImageAttachment[] = [];
for (const call of message.tool_calls) {
const nativeCalls: { resolved: ResolvedToolCall; label: string; call: any }[] = message.tool_calls.map((call: any) => {
const resolved = resolveToolCall(call as any, toolset.registry);
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
const result = await gateAndRun(resolved, label, session, emit, signal);
return { resolved, label, call };
});
const results = await runToolBatch(
nativeCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
session,
emit,
signal,
);
for (let i = 0; i < nativeCalls.length; i++) {
const { resolved, call } = nativeCalls[i]!;
const result = results[i]!;
const image = pushToolResultMessage(session, "native", call.id, call.type === "function" ? call.function.name : call.type, result);
noteMutationCommit(session, resolved, result);
if (image) pendingImages.push(image);
@@ -635,12 +926,21 @@ async function handleCompletedMessage(
if (parsed.calls.length) {
session.messages.push({ role: "assistant", content: text });
for (const call of parsed.calls) {
const fbCalls: { resolved: ResolvedToolCall; label: string; call: { name: string; arguments: any } }[] = parsed.calls.map((call) => {
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
const result = await gateAndRun(resolved, label, session, emit, signal);
pushToolResultMessage(session, "fallback", "", call.name, result);
noteMutationCommit(session, resolved, result);
return { resolved, label, call };
});
const fbResults = await runToolBatch(
fbCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
session,
emit,
signal,
);
for (let i = 0; i < fbCalls.length; i++) {
const { resolved, call } = fbCalls[i]!;
pushToolResultMessage(session, "fallback", "", call.name, fbResults[i]!);
noteMutationCommit(session, resolved, fbResults[i]!);
}
return { text: "", hadToolCalls: true };
}
@@ -667,6 +967,7 @@ export async function runTurn(
if (session.subAgentDepth === 0) session.stats.turns++;
session.mutationCommitLength = null;
let malformedRetries = 0;
let emptyResponseRetries = 0;
for (let i = 0; i < session.maxIterations; i++) {
pruneOldImages(session);
@@ -681,13 +982,13 @@ export async function runTurn(
try {
await compactSession(session);
emit({ type: "notice", text: "Context was getting full — auto-compacted mid-turn.", isError: false });
// compactSession leaves the history as [system, assistant-recap] with no user turn. Sending
// that to the model gives it nothing to respond to — local models routinely answer with an
// empty stop (which runTurn then returns as ""), which is exactly why sub-agents that
// compacted mid-task came back as "Sub-agent finished (0 chars)", and why a tool-heavy main
// turn appeared to hang/stop after compacting. Re-add a user turn so the model resumes the
// task instead of going empty. (Between-turn compaction in App.tsx doesn't need this — the
// user's next message supplies the turn.)
// compactSession now leaves [system, recap, ...preserved tail]. The tail usually ends with
// a tool result (role: tool in native mode, a user-role tool_result block in fallback) or an
// assistant turn — either way the model needs an explicit cue to resume rather than stop. A
// bare [system, recap] used to make local models answer with an empty stop (runTurn returns ""),
// which is why sub-agents that compacted mid-task came back "Sub-agent finished (0 chars)" and
// tool-heavy main turns appeared to hang. The synthetic user turn fixes that. (Between-turn
// compaction in App.tsx doesn't need this — the user's next message supplies the turn.)
session.messages.push({
role: "user",
content:
@@ -702,10 +1003,12 @@ export async function runTurn(
// rollback preserves it (dropping it would leave [system, recap] with no user turn again —
// the very bug this user message exists to prevent).
session.mutationCommitLength = session.messages.length;
} catch {
} catch (err) {
// Best-effort: if compaction itself fails, proceed with the oversized context rather than
// aborting the whole turn — the idle-abort guard on the next request still protects against
// an outright hang, it'll just be a slower/costlier request.
// eslint-disable-next-line no-console
console.error("[compactSession] compaction failed (non-fatal):", err);
}
}
@@ -718,6 +1021,12 @@ export async function runTurn(
const requestStart = Date.now();
const idleGuard = createIdleAbort(resolveRequestTimeoutMs(), signal);
// Independent from idleGuard — a repetition loop keeps producing chunks, so idleGuard's
// silence-based timer never trips. Combined below via AbortSignal.any so either guard can end
// the request; only this one needs its own controller since it fires from content, not silence.
const repetitionAbort = new AbortController();
let repetitionDetected = false;
let lastRepetitionCheckLength = 0;
try {
const stream = await session.client.chat.completions.create(
{
@@ -726,9 +1035,9 @@ export async function runTurn(
tools: session.mode === "native" ? toolset.openaiTools : undefined,
stream: true,
stream_options: { include_usage: true },
max_tokens: 4096,
max_tokens: resolveMaxTokens(session),
},
{ signal: idleGuard.signal },
{ signal: AbortSignal.any([idleGuard.signal, repetitionAbort.signal]) },
);
for await (const chunk of stream) {
@@ -755,6 +1064,15 @@ export async function runTurn(
if (delta?.content) {
fullText += delta.content;
emit({ type: "text_delta", delta: delta.content });
// Throttled to roughly every 100 new characters — detectRepetitionLoop is cheap per call,
// but there's no reason to run it on every single-token chunk.
if (fullText.length - lastRepetitionCheckLength >= 100) {
lastRepetitionCheckLength = fullText.length;
if (detectRepetitionLoop(fullText)) {
repetitionDetected = true;
repetitionAbort.abort();
}
}
}
// Accumulate native tool call deltas
@@ -784,6 +1102,11 @@ export async function runTurn(
if (fullText) {
emit({ type: "text_done", fullText });
}
if (repetitionDetected) {
throw new AgentError(
"Detected a repetition loop in the model's response and aborted early — the model got stuck repeating itself. Try again; if it keeps happening, this model may need a different prompt or a lower temperature.",
);
}
if (idleGuard.didTimeOut()) {
throw new AgentError(
`Backend stopped responding mid-stream (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`,
@@ -794,6 +1117,15 @@ export async function runTurn(
idleGuard.dispose();
}
// A repetition-triggered abort can also surface as a clean (chunk-less) end of the async
// iterator instead of a thrown error, depending on how far into the stream it landed — check
// unconditionally rather than only in the catch above, mirroring the idle-timeout check below.
if (repetitionDetected) {
throw new AgentError(
"Detected a repetition loop in the model's response and aborted early — the model got stuck repeating itself. Try again; if it keeps happening, this model may need a different prompt or a lower temperature.",
);
}
// An idle-triggered abort can also surface as a clean (chunk-less) end of the async iterator
// instead of a thrown error, depending on how far into the stream it landed — check unconditionally
// rather than only in the catch above, or it'd silently fall through to "Empty response from model."
@@ -809,13 +1141,23 @@ export async function runTurn(
// --- Handle native tool calls from streaming ---
if (session.mode === "native" && accumulatedToolCalls.length > 0) {
// Validate accumulated arguments — if any fail to parse (Ollama streaming bug),
// retry the entire turn non-streaming
// Validate accumulated arguments. Local-model streaming (Ollama/LM Studio deltas)
// commonly truncates the JSON (max_tokens clipped mid-value) or leaves a trailing
// comma / unbalanced brace. Before paying for a full non-streaming regeneration of
// the turn (expensive on a local backend, and it fails identically when the cause was
// max_tokens), try a cheap structural repair — close unterminated strings, balance
// braces/brackets, strip stray trailing tokens. A repaired call still goes through
// schema validation at resolve time, so a bad repair can't execute a wrong call.
let allValid = true;
for (const tc of accumulatedToolCalls) {
try {
JSON.parse(tc.arguments);
} catch {
const repaired = repairPartialJson(tc.arguments);
if (repaired !== null) {
tc.arguments = JSON.stringify(repaired);
continue; // repaired successfully — keep checking the rest
}
allValid = false;
break;
}
@@ -837,7 +1179,7 @@ export async function runTurn(
messages: session.messages,
tools: toolset.openaiTools,
stream: false,
max_tokens: 4096,
max_tokens: resolveMaxTokens(session),
},
{ signal: retryGuard.signal },
);
@@ -881,13 +1223,23 @@ export async function runTurn(
} as ChatCompletionMessageParam);
const pendingImages: ImageAttachment[] = [];
for (const tc of accumulatedToolCalls) {
const streamNativeCalls: { resolved: ResolvedToolCall; label: string; tc: AccumulatedToolCall }[] = accumulatedToolCalls.map((tc) => {
const resolved = resolveToolCall(
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
toolset.registry,
);
const label = `${tc.name}(${tc.arguments})`;
const result = await gateAndRun(resolved, label, session, emit, signal);
return { resolved, label, tc };
});
const streamResults = await runToolBatch(
streamNativeCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
session,
emit,
signal,
);
for (let i = 0; i < streamNativeCalls.length; i++) {
const { resolved, tc } = streamNativeCalls[i]!;
const result = streamResults[i]!;
const image = pushToolResultMessage(session, "native", tc.id, tc.name, result);
noteMutationCommit(session, resolved, result);
if (image) pendingImages.push(image);
@@ -905,12 +1257,21 @@ export async function runTurn(
if (parsed.calls.length) {
emit({ type: "text_done", fullText });
session.messages.push({ role: "assistant", content: fullText });
for (const call of parsed.calls) {
const streamFbCalls: { resolved: ResolvedToolCall; label: string; call: { name: string; arguments: any } }[] = parsed.calls.map((call) => {
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
const result = await gateAndRun(resolved, label, session, emit, signal);
pushToolResultMessage(session, "fallback", "", call.name, result);
noteMutationCommit(session, resolved, result);
return { resolved, label, call };
});
const streamFbResults = await runToolBatch(
streamFbCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
session,
emit,
signal,
);
for (let i = 0; i < streamFbCalls.length; i++) {
const { resolved, call } = streamFbCalls[i]!;
pushToolResultMessage(session, "fallback", "", call.name, streamFbResults[i]!);
noteMutationCommit(session, resolved, streamFbResults[i]!);
}
continue;
}
@@ -924,6 +1285,19 @@ export async function runTurn(
}
}
// A bare empty `stop` (no text, no tool calls) — common from small/quantized local models.
// Treat it as retryable rather than a final empty answer: nudge once and continue, so a
// transient empty response doesn't end the task with no output (mirroring malformed path).
if (!fullText.trim()) {
if (emptyResponseRetries < MAX_EMPTY_RESPONSE_RETRIES) {
emptyResponseRetries++;
session.messages.push({ role: "user", content: "You returned an empty response. Please continue with your answer or call a tool." } as ChatCompletionMessageParam);
continue;
}
// Retry budget exhausted — surface a hard error instead of silently returning "".
throw new AgentError("Empty response from model.");
}
// Final text answer
emit({ type: "text_done", fullText });
session.messages.push({ role: "assistant", content: fullText });
@@ -931,11 +1305,21 @@ export async function runTurn(
return fullText;
}
// Empty response with no tool calls and no text — this shouldn't happen normally
// Empty response with no tool calls and no text. Small/quantized local models sometimes
// emit a bare empty `stop` chunk; retry once with a short nudge before giving up, so a
// transient empty response doesn't abort the whole turn (mirroring the malformed path).
if (emptyResponseRetries < MAX_EMPTY_RESPONSE_RETRIES) {
emptyResponseRetries++;
session.messages.push({ role: "user", content: "You returned an empty response. Please continue with your answer or call a tool." } as ChatCompletionMessageParam);
continue;
}
throw new AgentError("Empty response from model.");
}
throw new MaxIterationsError(
`Paused after ${session.maxIterations} steps in this turn. Everything done so far (including any file edits) is saved — send another message to continue.`,
`Paused after ${session.maxIterations} steps in this turn (each step is one model request). ` +
`Everything done so far — including any file edits or tool results — is saved in the conversation. ` +
`Send another message to continue from where it stopped (e.g. "continue" or "keep going"). ` +
`If this happens often, raise the limit with: locode config set maxIterations <number>.`,
);
}
+229
View File
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { runTurn } from "./loop.js";
import { agentTool } from "../tools/agentTool.js";
import { createSession } from "./session.js";
import { buildToolSet } from "../tools/toolset.js";
import type { ToolDef } from "../tools/types.js";
/** A one-shot streaming response: yields `chunk` once, then ends. */
function oneShotStream(chunk: any) {
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: chunk };
},
}),
};
}
function textChunk(text: string) {
return { choices: [{ delta: { content: text }, finish_reason: "stop" }] };
}
function toolCallChunk(name: string, args: string, id = "call_0") {
return {
choices: [
{
delta: { tool_calls: [{ index: 0, id, function: { name, arguments: args } }] },
finish_reason: "tool_calls",
},
],
};
}
describe("agent tool / parallel sub-agents", () => {
it("runs a `tasks` batch and returns each result independently", async () => {
const agentArgs = JSON.stringify({
description: "parallel research",
tasks: [
{ description: "task A", prompt: "do A" },
{ description: "task B", prompt: "do B" },
],
});
// create() is called: 1 parent agent-call, then sub-agent A, then sub-agent B, then parent final.
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
if (call === 1) return oneShotStream(toolCallChunk("agent", agentArgs));
if (call === 2) return oneShotStream(textChunk("result-A"));
if (call === 3) return oneShotStream(textChunk("result-B"));
return oneShotStream(textChunk("all done"));
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [agentTool]);
const result = await runTurn(session, "research A and B in parallel", () => {});
expect(result).toBe("all done");
// The agent tool's result must carry both sub-agent answers as a `results` array.
const toolResultMsg = session.messages.find(
(m) => m.role === "tool" && typeof (m as any).content === "string" && (m as any).content.includes("results"),
) as any;
expect(toolResultMsg).toBeTruthy();
const parsed = JSON.parse(toolResultMsg.content);
expect(parsed.results).toHaveLength(2);
const byDesc = Object.fromEntries(parsed.results.map((r: any) => [r.description, r]));
expect(byDesc["task A"].result).toBe("result-A");
expect(byDesc["task B"].result).toBe("result-B");
});
it("a failed sub-agent surfaces as its own error without discarding sibling results", async () => {
const agentArgs = JSON.stringify({
description: "mixed batch",
tasks: [
{ description: "ok", prompt: "succeed" },
{ description: "boom", prompt: "fail" },
],
});
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
if (call === 1) return oneShotStream(toolCallChunk("agent", agentArgs));
if (call === 2) return oneShotStream(textChunk("ok-result"));
if (call === 3) throw new Error("sub-agent boom failed");
return oneShotStream(textChunk("done"));
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [agentTool]);
await runTurn(session, "run mixed batch", () => {});
const toolResultMsg = session.messages.find(
(m) => m.role === "tool" && typeof (m as any).content === "string" && (m as any).content.includes("results"),
) as any;
expect(toolResultMsg).toBeTruthy();
const parsed = JSON.parse(toolResultMsg.content);
const byDesc = Object.fromEntries(parsed.results.map((r: any) => [r.description, r]));
expect(byDesc.ok.result).toBe("ok-result");
expect(byDesc.boom.error).toBeTruthy();
expect(byDesc.boom.error).toMatch(/boom/);
});
});
describe("mutation gate / serialization", () => {
it("serializes mutating tool calls so two never run concurrently", async () => {
let inFlight = 0;
let maxOverlap = 0;
const slowEdit: ToolDef = {
name: "slow_edit",
description: "slow edit",
schema: z.object({}),
mutating: true,
handler: async () => {
inFlight++;
maxOverlap = Math.max(maxOverlap, inFlight);
await new Promise((r) => setTimeout(r, 20));
inFlight--;
return { ok: true };
},
};
function twoEditsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "c0", function: { name: "slow_edit", arguments: "{}" } },
{ index: 1, id: "c1", function: { name: "slow_edit", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
],
};
}
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
const chunk = call === 1 ? twoEditsChunk() : textChunk("done");
return oneShotStream(chunk);
}),
},
},
} as any;
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [slowEdit]);
session.maxIterations = 5;
await runTurn(session, "two edits", () => {});
expect(maxOverlap).toBe(1);
});
it("lets read-only tools run concurrently (gate only blocks mutating)", async () => {
let inFlight = 0;
let maxOverlap = 0;
const fastRead: ToolDef = {
name: "fast_read",
description: "fast read",
schema: z.object({}),
mutating: false,
handler: async () => {
inFlight++;
maxOverlap = Math.max(maxOverlap, inFlight);
await new Promise((r) => setTimeout(r, 20));
inFlight--;
return { ok: true };
},
};
function threeReadsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "c0", function: { name: "fast_read", arguments: "{}" } },
{ index: 1, id: "c1", function: { name: "fast_read", arguments: "{}" } },
{ index: 2, id: "c2", function: { name: "fast_read", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
],
};
}
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
const chunk = call === 1 ? threeReadsChunk() : textChunk("done");
return oneShotStream(chunk);
}),
},
},
} as any;
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [fastRead]);
session.maxIterations = 5;
await runTurn(session, "three reads", () => {});
// All three reads are read-only → runToolBatch runs them concurrently → they overlap.
expect(maxOverlap).toBe(3);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { undoLastTurn, resetSession } from "./session.js";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
function makeSession(messages: ChatCompletionMessageParam[]) {
return {
messages,
lastContextTokens: 0,
lastContextTokensIsEstimate: true,
} as any;
}
describe("undoLastTurn", () => {
it("returns 0 when there are no user messages", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
]);
expect(undoLastTurn(session)).toBe(0);
});
it("removes a single user turn at the end", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(2);
expect(session.messages.length).toBe(1);
expect(session.messages[0]!.role).toBe("system");
});
it("removes user + assistant + tool results together", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Read the file" },
{ role: "assistant", content: "", tool_calls: [{ id: "tc1", type: "function", function: { name: "read_file", arguments: "{}" } }] } as any,
{ role: "tool", content: "file contents here", tool_call_id: "tc1" } as any,
{ role: "assistant", content: "The file contains..." },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(4);
expect(session.messages.length).toBe(1);
});
it("only removes the last turn, keeping earlier turns", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "First question" },
{ role: "assistant", content: "First answer" },
{ role: "user", content: "Second question" },
{ role: "assistant", content: "Second answer" },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(2);
expect(session.messages.length).toBe(3);
expect((session.messages[2] as any).content).toBe("First answer");
});
it("handles consecutive user messages (removing only the last one)", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Message 1" },
{ role: "user", content: "Message 2" },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(1);
expect(session.messages.length).toBe(2);
expect((session.messages[1] as any).content).toBe("Message 1");
});
it("updates context token tracking after undo", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi!" },
]);
session.lastContextTokens = 5000;
session.lastContextTokensIsEstimate = false;
undoLastTurn(session);
expect(session.lastContextTokensIsEstimate).toBe(true);
// lastContextTokens should be recalculated (smaller than before)
expect(session.lastContextTokens).toBeLessThan(5000);
});
});
describe("resetSession", () => {
it("clears all messages except system prompt", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi!" },
]);
resetSession(session);
expect(session.messages.length).toBe(1);
expect(session.messages[0]!.role).toBe("system");
});
});
+77 -9
View File
@@ -2,13 +2,15 @@ import { randomUUID } from "node:crypto";
import type OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
import type { SessionRecord } from "../persistence/sessionStore.js";
import { PermissionManager } from "../permissions/permissionManager.js";
import type { ConfirmFn } from "../permissions/types.js";
import { TOOLS } from "../tools/index.js";
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
import type { TodoItem, ToolDef } from "../tools/types.js";
import { TaskStore } from "../tools/task.js";
import type { TaskStoreSnapshot } from "../tools/task.js";
import { estimateTokens } from "../utils/tokens.js";
import { buildSystemPrompt } from "./systemPrompt.js";
@@ -86,6 +88,26 @@ export interface Session {
/** Current task checklist shown to the user via the `todo_write` tool — session-scoped state
* since checklist items are a snapshot of progress, not part of the model-visible conversation. */
todos: TodoItem[];
/** Structured task store backing the task_create/list/get/update tools — session-scoped,
* in-memory (not persisted), and independent of the flat `todos` checklist. */
taskStore: TaskStore;
/** A serialized-mutation gate shared by EVERY tool call in this session — including sub-agents
* spawned in parallel via the `agent` tool's `tasks` array. Parallel sub-agents share their
* parent's session, so without a lock two of them could simultaneously call a mutating tool,
* race on the single React `permission` slot (makeConfirmFn), and interleave filesystem writes.
* This gate lets read-only tools run concurrently (matching runToolBatch) while serializing
* mutating ones: each mutating call awaits the previous one before it even prompts, so
* permission prompts stay one-at-a-time and edits can't overlap. The chain is per-session,
* so a top-level turn and its sub-agents all funnel through the same queue. Initialized as a
* resolved promise so the first caller doesn't wait on anything. */
mutationGate: Promise<void>;
/** True when this is a small/less-capable local model, not just a local *backend* — Ollama's
* cloud-routed models (e.g. "glm-5.2:cloud") share a localhost endpoint with genuinely local
* ones, so this is more than a baseURL check. Used to tailor the system prompt — small local
* models need extra guidance about their limitations; everything else gets a leaner prompt
* without self-fulfilling "you may produce empty responses" framing. Derived by
* isSmallLocalModel(baseURL, model); recomputed on /model and /backend switches (see App.tsx). */
isLocal: boolean;
}
export function createSession(
@@ -95,15 +117,18 @@ export function createSession(
confirm: ConfirmFn,
mode: ToolCallMode,
tools: ToolDef[] = TOOLS,
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
contextWindow?: number,
contextWindowIsEstimate: boolean = true,
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
projectInstructions: string | null = null,
isLocal?: boolean,
): Session {
const resolvedIsLocal = isLocal ?? false;
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions) },
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions, resolvedIsLocal) },
];
return {
id: randomUUID(),
@@ -112,13 +137,14 @@ export function createSession(
model,
cwd,
mode,
isLocal: resolvedIsLocal,
messages,
maxIterations,
permissions: new PermissionManager(),
confirm,
toolset,
subAgentDepth: 0,
contextWindow,
contextWindow: resolvedContextWindow,
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
@@ -128,6 +154,8 @@ export function createSession(
mutationCommitLength: null,
projectInstructions,
todos: [],
taskStore: new TaskStore(),
mutationGate: Promise.resolve(),
};
}
@@ -139,31 +167,35 @@ export function createSessionFromRecord(
cwd: string,
confirm: ConfirmFn,
tools: ToolDef[] = TOOLS,
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
contextWindow?: number,
contextWindowIsEstimate: boolean = true,
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
projectInstructions: string | null = null,
isLocal?: boolean,
): Session {
const resolvedIsLocal = isLocal ?? false;
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions) },
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions, resolvedIsLocal) },
...record.messages,
];
return {
const session: Session = {
id: record.id,
createdAt: record.createdAt,
client,
model: record.model,
cwd,
mode: record.mode,
isLocal: resolvedIsLocal,
messages,
maxIterations,
permissions: new PermissionManager(),
confirm,
toolset,
subAgentDepth: 0,
contextWindow,
contextWindow: resolvedContextWindow,
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
@@ -173,7 +205,18 @@ export function createSessionFromRecord(
mutationCommitLength: null,
projectInstructions,
todos: [],
taskStore: TaskStore.fromJSON(record.tasks ?? { seq: 0, tasks: [] }),
mutationGate: Promise.resolve(),
};
// Restore session-allowed tools from the saved record, so /perm approvals survive resume.
if (record.allowedTools) {
for (const toolName of record.allowedTools) {
session.permissions.allowForSession(toolName);
}
}
return session;
}
export function toSessionRecord(session: Session, baseURL: string): SessionRecord {
@@ -186,6 +229,8 @@ export function toSessionRecord(session: Session, baseURL: string): SessionRecor
model: session.model,
mode: session.mode,
messages: session.messages.slice(1),
allowedTools: session.permissions.listAllowed(),
tasks: session.taskStore.toJSON(),
};
}
@@ -195,7 +240,30 @@ export function resetSession(session: Session): void {
session.lastContextTokensIsEstimate = true;
}
/** Removes the last complete user turn (the user message + all subsequent assistant/tool messages
* up to the next user message or the end of history). Returns the number of messages removed,
* or 0 if there's no user message to undo (only the system prompt remains). This is a soft undo \u2014
* filesystem changes from tool calls are NOT rolled back, but the model will no longer see the
* removed context, so it won't repeat those actions. */
export function undoLastTurn(session: Session): number {
// Walk backwards from the end to find the last user message.
let lastUserIdx = -1;
for (let i = session.messages.length - 1; i >= 1; i--) {
if (session.messages[i]!.role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return 0; // No user messages to undo.
const removed = session.messages.length - lastUserIdx;
session.messages.length = lastUserIdx;
session.lastContextTokens = estimateTokens(session.messages);
session.lastContextTokensIsEstimate = true;
return removed;
}
export function setMode(session: Session, mode: ToolCallMode): void {
session.mode = mode;
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions) };
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions, session.isLocal) };
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import { buildSystemPrompt } from "./systemPrompt.js";
const dummyTool = (name: string, mutating: boolean): ToolDef => ({
name,
description: `Tool ${name} for testing`,
schema: z.object({}),
mutating,
handler: async () => null,
});
describe("buildSystemPrompt", () => {
const readTool = dummyTool("read_file", false);
const writeTool = dummyTool("write_file", true);
it("includes tool names grouped by category", () => {
const prompt = buildSystemPrompt([readTool, writeTool], "native");
expect(prompt).toContain("read_file");
expect(prompt).toContain("write_file");
expect(prompt).toContain("Read-only");
expect(prompt).toContain("Mutating");
});
it("includes core principles for local models", () => {
const prompt = buildSystemPrompt([readTool], "native", null, true);
expect(prompt).toContain("Inspect before answering");
expect(prompt).toContain("Prefer small, targeted edits");
expect(prompt).toContain("Recovery over retry");
expect(prompt).toContain("Respect confirmation");
});
it("includes core principles for cloud models", () => {
const prompt = buildSystemPrompt([readTool], "native", null, false);
expect(prompt).toContain("Inspect before answering");
expect(prompt).toContain("Prefer small, targeted edits");
expect(prompt).toContain("Recovery over retry");
expect(prompt).toContain("Respect confirmation");
});
it("includes tool usage guide", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain("read_file");
expect(prompt).toContain("edit_file");
expect(prompt).toContain("bash");
expect(prompt).toContain("agent");
});
it("includes local model guidance when isLocal is true", () => {
const prompt = buildSystemPrompt([readTool], "native", null, true);
expect(prompt).toContain("Working with local models");
expect(prompt).toContain("Tool-call formatting can be unreliable");
expect(prompt).toContain("Empty or malformed responses can happen");
});
it("excludes local model guidance when isLocal is false (cloud)", () => {
const prompt = buildSystemPrompt([readTool], "native", null, false);
expect(prompt).not.toContain("Working with local models");
expect(prompt).not.toContain("Tool-call formatting can be unreliable");
expect(prompt).not.toContain("Empty or malformed responses can happen");
});
it("defaults to cloud prompt when isLocal is not specified", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain("coding assistant");
expect(prompt).not.toContain("Working with local models");
});
it("includes safety guidelines for both local and cloud", () => {
const localPrompt = buildSystemPrompt([readTool], "native", null, true);
const cloudPrompt = buildSystemPrompt([readTool], "native", null, false);
expect(localPrompt).toContain(".git");
expect(localPrompt).toContain("destructive");
expect(cloudPrompt).toContain(".git");
expect(cloudPrompt).toContain("destructive");
});
it("includes fallback instructions when mode is fallback", () => {
const prompt = buildSystemPrompt([readTool], "fallback");
expect(prompt).toContain("tool_call");
expect(prompt.toLowerCase()).toContain("fallback");
});
it("includes native mode instructions when mode is native (local)", () => {
const prompt = buildSystemPrompt([readTool], "native", null, true);
expect(prompt).toContain("native tool-call mode");
});
it("includes native mode instructions when mode is native (cloud)", () => {
const prompt = buildSystemPrompt([readTool], "native", null, false);
expect(prompt).toContain("native tool-call mode");
});
it("appends project instructions", () => {
const prompt = buildSystemPrompt([readTool], "native", "Always use TypeScript strict mode.");
expect(prompt).toContain("Always use TypeScript strict mode.");
// Project instructions should be at the end
const idx = prompt.indexOf("Always use TypeScript strict mode.");
const safetyIdx = prompt.indexOf("## Safety");
expect(idx).toBeGreaterThan(safetyIdx);
});
it("works without project instructions", () => {
const prompt = buildSystemPrompt([readTool], "native", null);
expect(prompt).not.toContain("Project instructions");
});
it("handles empty tool list", () => {
const prompt = buildSystemPrompt([], "native");
expect(prompt).toContain("Available tools");
expect(prompt).toContain("Core principles");
});
it("handles all read-only tools", () => {
const tools = [dummyTool("read_file", false), dummyTool("grep", false), dummyTool("definition", false)];
const prompt = buildSystemPrompt(tools, "native");
// Tool list section should only have Read-only
const toolSection = prompt.split("## Core principles")[0];
expect(toolSection).toContain("Read-only: read_file, grep, definition");
expect(toolSection).not.toContain("Mutating");
});
it("handles all mutating tools", () => {
const tools = [dummyTool("write_file", true), dummyTool("edit_file", true)];
const prompt = buildSystemPrompt(tools, "native");
const toolSection = prompt.split("## Core principles")[0];
expect(toolSection).toContain("Mutating (requires confirmation): write_file, edit_file");
expect(toolSection).not.toContain("Read-only");
});
});
+135 -12
View File
@@ -2,20 +2,143 @@ import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { FALLBACK_TOOL_INSTRUCTIONS } from "../toolcalling/fallbackPrompt.js";
import type { ToolDef } from "../tools/types.js";
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null): string {
const toolList = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
const base = `You are a helpful local coding assistant with access to tools for exploring a codebase on the user's machine.
function formatToolList(tools: ToolDef[]): string {
const readWrite = new Map<string, string[]>();
for (const t of tools) {
const category = t.mutating ? "Mutating (requires confirmation)" : "Read-only";
const list = readWrite.get(category) ?? [];
list.push(t.name);
readWrite.set(category, list);
}
const parts: string[] = [];
for (const [category, names] of readWrite) {
parts.push(`${category}: ${names.join(", ")}`);
}
return parts.join("\n");
}
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null, isLocal?: boolean): string {
const toolList = formatToolList(tools);
// Auto-detect: if not explicitly specified, use the cloud prompt by default.
// Callers (App.tsx) always pass isSmallLocalModel(baseURL, model) explicitly, so this
// default only affects tests or edge cases without a baseURL/model.
const useLocal = isLocal ?? false;
const base = useLocal
? buildLocalPrompt(toolList, mode)
: buildCloudPrompt(toolList, mode);
return projectInstructions ? `${base}\n\n${projectInstructions}` : base;
}
/** System prompt for local models (Ollama / LM Studio) — includes extra guidance about their
* limitations (unreliable tool-call formatting, occasional empty/malformed responses).
* Cloud models get a leaner prompt (buildCloudPrompt) that omits these assumptions. */
function buildLocalPrompt(toolList: string, mode: ToolCallMode): string {
return `You are a helpful coding assistant with access to tools for exploring and editing a codebase on the user's machine.
## Available tools
Available tools:
${toolList}
Guidelines:
- Inspect files with tools before answering; don't guess contents.
- Call at most one tool at a time.
- Mutating tools (write_file, edit_file, bash, git_commit) require user confirmation.
- Use edit_file for small edits; write_file for new files or full rewrites.
- Respond in plain text when you have enough information. Keep answers concise.`;
## Core principles
const withMode = mode === "fallback" ? `${base}\n\n${FALLBACK_TOOL_INSTRUCTIONS}` : base;
return projectInstructions ? `${withMode}\n\n${projectInstructions}` : withMode;
1. **Inspect before answering.** Never guess file contents, function signatures, or directory structures — use read_file, list_files, grep, or definition to verify. Stale assumptions are worse than an extra tool call.
2. **Prefer small, targeted edits.** Use edit_file (or multi_edit for several changes in one file) for surgical changes. Use write_file only for new files or full rewrites. edit_file requires old_string to match exactly — copy the exact text from the file (read it first), including indentation and blank lines.
3. **One tool call per response in fallback mode.** If you are in fallback mode (see below), call at most one tool per response and wait for the result before proceeding. In native mode you may call multiple read-only tools in parallel.
4. **Preserve existing style.** Match the surrounding code's indentation, naming conventions, quotes, and formatting. Don't reformat code outside the change scope.
5. **Keep answers concise.** When you have enough information, respond in plain text — don't pad with pleasantries or restated context. Code explanations should be brief and focused on the "why", not the "what" (the code already says what).
6. **Recovery over retry.** If a tool call fails (edit_file "not found", bash non-zero exit, etc.), read the file or check the error output before retrying — don't repeat the same call. If edit_file suggests a closest match, use that text exactly.
7. **Respect confirmation.** Mutating tools (write_file, edit_file, multi_edit, notebook_edit, bash, git_commit) require user confirmation — you will see a permission prompt. Plan your edits so the user sees a clear, concise preview.
## Tool usage guide
- **read_file**: Start here. Use offset/limit for large files. Always read before editing.
- **list_files**: Explore directory structure. Supports glob patterns like "src/**/*.ts".
- **grep**: Search file contents. Prefer over read_file when you know what you're looking for.
- **definition / references / diagnostics**: LSP-powered code intelligence. Use definition to find where a symbol is declared, references for all usages, diagnostics for type errors.
- **edit_file**: For small changes to existing files. old_string must match exactly — include enough surrounding context to be unique. On mismatch, the tool suggests the closest similar text.
- **multi_edit**: Apply several edits to the same file in one call. Each edit sees the result of previous edits, so adjust old_string for context shifts.
- **write_file**: For new files or complete rewrites. Overwrites the entire file — use with care.
- **bash**: Run shell commands. Prefer targeted tools (grep, definition) over broad shell commands when possible. Use timeout_ms for long-running commands. Background with Ctrl+B for very long commands.
- **git_status / git_commit**: Inspect repo state and commit changes. Always check status before committing.
- **web_search / web_fetch**: Look up information not in the local codebase. For API docs, error messages, or unfamiliar libraries.
- **agent**: Delegate a sub-task to a focused sub-agent. Good for researching many files in parallel. Sub-agents cannot spawn further sub-agents.
- **task_create / task_list / task_get / task_update**: Track structured work items with dependencies. Use for multi-step tasks (3+ steps) so progress is visible.
- **todo_write**: Simple checklist for progress tracking. Good for linear step-by-step work.
## Working with local models
- **Tool-call formatting can be unreliable.** If you're in fallback mode, follow the tool_call format strictly. If native mode produces errors, the system will automatically retry with fallback parsing.
- **Empty or malformed responses can happen.** The system retries automatically, but if you see repeated failures, simplify your request.
- **Output length may be limited.** For large file generations, prefer edit_file over write_file when possible — it uses fewer output tokens.
## Fallback mode
${mode === "fallback" ? FALLBACK_TOOL_INSTRUCTIONS : "You are in native tool-call mode. Call tools using the standard function-calling format. You may call multiple read-only tools in parallel, but mutating tools are always run sequentially."}
## Safety
- Do not modify .git directories or other version-control internals.
- Do not delete large sections of code without clear justification and user confirmation.
- When running bash commands, prefer read-only inspections (ls, cat, git status) over destructive operations (rm, git reset --hard).
- If unsure about a destructive action, ask the user first rather than proceeding.`;
}
/** System prompt for cloud models (large context window, reliable tool calls, no local-model quirks).
* Leaner than the local prompt — skips the "Working with local models" section entirely and uses
* a more direct tone, since cloud models don't need hand-holding about their own limitations. */
function buildCloudPrompt(toolList: string, mode: ToolCallMode): string {
return `You are a coding assistant with access to tools for exploring and editing a codebase on the user's machine.
## Available tools
${toolList}
## Core principles
1. **Inspect before answering.** Never guess file contents, function signatures, or directory structures — use read_file, list_files, grep, or definition to verify. Stale assumptions are worse than an extra tool call.
2. **Prefer small, targeted edits.** Use edit_file (or multi_edit for several changes in one file) for surgical changes. Use write_file only for new files or full rewrites. edit_file requires old_string to match exactly — copy the exact text from the file (read it first), including indentation and blank lines.
3. **Preserve existing style.** Match the surrounding code's indentation, naming conventions, quotes, and formatting. Don't reformat code outside the change scope.
4. **Keep answers concise.** When you have enough information, respond in plain text — don't pad with pleasantries or restated context. Code explanations should be brief and focused on the "why", not the "what" (the code already says what).
5. **Recovery over retry.** If a tool call fails (edit_file "not found", bash non-zero exit, etc.), read the file or check the error output before retrying — don't repeat the same call. If edit_file suggests a closest match, use that text exactly.
6. **Respect confirmation.** Mutating tools (write_file, edit_file, multi_edit, notebook_edit, bash, git_commit) require user confirmation — you will see a permission prompt. Plan your edits so the user sees a clear, concise preview.
## Tool usage guide
- **read_file**: Start here. Use offset/limit for large files. Always read before editing.
- **list_files**: Explore directory structure. Supports glob patterns like "src/**/*.ts".
- **grep**: Search file contents. Prefer over read_file when you know what you're looking for.
- **definition / references / diagnostics**: LSP-powered code intelligence. Use definition to find where a symbol is declared, references for all usages, diagnostics for type errors.
- **edit_file**: For small changes to existing files. old_string must match exactly — include enough surrounding context to be unique. On mismatch, the tool suggests the closest similar text.
- **multi_edit**: Apply several edits to the same file in one call. Each edit sees the result of previous edits, so adjust old_string for context shifts.
- **write_file**: For new files or complete rewrites. Overwrites the entire file — use with care.
- **bash**: Run shell commands. Prefer targeted tools (grep, definition) over broad shell commands when possible. Use timeout_ms for long-running commands. Background with Ctrl+B for very long commands.
- **git_status / git_commit**: Inspect repo state and commit changes. Always check status before committing.
- **web_search / web_fetch**: Look up information not in the local codebase. For API docs, error messages, or unfamiliar libraries.
- **agent**: Delegate a sub-task to a focused sub-agent. Good for researching many files in parallel. Sub-agents cannot spawn further sub-agents.
- **task_create / task_list / task_get / task_update**: Track structured work items with dependencies. Use for multi-step tasks (3+ steps) so progress is visible.
- **todo_write**: Simple checklist for progress tracking. Good for linear step-by-step work.
## ${mode === "fallback" ? "Fallback mode" : "Tool calling"}
${mode === "fallback" ? FALLBACK_TOOL_INSTRUCTIONS : "You are in native tool-call mode. Call tools using the standard function-calling format. You may call multiple read-only tools in parallel, but mutating tools are always run sequentially."}
## Safety
- Do not modify .git directories or other version-control internals.
- Do not delete large sections of code without clear justification and user confirmation.
- When running bash commands, prefer read-only inspections (ls, cat, git status) over destructive operations (rm, git reset --hard).
- If unsure about a destructive action, ask the user first rather than proceeding.`;
}
+40 -4
View File
@@ -7,7 +7,7 @@ import type { ToolCallMode } from "./capabilityProbe.js";
const paths = envPaths("locode", { suffix: "" });
const cacheFile = path.join(paths.config, "model-capabilities.json");
type Cache = Record<string, ToolCallMode>;
type Cache = Record<string, { mode: ToolCallMode; cachedAt?: number }>;
function keyFor(baseURL: string, model: string): string {
return `${baseURL}::${model}`;
@@ -23,7 +23,19 @@ function load(): Cache {
// Always re-read from disk if possible, so concurrent processes' writes aren't overwritten.
if (existsSync(cacheFile)) {
try {
memoryCache = JSON.parse(readFileSync(cacheFile, "utf-8")) as Cache;
const raw = JSON.parse(readFileSync(cacheFile, "utf-8")) as Record<string, unknown>;
// Migrate legacy format: bare string values ("native" | "fallback") become { mode, cachedAt }.
const cache: Cache = {};
for (const [k, v] of Object.entries(raw)) {
if (typeof v === "string" && (v === "native" || v === "fallback")) {
// Legacy entry — no cachedAt, so it can be re-validated on next probe.
cache[k] = { mode: v };
} else if (typeof v === "object" && v !== null && "mode" in v) {
cache[k] = v as Cache[string];
}
// Silently drop unrecognized entries.
}
memoryCache = cache;
return memoryCache;
} catch {
memoryCache = {};
@@ -41,12 +53,36 @@ function save(cache: Cache): void {
void writeFileAtomic(cacheFile, JSON.stringify(cache, null, 2));
}
/** How long a cached tool-call mode detection stays fresh before locode re-probes. A model's
* tool-call capability rarely changes, but a transient probe failure (network timeout, 5xx)
* can leave a stale "fallback" entry that permanently disables native tool calls. The TTL
* ensures periodic re-validation. Set to 0 via LOCODE_CAPABILITY_CACHE_TTL_DAYS=0 to force
* a re-probe every session. */
const DEFAULT_CACHE_TTL_DAYS = 30;
function resolveCacheTtlDays(): number {
const envValue = Number(process.env.LOCODE_CAPABILITY_CACHE_TTL_DAYS);
if (Number.isFinite(envValue) && envValue >= 0 && envValue <= 365) return envValue;
return DEFAULT_CACHE_TTL_DAYS;
}
function isStale(entry: { cachedAt?: number }, ttlDays: number): boolean {
if (ttlDays <= 0) return true;
if (typeof entry.cachedAt !== "number") return true; // legacy entry — always re-probe
const ageMs = Date.now() - entry.cachedAt;
return ageMs > ttlDays * 24 * 60 * 60 * 1000;
}
export function getCachedMode(baseURL: string, model: string): ToolCallMode | undefined {
return load()[keyFor(baseURL, model)];
const entry = load()[keyFor(baseURL, model)];
if (entry === undefined) return undefined;
// Treat stale or legacy entries as a miss so the backend is re-probed.
if (isStale(entry, resolveCacheTtlDays())) return undefined;
return entry.mode;
}
export function setCachedMode(baseURL: string, model: string, mode: ToolCallMode): void {
const cache = load();
cache[keyFor(baseURL, model)] = mode;
cache[keyFor(baseURL, model)] = { mode, cachedAt: Date.now() };
save(cache);
}
+6 -2
View File
@@ -1,5 +1,5 @@
import OpenAI from "openai";
import { resolveRequestTimeoutMs } from "../config/config.js";
import { resolveMaxRetries, resolveRequestTimeoutMs } from "../config/config.js";
import type { AppConfig } from "../config/types.js";
export function makeClient(cfg: AppConfig): OpenAI {
@@ -15,6 +15,10 @@ export function makeClient(cfg: AppConfig): OpenAI {
// requests behind a concurrency limit (e.g. Ollama's OLLAMA_NUM_PARALLEL) can legitimately take
// longer than the 180s default to even start serving a request under contention.
timeout: resolveRequestTimeoutMs(),
maxRetries: 0,
// Configurable retries on transient failures (connection errors, 429, 5xx) with exponential
// backoff. Defaults to 0 (fail immediately) to preserve the old behavior, since a local
// backend's slow response usually means the model is stuck rather than a transient blip — but
// raise via `maxRetries` / LOCODE_MAX_RETRIES for setups with occasional connection drops.
maxRetries: resolveMaxRetries(),
});
}
+9 -4
View File
@@ -61,9 +61,14 @@ async function detectLmStudioContextWindow(baseURL: string, model: string): Prom
* assume which one is actually running behind an OpenAI-compatible baseURL. Returns null (rather
* than guessing) if neither responds usefully — callers should fall back to a configured default. */
export async function detectContextWindow(baseURL: string, model: string): Promise<number | null> {
const ollama = await detectOllamaContextWindow(baseURL, model);
if (ollama !== null) return ollama;
return detectLmStudioContextWindow(baseURL, model);
// Try both backends in parallel to halve detection latency.
const [ollama, lmStudio] = await Promise.allSettled([
detectOllamaContextWindow(baseURL, model),
detectLmStudioContextWindow(baseURL, model),
]);
if (ollama.status === "fulfilled" && ollama.value !== null) return ollama.value;
if (lmStudio.status === "fulfilled" && lmStudio.value !== null) return lmStudio.value;
return null;
}
export interface ResolvedContextWindow {
@@ -85,5 +90,5 @@ export async function resolveContextWindow(baseURL: string, model: string): Prom
return { value: detected, isEstimate: false };
}
return { value: resolveContextWindowDefault(), isEstimate: true };
return { value: resolveContextWindowDefault(baseURL, model), isEstimate: true };
}
+44 -3
View File
@@ -4,6 +4,8 @@ import envPaths from "env-paths";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getCachedContextWindow } from "./contextWindowCache.js";
const KEY = "http://localhost:11434/v1::ttl-model";
const cacheFile = path.join(envPaths("locode", { suffix: "" }).config, "context-windows.json");
describe("contextWindowCache", () => {
@@ -19,16 +21,18 @@ describe("contextWindowCache", () => {
} else if (existsSync(cacheFile)) {
rmSync(cacheFile);
}
delete process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS;
});
it("reads a well-formed cached entry", () => {
// Written directly (rather than via setCachedContextWindow, whose write is fire-and-forget
// async and would race this file-backed cache's always-read-from-disk load()) so the test is
// deterministic and can't leak a pending write past its own afterEach cleanup.
// deterministic and can't leak a pending write past its own afterEach cleanup. Includes a
// fresh cachedAt so the TTL check treats it as current.
mkdirSync(path.dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, JSON.stringify({ "http://localhost:11434/v1::test-model": { value: 32768, isEstimate: false } }), "utf-8");
writeFileSync(cacheFile, JSON.stringify({ "http://localhost:11434/v1::test-model": { value: 32768, isEstimate: false, cachedAt: Date.now() } }), "utf-8");
expect(getCachedContextWindow("http://localhost:11434/v1", "test-model")).toEqual({ value: 32768, isEstimate: false });
expect(getCachedContextWindow("http://localhost:11434/v1", "test-model")).toEqual({ value: 32768, isEstimate: false, cachedAt: expect.any(Number) });
});
it("treats a legacy bare-number cache entry as a miss instead of returning {value: undefined}", () => {
@@ -42,4 +46,41 @@ describe("contextWindowCache", () => {
const result = getCachedContextWindow("http://localhost:11434/v1", "legacy-model");
expect(result).toBeUndefined();
});
it("treats an entry without cachedAt as expired (re-detect)", () => {
mkdirSync(path.dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false } }), "utf-8");
// No cachedAt field — legacy entry from before TTL was added; should be a miss.
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeUndefined();
});
it("treats a fresh entry (recent cachedAt) as a hit", () => {
mkdirSync(path.dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: Date.now() } }), "utf-8");
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toEqual({ value: 32768, isEstimate: false, cachedAt: expect.any(Number) });
});
it("treats an old entry (cachedAt beyond TTL) as a miss", () => {
mkdirSync(path.dirname(cacheFile), { recursive: true });
// 30 days ago, default TTL is 7 days — stale.
const old = Date.now() - 30 * 24 * 60 * 60 * 1000;
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: old } }), "utf-8");
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeUndefined();
});
it("respects a configured TTL of 0 (always re-detect)", () => {
process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS = "0";
mkdirSync(path.dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: Date.now() } }), "utf-8");
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeUndefined();
});
it("respects a longer configured TTL", () => {
process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS = "365";
mkdirSync(path.dirname(cacheFile), { recursive: true });
// 30 days ago, but TTL is now 365 days — fresh.
const old = Date.now() - 30 * 24 * 60 * 60 * 1000;
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: old } }), "utf-8");
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeDefined();
});
});
+28 -2
View File
@@ -6,9 +6,23 @@ import { writeFileAtomic } from "../utils/writeFileAtomic.js";
const paths = envPaths("locode", { suffix: "" });
const cacheFile = path.join(paths.config, "context-windows.json");
/** How long a cached context-window detection stays fresh before locode re-detects it. A model's
* context window rarely changes, but a backend can be reconfigured (quantization swapped, a
* different model loaded under the same id, Ollama's `num_ctx` raised) — a TTL avoids pinning a
* stale value forever. Set to 0 to disable caching (re-detect every session). */
const DEFAULT_CACHE_TTL_DAYS = 7;
export function resolveCacheTtlDays(): number {
const envValue = Number(process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS);
if (Number.isFinite(envValue) && envValue >= 0 && envValue <= 365) return envValue;
return DEFAULT_CACHE_TTL_DAYS;
}
export interface CachedContextWindow {
value: number;
isEstimate: boolean;
/** Unix epoch ms when this entry was cached. Absent on legacy entries (treated as expired). */
cachedAt?: number;
}
type Cache = Record<string, CachedContextWindow>;
@@ -39,7 +53,17 @@ function save(cache: Cache): void {
void writeFileAtomic(cacheFile, JSON.stringify(cache, null, 2));
}
/** Returns true when the entry is stale given the configured TTL. A TTL of 0 means "always
* re-detect", so every entry is stale; a missing `cachedAt` (legacy entry) is also stale. */
function isStale(entry: CachedContextWindow, ttlDays: number): boolean {
if (ttlDays <= 0) return true;
if (typeof entry.cachedAt !== "number") return true;
const ageMs = Date.now() - entry.cachedAt;
return ageMs > ttlDays * 24 * 60 * 60 * 1000;
}
export function getCachedContextWindow(baseURL: string, model: string): CachedContextWindow | undefined {
const ttlDays = resolveCacheTtlDays();
const entry = load()[keyFor(baseURL, model)];
if (entry === undefined) return undefined;
// An older locode version cached a bare number instead of { value, isEstimate }. Treat that
@@ -50,11 +74,13 @@ export function getCachedContextWindow(baseURL: string, model: string): CachedCo
if (typeof entry !== "object" || entry === null || typeof (entry as CachedContextWindow).value !== "number") {
return undefined;
}
// Expired entries are treated as a miss so the backend is re-queried and the entry refreshed.
if (isStale(entry, ttlDays)) return undefined;
return entry;
}
export function setCachedContextWindow(baseURL: string, model: string, contextWindow: CachedContextWindow): void {
const cache = load();
cache[keyFor(baseURL, model)] = contextWindow;
cache[keyFor(baseURL, model)] = { ...contextWindow, cachedAt: Date.now() };
save(cache);
}
}
+45 -6
View File
@@ -2,13 +2,14 @@ import { execa } from "execa";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { Command } from "commander";
import pkg from "../package.json" with { type: "json" };
import { makeClient } from "./backend/client.js";
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js";
import { loadMergedHooks, userHooksFilePath } from "./hooks/config.js";
import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js";
import { isHttpServerConfig } from "./mcp/types.js";
import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
import { deleteSession, listSessions, loadSession, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
import { addInstalledPlugin, loadInstalledPlugins, pluginsDir, removeInstalledPlugin } from "./plugins/config.js";
import { loadPlugin } from "./plugins/loader.js";
import { runInkApp } from "./ui/ink/index.js";
@@ -24,7 +25,7 @@ function addBackendOptions(cmd: Command): Command {
program
.name("locode")
.description("Agentic coding CLI for local models via Ollama and LM Studio")
.version("0.3.1");
.version(pkg.version);
addBackendOptions(program)
.option("-m, --model <name>", "model name as known to the backend")
@@ -114,31 +115,57 @@ configCmd
configCmd
.command("set <key> <value>")
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs)")
.description(
"Persist a config value (backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers)",
)
.action((key: string, value: string) => {
if (
key !== "backend" &&
key !== "model" &&
key !== "baseUrl" &&
key !== "contextWindow" &&
key !== "maxOutputTokens" &&
key !== "maxIterations" &&
key !== "autoCompactThreshold" &&
key !== "requestTimeoutMs" &&
key !== "subagentTimeoutMs"
key !== "subagentTimeoutMs" &&
key !== "maxRetries" &&
key !== "lspServers"
) {
console.error(
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs`,
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers`,
);
process.exit(1);
}
const stored = loadStoredConfig();
if (key === "contextWindow" || key === "maxIterations") {
if (key === "lspServers") {
// lspServers is a JSON object: { "<languageId>": { "command": "...", "args": [...], "extensions": [...] } }
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
console.error(`lspServers must be a JSON object, got invalid JSON: ${value}`);
process.exit(1);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
console.error(`lspServers must be a JSON object keyed by language id, got: ${value}`);
process.exit(1);
}
stored.lspServers = parsed as Record<string, { command: string; args?: string[]; extensions?: string[] }>;
} else if (key === "contextWindow" || key === "maxIterations") {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
console.error(`${key} must be a positive number, got "${value}".`);
process.exit(1);
}
stored[key] = n;
} else if (key === "maxOutputTokens") {
const n = Number(value);
if (!Number.isFinite(n) || n < 256 || n > 1_000_000) {
console.error(`maxOutputTokens must be between 256 and 1000000, got "${value}".`);
process.exit(1);
}
stored[key] = n;
} else if (key === "autoCompactThreshold") {
const n = Number(value);
if (!Number.isFinite(n) || n < 0.1 || n > 0.95) {
@@ -160,6 +187,13 @@ configCmd
process.exit(1);
}
stored[key] = n;
} else if (key === "maxRetries") {
const n = Number(value);
if (!Number.isFinite(n) || n < 0 || n > 10) {
console.error(`maxRetries must be between 0 and 10, got "${value}".`);
process.exit(1);
}
stored[key] = n;
} else {
stored[key] = value;
}
@@ -196,6 +230,11 @@ sessionsCmd
.action((id: string) => {
if (deleteSession(id)) {
console.log(`Deleted session ${id}.`);
} else if (loadSession(id)) {
// The file exists but deleteSession() couldn't actually remove it (e.g. locked by another
// process) — a different situation from "no such session", so say so distinctly.
console.error(`Could not delete session "${id}" — the file may be in use by another process.`);
process.exit(1);
} else {
console.error(`No saved session found with id "${id}".`);
process.exit(1);
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { configureLanguageSpecs, _resetSpecsForTests, _specsForTests } from "./lspManager.js";
// configureLanguageSpecs mutates the module's LANGUAGE_SPECS (forward-only by design — production
// applies it once at startup). Tests restore the built-in defaults via _resetSpecsForTests so they
// stay independent, then assert the merged spec list through _specsForTests (no server spawned).
function specFor(ext: string) {
const specs = _specsForTests();
return specs.find((s) => s.extensions.includes(ext)) ?? null;
}
describe("configureLanguageSpecs", () => {
beforeEach(() => _resetSpecsForTests());
afterEach(() => _resetSpecsForTests());
it("leaves the built-in specs untouched for an empty override", () => {
configureLanguageSpecs({});
expect(_specsForTests().map((s) => s.languageId)).toEqual([
"typescript",
"python",
"go",
"rust",
"c",
]);
// C and C++ share one clangd spec (no separate "cpp" entry).
expect(specFor(".cpp")?.languageId).toBe("c");
expect(specFor(".h")?.languageId).toBe("c");
});
it("adds a brand-new language with extensions", () => {
configureLanguageSpecs({ java: { command: "jdtls", extensions: [".java"] } });
expect(specFor(".java")?.command).toBe("jdtls");
expect(specFor(".java")?.languageId).toBe("java");
});
it("ignores a new-language entry without extensions (can't route files to it)", () => {
configureLanguageSpecs({ ruby: { command: "solargraph" } });
expect(specFor(".rb")).toBeNull();
});
it("overrides a built-in server's command and args", () => {
configureLanguageSpecs({ typescript: { command: "my-tsserver", args: ["--stdio"] } });
expect(specFor(".ts")?.command).toBe("my-tsserver");
expect(specFor(".ts")?.args).toEqual(["--stdio"]);
});
it("keeps a built-in's extensions when an override omits them", () => {
configureLanguageSpecs({ python: { command: "basedpyright", args: ["--stdio"] } });
expect(specFor(".py")?.command).toBe("basedpyright");
expect(specFor(".pyi")?.languageId).toBe("python"); // extensions unchanged
});
it("rewrites a built-in language's extensions when provided", () => {
configureLanguageSpecs({ go: { command: "gopls", args: ["serve"], extensions: [".rs"] } });
// .rs now routes to "go", not "rust".
expect(specFor(".rs")?.languageId).toBe("go");
expect(specFor(".go")).toBeNull(); // .go no longer claimed by go
});
});
+452
View File
@@ -0,0 +1,452 @@
import { spawn, type ChildProcess } from "node:child_process";
import path from "node:path";
import { readFile as fsReadFile } from "node:fs/promises";
import {
createProtocolConnection,
DidChangeTextDocumentNotification,
DidOpenTextDocumentNotification,
DefinitionRequest,
ReferencesRequest,
type ProtocolConnection,
type TextDocumentIdentifier,
type Position,
type Location,
type Diagnostic,
} from "vscode-languageserver-protocol";
import { StreamMessageReader, StreamMessageWriter } from 'vscode-languageserver-protocol/node';
import { URI } from "vscode-uri";
import type { MarkupContent } from "vscode-languageserver-protocol";
/** LSP diagnostic messages can be either a plain string or a { kind, value } MarkupContent object.
* locode's tool surface deals in plain strings, so flatten either form to text. */
function messageToString(message: string | MarkupContent): string {
if (typeof message === "string") return message;
return message?.value ?? "";
}
/** A connected LSP server for one language, plus its child process so we can clean it up. */
interface LspHandle {
connection: ProtocolConnection;
child: ChildProcess;
languageId: string;
/** Open documents we've already sent didOpen for, so we send didChange (not didOpen) on edits. */
openDocs: Set<string>;
}
/** Maps a file extension to a language id (the LSP "languageId" string) and the server command to
* spawn for it. Only one server per language is ever spawned (lazy, on first use). A missing entry
* means locode has no built-in mapping — the user can still point a server at it via config in a
* future extension. The command is resolved on the PATH; if it isn't installed the spawn fails and
* the tool returns a clear "install X" error rather than a silent no-op. */
interface LanguageSpec {
languageId: string;
extensions: string[];
/** The server command (no args). Must be on PATH. */
command: string;
/** Args passed to the server command. */
args?: string[];
}
// The built-in language→server mappings. Mutable so `configureLanguageSpecs` can merge in user
// overrides/additions from config (see config.ts `lspServers`). One clangd spec covers both C
// and C++ — clangd handles both, and merging avoids spawning a second clangd for a mixed C/C++
// project (two servers keyed by separate languageIds would each index the same headers twice).
let LANGUAGE_SPECS: LanguageSpec[] = [
// TypeScript / JavaScript — `typescript-language-server` wraps tsserver and speaks LSP. The most
// common local-model codebase shape, so it's the first one locode wires up.
{
languageId: "typescript",
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
command: "typescript-language-server",
args: ["--stdio"],
},
{
languageId: "python",
extensions: [".py", ".pyi"],
command: "pyright-langserver",
args: ["--stdio"],
},
{
languageId: "go",
extensions: [".go"],
command: "gopls",
args: ["serve"],
},
{
languageId: "rust",
extensions: [".rs"],
command: "rust-analyzer",
},
// C and C++ share clangd. The languageId is "c" (clangd treats .cpp/.hpp the same way);
// all C/C++ extensions route to the single clangd process.
{
languageId: "c",
extensions: [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"],
command: "clangd",
},
];
/** Merge user-configured LSP server entries (from `locode config set lspServers`) into the
* built-in specs. An entry keyed by a built-in languageId overrides that spec's command/args
* and, if `extensions` is provided, which file extensions route to it. An entry keyed by a new
* languageId (e.g. "java", "ruby") adds a brand-new mapping — it MUST supply `extensions` so
* files can be routed to it. Call once at startup; idempotent against the built-in list.
*
* Entries missing a `command` are ignored (a server we can't spawn is useless), and entries for
* new ids without `extensions` are ignored too (no way to route files to them). */
export function configureLanguageSpecs(overrides: Record<string, { command: string; args?: string[]; extensions?: string[] }>): void {
const merged: LanguageSpec[] = LANGUAGE_SPECS.map((spec) => {
const ov = overrides[spec.languageId];
if (!ov) return spec;
return {
languageId: spec.languageId,
extensions: ov.extensions ?? spec.extensions,
command: ov.command,
args: ov.args,
};
});
for (const [languageId, ov] of Object.entries(overrides)) {
if (merged.some((s) => s.languageId === languageId)) continue; // already a built-in we overrode
if (!ov.command || !ov.extensions || ov.extensions.length === 0) continue;
merged.push({ languageId, extensions: ov.extensions, command: ov.command, args: ov.args });
}
LANGUAGE_SPECS = merged;
}
/** Picks the LanguageSpec for a file path, or null if no extension matches. */
function specForFile(filePath: string): LanguageSpec | null {
const ext = path.extname(filePath).toLowerCase();
if (!ext) return null;
return LANGUAGE_SPECS.find((s) => s.extensions.includes(ext)) ?? null;
}
/** A per-workspace (cwd) registry of live LSP servers, keyed by language id. One server per
* language per cwd — a second project gets its own manager (locode is single-session-per-process
* today, but keying on cwd keeps it correct if that ever changes). */
const handles = new Map<string, LspHandle>();
/** Convert an absolute filesystem path to an LSP file:// URI string. */
function toUri(absPath: string): string {
return URI.file(absPath).toString();
}
interface LocResult {
path: string;
line: number;
column: number;
}
function toLocation(loc: Location): LocResult {
return {
path: URI.parse(loc.uri).fsPath,
line: loc.range.start.line + 1,
column: loc.range.start.character + 1,
};
}
/** Spawns the LSP server for `spec`, initializes it, and returns a live handle. Throws a clear,
* actionable error if the server binary isn't on the PATH (the most common failure) so the tool
* can surface "install typescript-language-server" instead of an opaque spawn ENOENT. */
async function startServer(spec: LanguageSpec, cwd: string): Promise<LspHandle> {
let child: ChildProcess;
try {
// npm installs global CLI packages on Windows as .cmd/.ps1 shims, not raw .exe files — spawn()
// can't resolve those without shell:true, so a genuinely-installed server would otherwise ENOENT.
child = spawn(spec.command, spec.args ?? [], {
cwd,
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
});
} catch (err) {
throw new Error(
`Could not start the LSP server "${spec.command}" for ${spec.languageId}. Is it installed and on your PATH? (${(err as Error).message})`,
);
}
// spawn() itself rarely throws synchronously — a missing binary (ENOENT) instead fires an
// async 'error' event on the child process. With no listener, that event is unhandled and
// crashes the whole process, so wait for either a successful spawn or that error before
// proceeding.
await new Promise<void>((resolve, reject) => {
child.once("spawn", () => resolve());
child.once("error", (err) => {
reject(
new Error(
`Could not start the LSP server "${spec.command}" for ${spec.languageId}. Is it installed and on your PATH? (${(err as Error).message})`,
),
);
});
});
// After startup, a late 'error' (e.g. the process dying unexpectedly) must not go unhandled
// either — the 'exit' handler below already disposes the connection, so just swallow it here.
child.on("error", () => {});
if (!child.stdin || !child.stdout) {
child.kill();
throw new Error(`LSP server "${spec.command}" did not open stdio streams.`);
}
const reader = new StreamMessageReader(child.stdout);
const writer = new StreamMessageWriter(child.stdin);
const connection = createProtocolConnection(reader, writer);
// vscode-jsonrpc buffers all messages until listen() starts pumping them — without this,
// sendRequest hangs (nothing is ever written) or throws "Call listen() first."
connection.listen();
// Surface stderr so a crashing server isn't a silent void (matches locode's MCP stdio policy).
child.stderr?.on("data", () => {
// Discard by default; a future debug mode could surface this. Don't let it back up.
});
await connection.sendRequest("initialize", {
processId: process.pid,
rootUri: URI.file(cwd).toString(),
capabilities: {
// locode consumes definition/references/diagnostics; declare only those so a server doesn't
// waste effort enabling features we'll never query. Full text sync (change=1) is simplest and
// correct — we always resend the whole file, never a range edit.
textDocumentSync: { openClose: true, change: 1 },
definitionProvider: true,
referencesProvider: true,
},
workspaceFolders: [{ uri: URI.file(cwd).toString(), name: path.basename(cwd) || cwd }],
});
// Per LSP spec, the client must send `initialized` after the initialize response.
await connection.sendNotification("initialized", {});
// A server crash should reject any in-flight request rather than hanging forever — listen for
// exit and dispose the connection so the next call throws instead of awaiting a dead process.
child.on("exit", () => {
connection.dispose();
handles.delete(`${cwd}::${spec.languageId}`);
});
return { connection, child, languageId: spec.languageId, openDocs: new Set() };
}
/** Returns the (lazily-started) LSP handle for the language owning `filePath`, or throws if no
* server is configured/can't start. The first call for a language pays the initialize round-trip;
* every later call reuses the live server. */
async function handleForFile(filePath: string, cwd: string): Promise<LspHandle> {
const spec = specForFile(filePath);
if (!spec) {
throw new Error(`No LSP server configured for "${path.extname(filePath)}" (code intelligence supports: ${LANGUAGE_SPECS.map((s) => s.extensions[0]).join(", ")}).`);
}
const key = `${cwd}::${spec.languageId}`;
let handle = handles.get(key);
if (!handle) {
handle = await startServer(spec, cwd);
handles.set(key, handle);
}
return handle;
}
/** Ensures the LSP server knows the current on-disk contents of `filePath`. Sends didOpen the
* first time a file is touched, didChange on subsequent syncs (the file was edited on disk since).
* Reads the file fresh each time — locode's tools write to disk before this runs, so the disk is
* the source of truth, not any in-memory buffer. */
async function syncDocument(handle: LspHandle, absPath: string, cwd: string): Promise<void> {
const uri = toUri(absPath);
const content = await fsReadFile(absPath, "utf-8");
if (!handle.openDocs.has(uri)) {
await handle.connection.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: { uri, languageId: handle.languageId, version: 1, text: content },
});
handle.openDocs.add(uri);
} else {
await handle.connection.sendNotification(DidChangeTextDocumentNotification.type, {
textDocument: { uri, version: Date.now() },
contentChanges: [{ text: content }],
});
}
}
export interface DefinitionResult {
/** The file/line/column of the symbol's definition. Multiple entries if the symbol has more than
* one definition (interface implementations, overloads, partial classes). Empty if the server
* found none (undefined symbol, or the server couldn't resolve it). */
definitions: LocResult[];
}
/** Resolves where the symbol at `line`/`column` (1-indexed) in `filePath` is defined. Syncs the
* document first so the server's view matches disk. Returns an empty list (not an error) when the
* server has no definition to offer — that's a legitimate "not found", not a failure. */
export async function getDefinition(
filePath: string,
line: number,
column: number,
cwd: string,
): Promise<DefinitionResult> {
const absPath = path.resolve(cwd, filePath);
const handle = await handleForFile(filePath, cwd);
await syncDocument(handle, absPath, cwd);
const pos: Position = { line: line - 1, character: column - 1 };
const result = (await handle.connection.sendRequest(DefinitionRequest.type, {
textDocument: { uri: toUri(absPath) } as TextDocumentIdentifier,
position: pos,
})) as Location | Location[] | null;
const locs = Array.isArray(result) ? result : result ? [result] : [];
return { definitions: locs.map(toLocation) };
}
export interface ReferencesResult {
/** Every place the symbol at `line`/`column` is referenced (including its definition). */
references: LocResult[];
}
/** Finds every reference to the symbol at `line`/`column` in `filePath`. `includeDeclaration`
* defaults to true (matches most IDE "find all references" behavior). */
export async function getReferences(
filePath: string,
line: number,
column: number,
cwd: string,
includeDeclaration = true,
): Promise<ReferencesResult> {
const absPath = path.resolve(cwd, filePath);
const handle = await handleForFile(filePath, cwd);
await syncDocument(handle, absPath, cwd);
const pos: Position = { line: line - 1, character: column - 1 };
const result = (await handle.connection.sendRequest(ReferencesRequest.type, {
textDocument: { uri: toUri(absPath) } as TextDocumentIdentifier,
position: pos,
context: { includeDeclaration },
})) as Location[] | null;
return { references: (result ?? []).map(toLocation) };
}
/** Notifies the LSP server that `filePath` changed on disk, so a subsequent `diagnostics` call
* reflects the new content. Called from the FileChanged hook path after edit_file/write_file. If no
* server is running for this language (or the file isn't one we manage), this is a no-op — it must
* never throw from a hook context, since hooks fire on every mutating tool. */
export async function notifyFileChanged(filePath: string, cwd: string): Promise<void> {
try {
const spec = specForFile(filePath);
if (!spec) return;
const key = `${cwd}::${spec.languageId}`;
const handle = handles.get(key);
if (!handle) return; // No server started yet — diagnostics will sync on first query.
await syncDocument(handle, path.resolve(cwd, filePath), cwd);
} catch {
// Best-effort: a hook context can't propagate errors into the turn.
}
}
type Severity = "error" | "warning" | "information" | "hint";
export interface DiagnosticsResult {
diagnostics: { path: string; line: number; column: number; severity: Severity; message: string; source?: string }[];
}
/** The most recent diagnostics the server has published for `filePath`. LSP pushes diagnostics via
* `textDocument/publishDiagnostics` notifications; locode collects them per-URI as they arrive and
* returns the latest snapshot here. Forces a document sync first so the snapshot is current. */
const diagnosticsByUri = new Map<string, Diagnostic[]>();
// Per-URI resolvers waiting on the next publishDiagnostics notification. getDiagnostics arms one
// for the file it just synced, then races it against a timeout — so a slow server (tsserver on a
// large file) still gets a chance to publish the fresh snapshot rather than the caller reading a
// stale one after a single event-loop turn. Resolved and cleared by the publishDiagnostics handler.
const diagWaiters = new Map<string, () => void>();
/** Wait for the next publishDiagnostics for `uri`, or give up after `timeoutMs`. Resolves true if
* a publish arrived, false on timeout. The waiter is removed either way. */
function waitForDiagnostics(uri: string, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
diagWaiters.delete(uri);
resolve(false);
}, timeoutMs);
diagWaiters.set(uri, () => {
clearTimeout(timer);
diagWaiters.delete(uri);
resolve(true);
});
});
}
const SEVERITY_MAP: Record<number, Severity> = {
1: "error",
2: "warning",
3: "information",
4: "hint",
};
export async function getDiagnostics(filePath: string, cwd: string): Promise<DiagnosticsResult> {
const absPath = path.resolve(cwd, filePath);
const handle = await handleForFile(filePath, cwd);
const uri = toUri(absPath);
// Attach a per-connection diagnostic collector the first time we use this handle.
if (!(handle as unknown as { __diagWired?: boolean }).__diagWired) {
(handle as unknown as { __diagWired?: boolean }).__diagWired = true;
handle.connection.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => {
diagnosticsByUri.set(params.uri, params.diagnostics);
// Wake a getDiagnostics call waiting on this URI, if any.
diagWaiters.get(params.uri)?.();
});
}
// Clear any stale snapshot for this URI before syncing so a timeout fallthrough can't return
// diagnostics from before the edit. The server publishes asynchronously after didChange; race
// its next publish against a short timeout so a slow server (tsserver on a large file) still
// gets a chance to compute fresh diagnostics rather than us reading a stale snapshot after one
// event-loop turn. Fall through to whatever's cached on timeout (possibly empty).
diagnosticsByUri.delete(uri);
await syncDocument(handle, absPath, cwd);
await waitForDiagnostics(uri, 1500);
const diags = diagnosticsByUri.get(uri) ?? [];
return {
diagnostics: diags.map((d) => ({
path: URI.parse(uri).fsPath,
line: (d.range?.start.line ?? 0) + 1,
column: (d.range?.start.character ?? 0) + 1,
severity: SEVERITY_MAP[d.severity ?? 1] ?? "information",
message: messageToString(d.message ?? ""),
source: d.source,
})),
};
}
/** Shuts down every live LSP server. Call on locode exit so spawned servers (tsserver, pyright,
* gopls, …) don't outlive the process as orphans. Awaits each shutdown so the signals land before
* teardown. Best-effort: a stuck server can't block exit forever (the child kill still fires). */
export async function shutdownAll(): Promise<void> {
const all = [...handles.values()];
handles.clear();
await Promise.allSettled(
all.map(async (h) => {
try {
await h.connection.sendRequest("shutdown", null);
h.connection.sendNotification("exit", {});
} catch {
// Already dead — fall through to kill.
}
h.child.kill();
}),
);
}
/** For tests only: clear the live-handle registry and diagnostic cache without spawning/killing. */
export function _resetForTests(): void {
handles.clear();
diagnosticsByUri.clear();
diagWaiters.clear();
}
/** For tests only: a snapshot of the currently configured language specs (after any
* configureLanguageSpecs merge), so tests can assert the merge without spawning a server. */
export function _specsForTests(): readonly LanguageSpec[] {
return LANGUAGE_SPECS;
}
// The immutable built-in spec list, kept so tests can restore LANGUAGE_SPECS to defaults after a
// configureLanguageSpecs call (the merge is forward-only by design — production applies it once).
const BUILTIN_LANGUAGE_SPECS: readonly LanguageSpec[] = [
{ languageId: "typescript", extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], command: "typescript-language-server", args: ["--stdio"] },
{ languageId: "python", extensions: [".py", ".pyi"], command: "pyright-langserver", args: ["--stdio"] },
{ languageId: "go", extensions: [".go"], command: "gopls", args: ["serve"] },
{ languageId: "rust", extensions: [".rs"], command: "rust-analyzer" },
{ languageId: "c", extensions: [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], command: "clangd" },
];
/** For tests only: restore the built-in language specs (undo any configureLanguageSpecs merge). */
export function _resetSpecsForTests(): void {
LANGUAGE_SPECS = BUILTIN_LANGUAGE_SPECS.map((s) => ({ ...s }));
}
+60 -4
View File
@@ -3,8 +3,8 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { _setConfigFilePathForTest, loadStoredConfig, saveStoredConfig } from "./store.js";
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations, resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "./config.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SUBAGENT_TIMEOUT_MS } from "./defaults.js";
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations, resolveMaxOutputTokens, resolveMaxRetries, resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "./config.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_MAX_ITERATIONS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SUBAGENT_TIMEOUT_MS } from "./defaults.js";
// Isolate the persisted config to a temp directory so the suite never reads or overwrites the
// user's real ~/.config/locode/config.json (the previous afterEach { saveStoredConfig({}) } wiped
@@ -25,6 +25,9 @@ describe("config resolution", () => {
delete process.env.LOCODE_AUTO_COMPACT_THRESHOLD;
delete process.env.LOCODE_CONTEXT_WINDOW;
delete process.env.LOCODE_MAX_ITERATIONS;
delete process.env.LOCODE_MAX_OUTPUT_TOKENS;
delete process.env.LOCODE_MAX_OUTPUT_TOKENS;
delete process.env.LOCODE_MAX_RETRIES;
delete process.env.LOCODE_REQUEST_TIMEOUT_MS;
delete process.env.LOCODE_SUBAGENT_TIMEOUT_MS;
});
@@ -50,14 +53,46 @@ describe("config resolution", () => {
expect(resolveAutoCompactThreshold()).toBe(DEFAULT_AUTO_COMPACT_THRESHOLD);
});
it("resolves context window default", () => {
expect(resolveContextWindowDefault()).toBe(DEFAULT_CONTEXT_WINDOW);
it("resolves context window default — small local model", () => {
expect(resolveContextWindowDefault("http://localhost:11434/v1", "llama3.2:latest")).toBe(DEFAULT_CONTEXT_WINDOW_LOCAL);
});
it("resolves context window default — remote cloud API", () => {
expect(resolveContextWindowDefault("https://api.openai.com/v1", "gpt-4")).toBe(DEFAULT_CONTEXT_WINDOW_CLOUD);
});
it("resolves context window default — Ollama's cloud-routed models on a local endpoint", () => {
// Regression: glm-5.2:cloud etc. run through the same localhost Ollama daemon as a genuinely
// local model, so the base URL alone can't distinguish them — see isSmallLocalModel().
expect(resolveContextWindowDefault("http://localhost:11434/v1", "glm-5.2:cloud")).toBe(DEFAULT_CONTEXT_WINDOW_CLOUD);
expect(resolveContextWindowDefault("http://localhost:11434/v1", "qwen3.5:397b-cloud")).toBe(DEFAULT_CONTEXT_WINDOW_CLOUD);
});
it("resolves max iterations default", () => {
expect(resolveMaxIterations()).toBe(DEFAULT_MAX_ITERATIONS);
});
it("resolves max output tokens default", () => {
expect(resolveMaxOutputTokens()).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
});
it("reads max output tokens from env", () => {
process.env.LOCODE_MAX_OUTPUT_TOKENS = "16384";
expect(resolveMaxOutputTokens()).toBe(16_384);
});
it("reads max output tokens from stored config", () => {
saveStoredConfig({ maxOutputTokens: 4096 });
expect(resolveMaxOutputTokens()).toBe(4096);
});
it("rejects out-of-range max output tokens", () => {
saveStoredConfig({ maxOutputTokens: 100 });
expect(resolveMaxOutputTokens()).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
process.env.LOCODE_MAX_OUTPUT_TOKENS = "5000000";
expect(resolveMaxOutputTokens()).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
});
it("resolves request timeout default", () => {
expect(resolveRequestTimeoutMs()).toBe(DEFAULT_REQUEST_TIMEOUT_MS);
});
@@ -101,4 +136,25 @@ describe("config resolution", () => {
saveStoredConfig({ subagentTimeoutMs: 500 }); // stored below floor
expect(resolveSubagentTimeoutMs()).toBe(DEFAULT_SUBAGENT_TIMEOUT_MS);
});
it("resolves max retries default", () => {
expect(resolveMaxRetries()).toBe(DEFAULT_MAX_RETRIES);
});
it("reads max retries from env", () => {
process.env.LOCODE_MAX_RETRIES = "3";
expect(resolveMaxRetries()).toBe(3);
});
it("reads max retries from stored config", () => {
saveStoredConfig({ maxRetries: 5 });
expect(resolveMaxRetries()).toBe(5);
});
it("rejects out-of-range max retries", () => {
saveStoredConfig({ maxRetries: 11 });
expect(resolveMaxRetries()).toBe(DEFAULT_MAX_RETRIES);
process.env.LOCODE_MAX_RETRIES = "-1";
expect(resolveMaxRetries()).toBe(DEFAULT_MAX_RETRIES);
});
});
+46 -5
View File
@@ -1,11 +1,15 @@
import {
DEFAULT_AUTO_COMPACT_THRESHOLD,
DEFAULT_CONTEXT_WINDOW,
DEFAULT_CONTEXT_WINDOW_LOCAL,
DEFAULT_CONTEXT_WINDOW_CLOUD,
DEFAULT_MAX_ITERATIONS,
DEFAULT_MAX_OUTPUT_TOKENS,
DEFAULT_MAX_RETRIES,
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_SUBAGENT_TIMEOUT_MS,
KNOWN_BACKENDS,
type BackendName,
isSmallLocalModel,
} from "./defaults.js";
import { loadStoredConfig } from "./store.js";
@@ -47,13 +51,28 @@ export function resolveModel(cliModel?: string): string | undefined {
return cliModel ?? process.env.LOCODE_MODEL ?? stored.model;
}
/** The fallback context window size to use when it can't be auto-detected from the backend. */
export function resolveContextWindowDefault(): number {
/** The fallback context window size to use when it can't be auto-detected from the backend. Picks a
* small-local or cloud-scale default based on isSmallLocalModel() — the backend host alone isn't
* enough, since Ollama's cloud-routed models (e.g. "glm-5.2:cloud") share a local host with
* genuinely local ones. */
export function resolveContextWindowDefault(baseURL: string, model: string): number {
const stored = loadStoredConfig();
const envValue = Number(process.env.LOCODE_CONTEXT_WINDOW);
if (Number.isFinite(envValue) && envValue > 0) return envValue;
if (typeof stored.contextWindow === "number" && stored.contextWindow > 0) return stored.contextWindow;
return DEFAULT_CONTEXT_WINDOW;
return isSmallLocalModel(baseURL, model) ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD;
}
/** Ceiling on a single response's max_tokens (see DEFAULT_MAX_OUTPUT_TOKENS), independent of the
* context window. Bounded to 256–1,000,000 to reject pathological values. */
export function resolveMaxOutputTokens(): number {
const stored = loadStoredConfig();
const envValue = Number(process.env.LOCODE_MAX_OUTPUT_TOKENS);
if (Number.isFinite(envValue) && envValue >= 256 && envValue <= 1_000_000) return envValue;
if (typeof stored.maxOutputTokens === "number" && stored.maxOutputTokens >= 256 && stored.maxOutputTokens <= 1_000_000) {
return stored.maxOutputTokens;
}
return DEFAULT_MAX_OUTPUT_TOKENS;
}
/** Max tool calls allowed per turn before locode gives up. */
@@ -88,8 +107,22 @@ export function resolveAutoCompactThreshold(): number {
return DEFAULT_AUTO_COMPACT_THRESHOLD;
}
/** Max retry attempts the OpenAI SDK makes on transient failures (connection errors, 429, 5xx)
* with exponential backoff. Bounded to 0–10 to reject pathological values. 0 = fail immediately,
* matching locode's old behavior of never retrying (a slow local backend usually means the model
* is genuinely stuck, not a transient blip — but some setups have occasional connection drops). */
export function resolveMaxRetries(): number {
const stored = loadStoredConfig();
const envValue = Number(process.env.LOCODE_MAX_RETRIES);
if (Number.isFinite(envValue) && envValue >= 0 && envValue <= 10) return envValue;
if (typeof stored.maxRetries === "number" && stored.maxRetries >= 0 && stored.maxRetries <= 10) {
return stored.maxRetries;
}
return DEFAULT_MAX_RETRIES;
}
/** Milliseconds to wait on a single chat completion request before giving up (see backend/client.ts
* for why locode doesn't retry on top of this). Bounded to 10s–30min to reject pathological values. */
* for why locode defaults to no retries). Bounded to 10s–30min to reject pathological values. */
export function resolveRequestTimeoutMs(): number {
const stored = loadStoredConfig();
const envValue = Number(process.env.LOCODE_REQUEST_TIMEOUT_MS);
@@ -99,3 +132,11 @@ export function resolveRequestTimeoutMs(): number {
}
return DEFAULT_REQUEST_TIMEOUT_MS;
}
/** User-configured LSP server overrides/additions (see StoredConfig.lspServers). An empty object
* means "use the built-in language→server mappings only". Validated loosely: entries without a
* command are dropped by configureLanguageSpecs, so we just pass them through. */
export function resolveLspServers(): Record<string, { command: string; args?: string[]; extensions?: string[] }> {
const stored = loadStoredConfig();
return stored.lspServers ?? {};
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, isCloudRoutedModelName, isLocalBackendURL, isSmallLocalModel } from "./defaults.js";
describe("isLocalBackendURL", () => {
it("recognizes localhost, 127.0.0.1, and ::1", () => {
expect(isLocalBackendURL("http://localhost:11434/v1")).toBe(true);
expect(isLocalBackendURL("http://127.0.0.1:1234/v1")).toBe(true);
expect(isLocalBackendURL("http://[::1]:11434/v1")).toBe(true);
});
it("rejects a remote host", () => {
expect(isLocalBackendURL("https://api.openai.com/v1")).toBe(false);
});
it("returns false for an unparseable URL instead of throwing", () => {
expect(isLocalBackendURL("not a url")).toBe(false);
});
});
describe("isCloudRoutedModelName", () => {
it("recognizes Ollama's cloud tag conventions", () => {
expect(isCloudRoutedModelName("glm-5.2:cloud")).toBe(true);
expect(isCloudRoutedModelName("qwen3.5:397b-cloud")).toBe(true);
expect(isCloudRoutedModelName("gpt-oss:120b-cloud")).toBe(true);
});
it("does not match a genuinely local model", () => {
expect(isCloudRoutedModelName("llama3.2:latest")).toBe(false);
expect(isCloudRoutedModelName("gemma3:4b")).toBe(false);
expect(isCloudRoutedModelName("phi4:14b")).toBe(false);
});
it("does not false-positive on 'cloud' appearing outside the tag segment", () => {
expect(isCloudRoutedModelName("cloudmodel:latest")).toBe(false);
expect(isCloudRoutedModelName("cloud")).toBe(false); // no colon at all
});
});
describe("isSmallLocalModel", () => {
it("is true for a genuinely local model on a local endpoint", () => {
expect(isSmallLocalModel("http://localhost:11434/v1", "llama3.2:latest")).toBe(true);
});
it("is false for a cloud-routed model even on a local endpoint", () => {
expect(isSmallLocalModel("http://localhost:11434/v1", "glm-5.2:cloud")).toBe(false);
});
it("is false for a remote backend regardless of model name", () => {
expect(isSmallLocalModel("https://api.openai.com/v1", "gpt-4")).toBe(false);
});
});
describe("DEFAULT_CONTEXT_WINDOW_LOCAL / CLOUD", () => {
it("LOCAL is the small-local fallback (8 192)", () => {
expect(DEFAULT_CONTEXT_WINDOW_LOCAL).toBe(8192);
});
it("CLOUD is the cloud/large fallback (131 072)", () => {
expect(DEFAULT_CONTEXT_WINDOW_CLOUD).toBe(131072);
});
});
+70 -11
View File
@@ -8,23 +8,82 @@ export const KNOWN_BACKENDS = {
export type BackendName = keyof typeof KNOWN_BACKENDS;
/** Used when the context window can't be auto-detected from the backend (see backend/contextWindow.ts)
* and the user hasn't configured one — a conservative size common among smaller local models. */
export const DEFAULT_CONTEXT_WINDOW = 8192;
/** Returns true if the given base URL looks like a local backend (Ollama or LM Studio on localhost).
* On its own this is NOT enough to tell a small local model from a large one — see
* isSmallLocalModel() below, which is what callers should actually use. */
export function isLocalBackendURL(baseURL: string): boolean {
try {
const url = new URL(baseURL);
// WHATWG URL keeps the brackets on a literal IPv6 host in .hostname (e.g. "[::1]", not "::1") —
// https://url.spec.whatwg.org/#concept-host-serializer.
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
} catch {
return false;
}
}
/** Max tool calls per turn before locode gives up rather than looping forever. 50 gives real
* multi-file tasks room to breathe (local models often issue one tool call per turn, so a
* multi-file edit + verify sequence can easily run past 25); still bounded so a genuinely stuck
* model fails fast, and hitting the cap is a soft pause, not a failure (see MaxIterationsError). */
export const DEFAULT_MAX_ITERATIONS = 50;
/** Ollama's cloud-hosted models are proxied through the same local daemon as a genuinely local
* model — same base URL, same host — so isLocalBackendURL() alone can't tell them apart. Ollama
* names them with a "cloud" tag segment instead, e.g. "glm-5.2:cloud" or "qwen3.5:397b-cloud". */
export function isCloudRoutedModelName(model: string): boolean {
const tag = model.split(":")[1] ?? "";
return tag === "cloud" || tag.endsWith("-cloud");
}
/** Whether locode should treat this model as a small/less-capable local model — extra system-prompt
* guidance about unreliable tool calls and limited output, plus a conservative context-window
* fallback — rather than a large, reliable one. True only when the backend is local AND the model
* isn't one of Ollama's cloud-routed models under that same local endpoint; a backend hosted
* elsewhere (a real remote/cloud API) is never treated as "small local" regardless of model name. */
export function isSmallLocalModel(baseURL: string, model: string): boolean {
return isLocalBackendURL(baseURL) && !isCloudRoutedModelName(model);
}
/** Fallback context window when auto-detection fails and the user hasn't configured one.
* Small local models (see isSmallLocalModel()) typically have 8k–32k context, so 8192 is a safe
* conservative default. Everything else (cloud APIs, and Ollama's own cloud-routed models) typically
* has 128k–1M+ context, so 131072 (128K) avoids severely underutilizing them. */
export const DEFAULT_CONTEXT_WINDOW_LOCAL = 8192;
export const DEFAULT_CONTEXT_WINDOW_CLOUD = 131072;
/** Ceiling on a single response's `max_tokens`, independent of the model's context window. Most
* backends cap how much a single completion can generate well below the total context window they
* advertise (e.g. Ollama's glm-5.1:cloud / glm-5.2:cloud report a 1,000,000-token context window
* but error on a request above their real 131,072-token output cap: "max_tokens (500000) exceeds
* model's maximum output tokens (131072)") — resolveMaxTokens (agent/loop.ts) used to request up to
* the whole remaining window, which such backends rejected outright as a context/length error even
* on the very first turn. 131072 (128K) matches that verified real ceiling exactly, so it's safe to
* use as the default without erroring on the very first turn — going straight to a backend's real
* cap only became reasonable once detectRepetitionLoop (agent/loop.ts) existed to abort a model
* that gets stuck generating instead of relying on this value alone as the safety valve. Raise it
* further via `locode config set maxOutputTokens` for backends known to allow more; lower it for
* ones with a smaller real ceiling. */
export const DEFAULT_MAX_OUTPUT_TOKENS = 131_072;
/** Max model requests per turn before locode pauses rather than looping forever. Each iteration
* is one model generation request (one tool-call round-trip), and local models commonly issue a
* single tool call per request — so a real multi-file task (read several files, edit each, grep
* to verify, re-read) easily needs 40–60 requests. 50 was too tight and caused frequent
* "Paused after 50 steps" soft-stops on legitimate work; 100 still caused frequent pauses on
* larger tasks. 300 gives real tasks ample room to finish while still bounding a genuinely stuck
* model. Hitting the cap is a soft pause, not a failure (the work so far is intact — send another
* message to resume). Configurable via `maxIterations`, e.g. `locode config set maxIterations 500`
* for very large batch jobs. */
export const DEFAULT_MAX_ITERATIONS = 300;
/** Fraction of the context window at which locode automatically summarizes the conversation.
* User-configurable via `locode config set autoCompactThreshold`. */
export const DEFAULT_AUTO_COMPACT_THRESHOLD = 0.85;
/** How long to wait on a single chat completion request before giving up (no retries — see
* backend/client.ts). Raise this via `requestTimeoutMs` if your backend queues requests behind a
* concurrency limit (e.g. Ollama's `OLLAMA_NUM_PARALLEL`) rather than serving them immediately. */
/** How long to wait on a single chat completion request before giving up. The OpenAI SDK retries
* transient failures (connection errors, 429, 5xx) up to `maxRetries` times with exponential
* backoff before surfacing the error; set to 0 to fail immediately like older locode versions.
* Raise this via `maxRetries` if your backend has occasional transient blips. See backend/client.ts. */
export const DEFAULT_MAX_RETRIES = 0;
/** How long to wait on a single chat completion request before giving up. Raise this via
* `requestTimeoutMs` if your backend queues requests behind a concurrency limit (e.g. Ollama's
* `OLLAMA_NUM_PARALLEL`) rather than serving them immediately. */
export const DEFAULT_REQUEST_TIMEOUT_MS = 180_000;
/** Wall-clock budget for a single sub-agent turn. Sub-agents make their own sequence of model
+31 -3
View File
@@ -16,6 +16,18 @@ export interface StoredConfig {
requestTimeoutMs?: number;
/** Milliseconds of wall-clock budget for a single sub-agent turn. */
subagentTimeoutMs?: number;
/** Ceiling on a single response's max_tokens, independent of contextWindow. */
maxOutputTokens?: number;
/** Max retry attempts the OpenAI SDK makes on transient failures (connection errors, 429, 5xx)
* before surfacing the error. 0 = fail immediately (old behavior); the SDK uses exponential
* backoff between attempts. */
maxRetries?: number;
/** User-defined LSP server overrides/additions, keyed by language id (e.g. "java", "ruby",
* "typescript"). Each entry is { command, args?, extensions? }. An entry for a built-in id
* overrides its command/args; an entry with `extensions` also rewrites which file extensions
* route to that language. Entries for new ids add support for languages locode doesn't ship a
* server for. See `locode config set lspServers` (JSON value). */
lspServers?: Record<string, { command: string; args?: string[]; extensions?: string[] }>;
}
const paths = envPaths("locode", { suffix: "" });
@@ -32,20 +44,36 @@ export function configFilePath(): string {
/** @internal For tests only — redirect config persistence to `path` (pass undefined to reset). */
export function _setConfigFilePathForTest(p: string | undefined): void {
configFileOverride = p;
invalidateConfigCache();
}
// In-memory cache so every resolve*() call in the same process doesn't re-read and re-parse
// the same small JSON file (8–10 calls during startup alone). Invalidated by saveStoredConfig
// and by tests that change the config path.
let cachedConfig: StoredConfig | undefined;
export function loadStoredConfig(): StoredConfig {
if (cachedConfig !== undefined) return cachedConfig;
const file = configFilePath();
if (!existsSync(file)) return {};
if (!existsSync(file)) { cachedConfig = {}; return cachedConfig; }
try {
return JSON.parse(readFileSync(file, "utf-8")) as StoredConfig;
cachedConfig = JSON.parse(readFileSync(file, "utf-8")) as StoredConfig;
return cachedConfig;
} catch {
return {};
cachedConfig = {};
return cachedConfig;
}
}
/** Invalidate the in-memory cache — called by saveStoredConfig and by tests that change
* the config file path. */
export function invalidateConfigCache(): void {
cachedConfig = undefined;
}
export function saveStoredConfig(cfg: StoredConfig): void {
const file = configFilePath();
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(cfg, null, 2));
cachedConfig = cfg;
}
+14 -3
View File
@@ -91,11 +91,22 @@ describe("runHooksForEvent", () => {
expect(result.additionalContext).toBeUndefined();
});
it("warns for unsupported prompt hooks", async () => {
it("injects a prompt hook's message as additional context", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "prompt", message: "ok?" } as any] }],
SessionStart: [{ hooks: [{ type: "prompt", message: "Remember to check the changelog." }] }],
});
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.warnings).toContain("1 prompt hook(s) skipped (not yet implemented).");
expect(result.additionalContext).toBe("Remember to check the changelog.");
expect(result.warnings).toEqual([]);
});
it("combines a prompt hook's message with a command hook's stdout", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "prompt", message: "prompt message" }, { type: "command", command: "echo cmd" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "cmd output", stderr: "", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.additionalContext).toContain("prompt message");
expect(result.additionalContext).toContain("cmd output");
});
});
+17 -11
View File
@@ -2,7 +2,7 @@ import { execa } from "execa";
import path from "node:path";
import { loadMergedHooks } from "./config.js";
import { matcherMatches } from "./matcher.js";
import type { Hook, HookCommand, HookEventName, HookHttp } from "./types.js";
import type { Hook, HookCommand, HookEventName, HookHttp, HookPrompt } from "./types.js";
const DEFAULT_TIMEOUT_SECONDS = 30;
@@ -37,6 +37,20 @@ function isHttpHook(hook: Hook): hook is HookHttp {
return hook.type === "http";
}
function isPromptHook(hook: Hook): hook is HookPrompt {
return hook.type === "prompt";
}
/** A prompt hook has no process/response to run — it's just a static message that always
* "succeeds" and folds into additionalContext the same way a command/http hook's plain-text
* stdout does (see the outcome-handling loop in runHooksForEvent). Wrapped in a resolved promise
* so it can share the same Promise.all as the other hook kinds. */
async function runPromptHook(
hook: HookPrompt,
): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean; json?: unknown; warning?: string }> {
return { exitCode: 0, stdout: hook.message, stderr: "", timedOut: false };
}
function parseOutput(output: string, schema?: "json"): { text?: string; json?: unknown; warning?: string } {
if (!schema) return { text: output };
if (schema !== "json") return { text: output };
@@ -195,26 +209,18 @@ export async function runHooksForEvent(
.flatMap((entry) => entry.hooks);
if (hooks.length === 0) return EMPTY_RESULT;
// Prompt hooks are declared in the type system but not implemented in this pass — they need a
// blocking UI flow the current runner doesn't have. Skip them with a warning so a config that
// includes them doesn't silently do nothing.
const skippedPrompts = hooks.filter((h) => h.type === "prompt").length;
const runnableHooks = hooks.filter((hook) => hook.type !== "prompt");
const stdinPayload = JSON.stringify({ hook_event_name: event, session_id: ctx.sessionId, cwd: ctx.cwd, ...payload });
const outcomes = await Promise.all(
runnableHooks.map(async (hook) => {
hooks.map(async (hook) => {
if (isCommandHook(hook)) return runCommandHook(hook, stdinPayload, ctx);
if (isHttpHook(hook)) return runHttpHook(hook, stdinPayload, ctx);
if (isPromptHook(hook)) return runPromptHook(hook);
return { exitCode: 1, stdout: "", stderr: `Unsupported hook type: ${(hook as Hook).type}`, timedOut: false };
}),
);
const result: HookResult = { blocked: false, warnings: [] };
if (skippedPrompts > 0) {
result.warnings.push(`${skippedPrompts} prompt hook(s) skipped (not yet implemented).`);
}
for (const outcome of outcomes) {
if (outcome.warning) {
result.warnings.push(outcome.warning);
+3 -1
View File
@@ -40,8 +40,10 @@ export interface HookHttp extends HookBase {
export interface HookPrompt extends HookBase {
type: "prompt";
/** Injected verbatim as additional context, the same way a command/http hook's stdout is —
* see runner.ts. Always "succeeds" (there's no process/response to fail); a prompt hook can't
* block an event the way a command hook's exit code 2 can. */
message: string;
/** Not yet implemented — prompt hooks require a UI blocking flow the current runner doesn't support. */
}
export type Hook = HookCommand | HookHttp | HookPrompt;
+48 -24
View File
@@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { isHttpServerConfig, type McpServerConfig } from "./types.js";
import pkg from "../../package.json" with { type: "json" };
export interface McpTextContentBlock {
type: "text";
@@ -54,30 +55,53 @@ export interface ConnectedMcpServer {
tools: McpToolInfo[];
}
export async function connectMcpServer(name: string, config: McpServerConfig): Promise<ConnectedMcpServer> {
const transport: Transport = isHttpServerConfig(config)
? new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers ? { headers: config.headers } : undefined,
})
: new StdioClientTransport({
command: config.command,
args: config.args,
env: config.env,
// Default is "inherit", which would leak the child's stderr straight into the terminal
// and corrupt Ink's alternate-screen UI. Pipe it instead so it's just discarded.
stderr: "pipe",
});
/** Max connection attempts for an MCP server that fails transiently (process slow to start,
* HTTP 503, etc.). A stdio server whose command genuinely doesn't exist fails immediately every
* time, so retries only help the transient case — kept small to avoid stalling startup. */
const MCP_CONNECT_MAX_ATTEMPTS = 3;
const MCP_CONNECT_BASE_DELAY_MS = 500;
const client = new Client({ name: "locode", version: "0.3.1" });
await client.connect(transport);
try {
const { tools } = await client.listTools();
return { name, client, transport, tools: tools as McpToolInfo[] };
} catch (err) {
// listTools failed (server connected but never responded to the listing) — close the
// transport so the stdio subprocess / HTTP connection isn't orphaned. manager.ts catches
// the rejection as an error status, but without this the child process keeps running.
await transport.close().catch(() => {});
throw err;
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")); }, { once: true });
});
}
export async function connectMcpServer(name: string, config: McpServerConfig): Promise<ConnectedMcpServer> {
let lastErr: unknown;
for (let attempt = 1; attempt <= MCP_CONNECT_MAX_ATTEMPTS; attempt++) {
const transport: Transport = isHttpServerConfig(config)
? new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers ? { headers: config.headers } : undefined,
})
: new StdioClientTransport({
command: config.command,
args: config.args,
env: config.env,
// Default is "inherit", which would leak the child's stderr straight into the terminal
// and corrupt Ink's alternate-screen UI. Pipe it instead so it's just discarded.
stderr: "pipe",
});
const client = new Client({ name: "locode", version: pkg.version });
try {
await client.connect(transport);
const { tools } = await client.listTools();
return { name, client, transport, tools: tools as McpToolInfo[] };
} catch (err) {
// listTools failed or connect failed — close the transport so the stdio subprocess / HTTP
// connection isn't orphaned. manager.ts catches the rejection as an error status, but
// without this the child process keeps running.
await transport.close().catch(() => {});
lastErr = err;
// Retry with exponential backoff for transient failures; the last attempt's error is what
// the caller sees. A genuinely broken config (missing binary, bad URL) fails fast every time,
// so the retries just add a small delay — acceptable for the rare transient-startup case.
if (attempt < MCP_CONNECT_MAX_ATTEMPTS) {
await sleep(MCP_CONNECT_BASE_DELAY_MS * Math.pow(2, attempt - 1));
}
}
}
throw lastErr;
}
+9
View File
@@ -76,3 +76,12 @@ export async function disconnectAllMcpServers(): Promise<void> {
await Promise.allSettled(connections.map((c) => c.transport.close()));
connections = [];
}
/** Re-connects to every configured MCP server (e.g. after the user edits `.mcp.json` or restarts a
* server process), swapping in the new connections and returning the fresh tool list so the caller
* can rebuild the session's toolset. Equivalent to a fresh `connectConfiguredMcpServers` call,
* exposed separately so callers can name the intent ("reconnect") without implying the first-run
* setup path. */
export async function reconnectMcpServers(cwd: string): Promise<ToolDef[]> {
return connectConfiguredMcpServers(cwd);
}
+6 -4
View File
@@ -15,10 +15,12 @@ export class PermissionManager {
/** Check whether a mutating tool should be auto-approved (no confirmation needed). */
isAutoApproved(toolName: string): boolean {
// auto-accept only covers the same file-edit tools as auto-edit, not arbitrary mutating tools
// such as bash or git_commit. This prevents a user who intended "approve edits" from silently
// approving every dangerous operation.
if (this.mode === "auto-accept" && AUTO_EDIT_TOOLS.has(toolName)) return true;
// auto-accept approves EVERY mutating tool (bash, git_commit, write_file, edit_file, ...) —
// the "I trust everything, don't ask" mode. auto-edit is the narrower "approve file edits only"
// mode, auto-approving just the file-edit tools so a user can batch-edit without approving each
// one but still gate dangerous shell/git operations. default falls back to the session-allowed
// list (tools the user approved "for this session" in a prior prompt).
if (this.mode === "auto-accept") return true;
if (this.mode === "auto-edit" && AUTO_EDIT_TOOLS.has(toolName)) return true;
// "default" — check session-allowed list
return this.allowedForSession.has(toolName);
+87
View File
@@ -0,0 +1,87 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { exportSession, sessionToJson, sessionToMarkdown, defaultExportFilename } from "./exportSession.js";
const meta = { model: "test-model", createdAt: "2026-01-01T00:00:00.000Z" };
const messages: ChatCompletionMessageParam[] = [
{ role: "user", content: "hello" },
{ role: "assistant", content: "let me check", tool_calls: [{ id: "call_1", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }] },
{ role: "tool", tool_call_id: "call_1", content: "file contents" },
{ role: "assistant", content: "done" },
];
describe("sessionToMarkdown (full transcript)", () => {
it("includes user text, assistant text, tool calls, and tool results", () => {
const md = sessionToMarkdown(messages, meta);
expect(md).toContain("### You");
expect(md).toContain("hello");
expect(md).toContain("let me check");
expect(md).toContain("#### Tool calls");
expect(md).toContain('"name": "read_file"');
expect(md).toContain("#### Tool result");
expect(md).toContain("file contents");
expect(md).toContain("done");
});
it("does not drop a tool-only assistant turn (no text)", () => {
const md = sessionToMarkdown(
[{ role: "assistant", content: null, tool_calls: [{ id: "c", type: "function", function: { name: "grep", arguments: "{}" } }] } as ChatCompletionMessageParam],
meta,
);
expect(md).toContain("#### Tool calls");
expect(md).toContain('"name": "grep"');
});
});
describe("sessionToJson", () => {
it("produces a JSON object with meta, exportedAt, and the verbatim messages", () => {
const json = sessionToJson(messages, meta);
const parsed = JSON.parse(json);
expect(parsed.model).toBe("test-model");
expect(parsed.exportedAt).toBeTruthy();
expect(parsed.messages).toHaveLength(4);
expect(parsed.messages[1].tool_calls[0].function.name).toBe("read_file");
});
});
describe("defaultExportFilename", () => {
it("defaults to a .md extension", () => {
expect(defaultExportFilename()).toMatch(/\.md$/);
});
it("uses .json for the json format", () => {
expect(defaultExportFilename("json")).toMatch(/\.json$/);
});
});
describe("exportSession", () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-export-"));
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it("writes a markdown file by default", async () => {
const resolved = await exportSession(messages, meta, cwd, "out.md");
const content = readFileSync(resolved, "utf-8");
expect(content).toContain("# locode conversation");
expect(content).toContain("hello");
});
it("writes a JSON file when format is json", async () => {
const resolved = await exportSession(messages, meta, cwd, "out.json", "json");
const content = readFileSync(resolved, "utf-8");
const parsed = JSON.parse(content);
expect(parsed.messages).toHaveLength(4);
});
it("auto-generates a filename with the right extension when none given", async () => {
const resolved = await exportSession(messages, meta, cwd, undefined, "json");
expect(resolved).toMatch(/\.json$/);
});
});
+59 -18
View File
@@ -3,15 +3,41 @@ import path from "node:path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
/** Mirrors the filtering used when replaying a resumed session (see App.tsx initSessionFromRecord):
* only plain user/assistant text turns are human-readable — raw tool-call/tool-result payloads
* and fallback-mode `tool_result` blocks are internal bookkeeping, not conversation content. */
/** Renders one message as a markdown section for a FULL transcript export — including tool
* calls and their results, which the old prose-only export dropped. A tool-call assistant turn
* lists each call as a fenced JSON block; a tool-result message is rendered as a fenced result.
* Multimodal user content (text + image parts) is reduced to its text parts plus an
* `[image attached]` placeholder. Returns null only for genuinely empty turns. */
function messageSection(m: ChatCompletionMessageParam): string | null {
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
return `### You\n\n${m.content}`;
if (m.role === "user") {
if (typeof m.content === "string") {
if (m.content.startsWith("```tool_result")) {
// A fallback-mode tool result block — render it verbatim under a Tool result heading.
return `#### Tool result\n\n${m.content}`;
}
return `### You\n\n${m.content}`;
}
if (Array.isArray(m.content)) {
const parts = m.content.map((p) => (p.type === "text" ? p.text : "[image attached]")).join("\n");
return parts.trim() ? `### You\n\n${parts}` : null;
}
return null;
}
if (m.role === "assistant" && typeof m.content === "string" && m.content) {
return `### Assistant\n\n${m.content}`;
if (m.role === "assistant") {
const text = typeof m.content === "string" ? m.content : "";
const calls = (m as { tool_calls?: { id: string; function: { name: string; arguments: string } }[] }).tool_calls;
const parts: string[] = [];
if (text.trim()) parts.push(`### Assistant\n\n${text}`);
if (calls && calls.length) {
const block = calls.map((c) => `{"name": "${c.function.name}", "arguments": ${c.function.arguments}}`).join("\n");
parts.push(`#### Tool calls\n\n` + "```json\n" + block + "\n```");
}
return parts.length ? parts.join("\n\n") : null;
}
if (m.role === "tool") {
const tm = m as { content?: string; tool_call_id?: string };
const body = typeof tm.content === "string" ? tm.content : JSON.stringify(tm.content);
return `#### Tool result${tm.tool_call_id ? ` (${tm.tool_call_id})` : ""}\n\n` + "```\n" + body + "\n```";
}
return null;
}
@@ -21,6 +47,8 @@ export interface ExportMeta {
createdAt: string;
}
export type ExportFormat = "markdown" | "json";
export function sessionToMarkdown(messages: ChatCompletionMessageParam[], meta: ExportMeta): string {
const header = [
"# locode conversation",
@@ -33,22 +61,35 @@ export function sessionToMarkdown(messages: ChatCompletionMessageParam[], meta:
return [header, ...sections].join("\n\n");
}
export function defaultExportFilename(): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `locode-export-${stamp}.md`;
/** A JSON export is the full record (messages verbatim + metadata), suitable for cross-machine
* replay/sharing or feeding into another tool. The markdown export is for humans. */
export function sessionToJson(messages: ChatCompletionMessageParam[], meta: ExportMeta): string {
return JSON.stringify({ ...meta, exportedAt: new Date().toISOString(), messages }, null, 2);
}
/** Writes the conversation to a markdown file and returns the resolved absolute path.
* `target` may be a bare filename, a relative path, or an absolute path; a bare directory
* (or nothing at all) falls back to an auto-generated filename inside `cwd`. Written atomically
export function defaultExportFilename(format: ExportFormat = "markdown"): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `locode-export-${stamp}.${format === "json" ? "json" : "md"}`;
}
/** Writes the conversation to a file and returns the resolved absolute path. `target` may be a bare
* filename, a relative path, or an absolute path; a bare directory (or nothing at all) falls back
* to an auto-generated filename inside `cwd`. `format` selects a human markdown transcript
* (default, now including tool calls/results) or a machine-readable JSON dump. Written atomically
* (temp file + rename), matching sessionStore's saves, so a crash mid-export can't leave a
* truncated file. */
export async function exportSession(messages: ChatCompletionMessageParam[], meta: ExportMeta, cwd: string, target?: string): Promise<string> {
const filename = target?.trim() || defaultExportFilename();
export async function exportSession(
messages: ChatCompletionMessageParam[],
meta: ExportMeta,
cwd: string,
target?: string,
format: ExportFormat = "markdown",
): Promise<string> {
const filename = target?.trim() || defaultExportFilename(format);
let resolved = path.isAbsolute(filename) ? filename : path.resolve(cwd, filename);
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
resolved = path.join(resolved, defaultExportFilename());
resolved = path.join(resolved, defaultExportFilename(format));
}
await writeFileAtomic(resolved, sessionToMarkdown(messages, meta));
await writeFileAtomic(resolved, format === "json" ? sessionToJson(messages, meta) : sessionToMarkdown(messages, meta));
return resolved;
}
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { buildReplayHistory } from "./replayHistory.js";
describe("buildReplayHistory", () => {
it("replays plain user/assistant text turns", () => {
const messages: ChatCompletionMessageParam[] = [
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello there" },
];
expect(buildReplayHistory(messages)).toMatchObject([
{ kind: "user", text: "hi" },
{ kind: "assistant", text: "hello there" },
]);
});
it("replays native-mode tool calls and results, correlating the result's name via tool_call_id", () => {
const messages: ChatCompletionMessageParam[] = [
{ role: "user", content: "read foo.txt" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_1", type: "function", function: { name: "read_file", arguments: JSON.stringify({ path: "foo.txt" }) } },
],
},
{ role: "tool", tool_call_id: "call_1", content: JSON.stringify({ totalLines: 3 }) },
{ role: "assistant", content: "It has 3 lines." },
];
expect(buildReplayHistory(messages)).toMatchObject([
{ kind: "user", text: "read foo.txt" },
{ kind: "tool_call", label: "Read(foo.txt)" },
{ kind: "tool_result", summary: "Read 3 lines", isError: false },
{ kind: "assistant", text: "It has 3 lines." },
]);
});
it("marks a native-mode tool error result", () => {
const messages: ChatCompletionMessageParam[] = [
{
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "bash", arguments: "{}" } }],
},
{ role: "tool", tool_call_id: "call_1", content: JSON.stringify({ error: "command not found" }) },
];
expect(buildReplayHistory(messages)).toMatchObject([
{ kind: "tool_call", label: "Bash()" },
{ kind: "tool_result", summary: "command not found", isError: true },
]);
});
it("replays fallback-mode tool_call blocks embedded in assistant text", () => {
const messages: ChatCompletionMessageParam[] = [
{
role: "assistant",
content: 'Let me check.\n```tool_call\n{"name":"read_file","arguments":{"path":"foo.txt"}}\n```',
},
{
role: "user",
content: '```tool_result\n{"name":"read_file","result":{"totalLines":3}}\n```',
},
];
expect(buildReplayHistory(messages)).toMatchObject([
{ kind: "assistant", text: "Let me check." },
{ kind: "tool_call", label: "Read(foo.txt)" },
{ kind: "tool_result", summary: "Read 3 lines", isError: false },
]);
});
it("drops the empty assistant text bubble when a native tool call has no accompanying prose", () => {
const messages: ChatCompletionMessageParam[] = [
{
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "grep", arguments: "{}" } }],
},
];
expect(buildReplayHistory(messages)).toMatchObject([{ kind: "tool_call", label: "Grep()" }]);
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
import { nextId, type HistoryItem, type NewHistoryItem } from "../ui/ink/types.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
const TOOL_CALL_BLOCK_RE = /```tool_call\s*[\s\S]*?```/g;
const TOOL_RESULT_BLOCK_RE = /^```tool_result\n([\s\S]*?)\n```$/;
/** A message's `content` can be a plain string or an array of content parts (text/image_url) —
* see pushToolResultMessage in agent/loop.ts. Only the text part matters for replay display. */
function textContent(content: ChatCompletionMessageParam["content"]): string | null {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const part = content.find((p): p is { type: "text"; text: string } => (p as { type?: string }).type === "text");
return part?.text ?? null;
}
return null;
}
function isErrorResult(result: unknown): boolean {
return !!(result && typeof result === "object" && "error" in (result as object));
}
/** Rebuilds the tool_call/tool_result HistoryItems a resumed session's transcript is otherwise
* missing (see App.tsx initSessionFromRecord) — the persisted record (SessionRecord.messages) is
* the raw OpenAI-shape history, which carries everything needed (tool name, arguments, result)
* even though it was never saved as a pre-rendered display label. Handles both native mode
* (assistant `tool_calls` + matching `tool`-role messages, correlated by `tool_call_id`) and
* fallback mode (` ```tool_call``` ` blocks embedded in assistant text, ` ```tool_result``` `
* blocks embedded in user text — see fallbackParser.ts and pushToolResultMessage). */
export function buildReplayHistory(messages: ChatCompletionMessageParam[]): HistoryItem[] {
const items: HistoryItem[] = [];
// Native mode: `tool` messages only carry a tool_call_id, not the tool's name — remember the
// name from the assistant message that made the call so its later result can be labeled.
const pendingCallNames = new Map<string, string>();
const push = (item: NewHistoryItem) => items.push({ id: nextId(), ...item } as HistoryItem);
for (const m of messages) {
if (m.role === "user") {
const text = textContent(m.content);
if (text === null) continue;
const fallbackResult = TOOL_RESULT_BLOCK_RE.exec(text.trim());
if (fallbackResult) {
try {
const { name, result } = JSON.parse(fallbackResult[1]!) as { name: string; result: unknown };
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
} catch {
// Malformed persisted block (shouldn't happen since we wrote it) — drop rather than
// show the raw JSON to the user.
}
continue;
}
if (text) push({ kind: "user", text });
continue;
}
if (m.role === "assistant") {
const toolCalls = m.tool_calls;
if (toolCalls?.length) {
const text = textContent(m.content);
if (text) push({ kind: "assistant", text });
for (const call of toolCalls) {
if (call.type !== "function") continue;
let args: unknown = {};
try {
args = JSON.parse(call.function.arguments);
} catch {
// Leave args as {} — formatCallLabel degrades gracefully for a missing field.
}
pendingCallNames.set(call.id, call.function.name);
push({ kind: "tool_call", label: formatCallLabel(call.function.name, args) });
}
continue;
}
const text = textContent(m.content);
if (!text) continue;
const parsed = parseFallbackToolCalls(text);
if (parsed.calls.length) {
const stripped = text.replace(TOOL_CALL_BLOCK_RE, "").trim();
if (stripped) push({ kind: "assistant", text: stripped });
for (const call of parsed.calls) {
push({ kind: "tool_call", label: formatCallLabel(call.name, call.arguments) });
}
} else {
push({ kind: "assistant", text });
}
continue;
}
if (m.role === "tool") {
const name = pendingCallNames.get(m.tool_call_id) ?? "unknown";
const text = textContent(m.content);
let result: unknown = text;
if (text) {
try {
result = JSON.parse(text);
} catch {
result = text;
}
}
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
}
}
return items;
}
+36 -2
View File
@@ -1,6 +1,14 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
// unlinkSync is mocked (default: pass-through to the real implementation) only so the "can't
// actually delete the file" test below can make a single call fail — every other test's calls to
// unlinkSync still hit the real filesystem via this same mock.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return { ...actual, unlinkSync: vi.fn(actual.unlinkSync) };
});
import envPaths from "env-paths";
import {
deleteSession,
@@ -70,6 +78,32 @@ describe("sessionStore", () => {
expect(listSessions()).toHaveLength(0);
});
it("rebuilds the summary index from disk when no index file exists yet", () => {
writeFileSync(path.join(dir, "manual-session.json"), JSON.stringify(makeRecord("manual-session")));
expect(listSessions()).toHaveLength(1);
expect(listSessions()[0]?.id).toBe("manual-session");
});
it("reports failure (not success) when the file can't actually be deleted", async () => {
// Regression: a real unlink failure (e.g. Windows EPERM/EBUSY from a file lock) used to still
// return true and drop the entry from the index — reporting success while orphaning the file
// on disk with no way to reference it again.
await saveSession(makeRecord("locked-session"));
vi.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" });
});
expect(deleteSession("locked-session")).toBe(false);
expect(loadSession("locked-session")?.id).toBe("locked-session");
expect(listSessions()).toHaveLength(1);
});
it("self-heals when a session file is deleted outside of deleteSession()", async () => {
await saveSession(makeRecord("will-vanish"));
expect(listSessions()).toHaveLength(1);
unlinkSync(path.join(dir, "will-vanish.json"));
expect(listSessions()).toHaveLength(0);
});
it("deriveTitle extracts the first user message", () => {
const title = deriveTitle([
{ role: "system", content: "sys" },
+118 -22
View File
@@ -1,8 +1,9 @@
import envPaths from "env-paths";
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import type { TaskStoreSnapshot } from "../tools/task.js";
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
@@ -16,6 +17,10 @@ export interface SessionRecord {
model: string;
mode: ToolCallMode;
messages: ChatCompletionMessageParam[];
/** Tool names the user approved "for this session" — preserved across resume. */
allowedTools?: string[];
/** Persisted task store snapshot so tasks survive session resume. */
tasks?: TaskStoreSnapshot;
}
export interface SessionSummary {
@@ -45,6 +50,92 @@ function filePath(id: string): string {
return path.join(dir, `${safeSessionId(id)}.json`);
}
const INDEX_FILENAME = "_index.json";
function indexFilePath(): string {
return path.join(dir, INDEX_FILENAME);
}
function summarize(record: SessionRecord): SessionSummary {
return {
id: record.id,
updatedAt: record.updatedAt,
title: deriveTitle(record.messages),
model: record.model,
baseURL: record.baseURL,
messageCount: record.messages.length,
};
}
/** Reads the on-disk summary index and reconciles it against the actual session files, so a stale or
* missing index self-heals instead of ever going wrong: entries whose file was deleted (by this or
* another locode process) are dropped, and files present on disk but missing from the index (a fresh
* install, a crash before the last index write, another process's save racing this one) are parsed
* individually. This keeps the common case to O(session count) stat calls instead of O(total
* transcript bytes) — listSessions() used to JSON.parse every saved session in full just to read 6
* summary fields off each one. */
function readIndex(): Map<string, SessionSummary> {
let index = new Map<string, SessionSummary>();
if (existsSync(indexFilePath())) {
try {
const entries = JSON.parse(readFileSync(indexFilePath(), "utf-8")) as SessionSummary[];
index = new Map(entries.map((e) => [e.id, e]));
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[sessionStore] failed to parse index file, rebuilding:", err);
index = new Map();
}
}
for (const id of index.keys()) {
if (!existsSync(filePath(id))) index.delete(id);
}
const known = new Set([...index.keys()].map((id) => safeSessionId(id)));
if (existsSync(dir)) {
for (const entry of readdirSync(dir)) {
if (!entry.endsWith(".json") || entry === INDEX_FILENAME) continue;
const stem = entry.slice(0, -".json".length);
if (known.has(stem)) continue;
try {
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
index.set(record.id, summarize(record));
} catch (err) {
// Skip corrupt/partial session files — but log so disk issues aren't silent.
// eslint-disable-next-line no-console
console.warn(`[sessionStore] skipping corrupt session file ${entry}:`, err);
}
}
}
return index;
}
/** Best-effort atomic write of the index. A failed write just means the next readIndex() call
* re-parses whatever files it doesn't recognize yet — never incorrect data, only a missed
* optimization. */
function writeIndex(index: Map<string, SessionSummary>): void {
const tmp = `${indexFilePath()}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(tmp, JSON.stringify([...index.values()]));
renameSync(tmp, indexFilePath());
} catch (err) {
try {
unlinkSync(tmp);
} catch {
// tmp may not have been created if the write itself failed
}
// eslint-disable-next-line no-console
console.warn("[sessionStore] failed to write session index:", err);
}
}
/** Updates one entry in the persisted index. Two saves for different sessions racing this can lose
* one's index write, but never lose data: readIndex() picks up any on-disk session file it doesn't
* recognize, so the loser just costs the next listSessions() call one extra parse. */
function updateIndexEntry(record: SessionRecord): void {
const index = readIndex();
index.set(record.id, summarize(record));
writeIndex(index);
}
export function deriveTitle(messages: ChatCompletionMessageParam[]): string {
const first = messages.find((m) => m.role === "user");
const text = first && typeof first.content === "string" ? first.content.trim() : "";
@@ -63,7 +154,8 @@ const saveQueues = new Map<string, Promise<void>>();
* otherwise load as "no session found" (see loadSession, which treats an unparseable file as absent). */
export async function saveSession(record: SessionRecord): Promise<void> {
const file = filePath(record.id);
const write = () => writeFileAtomic(file, JSON.stringify(record, null, 2));
const write = () =>
writeFileAtomic(file, JSON.stringify(record, null, 2)).then(() => updateIndexEntry(record));
// Run whether or not the previous save rejected, so one failure can't stall the chain.
const prev = saveQueues.get(record.id);
const next = (prev ?? Promise.resolve()).then(write, write);
@@ -85,31 +177,19 @@ export function loadSession(id: string): SessionRecord | undefined {
if (!existsSync(file)) return undefined;
try {
return JSON.parse(readFileSync(file, "utf-8")) as SessionRecord;
} catch {
} catch (err) {
// Corrupt or unreadable session file — treat as absent, but log so disk issues
// aren't completely silent. Callers can't distinguish "no file" from "corrupt file",
// but at least the log preserves the reason.
// eslint-disable-next-line no-console
console.warn(`[sessionStore] failed to load session ${id}, treating as absent:`, err);
return undefined;
}
}
export function listSessions(): SessionSummary[] {
if (!existsSync(dir)) return [];
const summaries: SessionSummary[] = [];
for (const entry of readdirSync(dir)) {
if (!entry.endsWith(".json")) continue;
try {
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
summaries.push({
id: record.id,
updatedAt: record.updatedAt,
title: deriveTitle(record.messages),
model: record.model,
baseURL: record.baseURL,
messageCount: record.messages.length,
});
} catch {
// Skip corrupt/partial session files
}
}
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return [...readIndex().values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function mostRecentSessionId(): string | undefined {
@@ -119,6 +199,22 @@ export function mostRecentSessionId(): string | undefined {
export function deleteSession(id: string): boolean {
const file = filePath(id);
if (!existsSync(file)) return false;
unlinkSync(file);
try {
unlinkSync(file);
} catch (err) {
// ENOENT is harmless (race with another process) — the end state (file gone) is what we
// wanted anyway, so fall through and report success. Any other error (e.g. EPERM/EBUSY from
// a file lock, common on Windows) means the file is still on disk — report failure and leave
// the index entry alone, or listSessions()/resume would silently orphan a file no one could
// reference again (removed from the index, but never actually deleted).
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
// eslint-disable-next-line no-console
console.warn(`[sessionStore] failed to delete session file ${file}:`, err);
return false;
}
}
const index = readIndex();
index.delete(id);
writeIndex(index);
return true;
}
+17
View File
@@ -46,4 +46,21 @@ describe("loadPlugin", () => {
expect(plugin.agents).toHaveLength(1);
expect(plugin.agents[0]).toMatchObject({ name: "review", description: "Code reviewer" });
});
it("parses a command's allowed-tools frontmatter, translating Claude Code tool names", () => {
mkdirSync(path.join(tempDir, ".claude-plugin"), { recursive: true });
writeFileSync(path.join(tempDir, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "test-plugin" }));
mkdirSync(path.join(tempDir, "commands"), { recursive: true });
writeFileSync(
path.join(tempDir, "commands", "readonly.md"),
"---\ndescription: Look but don't touch\nallowed-tools: Read, Grep\n---\nInvestigate $ARGUMENTS",
);
writeFileSync(path.join(tempDir, "commands", "unrestricted.md"), "---\ndescription: No restriction\n---\nDo $ARGUMENTS");
const plugin = loadPlugin(tempDir);
const readonly = plugin.commands.find((c) => c.name === "readonly");
const unrestricted = plugin.commands.find((c) => c.name === "unrestricted");
expect(readonly?.allowedTools).toEqual(["read_file", "grep"]);
expect(unrestricted?.allowedTools).toBeUndefined();
});
});
+13 -5
View File
@@ -20,6 +20,17 @@ function listMarkdownFiles(dir: string): string[] {
return readdirSync(dir).filter((f) => f.endsWith(".md"));
}
/** Parses a comma-separated tool-name list from frontmatter (agents' `tools`, commands'
* `allowed-tools`) and translates each from Claude Code's built-in names to locode's. Returns
* undefined for an absent/empty field so callers can treat that as "no restriction". */
function parseToolList(field: string | undefined): string[] | undefined {
const tools = field
?.split(",")
.map((t) => resolveToolName(t.trim()))
.filter(Boolean);
return tools?.length ? tools : undefined;
}
function loadCommands(pluginRoot: string, pluginName: string): PluginCommand[] {
const dir = path.join(pluginRoot, "commands");
return listMarkdownFiles(dir).map((entry) => {
@@ -30,6 +41,7 @@ function loadCommands(pluginRoot: string, pluginName: string): PluginCommand[] {
description: frontmatter.description,
argumentHint: frontmatter["argument-hint"],
template: body,
allowedTools: parseToolList(frontmatter["allowed-tools"]),
};
});
}
@@ -39,15 +51,11 @@ function loadAgents(pluginRoot: string, pluginName: string): PluginAgentDef[] {
return listMarkdownFiles(dir).map((entry) => {
const { frontmatter, body } = parseFrontmatter(readFileSync(path.join(dir, entry), "utf-8"));
const name = frontmatter.name || entry.replace(/\.md$/, "");
const tools = frontmatter.tools
?.split(",")
.map((t) => resolveToolName(t.trim()))
.filter(Boolean);
return {
pluginName,
name,
description: frontmatter.description || name,
tools: tools?.length ? tools : undefined,
tools: parseToolList(frontmatter.tools),
systemPrompt: body,
};
});
+4
View File
@@ -16,6 +16,10 @@ export interface PluginCommand {
/** The markdown body — expanded via $ARGUMENTS/$1../$9 (see expandTemplate.ts) and submitted
* as the turn's input when the command is invoked. */
template: string;
/** Tool names this command's turn is restricted to (already translated from Claude Code's
* built-in tool names — see toolNameMap.ts), from frontmatter `allowed-tools`; undefined means
* no restriction (the command runs with the session's full toolset). */
allowedTools?: string[];
}
export interface PluginAgentDef {
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import { parseFallbackToolCalls } from "./fallbackParser.js";
describe("parseFallbackToolCalls", () => {
it("parses the instructed ```tool_call fenced format", () => {
const out = parseFallbackToolCalls(
'```tool_call\n{"name":"read_file","arguments":{"path":"src/a.ts"}}\n```',
);
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "src/a.ts" } }]);
expect(out.malformed).toBe(false);
});
it("returns no calls and not-malformed for plain prose with no tool attempt", () => {
const out = parseFallbackToolCalls("Here is the answer: use foo().");
expect(out.calls).toEqual([]);
expect(out.malformed).toBe(false);
});
it("flags a tool_call block whose JSON is unparseable as malformed", () => {
const out = parseFallbackToolCalls("```tool_call\n{not valid json\n```");
expect(out.calls).toEqual([]);
expect(out.malformed).toBe(true);
});
it("accepts a ```json fenced block when no tool_call block is present", () => {
const out = parseFallbackToolCalls(
'```json\n{"name":"grep","arguments":{"pattern":"foo"}}\n```',
);
expect(out.calls).toEqual([{ name: "grep", arguments: { pattern: "foo" } }]);
expect(out.malformed).toBe(false);
});
it("prefers a ```tool_call block over a ```json block when both appear", () => {
const out = parseFallbackToolCalls(
'```tool_call\n{"name":"read_file","arguments":{"path":"a"}}\n```\n' +
'```json\n{"name":"grep","arguments":{"pattern":"x"}}\n```',
);
expect(out.calls).toHaveLength(1);
expect(out.calls[0]!.name).toBe("read_file");
});
it("does not treat a ```json fence inside a tool_call block as the terminator", () => {
const out = parseFallbackToolCalls(
"```tool_call\n```json\n{\"name\":\"read_file\",\"arguments\":{\"path\":\"a\"}}\n```\n```",
);
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "a" } }]);
});
it("extracts a bare (unfenced) tool-call object from surrounding prose", () => {
const out = parseFallbackToolCalls(
'Let me read that file.\n{"name":"read_file","arguments":{"path":"src/loop.ts"}}\nThat should help.',
);
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "src/loop.ts" } }]);
});
it("ignores bare braces in prose that don't look like a tool call", () => {
const out = parseFallbackToolCalls("The config is { key: value } and that's it.");
expect(out.calls).toEqual([]);
expect(out.malformed).toBe(false);
});
it("repairs a truncated JSON tool_call block via partial-JSON repair", () => {
// max_tokens clipped the closing brace and quote
const out = parseFallbackToolCalls('```tool_call\n{"name":"read_file","arguments":{"path":"src/lo');
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "src/lo" } }]);
});
it("accepts a tool call with omitted arguments as empty arguments", () => {
const out = parseFallbackToolCalls('```tool_call\n{"name":"git_status"}\n```');
expect(out.calls).toEqual([{ name: "git_status", arguments: {} }]);
});
it("flags a tool call whose arguments is not an object", () => {
const out = parseFallbackToolCalls('```tool_call\n{"name":"x","arguments":"foo"}\n```');
expect(out.calls).toEqual([]);
expect(out.malformed).toBe(true);
});
});
+145 -13
View File
@@ -1,3 +1,5 @@
import { repairPartialJson } from "./partialJson.js";
export interface FallbackToolCall {
name: string;
arguments: Record<string, unknown>;
@@ -8,25 +10,155 @@ export interface FallbackParseResult {
malformed: boolean;
}
const BLOCK_RE = /```tool_call\s*([\s\S]*?)```/g;
// Local models in fallback mode (no native function calling) are asked to emit tool calls as a
// fenced ```tool_call block. In practice they frequently deviate, so the parser is lenient about
// FORMAT but strict about CONTENT: anything we extract must still parse to { name, arguments }.
// Accepted shapes, in priority order:
// 1. A ```tool_call fenced block (the instructed format). The opening fence is ```tool_call on
// its own line; the closing fence is ``` on its own line — anchored so a ```json block INSIDE
// isn't mistaken for the terminator. Some models wrap the JSON in an inner ```json fence; we
// strip that inner fence before parsing.
// 2. A ```json fenced block whose content is a tool-call object (name + arguments). Models that
// ignore the custom "tool_call" fence name but reach for the familiar "json" one.
// 3. A bare tool-call object appearing in the response with no fence at all. We scan for the
// first balanced {...} that contains a string "name" and an object "arguments". To avoid
// matching arbitrary prose-embedded JSON, we require the recognizable keys.
//
// Every extracted candidate goes through `coerce`, which parses (with partial-JSON repair as a
// last resort for truncated streaming output) and validates the name/arguments shape. A candidate
// that doesn't yield a valid call sets `malformed` — the loop nudges the model to retry rather than
// silently ending the task with no tool executed.
// Opening fence ```tool_call on its own line; closing ``` on its own line. Multiline-anchored so
// an inner ```json fence can't be read as the terminator.
const TOOL_CALL_FENCE_RE = /^```tool_call\s*\n([\s\S]*?)\n```(?:\n|$)/gm;
// A ```json block — only used if no ```tool_call block matched, since a json fence may carry prose.
const JSON_FENCE_RE = /^```json\s*\n([\s\S]*?)\n```(?:\n|$)/gm;
/** Pull the textual content out of a fenced block, stripping any inner ```json fence a model may
* have nested inside it. Returns the cleaned, trimmed body. */
function cleanFencedBody(raw: string): string {
return raw.replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
}
/** Parse a candidate string into a tool call, or null if it isn't one. Tries a direct parse, then a
* partial-JSON repair (truncation / trailing comma / unbalanced braces) for clipped streaming. */
function coerce(candidate: string): FallbackToolCall | null {
const trimmed = candidate.trim();
if (!trimmed) return null;
// Direct parse first.
let parsed: unknown = null;
try {
parsed = JSON.parse(trimmed);
} catch {
parsed = null;
}
// Repair pass for truncated / sloppy JSON from local streaming.
if (parsed === null) parsed = repairPartialJson(trimmed);
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
if (typeof obj.name !== "string") return null;
if (obj.arguments === undefined || obj.arguments === null) {
// Some models omit arguments entirely when the tool takes none — treat as empty.
return { name: obj.name, arguments: {} };
}
if (typeof obj.arguments !== "object" || Array.isArray(obj.arguments)) return null;
return { name: obj.name, arguments: obj.arguments as Record<string, unknown> };
}
/** Scan `content` for the first balanced {...} object containing a `"name"` string and an
* `"arguments"` object, with no fence at all. Tracks string literals so braces inside strings
* don't affect nesting, and cuts to the first complete top-level object. */
function findBareToolCall(content: string): string | null {
const start = content.indexOf("{");
if (start < 0) return null;
let depth = 0;
let inStr = false;
for (let i = start; i < content.length; i++) {
const c = content[i]!;
if (inStr) {
if (c === "\\") {
i++;
continue;
}
if (c === '"') inStr = false;
continue;
}
if (c === '"') {
inStr = true;
continue;
}
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) {
const candidate = content.slice(start, i + 1);
// Only accept it if it actually looks like a tool call — otherwise keep scanning.
if (/"name"\s*:/.test(candidate) && /"arguments"\s*:/.test(candidate)) {
return candidate;
}
// Reset to the next brace past this point to keep looking.
const next = content.indexOf("{", i + 1);
if (next < 0) return null;
i = next - 1;
depth = 0;
}
}
}
// Ran off the end with an unclosed object — a truncated tool call (max_tokens clipped the
// closing brace, and possibly the closing fence too). Hand the unbalanced substring back;
// `coerce` will run it through partial-JSON repair to close what's open.
if (depth > 0) {
const candidate = content.slice(start);
if (/"name"\s*:/.test(candidate) && /"arguments"\s*:/.test(candidate)) {
return candidate;
}
}
return null;
}
export function parseFallbackToolCalls(content: string): FallbackParseResult {
const calls: FallbackToolCall[] = [];
let malformed = false;
let sawAnyCandidate = false;
for (const match of content.matchAll(BLOCK_RE)) {
const raw = match[1]?.trim() ?? "";
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.name === "string" && typeof parsed.arguments === "object") {
calls.push({ name: parsed.name, arguments: parsed.arguments ?? {} });
} else {
malformed = true;
}
} catch {
malformed = true;
// (1) ```tool_call fenced blocks (the instructed format) — there may be several.
for (const match of content.matchAll(TOOL_CALL_FENCE_RE)) {
sawAnyCandidate = true;
const body = cleanFencedBody(match[1] ?? "");
const call = coerce(body);
if (call) calls.push(call);
else malformed = true;
}
if (calls.length > 0) return { calls, malformed };
// (2) ```json fenced blocks — only if no tool_call block matched. Take the first that coerces.
for (const match of content.matchAll(JSON_FENCE_RE)) {
const call = coerce(cleanFencedBody(match[1] ?? ""));
if (call) {
calls.push(call);
return { calls, malformed: false };
}
sawAnyCandidate = true;
malformed = true;
}
if (calls.length > 0) return { calls, malformed };
// (3) Bare (unfenced) tool-call object — last resort. At most one: the prompt says one call per
// response, and extracting multiple bare objects from prose is too error-prone.
const bare = findBareToolCall(content);
if (bare !== null) {
sawAnyCandidate = true;
const call = coerce(bare);
if (call) {
calls.push(call);
return { calls, malformed: false };
}
malformed = true;
}
// If we never saw anything that even looked like a tool-call attempt, that's not malformed —
// the model simply answered in prose (no tool needed). `malformed` stays false.
void sawAnyCandidate;
return { calls, malformed };
}
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { resolveToolCall } from "./nativeAdapter.js";
import type { ToolDef } from "../tools/types.js";
function makeRegistry(): Map<string, ToolDef> {
const tool: ToolDef = {
name: "write_file",
description: "d",
schema: z.object({ path: z.string(), content: z.string() }),
mutating: true,
handler: async () => ({}),
};
return new Map([["write_file", tool]]);
}
function call(args: string) {
return {
id: "1",
type: "function" as const,
function: { name: "write_file", arguments: args },
};
}
describe("resolveToolCall — argument repair", () => {
it("resolves normally when arguments are already valid JSON", () => {
const r = resolveToolCall(call('{"path":"a.ts","content":"hi"}'), makeRegistry());
expect("tool" in r).toBe(true);
});
it("repairs arguments containing a raw (unescaped) newline instead of failing outright", () => {
// A local model echoing multi-line file content into a non-streaming completion often pastes
// a real newline byte into the JSON string rather than escaping it — this path (unlike the
// streaming path in loop.ts) previously had no repair attempt at all.
const args = '{"path":"a.ts","content":"line1\nline2"}';
const r = resolveToolCall(call(args), makeRegistry());
expect("tool" in r).toBe(true);
if ("tool" in r) expect((r.args as { content: string }).content).toBe("line1\nline2");
});
it("still errors when the arguments are unsalvageable", () => {
const r = resolveToolCall(call("not json at all"), makeRegistry());
expect(r).toEqual({ error: "arguments were not valid JSON" });
});
});
+8 -1
View File
@@ -1,6 +1,7 @@
import type { ChatCompletionMessageToolCall, ChatCompletionTool } from "openai/resources/chat/completions";
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import { repairPartialJson } from "./partialJson.js";
import { resolveToolInvocation, type ResolvedToolCall } from "./resolve.js";
export function toOpenAITools(tools: ToolDef[]): ChatCompletionTool[] {
@@ -25,7 +26,13 @@ export function resolveToolCall(
try {
parsedArgs = JSON.parse(call.function.arguments || "{}");
} catch {
return { error: "arguments were not valid JSON" };
// Non-streaming completions land here directly (unlike the streaming path in loop.ts, which
// already repairs before this point) — without a repair attempt here too, a call whose
// arguments contain e.g. an unescaped literal newline (a local model echoing multi-line file
// content raw) fails outright instead of being salvaged.
const repaired = repairPartialJson(call.function.arguments || "");
if (repaired === null) return { error: "arguments were not valid JSON" };
parsedArgs = repaired;
}
return resolveToolInvocation(call.function.name, parsedArgs, registry);
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { repairPartialJson } from "./partialJson.js";
describe("repairPartialJson", () => {
it("parses already-valid JSON unchanged", () => {
expect(repairPartialJson('{"path":"src/a.ts"}')).toEqual({ path: "src/a.ts" });
expect(repairPartialJson("[]")).toEqual([]);
expect(repairPartialJson(" 42 ")).toBe(42);
expect(repairPartialJson('{"a":1}\n')).toEqual({ a: 1 });
});
it("returns null for empty / whitespace input", () => {
expect(repairPartialJson("")).toBeNull();
expect(repairPartialJson(" ")).toBeNull();
});
it("strips a trailing comma before a closing bracket", () => {
expect(repairPartialJson('{"a":1,}')).toEqual({ a: 1 });
expect(repairPartialJson('[1,2,]')).toEqual([1, 2]);
expect(repairPartialJson('{"a":{"b":2,},}')).toEqual({ a: { b: 2 } });
});
it("strips stray trailing content after a complete value", () => {
expect(repairPartialJson('{"a":1}\n```')).toEqual({ a: 1 });
expect(repairPartialJson('{"a":1}garbage')).toEqual({ a: 1 });
expect(repairPartialJson('[1,2] }')).toEqual([1, 2]);
});
it("closes a truncated string value", () => {
// max_tokens clipped mid-value: {"path":"src/lo → needs closing quote + brace
expect(repairPartialJson('{"path":"src/lo')).toEqual({ path: "src/lo" });
expect(repairPartialJson('{"a":"hello wor')).toEqual({ a: "hello wor" });
});
it("balances unclosed braces and brackets from truncation", () => {
expect(repairPartialJson('{"a":1')).toEqual({ a: 1 });
expect(repairPartialJson('{"a":{"b":2')).toEqual({ a: { b: 2 } });
expect(repairPartialJson("[1,2")).toEqual([1, 2]);
expect(repairPartialJson('{"items":[1,2')).toEqual({ items: [1, 2] });
});
it("does not count braces inside string literals", () => {
// The braces/brackets inside the string are content, not nesting.
expect(repairPartialJson('{"code":"func() { return [1] "')).toEqual({
code: "func() { return [1] ",
});
expect(repairPartialJson('{"s":"\\\"escaped\\\""}')).toEqual({ s: '"escaped"' });
});
it("handles escaped quotes inside strings during truncation repair", () => {
// Unterminated string with an escaped quote inside: {"s":"a\"b
expect(repairPartialJson('{"s":"a\\"b')).toEqual({ s: 'a"b' });
});
it("combined: trailing comma exposed after balancing", () => {
// {"a":1,"b":2, (truncated with trailing comma) → close brace, then strip comma
expect(repairPartialJson('{"a":1,"b":2,')).toEqual({ a: 1, b: 2 });
});
it("returns null when input is not salvageable as object/array/scalar", () => {
expect(repairPartialJson("just prose with no json")).toBeNull();
expect(repairPartialJson("{:}")).toBeNull();
});
it("escapes a raw (unescaped) literal newline inside a string value", () => {
// A local model echoing multi-line file content often pastes real \n bytes into the JSON
// string instead of writing the two-char `\n` escape — JSON.parse rejects that outright.
expect(repairPartialJson('{"content":"line1\nline2"}')).toEqual({ content: "line1\nline2" });
});
it("escapes a raw carriage return inside a string value (CRLF source content)", () => {
expect(repairPartialJson('{"content":"line1\r\nline2"}')).toEqual({ content: "line1\r\nline2" });
});
it("leaves an already-escaped \\n sequence untouched", () => {
expect(repairPartialJson('{"content":"line1\\nline2"}')).toEqual({ content: "line1\nline2" });
});
it("combines raw-newline escaping with truncation repair", () => {
// Truncated mid-value AND containing a raw newline earlier in the string.
expect(repairPartialJson('{"content":"line1\nline2')).toEqual({ content: "line1\nline2" });
});
});
+252
View File
@@ -0,0 +1,252 @@
// Partial/truncated JSON repair for native streaming tool-call arguments.
//
// Local-model backends (Ollama, LM Studio) streaming tool calls accumulate the `arguments` string
// across deltas. Two common pathologies produce a string that JSON.parse rejects but that contains
// all the semantic content the model intended:
//
// 1. TRUNCATION — max_tokens clipped the JSON mid-value. The string ends inside a string value,
// an array, or an object: `{"path": "src/lo`, `{"items": [1, 2`, `{"a": {"b": 1`.
// 2. LOCAL-MODEL SLOPPINESS — a trailing comma, an unbalanced brace/bracket, or a trailing
// garbage token after the closing brace: `{"path": "x.ts",}`, `{"a": 1 `.
//
// This module attempts a cheap, conservative repair BEFORE the caller falls back to a full
// non-streaming regeneration (which is expensive on a local backend and often fails identically
// when the cause was max_tokens). It only closes what's open and trims what's stray — it never
// invents keys or values, so a genuinely malformed call still fails downstream at schema validation.
//
// The repair is best-effort: if it can't produce parseable JSON, it returns null and the caller
// keeps its existing retry path. It is deliberately string-based (no AST) so it's trivially fast and
// has no dependencies, and so it handles truncated input that a strict parser can't even build an
// AST from.
/** Parse `s` as JSON; on success return the value, on failure return null (never throws). */
function tryParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return null;
}
}
/** JSON disallows raw control characters (0x00-0x1F — notably literal newline, CR, tab) inside
* string literals; they must be written as `\n`/`\r`/`\t`/`\u00XX`. Local models echoing
* multi-line file content (very common for write_file/edit_file on this project's CRLF-heavy
* source) routinely paste it in unescaped, which makes an otherwise complete, semantically
* correct tool call fail JSON.parse with "Bad control character in string literal". Escaping
* only touches raw bytes found *inside* a string (tracked the same way `skipString` does, so an
* existing `\\n` escape sequence is left alone) — it never changes where a string starts/ends or
* where a brace/bracket falls outside one, so it's safe to run before the other repair steps. */
function escapeRawControlCharsInStrings(s: string): string {
let out = "";
let inStr = false;
let changed = false;
for (let i = 0; i < s.length; i++) {
const c = s[i]!;
if (!inStr) {
if (c === '"') inStr = true;
out += c;
continue;
}
if (c === "\\") {
// Preserve an existing escape sequence verbatim — don't touch the char after the backslash.
out += c + (s[i + 1] ?? "");
i++;
continue;
}
if (c === '"') {
inStr = false;
out += c;
continue;
}
const code = c.charCodeAt(0);
if (code < 0x20) {
changed = true;
if (c === "\n") out += "\\n";
else if (c === "\r") out += "\\r";
else if (c === "\t") out += "\\t";
else out += "\\u" + code.toString(16).padStart(4, "0");
continue;
}
out += c;
}
return changed ? out : s;
}
/** Skip past the next JSON string literal starting at `i` (the opening quote). Returns the index
* just past the closing quote. Strings are the only place braces/brackets can appear without
* affecting nesting, so we must not count them while inside one. Handles `\"` and other escapes. */
function skipString(s: string, i: number): number {
let j = i + 1; // past opening quote
for (; j < s.length; j++) {
const c = s[j]!;
if (c === "\\") {
j++; // skip the escaped char (covers \", \\, etc.)
continue;
}
if (c === '"') return j + 1; // past closing quote
}
return j; // ran off the end — unterminated string
}
/** Attempts to repair `raw` into parseable JSON. Returns the parsed value on success, or null if no
* repair produced valid JSON. Steps, applied in order of how cheap and safe they are:
*
* 1. Maybe it already parses (trailing whitespace/newlines are fine for JSON.parse) — return as-is.
* 2. Escape raw control characters (literal newline/CR/tab) found inside string literals — a
* model echoing multi-line file content unescaped, common on this project's CRLF sources.
* 3. Strip a trailing comma before an expected-but-absent `}` or `]` (common local-model slip).
* 4. Strip stray non-JSON tokens after the first complete top-level value (`{"a":1}\n` → `{"a":1}`,
* and `{"a":1}garbage` → `{"a":1}` — JSON.parse rejects trailing content, so trim to the first
* complete value).
* 5. Close unterminated strings, then balance still-open braces/brackets (truncation repair).
*
* Each step re-attempts a parse, so the cheapest fix that works wins. */
export function repairPartialJson(raw: string): unknown | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
// (1) Already valid?
const direct = tryParse(trimmed);
if (direct !== null) return direct;
// (2) Raw control characters (literal newlines/CR/tab) inside string literals — see
// escapeRawControlCharsInStrings for why this is common. Escaping never moves a quote or
// brace, so every remaining step below runs against this version instead of the original.
const working = escapeRawControlCharsInStrings(trimmed);
if (working !== trimmed) {
const v = tryParse(working);
if (v !== null) return v;
}
// (3) Trailing comma before end-of-object/array: `{"a":1,}` or `[1,2,]`. Repeat until none
// left so a nested shape like `{"a":{"b":2,},}` clears both commas (innermost-first).
let noTrailing = working;
let prev: string;
do {
prev = noTrailing;
noTrailing = noTrailing.replace(/,\s*([\]}]+\s*$)/, "$1");
} while (noTrailing !== prev);
if (noTrailing !== working) {
const v = tryParse(noTrailing);
if (v !== null) return v;
}
// (4) Stray trailing content after the first complete value. JSON.parse refuses trailing tokens,
// but a model often emits a closing brace then a stray newline, a repeated token, or prose.
// Find the end of the first balanced top-level value and cut there.
const cut = cutToFirstCompleteValue(working);
if (cut !== null && cut !== working) {
const v = tryParse(cut);
if (v !== null) return v;
}
// (5) Truncation repair: close an unterminated string, then balance open braces/brackets.
const balanced = balanceAndClose(working);
if (balanced !== null && balanced !== working) {
// Re-run the earlier cheap fixes on the balanced result (a trailing comma may now be exposed).
const v = tryParse(balanced);
if (v !== null) return v;
const v2 = tryParse(balanced.replace(/,\s*([\]}]\s*$)/, "$1"));
if (v2 !== null) return v2;
}
return null;
}
/** If `s` starts with a complete top-level JSON value followed by stray content, return just that
* value (as a substring). Returns null if we can't find a clean boundary (e.g. the value is itself
* truncated). Walks the string tracking string literals and nesting depth. */
function cutToFirstCompleteValue(s: string): string | null {
let i = 0;
// Skip leading whitespace.
while (i < s.length && /\s/.test(s[i]!)) i++;
if (i >= s.length) return null;
const start = i;
const stack: string[] = [];
let inStr = false;
while (i < s.length) {
const c = s[i]!;
if (inStr) {
if (c === "\\") {
i += 2;
continue;
}
if (c === '"') inStr = false;
i++;
continue;
}
if (c === '"') {
inStr = true;
i++;
continue;
}
if (c === "{" || c === "[") {
stack.push(c);
i++;
continue;
}
if (c === "}" || c === "]") {
stack.pop();
i++;
// If the stack is empty, this was the end of the top-level value — cut here.
if (stack.length === 0) return s.slice(start, i);
continue;
}
// A bare scalar (number/true/false/null) ends at the next delimiter/comma/whitespace.
if (stack.length === 0 && (c === "," || c === "}" || c === "]" || /\s/.test(c))) {
return s.slice(start, i);
}
i++;
}
// Ran off the end without closing the top-level value → it's truncated, not "complete + stray".
if (stack.length > 0) return null;
return null;
}
/** Closes an unterminated trailing string and balances any open braces/brackets. Returns the
* repaired string, or null if nothing needed closing (caller can compare to skip a no-op parse). */
function balanceAndClose(s: string): string | null {
let out = s;
const stack: string[] = [];
let inStr = false;
let i = 0;
for (; i < out.length; i++) {
const c = out[i]!;
if (inStr) {
if (c === "\\") {
i++;
continue;
}
if (c === '"') inStr = false;
continue;
}
if (c === '"') {
inStr = true;
continue;
}
if (c === "{") stack.push("}");
else if (c === "[") stack.push("]");
else if (c === "}" || c === "]") stack.pop();
}
// If we ended inside a string, close it. A truncated value like `{"path":"src/lo` needs a closing
// quote before we can balance the outer braces.
if (inStr) {
out += '"';
}
// Close anything still open, innermost-first. Truncation mid-array/object → append the closers.
if (stack.length === 0 && !inStr) {
// Nothing to close — but a trailing comma may have been the only issue; let the caller handle it.
return inStr ? out : null;
}
while (stack.length) {
out += stack.pop();
}
return out;
}
+48 -5
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { SubAgentResult } from "./types.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
@@ -7,22 +8,64 @@ const schema = z.object({
.string()
.describe(
"Full, self-contained task description. The sub-agent has no conversation memory and cannot ask follow-ups.",
),
)
.optional(),
tasks: z
.array(
z.object({
description: z.string().describe("Short label for this sub-task."),
prompt: z
.string()
.describe(
"Full, self-contained task description for this sub-task. The sub-agent has no conversation memory.",
),
}),
)
.min(2)
.describe(
"Run several sub-agents IN PARALLEL (read-heavy research/audit tasks). Each gets its own " +
"isolated context and returns independently. Use this for many files, e.g. one sub-agent " +
"per directory or per concern (security, performance, tests). Prefer `prompt` for a single task.",
)
.optional(),
}).refine((v) => v.prompt || v.tasks, {
message: "Provide either `prompt` (single sub-agent) or `tasks` (parallel batch).",
});
export const agentTool: ToolDef<z.infer<typeof schema>> = {
name: "agent",
description:
"Delegate a task to a sub-agent with its own tool loop (no nested agents). Only the final answer is returned. " +
"For many files, split into multiple sub-agents. Sub-agents have a smaller step budget; if one runs out, " +
"narrow the task rather than retrying.",
"For many files, split into multiple sub-agents — pass a `tasks` array to run several in PARALLEL " +
"(each returns independently; one failure doesn't discard the others). Sub-agents have a smaller " +
"step budget; if one runs out, narrow the task rather than retrying. Mutating tool calls from any " +
"sub-agent still go through the same permission prompts (serialized, so parallel sub-agents that " +
"edit don't race).",
schema,
mutating: false,
handler: async (args, ctx) => {
if (!ctx.runSubAgent) {
const runSubAgent = ctx.runSubAgent;
if (!runSubAgent) {
throw new Error("Sub-agents are not available in this context.");
}
const result = await ctx.runSubAgent(args);
// Parallel batch: run each task as its own sub-agent, concurrently. A single failure surfaces
// as that task's `error` rather than rejecting the whole batch — the model gets every sibling's
// result and can retry just the one that failed instead of paying for all of them again.
if (args.tasks && args.tasks.length > 0) {
const results = await Promise.all(
args.tasks.map(async (t): Promise<SubAgentResult> => {
try {
const result = await runSubAgent({ description: t.description, prompt: t.prompt });
return { description: t.description, result };
} catch (err) {
return { description: t.description, error: (err as Error).message ?? String(err) };
}
}),
);
return { description: args.description, results };
}
// Single sub-agent (the original path).
const result = await runSubAgent({ description: args.description, prompt: args.prompt ?? "" });
return { description: args.description, result };
},
};
+33
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { execa } from "execa";
import type { ResultPromise } from "execa";
import { bashTool } from "./bash.js";
import { killProcessTree } from "../utils/processTree.js";
@@ -80,4 +81,36 @@ describe("bash tool — sub-agent abort", () => {
expect(killProcessTree).not.toHaveBeenCalled();
});
});
describe("bash tool — safety guards", () => {
beforeEach(() => {
vi.mocked(execa).mockClear();
});
it("refuses a command that wipes the filesystem root before ever spawning it", async () => {
const ctx: ToolContext = { cwd: process.cwd() };
await expect(bashTool.handler({ command: "rm -rf /" }, ctx)).rejects.toThrow(/Refusing to run/);
expect(execa).not.toHaveBeenCalled();
});
it("does not flag an ordinary rm -rf on a project subdirectory", async () => {
// preview (not handler) so this doesn't spawn the fake child, which only ever resolves when
// killed/backgrounded — nothing here would do either, so awaiting handler() would hang.
const ctx: ToolContext = { cwd: process.cwd() };
const preview = await bashTool.preview!({ command: "rm -rf node_modules" }, ctx);
expect(preview).not.toMatch(/^Blocked:/);
});
it("refuses a cwd override that escapes the working directory", async () => {
const ctx: ToolContext = { cwd: process.cwd() };
await expect(bashTool.handler({ command: "ls", cwd: "../../" }, ctx)).rejects.toThrow(/Refusing to write outside/);
expect(execa).not.toHaveBeenCalled();
});
it("preview surfaces the block reason instead of running the command", async () => {
const ctx: ToolContext = { cwd: process.cwd() };
const preview = await bashTool.preview!({ command: "mkfs.ext4 /dev/sda1" }, ctx);
expect(preview).toMatch(/^Blocked:/);
});
});
+27 -11
View File
@@ -1,7 +1,8 @@
import path from "node:path";
import { execa } from "execa";
import { z } from "zod";
import { registerBackgroundJob } from "./backgroundJobs.js";
import { riskyBashCommandReason } from "./bashGuard.js";
import { resolveWithinCwd } from "./pathGuard.js";
import { killProcessTree } from "../utils/processTree.js";
import { truncate } from "../utils/truncate.js";
import { resolveShell } from "../utils/shell.js";
@@ -21,24 +22,39 @@ function delay(ms: number): Promise<"pending"> {
export const bashTool: ToolDef<z.infer<typeof schema>> = {
name: "bash",
description: "Run a shell command and return its stdout, stderr, and exit code.",
description: "Run a shell command and return its stdout, stderr, and exit code. Use for building, running tests, git operations, or inspecting the environment. Output is capped (head+tail preserved); long-running commands can be backgrounded with Ctrl+B and checked with bash_output.",
schema,
mutating: true,
preview: async ({ command, cwd }) => `Run shell command: ${command}${cwd ? ` (cwd: ${cwd})` : ""}`,
preview: async ({ command, cwd }, ctx) => {
const riskyReason = riskyBashCommandReason(command);
if (riskyReason) return `Blocked: this command ${riskyReason}.`;
if (cwd) {
try {
resolveWithinCwd(ctx.cwd, cwd);
} catch (err) {
return (err as Error).message;
}
}
return `Run shell command: ${command}${cwd ? ` (cwd: ${cwd})` : ""}`;
},
handler: async ({ command, cwd, timeout_ms }, ctx) => {
const workDir = cwd ? path.resolve(ctx.cwd, cwd) : ctx.cwd;
const riskyReason = riskyBashCommandReason(command);
if (riskyReason) {
throw new Error(`Refusing to run: this command ${riskyReason}.`);
}
const workDir = cwd ? resolveWithinCwd(ctx.cwd, cwd) : ctx.cwd;
// Timeout is enforced by our own timer rather than execa's built-in `timeout` option, so that
// backgrounding via Ctrl+B can cancel it below — execa's own timeout kills the process on a
// fixed schedule regardless of what happens to it afterward, which would silently kill a
// long-running command right after the user chose to keep it running in the background.
const child = execa(command, { shell: resolveShell(), cwd: workDir, reject: false });
let stdout = "";
let stderr = "";
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const onStdout = (d: Buffer) => {
stdout += d.toString();
stdoutChunks.push(d.toString());
};
const onStderr = (d: Buffer) => {
stderr += d.toString();
stderrChunks.push(d.toString());
};
child.stdout?.on("data", onStdout);
child.stderr?.on("data", onStderr);
@@ -78,7 +94,7 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
// long-running backgrounded job. The buffers captured so far seed the job.
child.stdout?.off("data", onStdout);
child.stderr?.off("data", onStderr);
const job = registerBackgroundJob(command, workDir, child, stdout, stderr);
const job = registerBackgroundJob(command, workDir, child, stdoutChunks.join(""), stderrChunks.join(""));
return {
backgrounded: true,
jobId: job.id,
@@ -90,8 +106,8 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
clearTimeout(foregroundTimer);
return {
exitCode: settled.exitCode,
stdout: truncate(stdout),
stderr: truncate(stderr),
stdout: truncate(stdoutChunks.join("")),
stderr: truncate(stderrChunks.join("")),
timedOut,
};
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { riskyBashCommandReason } from "./bashGuard.js";
describe("riskyBashCommandReason", () => {
describe("blocks", () => {
const cases: [name: string, command: string][] = [
["rm -rf /", "rm -rf /"],
["rm -fr / (flag order swapped)", "rm -fr /"],
["rm -rf / with a trailing slash-star", "rm -rf /*"],
["rm -Rf ~ (home dir)", "rm -Rf ~"],
["rm --recursive --force /", "rm --recursive --force /"],
["sudo rm -rf /", "sudo rm -rf /"],
["classic fork bomb", ":(){ :|:& };:"],
["fork bomb with extra whitespace", ": ( ) { : | : & } ; :"],
["mkfs.ext4", "mkfs.ext4 /dev/sda1"],
["dd to a raw device", "dd if=/dev/zero of=/dev/sda bs=1M"],
["redirect onto a raw device", "echo oops > /dev/sda"],
["Windows format", "format C:"],
["Windows rd /s /q on a drive root", "rd /s /q C:\\"],
["PowerShell Remove-Item -Recurse -Force on a drive", "Remove-Item -Recurse -Force C:\\"],
];
for (const [name, command] of cases) {
it(name, () => {
expect(riskyBashCommandReason(command)).not.toBeNull();
});
}
});
describe("does not block", () => {
const cases: [name: string, command: string][] = [
["rm -rf on a project subdirectory", "rm -rf node_modules"],
["rm -rf on a relative build dir", "rm -rf ./dist"],
["rm without force/recursive on root-looking arg", "rm /tmp/foo.txt"],
["dd between two regular files", "dd if=file.img of=out.img"],
["a command that merely contains the word format", "echo 'please format your PR title'"],
["a normal git command", "git status"],
["listing a directory named format", "ls format"],
];
for (const [name, command] of cases) {
it(name, () => {
expect(riskyBashCommandReason(command)).toBeNull();
});
}
});
});
+87
View File
@@ -0,0 +1,87 @@
/** Blocks a small set of unambiguously catastrophic shell commands — wiping the whole filesystem
* or a whole drive, formatting a device, a fork bomb — before they ever reach the confirmation
* prompt (or, under auto-accept, before they'd run with no prompt at all). This is not a general
* command sandbox: it doesn't stop a model from `rm -rf`-ing some *other* directory it shouldn't,
* running a slow fork loop that isn't the canonical bomb syntax, or anything merely inadvisable —
* only the handful of patterns whose only realistic purpose is destroying the whole machine, where
* a false negative is far more likely than a false positive. Deliberately narrow so it doesn't
* reject legitimate commands like `rm -rf node_modules` or `dd if=file.img of=out.img`. */
interface RiskyPattern {
test: (command: string) => boolean;
reason: string;
}
/** Splits on whitespace for a crude token scan — good enough for a blocklist (not a security
* boundary; execa still runs the raw string through a real shell either way) and avoids a brittle
* do-everything regex that has to encode flag ordering itself. */
function tokenize(command: string): string[] {
return command.trim().split(/\s+/);
}
const ROOT_TARGETS = new Set(["/", "/*", "~", "~/", "~/*", "$home", "${home}"]);
/** `rm -rf /`, `rm -fr ~`, `sudo rm -Rf --no-preserve-root /`, etc. — recursive+forced deletion
* whose target is the filesystem root or the whole home directory, in any flag order/spelling. */
function isRmWipingRootOrHome(command: string): boolean {
const tokens = tokenize(command).map((t) => t.toLowerCase());
const rmIdx = tokens.findIndex((t) => t === "rm" || t.endsWith("/rm"));
if (rmIdx === -1) return false;
const rest = tokens.slice(rmIdx + 1);
const isFlag = (t: string) => t.startsWith("-");
const hasForce = rest.some((t) => (isFlag(t) && !t.startsWith("--") && t.includes("f")) || t === "--force");
const hasRecursive = rest.some((t) => (isFlag(t) && !t.startsWith("--") && (t.includes("r") || t.includes("R"))) || t === "--recursive");
const targets = rest.filter((t) => !isFlag(t));
return hasForce && hasRecursive && targets.some((t) => ROOT_TARGETS.has(t));
}
const RISKY_PATTERNS: RiskyPattern[] = [
{
test: isRmWipingRootOrHome,
reason: "recursively force-deletes the filesystem root or home directory",
},
{
// Classic bash fork bomb: ":(){ :|:& };:" (whitespace-tolerant).
test: (cmd) => /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&?\s*;?\s*\}\s*;\s*:/.test(cmd),
reason: "is a fork bomb (unbounded process spawning)",
},
{
test: (cmd) => /\bmkfs(\.\w+)?\b/i.test(cmd),
reason: "formats a filesystem (mkfs)",
},
{
test: (cmd) => /\bdd\b[^\n]*\bof=\/dev\/(sd|hd|nvme|disk|xvd|rdisk)\w*/i.test(cmd),
reason: "writes raw data directly to a block device (dd of=/dev/...)",
},
{
test: (cmd) => />\s*\/dev\/(sd|hd|nvme|disk|xvd|rdisk)\w*\b/i.test(cmd),
reason: "redirects output directly onto a block device",
},
{
// `format C:`, `format /Y D:` — Windows drive format.
test: (cmd) => /\bformat\b[^\n]*\b[a-zA-Z]:/i.test(cmd),
reason: "formats a Windows drive (format)",
},
{
// `rd /s /q C:\`, `rmdir /s /q D:\` — recursive quiet delete of a bare drive root.
test: (cmd) => /\b(rd|rmdir)\b[^\n]*\/s\b[^\n]*\b[a-zA-Z]:\\?\s*(\/q\b[^\n]*)?$/im.test(cmd),
reason: "recursively deletes an entire Windows drive",
},
{
// PowerShell `Remove-Item -Recurse -Force C:\` (or -Path C:\, or $env:SystemDrive), flag order-tolerant.
test: (cmd) =>
/remove-item\b/i.test(cmd) &&
/-recurse\b/i.test(cmd) &&
/-force\b/i.test(cmd) &&
(/\b[a-zA-Z]:\\?\s*($|['")\s;])/.test(cmd) || /\$env:systemdrive\b/i.test(cmd)),
reason: "recursively force-deletes an entire Windows drive (Remove-Item)",
},
];
/** Returns a human-readable reason if `command` matches a known catastrophic pattern, else null. */
export function riskyBashCommandReason(command: string): string | null {
for (const pattern of RISKY_PATTERNS) {
if (pattern.test(command)) return pattern.reason;
}
return null;
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// The LSP tools (definition/references/diagnostics) are thin dispatchers over lspManager. We mock
// the manager functions so the tests run without spawning a language server, and assert each tool
// forwards the right arguments (path resolved against cwd, 1-indexed→handled by the manager,
// includeDeclaration default) and returns the manager's result verbatim.
vi.mock("../codeintel/lspManager.js", () => ({
getDefinition: vi.fn(async () => ({ definitions: [{ path: "/abs/a.ts", line: 3, column: 5 }] })),
getReferences: vi.fn(async () => ({ references: [{ path: "/abs/a.ts", line: 3, column: 5 }] })),
getDiagnostics: vi.fn(async () => ({
diagnostics: [{ path: "/abs/a.ts", line: 1, column: 1, severity: "error", message: "oops" }],
})),
}));
import { definitionTool, referencesTool, diagnosticsTool } from "./codeIntel.js";
import { getDefinition, getReferences, getDiagnostics } from "../codeintel/lspManager.js";
const ctx = { cwd: "/proj" };
describe("definition tool", () => {
beforeEach(() => vi.clearAllMocks());
it("forwards path/line/column/cwd to getDefinition and returns its result", async () => {
const out = await definitionTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
expect(getDefinition).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj");
expect(out).toEqual({ definitions: [{ path: "/abs/a.ts", line: 3, column: 5 }] });
});
it("is read-only (no permission prompt)", () => {
expect(definitionTool.mutating).toBe(false);
});
});
describe("references tool", () => {
beforeEach(() => vi.clearAllMocks());
it("defaults includeDeclaration to true when omitted", async () => {
await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
expect(getReferences).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj", true);
});
it("passes an explicit includeDeclaration through", async () => {
await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5, include_declaration: false }, ctx);
expect(getReferences).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj", false);
});
it("returns the manager's references result", async () => {
const out = await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
expect(out).toEqual({ references: [{ path: "/abs/a.ts", line: 3, column: 5 }] });
});
});
describe("diagnostics tool", () => {
beforeEach(() => vi.clearAllMocks());
it("forwards path/cwd to getDiagnostics and returns its result", async () => {
const out = await diagnosticsTool.handler({ path: "src/a.ts" }, ctx);
expect(getDiagnostics).toHaveBeenCalledWith("src/a.ts", "/proj");
expect(out).toEqual({
diagnostics: [{ path: "/abs/a.ts", line: 1, column: 1, severity: "error", message: "oops" }],
});
});
it("is read-only", () => {
expect(diagnosticsTool.mutating).toBe(false);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { z } from "zod";
import type { ToolDef } from "./types.js";
import { getDefinition, getReferences, getDiagnostics } from "../codeintel/lspManager.js";
// All three tools are read-only LSP queries. They share a common shape: point them at a file
// (relative to cwd) and a 1-indexed line/column, and they ask the language server for the answer.
// The server is lazily started on first use per language (tsserver, pyright, gopls, clangd,
// rust-analyzer) and reused across the whole session — see lspManager.ts for the lifecycle.
//
// The error messages from lspManager are written to be actionable (e.g. "install
// typescript-language-server"), so we let them surface verbatim rather than wrapping them — a
// generic "LSP unavailable" would hide the one piece of info the model needs to recover.
const positionSchema = z.object({
path: z
.string()
.describe("File path, relative to the working directory. Must match an extension with a configured LSP server (.ts/.tsx/.js/.jsx/.py/.go/.rs/.c/.cpp/…)."),
line: z.number().int().min(1).describe("1-indexed line number of the symbol to query."),
column: z.number().int().min(1).describe("1-indexed column number of the symbol to query."),
});
export const definitionTool: ToolDef<z.infer<typeof positionSchema>> = {
name: "definition",
description:
"Resolve where a symbol is DEFINED using the language server (LSP). Use when grep finds a call site but you need the actual declaration — e.g. a function/variable/type name at a line:column. Returns one or more file:line:column locations (empty list if the server couldn't resolve it, which is a legitimate 'not found', not an error). Requires the relevant language server on PATH (typescript-language-server, pyright-langserver, gopls, clangd, or rust-analyzer).",
schema: positionSchema,
mutating: false,
handler: async (args, ctx) => getDefinition(args.path, args.line, args.column, ctx.cwd),
};
const referencesSchema = positionSchema.extend({
include_declaration: z
.boolean()
.optional()
.describe("Whether to include the symbol's own declaration among the references. Defaults to true (matches most IDE 'find all references' behavior)."),
});
export const referencesTool: ToolDef<z.infer<typeof referencesSchema>> = {
name: "references",
description:
"Find every reference to a symbol using the language server (LSP) — the same as an IDE's 'find all references'. Use to enumerate all call/usage sites of a function/variable/type at a line:column before a rename or to gauge impact. Returns a list of file:line:column locations. Requires the relevant language server on PATH.",
schema: referencesSchema,
mutating: false,
handler: async (args, ctx) =>
getReferences(args.path, args.line, args.column, ctx.cwd, args.include_declaration ?? true),
};
const diagnosticsSchema = z.object({
path: z
.string()
.describe("File path, relative to the working directory, to check for type/syntax errors."),
});
export const diagnosticsTool: ToolDef<z.infer<typeof diagnosticsSchema>> = {
name: "diagnostics",
description:
"Get the latest type/syntax diagnostics (errors and warnings) the language server has published for a file — equivalent to an editor's Problems panel. Use right after an edit_file/write_file to verify the change didn't introduce a type error, or when `tsc --noEmit`/`pyright` would be the alternative. Forces a document sync first so the snapshot is current. Returns severity (error/warning/information/hint), line, column, and message for each diagnostic. Requires the relevant language server on PATH.",
schema: diagnosticsSchema,
mutating: false,
handler: async (args, ctx) => getDiagnostics(args.path, ctx.cwd),
};
+121
View File
@@ -0,0 +1,121 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { detectEol, editFileTool, fromLF, toLF } from "./editFile.js";
import type { ToolContext } from "./types.js";
describe("editFile tool — path containment", () => {
let cwd: string;
let ctx: ToolContext;
beforeEach(() => {
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-editfile-"));
ctx = { cwd };
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it("edits a file inside the working directory", async () => {
writeFileSync(path.join(cwd, "note.txt"), "hello world");
const result = (await editFileTool.handler({ path: "note.txt", old_string: "world", new_string: "there" }, ctx)) as { replacements: number };
expect(result.replacements).toBe(1);
});
it("refuses to edit a file outside the working directory via ../ traversal", async () => {
await expect(
editFileTool.handler({ path: "../escape.txt", old_string: "a", new_string: "b" }, ctx),
).rejects.toThrow(/outside the working directory/);
});
it("preview reports the block instead of reading the target file", async () => {
const preview = await editFileTool.preview!({ path: "../escape.txt", old_string: "a", new_string: "b" }, ctx);
expect(preview).toMatch(/outside the working directory/);
});
it("handler suggests the closest match when old_string is not found", async () => {
writeFileSync(
path.join(cwd, "code.ts"),
"function greet(name: string): string {\n return `Hello, ${name}!`;\n}\n",
);
// Close but wrong: single quotes instead of backticks, "Hi" instead of "Hello".
await expect(
editFileTool.handler(
{ path: "code.ts", old_string: "return 'Hi, ${name}!';", new_string: "return `Hi, ${name}!`;" },
ctx,
),
).rejects.toThrow(/closest match/);
});
it("preview warns and shows the closest match when old_string is not found", async () => {
writeFileSync(path.join(cwd, "note.txt"), "the quick brown fox jumps over the lazy dog");
const preview = await editFileTool.preview!(
{ path: "note.txt", old_string: "the quick red fox jumps over the lazy cat", new_string: "x" },
ctx,
);
expect(preview).toMatch(/not found/);
expect(preview).toMatch(/closest match/);
expect(preview).toContain("quick brown fox");
});
it("does not suggest a match when nothing is remotely similar", async () => {
writeFileSync(path.join(cwd, "note.txt"), "aaaaaaaaaaaaaaaaaaaaaaaa");
await expect(
editFileTool.handler(
{ path: "note.txt", old_string: "completely different text xyz", new_string: "b" },
ctx,
),
).rejects.toThrow(/not found/);
// No "closest match" suffix when similarity is below the threshold.
await expect(
editFileTool.handler(
{ path: "note.txt", old_string: "completely different text xyz", new_string: "b" },
ctx,
),
).rejects.not.toThrow(/closest match/);
});
// read_file shows the model LF-normalized content regardless of the file's real line endings
// (see readFile.ts), so old_string/new_string from a model are always LF — matching must happen
// in that same space or every CRLF file in a project like this one fails with "not found".
it("matches an LF old_string against a CRLF file (mirrors what read_file shows the model)", async () => {
writeFileSync(path.join(cwd, "code.ts"), "function greet() {\r\n return 1;\r\n}\r\n");
const result = (await editFileTool.handler(
{ path: "code.ts", old_string: " return 1;", new_string: " return 2;" },
ctx,
)) as { replacements: number };
expect(result.replacements).toBe(1);
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
expect(onDisk).toBe("function greet() {\r\n return 2;\r\n}\r\n");
});
it("preserves CRLF line endings on disk after an edit spanning multiple lines", async () => {
writeFileSync(path.join(cwd, "code.ts"), "a\r\nb\r\nc\r\n");
await editFileTool.handler({ path: "code.ts", old_string: "a\nb", new_string: "a\nx\nb" }, ctx);
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
expect(onDisk).toBe("a\r\nx\r\nb\r\nc\r\n");
});
it("leaves a pure-LF file untouched by EOL conversion", async () => {
writeFileSync(path.join(cwd, "code.ts"), "a\nb\nc\n");
await editFileTool.handler({ path: "code.ts", old_string: "b", new_string: "x" }, ctx);
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
expect(onDisk).toBe("a\nx\nc\n");
});
});
describe("EOL helpers", () => {
it("detectEol finds CRLF, defaults to LF otherwise", () => {
expect(detectEol("a\r\nb")).toBe("\r\n");
expect(detectEol("a\nb")).toBe("\n");
expect(detectEol("a")).toBe("\n");
});
it("toLF/fromLF round-trip", () => {
expect(toLF("a\r\nb\r\nc")).toBe("a\nb\nc");
expect(fromLF("a\nb\nc", "\r\n")).toBe("a\r\nb\r\nc");
expect(fromLF("a\nb\nc", "\n")).toBe("a\nb\nc");
});
});
+161 -17
View File
@@ -1,8 +1,8 @@
import { createPatch } from "diff";
import { randomBytes } from "node:crypto";
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { resolveWithinCwd } from "./pathGuard.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
@@ -12,11 +12,31 @@ const schema = z.object({
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
});
function countOccurrences(haystack: string, needle: string): number {
/** `read_file` shows the model LF-normalized content (`content.split(/\r?\n/).join(...)` — see
* readFile.ts), regardless of the file's actual line endings on disk. A model's `old_string`/
* `new_string` are built from what it read, so they're always LF. Matching that against this
* tool's raw (real `\r\n`-preserving) file read would fail on every CRLF file in the project —
* which is most of them (see the repo's CRLF/LF notes). Detect the file's line ending once, do
* all matching/editing in LF space (so `old_string` from the model lines up), then convert the
* result back before writing so the file's on-disk convention is preserved rather than silently
* flipped to LF. */
export function detectEol(raw: string): "\r\n" | "\n" {
return raw.includes("\r\n") ? "\r\n" : "\n";
}
export function toLF(s: string): string {
return s.replace(/\r\n/g, "\n");
}
export function fromLF(s: string, eol: "\r\n" | "\n"): string {
return eol === "\n" ? s : s.replace(/\n/g, eol);
}
export function countOccurrences(haystack: string, needle: string): number {
return needle === "" ? 0 : haystack.split(needle).length - 1;
}
function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
export function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
// Use split/join for both paths instead of String.prototype.replace, whose replacement string
// interprets special $-tokens ($$, $&, $`, $', $<name>, $1–$9) even when the *pattern* is a plain
// string — which would silently corrupt edits whose replacement text contains a literal "$".
@@ -24,37 +44,161 @@ function applyEdit(original: string, oldString: string, newString: string, repla
return replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, () => newString);
}
/**
* Normalise a candidate snippet for fuzzy comparison: collapse runs of whitespace to single spaces
* and trim. This makes the similarity score tolerant to indentation/line-ending differences, which
* are the most common reasons a local model's old_string almost-matches but not quite.
*/
function normaliseForCompare(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
/**
* Compute a Levenshtein distance limited to `maxDist` — early-exits once the distance exceeds it,
* making it O(n*m) worst case but far cheaper in practice when we only care about "close enough".
*/
function boundedLevenshtein(a: string, b: string, maxDist: number): number {
const al = a.length;
const bl = b.length;
if (Math.abs(al - bl) > maxDist) return maxDist + 1;
if (al === 0) return bl;
if (bl === 0) return al;
let prev: number[] = new Array<number>(bl + 1);
let curr: number[] = new Array<number>(bl + 1);
for (let j = 0; j <= bl; j++) prev[j] = j;
for (let i = 1; i <= al; i++) {
curr[0] = i;
let rowMin = i;
const ai = a.charCodeAt(i - 1);
for (let j = 1; j <= bl; j++) {
const cost = ai === b.charCodeAt(j - 1) ? 0 : 1;
const del = (prev[j] ?? 0) + 1;
const ins = (curr[j - 1] ?? 0) + 1;
const sub = (prev[j - 1] ?? 0) + cost;
curr[j] = Math.min(del, ins, sub);
const cell = curr[j] ?? 0;
if (cell < rowMin) rowMin = cell;
}
// If every cell in this row already exceeds maxDist, the final answer can only be worse.
if (rowMin > maxDist) return maxDist + 1;
[prev, curr] = [curr, prev];
}
return prev[bl] ?? maxDist + 1;
}
interface SimilarMatch {
/** 0..1 similarity ratio (1 = identical, 0 = unrelated). */
score: number;
/** The exact text from the file at the best matching window. */
snippet: string;
/** 1-indexed line number where the snippet starts. */
line: number;
}
/**
* Find the region of `content` most similar to `needle`. Slides a window of the needle's length
* (±50%) across the file in word steps, scoring normalised text with bounded Levenshtein. Returns
* the best candidate when its similarity is at least 0.5 — clearly worth suggesting to the model.
* Returns null when nothing is close enough, in which case the caller falls back to the plain
* "not found" message.
*/
function findSimilarMatch(content: string, needle: string): SimilarMatch | null {
const needleNorm = normaliseForCompare(needle);
if (needleNorm.length < 3) return null;
const words = needleNorm.split(" ");
const minLen = Math.floor(needleNorm.length * 0.5);
const maxLen = Math.ceil(needleNorm.length * 1.5);
let best: SimilarMatch | null = null;
let bestDist = Infinity;
// Walk the file by character, treating every position as a potential window start is O(n*len)
// and too slow for big files. Instead, step at every Nth character (≈ word boundaries) to keep
// it cheap while still landing near real matches.
const step = Math.max(1, Math.floor(needleNorm.length / 8));
const contentLen = content.length;
for (let start = 0; start < contentLen; start += step) {
for (let len = minLen; len <= maxLen; len += step) {
const end = Math.min(start + len, contentLen);
const candidate = content.slice(start, end);
const candNorm = normaliseForCompare(candidate);
if (candNorm.length < minLen) continue;
// Only spend Levenshtein effort if the lengths are plausibly close.
const maxDist = Math.floor(needleNorm.length * 0.5);
const dist = boundedLevenshtein(needleNorm, candNorm, maxDist);
if (dist >= bestDist) continue;
bestDist = dist;
const score = 1 - dist / Math.max(needleNorm.length, candNorm.length);
// 1-indexed line: count newlines before `start`.
let line = 1;
for (let k = 0; k < start; k++) if (content.charCodeAt(k) === 10) line++;
best = { score, snippet: candidate.trim(), line };
}
}
if (best && best.score >= 0.5) return best;
return null;
}
/** Build a "did you mean" suffix for error/preview messages. Returns "" if nothing useful. */
function similarHint(original: string, oldString: string): string {
const m = findSimilarMatch(original, oldString);
if (!m) return "";
// Truncate long snippets so the message stays readable.
const snippet =
m.snippet.length > 300 ? `${m.snippet.slice(0, 300)}…` : m.snippet;
return `\n\nThe closest match in the file (line ${m.line}, ~${Math.round(m.score * 100)}% similar):\n"""\n${snippet}\n"""\nUse this exact text (or a unique subset of it) as old_string.`;
}
export const editFileTool: ToolDef<z.infer<typeof schema>> = {
name: "edit_file",
description:
"Replace exact text in a file. old_string must match exactly. Unless replace_all is set, it must be unique — include enough context.",
"Replace exact text in a file. old_string must match exactly. Unless replace_all is set, it must be unique — include enough context. " +
"Use for small, targeted changes to an existing file. On a mismatch, the closest similar text is suggested to help retry.",
schema,
mutating: true,
preview: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
let original: string;
let resolved: string;
try {
original = await fsReadFile(resolved, "utf-8");
resolved = resolveWithinCwd(ctx.cwd, filePath);
} catch (err) {
return (err as Error).message;
}
let raw: string;
try {
raw = await fsReadFile(resolved, "utf-8");
} catch {
return `File ${resolved} does not exist.`;
}
const occurrences = countOccurrences(original, old_string);
const eol = detectEol(raw);
const original = toLF(raw);
const oldLF = toLF(old_string);
const newLF = toLF(new_string);
const occurrences = countOccurrences(original, oldLF);
if (occurrences === 0) {
return `Warning: old_string not found in ${resolved} — this edit will fail.`;
return `Warning: old_string not found in ${resolved} — this edit will fail.${similarHint(original, oldLF)}`;
}
if (occurrences > 1 && !replace_all) {
return `Warning: old_string appears ${occurrences} times in ${resolved} — this edit will fail unless replace_all is set.`;
}
const updated = applyEdit(original, old_string, new_string, replace_all);
return createPatch(resolved, original, updated, "", "");
const updated = fromLF(applyEdit(original, oldLF, newLF, replace_all), eol);
return createPatch(resolved, raw, updated, "", "");
},
handler: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
const original = await fsReadFile(resolved, "utf-8");
const occurrences = countOccurrences(original, old_string);
const resolved = resolveWithinCwd(ctx.cwd, filePath);
const raw = await fsReadFile(resolved, "utf-8");
const eol = detectEol(raw);
const original = toLF(raw);
const oldLF = toLF(old_string);
const newLF = toLF(new_string);
const occurrences = countOccurrences(original, oldLF);
if (occurrences === 0) {
throw new Error(
`old_string not found in ${filePath}. Make sure it matches the file exactly, including whitespace.`,
`old_string not found in ${filePath}. Make sure it matches the file exactly, including whitespace.${similarHint(original, oldLF)}`,
);
}
if (occurrences > 1 && !replace_all) {
@@ -62,7 +206,7 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
`old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
);
}
const updated = applyEdit(original, old_string, new_string, replace_all);
const updated = fromLF(applyEdit(original, oldLF, newLF, replace_all), eol);
// Write to a temp file in the same directory, then rename — rename is atomic within a single
// directory, so a crash mid-write can't leave the user's source file half-overwritten (the live
// file stays intact until the rename swaps in the full new content). Clean up the temp file if
@@ -77,4 +221,4 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
}
return { path: resolved, replacements: replace_all ? occurrences : 1 };
},
};
};
+1 -1
View File
@@ -14,7 +14,7 @@ const schema = z.object({
export const grepTool: ToolDef<z.infer<typeof schema>> = {
name: "grep",
description: "Search file contents for a regular expression pattern using ripgrep.",
description: "Search file contents for a regular expression pattern using ripgrep. Use to find where a symbol/function/word is used across the codebase, or to locate files containing specific text. Faster than reading files one by one.",
schema,
mutating: false,
handler: async ({ pattern, path: searchPath, glob, case_insensitive, max_results }, ctx) => {
+13
View File
@@ -1,13 +1,17 @@
import { agentTool } from "./agentTool.js";
import { definitionTool, referencesTool, diagnosticsTool } from "./codeIntel.js";
import { bashTool } from "./bash.js";
import { bashKillTool } from "./bashKill.js";
import { bashOutputTool } from "./bashOutput.js";
import { editFileTool } from "./editFile.js";
import { multiEditTool } from "./multiEdit.js";
import { gitCommitTool, gitStatusTool } from "./git.js";
import { grepTool } from "./grep.js";
import { listFilesTool } from "./listFiles.js";
import { notebookEditTool } from "./notebookEdit.js";
import { readFileTool } from "./readFile.js";
import { todoWriteTool } from "./todoWrite.js";
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool } from "./task.js";
import { webFetchTool } from "./webFetch.js";
import { webSearchTool } from "./webSearch.js";
import { writeFileTool } from "./writeFile.js";
@@ -17,16 +21,25 @@ export const TOOLS: ToolDef[] = [
readFileTool,
listFilesTool,
grepTool,
definitionTool,
referencesTool,
diagnosticsTool,
webSearchTool,
webFetchTool,
gitStatusTool,
writeFileTool,
editFileTool,
multiEditTool,
notebookEditTool,
bashTool,
bashOutputTool,
bashKillTool,
gitCommitTool,
todoWriteTool,
taskCreateTool,
taskListTool,
taskGetTool,
taskUpdateTool,
agentTool,
];
+1 -1
View File
@@ -17,7 +17,7 @@ const MAX_MATCHES = 500;
export const listFilesTool: ToolDef<z.infer<typeof schema>> = {
name: "list_files",
description: "List files matching a glob pattern.",
description: "List files matching a glob pattern (e.g. `src/**/*.ts`). Use to explore the project structure or find files by name/extension before reading them.",
schema,
mutating: false,
handler: async ({ pattern, cwd }, ctx) => {
+153
View File
@@ -0,0 +1,153 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { multiEditTool } from "./multiEdit.js";
import type { ToolContext } from "./types.js";
describe("multiEdit tool", () => {
let cwd: string;
let ctx: ToolContext;
beforeEach(() => {
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-multiedit-"));
ctx = { cwd };
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it("applies an ordered batch to one file in a single atomic write", async () => {
writeFileSync(path.join(cwd, "code.ts"), "const A = 1;\nconst B = 2;\nconst C = 3;\n");
const result = (await multiEditTool.handler(
{
path: "code.ts",
edits: [
{ old_string: "const A = 1;", new_string: "const A = 10;" },
{ old_string: "const C = 3;", new_string: "const C = 30;" },
],
},
ctx,
)) as { applied: number };
expect(result.applied).toBe(2);
expect(readFileSync(path.join(cwd, "code.ts"), "utf-8")).toBe(
"const A = 10;\nconst B = 2;\nconst C = 30;\n",
);
});
it("an earlier edit can change the text a later edit matches", async () => {
writeFileSync(path.join(cwd, "f.txt"), "alpha\n");
await multiEditTool.handler(
{
path: "f.txt",
edits: [
{ old_string: "alpha", new_string: "beta" },
{ old_string: "beta", new_string: "gamma" },
],
},
ctx,
);
expect(readFileSync(path.join(cwd, "f.txt"), "utf-8")).toBe("gamma\n");
});
it("errors on the first edit that doesn't match, naming the edit index", async () => {
writeFileSync(path.join(cwd, "f.txt"), "alpha\n");
await expect(
multiEditTool.handler(
{
path: "f.txt",
edits: [
{ old_string: "alpha", new_string: "beta" },
{ old_string: "missing", new_string: "x" },
],
},
ctx,
),
).rejects.toThrow(/Edit 2: old_string not found/);
});
it("errors when an old_string is ambiguous and replace_all is not set", async () => {
writeFileSync(path.join(cwd, "f.txt"), "dup\ndup\n");
await expect(
multiEditTool.handler(
{
path: "f.txt",
edits: [{ old_string: "dup", new_string: "x" }],
},
ctx,
),
).rejects.toThrow(/Edit 1: old_string appears 2 times/);
});
it("replace_all applies to all occurrences within the batch step", async () => {
writeFileSync(path.join(cwd, "f.txt"), "dup\ndup\n");
await multiEditTool.handler(
{
path: "f.txt",
edits: [{ old_string: "dup", new_string: "x", replace_all: true }],
},
ctx,
);
expect(readFileSync(path.join(cwd, "f.txt"), "utf-8")).toBe("x\nx\n");
});
it("refuses to edit outside the working directory", async () => {
await expect(
multiEditTool.handler(
{ path: "../escape.txt", edits: [{ old_string: "a", new_string: "b" }] },
ctx,
),
).rejects.toThrow(/outside the working directory/);
});
it("preview produces a unified diff of the full batch", async () => {
writeFileSync(path.join(cwd, "f.txt"), "one\ntwo\n");
const preview = await multiEditTool.preview!(
{
path: "f.txt",
edits: [
{ old_string: "one", new_string: "ONE" },
{ old_string: "two", new_string: "TWO" },
],
},
ctx,
);
expect(preview).toMatch(/-one/);
expect(preview).toMatch(/\+ONE/);
expect(preview).toMatch(/-two/);
expect(preview).toMatch(/\+TWO/);
});
it("preview warns when a batch edit will fail", async () => {
writeFileSync(path.join(cwd, "f.txt"), "one\n");
const preview = await multiEditTool.preview!(
{
path: "f.txt",
edits: [{ old_string: "missing", new_string: "x" }],
},
ctx,
);
expect(preview).toMatch(/Edit 1: old_string not found/);
});
// Same LF-vs-CRLF mismatch as editFile.test.ts: read_file always shows the model LF content, so
// a batch's old_string/new_string must match against a CRLF file's LF-normalized text, and the
// result written back must preserve the file's original CRLF convention.
it("matches LF old_strings against a CRLF file and preserves CRLF on write", async () => {
writeFileSync(path.join(cwd, "code.ts"), "const A = 1;\r\nconst B = 2;\r\nconst C = 3;\r\n");
await multiEditTool.handler(
{
path: "code.ts",
edits: [
{ old_string: "const A = 1;", new_string: "const A = 10;" },
{ old_string: "const C = 3;", new_string: "const C = 30;" },
],
},
ctx,
);
expect(readFileSync(path.join(cwd, "code.ts"), "utf-8")).toBe(
"const A = 10;\r\nconst B = 2;\r\nconst C = 30;\r\n",
);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { createPatch } from "diff";
import { randomBytes } from "node:crypto";
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
import { z } from "zod";
import { resolveWithinCwd } from "./pathGuard.js";
import { applyEdit, countOccurrences, detectEol, fromLF, toLF } from "./editFile.js";
import type { ToolDef } from "./types.js";
// A single edit within a multi_edit batch. Mirrors edit_file's args minus `path` (which is shared
// across the whole batch). Each edit is applied in array order to the result of the previous one, so
// an earlier edit can shift the text a later edit matches — that's why each old_string is checked
// against the running result, not the original file.
const editSchema = z.object({
old_string: z.string().describe("Exact text to replace. Must match the current file content exactly at this point in the batch — earlier edits may have shifted it."),
new_string: z.string().describe("Replacement text."),
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
});
const schema = z.object({
path: z.string().describe("File path to edit, relative to the working directory or absolute."),
edits: z.array(editSchema).min(1).describe("Ordered list of edits to apply to the same file, one after another. Each edit sees the result of the previous one."),
});
/** Applies a batch of edits to an in-memory string, validating each. Throws on the first edit that
* doesn't match uniquely (unless its replace_all is set) or doesn't match at all. Edits apply to the
* running result, so an earlier edit can change the text a later edit matches. `original` and every
* edit's old_string/new_string must already be LF-normalized (see editFile.ts's detectEol/toLF —
* read_file shows the model LF-only content regardless of the file's real line endings, so matching
* must happen in that same space). */
function applyBatch(
original: string,
edits: { old_string: string; new_string: string; replace_all?: boolean }[],
filePath: string,
): string {
let current = original;
edits.forEach((edit, i) => {
const oldLF = toLF(edit.old_string);
const occurrences = countOccurrences(current, oldLF);
if (occurrences === 0) {
throw new Error(
`Edit ${i + 1}: old_string not found in ${filePath}. Earlier edits may have shifted the text — re-read the file and adjust. Make sure it matches exactly, including whitespace.`,
);
}
if (occurrences > 1 && !edit.replace_all) {
throw new Error(
`Edit ${i + 1}: old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
);
}
current = applyEdit(current, oldLF, toLF(edit.new_string), edit.replace_all);
});
return current;
}
export const multiEditTool: ToolDef<z.infer<typeof schema>> = {
name: "multi_edit",
description:
"Apply several edits to the same file in one call, in order. Each edit is {old_string, new_string, replace_all?}. " +
"Use this instead of repeated edit_file calls when you have multiple distinct changes to one file — it's one confirmation " +
"and one atomic write instead of N round-trips. Each old_string must match uniquely at its point in the batch " +
"(unless replace_all is set). Read the file first.",
schema,
mutating: true,
preview: async ({ path: filePath, edits }, ctx) => {
let resolved: string;
try {
resolved = resolveWithinCwd(ctx.cwd, filePath);
} catch (err) {
return (err as Error).message;
}
let raw: string;
try {
raw = await fsReadFile(resolved, "utf-8");
} catch {
return `File ${resolved} does not exist.`;
}
try {
const eol = detectEol(raw);
const updated = fromLF(applyBatch(toLF(raw), edits, filePath), eol);
return createPatch(resolved, raw, updated, "", "");
} catch (err) {
return `Warning: ${(err as Error).message} — this edit will fail.`;
}
},
handler: async ({ path: filePath, edits }, ctx) => {
const resolved = resolveWithinCwd(ctx.cwd, filePath);
const raw = await fsReadFile(resolved, "utf-8");
const eol = detectEol(raw);
const updated = fromLF(applyBatch(toLF(raw), edits, filePath), eol);
// Atomic write via temp+rename (same rationale as edit_file): a crash mid-write can't leave the
// user's source file half-overwritten — the live file stays intact until the rename swaps in the
// full new content. Clean up the temp file if anything fails so a stray `.tmp` doesn't accumulate.
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
try {
await fsWriteFile(tmp, updated, "utf-8");
await fsRename(tmp, resolved);
} catch (err) {
await fsUnlink(tmp).catch(() => {});
throw err;
}
return { path: resolved, applied: edits.length };
},
};
+186
View File
@@ -0,0 +1,186 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { notebookEditTool } from "./notebookEdit.js";
/** A minimal valid nbformat 4 notebook with two code cells. */
function minimalNotebook(): string {
return (
JSON.stringify(
{
nbformat: 4,
nbformat_minor: 5,
metadata: {},
cells: [
{ cell_type: "code", id: "c1", source: ["print('a')\n"], metadata: {}, outputs: [], execution_count: null },
{ cell_type: "code", id: "c2", source: ["print('b')\n"], metadata: {}, outputs: [], execution_count: null },
],
},
null,
2,
) + "\n"
);
}
async function makeCwd(): Promise<string> {
return mkdtemp(path.join(tmpdir(), "locode-notebook-"));
}
function parseCells(content: string): { cell_type: string; id?: string; source: string[] }[] {
return (JSON.parse(content) as { cells: { cell_type: string; id?: string; source: string[] }[] }).cells;
}
describe("notebookEditTool", () => {
it("rejects paths that escape the working directory", async () => {
const cwd = await makeCwd();
try {
await expect(
notebookEditTool.handler({ notebook_path: "../outside.ipynb", edit_mode: "delete" }, { cwd }),
).rejects.toThrow(/outside the working directory/);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("replaces a cell source by cell_index", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
await notebookEditTool.handler(
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", new_source: "print('A')\n" },
{ cwd },
);
const cells = parseCells(await readFile(file, "utf-8"));
expect(cells[0]!.source).toEqual(["print('A')\n"]);
expect(cells).toHaveLength(2);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("replaces a cell source by cell_id", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
await notebookEditTool.handler(
{ notebook_path: "nb.ipynb", cell_id: "c2", edit_mode: "replace", new_source: "print('B2')" },
{ cwd },
);
const cells = parseCells(await readFile(file, "utf-8"));
expect(cells[1]!.source).toEqual(["print('B2')"]);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("inserts a new markdown cell at a position, shifting later cells down", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
await notebookEditTool.handler(
{ notebook_path: "nb.ipynb", edit_mode: "insert", cell_index: 1, cell_type: "markdown", new_source: "# heading\n\ntext" },
{ cwd },
);
const cells = parseCells(await readFile(file, "utf-8"));
expect(cells).toHaveLength(3);
expect(cells[1]!.cell_type).toBe("markdown");
expect(cells[1]!.source).toEqual(["# heading\n", "\n", "text"]);
// Original second cell (c2) is now at index 2.
expect(cells[2]!.id).toBe("c2");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("appends a cell when insert omits cell_index", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
await notebookEditTool.handler(
{ notebook_path: "nb.ipynb", edit_mode: "insert", cell_type: "code", new_source: "x = 1" },
{ cwd },
);
const cells = parseCells(await readFile(file, "utf-8"));
expect(cells).toHaveLength(3);
expect(cells[2]!.source).toEqual(["x = 1"]);
expect(cells[2]!.cell_type).toBe("code");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("deletes a cell by cell_id, and a missing id fails", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
await notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_id: "c1", edit_mode: "delete" }, { cwd });
const cells = parseCells(await readFile(file, "utf-8"));
expect(cells).toHaveLength(1);
expect(cells[0]!.id).toBe("c2");
// A missing id fails.
await expect(
notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_id: "nope", edit_mode: "delete" }, { cwd }),
).rejects.toThrow(/not found/);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("rejects insert without cell_type, and replace without new_source", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
await expect(
notebookEditTool.handler({ notebook_path: "nb.ipynb", edit_mode: "insert", new_source: "x" }, { cwd }),
).rejects.toThrow(/cell_type/);
await expect(
notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace" }, { cwd }),
).rejects.toThrow(/new_source/);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("produces a diff preview for an edit", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
const preview = await notebookEditTool.preview!(
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", new_source: "print('A')\n" },
{ cwd },
);
expect(preview).toContain("@@");
expect(preview).toContain("print('A')");
expect(preview).toContain("print('a')");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("switching a code cell to markdown drops code-only fields", async () => {
const cwd = await makeCwd();
try {
const file = path.join(cwd, "nb.ipynb");
await writeFile(file, minimalNotebook());
await notebookEditTool.handler(
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", cell_type: "markdown", new_source: "prose" },
{ cwd },
);
const cell = parseCells(await readFile(file, "utf-8"))[0]!;
expect(cell.cell_type).toBe("markdown");
expect(cell).not.toHaveProperty("execution_count");
expect(cell).not.toHaveProperty("outputs");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
});
+160
View File
@@ -0,0 +1,160 @@
import { createPatch } from "diff";
import { randomBytes } from "node:crypto";
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
import { z } from "zod";
import { resolveWithinCwd } from "./pathGuard.js";
import type { ToolDef } from "./types.js";
// .ipynb is a JSON document (nbformat 4): { nbformat, nbformat_minor, metadata, cells: Cell[] }.
// Each cell is { cell_type: "code"|"markdown"|"raw", id?, source, metadata, outputs?, execution_count? }.
// `source` is a list of strings where every line except the last carries a trailing "\n" (nbformat
// convention). We convert the model's single-string new_source to/from that array form, so the
// model never has to hand-write nbformat's line-array quirk — it just gives the full cell text.
type Notebook = { nbformat: number; nbformat_minor: number; metadata: Record<string, unknown>; cells: Cell[] };
type Cell = { cell_type: string; id?: string; source: string[]; metadata: Record<string, unknown>; outputs?: unknown[]; execution_count?: unknown };
const schema = z.object({
notebook_path: z.string().describe("Path to the .ipynb notebook to edit, relative to the working directory or absolute."),
cell_id: z.string().optional().describe("The id of the cell to replace or delete. Ignored for insert."),
cell_index: z
.number()
.int()
.optional()
.describe("0-based index of the cell to replace or delete; for insert, the position to insert at (defaults to append)."),
cell_type: z.enum(["code", "markdown", "raw"]).optional().describe("Required for insert. For replace, overrides the existing cell's type if given."),
edit_mode: z.enum(["replace", "insert", "delete"]).default("replace").describe("Whether to replace a cell, insert a new one, or delete."),
new_source: z.string().optional().describe("The new cell source as a single string (required for replace and insert)."),
});
/** Converts a plain multi-line string into nbformat's source array: each line carries a trailing
* "\n" except the last, and a trailing newline in the input is preserved (so "a\n" => ["a\n"], not
* ["a\n", ""]). An empty source becomes an empty array. Round-trips: array.join("") === input. */
function toSourceArray(source: string): string[] {
if (!source) return [];
let lines = source.split("\n");
// split("a\n") => ["a", ""] — the trailing "" is an artifact of the trailing newline, not a real
// empty last line. Drop it and remember the input ended with \n so the now-last line keeps its \n.
const endedWithNewline = lines.length > 1 && lines[lines.length - 1] === "";
if (endedWithNewline) lines = lines.slice(0, -1);
return lines.map((line, i) => (i < lines.length - 1 || endedWithNewline ? line + "\n" : line));
}
/** Finds a cell's index by id (if present) falling back to the explicit index. Returns -1 if not
* found. Used by replace/delete. */
function findCellIndex(notebook: Notebook, cellId: string | undefined, cellIndex: number | undefined): number {
if (cellId !== undefined) {
return notebook.cells.findIndex((c) => c.id === cellId);
}
if (cellIndex !== undefined) {
return cellIndex >= 0 && cellIndex < notebook.cells.length ? cellIndex : -1;
}
return -1;
}
function applyNotebookEdit(notebook: Notebook, args: z.infer<typeof schema>, notebookPath: string): Notebook {
const mode = args.edit_mode ?? "replace";
if (mode === "insert") {
if (!args.cell_type) throw new Error("insert requires 'cell_type'.");
if (args.new_source === undefined) throw new Error("insert requires 'new_source'.");
const cell: Cell = {
cell_type: args.cell_type,
source: toSourceArray(args.new_source),
metadata: {},
};
if (args.cell_type === "code") {
cell.execution_count = null;
cell.outputs = [];
}
const insertAt = args.cell_index ?? notebook.cells.length;
if (insertAt < 0 || insertAt > notebook.cells.length) {
throw new Error(`insert cell_index ${insertAt} is out of range (0–${notebook.cells.length}).`);
}
notebook.cells.splice(insertAt, 0, cell);
return notebook;
}
const idx = findCellIndex(notebook, args.cell_id, args.cell_index);
if (idx === -1) {
const where = args.cell_id !== undefined ? `cell_id "${args.cell_id}"` : `cell_index ${args.cell_index}`;
throw new Error(`${mode}: ${where} not found in ${notebookPath}.`);
}
if (mode === "delete") {
notebook.cells.splice(idx, 1);
return notebook;
}
// replace
if (args.new_source === undefined) throw new Error("replace requires 'new_source'.");
const cell = notebook.cells[idx]!;
if (args.cell_type) cell.cell_type = args.cell_type;
cell.source = toSourceArray(args.new_source);
// Switching to a non-code cell type drops code-only fields; switching to code adds them.
if (cell.cell_type === "code") {
cell.execution_count ??= null;
cell.outputs ??= [];
} else {
delete cell.execution_count;
delete cell.outputs;
}
return notebook;
}
export const notebookEditTool: ToolDef<z.infer<typeof schema>> = {
name: "notebook_edit",
description:
"Edit a Jupyter (.ipynb) notebook cell-aware: replace, insert, or delete a cell by cell_id or cell_index. " +
"new_source is the full new cell source as a single string. Read the notebook first (read_file shows the JSON). " +
"Prefer this over edit_file/write_file for .ipynb so the JSON structure stays valid.",
schema,
mutating: true,
preview: async (args, ctx) => {
let resolved: string;
try {
resolved = resolveWithinCwd(ctx.cwd, args.notebook_path);
} catch (err) {
return (err as Error).message;
}
let original: string;
try {
original = await fsReadFile(resolved, "utf-8");
} catch {
return `Notebook ${resolved} does not exist.`;
}
let notebook: Notebook;
try {
notebook = JSON.parse(original) as Notebook;
} catch {
return `Warning: ${resolved} is not valid JSON — this edit will fail.`;
}
try {
const updated = applyNotebookEdit(structuredClone(notebook), args, args.notebook_path);
return createPatch(resolved, original, JSON.stringify(updated, null, 2) + "\n", "", "");
} catch (err) {
return `Warning: ${(err as Error).message} — this edit will fail.`;
}
},
handler: async (args, ctx) => {
const resolved = resolveWithinCwd(ctx.cwd, args.notebook_path);
const original = await fsReadFile(resolved, "utf-8");
let notebook: Notebook;
try {
notebook = JSON.parse(original) as Notebook;
} catch {
throw new Error(`${resolved} is not valid JSON — can't edit as a notebook.`);
}
if (!Array.isArray(notebook.cells)) throw new Error(`${resolved} has no cells array — not a valid .ipynb.`);
applyNotebookEdit(notebook, args, args.notebook_path);
const updated = JSON.stringify(notebook, null, 2) + "\n";
// Atomic write via temp+rename (same rationale as edit_file/multi_edit): a crash mid-write can't
// leave the notebook half-overwritten. Clean up the temp file if anything fails.
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
try {
await fsWriteFile(tmp, updated, "utf-8");
await fsRename(tmp, resolved);
} catch (err) {
await fsUnlink(tmp).catch(() => {});
throw err;
}
return { path: resolved, edit_mode: args.edit_mode ?? "replace", cell_count: notebook.cells.length };
},
};
+36
View File
@@ -0,0 +1,36 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PathOutsideCwdError, resolveWithinCwd } from "./pathGuard.js";
describe("resolveWithinCwd", () => {
const cwd = path.resolve("/project");
it("resolves a plain relative path inside cwd", () => {
expect(resolveWithinCwd(cwd, "src/index.ts")).toBe(path.join(cwd, "src", "index.ts"));
});
it("resolves an absolute path that happens to already be inside cwd", () => {
const inside = path.join(cwd, "foo.txt");
expect(resolveWithinCwd(cwd, inside)).toBe(inside);
});
it("resolves cwd itself", () => {
expect(resolveWithinCwd(cwd, ".")).toBe(cwd);
});
it("rejects a ../ escape", () => {
expect(() => resolveWithinCwd(cwd, "../outside.txt")).toThrow(PathOutsideCwdError);
});
it("rejects a deeper ../../ escape", () => {
expect(() => resolveWithinCwd(cwd, "sub/../../outside.txt")).toThrow(PathOutsideCwdError);
});
it("rejects an absolute path outside cwd", () => {
expect(() => resolveWithinCwd(cwd, path.resolve("/etc/passwd"))).toThrow(PathOutsideCwdError);
});
it("rejects the filesystem root", () => {
expect(() => resolveWithinCwd(cwd, path.parse(cwd).root)).toThrow(PathOutsideCwdError);
});
});
+27
View File
@@ -0,0 +1,27 @@
import path from "node:path";
/** Thrown by resolveWithinCwd — kept as its own class only so callers can recognize it (via
* instanceof) if they ever need to react differently than a plain thrown Error. */
export class PathOutsideCwdError extends Error {}
/** Resolves `targetPath` against `cwd` and hard-blocks the result if it would land outside the
* project root (the working directory locode was launched in) — an absolute path elsewhere on
* disk, a `../` escape, or (on Windows) a path on a different drive all reject. This applies
* unconditionally, regardless of permission mode: even `auto-accept` skips a tool's `preview`
* entirely (see gateAndRun in agent/loop.ts), so this check has to live in each tool's `handler`
* — which always runs — to actually hold as a floor rather than just a confirmation-dialog hint.
* It's deliberately not configurable; a model tricked (or simply mistaken) into targeting a path
* outside the project shouldn't be one auto-approved call away from touching it. */
export function resolveWithinCwd(cwd: string, targetPath: string): string {
const resolved = path.resolve(cwd, targetPath);
const rel = path.relative(cwd, resolved);
// rel === "" is targetPath resolving to cwd itself — fine. Anything starting with ".." walked
// upward out of cwd; an absolute rel (Windows: a different drive, e.g. "D:\foo") never went
// through cwd's tree in the first place. Either way, it's outside.
if (rel !== "" && (rel.startsWith(`..${path.sep}`) || rel === ".." || path.isAbsolute(rel))) {
throw new PathOutsideCwdError(
`Refusing to write outside the working directory: "${targetPath}" resolves to ${resolved}, which is not inside ${cwd}.`,
);
}
return resolved;
}
+40 -3
View File
@@ -9,6 +9,18 @@ function withinWorkspace(resolved: string, workspace: string): boolean {
return !rel.startsWith("..") && !path.isAbsolute(rel);
}
// Reject files larger than this so we never accidentally OOM on a huge binary or log file.
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
// Common binary extensions — if the file extension matches, reject without reading.
const BINARY_EXTENSIONS = new Set([
".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".o", ".obj", ".pyc", ".pyo",
".class", ".jar", ".war", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
".iso", ".dmg", ".pdb", ".lib", ".a", ".woff", ".woff2", ".eot", ".ttf", ".otf",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".sqlite", ".db", ".ico", ".cur",
]);
// Cap on how much text a single read_file call returns, so a huge file can't blow up the context
// in one call. Cut on a line boundary (never mid-line) and report the exact next offset, so the
// model can page through the rest with `offset` instead of re-reading the same truncated prefix in
@@ -26,7 +38,8 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
name: "read_file",
description:
"Read a local file. Text files return 1-indexed lines; large files are paginated (use nextOffset for next page). " +
"Image files (png, jpg, jpeg, gif, webp, bmp) are returned as image content (requires vision-capable model).",
"Image files (png, jpg, jpeg, gif, webp, bmp) are returned as image content (requires vision-capable model). " +
"Use to inspect file contents before editing, or to understand existing code. Prefer this over bash cat for files.",
schema,
mutating: false,
handler: async ({ path: filePath, offset, limit }, ctx) => {
@@ -35,9 +48,16 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
throw new Error(`File ${filePath} resolves outside the workspace.`);
}
// --- Size guard: reject files over MAX_FILE_SIZE before reading ---
const stats = await fsStat(resolved);
if (stats.size > MAX_FILE_SIZE) {
throw new Error(
`${filePath} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_FILE_SIZE / 1_048_576}MB read limit.`,
);
}
const mimeType = imageMimeType(resolved);
if (mimeType) {
const stats = await fsStat(resolved);
if (stats.size > MAX_IMAGE_BYTES) {
throw new Error(
`${filePath} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1_048_576}MB limit for image reads.`,
@@ -47,8 +67,25 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
return { path: resolved, image: true, mimeType, bytes: buffer.byteLength, base64: buffer.toString("base64") };
}
// --- Binary guard: reject by extension ---
const ext = path.extname(resolved).toLowerCase();
if (BINARY_EXTENSIONS.has(ext)) {
throw new Error(
`${filePath} looks like a binary file (${ext}). Use bash for binary inspection.`,
);
}
const content = await fsReadFile(resolved, "utf-8");
const lines = content.split("\n");
// --- Binary guard: null-byte heuristic (catches extensionless binaries) ---
const nullIndex = content.indexOf("\0");
if (nullIndex !== -1) {
throw new Error(
`${filePath} appears to be a binary file (null byte at position ${nullIndex}). Use bash for binary inspection.`,
);
}
const lines = content.split(/\r?\n/);
const start = offset ? offset - 1 : 0;
const requestedEnd = limit ? Math.min(start + limit, lines.length) : lines.length;
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool, TaskStore } from "./task.js";
import type { ToolContext } from "./types.js";
function ctxWithStore(): { ctx: ToolContext; store: TaskStore } {
const store = new TaskStore();
return { ctx: { taskStore: store } as ToolContext, store };
}
describe("task tools", () => {
it("task_create creates a pending task and returns it with an id", async () => {
const { ctx, store } = ctxWithStore();
const out = (await taskCreateTool.handler({ subject: "Fix bug", description: "details" }, ctx)) as {
id: string;
task: { status: string; blocks: string[]; blockedBy: string[] };
};
expect(out.id).toMatch(/^t\d+$/);
expect(out.task.status).toBe("pending");
expect(out.task.blocks).toEqual([]);
expect(out.task.blockedBy).toEqual([]);
expect(store.list()).toHaveLength(1);
});
it("task_list returns all task summaries", async () => {
const { ctx } = ctxWithStore();
await taskCreateTool.handler({ subject: "A", description: "x" }, ctx);
await taskCreateTool.handler({ subject: "B", description: "y" }, ctx);
const out = (await taskListTool.handler({}, ctx)) as { tasks: { subject: string }[] };
expect(out.tasks.map((t) => t.subject)).toEqual(["A", "B"]);
});
it("task_get returns full details and errors on unknown id", async () => {
const { ctx } = ctxWithStore();
const created = (await taskCreateTool.handler({ subject: "A", description: "long desc" }, ctx)) as {
id: string;
task: { description: string };
};
const got = (await taskGetTool.handler({ taskId: created.id }, ctx)) as { task: { description: string } };
expect(got.task.description).toBe("long desc");
const miss = (await taskGetTool.handler({ taskId: "nope" }, ctx)) as { error: string };
expect(miss.error).toMatch(/not found/);
});
it("task_update sets status and marks in_progress/completed", async () => {
const { ctx } = ctxWithStore();
const created = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const upd = (await taskUpdateTool.handler({ taskId: created.id, status: "in_progress" }, ctx)) as {
task: { status: string };
};
expect(upd.task.status).toBe("in_progress");
const done = (await taskUpdateTool.handler({ taskId: created.id, status: "completed" }, ctx)) as {
task: { status: string };
};
expect(done.task.status).toBe("completed");
});
it("addBlocks/addBlockedBy link two tasks both ways", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
// B is blocked by A (one-directional: sets B.blockedBy, not A.blocks)
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
expect(bAfter.task.blockedBy).toContain(a.id);
// Add the back-ref explicitly: A blocks B.
await taskUpdateTool.handler({ taskId: a.id, addBlocks: [b.id] }, ctx);
const aAfter = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blocks: string[] } };
expect(aAfter.task.blocks).toContain(b.id);
});
it("ignores self-refs, unknown ids, and direct 2-cycles", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
// self-ref ignored
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [a.id] }, ctx);
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
// unknown id ignored
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: ["zzz"] }, ctx);
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
// B waits on A; now make A wait on B — should be skipped (2-cycle)
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [b.id] }, ctx);
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
});
it("status deleted removes the task and prunes dangling refs", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
const del = (await taskUpdateTool.handler({ taskId: a.id, status: "deleted" }, ctx)) as { deleted: string };
expect(del.deleted).toBe(a.id);
// B no longer blocked by the removed A
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
expect(bAfter.task.blockedBy).toEqual([]);
expect(((await taskListTool.handler({}, ctx)) as { tasks: unknown[] }).tasks).toHaveLength(1);
});
it("metadata merge-patch: set keys, null deletes", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x", metadata: { k: 1 } }, ctx)) as { id: string };
await taskUpdateTool.handler({ taskId: a.id, metadata: { k2: "v" } }, ctx);
let t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
expect(t.task.metadata).toEqual({ k: 1, k2: "v" });
await taskUpdateTool.handler({ taskId: a.id, metadata: { k: null } }, ctx);
t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
expect(t.task.metadata).toEqual({ k2: "v" });
});
it("returns an error when taskStore is absent", async () => {
const ctx = {} as ToolContext;
const out = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { error: string };
expect(out.error).toMatch(/not available/);
});
});
+296
View File
@@ -0,0 +1,296 @@
import { z } from "zod";
import type { ToolDef } from "./types.js";
/** A task's lifecycle state. `deleted` is only used as an update target (it removes the task); it is
* never a stored status. */
export type TaskStatus = "pending" | "in_progress" | "completed";
/** A structured, trackable unit of work. Tasks form a dependency graph via `blocks`/`blockedBy`
* (each lists the other's task ids), can be owned/claimed by a named agent, and carry free-form
* metadata. Unlike the flat todo list, tasks are created and updated incrementally (not replaced
* wholesale) so dependencies and ownership can be expressed. */
export interface Task {
id: string;
subject: string;
description: string;
/** Present-continuous label shown in a spinner while the task is in_progress (e.g. "Running tests"). */
activeForm?: string;
status: TaskStatus;
/** Who has claimed the task (an agent name). Unset = unclaimed. */
owner?: string;
/** Ids of tasks THIS task blocks (i.e. that depend on it). */
blocks: string[];
/** Ids of tasks that must be completed before this one can start. */
blockedBy: string[];
/** Free-form metadata; merge-patched on update (set a key to null to delete it). */
metadata?: Record<string, unknown>;
}
/** A compact, list-view projection of a Task — enough to render the checklist without the long
* description/metadata. */
export interface TaskSummary {
id: string;
subject: string;
status: TaskStatus;
owner?: string;
blockedBy: string[];
}
/** JSON-serializable shape for persisting a TaskStore. */
export interface TaskStoreSnapshot {
seq: number;
tasks: Task[];
}
/** In-memory task store. The task tools operate on it via `ctx.taskStore`. Mutations emit a
* snapshot through an optional `onChange` callback the loop wires up, so each create/update can
* refresh a UI checklist. Purely in-memory (per-session); not persisted. */
export class TaskStore {
private tasks = new Map<string, Task>();
private seq = 0;
private emitter: ((tasks: TaskSummary[]) => void) | undefined;
/** Wired by the host so the store can broadcast a snapshot after each mutation. */
setEmitter(emit: (tasks: TaskSummary[]) => void): void {
this.emitter = emit;
}
private nextId(): string {
this.seq += 1;
return `t${this.seq}`;
}
private snapshot(): TaskSummary[] {
return [...this.tasks.values()].map((t) => ({
id: t.id,
subject: t.subject,
status: t.status,
owner: t.owner,
blockedBy: [...t.blockedBy],
}));
}
private emit(): void {
this.emitter?.(this.snapshot());
}
create(input: {
subject: string;
description: string;
activeForm?: string;
metadata?: Record<string, unknown>;
}): Task {
const task: Task = {
id: this.nextId(),
subject: input.subject,
description: input.description,
activeForm: input.activeForm,
status: "pending",
blocks: [],
blockedBy: [],
metadata: input.metadata ? { ...input.metadata } : undefined,
};
this.tasks.set(task.id, task);
this.emit();
return task;
}
list(): TaskSummary[] {
return this.snapshot();
}
get(id: string): Task | undefined {
return this.tasks.get(id);
}
/** Applies an update. `status: "deleted"` removes the task (and prunes dangling block/blockedBy
* refs). Returns the updated task, or undefined if the task was deleted or doesn't exist. */
update(
id: string,
updates: {
status?: TaskStatus | "deleted";
subject?: string;
description?: string;
activeForm?: string;
owner?: string;
addBlocks?: string[];
addBlockedBy?: string[];
metadata?: Record<string, unknown>;
},
): Task | undefined {
const task = this.tasks.get(id);
if (!task) return undefined;
if (updates.status === "deleted") {
this.remove(id);
return undefined;
}
if (updates.status) task.status = updates.status;
if (updates.subject !== undefined) task.subject = updates.subject;
if (updates.description !== undefined) task.description = updates.description;
if (updates.activeForm !== undefined) task.activeForm = updates.activeForm;
if (updates.owner !== undefined) task.owner = updates.owner;
if (updates.addBlocks) {
for (const b of updates.addBlocks) {
// Only link to existing OTHER tasks; ignore self-refs, unknown ids, and duplicates.
if (b !== id && this.tasks.has(b) && !task.blocks.includes(b)) task.blocks.push(b);
}
}
if (updates.addBlockedBy) {
for (const b of updates.addBlockedBy) {
if (b === id || !this.tasks.has(b) || task.blockedBy.includes(b)) continue;
// Skip a direct 2-cycle: if b is already waiting on this task, don't make them wait on each other.
const other = this.tasks.get(b);
if (other && other.blockedBy.includes(id)) continue;
task.blockedBy.push(b);
}
}
if (updates.metadata) {
task.metadata = mergeMetadata(task.metadata, updates.metadata);
}
this.emit();
return task;
}
private remove(id: string): void {
this.tasks.delete(id);
// Prune dangling dependency refs in surviving tasks.
for (const other of this.tasks.values()) {
other.blocks = other.blocks.filter((b) => b !== id);
other.blockedBy = other.blockedBy.filter((b) => b !== id);
}
this.emit();
}
/** Serialize the entire store for persistence. */
toJSON(): TaskStoreSnapshot {
return {
seq: this.seq,
tasks: [...this.tasks.values()].map(serializeTask),
};
}
/** Restore a store from a previously-serialized snapshot. */
static fromJSON(snapshot: TaskStoreSnapshot): TaskStore {
const store = new TaskStore();
store.seq = snapshot.seq;
for (const task of snapshot.tasks) {
store.tasks.set(task.id, { ...task, blocks: [...task.blocks], blockedBy: [...task.blockedBy], metadata: task.metadata ? { ...task.metadata } : undefined });
}
return store;
}
}
/** Merge-patches metadata: a null value deletes the key, any other value sets it. */
function mergeMetadata(
existing: Record<string, unknown> | undefined,
patch: Record<string, unknown>,
): Record<string, unknown> | undefined {
const out: Record<string, unknown> = { ...(existing ?? {}) };
for (const [k, v] of Object.entries(patch)) {
if (v === null) delete out[k];
else out[k] = v;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Returns a deep-enough copy of a task so handing it back in a tool result can't let the caller
* mutate the store's internal object. */
function serializeTask(t: Task): Task {
return {
...t,
blocks: [...t.blocks],
blockedBy: [...t.blockedBy],
metadata: t.metadata ? { ...t.metadata } : undefined,
};
}
const metadataSchema = z.record(z.string(), z.any()).optional();
const taskCreateSchema = z.object({
subject: z.string().min(1).describe("A brief, actionable title in imperative form (e.g. 'Fix authentication bug')."),
description: z.string().describe("What needs to be done, in enough detail to act on."),
activeForm: z
.string()
.optional()
.describe("Present-continuous label shown in the spinner while in_progress (e.g. 'Running tests'). Optional."),
metadata: metadataSchema,
});
export const taskCreateTool: ToolDef<z.infer<typeof taskCreateSchema>> = {
name: "task_create",
description:
"Create a structured task to track a unit of multi-step work. Use for non-trivial work (3+ steps) so progress is " +
"visible and dependencies can be expressed. Returns the new task with its id. Call task_list to see all tasks, " +
"task_get for full details, and task_update to set status, add dependencies (addBlocks/addBlockedBy), or claim ownership.",
schema: taskCreateSchema,
// Purely informational (tracks state in-memory, never touches the filesystem) — no confirmation prompt.
mutating: false,
handler: async (args, ctx) => {
if (!ctx.taskStore) return { error: "Task tracking is not available in this context." };
const task = ctx.taskStore.create(args);
return { id: task.id, task: serializeTask(task) };
},
};
const taskListSchema = z.object({});
export const taskListTool: ToolDef<z.infer<typeof taskListSchema>> = {
name: "task_list",
description:
"List all tasks with their id, subject, status, owner, and what blocks them. Use this to see overall progress and " +
"find the next available task to claim.",
schema: taskListSchema,
mutating: false,
handler: async (_args, ctx) => {
return { tasks: ctx.taskStore?.list() ?? [] };
},
};
const taskGetSchema = z.object({ taskId: z.string().min(1) });
export const taskGetTool: ToolDef<z.infer<typeof taskGetSchema>> = {
name: "task_get",
description:
"Get a task's full details (description, activeForm, blocks, blockedBy, metadata). Use before starting a task to " +
"verify its blockedBy list is empty — if it isn't, the blocking tasks must complete first.",
schema: taskGetSchema,
mutating: false,
handler: async (args, ctx) => {
const task = ctx.taskStore?.get(args.taskId);
return task ? { task: serializeTask(task) } : { error: `Task ${args.taskId} not found.` };
},
};
const taskUpdateSchema = z.object({
taskId: z.string().min(1),
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
subject: z.string().optional(),
description: z.string().optional(),
activeForm: z.string().optional(),
owner: z.string().optional(),
addBlocks: z.array(z.string()).optional(),
addBlockedBy: z.array(z.string()).optional(),
metadata: metadataSchema,
});
export const taskUpdateTool: ToolDef<z.infer<typeof taskUpdateSchema>> = {
name: "task_update",
description:
"Update a task: set status (pending|in_progress|completed — or 'deleted' to remove it), rename subject/description, " +
"set owner to claim it, add dependencies via addBlocks/addBlockedBy (task ids), or merge-patch metadata (set a key " +
"to null to delete it). Mark a task in_progress when starting it and completed when done. Verify blockedBy is empty " +
"before starting. Returns the updated task, or { deleted: id } when status is 'deleted'.",
schema: taskUpdateSchema,
mutating: false,
handler: async (args, ctx) => {
const store = ctx.taskStore;
if (!store) return { error: "Task tracking is not available in this context." };
if (!store.get(args.taskId)) return { error: `Task ${args.taskId} not found.` };
if (args.status === "deleted") {
store.update(args.taskId, args);
return { deleted: args.taskId };
}
const task = store.update(args.taskId, args);
return task ? { task: serializeTask(task) } : { deleted: args.taskId };
},
};
+21 -1
View File
@@ -1,4 +1,5 @@
import type { z } from "zod";
import type { TaskStore } from "./task.js";
export interface SubAgentTask {
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
@@ -7,6 +8,20 @@ export interface SubAgentTask {
prompt: string;
}
/** Result of one sub-agent in a parallel batch: either its final text, or the error that
* terminated it (timeout, MaxIterationsError, a thrown tool error, etc.). Kept separate from a
* plain string so the parent model can see at a glance which sub-tasks succeeded and which it
* needs to retry or work around — one failed sub-task shouldn't discard the (potentially
* expensive) results of its siblings. */
export interface SubAgentResult {
description: string;
/** The sub-agent's final text answer, or undefined if it failed before producing one. */
result?: string;
/** Present when the sub-agent failed. A timeout, MaxIterationsError, or any other thrown error
* surfaces here rather than rejecting the whole batch. */
error?: string;
}
export interface SubAgentOverrides {
/** Replaces locode's generic sub-agent system prompt entirely — used by plugin-defined agents
* (agents/*.md) that ship their own identity/instructions instead of the generic "delegate a
@@ -24,11 +39,16 @@ export interface TodoItem {
export interface ToolContext {
cwd: string;
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent`
* tool). Runs a SINGLE sub-agent and returns its final text; a parallel batch is the `agent`
* tool's own responsibility (it calls this once per task). */
runSubAgent?: (task: SubAgentTask, overrides?: SubAgentOverrides) => Promise<string>;
/** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a
* future tool context is built without one — every session-backed context provides it. */
setTodos?: (todos: TodoItem[]) => void;
/** The session's structured task store (used by the task_create/list/get/update tools). Absent
* only if a tool context is built without one — every session-backed context provides it. */
taskStore?: TaskStore;
/** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool
* polls `requested` and, once true, detaches into the background job registry instead of
* awaiting completion. Absent for tools that don't support backgrounding. */
+2 -1
View File
@@ -19,7 +19,8 @@ function isBinaryContentType(contentType: string): boolean {
export const webFetchTool: ToolDef<z.infer<typeof schema>> = {
name: "web_fetch",
description:
"Fetch a URL and return readable text (HTML tags/scripts/styles stripped). Use for specific pages found via web_search.",
"Fetch a URL and return readable text (HTML tags/scripts/styles stripped). Use for specific pages found via web_search — " +
"e.g. to read a doc page or blog post in full when the search snippet was not enough.",
schema,
mutating: false,
handler: async ({ url }) => {
+2 -1
View File
@@ -45,7 +45,8 @@ function parseResults(html: string, limit: number): SearchResult[] {
export const webSearchTool: ToolDef<z.infer<typeof schema>> = {
name: "web_search",
description:
"Search the web via DuckDuckGo. Returns title, url, snippet. Use for info not in the local codebase.",
"Search the web via DuckDuckGo. Returns title, url, snippet. Use for info not in the local codebase — " +
"e.g. an unfamiliar API, library docs, or an error message. Follow up with web_fetch on a specific result for full page text.",
schema,
mutating: false,
handler: async ({ query, max_results }) => {
+45
View File
@@ -0,0 +1,45 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { writeFileTool } from "./writeFile.js";
import type { ToolContext } from "./types.js";
describe("writeFile tool — path containment", () => {
let cwd: string;
let ctx: ToolContext;
beforeEach(() => {
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-writefile-"));
ctx = { cwd };
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it("writes a file inside the working directory", async () => {
const result = (await writeFileTool.handler({ path: "note.txt", content: "hi" }, ctx)) as { path: string };
expect(readFileSync(result.path, "utf-8")).toBe("hi");
});
it("refuses to write outside the working directory via ../ traversal", async () => {
await expect(writeFileTool.handler({ path: "../escape.txt", content: "oops" }, ctx)).rejects.toThrow(/outside the working directory/);
});
it("refuses to write to an absolute path outside the working directory", async () => {
const outside = path.join(os.tmpdir(), "locode-outside-target.txt");
await expect(writeFileTool.handler({ path: outside, content: "oops" }, ctx)).rejects.toThrow(/outside the working directory/);
});
it("preview reports the block instead of showing a diff", async () => {
const preview = await writeFileTool.preview!({ path: "../escape.txt", content: "oops" }, ctx);
expect(preview).toMatch(/outside the working directory/);
});
it("still applies even when the escaping subdirectory already exists", async () => {
// Sanity check that the guard runs before mkdir/write, not after.
mkdirSync(path.join(cwd, "sub"), { recursive: true });
await expect(writeFileTool.handler({ path: "sub/../../escape.txt", content: "oops" }, ctx)).rejects.toThrow(/outside the working directory/);
});
});
+23 -8
View File
@@ -1,7 +1,9 @@
import { createPatch } from "diff";
import { mkdir, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { mkdir, readFile as fsReadFile, rename, unlink, writeFile as fsWriteFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { resolveWithinCwd } from "./pathGuard.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
@@ -12,18 +14,24 @@ const schema = z.object({
async function readExisting(resolved: string): Promise<string | null> {
try {
return await fsReadFile(resolved, "utf-8");
} catch {
return null;
} catch (err: any) {
if (err?.code === "ENOENT") return null;
throw err;
}
}
export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
name: "write_file",
description: "Create or overwrite a file with the given content.",
description: "Create or overwrite a file with the given content. Use for new files or full rewrites. For small changes to an existing file, prefer edit_file instead.",
schema,
mutating: true,
preview: async ({ path: filePath, content }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
let resolved: string;
try {
resolved = resolveWithinCwd(ctx.cwd, filePath);
} catch (err) {
return (err as Error).message;
}
const existing = await readExisting(resolved);
if (existing === null) {
return `Create new file ${resolved} (${content.length} chars)`;
@@ -31,9 +39,16 @@ export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
return createPatch(resolved, existing, content, "", "");
},
handler: async ({ path: filePath, content }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
const resolved = resolveWithinCwd(ctx.cwd, filePath);
await mkdir(path.dirname(resolved), { recursive: true });
await fsWriteFile(resolved, content, "utf-8");
const tmpPath = resolved + ".tmp-" + randomUUID();
try {
await fsWriteFile(tmpPath, content, "utf-8");
await rename(tmpPath, resolved);
} catch (err) {
try { await unlink(tmpPath); } catch {}
throw err;
}
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
},
};
};
+448 -323
View File
@@ -1,5 +1,6 @@
import { Box, Text, useApp, useBoxMetrics, useInput, useWindowSize, type DOMElement } from "ink";
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Static, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from "ink";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import path from "node:path";
import {
AgentError,
compactSession,
@@ -26,10 +27,10 @@ import { setCachedMode } from "../../backend/capabilityCache.js";
import { resolveContextWindow } from "../../backend/contextWindow.js";
import { resolveToolCallMode } from "../../backend/resolveMode.js";
import { resolveAutoCompactThreshold, resolveMaxIterations } from "../../config/config.js";
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
import { getMcpStatuses } from "../../mcp/manager.js";
import { KNOWN_BACKENDS, type BackendName, isSmallLocalModel } from "../../config/defaults.js";
import { getMcpStatuses, reconnectMcpServers } from "../../mcp/manager.js";
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
import { defaultExportFilename, exportSession } from "../../persistence/exportSession.js";
import { defaultExportFilename, exportSession, type ExportFormat } from "../../persistence/exportSession.js";
import { loadMergedHooks } from "../../hooks/config.js";
import { expandCommandTemplate } from "../../plugins/expandTemplate.js";
import { getLoadedPlugins, getPluginCommandCollisions } from "../../plugins/registry.js";
@@ -46,11 +47,15 @@ import {
type SessionRecord,
type SessionSummary,
} from "../../persistence/sessionStore.js";
import { buildReplayHistory } from "../../persistence/replayHistory.js";
import { onBackgroundJobDone } from "../../tools/backgroundJobs.js";
import { TOOLS } from "../../tools/index.js";
import type { ToolDef } from "../../tools/types.js";
import { buildToolSet, type ToolSet } from "../../tools/toolset.js";
import { ChatInput } from "./ChatInput.js";
import { makeConfirmFn } from "./confirmFn.js";
import { ExportPrompt } from "./ExportPrompt.js";
import { FilePanel, type FilePanelTab, type TouchedFile } from "./FilePanel.js";
import { HistoryItemView } from "./HistoryItemView.js";
import { ModelSelect } from "./ModelSelect.js";
import { PermissionPrompt } from "./PermissionPrompt.js";
@@ -63,6 +68,20 @@ import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
// Cap for the input-history ring buffer used for ↑/↓ recall in the chat input.
const MAX_HISTORY = 100;
// Fixed width (in columns) of the file panel (see FilePanel.tsx) when shown — a compromise between
// filenames actually fitting and leaving enough room for the chat column on an 80-col terminal.
const FILE_PANEL_WIDTH = 30;
// Maps a tool name to how the file panel's Activity tab should label a successful call that
// touched a file — see the tool_result handling in submitTurn. Every one of these tools returns
// `{ path: <resolved absolute path>, ... }` from its handler (see readFile.ts/writeFile.ts/
// editFile.ts), which is what makes a single lookup here enough to build a TouchedFile entry.
const FILE_TOUCH_STATUS: Partial<Record<string, TouchedFile["status"]>> = {
read_file: "read",
write_file: "written",
edit_file: "edited",
};
// Shared between /perm's explicit-cycle notice and Shift+Tab's cyclePermMode so the two paths to
// the same action can't drift out of sync with each other.
const PERM_MODE_LABELS: Record<PermissionMode, string> = {
@@ -107,31 +126,42 @@ export function App({
onSessionIdChange,
}: AppProps) {
const { exit } = useApp();
const { stdin, isRawModeSupported, setRawMode } = useStdin();
const { stdout } = useStdout();
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
const [phase, setPhase] = useState<Phase>(
resumeSessionId ? "connecting" : interactiveResume ? "starting" : initialModel ? "connecting" : "loading-models",
);
const [inputValue, setInputValue] = useState("");
const [permission, setPermission] = useState<PendingPermission | null>(null);
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string } | null>(null);
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string; format: ExportFormat } | null>(null);
const [streamingText, setStreamingText] = useState<string | null>(null);
const [isThinking, setIsThinking] = useState(false);
const [permMode, setPermMode] = useState<PermissionMode>("default");
const [modelList, setModelList] = useState<string[]>([]);
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
const [filePanelVisible, setFilePanelVisible] = useState(false);
const [filePanelTab, setFilePanelTab] = useState<FilePanelTab>("files");
// Whether the file panel currently owns keyboard input — see the Ctrl+F handler below and
// ChatInput's isActive prop, which this disables while true so an arrow/Enter/Escape keystroke
// doesn't simultaneously navigate the tree and edit/submit the chat input.
const [filePanelFocused, setFilePanelFocused] = useState(false);
// Keyed by relPath so repeated touches update the same entry (bumping count) instead of
// duplicating it — see the tool_result handling in submitTurn below.
const [touchedFiles, setTouchedFiles] = useState<Map<string, TouchedFile>>(new Map());
// Most-recently-touched first — the Activity tab's whole point is "what's happened lately".
const touchedFilesList = useMemo(() => Array.from(touchedFiles.values()).sort((a, b) => b.lastTouchedAt - a.lastTouchedAt), [touchedFiles]);
const [history, setHistory] = useState<string[]>([]);
const baseURLRef = useRef(initialBaseURL);
const sessionRef = useRef<Session | null>(null);
// Wraps whichever branch the bottom ternary renders (permission/export prompt, a picker, or the
// normal StatusBar+ChatInput column) — measured (height only) so the history viewport above it
// knows exactly how much vertical space is left (see historyHeight).
const bottomSectionRef = useRef<DOMElement | null>(null);
// Measures the history content's own natural (unclipped) height — Yoga still computes a child's
// intrinsic size even when its parent has a fixed height + overflowY:hidden, so this reports the
// *true* height regardless of clipping. Used to decide top-alignment vs bottom-alignment below.
const historyContentRef = useRef<DOMElement | null>(null);
// The AbortController for whichever top-level turn is currently in flight — null between turns.
// Escape (see the global useInput handler below) aborts it, which cancels the in-flight backend
// request, kills a running bash child (see gateAndRun/bashTool's ctx.signal), and dismisses/rejects
// a pending permission prompt (via makeConfirmFn) — the same abort plumbing sub-agent timeouts
// already used, just wired up to a top-level turn for the first time.
const turnAbortRef = useRef<AbortController | null>(null);
// Static history items can't be retroactively expanded once printed (Ink's <Static> is
// append-only), so Ctrl+O doesn't edit the collapsed compact notice in place — it prints a new
// item with the full text on demand. This just remembers the most recent one to print.
@@ -143,29 +173,22 @@ export function App({
// Throttle streaming text updates to ~30fps to avoid excessive re-renders
const streamingAccumulatorRef = useRef("");
const thinkingAccumulatorRef = useRef("");
const lastStreamRenderRef = useRef(0);
const streamRafRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable ref for the current phase so the global useInput handler can read it without being
// re-registered on every phase change.
const phaseRef = useRef<Phase>(phase);
phaseRef.current = phase;
const { height: bottomSectionHeight, hasMeasured: bottomSectionMeasured } = useBoxMetrics(bottomSectionRef);
const { rows: terminalRows } = useWindowSize();
// Estimated fallback for the one frame before bottomSectionRef's first real measurement lands
// (StatusBar ~2 rows + a single-line bordered ChatInput ~3 rows) — avoids a brief overflow/flash
// of the history viewport claiming the whole terminal height on first mount.
const FALLBACK_BOTTOM_HEIGHT = 6;
const historyHeight = Math.max(
1,
terminalRows - (bottomSectionMeasured ? bottomSectionHeight : FALLBACK_BOTTOM_HEIGHT),
);
const { height: historyContentHeight } = useBoxMetrics(historyContentRef);
// Short conversations (or right after connecting, with just the welcome banner) should stay
// top-aligned — flex-end would otherwise glue even a single item to the bottom of the viewport,
// leaving a large empty gap above it. Only once real content actually exceeds the available
// height does it make sense to bottom-align and clip the oldest (off-the-top) content, which is
// what makes the view auto-scroll to the latest message once a conversation grows past one screen.
const historyOverflows = historyContentHeight > historyHeight;
const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
// Columns actually left for the chat column once the file panel (see FilePanel.tsx) claims its
// fixed width on the right — ChatInput can't derive this from its own measured width (see the
// comment on its availableColumns prop), so it's computed once here and threaded down.
const chatColumns = terminalColumns - (filePanelVisible ? FILE_PANEL_WIDTH : 0);
// Width every printed history line is held within, so nothing the terminal would soft-wrap
// reaches Ink's <Static> — a soft-wrapped line desyncs Ink's redraw accounting and leaves
// stranded copies of the status bar / input box in scrollback (see renderMarkdown).
const contentWidth = Math.max(20, chatColumns);
const flushStreamingText = useCallback(() => {
const accumulated = streamingAccumulatorRef.current;
@@ -191,12 +214,6 @@ export function App({
getGitInfo(cwd).then(setGitInfo).catch(() => setGitInfo(null));
}, [cwd]);
// Fetch once up front; re-fetched after each turn (see submitTurn) since a tool call (git_commit,
// bash) can switch branches or change the dirty state mid-session.
useEffect(() => {
refreshGitInfo();
}, [refreshGitInfo]);
// A backgrounded bash job (see Ctrl+B below) can finish long after the turn that started it has
// ended — this is how its completion still reaches the transcript.
useEffect(() => {
@@ -209,9 +226,46 @@ export function App({
// Mounted for the whole App lifetime (unlike ChatInput's own useInput, which only exists while
// ChatInput is rendered) so both shortcuts work even mid-turn, when ChatInput is unmounted.
useInput((input, key) => {
// Escape interrupts an in-flight turn (like Claude Code) — checked first and unconditionally on
// phase/modal state so it always wins, including while a permission prompt is open (aborting
// dismisses it too, via makeConfirmFn) or the file panel is focused. Gated on isThinking/
// streamingText rather than the phase/permission/exportPrompt guard below, since those describe
// UI modal state, not whether a turn is actually running.
if (key.escape && (isThinking || streamingText !== null)) {
turnAbortRef.current?.abort();
return;
}
// Shift+Tab: cycle permission mode. Handled globally (not just inside ChatInput) so it still
// works while a turn is in flight or a permission prompt is open — both unmount ChatInput (see
// the bottom-section ternary below), which is exactly the gap Escape hit above. Requires an
// active session (phase "input"); excluded only for exportPrompt, where it isn't a meaningful
// action while naming a file.
if (key.shift && key.tab && phaseRef.current === "input" && !exportPrompt) {
cyclePermMode();
return;
}
// Only react to global shortcuts during the actual chat phase; ignore them while a modal
// (permission/export) or a non-input phase (model/session select, connecting) is open.
if (phaseRef.current !== "input" || permission || exportPrompt) return;
if (key.ctrl && input === "f") {
// Three-state cycle: hidden -> open+focused -> open+unfocused (via Escape, not here) -> hidden.
// Pressing Ctrl+F while open-but-unfocused (the Escape state) re-focuses it instead of hiding
// it outright, so "peek without hiding" (Escape) and "close" (Ctrl+F again) stay distinct.
if (!filePanelVisible) {
setFilePanelVisible(true);
setFilePanelFocused(true);
} else if (filePanelFocused) {
setFilePanelVisible(false);
setFilePanelFocused(false);
} else {
setFilePanelFocused(true);
}
return;
}
if (key.ctrl && input === "g") {
setFilePanelTab((t) => (t === "files" ? "activity" : "files"));
return;
}
if (key.ctrl && input === "o") {
const summary = lastCompactSummaryRef.current;
push({
@@ -251,12 +305,11 @@ export function App({
});
}, [push]);
// Ollama/LM Studio don't report a usable context length for every model (notably cloud-routed
// models, e.g. "*:cloud" tags, whose metadata isn't the local GGUF info /api/show expects) — when
// that happens contextWindow silently falls back to a small hardcoded default (see
// backend/contextWindow.ts), which makes auto-compaction trigger far more often than the model's
// real limit would require, burning extra summarization round-trips. Surface it so the user can
// set the real value instead of silently eating that cost every session.
// When neither backend reports a context length (model not loaded in LM Studio, /api/show
// unreachable, a model type Ollama has no metadata for) contextWindow silently falls back to a
// small hardcoded default (see backend/contextWindow.ts), which makes auto-compaction trigger far
// more often than the model's real limit would require, burning extra summarization round-trips.
// Surface it so the user can set the real value instead of silently eating that cost every session.
const notifyIfContextWindowGuessed = useCallback(
(model: string, contextWindow: { value: number; isEstimate: boolean }) => {
if (!contextWindow.isEstimate) return;
@@ -279,10 +332,7 @@ export function App({
// Default to "native" mode immediately — no blocking probe on startup.
// The probe runs lazily on the first turn if needed.
const mode: ToolCallMode = toolModeOverride ?? "native";
const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) =>
new Promise<PermissionDecision>((resolve) => {
setPermission({ ...opts, resolve });
});
const confirmFn = makeConfirmFn(setPermission);
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
extraToolsPromise,
resolveContextWindow(baseURLRef.current, model),
@@ -300,6 +350,7 @@ export function App({
resolveMaxIterations(),
resolveAutoCompactThreshold(),
projectInstructions,
isSmallLocalModel(baseURLRef.current, model),
);
onSessionIdChange?.(sessionRef.current.id);
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
@@ -331,10 +382,7 @@ export function App({
try {
baseURLRef.current = record.baseURL;
const client = makeClient({ baseURL: record.baseURL, model: record.model });
const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) =>
new Promise<PermissionDecision>((resolve) => {
setPermission({ ...opts, resolve });
});
const confirmFn = makeConfirmFn(setPermission);
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
extraToolsPromise,
resolveContextWindow(record.baseURL, record.model),
@@ -351,6 +399,7 @@ export function App({
resolveMaxIterations(),
resolveAutoCompactThreshold(),
projectInstructions,
isSmallLocalModel(record.baseURL, record.model),
);
onSessionIdChange?.(sessionRef.current.id);
push({
@@ -365,17 +414,9 @@ export function App({
push({ kind: "notice", text: `Hook warning: ${warning}` });
}
// Replay the saved user/assistant turns so the transcript isn't blank — tool
// call/result lines aren't replayed since we don't persist their display labels.
const replayed: HistoryItem[] = [];
for (const m of record.messages) {
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
replayed.push({ id: nextId(), kind: "user", text: m.content });
} else if (m.role === "assistant" && typeof m.content === "string" && m.content) {
replayed.push({ id: nextId(), kind: "assistant", text: m.content });
}
}
setStaticItems((prev) => [...prev, ...replayed]);
// Replay the saved turns (including tool_call/tool_result lines) so the transcript
// looks the same as it did live, not just the user/assistant text.
setStaticItems((prev) => [...prev, ...buildReplayHistory(record.messages)]);
setPhase("input");
} catch (err) {
@@ -459,6 +500,10 @@ export function App({
resolveToolCallMode(session.client, baseURLRef.current, name),
resolveContextWindow(baseURLRef.current, name),
]);
// Recompute before setMode's rebuild (see setMode) so switching e.g. a small local model ->
// glm-5.2:cloud (or the reverse) mid-session gets the right system-prompt branch immediately,
// not just on the next full session restart.
session.isLocal = isSmallLocalModel(baseURLRef.current, name);
setMode(session, newMode);
session.contextWindow = newContextWindow.value;
session.contextWindowIsEstimate = newContextWindow.isEstimate;
@@ -489,6 +534,9 @@ export function App({
resolveToolCallMode(session.client, baseURLRef.current, session.model),
resolveContextWindow(baseURLRef.current, session.model),
]);
// See the same recompute in switchModel — a backend switch changes isSmallLocalModel()'s
// input just as much as a model switch changes isCloudRoutedModelName()'s.
session.isLocal = isSmallLocalModel(baseURLRef.current, session.model);
setMode(session, newMode);
session.contextWindow = newContextWindow.value;
session.contextWindowIsEstimate = newContextWindow.isEstimate;
@@ -510,8 +558,10 @@ export function App({
}
}
async function submitTurn(session: Session, input: string | ChatCompletionUserContent) {
async function submitTurn(session: Session, input: string | ChatCompletionUserContent, toolset?: ToolSet) {
const rollbackLength = session.messages.length;
const ac = new AbortController();
turnAbortRef.current = ac;
setIsThinking(true);
setStreamingText(null);
streamingAccumulatorRef.current = "";
@@ -565,12 +615,22 @@ export function App({
} else if (event.type === "tool_result") {
setRunningToolIsBash(false);
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
const touchStatus = event.name && !event.isError ? FILE_TOUCH_STATUS[event.name] : undefined;
const resultPath = touchStatus ? (event.result as { path?: unknown } | undefined)?.path : undefined;
if (touchStatus && typeof resultPath === "string") {
const relPath = path.relative(cwd, resultPath) || resultPath;
setTouchedFiles((prev) => {
const next = new Map(prev);
next.set(relPath, { relPath, status: touchStatus, count: (next.get(relPath)?.count ?? 0) + 1, lastTouchedAt: Date.now() });
return next;
});
}
} else if (event.type === "hook_notice" || event.type === "notice") {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "notice", text: event.text, isError: event.isError } as HistoryItem]);
} else if (event.type === "todos_update") {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "todos", todos: event.todos } as HistoryItem]);
}
});
}, toolset, ac.signal);
// text_done already added the assistant message to staticItems
// No fallback needed — the streaming loop always emits text_done
void text;
@@ -608,7 +668,14 @@ export function App({
// retry the edit on the next turn (which then fails with "old_string not found", etc.).
const commitLength = session.mutationCommitLength;
session.messages.length = commitLength ?? rollbackLength;
if (err instanceof MaxIterationsError) {
if (ac.signal.aborted) {
// The user pressed Escape (see the global useInput handler) — not a failure, so no
// "Request failed" framing. Whatever error actually surfaced (a raw fetch AbortError, or
// makeConfirmFn's "prompt cancelled" rejection if Escape landed while a permission prompt
// was open) is irrelevant here; `ac` is ours, so its own aborted flag is the one source of
// truth for "this was the user stopping it" regardless of which code path threw.
push({ kind: "notice", text: "Interrupted.", isError: false });
} else if (err instanceof MaxIterationsError) {
// Not a failure — the model just ran out of per-turn budget. No "Request failed" framing,
// no isError styling, since nothing actually broke and the work done so far is intact.
push({ kind: "notice", text: err.message, isError: false });
@@ -617,6 +684,7 @@ export function App({
push({ kind: "notice", text: `Request failed: ${reason}`, isError: true });
}
} finally {
if (turnAbortRef.current === ac) turnAbortRef.current = null;
setIsThinking(false);
setStreamingText(null);
streamingAccumulatorRef.current = "";
@@ -628,6 +696,11 @@ export function App({
}
async function handleSubmit(raw: string) {
// A turn is already in flight (streaming output or waiting for the first chunk). Keep the
// input box mounted so the user can type ahead, but ignore Enter so a second turn can't
// overlap onto the same session. The in-flight turn is the source of truth; the typed text
// stays in the box for when the turn finishes.
if (isThinking || streamingText !== null) return;
setInputValue("");
const trimmed = raw.trim();
if (!trimmed) return;
@@ -646,187 +719,222 @@ export function App({
push({ kind: "user", text: trimmed });
if (trimmed === "/exit" || trimmed === "/quit") {
exit();
return;
}
if (trimmed === "/help") {
push({ kind: "help" });
return;
}
if (trimmed === "/clear") {
resetSession(session);
push({ kind: "notice", text: "Conversation history cleared." });
persistCurrentSession();
return;
}
if (trimmed === "/status") {
push({
kind: "status",
model: session.model,
baseURL: baseURLRef.current,
mode: session.mode,
cwd,
sessionId: session.id,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
});
return;
}
if (trimmed === "/dashboard" || trimmed === "/stats") {
push({
kind: "dashboard",
model: session.model,
baseURL: baseURLRef.current,
sessionId: session.id,
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
turns: session.stats.turns,
apiCalls: session.stats.apiCalls,
toolCalls: session.stats.toolCalls,
inputTokens: session.stats.inputTokens,
outputTokens: session.stats.outputTokens,
modelTimeMs: session.stats.modelTimeMs,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
});
return;
}
if (trimmed === "/compact") {
setIsThinking(true);
try {
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
return;
}
if (trimmed.startsWith("/export")) {
const arg = trimmed.slice("/export".length).trim();
setExportPrompt({ defaultName: arg || defaultExportFilename() });
return;
}
if (trimmed.startsWith("/import")) {
const rest = trimmed.slice("/import".length).trim();
if (!rest) {
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
// Built-in slash commands, checked in order before plugin commands / skills / @mentions get a
// look. `test` gets the trimmed input; `run` gets it too and slices its own argument (a prefix
// match like `/model` deliberately stays loose — `startsWith` — to match historical behavior).
// Order matters where prefixes overlap: `/permissions` sits before `/perm`, `/model` before
// `/mode`.
const builtinCommands: { test: (t: string) => boolean; run: (t: string) => void | Promise<void> }[] = [
{ test: (t) => t === "/exit" || t === "/quit", run: () => exit() },
{ test: (t) => t === "/help", run: () => push({ kind: "help" }) },
{
test: (t) => t === "/clear",
run: () => {
resetSession(session);
push({ kind: "notice", text: "Conversation history cleared." });
persistCurrentSession();
},
},
{
test: (t) => t === "/status",
run: () =>
push({
kind: "status",
model: session.model,
baseURL: baseURLRef.current,
mode: session.mode,
cwd,
sessionId: session.id,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
}),
},
{
test: (t) => t === "/dashboard" || t === "/stats",
run: () =>
push({
kind: "dashboard",
model: session.model,
baseURL: baseURLRef.current,
sessionId: session.id,
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
turns: session.stats.turns,
apiCalls: session.stats.apiCalls,
toolCalls: session.stats.toolCalls,
inputTokens: session.stats.inputTokens,
outputTokens: session.stats.outputTokens,
modelTimeMs: session.stats.modelTimeMs,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
}),
},
{
test: (t) => t === "/compact",
run: async () => {
setIsThinking(true);
try {
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
},
},
{
test: (t) => t.startsWith("/export"),
run: (t) => {
const rest = t.slice("/export".length).trim();
// "/export json [file]" -> JSON dump; "/export [file]" -> markdown transcript.
let fmt: ExportFormat = "markdown";
let arg = rest;
if (rest === "json" || rest.startsWith("json ")) {
fmt = "json";
arg = rest === "json" ? "" : rest.slice("json".length).trim();
}
setExportPrompt({ defaultName: arg || defaultExportFilename(fmt), format: fmt });
},
},
{
test: (t) => t.startsWith("/import"),
run: async (t) => {
const rest = t.slice("/import".length).trim();
if (!rest) {
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
return;
}
const spaceIdx = rest.indexOf(" ");
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
try {
const content = await buildImportContent(cwd, filePathArg, caption);
await submitTurn(session, content);
} catch (err) {
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
}
},
},
{ test: (t) => t === "/tools", run: () => push({ kind: "tools", tools: session.toolset.tools }) },
{ test: (t) => t === "/permissions", run: () => push({ kind: "permissions", allowed: session.permissions.listAllowed() }) },
{ test: (t) => t === "/sessions", run: () => push({ kind: "sessions", sessions: listSessions() }) },
{
test: (t) => t === "/mcp" || t === "/mcp reconnect",
run: async (t) => {
if (t === "/mcp reconnect" && sessionRef.current) {
try {
const newMcpTools = await reconnectMcpServers(cwd);
// Rebuild the session's toolset: keep non-MCP tools (built-ins + plugin tools already in
// the toolset), drop the old MCP tools (namespaced `mcp__...`), and add the fresh ones.
const kept = sessionRef.current.toolset.tools.filter((tool) => !tool.name.startsWith("mcp__"));
sessionRef.current.toolset = buildToolSet([...kept, ...newMcpTools]);
} catch {
// fall through to just re-rendering current statuses
}
}
push({ kind: "mcp", statuses: getMcpStatuses() });
},
},
{
test: (t) => t === "/plugins",
run: () => push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() }),
},
{ test: (t) => t === "/hooks", run: () => push({ kind: "hooks", config: loadMergedHooks(cwd) }) },
{
test: (t) => t === "/skills",
run: () => {
const skills = getLoadedPlugins().flatMap((p) => p.skills);
push({ kind: "skills", skills, collisions: findSkillCollisions(skills) });
},
},
{
test: (t) => t.startsWith("/model"),
run: async (t) => {
const name = t.slice("/model".length).trim();
if (!name) push({ kind: "notice", text: `Current model: ${session.model}` });
else await switchModel(name);
},
},
{
test: (t) => t.startsWith("/backend"),
run: async (t) => {
const name = t.slice("/backend".length).trim();
if (!name) {
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
} else if (name !== "ollama" && name !== "lmstudio") {
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
} else {
await switchBackend(name);
}
},
},
{
test: (t) => t.startsWith("/perm"),
run: (t) => {
const name = t.slice("/perm".length).trim();
const validModes: Record<string, PermissionMode> = {
default: "default",
plan: "plan",
"auto-edit": "auto-edit",
"auto-accept": "auto-accept",
};
if (!name) {
// Cycle
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode);
session.permissions.setMode(nextMode);
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
} else if (validModes[name]) {
const newMode = validModes[name];
setPermMode(newMode);
session.permissions.setMode(newMode);
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
} else {
push({
kind: "notice",
text: `Unknown permission mode "${name}". Use "default", "plan", "auto-edit", or "auto-accept".`,
isError: true,
});
}
},
},
{
test: (t) => t.startsWith("/mode"),
run: (t) => {
const name = t.slice("/mode".length).trim();
if (!name) {
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
} else if (name !== "native" && name !== "fallback") {
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
} else {
const previousMode = session.mode;
setMode(session, name);
setCachedMode(baseURLRef.current, session.model, name);
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
persistCurrentSession();
runHooksForEvent(
"ConfigChange",
{ sessionId: session.id, cwd },
{ key: "toolMode", previous: previousMode, value: name },
).catch(() => {});
}
},
},
];
for (const command of builtinCommands) {
if (command.test(trimmed)) {
await command.run(trimmed);
return;
}
const spaceIdx = rest.indexOf(" ");
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
try {
const content = await buildImportContent(cwd, filePathArg, caption);
await submitTurn(session, content);
} catch (err) {
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
}
return;
}
if (trimmed === "/tools") {
push({ kind: "tools", tools: session.toolset.tools });
return;
}
if (trimmed === "/permissions") {
push({ kind: "permissions", allowed: session.permissions.listAllowed() });
return;
}
if (trimmed === "/sessions") {
push({ kind: "sessions", sessions: listSessions() });
return;
}
if (trimmed === "/mcp") {
push({ kind: "mcp", statuses: getMcpStatuses() });
return;
}
if (trimmed === "/plugins") {
push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() });
return;
}
if (trimmed === "/hooks") {
push({ kind: "hooks", config: loadMergedHooks(cwd) });
return;
}
if (trimmed === "/skills") {
const skills = getLoadedPlugins().flatMap((p) => p.skills);
push({ kind: "skills", skills, collisions: findSkillCollisions(skills) });
return;
}
if (trimmed.startsWith("/model")) {
const name = trimmed.slice("/model".length).trim();
if (!name) {
push({ kind: "notice", text: `Current model: ${session.model}` });
} else {
await switchModel(name);
}
return;
}
if (trimmed.startsWith("/backend")) {
const name = trimmed.slice("/backend".length).trim();
if (!name) {
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
} else if (name !== "ollama" && name !== "lmstudio") {
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
} else {
await switchBackend(name);
}
return;
}
if (trimmed.startsWith("/perm")) {
const name = trimmed.slice("/perm".length).trim();
const validModes: Record<string, PermissionMode> = {
default: "default",
plan: "plan",
"auto-edit": "auto-edit",
"auto-accept": "auto-accept",
};
if (!name) {
// Cycle
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode);
session.permissions.setMode(nextMode);
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
} else if (validModes[name]) {
const newMode = validModes[name];
setPermMode(newMode);
session.permissions.setMode(newMode);
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
} else {
push({
kind: "notice",
text: `Unknown permission mode "${name}". Use "default", "plan", "auto-edit", or "auto-accept".`,
isError: true,
});
}
return;
}
if (trimmed.startsWith("/mode")) {
const name = trimmed.slice("/mode".length).trim();
if (!name) {
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
} else if (name !== "native" && name !== "fallback") {
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
} else {
const previousMode = session.mode;
setMode(session, name);
setCachedMode(baseURLRef.current, session.model, name);
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
persistCurrentSession();
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "toolMode", previous: previousMode, value: name }).catch(
() => {},
);
}
return;
}
// UserPromptSubmit hooks see the raw text before plugin-command expansion or @mention
@@ -860,7 +968,13 @@ export function App({
const pluginCommand = plugins.flatMap((p) => p.commands).find((c) => c.name === cmdName);
if (pluginCommand) {
const expanded = expandCommandTemplate(pluginCommand.template, argsText);
await submitTurn(session, extraContext ? `${expanded}\n\n${extraContext}` : expanded);
// A command's `allowed-tools` frontmatter restricts only this one turn — build a
// narrowed ToolSet from the session's full one rather than mutating session.toolset,
// the same pattern runSubAgentTurn (agent/loop.ts) uses for a plugin agent's `tools:`.
const restrictedToolset = pluginCommand.allowedTools
? buildToolSet(session.toolset.tools.filter((t) => pluginCommand.allowedTools!.includes(t.name)))
: undefined;
await submitTurn(session, extraContext ? `${expanded}\n\n${extraContext}` : expanded, restrictedToolset);
return;
}
@@ -895,6 +1009,19 @@ export function App({
await submitTurn(session, extraContext ? `${trimmed}\n\n${extraContext}` : trimmed);
}
// handleSubmit is a plain function recreated every render (it's 300 lines closing over dozens of
// things, including isThinking/streamingText — both of which change during every streaming frame,
// so a naive useCallback with a correct dependency list would still get a new identity constantly
// while streaming, exactly when we want it stable). Instead, the same ref-indirection this file
// already uses for phaseRef: keep a ref pointing at the latest handleSubmit, and hand ChatInput a
// permanently-stable wrapper that just calls through it — this is what actually makes ChatInput's
// memo() below effective during streaming, without needing handleSubmit's own identity to be stable.
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const stableHandleSubmit = useCallback((raw: string) => {
void handleSubmitRef.current(raw);
}, []);
function handlePermissionSelect(decision: PermissionDecision) {
const pending = permission;
setPermission(null);
@@ -912,7 +1039,7 @@ export function App({
return;
}
try {
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName);
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName, exportPrompt?.format ?? "markdown");
push({ kind: "notice", text: `Exported conversation to ${resolved}` });
} catch (err) {
push({ kind: "notice", text: `Export failed: ${(err as Error).message}`, isError: true });
@@ -936,94 +1063,92 @@ export function App({
}
return (
<Box flexDirection="column" width="100%" height={terminalRows}>
{/* Fixed-height, bottom-pinned viewport: overflowY="hidden" clips whatever scrolls past the
* top, and justifyContent="flex-end" keeps the *latest* content flush against the bottom
* edge — together they auto-scroll to the newest message without any manual scroll-offset
* math, the same way a normal chat view does. This replaced <Static> (permanent one-shot
* scrollback printing) because Static's already-flushed rows never participate in Yoga
* layout again, which is fundamentally incompatible with letting old items visually scroll
* out of a *bounded* viewport as new ones arrive. Trade-off: every item re-renders on every
* frame now (Static rendered each item exactly once, ever) — fine at the sizes a single
* session reaches before auto-compaction, but worth knowing if a session gets huge. */}
<Box
flexDirection="column"
height={historyHeight}
overflowY="hidden"
justifyContent={historyOverflows ? "flex-end" : "flex-start"}
>
<Box flexDirection="column" ref={historyContentRef}>
{staticItems.map((item) => (
<HistoryItemView key={item.id} item={item} />
))}
<>
{/* Finished history prints once, directly to the terminal's real scrollback — never
* re-rendered, never height-clipped — which is what makes the terminal's own native mouse
* wheel scroll and click-drag text selection/copy work with zero app-side mouse tracking or
* virtual-scroll code (see index.tsx for why locode also stays off the alternate screen). */}
<Static items={staticItems}>
{(item) => (
<Box key={item.id} width={contentWidth} flexShrink={0}>
<HistoryItemView item={item} width={contentWidth} />
</Box>
)}
</Static>
<Box flexDirection="row" width="100%" alignItems="flex-end">
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
{streamingText !== null && (
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} />
<Box width={contentWidth} flexShrink={0}>
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} width={contentWidth} />
</Box>
)}
{isThinking && streamingText === null && !permission && !exportPrompt && (
<ThinkingIndicator label={runningToolIsBash ? "thinking... (ctrl+b to background)" : undefined} />
)}
</Box>
</Box>
<Box flexDirection="column" ref={bottomSectionRef}>
{permission ? (
<PermissionPrompt
toolName={permission.toolName}
args={permission.args}
preview={permission.preview}
onSelect={handlePermissionSelect}
/>
) : exportPrompt ? (
<ExportPrompt defaultName={exportPrompt.defaultName} onSubmit={handleExportSubmit} onCancel={handleExportCancel} />
) : phase === "starting" ? (
<ThinkingIndicator label="starting..." />
) : phase === "connecting" ? (
<ThinkingIndicator label="connecting..." />
) : phase === "loading-models" ? (
<ThinkingIndicator label="loading models..." />
) : phase === "session-select" ? (
<SessionSelect sessions={sessionList} onSelect={handleSessionSelect} />
) : phase === "model-select" ? (
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
) : (
<>
{sessionRef.current && phaseRef.current === "input" && (
<StatusBar
model={sessionRef.current.model}
mode={sessionRef.current.mode}
permMode={permMode}
cwd={cwd}
contextTokens={sessionRef.current.lastContextTokens}
contextWindow={sessionRef.current.contextWindow}
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
sessionId={sessionRef.current.id}
createdAt={sessionRef.current.createdAt}
inputTokens={sessionRef.current.stats.inputTokens}
outputTokens={sessionRef.current.stats.outputTokens}
gitInfo={gitInfo}
/>
)}
{isThinking || streamingText !== null ? (
// Swapped in for ChatInput while a turn is in flight. Closes a real (if rare) race:
// handleSubmit had no guard against firing while a turn was already running, so
// typing and hitting Enter mid-stream could submit a second overlapping turn onto
// the same session.
<Box borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
<Text dimColor>Waiting for response…</Text>
</Box>
) : (
{permission ? (
<PermissionPrompt
toolName={permission.toolName}
args={permission.args}
preview={permission.preview}
onSelect={handlePermissionSelect}
/>
) : exportPrompt ? (
<ExportPrompt defaultName={exportPrompt.defaultName} onSubmit={handleExportSubmit} onCancel={handleExportCancel} />
) : phase === "starting" ? (
<ThinkingIndicator label="starting..." />
) : phase === "connecting" ? (
<ThinkingIndicator label="connecting..." />
) : phase === "loading-models" ? (
<ThinkingIndicator label="loading models..." />
) : phase === "session-select" ? (
<SessionSelect sessions={sessionList} onSelect={handleSessionSelect} />
) : phase === "model-select" ? (
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
) : (
<>
{sessionRef.current && phaseRef.current === "input" && (
<StatusBar
model={sessionRef.current.model}
mode={sessionRef.current.mode}
permMode={permMode}
cwd={cwd}
contextTokens={sessionRef.current.lastContextTokens}
contextWindow={sessionRef.current.contextWindow}
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
sessionId={sessionRef.current.id}
createdAt={sessionRef.current.createdAt}
inputTokens={sessionRef.current.stats.inputTokens}
outputTokens={sessionRef.current.stats.outputTokens}
modelTimeMs={sessionRef.current.stats.modelTimeMs}
gitInfo={gitInfo}
/>
)}
{(isThinking || streamingText !== null) && (
<Text dimColor> Waiting for response… (esc to interrupt) — type ahead, Enter sends when done.</Text>
)}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
onCyclePermMode={cyclePermMode}
onSubmit={stableHandleSubmit}
cwd={cwd}
history={history}
availableColumns={chatColumns}
isActive={!filePanelFocused}
/>
)}
</>
)}
</>
)}
</Box>
<FilePanel
visible={filePanelVisible}
activeTab={filePanelTab}
cwd={cwd}
touchedFiles={touchedFilesList}
width={FILE_PANEL_WIDTH}
height={terminalRows}
focused={filePanelFocused}
onExitFocus={() => setFilePanelFocused(false)}
/>
</Box>
</Box>
</>
);
}
+153 -43
View File
@@ -1,18 +1,29 @@
import { Box, Text, useBoxMetrics, useCursor, useInput, useWindowSize, type DOMElement } from "ink";
import fg from "fast-glob";
import { useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import stringWidth from "string-width";
import { getAbsolutePosition } from "./absolutePosition.js";
import { ACCENT_HEX } from "../theme.js";
import { getActiveMention } from "../../utils/mentions.js";
// Bracketed paste markers emitted by terminals when the user pastes text.
const BP_START = "\x1b[200~";
const BP_END = "\x1b[201~";
interface Props {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
onCyclePermMode?: () => void;
cwd: string;
history?: string[];
/** Actual terminal columns available to this box's content — normally the full terminal width,
* but narrower when the file panel (App.tsx) is showing alongside it. Defaults to the terminal's
* own column count so callers that don't have a side panel can omit it. */
availableColumns?: number;
/** False while the file panel has keyboard focus (App.tsx) — disables this component's own
* useInput so the same keystroke (arrows, Enter, Escape) doesn't also edit/submit the chat
* input while it's being used to navigate the file tree. Defaults to true. */
isActive?: boolean;
}
const MAX_MATCHES = 50;
@@ -22,7 +33,7 @@ const PROMPT_WIDTH = 2;
// Border (1 col each side) + paddingX={1} (1 col each side) around the bordered box's content.
const BOX_CHROME_WIDTH = 4;
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, history = [] }: Props) {
export const ChatInput = memo(function ChatInput({ value, onChange, onSubmit, cwd, history = [], availableColumns, isActive = true }: Props) {
const [allFiles, setAllFiles] = useState<string[] | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [historyIndex, setHistoryIndex] = useState(-1);
@@ -43,33 +54,60 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
// time (through however many wrapper Boxes sit above this one) proved fragile in practice.
const { hasMeasured } = useBoxMetrics(boxRef);
const { setCursorPosition } = useCursor();
// Terminal columns, not this box's own measured width: the input box is always width="100%"
// with no horizontal siblings, so its content width is deterministically `columns -
// BOX_CHROME_WIDTH`. Using the measured width instead briefly produced a near-zero value before
// the box's first real layout pass landed (hasMeasured only means the ref is attached, not that
// Yoga has computed a real width yet) — with content width floored at 1, every single typed
// character was computed as needing its own wrapped row, so the reported cursor row grew by one
// per keystroke, visibly "falling" down the screen as you typed.
// Terminal columns, not this box's own measured width: the input box is always width="100%" of
// its column (deterministic), so its content width is `availableColumns - BOX_CHROME_WIDTH`.
// Using the measured width instead briefly produced a near-zero value before the box's first real
// layout pass landed (hasMeasured only means the ref is attached, not that Yoga has computed a
// real width yet) — with content width floored at 1, every single typed character was computed as
// needing its own wrapped row, so the reported cursor row grew by one per keystroke, visibly
// "falling" down the screen as you typed. `availableColumns` defaults to the raw terminal width
// for callers with no horizontal siblings; App.tsx passes the narrower figure when the file panel
// is showing alongside this box, since it isn't full terminal width in that case.
const { columns: terminalColumns } = useWindowSize();
const contentColumns = availableColumns ?? terminalColumns;
const mention = getActiveMention(value);
// Glob the project's files lazily — only once a "@" is actually typed — and cache the result
// for the rest of the session rather than re-scanning on every keystroke.
// for up to 5 minutes (re-glob after that to pick up newly created/deleted files).
const [filesCachedAt, setFilesCachedAt] = useState(0);
useEffect(() => {
if (!mention || allFiles !== null) return;
if (!mention) return;
const now = Date.now();
if (allFiles !== null && now - filesCachedAt < 300_000) return; // 5 min cache
let cancelled = false;
fg("**/*", { cwd, dot: false, onlyFiles: true, absolute: false, ignore: ["node_modules/**", ".git/**", "dist/**"] })
.then((files) => {
if (!cancelled) setAllFiles(files);
if (!cancelled) {
setAllFiles(files);
setFilesCachedAt(Date.now());
}
})
.catch(() => {
if (!cancelled) setAllFiles([]);
if (!cancelled) {
setAllFiles([]);
setFilesCachedAt(Date.now());
}
});
return () => {
cancelled = true;
};
}, [mention !== null, allFiles, cwd]);
}, [mention !== null, allFiles, cwd, filesCachedAt]);
// Fuzzy path matching: split the query and file into segments, matching each query
// token against consecutive characters in any path segment (e.g. "ut" matches "utils/").
function fuzzyMatch(query: string, filePath: string): boolean {
const q = query.toLowerCase();
const f = filePath.toLowerCase();
// Fast path: exact substring match
if (f.includes(q)) return true;
// Fuzzy: split query into characters and check if they appear in order across path segments
let qi = 0;
for (let fi = 0; fi < f.length && qi < q.length; fi++) {
if (f[fi] === q[qi]) qi++;
}
return qi === q.length;
}
// The full match set (capped at MAX_MATCHES for sanity) — separate from what's actually
// rendered, since only a VISIBLE_SUGGESTIONS-tall window of it is shown at once (see `visible`).
@@ -77,8 +115,14 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
const matches =
mention && allFiles
? allFiles
.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.length - b.length)
.filter((f) => fuzzyMatch(query, f))
.sort((a, b) => {
// Exact match first, then by length
const aExact = a.toLowerCase().includes(query.toLowerCase()) ? 0 : 1;
const bExact = b.toLowerCase().includes(query.toLowerCase()) ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
return a.length - b.length;
})
.slice(0, MAX_MATCHES)
: [];
@@ -103,19 +147,25 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
// is up to date before this same render's insertion effect runs.
if (hasMeasured) {
const origin = getAbsolutePosition(boxRef.current);
const contentWidth = Math.max(1, terminalColumns - BOX_CHROME_WIDTH);
const contentWidth = Math.max(1, contentColumns - BOX_CHROME_WIDTH);
const totalWidth = PROMPT_WIDTH + stringWidth(value.slice(0, cursorOffset));
const row = Math.floor(totalWidth / contentWidth);
const col = totalWidth % contentWidth;
// +2 rows / +2 cols for the box's own top border and left border+padding —
// getAbsolutePosition gives the box's outer (border) edge, not where its content actually
// starts. Empirically 2 rows, not 1 — some ancestor's own contribution isn't visible from the
// Yoga tree alone (Ink applies at least one more row of vertical offset somewhere between the
// computed layout and the actual terminal row), so this constant is tuned to match reality
// rather than derived purely from the box's own chrome.
// +2 cols for the box's own left border+padding — getAbsolutePosition gives the box's outer
// (border) edge, not where its content actually starts; this one is exact (1 border + 1
// paddingX column), not empirical.
//
// The row offset used to be +2 (1 for the box's own top border, +1 unexplained — "some
// ancestor's own contribution isn't visible from the Yoga tree alone", found empirically, not
// derived) back when App.tsx nested this box inside an extra `bottomSectionRef` wrapper Box and
// a fixed-height virtual-scroll viewport. Both are gone now (App.tsx moved history into Ink's
// own <Static>, so this box sits directly in a plain live-region Box) — dropped back to +1,
// the part that's actually justified by this box's own top border. Unverified in a real
// terminal (this environment has none) — if the cursor's still off by a row, that "mystery"
// contribution may not be fully gone.
setCursorPosition({
x: origin.x + 2 + col,
y: origin.y + 2 + row,
y: origin.y + 1 + row,
});
}
@@ -150,23 +200,66 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
replaceValue(value.slice(0, mention.start), mention.start);
}
function handleSubmit(raw: string) {
// Enter while the picker is open accepts the highlighted file instead of sending the message.
if (matches.length > 0) {
acceptSuggestion(matches[selectedIndex] ?? matches[0]!);
return;
// --- Bracketed paste support ---
// Terminals wrap pasted text in \x1b[200~ ... \x1b[201~. Ink delivers these as raw escape
// sequences in `input` (not as key sequences). We buffer text between the markers and insert
// it as a single multiline string, allowing newlines through instead of treating Enter as submit.
const pasteBufferRef = useRef("");
const inPasteRef = useRef(false);
const maybeHandlePaste = useCallback((input: string): boolean => {
// Check for bracketed paste start
if (input.includes(BP_START)) {
const startIdx = input.indexOf(BP_START);
const afterStart = input.slice(startIdx + BP_START.length);
// Check if the end marker is also in this same input chunk
const endIdx = afterStart.indexOf(BP_END);
if (endIdx !== -1) {
// Complete paste in one chunk
const pasted = afterStart.slice(0, endIdx).replace(/\r\n/g, "\n");
if (pasted) {
replaceValue(value.slice(0, cursorOffset) + pasted + value.slice(cursorOffset), cursorOffset + pasted.length);
}
const remaining = afterStart.slice(endIdx + BP_END.length);
if (remaining) maybeHandlePaste(remaining);
return true;
}
// Paste started but not ended in this chunk — start buffering
inPasteRef.current = true;
const pasted = afterStart.replace(/\r\n/g, "\n");
pasteBufferRef.current = pasted;
return true;
}
setHistoryIndex(-1);
setTempValue("");
onSubmit(raw);
}
// Check for bracketed paste end while buffering
if (inPasteRef.current) {
if (input.includes(BP_END)) {
const endIdx = input.indexOf(BP_END);
pasteBufferRef.current += input.slice(0, endIdx).replace(/\r\n/g, "\n");
const pasted = pasteBufferRef.current;
const remaining = input.slice(endIdx + BP_END.length);
pasteBufferRef.current = "";
inPasteRef.current = false;
if (pasted) {
replaceValue(value.slice(0, cursorOffset) + pasted + value.slice(cursorOffset), cursorOffset + pasted.length);
}
if (remaining) maybeHandlePaste(remaining);
return true;
}
// Still in paste mode — keep buffering
pasteBufferRef.current += input.replace(/\r\n/g, "\n");
return true;
}
return false;
}, [value, cursorOffset, replaceValue]);
useInput((input, key) => {
// Shift+Tab: cycle permission mode (takes priority even while suggestions are open).
if (key.shift && key.tab) {
onCyclePermMode?.();
return;
}
// Shift+Tab (cycle permission mode) is handled globally in App.tsx now, not here — see its
// useInput handler for why.
if (key.shift && key.tab) return;
// --- Bracketed paste ---
if (maybeHandlePaste(input)) return;
if (key.escape) {
cancelMention();
return;
@@ -205,8 +298,22 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
}
// --- Text editing (replaces ink-text-input, see cursorOffset above) ---
// Shift+Enter inserts a newline (multiline input)
if (key.return && key.shift) {
replaceValue(value.slice(0, cursorOffset) + "\n" + value.slice(cursorOffset), cursorOffset + 1);
return;
}
// Plain Enter: submits if single-line, inserts newline if already multiline
if (key.return) {
handleSubmit(value);
if (value.includes("\n")) {
// Multiline input: Enter inserts newline. Ctrl+Enter or Shift+Enter submits.
replaceValue(value.slice(0, cursorOffset) + "\n" + value.slice(cursorOffset), cursorOffset + 1);
return;
}
// Single-line: Enter submits
setHistoryIndex(-1);
setTempValue("");
onSubmit(value);
return;
}
if (key.leftArrow) {
@@ -231,7 +338,10 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
if (input) {
replaceValue(value.slice(0, cursorOffset) + input + value.slice(cursorOffset), cursorOffset + input.length);
}
});
}, { isActive });
// Render multiline input: show newlines as actual line breaks in the text display
const displayValue = value;
return (
<Box flexDirection="column" width="100%">
@@ -256,8 +366,8 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
)}
<Box ref={boxRef} borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
<Text color={ACCENT_HEX}>{"> "}</Text>
<Text>{value}</Text>
<Text>{displayValue}</Text>
</Box>
</Box>
);
}
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { pairHunk, clip } from "./DiffView.js";
describe("pairHunk", () => {
it("pairs a deletion immediately followed by an addition as one change row", () => {
const rows = pairHunk(["-old", "+new"]);
expect(rows).toEqual([{ left: "old", leftKind: "del", right: "new", rightKind: "add" }]);
});
it("treats context lines as both sides", () => {
const rows = pairHunk([" ctx"]);
expect(rows).toEqual([{ left: "ctx", leftKind: "ctx", right: "ctx", rightKind: "ctx" }]);
});
it("pads a pure deletion with an empty right", () => {
const rows = pairHunk(["-gone", "-also"]);
expect(rows).toEqual([
{ left: "gone", leftKind: "del", right: "", rightKind: "empty" },
{ left: "also", leftKind: "del", right: "", rightKind: "empty" },
]);
});
it("pads a pure insertion with an empty left", () => {
const rows = pairHunk(["+ins"]);
expect(rows).toEqual([{ left: "", leftKind: "empty", right: "ins", rightKind: "add" }]);
});
it("renders a no-newline marker line as context", () => {
const rows = pairHunk(["\\ No newline at end of file"]);
expect(rows[0]?.leftKind).toBe("ctx");
});
});
describe("clip", () => {
it("returns the string unchanged when it fits", () => {
expect(clip("hi", 10)).toBe("hi");
});
it("truncates with an ellipsis when it overflows", () => {
expect(clip("abcdefghij", 5)).toBe("abcd…");
});
it("returns empty for non-positive width", () => {
expect(clip("x", 0)).toBe("");
expect(clip("x", -1)).toBe("");
});
});
+151
View File
@@ -0,0 +1,151 @@
import { Box, Text, useStdout } from "ink";
import type { ReactNode } from "react";
import { DIFF_ADD_HEX, DIFF_REMOVE_HEX } from "../theme.js";
/** A single row of a side-by-side diff: the old (left) and new (right) versions of one line, plus
* each side's kind so the renderer can color it. `empty` is a padding row used when one side has a
* line the other doesn't (a pure insertion or deletion). */
interface DiffRow {
left: string;
leftKind: "ctx" | "add" | "del" | "empty";
right: string;
rightKind: "ctx" | "add" | "del" | "empty";
}
/** Splits a unified-diff hunk body (the lines after a `@@ … @@` header, each starting with ` `,
* `+`, or `-`) into paired old/new rows. A deletion immediately followed by an addition is treated
* as a change and shown on one row (old left, new right); runs of pure deletions or additions are
* padded with `empty` on the opposite side so the two columns stay aligned. Exported for testing. */
export function pairHunk(body: string[]): DiffRow[] {
const rows: DiffRow[] = [];
for (let i = 0; i < body.length; i++) {
const line = body[i] ?? "";
const tag = line[0];
const rest = line.slice(1);
if (tag === " ") {
rows.push({ left: rest, leftKind: "ctx", right: rest, rightKind: "ctx" });
} else if (tag === "-") {
const next = body[i + 1] ?? "";
if (next[0] === "+") {
rows.push({ left: rest, leftKind: "del", right: next.slice(1), rightKind: "add" });
i++; // consume the paired addition
} else {
rows.push({ left: rest, leftKind: "del", right: "", rightKind: "empty" });
}
} else if (tag === "+") {
// An addition not preceded by a paired deletion (the deletion branch above consumes its
// paired addition) — a pure insertion.
rows.push({ left: "", leftKind: "empty", right: rest, rightKind: "add" });
} else {
// Blank or unexpected line (e.g. a trailing "\ No newline at end of file") — render as context.
rows.push({ left: line, leftKind: "ctx", right: line, rightKind: "ctx" });
}
}
return rows;
}
/** Truncates `s` to `width` columns with an ellipsis when it overflows, so the two side-by-side
* columns stay row-aligned even when a line is longer than half the terminal. Exported for testing. */
export function clip(s: string, width: number): string {
if (width <= 0) return "";
if (s.length <= width) return s;
return s.slice(0, Math.max(0, width - 1)) + "…";
}
function colorFor(kind: DiffRow["leftKind"]): string | undefined {
if (kind === "add") return DIFF_ADD_HEX;
if (kind === "del") return DIFF_REMOVE_HEX;
return undefined;
}
/** Renders a unified-diff string with color-coded lines: additions green, removals red, hunk
* headers (`@@ … @@`) and the `Index:`/`--- `/`+++ ` file headers dimmed. In `sideBySide` mode the
* old and new versions are shown in two columns (old left, new right, separated by a dim ` │ `),
* with deletions red on the left and additions green on the right — easier to compare at a glance
* than a unified diff, at the cost of horizontal space. Used both for the pre-confirmation
* permission preview and the post-confirmation scrollback entry. */
export function DiffView({ diff, sideBySide = false }: { diff: string; sideBySide?: boolean }) {
const { stdout } = useStdout();
const columns = stdout?.columns ?? 80;
const lines = diff.split("\n");
if (!sideBySide) {
return (
<Box flexDirection="column">
{lines.map((line, i) => {
let color: string | undefined;
let dim = false;
const c = line[0];
if (c === "+" && !line.startsWith("+++")) {
color = DIFF_ADD_HEX;
} else if (c === "-" && !line.startsWith("---")) {
color = DIFF_REMOVE_HEX;
} else if (c === "@") {
dim = true; // hunk header
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
dim = true; // file headers
}
return (
<Text key={i} color={color} dimColor={dim}>
{line}
</Text>
);
})}
</Box>
);
}
// Side-by-side: reserve 3 columns for the " │ " gutter; split the rest evenly. Guard against the
// no-stdout / very-narrow case so a tiny terminal doesn't produce negative widths. Each cell keeps
// a 1-column sign prefix (` ` context, `-` deletion, `+` addition) so the kind is legible without
// relying on color alone.
const colWidth = Math.max(10, Math.floor((columns - 3) / 2));
// Walk the unified diff and group lines under their hunk headers, pairing each hunk body into rows.
const rendered: ReactNode[] = [];
let hunkBody: string[] = [];
let hunkIdx = 0;
const flushHunk = () => {
if (!hunkBody.length) return;
const rows = pairHunk(hunkBody);
rows.forEach((row, r) => {
const key = `${hunkIdx}-${r}`;
rendered.push(
<Box key={key} flexDirection="row">
<Text color={colorFor(row.leftKind)} dimColor={row.leftKind === "ctx"}>
{row.leftKind === "empty" ? "" : `${row.leftKind === "del" ? "-" : " "}${clip(row.left, colWidth - 1)}`}
</Text>
<Text dimColor> │ </Text>
<Text color={colorFor(row.rightKind)} dimColor={row.rightKind === "ctx"}>
{row.rightKind === "empty" ? "" : `${row.rightKind === "add" ? "+" : " "}${clip(row.right, colWidth - 1)}`}
</Text>
</Box>,
);
});
hunkBody = [];
hunkIdx++;
};
for (const line of lines) {
if (line.startsWith("@@")) {
flushHunk();
rendered.push(
<Text key={`h${hunkIdx}`} dimColor>
{line}
</Text>,
);
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
flushHunk();
rendered.push(
<Text key={`f${rendered.length}`} dimColor>
{line}
</Text>,
);
} else {
hunkBody.push(line);
}
}
flushHunk();
return <Box flexDirection="column">{rendered}</Box>;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { buildTree, flattenTree } from "./FilePanel.js";
function names(lines: ReturnType<typeof flattenTree>): string[] {
return lines.map((l) => `${" ".repeat(l.depth)}${l.isDir ? `${l.name}/` : l.name}`);
}
describe("buildTree / flattenTree", () => {
it("nests files under their directories, directories sorted before files", () => {
const tree = buildTree(["src/index.ts", "src/utils/foo.ts", "README.md"]);
const lines = flattenTree(tree);
expect(names(lines)).toEqual(["src/", " utils/", " foo.ts", " index.ts", "README.md"]);
});
it("sorts directories before files, alphabetically within each group", () => {
const tree = buildTree(["z.ts", "a.ts", "zdir/x.ts", "adir/x.ts"]);
const lines = flattenTree(tree);
expect(names(lines)).toEqual(["adir/", " x.ts", "zdir/", " x.ts", "a.ts", "z.ts"]);
});
it("produces stable, unique keys per full relative path", () => {
const tree = buildTree(["a/x.ts", "b/x.ts"]);
const keys = flattenTree(tree).map((l) => l.key);
expect(new Set(keys).size).toBe(keys.length);
expect(keys).toContain("a/x.ts");
expect(keys).toContain("b/x.ts");
});
it("handles an empty file list", () => {
expect(flattenTree(buildTree([]))).toEqual([]);
});
it("marks a directory as isDir with collapsed=false by default", () => {
const tree = buildTree(["src/index.ts"]);
const [srcLine] = flattenTree(tree);
expect(srcLine).toMatchObject({ key: "src", isDir: true, collapsed: false });
});
it("omits a collapsed directory's children but keeps its own line", () => {
const tree = buildTree(["src/index.ts", "src/utils/foo.ts", "README.md"]);
const lines = flattenTree(tree, new Set(["src"]));
expect(names(lines)).toEqual(["src/", "README.md"]);
expect(lines.find((l) => l.key === "src")).toMatchObject({ collapsed: true });
});
it("collapsing a directory doesn't affect a sibling directory's expansion", () => {
const tree = buildTree(["a/x.ts", "b/y.ts"]);
const lines = flattenTree(tree, new Set(["a"]));
expect(names(lines)).toEqual(["a/", "b/", " y.ts"]);
});
it("collapsing a nested directory only hides its own subtree", () => {
const tree = buildTree(["src/utils/a.ts", "src/utils/b.ts", "src/index.ts"]);
const lines = flattenTree(tree, new Set(["src/utils"]));
expect(names(lines)).toEqual(["src/", " utils/", " index.ts"]);
});
});
+262
View File
@@ -0,0 +1,262 @@
import { Box, Text, useBoxMetrics, useInput, type DOMElement } from "ink";
import fg from "fast-glob";
import { useEffect, useMemo, useRef, useState } from "react";
import { ACCENT_HEX } from "../theme.js";
export type FilePanelTab = "files" | "activity";
export interface TouchedFile {
/** Relative to cwd — what's actually shown, so it stays legible regardless of where the project
* lives on disk. */
relPath: string;
status: "read" | "written" | "edited";
/** How many times this file has been touched this session — shown as "(N)" past the first. */
count: number;
lastTouchedAt: number;
}
interface Props {
visible: boolean;
activeTab: FilePanelTab;
cwd: string;
touchedFiles: TouchedFile[];
width: number;
/** Explicit height (in rows) for the panel — it's no longer nested inside a fixed-height
* ancestor (App.tsx's history now flows into the terminal's own scrollback via <Static>
* rather than a bounded viewport), so this panel sizes itself instead of inheriting a height
* to flexGrow against. */
height: number;
/** Whether the panel currently owns keyboard input (App.tsx disables ChatInput's own useInput
* while this is true, so the same arrow/Enter/Escape keystroke doesn't do both at once). */
focused: boolean;
/** Escape while focused calls this to hand keyboard focus back to the chat input — the panel
* itself stays visible, only `focused` flips. */
onExitFocus: () => void;
}
const STATUS_COLOR: Record<TouchedFile["status"], string> = { read: "gray", written: "green", edited: "yellow" };
const STATUS_GLYPH: Record<TouchedFile["status"], string> = { read: "·", written: "+", edited: "~" };
interface TreeNode {
children: Map<string, TreeNode>;
isDir: boolean;
}
export function buildTree(paths: string[]): TreeNode {
const root: TreeNode = { children: new Map(), isDir: true };
for (const filePath of paths) {
const parts = filePath.split("/");
let node = root;
parts.forEach((part, i) => {
const isLast = i === parts.length - 1;
let child = node.children.get(part);
if (!child) {
child = { children: new Map(), isDir: !isLast };
node.children.set(part, child);
}
node = child;
});
}
return root;
}
export interface TreeLine {
/** Full path relative to cwd — unique per line, and what collapsedPaths/selection key on. */
key: string;
depth: number;
name: string;
isDir: boolean;
/** Only meaningful when isDir — whether this directory's children are hidden. */
collapsed: boolean;
}
/** Directories first, then alphabetical within each group — matches how most file explorers sort.
* A collapsed directory's own line is still emitted (so it stays selectable/expandable) but its
* children are skipped entirely, the same way a real file explorer hides them. */
export function flattenTree(node: TreeNode, collapsedPaths: ReadonlySet<string> = new Set(), prefix = "", depth = 0): TreeLine[] {
const entries = [...node.children.entries()].sort(([nameA, a], [nameB, b]) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return nameA.localeCompare(nameB);
});
const lines: TreeLine[] = [];
for (const [name, child] of entries) {
const fullPath = prefix ? `${prefix}/${name}` : name;
const collapsed = child.isDir && collapsedPaths.has(fullPath);
lines.push({ key: fullPath, depth, name, isDir: child.isDir, collapsed });
if (child.isDir && !collapsed) {
lines.push(...flattenTree(child, collapsedPaths, fullPath, depth + 1));
}
}
return lines;
}
// Same ignore list ChatInput's `@`-mention picker uses (see getActiveMention/fg call there) —
// keeps the two file listings consistent rather than drifting apart independently.
const IGNORE = ["node_modules/**", ".git/**", "dist/**"];
// A very large repo could produce tens of thousands of paths; capping keeps the tree-build/sort/
// flatten cheap. Collapsing directories (this component's whole point) is the actual answer to a
// tree that's too big to show at once — this cap is just a hard ceiling under that.
const MAX_FILES = 2000;
export function FilePanel({ visible, activeTab, cwd, touchedFiles, width, height, focused, onExitFocus }: Props) {
const [allFiles, setAllFiles] = useState<string[] | null>(null);
const [collapsedPaths, setCollapsedPaths] = useState<Set<string>>(new Set());
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const scrollRef = useRef<DOMElement | null>(null);
const { height: measuredScrollHeight } = useBoxMetrics(scrollRef);
const visibleRows = Math.max(1, measuredScrollHeight);
// Fetched lazily on first show (not on mount) and cached for the rest of the session — the panel
// itself stays mounted at all times (hidden via display:none below) specifically so this cache
// survives toggling the panel off and back on, rather than re-globbing the project every time.
useEffect(() => {
if (!visible || allFiles !== null) return;
let cancelled = false;
fg("**/*", { cwd, dot: false, onlyFiles: true, absolute: false, ignore: IGNORE })
.then((files) => {
if (!cancelled) setAllFiles(files.sort().slice(0, MAX_FILES));
})
.catch(() => {
if (!cancelled) setAllFiles([]);
});
return () => {
cancelled = true;
};
}, [visible, allFiles, cwd]);
const treeLines = useMemo(() => (allFiles ? flattenTree(buildTree(allFiles), collapsedPaths) : []), [allFiles, collapsedPaths]);
const lineCount = activeTab === "files" ? treeLines.length : touchedFiles.length;
// Fresh list each time you switch tabs — a leftover selection/scroll position from the other
// tab's (usually different-length) list would either point at the wrong row or be out of range.
useEffect(() => {
setSelectedIndex(0);
setScrollTop(0);
}, [activeTab]);
// Clamp on every shrink (collapsing a directory, or the file list finishing its first fetch)
// rather than just when growing, so a collapse that removes the selected row doesn't leave
// selectedIndex pointing past the new end of the list.
useEffect(() => {
setSelectedIndex((i) => Math.max(0, Math.min(i, lineCount - 1)));
}, [lineCount]);
// Keeps the selected row inside the currently-scrolled window — same marginTop-shift technique
// App.tsx's history viewport uses (see effectiveScrollTop there), just driven by selection moving
// instead of new content arriving.
useEffect(() => {
setScrollTop((top) => {
if (selectedIndex < top) return selectedIndex;
if (selectedIndex >= top + visibleRows) return selectedIndex - visibleRows + 1;
return top;
});
}, [selectedIndex, visibleRows]);
function toggleCollapse(dirPath: string) {
setCollapsedPaths((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) next.delete(dirPath);
else next.add(dirPath);
return next;
});
}
useInput(
(_input, key) => {
if (key.escape) {
onExitFocus();
return;
}
if (key.upArrow) {
setSelectedIndex((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow) {
setSelectedIndex((i) => Math.min(lineCount - 1, i + 1));
return;
}
if (activeTab !== "files") return;
const line = treeLines[selectedIndex];
if (!line?.isDir) return;
if (key.return) {
toggleCollapse(line.key);
} else if (key.leftArrow && !line.collapsed) {
toggleCollapse(line.key);
} else if (key.rightArrow && line.collapsed) {
toggleCollapse(line.key);
}
},
{ isActive: visible && focused },
);
return (
// display "none" (not conditional mounting) so the glob fetch above only ever runs once per
// session regardless of how many times the panel is toggled — see the effect's comment.
<Box
display={visible ? "flex" : "none"}
flexDirection="column"
flexShrink={0}
width={width}
height={height}
borderStyle="single"
borderColor={focused ? ACCENT_HEX : "gray"}
paddingX={1}
>
<Text>
<Text bold color={activeTab === "files" ? ACCENT_HEX : undefined}>
Files
</Text>
<Text dimColor> · </Text>
<Text bold color={activeTab === "activity" ? ACCENT_HEX : undefined}>
Activity
</Text>
</Text>
<Text dimColor>{"─".repeat(Math.max(1, width - 4))}</Text>
<Box ref={scrollRef} flexDirection="column" flexGrow={1} overflowY="hidden">
{/* flexShrink={0} is load-bearing the same way it is in App.tsx's history content box:
* without it, Yoga shrinks this box (and every line inside it) to fit the panel's height
* instead of letting overflowY:hidden clip it, which renders as scrambled/decimated lines
* rather than a clean top slice. */}
<Box flexDirection="column" flexShrink={0} marginTop={-scrollTop}>
{activeTab === "files" ? (
allFiles === null ? (
<Text dimColor>Loading…</Text>
) : treeLines.length === 0 ? (
<Text dimColor>No files found.</Text>
) : (
treeLines.map((line, i) => {
const isSelected = focused && i === selectedIndex;
const chevron = line.isDir ? (line.collapsed ? "▸ " : "▾ ") : " ";
const label = line.isDir ? `${line.name}/` : line.name;
return (
<Text key={line.key} color={isSelected ? ACCENT_HEX : line.isDir ? "cyan" : undefined} bold={isSelected || line.isDir}>
{isSelected ? "❯" : " "}
{" ".repeat(line.depth)}
{chevron}
{label}
</Text>
);
})
)
) : touchedFiles.length === 0 ? (
<Text dimColor>No files touched yet.</Text>
) : (
touchedFiles.map((f, i) => {
const isSelected = focused && i === selectedIndex;
return (
<Text key={f.relPath} color={isSelected ? ACCENT_HEX : undefined} bold={isSelected}>
{isSelected ? "❯ " : " "}
<Text color={STATUS_COLOR[f.status]}>{STATUS_GLYPH[f.status]}</Text> {f.relPath}
{f.count > 1 ? <Text dimColor> ({f.count})</Text> : null}
</Text>
);
})
)}
</Box>
</Box>
<Text dimColor>{"─".repeat(Math.max(1, width - 4))}</Text>
<Text dimColor>{focused ? "↑↓ move · ↵/←/→ expand · Esc unfocus" : "Ctrl+G tab · Ctrl+F focus/hide"}</Text>
</Box>
);
}
+30 -6
View File
@@ -1,4 +1,5 @@
import { Box, Text } from "ink";
import { memo } from "react";
import { renderMarkdown } from "../render.js";
import { ACCENT_HEX } from "../theme.js";
import type { HistoryItem } from "./types.js";
@@ -15,20 +16,27 @@ const HELP_LINES = [
" /permissions list mutating tools allowed for the rest of this session",
" /sessions list saved conversations you can resume with --resume",
" /mcp show connected MCP servers and their tool counts",
" /mcp reconnect re-connect to configured MCP servers (after editing .mcp.json or restarting one)",
" /plugins show installed Claude Code-compatible plugins (commands, agents, MCP servers)",
" /hooks show configured hooks per lifecycle event",
" /skills show installed skills; /<skill-name> [request] invokes one directly",
" /compact summarize the conversation now to free up context",
" /export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)",
"save the conversation as markdown (or /export json [file] for a JSON dump) — editable filename prompt",
" /import <path> [caption] attach a local file or image to your next message",
" /clear clear conversation history",
" /help show this help",
" /exit, /quit exit",
"",
"Keyboard shortcuts:",
" Esc interrupt the current response",
" Shift+Tab cycle permission mode",
" Ctrl+O print the full text of the last /compact summary",
" Ctrl+B background the currently-running bash command",
" Ctrl+F open/focus the file panel; press again to close it (Esc to unfocus without closing)",
" Ctrl+G switch the file panel's tab (Files / Activity)",
" ↑↓ ↵ ← → (while the file panel is focused) navigate / expand / collapse folders",
" Mouse wheel / click+drag your terminal's own native scroll and text selection/copy — locode",
" doesn't intercept the mouse, so this always works like any other CLI",
];
function formatDuration(ms: number): string {
@@ -41,7 +49,12 @@ function formatDuration(ms: number): string {
return `${s}s`;
}
export function HistoryItemView({ item }: { item: HistoryItem }) {
// Memoized: staticItems only ever grows by appending, so every previously-committed item keeps
// the same object reference across re-renders. Without this, every historical item (including
// "assistant" ones, whose renderMarkdown call is real CPU work) gets its render function
// re-invoked on every parent re-render — which happens on every ThinkingIndicator spinner tick
// and every throttled streaming-text frame — even though nothing about that item changed.
export const HistoryItemView = memo(function HistoryItemView({ item, width }: { item: HistoryItem; width?: number }) {
switch (item.kind) {
case "banner":
return (
@@ -172,7 +185,18 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
);
case "assistant":
return <Text>{renderMarkdown(item.text)}</Text>;
return <Text>{renderMarkdown(item.text, width)}</Text>;
case "thinking":
// Dimmed, collapsible-style rendering for reasoning/thinking blocks
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Text dimColor>
<Text bold dimColor>Thinking:</Text>
{" "}{item.text}
</Text>
</Box>
);
// Deliberately NOT markdown-rendered while still streaming, unlike the finished "assistant"
// case above. marked-terminal re-wraps the *entire* accumulated text from scratch on every
@@ -219,8 +243,8 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
{item.todos.length === 0 ? (
<Text dimColor>Todos: (cleared)</Text>
) : (
item.todos.map((t, i) => (
<Text key={i} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
item.todos.map((t) => (
<Text key={t.content} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
{" "}
{icon[t.status]} {t.content}
</Text>
@@ -395,4 +419,4 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
</Box>
);
}
}
});
+13 -3
View File
@@ -2,6 +2,8 @@ import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import type { PermissionDecision } from "../../permissions/types.js";
import { ACCENT_HEX } from "../theme.js";
import { DiffView } from "./DiffView.js";
import { looksLikeDiff } from "../../utils/diff.js";
interface Props {
toolName: string;
@@ -33,15 +35,23 @@ function PermissionItem({ isSelected, label }: { isSelected?: boolean; label?: s
}
export function PermissionPrompt({ toolName, args, preview, onSelect }: Props) {
// Color-code the preview when it is a real diff (additions green, removals red, hunk/file
// headers dimmed) - the diff is the single most important thing the user evaluates before
// approving a mutating tool, so rendering it as plain monochrome text throws away signal.
// Falls back to the old line-by-line render for non-diff previews (a bash command, "Create
// new file ...", JSON args, etc.).
const isDiff = looksLikeDiff(preview);
const previewLines = (preview ?? JSON.stringify(args)).split("\n");
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
<Text bold>{toolName}</Text>
<Text> </Text>
{previewLines.map((line, i) => (
<Text key={i}>{line}</Text>
))}
{isDiff && preview ? (
<DiffView diff={preview} />
) : (
previewLines.map((line, i) => <Text key={i}>{line}</Text>)
)}
<Text> </Text>
<Text>Do you want to proceed?</Text>
<SelectInput
+17 -7
View File
@@ -1,5 +1,5 @@
import { Box, Text } from "ink";
import { useEffect, useState } from "react";
import { memo, useEffect, useState } from "react";
import { ACCENT_HEX } from "../theme.js";
import type { PermissionMode } from "../../permissions/types.js";
import type { GitInfo } from "../../utils/gitInfo.js";
@@ -32,6 +32,7 @@ interface Props {
createdAt: string;
inputTokens: number;
outputTokens: number;
modelTimeMs: number;
gitInfo: GitInfo | null;
}
@@ -63,7 +64,7 @@ function formatElapsed(ms: number): string {
return h > 0 ? `${h}h${m}m` : `${m}m`;
}
export function StatusBar({
export const StatusBar = memo(function StatusBar({
model,
mode,
permMode,
@@ -75,6 +76,7 @@ export function StatusBar({
createdAt,
inputTokens,
outputTokens,
modelTimeMs,
gitInfo,
}: Props) {
// Ticks every 30s purely to keep "elapsed"/burn-rate live while otherwise idle — no other state
@@ -90,10 +92,18 @@ export function StatusBar({
const approx = contextIsEstimate ? "~" : "";
const elapsedMs = Math.max(0, now - new Date(createdAt).getTime());
const totalTokens = inputTokens + outputTokens;
const elapsedMinutes = elapsedMs / 60_000;
const burnRate = elapsedMinutes >= 0.1 ? totalTokens / elapsedMinutes : null;
// Use model time (wall-clock time spent waiting on backend responses) rather than session
// elapsed time. A session open for 30 minutes with only 2 minutes of actual model interaction
// should show the real throughput, not a diluted rate that makes the model look slow.
const modelMinutes = modelTimeMs / 60_000;
const burnRate = modelMinutes >= 0.1 ? outputTokens / modelMinutes : null;
// Icons here are deliberately either full RGI emoji (📁 🔑 ⏳ 🔥 — string-width and terminals
// agree they're 2 wide) or plain geometric shapes (◆ ▸ — agree they're 1 wide). Avoid
// text-default emoji like ⏱ U+23F1 / ⏵ U+23F5: string-width calls them 1 but many terminals
// (Windows Terminal, iTerm) render them 2, so a line drifts wider than Ink thinks and the live
// region below leaves stranded copies of itself in scrollback on every re-render.
return (
<Box flexDirection="column" width="100%" paddingX={1}>
<Box gap={1}>
@@ -118,16 +128,16 @@ export function StatusBar({
<Text dimColor>│</Text>
<Text dimColor>🔑 {sessionId.slice(0, 8)}</Text>
<Text dimColor>│</Text>
<Text dimColor>⏱ {formatElapsed(elapsedMs)}</Text>
<Text dimColor>⏳ {formatElapsed(elapsedMs)}</Text>
<Text dimColor>│</Text>
<Text dimColor>🔥 {burnRate === null ? "—" : `${formatTokenCount(burnRate)}/min`}</Text>
</Box>
<Box gap={1} justifyContent="space-between">
<Text color={MODE_COLORS[permMode]}>
⏵⏵ {MODE_LABELS[permMode]} (shift+tab to cycle)
▸▸ {MODE_LABELS[permMode]} (shift+tab to cycle)
</Text>
<Text color="yellow">/export · /compact · /help · /exit</Text>
</Box>
</Box>
);
}
});
+3 -2
View File
@@ -1,11 +1,12 @@
import { Text } from "ink";
import Spinner from "ink-spinner";
import { memo } from "react";
import { ACCENT_HEX } from "../theme.js";
export function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
export const ThinkingIndicator = memo(function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
return (
<Text color={ACCENT_HEX}>
<Spinner type="dots" /> {label}
</Text>
);
}
});
+16 -29
View File
@@ -9,6 +9,9 @@ import { buildSkillTool } from "../../plugins/skillTool.js";
import type { ToolDef } from "../../tools/types.js";
import { flushPendingSaves } from "../../persistence/sessionStore.js";
import { killAllBackgroundJobs } from "../../tools/backgroundJobs.js";
import { shutdownAll as shutdownAllLspServers } from "../../codeintel/lspManager.js";
import { configureLanguageSpecs } from "../../codeintel/lspManager.js";
import { resolveLspServers } from "../../config/config.js";
import { App } from "./App.js";
export interface RunInkAppOptions {
@@ -29,6 +32,10 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
process.exit(1);
}
// Apply user-configured LSP server overrides/additions (locode config set lspServers) to the
// built-in language→server mappings before any LSP tool is used. Cheap and idempotent.
configureLanguageSpecs(resolveLspServers());
// Kick off MCP server connections immediately so they overlap with model listing/picking —
// by the time a session is actually created, this is usually already settled. Plugin-defined
// agents (agents/*.md) become tools too, alongside MCP-provided ones, and every installed
@@ -45,25 +52,12 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
// Ink render tree so cleanup() can still read it after instance.unmount() has torn everything
// in App.tsx down.
let currentSessionId: string | null = null;
// Whether we've switched to the terminal's alternate screen buffer — tracked so cleanup() only
// switches back if we actually switched away, and so a second session starting mid-process
// (e.g. picking a different saved session via /resume) doesn't re-enter it redundantly.
let enteredAltScreen = false;
// Entering the alternate screen alone does not guarantee the cursor starts at the top-left —
// some terminals carry the cursor row over from the main screen, leaving blank rows above
// Ink's first output until enough content has been printed to push past that row. Clear the
// new (blank) buffer and explicitly home the cursor so content always starts flush at (0, 0).
const ALT_SCREEN_ENTER = "[?1049h" + "" + "";
const ALT_SCREEN_EXIT = "[?1049l";
// Start the UI immediately — no blocking network calls before rendering.
// Model listing happens inside the App component so the user sees the UI right away.
//
// The model-select/connecting phases before a session exists stay on the main screen (so any
// startup errors remain in normal scrollback); once a session actually starts, we switch to the
// alternate screen buffer for a clean full-screen chat view. This trades away scrollback (no
// mouse-wheel/Shift+PgUp scrolling once in the alt screen) for that full-screen feel — a
// deliberate choice, revisit if the lack of scrollback turns out to matter more in practice.
// Start the UI immediately — no blocking network calls before rendering. Model listing happens
// inside the App component so the user sees the UI right away. Deliberately stays on the
// terminal's normal screen buffer (never the alternate screen) the whole time — finished
// history prints as permanent scrollback (see App.tsx's use of Ink's <Static>), so the
// terminal's own native mouse-wheel scroll and click-drag text selection/copy just work, with
// no app-side mouse tracking or virtual-scroll machinery needed.
const instance = render(
<App
baseURL={opts.baseURL}
@@ -76,10 +70,6 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
extraToolsPromise={extraToolsPromise}
onSessionIdChange={(id) => {
currentSessionId = id;
if (!enteredAltScreen) {
enteredAltScreen = true;
process.stdout.write(ALT_SCREEN_ENTER);
}
}}
/>,
);
@@ -92,16 +82,13 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
// Leave the alternate screen first (if we ever entered it) so everything printed below —
// hook errors, the resume hint — lands on the user's normal scrollback, not a screen that's
// about to disappear.
if (enteredAltScreen) {
process.stdout.write(ALT_SCREEN_EXIT);
}
// Flush in-flight autosaves first so a fire-and-forget persist right before exit isn't lost.
await flushPendingSaves();
// Best-effort: don't leave backgrounded shells (dev servers, watch builds) running as orphans.
await killAllBackgroundJobs();
// Best-effort: shut down any lazily-spawned LSP servers (tsserver, pyright, gopls,
// clangd, rust-analyzer) so they don't outlive locode as orphans.
await shutdownAllLspServers().catch(() => {});
await disconnectAllMcpServers();
// Best-effort — fires on every exit path, so there isn't always a specific session id to attach
// (e.g. exiting from the model picker before a session ever started).
+1
View File
@@ -33,6 +33,7 @@ export type HistoryItem =
}
| { id: string; kind: "user"; text: string }
| { id: string; kind: "assistant"; text: string }
| { id: string; kind: "thinking"; text: string }
| { id: string; kind: "streaming_text"; text: string }
| { id: string; kind: "tool_call"; label: string }
| { id: string; kind: "tool_result"; summary: string; isError: boolean }
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import stringWidth from "string-width";
import { renderMarkdown } from "./render.js";
const stripAnsi = (s: string) => s.replace(/\[[0-9;]*m/g, "");
describe("renderMarkdown width wrapping", () => {
it("holds every line of a wide table within the given width", () => {
const md = [
"| col a | col b | col c |",
"|-------|-------|-------|",
"| 압도적 (LangChain, LlamaIndex, 벡터DB 등 거의 다 Python 우선) | 따라가는 중 | x |",
"| asyncio 있지만 동기로 시작하면 나중에 고통 | 처음부터 async가 기본 | y |",
].join("\n");
for (const width of [40, 60, 80, 100]) {
const out = renderMarkdown(md, width);
for (const line of out.split("\n")) {
expect(stringWidth(line), `line "${line}" @${width}`).toBeLessThanOrEqual(width);
}
}
});
it("hard-breaks a long unbroken line (URL / code) to fit", () => {
const url = "https://example.com/" + "a".repeat(300);
const out = renderMarkdown(url, 50);
expect(out.split("\n").length).toBeGreaterThan(1);
for (const line of out.split("\n")) {
expect(stringWidth(line)).toBeLessThanOrEqual(50);
}
});
it("leaves already-narrow prose intact (content preserved)", () => {
const out = renderMarkdown("hello **world**", 80);
expect(stripAnsi(out)).toContain("hello world");
});
it("does not wrap when no width is given", () => {
const md = "a ".repeat(100).trim();
const out = renderMarkdown(md);
expect(out).not.toContain("\n");
});
it("caches width variants independently (no key collision)", () => {
const text = "80 this text starts with digits";
const wide = renderMarkdown(text);
const narrow = renderMarkdown(text, 80);
// A width-less call and a width-80 call on colliding-looking text must not return each other.
expect(wide).not.toContain("\n");
void narrow;
});
});
+31 -3
View File
@@ -1,11 +1,39 @@
import { marked } from "marked";
import { markedTerminal } from "marked-terminal";
import wrapAnsi from "wrap-ansi";
// @types/marked-terminal's MarkedExtension shape doesn't line up with the installed marked version;
// the two packages are functionally compatible at runtime per marked-terminal's own peer range.
marked.use(markedTerminal() as Parameters<typeof marked.use>[0]);
//
// tableOptions overrides cli-table3's own default header color (style.head: ['red']) — red-on-blue
// terminal themes make table headers hard to read. "yellow" is the closest this color scheme
// actually supports: it only recognizes standard ANSI 16-color names, and terminals have no true
// "orange" in that palette — literal "orange"/hex values are silently accepted but render with no
// color at all (verified empirically), so this is the practical substitute, not a compromise pick.
marked.use(markedTerminal({ tableOptions: { style: { head: ["yellow"] } } }) as Parameters<typeof marked.use>[0]);
export function renderMarkdown(text: string): string {
// marked.parse() + marked-terminal's ANSI formatting is real CPU work (word-wrap, table layout,
// syntax highlighting). Historical assistant messages are immutable once committed, so re-parsing
// the same text on every re-render (every spinner tick, every streamed-token frame) is pure waste
// that scales with total conversation length — cache by input text so each message is parsed once.
const renderCache = new Map<string, string>();
// marked-terminal (and cli-table3, for tables) emit lines that can be far wider than the terminal —
// a wide table, a long code line, a URL. The terminal soft-wraps those, but Ink's <Static> redraw
// math counts one physical row per logical line, so anything that soft-wrapped throws off its
// accounting: the live region below (status bar + input box) gets stranded copies of itself in
// scrollback on every subsequent re-render. Hard-wrapping the finished output to the caller's width
// keeps every physical line within bounds so Ink's line count stays exact.
export function renderMarkdown(text: string, width?: number): string {
const wrapWidth = width && width > 0 ? Math.floor(width) : 0;
const key = `${wrapWidth} ${text}`;
const cached = renderCache.get(key);
if (cached !== undefined) return cached;
const rendered = marked.parse(text);
return typeof rendered === "string" ? rendered.trimEnd() : text;
let result = typeof rendered === "string" ? rendered.trimEnd() : text;
if (wrapWidth > 0) {
result = wrapAnsi(result, wrapWidth, { hard: true, trim: false });
}
renderCache.set(key, result);
return result;
}
+2
View File
@@ -1 +1,3 @@
export const ACCENT_HEX = "#D97757";
export const DIFF_ADD_HEX = "#2ea043";
export const DIFF_REMOVE_HEX = "#e8904e";
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { looksLikeDiff } from "./diff.js";
describe("looksLikeDiff", () => {
it("recognizes a createPatch unified diff", () => {
expect(looksLikeDiff("Index: foo\n--- a\n+++ b\n@@ -1 +1 @@\n-x\n+y\n")).toBe(true);
});
it("recognizes a leading @@ hunk", () => {
expect(looksLikeDiff("@@ -1,2 +1,2 @@\n ctx\n-x\n+y\n")).toBe(true);
});
it("returns false for a plain bash command preview", () => {
expect(looksLikeDiff("rm -rf node_modules")).toBe(false);
});
it("returns false for empty / undefined", () => {
expect(looksLikeDiff("")).toBe(false);
expect(looksLikeDiff(undefined)).toBe(false);
expect(looksLikeDiff(null)).toBe(false);
});
});
+10
View File
@@ -0,0 +1,10 @@
/** True if `s` looks like a unified diff (the output of `createPatch` from the `diff` package),
* vs a plain preview like a bash command line or "Create new file …". Used to decide whether to
* render a preview with +/- color coding or as plain text. createPatch output starts with an
* `Index:`/`---`/`+++` header and always contains at least one `@@` hunk marker when there's a
* real change, so the hunk marker is the most reliable discriminator (a command line containing
* a stray `-` won't false-positive). */
export function looksLikeDiff(s: string | undefined | null): boolean {
if (!s) return false;
return s.includes("\n@@") || s.startsWith("@@") || s.startsWith("Index:") || s.startsWith("--- ");
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { estimateTokens } from "./tokens.js";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
describe("estimateTokens — script-aware heuristic", () => {
it("returns a positive number for a single system message", () => {
const msgs: ChatCompletionMessageParam[] = [{ role: "system", content: "You are helpful." }];
expect(estimateTokens(msgs)).toBeGreaterThan(0);
});
it("scales roughly linearly with English prose length", () => {
const short: ChatCompletionMessageParam[] = [{ role: "user", content: "hello" }];
const long: ChatCompletionMessageParam[] = [
{ role: "user", content: "hello ".repeat(100) + "world" },
];
// ~605 chars vs 5 chars: the long message should be many times larger (overhead aside).
expect(estimateTokens(long)).toBeGreaterThan(estimateTokens(short) * 15);
});
it("estimates CJK text as more tokens than the same length of Latin text", () => {
// Same character count, but Korean characters each map closer to 1:1 token.
const latin: ChatCompletionMessageParam[] = [
{ role: "user", content: "a".repeat(40) },
];
const korean: ChatCompletionMessageParam[] = [
{ role: "user", content: "안".repeat(40) },
];
expect(estimateTokens(korean)).toBeGreaterThan(estimateTokens(latin));
});
it("estimates symbol-heavy (code) text as more tokens than prose of the same length", () => {
const prose: ChatCompletionMessageParam[] = [
{ role: "user", content: "word word word word word word word word" },
];
const code: ChatCompletionMessageParam[] = [
{ role: "user", content: "{}{}{}{}{}{}{}{}()()()()()()()()[][][][]" },
];
// Both 39 chars; the code version has more dense-symbol weight.
expect(estimateTokens(code)).toBeGreaterThan(estimateTokens(prose));
});
it("accounts for tool_calls structure", () => {
const withCalls: ChatCompletionMessageParam[] = [
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "read_file", arguments: '{"path":"src/index.ts"}' },
},
],
} as ChatCompletionMessageParam,
];
expect(estimateTokens(withCalls)).toBeGreaterThan(10);
});
it("accounts for tool result messages", () => {
const result: ChatCompletionMessageParam[] = [
{ role: "tool", tool_call_id: "call_1", content: "the file contents are here" },
] as ChatCompletionMessageParam[];
expect(estimateTokens(result)).toBeGreaterThan(10);
});
it("handles multipart content arrays (text parts)", () => {
const msgs: ChatCompletionMessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "describe this" },
{ type: "text", text: "and this" },
],
} as ChatCompletionMessageParam,
];
expect(estimateTokens(msgs)).toBeGreaterThan(
estimateTokens([{ role: "user", content: "describe this" }]),
);
});
it("charges a flat cost for non-text content parts (images)", () => {
const textOnly: ChatCompletionMessageParam[] = [
{ role: "user", content: [{ type: "text", text: "look" }] } as unknown as ChatCompletionMessageParam,
];
const withImage: ChatCompletionMessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "look" },
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
],
} as unknown as ChatCompletionMessageParam,
];
expect(estimateTokens(withImage)).toBeGreaterThan(estimateTokens(textOnly));
});
it("grows with more messages, not just longer content", () => {
const one: ChatCompletionMessageParam[] = [{ role: "user", content: "ab" }];
const two: ChatCompletionMessageParam[] = [
{ role: "user", content: "ab" },
{ role: "assistant", content: "cd" },
];
// Each message carries a per-message overhead, so two short messages cost more than one.
expect(estimateTokens(two)).toBeGreaterThan(estimateTokens(one));
});
});
+92 -4
View File
@@ -1,8 +1,96 @@
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
/** Rough token estimate (~4 chars/token) used when the backend doesn't report real usage stats
* (via `stream_options: { include_usage: true }`) or before any turn has run yet. */
/**
* Per-message structural overhead the chat template adds (role tags, delimiters, etc.).
* Most chat templates wrap each message with ~3–5 special tokens (`<|im_start|>role\n`,
* `<|im_end|>\n`, etc.), so charge a flat per-message cost regardless of content length.
*/
const PER_MESSAGE_OVERHEAD = 4;
/**
* Estimate the token count of a chat message array without backend support.
*
* The old `JSON.stringify(messages).length / 4` heuristic had two failure modes:
* 1. It counted the JSON serialization overhead (quotes, braces, escaped delimiters) as
* content tokens — inflating the estimate by ~15–25% since those aren't sent to the model.
* 2. It used one flat chars-per-token ratio for everything, but that ratio varies a lot by
* script: English prose is ~4 chars/token, code/symbols ~3.5, and CJK (Korean/Chinese/
* Japanese) is ~1.5 chars/token because each code point is usually its own BPE token.
*
* This walker reconstructs only the text the model actually sees (system/user content strings,
* assistant content, tool calls, tool results) and applies a script-aware ratio. It stays a
* pure synchronous estimate — no backend calls — so it's safe to use before the first turn,
* after compaction, and when creating sub-agents.
*/
export function estimateTokens(messages: ChatCompletionMessageParam[]): number {
const chars = JSON.stringify(messages).length;
return Math.ceil(chars / 4);
let tokens = 0;
for (const msg of messages) {
tokens += PER_MESSAGE_OVERHEAD;
tokens += estimateContentTokens(msg);
}
// A trailing assistant generation sentinel / chat-template end tokens.
tokens += 3;
return Math.max(1, tokens);
}
function estimateContentTokens(msg: ChatCompletionMessageParam): number {
let chars = 0;
const content = (msg as { content?: unknown }).content;
if (typeof content === "string") {
chars += weightedChars(content);
} else if (Array.isArray(content)) {
for (const part of content) {
if (part == null) continue;
if (typeof part === "string") {
chars += weightedChars(part);
} else if (typeof part === "object") {
const p = part as { type?: string; text?: string };
// Text parts contribute their text; image/audio parts are a flat structural cost
// (the model sees a placeholder image token, not the base64 bytes).
if (p.type === "text" && typeof p.text === "string") chars += weightedChars(p.text);
else chars += 8;
}
}
}
// Tool calls: the model emits a JSON-ish structure; estimate its serialized size.
const toolCalls = (msg as { tool_calls?: unknown }).tool_calls;
if (Array.isArray(toolCalls)) {
for (const call of toolCalls) {
const c = call as { function?: { name?: string; arguments?: string }; id?: string };
if (c.function?.name) chars += weightedChars(c.function.name) + 4;
if (c.function?.arguments) chars += weightedChars(c.function.arguments) + 2;
if (c.id) chars += weightedChars(c.id) + 2;
chars += 6; // call/function wrapper tokens
}
}
// Tool result role: the content is the tool output text.
// `name` and `tool_call_id` are small metadata fields.
const name = (msg as { name?: string }).name;
if (name) chars += weightedChars(name) + 2;
const toolCallId = (msg as { tool_call_id?: string }).tool_call_id;
if (toolCallId) chars += weightedChars(toolCallId) + 2;
return Math.ceil(chars);
}
/**
* Map a content string to "effective chars" using a script-aware weight, then divided by a
* base chars-per-token ratio to get tokens. The weight encodes that a single CJK code point
* is worth roughly one token (ratio ~1.5) while Latin code points cluster ~4 per token, and
* dense punctuation/symbols (common in code) are closer to ~3.5.
*
* We accumulate weighted chars and the caller divides by the base ratio once.
*
* Regex-based approach: instead of iterating per character, we count CJK and dense-symbol
* matches in bulk and assign their weights, then treat the remainder as base weight.
*/
function weightedChars(text: string): number {
const cjk = text.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\u1100-\u11ff]/gu)?.length ?? 0;
const dense = text.match(/[!-\/:\-@\[-`\{-~]/g)?.length ?? 0;
const rest = text.length - cjk - dense;
const weight = cjk * 2.4 + dense * 1.15 + rest * 1;
return weight / 4; // base ratio
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { truncate } from "./truncate.js";
describe("truncate — head+tail preservation", () => {
it("returns short text unchanged", () => {
expect(truncate("hello", 100)).toBe("hello");
});
it("returns text at exactly the limit unchanged", () => {
const text = "x".repeat(100);
expect(truncate(text, 100)).toBe(text);
});
it("keeps the head and tail and elides the middle", () => {
// 50 lines of 10 chars each = 599 chars (49 newlines). Cap at 300.
const lines = Array.from({ length: 50 }, (_, i) => `line${String(i).padStart(5, "0")}`);
const text = lines.join("\n");
const out = truncate(text, 300);
// Head preserved: first line still present.
expect(out).toContain("line00000");
// Tail preserved: last line still present.
expect(out).toContain("line00049");
// Middle dropped: a middle line is gone.
expect(out).not.toContain("line00025");
// Truncation marker present with a character count.
expect(out).toMatch(/\[truncated \d+ more characters/);
});
it("preserves the tail error line a model most needs to see", () => {
const lines = Array.from({ length: 200 }, (_, i) => `row ${i}: data`);
// Append a final error line — the whole point of keeping the tail.
lines.push("Error: compilation failed at line 42");
const text = lines.join("\n");
const out = truncate(text, 300);
expect(out).toContain("Error: compilation failed at line 42");
});
it("cuts on line boundaries, never half a line", () => {
const lines = Array.from({ length: 100 }, (_, i) => `line-${i}-${"x".repeat(40)}`);
const text = lines.join("\n");
const out = truncate(text, 800);
// The head section's last kept line should be a complete line, not a fragment.
const firstPart = out.split("\n\n... [truncated")[0]!;
for (const line of firstPart.split("\n")) {
// Every kept head line should start with the known prefix or be empty.
expect(line === "" || /^line-\d+-x+$/.test(line)).toBe(true);
}
});
it("handles a file only slightly over budget without overlapping head and tail", () => {
const lines = Array.from({ length: 20 }, (_, i) => `line ${i}`);
const text = lines.join("\n");
// Budget just under total length so head and tail would overlap without the guard.
const out = truncate(text, text.length - 1);
// Should still return something readable with a truncation marker.
expect(out).toMatch(/\[truncated/);
// No duplicate lines: each kept line appears at most once as a *whole line*
// (match against line boundaries, not as a substring, since "line 1" is a substring of "line 10").
const outLines = out.split("\n");
for (const line of lines) {
const occurrences = outLines.filter((l) => l === line).length;
expect(occurrences).toBeLessThanOrEqual(1);
}
});
it("falls back to a character cut when every line is huge", () => {
// One massive line longer than the head budget.
const text = "x".repeat(5000);
const out = truncate(text, 1000);
expect(out).toMatch(/\[truncated/);
expect(out.length).toBeLessThan(text.length);
});
});
+64 -2
View File
@@ -1,4 +1,66 @@
/**
* Cap a string to roughly `maxChars` by keeping the head and the tail and eliding the
* middle — far more useful for command output than a bare head-only cut, because the tail
* usually carries the error/status line a model most needs to see.
*
* Cuts on line boundaries when possible so the result stays readable, and reports exactly
* how many characters were dropped so the model knows there is more it can't see.
*/
export function truncate(text: string, maxChars = 20_000): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n... [truncated ${text.length - maxChars} more characters]`;
}
// Reserve a few lines for the truncation notice itself so the final string doesn't
// overshoot maxChars after we splice the marker back in.
const NOTICE_SLACK = 120;
const budget = Math.max(maxChars - NOTICE_SLACK, Math.floor(maxChars * 0.9));
// Split into lines so we can cut on boundaries. We keep whole lines for both head and tail,
// never half a line — half-lines confuse both the model and the tests.
const lines = text.split("\n");
// First pass: keep ~60% of the budget for the head, ~40% for the tail. The head carries
// context and the tail carries the outcome (exit status, error, final summary).
const headBudget = Math.floor(budget * 0.6);
const tailBudget = budget - headBudget;
const headLines: string[] = [];
let headChars = 0;
for (const line of lines) {
// +1 accounts for the "\n" we'll rejoin with.
if (headChars + line.length + 1 > headBudget) break;
headLines.push(line);
headChars += line.length + 1;
}
const tailLines: string[] = [];
let tailChars = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (tailChars + line.length + 1 > tailBudget) break;
// Avoid overlapping with the head when the file is only slightly over budget.
if (i < headLines.length) break;
tailLines.unshift(line);
tailChars += line.length + 1;
}
const keptHead = headLines.join("\n");
const keptTail = tailLines.join("\n");
const dropped = text.length - (keptHead.length + keptTail.length);
// Edge case: the file is over budget but every single line is huge (longer than headBudget),
// so the loop above kept zero head lines. Fall back to a character cut so we still return
// something useful rather than an empty head + the whole tail.
if (headLines.length === 0) {
const headSlice = text.slice(0, headBudget);
const tailSlice = text.slice(text.length - tailBudget);
const droppedChars = text.length - headSlice.length - tailSlice.length;
return `${headSlice}\n\n... [truncated ${droppedChars} more characters — head+tail preserved, middle omitted] ...\n\n${tailSlice}`;
}
if (tailLines.length === 0) {
// Tail couldn't keep anything without overlapping head: just head + notice.
return `${keptHead}\n\n... [truncated ${dropped} more characters — tail omitted] ...`;
}
return `${keptHead}\n\n... [truncated ${dropped} more characters — middle omitted, ${headLines.length} head + ${tailLines.length} tail lines kept] ...\n\n${keptTail}`;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/cli.ts"],
format: ["esm"],
target: "node20",
target: "node22",
clean: true,
banner: {
js: "#!/usr/bin/env node",