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)
This commit is contained in:
kim
2026-08-20 16:52:03 +09:00
parent 6677ac1e9e
commit 6fe98887d5
54 changed files with 2944 additions and 456 deletions
+6 -6
View File
@@ -87,6 +87,7 @@ 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.
@@ -100,7 +101,7 @@ 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`.
@@ -151,13 +152,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 is manual: PageUp/PageDown scroll the conversation view a page at a time (the terminal's native scrollback isn't available in the alternate screen buffer). Scrolling back up unpins the view from the latest message; PageDown back to the bottom (or sending a new message) re-pins it so new messages auto-scroll into view again.
- 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.
+247
View File
@@ -0,0 +1,247 @@
# locode 업그레이드 작업 메모
## 프로젝트 개요
**locode** — Claude Code의 설계 철학을 가져와 로컬 모델(Ollama/LM Studio)용으로 재구현한 에이전트 코딩 CLI. TypeScript + Ink(React-for-CLI) 기반. 백엔드는 OpenAI 호환 `/v1/chat/completions` 엔드포인트 사용.
- 핵심 철학: "신뢰할 수 없고 느리고 비전/툴콜 지원이 불확실한 로컬 모델"이라는 현실에 맞춰 모든 가정을 비관적으로 재단
- Claude Code 플러그인 포맷을 직접 소비하는 하위호환 브리지 (`.claude-plugin/plugin.json`, commands/agents/skills/hooks/MCP)
## 빌드/테스트 상태
- `npm run build` (tsup) — 깨끗
- `npx tsc --noEmit` — 깨끗
- `npm test` (vitest) — 29 파일 215개 전부 통과
- 파일 인코딩: **CRLF** (edit_file 도구가 LF로 정규화해서 매칭 실패 → Python 스크립트로 바이너리 편집해야 함)
---
## 발견한 업그레이드 후보 (12개)
| # | 항목 | 난이도 | 효과 | 로컬 특화 | 상태 |
|---|---|:---:|:---:|:---:|:---:|
| 1 | 병렬 툴 실행 (read-only) | 중 | 대 | ★ | **완료** ✅ |
| 2 | 재시도 정책 설정화 (`maxRetries`) | 하 | 중 | ★ | **완료** ✅ |
| 3 | 정확한 토큰 추정 (`/api/tokenize` 또는 BPE) | 중 | 대 | ★★ | **완료** ✅ |
| 4 | 스마트 출력 캡 (head+tail, 라인 길이) | 하 | 중 | ★ | **완료** ✅ |
| 5 | 부분 히스토리 보존 컴팩션 | **상** | 대 | ★ | **완료** ✅ |
| 6 | 동적 `max_tokens` | 하 | 대 | ★ | **완료** ✅ |
| 7 | 툴 설명 풍부화 + 동적 툴 선택 | 중 | 중 | ★ | **완료** ✅ |
| 8 | MCP 연결 재시도·재연결 | 중 | 중 | | **완료** ✅ |
| 9 | 컨텍스트 윈도우 캐시 TTL | 하 | 중 | ★ | **완료** ✅ |
| 10 | `edit_file` 유사 매치 제안 | 중 | 대 | ★★ | **완료** ✅ |
| 11 | git_status 출력 head+tail | 하 | 중 | | **완료** ✅ |
| 12 | `auto-accept` 모드 세분화 | 중 | 중 | | **완료** ✅ |
---
## ✅ 완료: #6 동적 `max_tokens`
### 문제
`src/agent/loop.ts`의 두 생성 요청이 `max_tokens: 4096` 하드코딩:
- 729행: 스트리밍 요청 (`session.client.chat.completions.create`, `stream: true`)
- 840행(→이제 856행): 비스트리밍 재시도 (native 툴콜 인자가 깨졌을 때)
로컬 모델이 파일을 통째로 다시 쓸 때(fallback 모드에서 정밀 edit이 어려워 흔함) 4096 토큰으로 부족 → 응답 중간 잘림 → 툴콜 JSON 불완전 → malformed 에러 반복. 이게 "자꾸 에러가 나"던 원인.
### 해결
`shouldAutoCompact` 뒤(157행 근처)에 `resolveMaxTokens(session)` 헬퍼 추가:
```ts
function resolveMaxTokens(session: Session): number {
const MARGIN = 512;
const MIN = 2048;
const available = session.contextWindow - session.lastContextTokens - MARGIN;
return Math.max(MIN, Math.min(available, session.contextWindow));
}
```
두 사이트 모두 `max_tokens: 4096` → `max_tokens: resolveMaxTokens(session)` 교체.
### 동작
- `contextWindow − lastContextTokens − 512`를 출력 예산으로 할당
- 하한 2048: 컨텍스트 거의 찼어도 최소 출력 보장
- 상한 `contextWindow`: 윈도우 커도 그 이상 요구 안 함
- 32k 윈도우 / 8k 사용 중 → 약 23k 출력 (기존 4096의 5.7배)
- 8k 윈도우 / 6k 사용 중 → 2048 (기존과 동일)
### 남겨둔 것
- `compactSession`의 `max_tokens: 1024` (loop.ts:186) — 짧은 산문 요약용이라 동적 계산 불필요, 그대로 유지
- `capabilityProbe.ts`의 `max_tokens: 200` — 핑 툴용, 그대로 유지
### 검증
- `tsc --noEmit` ✓
- `npm run build` ✓ (217.48 KB)
- `npm test` ✓ 27파일 179개 전부 통과
### 편집 메모
- 파일이 CRLF라 edit_file 도구가 매칭 실패함 → `src/agent/_patch.py` 임시 스크립트로 바이너리 교체 후 삭제
- 향후 이 프로젝트 edit_file 시도 전 `file <path>`로 인코딩 확인; CRLF면 Python 바이너리 편집 또는 `sed` 사용
---
## ✅ 완료: #3 정확한 토큰 추정 (스크립트 인식 휴리스틱)
### 문제
`src/utils/tokens.ts`의 `estimateTokens`가 `JSON.stringify(messages).length / 4` — 두 가지 실패 모드:
1. JSON 직렬화 오버헤드(따옴표, 중괄호, 이스케이프)를 콘텐츠 토큰으로 계산 → 추정치 15–25% 부풀림 (모델에 안 보내는 것들)
2. 동일한 chars/token 비율을 모든 스크립트에 적용 → 영어 산문 ~4, 코드/기호 ~3.5, CJK(한국어/중국어/일본어) ~1.5인데 무시 → CJK 컨텍스트 과소평가, 컴팩션 타이밍 부정확
실제 usage가 오면 이미 정확하지만(`lastContextTokensIsEstimate = false`), 추정은 첫 턴 전/컴팩션 직후/서브에이전트 생성 시 사용 → 이 시점의 부정확이 컴팩션 타이밍을 빗나가게 함.
### 해결
`tokens.ts`를 메시지 구조 순회 + 스크립트 인식 휴리스틱으로 재작성:
- **PER_MESSAGE_OVERHEAD = 4**: 채팅 템플릿이 각 메시지에 추가하는 역할/구분자 토큰(~3–5) 반영
- **메시지별 콘텐츠 순회**: 시스템/사용자/어시스턴트 텍스트, tool_calls 구조, tool 결과를 JSON이 아닌 모델이 실제로 보는 텍스트로 추출
- **스크립트 인식 가중치** (`weightedChars`):
- CJK(히라가나/가타카나/한자/한글) ×2.4 → ~1.5 chars/token (각 코드 포인트가 보통 자체 BPE 토큰)
- 조밀 기호(구두점/연산자/괄호, 코드에 흔함) ×1.15 → ~3.5 chars/token
- 라틴 기본 ×1 → ~4 chars/token
- **멀티파트 콘텐츠**: 텍스트 파트는 텍스트, 이미지/오디오 파트는 flat 8 토큰(base64가 아닌 placeholder 토큰)
- 동기 순수 추정 유지(백엔드 호출 없음) → 첫 턴/컴팩션/서브에이전트에 안전
Ollama `/api/tokenize`는 백엔드 분기 + 매 턴 지연이 필요해 제외(의존성·복잡도 대비 효과 부족). 휴리스틱 개선으로 즉시 효과.
### 동작
- 한국어 메시지 40자: 기존 10 토큰 → ~27 토큰 (실제에 가까움)
- 코드/기호: 기존보다 약간 높게 → 컴팩션 조기 트리거 (OOM 방지)
- 영어 산문: 기존과 유사하되 JSON 오버헤드 제거 → 약간 낮아짐
- 구조(tool_calls, 멀티파트) 비용 반영
### 검증
- `tsc --noEmit` ✓
- `npm run build` ✓ (224.26 KB)
- `npm test` ✓ 29파일 203개 전부 통과 (신규 9개: tokens.test.ts)
- `loop.test.ts`는 `lastContextTokens` 직접 설정 → 추정값 변화에 영향 없음 확인
### 편집 메모
- `tokens.ts` CRLF → write_file + Python 변환
- `tokens.test.ts` LF → edit_file 사용
- `ChatCompletionMessageParam` 멀티파트 타입 캐스트 `as unknown as` 필요 (OpenAI 타입 narrow)
### 남겨둔 것
- Ollama `/api/tokenize` 캐싱: 백엔드별 분기 + 비동기 필요 → 별도 작업. 현재 휴리스틱으로 충분히 개선됨
- 실제 usage 도착 후에는 항상 정확한 값 사용(`updateContextTracking`의 `lastContextTokensIsEstimate = false`)
---
## ✅ 완료: #4 스마트 출력 캡 (head+tail 보존)
### 문제
`src/utils/truncate.ts`의 `truncate()`가 head만 보존. 긴 명령 출력에서 tail의 에러/상태 줄이 잘림 → 모델이 실패 원인을 못 봄. 특히 로컬 모델에서 bash/git 출력이 길면 마지막 에러 메시지가 사라져 디버깅 불가.
### 해결
`truncate.ts`를 head+tail 보존(중간 생략)으로 재작성:
- 라인 단위로 잘라 가독성 유지 (반 줄 잘림 방지)
- 예산의 60% head, 40% tail 할당 (tail이 에러/상태 줄을 담는 경우가 많아 비중 높임)
- head/tail 오버랩 가드 (예산 초과가 적을 때 중복 라인 방지)
- 모든 라인이 예산보다 길면 문자 단위 폴백
- 생략된 문자 수 + 보존된 head/tail 라인 수 표시
### 적용 범위
`truncate()` 시그니처 유지 → 모든 기존 호출자 자동 개선:
- `bash.ts` (stdout/stderr) — 가장 큰 효과
- `git.ts` (status/diff/log/show/branches 출력) — **#11도 함께 해결**
- `bashOutput.ts`, `backgroundJobs.ts`, `webFetch.ts`, `importFile.ts`
`readFile.ts`는 자체 페이지네이션(nextOffset)을 쓰므로 그대로 유지. `grep.ts`/`listFiles.ts`는 자체 limit 잘라내기 사용.
### 동작
- 짧으면 그대로 반환
- 길면 head 일부 + `... [truncated N more characters — middle omitted, X head + Y tail lines kept] ...` + tail 일부
- tail에 에러 줄이 있으면 모델이 볼 수 있음
### 검증
- `tsc --noEmit` ✓
- `npm run build` ✓ (222.30 KB)
- `npm test` ✓ 28파일 194개 전부 통과 (신규 7개: truncate.test.ts)
- 기존 호출자 테스트(bash/git/grep 등) 전부 통과 → 호환성 확인
### 편집 메모
- `truncate.ts`는 CRLF → write_file 후 Python으로 CRLF 변환
- `truncate.test.ts`는 LF → edit_file 도구 사용 가능
---
## ✅ 완료: #10 `edit_file` 유사 매치 제안
### 문제
fallback 모델(그리고 정밀 edit이 어려운 로컬 모델)이 `old_string`을 거의 정확히 but not exactly 제공 → `occurrences === 0` → 단순 "not found" 에러 → 모델이 맥락 없이 재시도, 실패 반복. 정확한 텍스트를 어디서 가져와야 할지 힌트가 없음.
### 해결
`src/tools/editFile.ts`에 유사 매치 제안 추가:
- `normaliseForCompare(s)`: 공백 연속을 단일 스페이스로 정규화 → 들여쓰기/줄바꿈 차이에 강건
- `boundedLevenshtein(a, b, maxDist)`: 조기 종료 Levenshtein. `maxDist` 초과 시 즉시 반환 → 큰 파일에서도 저렴
- `findSimilarMatch(content, needle)`: 파일 전체를 슬라이딩 윈도우(needle 길이 ±50%, step = needle/8)로 순회하며 정규화된 텍스트로 유사도 측정. 최고 점수 ≥ 0.5일 때만 반환
- `similarHint(original, oldString)`: 매치 실패 시 에러/preview 메시지에 "The closest match in the file (line N, ~X% similar):" + snippet 추가
handler와 preview 양쪽의 `occurrences === 0` 경로에 적용. 기존 "not found" 메시지 뒤에 힌트가 붙음.
### 동작
- 정확히 일치하는 부분이 있으면 기존 동작 유지 (힌트 없음)
- 유사한 부분이 있으면 위치·유사도·snippet 제안 → 모델이 정확한 `old_string`으로 재시도 가능
- 전혀 다르면 힌트 없이 "not found"만 (노이즈 방지)
### 검증
- `tsc --noEmit` ✓
- `npm run build` ✓ (220.91 KB)
- `npm test` ✓ 27파일 187개 전부 통과 (신규 3개: closest match 제안/preview/유사도 임계값)
### 편집 메모
- `editFile.ts`는 CRLF → Python 바이너리 편집으로 교체 + CRLF 유지
- `editFile.test.ts`는 LF → edit_file 도구 사용 가능
- `noUncheckedIndexedAccess` 활성화 → 배열 인덱스 접근 시 `?? 기본값` 처리 필요
---
## 남은 우선순위 — 모두 완료 ✅
12개 업그레이드 후보 전부 완료. 아래는 구현 요약.
### 즉시 효과 (구현 가벼움)
- ✅ **#4 스마트 출력 캡** — `truncate.ts` head+tail 보존, 모든 호출자 자동 개선
- ✅ **#10 `edit_file` 유사 매치 제안** — 매치 실패 시 Levenshtein 유사 위치 제안
- ✅ **#6 동적 max_tokens** — `resolveMaxTokens(session)`
- ✅ **#11 git 출력 head+tail** — #4로 함께 해결
### 정확도에 큰 영향
- ✅ **#3 정확한 토큰 추정** — 스크립트 인식 휴리스틱 (CJK/기호/구조 비용)
- ✅ **#5 부분 히스토리 보존 컴팩션** — 최근 N턴 원본 보존 + 이전 요약
### 성능
- ✅ **#1 병렬 툴 실행** — `runToolBatch`: read-only 툴 `Promise.all` 병렬, mutating 순차. 4개 루프에 적용
### 회복력
- ✅ **#2 재시도 정책** — `maxRetries` 설정화 (기본 0, SDK 지수 백오프)
- ✅ **#8 MCP 재연결** — `connectMcpServer` 재시도 + `/mcp reconnect` 명령 + 세션 toolset 갱신
- ✅ **#9 캐시 TTL** — `cachedAt` 타임스탬프 + N일(기본 7) 경과 재감지
### 기타
- ✅ **#7 툴 설명 풍부화** — 8개 핵심 툴 description에 "use when…"/예시 추가
- ✅ **#12 auto-accept 세분화** — `auto-accept` = 모든 mutating 툴 자동 승인, `auto-edit` = 편집 툴만 (설명-동작 일치)
---
## 핵심 파일 맵
- `src/agent/loop.ts` (1138행) — 메인 에이전트 루프, 턴/스트리밍/툴콜/컴팩션/서브에이전트/병렬 툴 배치
- `src/agent/session.ts` — Session 객체, 통계, 상태
- `src/agent/systemPrompt.ts` — 시스템 프롬프트 빌더 (매우 간결, 로컬 준수율 우선)
- `src/tools/` — 14개 내장 툴 (read_file, list_files, grep, web_search, web_fetch, git_status, write_file, edit_file, bash, bash_output, bash_kill, git_commit, todo_write, agent)
- `src/toolcalling/` — native 어댑터, fallback 파서/프롬프트, resolve (Ollama 빈키 복구 포함)
- `src/mcp/` — MCP 클라이언트/매니저/어댑터/config (모든 MCP 툴 mutating 강제)
- `src/hooks/` — 훅 러너 (병렬 실행, SSRF 가드, exit 0/2 시맨틱스)
- `src/plugins/` — Claude Code 플러그인 로더 (commands/agents/skills/hooks/MCP, 툴명 매핑)
- `src/backend/` — client, capabilityProbe, contextWindow(자동 탐지), capabilityCache
- `src/config/` — config (CLI > env > 저장 > 기본값 우선순위), defaults, store, types
- `src/permissions/` — permissionManager (default/plan/auto-edit/auto-accept), types
- `src/persistence/` — sessionStore (원자 쓰기, 큐잉), exportSession, replayHistory
- `src/utils/` — tokens, truncate, shell, processTree, image, html, mentions, projectInstructions
- `src/ui/ink/` — Ink(React) 풀스크린 TUI 컴포넌트
## 트러블슈팅 힌트
- "자꾸 에러"의 주요 원인: `max_tokens: 4096` 잘림 → malformed 툴콜 (✅ 해결됨)
- 컨텍스트 윈도우가 8192 기본값이면 `locode config set contextWindow <실제값>` 필요 — `resolveMaxTokens`가 제값을 내려면
- `/status`로 현재 model/backend/mode/cwd 확인 가능
- fallback 모드 툴콜 실패 시 `/mode fallback` 강제 또는 모델 교체
- **"Paused after N steps"가 자주 뜨면**: 1 스텝 = 1 모델 요청. 로컬 모델은 한 번에 1 툴만 호출하는 경향이 있어 다수 파일 작업이 50스텝을 쉽게 초과. 기본값 50→**100** 상향(일상 작업용). 큰 배치 작업 시 `locode config set maxIterations <number>` (예: 200). 정지 시 작업 내용은 보존되므로 "continue"로 이어서 진행 가능.
+128 -128
View File
@@ -1,12 +1,12 @@
{
"name": "locode",
"version": "0.5.1",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "locode",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@vscode/ripgrep": "^1.18.0",
@@ -94,9 +94,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
@@ -111,9 +111,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
@@ -128,9 +128,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
@@ -145,9 +145,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
@@ -162,9 +162,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
@@ -179,9 +179,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
@@ -196,9 +196,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
@@ -213,9 +213,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
@@ -230,9 +230,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
@@ -247,9 +247,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
@@ -264,9 +264,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
@@ -281,9 +281,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
@@ -298,9 +298,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
@@ -315,9 +315,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
@@ -332,9 +332,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
@@ -349,9 +349,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
@@ -366,9 +366,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
@@ -383,9 +383,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
@@ -400,9 +400,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
@@ -417,9 +417,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
@@ -434,9 +434,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
@@ -451,9 +451,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
@@ -468,9 +468,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
@@ -485,9 +485,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
@@ -502,9 +502,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
@@ -519,9 +519,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
@@ -536,9 +536,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "1.19.17",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -2169,9 +2169,9 @@
]
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2182,32 +2182,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escalade": {
@@ -2394,9 +2394,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -2679,9 +2679,9 @@
}
},
"node_modules/hono": {
"version": "4.12.27",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
"integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
"version": "4.13.2",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz",
"integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -2985,9 +2985,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -3363,9 +3363,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -3644,9 +3644,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -3664,7 +3664,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+3
View File
@@ -47,5 +47,8 @@
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"allowScripts": {
"esbuild@0.27.2": true
}
}
+7 -2
View File
@@ -7,8 +7,13 @@ export type AgentEvent =
* 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. */
+187 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { MaxIterationsError, runTurn, shouldAutoCompact } from "./loop.js";
import { MaxIterationsError, compactSession, runTurn, shouldAutoCompact } from "./loop.js";
import { agentTool } from "../tools/agentTool.js";
import { createSession } from "./session.js";
import { buildToolSet } from "../tools/toolset.js";
@@ -22,6 +22,43 @@ 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, 8192-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(8192);
});
});
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 +321,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
@@ -497,3 +594,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);
});
});
+233 -35
View File
@@ -16,7 +16,7 @@ import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolca
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";
@@ -155,22 +155,135 @@ 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> {
// 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);
// 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[] = [
...session.messages,
...prefix,
{
role: "user",
content:
"Summarize this entire conversation so far, concisely but completely: the user's goals, key decisions " +
"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.",
"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.)",
},
];
@@ -199,19 +312,30 @@ export async function compactSession(session: Session): Promise<string> {
}
session.stats.modelTimeMs += Date.now() - requestStart;
recordUsage(session, res.usage);
const summary = res.choices[0]?.message?.content;
summary = res.choices[0]?.message?.content ?? null;
if (!summary) {
throw new AgentError("Compaction failed: the model returned no summary.");
}
}
if (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}` },
...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) },
...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. */
@@ -363,7 +487,7 @@ async function gateAndRun(
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
@@ -440,7 +564,7 @@ async function gateAndRun(
.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
@@ -455,6 +579,39 @@ async function gateAndRun(
}
}
/**
* 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);
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 —
@@ -614,10 +771,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 +802,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 };
}
@@ -681,13 +857,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:
@@ -726,7 +902,7 @@ 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 },
);
@@ -837,7 +1013,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 +1057,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 +1091,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;
}
@@ -936,6 +1131,9 @@ export async function runTurn(
}
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>.`,
);
}
+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(),
});
}
+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();
});
});
+27 -1
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);
}
+21 -3
View File
@@ -114,20 +114,24 @@ 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)",
)
.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"
) {
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`,
);
process.exit(1);
}
@@ -139,6 +143,13 @@ configCmd
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 +171,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;
}
+47 -2
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, 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;
});
@@ -58,6 +61,27 @@ describe("config resolution", () => {
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 +125,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);
});
});
+29 -1
View File
@@ -2,6 +2,8 @@ import {
DEFAULT_AUTO_COMPACT_THRESHOLD,
DEFAULT_CONTEXT_WINDOW,
DEFAULT_MAX_ITERATIONS,
DEFAULT_MAX_OUTPUT_TOKENS,
DEFAULT_MAX_RETRIES,
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_SUBAGENT_TIMEOUT_MS,
KNOWN_BACKENDS,
@@ -56,6 +58,18 @@ export function resolveContextWindowDefault(): number {
return DEFAULT_CONTEXT_WINDOW;
}
/** 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. */
export function resolveMaxIterations(): number {
const stored = loadStoredConfig();
@@ -88,8 +102,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);
+27 -8
View File
@@ -12,19 +12,38 @@ export type BackendName = keyof typeof KNOWN_BACKENDS;
* and the user hasn't configured one — a conservative size common among smaller local models. */
export const DEFAULT_CONTEXT_WINDOW = 8192;
/** 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;
/** 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.2:cloud reports a 1,000,000-token context window but only ever
* generates up to 8192 tokens per response) — 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. 8192 is a safe default most backends support; raise it via
* `locode config set maxOutputTokens` for backends known to allow more. */
export const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
/** 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 gives real tasks 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 200` for large batch jobs. */
export const DEFAULT_MAX_ITERATIONS = 100;
/** 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
+6
View File
@@ -16,6 +16,12 @@ 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;
}
const paths = envPaths("locode", { suffix: "" });
+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;
+28 -5
View File
@@ -54,7 +54,22 @@ export interface ConnectedMcpServer {
tools: McpToolInfo[];
}
/** 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;
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,
@@ -69,15 +84,23 @@ export async function connectMcpServer(name: string, config: McpServerConfig): P
});
const client = new Client({ name: "locode", version: "0.3.1" });
await client.connect(transport);
try {
await client.connect(transport);
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.
// 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(() => {});
throw err;
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);
+4 -3
View File
@@ -3,9 +3,10 @@ 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. */
/** A markdown export is meant to be read as prose, so only plain user/assistant text turns are
* included — raw tool-call/tool-result payloads and fallback-mode `tool_result` blocks are
* internal bookkeeping, not conversation content (unlike session resume, which does replay them
* as their own history items — see replayHistory.ts — since that's an interactive transcript). */
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}`;
+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;
}
+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 {
+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";
@@ -81,3 +82,35 @@ 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:/);
});
});
+20 -4
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,12 +22,27 @@ 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
+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;
}
+79
View File
@@ -0,0 +1,79 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { editFileTool } 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/);
});
});
+122 -6
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({
@@ -24,14 +24,130 @@ 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 resolved: string;
try {
resolved = resolveWithinCwd(ctx.cwd, filePath);
} catch (err) {
return (err as Error).message;
}
let original: string;
try {
original = await fsReadFile(resolved, "utf-8");
@@ -40,7 +156,7 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
}
const occurrences = countOccurrences(original, old_string);
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, old_string)}`;
}
if (occurrences > 1 && !replace_all) {
return `Warning: old_string appears ${occurrences} times in ${resolved} — this edit will fail unless replace_all is set.`;
@@ -49,12 +165,12 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
return createPatch(resolved, original, updated, "", "");
},
handler: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
const resolved = resolveWithinCwd(ctx.cwd, filePath);
const original = await fsReadFile(resolved, "utf-8");
const occurrences = countOccurrences(original, old_string);
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, old_string)}`,
);
}
if (occurrences > 1 && !replace_all) {
+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) => {
+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) => {
+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;
}
+2 -1
View File
@@ -26,7 +26,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) => {
+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/);
});
});
+9 -3
View File
@@ -2,6 +2,7 @@ import { createPatch } from "diff";
import { mkdir, readFile as fsReadFile, 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({
@@ -19,11 +20,16 @@ async function readExisting(resolved: string): Promise<string | null> {
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 of rewriting the whole file.",
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,7 +37,7 @@ 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");
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
+245 -66
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, Text, useApp, useBoxMetrics, useInput, useStdin, useStdout, useWindowSize, type DOMElement } from "ink";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import path from "node:path";
import {
AgentError,
compactSession,
@@ -27,7 +28,7 @@ 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 { getMcpStatuses, reconnectMcpServers } from "../../mcp/manager.js";
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
import { defaultExportFilename, exportSession } from "../../persistence/exportSession.js";
import { loadMergedHooks } from "../../hooks/config.js";
@@ -46,13 +47,18 @@ 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 { matchMouseSequence } from "./mouseInput.js";
import { PermissionPrompt } from "./PermissionPrompt.js";
import { SessionSelect } from "./SessionSelect.js";
import { StatusBar } from "./StatusBar.js";
@@ -63,6 +69,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,6 +127,8 @@ 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",
@@ -120,13 +142,30 @@ export function App({
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);
// 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);
// 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).
// knows exactly how much vertical space is left (the history viewport above it flexes).
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
@@ -149,23 +188,56 @@ export function App({
// 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 { 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);
// The split between the history viewport and the bottom section is resolved by Yoga in a single
// layout pass (history grows/shrinks, bottom is fixed) rather than computed by hand from a
// measured bottom height — that measurement always lagged one frame behind the bottom section's
// actual height (useBoxMetrics updates in an effect *after* render), so on any frame it grew
// (a permission/export modal mounting, or the @-mention suggestion box opening in ChatInput)
// history was sized too tall and the bottom section overpainted it, producing the overlap.
// We still read the history viewport's own measured height, but only for the top-vs-bottom
// alignment decision — a one-frame lag there only affects alignment, never the split, so it's
// harmless (unlike the split, which is what caused the overlap).
const historyViewportRef = useRef<DOMElement | null>(null);
const { height: measuredHistoryHeight } = useBoxMetrics(historyViewportRef);
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 viewportHeight = Math.max(1, measuredHistoryHeight);
// How far (in rows) the content has scrolled past the viewport — the max meaningful scrollTop.
// Short conversations (content fits entirely) have maxScroll 0, which also naturally keeps them
// top-aligned instead of glued to the bottom with a gap above.
const maxScroll = Math.max(0, historyContentHeight - viewportHeight);
// Whether the view should keep tracking the latest content as it arrives (the normal chat
// behavior) or hold still at a manually scrolled position. PageUp breaks the pin; PageDown
// re-establishes it once scrolled back down to the bottom; sending a new message always re-pins.
const [pinnedToBottom, setPinnedToBottom] = useState(true);
// Rows scrolled down from the content's top edge — only meaningful while not pinned; while
// pinned, the effective scrollTop is just maxScroll (always show the latest content).
const [scrollTop, setScrollTop] = useState(0);
const effectiveScrollTop = pinnedToBottom ? maxScroll : Math.min(scrollTop, maxScroll);
// Refs mirroring the scroll-relevant values so the (once-registered) mouse-wheel listener can
// read the latest without re-binding on every state change (see mouseMode effect below).
const maxScrollRef = useRef(maxScroll);
maxScrollRef.current = maxScroll;
const pinnedToBottomRef = useRef(pinnedToBottom);
pinnedToBottomRef.current = pinnedToBottom;
const scrollTopRef = useRef(scrollTop);
scrollTopRef.current = scrollTop;
// Scroll the history viewport by `delta` rows (negative = up, positive = down), using the same
// pin/unpin rules as the PageUp/PageDown handler above. Shared by keyboard paging and the
// mouse-wheel listener so the two paths can't drift.
const scrollBy = useCallback((delta: number) => {
const ms = maxScrollRef.current;
const current = pinnedToBottomRef.current ? ms : Math.min(scrollTopRef.current, ms);
const next = Math.max(0, Math.min(ms, current + delta));
setScrollTop(next);
setPinnedToBottom(next >= ms);
}, []);
const flushStreamingText = useCallback(() => {
const accumulated = streamingAccumulatorRef.current;
@@ -193,9 +265,23 @@ export function App({
// 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.
// Enable xterm mouse tracking (X11 mode 1000 + SGR-1006 pixel format) so wheel events arrive on
// stdin as escape sequences. ink's input parser passes each mouse sequence through to useInput
// as a single event (with the leading ESC stripped from `input`), where we detect it below.
// Only enabled during the chat phase and only when raw mode is supported; toggling it off on exit
// (and on phase change) restores the terminal so the shell's own mouse mode isn't disturbed.
// Also suspended while the export filename prompt is open: that prompt's text field is
// ink-text-input (third-party) with no guard against raw mouse sequences, so scrolling while it's
// open would otherwise type them straight into the filename — simplest fix is to stop the
// terminal from sending them at all rather than filtering inside a dependency we don't control.
useEffect(() => {
refreshGitInfo();
}, [refreshGitInfo]);
if (!isRawModeSupported || phase !== "input" || exportPrompt) return;
stdout.write("[?1000h[?1006h");
setRawMode(true);
return () => {
stdout.write("[?1006l[?1000l");
};
}, [phase, isRawModeSupported, stdout, setRawMode, exportPrompt]);
// 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.
@@ -209,9 +295,60 @@ 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;
// Mouse wheel events (xterm SGR-1006 format). button 64 = wheel up, 65 = wheel down. We only
// react to wheel events, not regular button clicks — but any recognized mouse sequence still
// returns early so it can't fall through to a shortcut check below.
const mouseEvent = matchMouseSequence(input);
if (mouseEvent) {
if (mouseEvent.button === 64) scrollBy(-3);
else if (mouseEvent.button === 65) scrollBy(3);
return;
}
if (key.pageUp || key.pageDown) {
const pageStep = Math.max(1, viewportHeight - 1);
scrollBy(key.pageUp ? -pageStep : pageStep);
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({
@@ -279,10 +416,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),
@@ -331,10 +465,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),
@@ -365,17 +496,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) {
@@ -510,8 +633,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 +690,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 +743,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 +759,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 = "";
@@ -631,6 +774,7 @@ export function App({
setInputValue("");
const trimmed = raw.trim();
if (!trimmed) return;
setPinnedToBottom(true);
const session = sessionRef.current;
if (!session) return;
@@ -743,7 +887,20 @@ export function App({
push({ kind: "sessions", sessions: listSessions() });
return;
}
if (trimmed === "/mcp") {
if (trimmed === "/mcp" || trimmed === "/mcp reconnect") {
if (trimmed === "/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((t) => !t.name.startsWith("mcp__"));
sessionRef.current.toolset = buildToolSet([...kept, ...newMcpTools]);
push({ kind: "mcp", statuses: getMcpStatuses() });
} catch (err) {
push({ kind: "mcp", statuses: getMcpStatuses() });
}
return;
}
push({ kind: "mcp", statuses: getMcpStatuses() });
return;
}
@@ -860,7 +1017,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;
}
@@ -936,23 +1099,25 @@ 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}>
<Box flexDirection="row" width="100%" height={terminalRows} overflow="hidden">
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
{/* Scrollable, bottom-pinned viewport: overflowY="hidden" clips the content box, which is
* shifted up by a negative marginTop equal to effectiveScrollTop rows — at maxScroll (the
* pinned-to-bottom default) that puts the *latest* content flush against the bottom edge,
* auto-scrolling to it without any input; PageUp/PageDown (see the useInput handler above)
* unpin and walk scrollTop up/down a page at a time. 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 within a *bounded* viewport. 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 ref={historyViewportRef} flexDirection="column" flexGrow={1} flexShrink={1} overflowY="hidden">
{/* flexShrink={0} is load-bearing: Yoga's default flexShrink is nonzero, so without this the
* content box (and every item inside it) gets squeezed down to the viewport's height instead
* of clipped at it — Yoga distributes the deficit proportionally across every child, which
* rounds most rows down to zero height and leaves only a handful of survivors, rendering as
* scrambled/decimated lines instead of a clean top slice or bottom slice of real content. */}
<Box flexDirection="column" flexShrink={0} ref={historyContentRef} marginTop={-effectiveScrollTop}>
{staticItems.map((item) => (
<HistoryItemView key={item.id} item={item} />
))}
@@ -965,7 +1130,7 @@ export function App({
</Box>
</Box>
<Box flexDirection="column" ref={bottomSectionRef}>
<Box flexDirection="column" ref={bottomSectionRef} flexShrink={0}>
{permission ? (
<PermissionPrompt
toolName={permission.toolName}
@@ -987,6 +1152,9 @@ export function App({
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
) : (
<>
{!pinnedToBottom && sessionRef.current && phaseRef.current === "input" && (
<Text dimColor>── scrolled up · PageDown to jump to latest ──</Text>
)}
{sessionRef.current && phaseRef.current === "input" && (
<StatusBar
model={sessionRef.current.model}
@@ -1009,21 +1177,32 @@ export function App({
// 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>
<Text dimColor>Waiting for response… (esc to interrupt)</Text>
</Box>
) : (
<ChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
onCyclePermMode={cyclePermMode}
cwd={cwd}
history={history}
availableColumns={chatColumns}
isActive={!filePanelFocused}
/>
)}
</>
)}
</Box>
</Box>
<FilePanel
visible={filePanelVisible}
activeTab={filePanelTab}
cwd={cwd}
touchedFiles={touchedFilesList}
width={FILE_PANEL_WIDTH}
focused={filePanelFocused}
onExitFocus={() => setFilePanelFocused(false)}
/>
</Box>
);
}
+31 -16
View File
@@ -5,14 +5,22 @@ import stringWidth from "string-width";
import { getAbsolutePosition } from "./absolutePosition.js";
import { ACCENT_HEX } from "../theme.js";
import { getActiveMention } from "../../utils/mentions.js";
import { matchMouseSequence } from "./mouseInput.js";
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 +30,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 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,14 +51,17 @@ 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);
@@ -103,7 +114,7 @@ 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;
@@ -162,11 +173,15 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
}
useInput((input, key) => {
// Shift+Tab: cycle permission mode (takes priority even while suggestions are open).
if (key.shift && key.tab) {
onCyclePermMode?.();
return;
}
// App.tsx's own useInput (mounted for the whole app) already handles xterm SGR mouse sequences
// (wheel scroll, clicks) for the history viewport — but ink broadcasts every raw stdin event to
// every active useInput hook, so this component sees the same sequence too. Without this guard
// it falls through to the catch-all `if (input)` below and types the raw escape text into the
// chat box on every scroll notch. See mouseInput.ts.
if (matchMouseSequence(input)) 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;
if (key.escape) {
cancelMention();
return;
@@ -231,7 +246,7 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
if (input) {
replaceValue(value.slice(0, cursorOffset) + input + value.slice(cursorOffset), cursorOffset + input.length);
}
});
}, { isActive });
return (
<Box flexDirection="column" width="100%">
+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"]);
});
});
+256
View File
@@ -0,0 +1,256 @@
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;
/** 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, 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}
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>
);
}
+14 -2
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,6 +16,7 @@ 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",
@@ -26,9 +28,14 @@ const HELP_LINES = [
" /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",
" PageUp/PageDown scroll the conversation",
];
function formatDuration(ms: number): string {
@@ -41,7 +48,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 }: { item: HistoryItem }) {
switch (item.kind) {
case "banner":
return (
@@ -395,4 +407,4 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
</Box>
);
}
}
});
+3 -3
View File
@@ -61,9 +61,9 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
//
// 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.
// alternate screen buffer for a clean full-screen chat view. This trades away the terminal's own
// scrollback (no mouse-wheel/Shift+PgUp once in the alt screen) for that full-screen feel — App.tsx
// provides its own in-app scrollback instead (PageUp/PageDown over the history viewport).
const instance = render(
<App
baseURL={opts.baseURL}
+13
View File
@@ -0,0 +1,13 @@
// xterm SGR-1006 mouse sequences (enabled in App.tsx via `\x1b[?1000h\x1b[?1006h`) arrive on stdin
// as a single ink `useInput` event with the leading ESC stripped, e.g. "[<64;12;5M" (wheel up) or
// "[<0;12;5M" (left button down). Every active `useInput` hook in the tree receives the same raw
// event — ink has no concept of one handler "consuming" it before others see it — so any text-input
// component that doesn't recognize the sequence falls through to its regular-character handling and
// types the raw escape text into the field. Shared so every `useInput` consumer can ignore it the
// same way App.tsx's own scroll handler already does.
const MOUSE_SEQUENCE_RE = /^\[<(\d+);\d+;\d+[Mm]$/;
export function matchMouseSequence(input: string): { button: number } | null {
const match = MOUSE_SEQUENCE_RE.exec(input);
return match ? { button: Number(match[1]) } : null;
}
+11 -1
View File
@@ -5,7 +5,17 @@ import { markedTerminal } from "marked-terminal";
// the two packages are functionally compatible at runtime per marked-terminal's own peer range.
marked.use(markedTerminal() as Parameters<typeof marked.use>[0]);
// 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>();
export function renderMarkdown(text: string): string {
const cached = renderCache.get(text);
if (cached !== undefined) return cached;
const rendered = marked.parse(text);
return typeof rendered === "string" ? rendered.trimEnd() : text;
const result = typeof rendered === "string" ? rendered.trimEnd() : text;
renderCache.set(text, result);
return result;
}
+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));
});
});
+117 -4
View File
@@ -1,8 +1,121 @@
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.
*/
function weightedChars(text: string): number {
let weight = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (isCjk(code)) {
weight += 2.4; // ~1.5 chars/token instead of 4 → ×2.4
} else if (isDenseSymbol(code)) {
weight += 1.15; // ~3.5 chars/token → ×1.15
} else {
weight += 1; // base ~4 chars/token
}
}
return weight / 4; // base ratio
}
function isCjk(code: number): boolean {
// CJK Unified Ideographs + extensions, Hiragana, Katakana, Hangul syllables/jamo.
return (
(code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
(code >= 0x3400 && code <= 0x9fff) || // CJK ideographs (incl. ext A)
(code >= 0xac00 && code <= 0xd7af) || // Hangul syllables
(code >= 0x1100 && code <= 0x11ff) // Hangul jamo
);
}
function isDenseSymbol(code: number): boolean {
// Punctuation, math, operators, brackets — the kind of characters that dominate code and
// tend to each consume ~1 BPE token rather than sharing a token with neighbours.
return (
(code >= 0x21 && code <= 0x2f) || // ! " # $ % & ' ( ) * + , - . /
(code >= 0x3a && code <= 0x40) || // : ; < = > ? @
(code >= 0x5b && code <= 0x60) || // [ \ ] ^ _ `
(code >= 0x7b && code <= 0x7e) // { | } ~
);
}
+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);
});
});
+63 -1
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}`;
}