Add plugin/hook/skill support, images, @mentions, dashboard, and stability fixes

Builds out locode's Claude Code plugin parity: MCP servers, slash commands,
sub-agents, hooks (12 lifecycle events), and skills, all loadable from a
local path or git URL. Also adds image support (read_file, /import), @-mention
file autocomplete, a /dashboard stats view, /export with an editable filename
prompt, an expanded git tool (reset/stash/merge/rebase/delete_branch), and a
redesigned status bar.

On top of that, a review pass found and fixed 10 correctness/stability bugs:
argument-injection in git reset/merge/rebase (a ref like "--hard" was parsed
as a flag), tool-call image results interleaving with native tool_call_id
messages and breaking OpenAI-compatible message ordering, backgrounded bash
jobs still being silently killed by their original timeout with the kill
masked as a clean exit, a SubagentStart hook's block being ignored, the
sub-agent timeout clock starting before the hook it should exclude, a
template-expansion bug that could re-substitute $1..$9 placeholders,
unbounded background-job output buffers, a missing directory-target fallback
in /export, and image size caps checked after reading the whole file instead
of before.

Session saves and exports now go through a shared atomic
write-then-rename helper so a crash can't leave a truncated file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-07 18:17:45 +09:00
co-authored by Claude Sonnet 5
parent 265f8930a3
commit 2a35f40c73
64 changed files with 3840 additions and 338 deletions
+144
View File
@@ -0,0 +1,144 @@
# Plan: Resolve locode's 9 known issues
## Goals
Fix all nine documented issues/limitations in one coherent pass, keep `typecheck` green, and add enough tests so `npm test` passes.
## Issues and implementation details
### 1. Grep bug: patterns starting with `-` are parsed as ripgrep flags
**File:** `src/tools/grep.ts`
**Approach:** Pass the pattern with ripgrep's `-e` option instead of as a positional argument, and put `--` before the path argument. This prevents any pattern (including `-foo`, `--foo`, `+foo`) from being interpreted as a flag.
**Test:** Add `src/tools/grep.test.ts` that mocks `execa` and verifies the generated args for a leading-dash pattern.
### 2. Security: session id is not sanitized before building the file path
**File:** `src/persistence/sessionStore.ts`
**Approach:**
- Derive a safe filename from the user-supplied id by replacing path separators and other unsafe characters with `_`.
- Keep the original id inside the record (it already is), but use the sanitized id for the filesystem lookup.
- Add a `safeId` helper and use it in `filePath`, `loadSession`, `deleteSession`, and `listSessions` so `--resume ../../etc/passwd` cannot escape the sessions directory.
**Test:** Add `src/persistence/sessionStore.test.ts` verifying that malicious ids are contained, normal ids still work, and `listSessions` ignores non-`.json` files.
### 3. Tests: `npm test` fails because there are no test files
**Approach:** Create the first test suite covering the fixes above. Add `vitest.config.ts` with `globals: true` and a `test/`/`src/**/*.test.ts` include. The tests will be pure unit tests that don't require a backend.
**Files to add:**
- `vitest.config.ts`
- `src/tools/grep.test.ts`
- `src/persistence/sessionStore.test.ts`
- `src/plugins/expandTemplate.test.ts` (small sanity test for existing behavior)
### 4. MCP content types: non-text results are placeholder-only
**Files:** `src/mcp/toolAdapter.ts`, `src/mcp/client.ts` (type update)
**Approach:**
- Update `McpToolInfo` and the call-tool handler to support `image`, `audio`, and `resource` content blocks from the MCP spec.
- For `image`: include `mimeType` and a truncated base64 note; if the model path is vision-capable, return the actual `data:` URI so the model can see it (same pattern as `read_file`).
- For `audio`: include `mimeType` and a note.
- For `resource`: if it's a text resource, inline the text; if binary, note the URI and mime type.
- Keep text blocks unchanged.
**Test:** Add `src/mcp/toolAdapter.test.ts` with mocked MCP content blocks of each type.
### 5. Hooks: only 6 events; no structured JSON output; no HTTP/prompt/agent hook types
**Files:** `src/hooks/types.ts`, `src/hooks/runner.ts`, `src/hooks/config.ts`
**Approach (scoped but complete within reason):**
- Expand the hook event set to the most useful missing events: `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop` is already present. Final set: all 12 events from Claude Code's common set:
`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`.
- Add typed payload shapes for each event in `src/hooks/types.ts`.
- Add a structured output schema option: hooks can specify `outputSchema: "json"` in their config; when set, stdout is parsed as JSON and validated against a simple zod schema. If parsing fails, treat as a warning.
- Add an `HTTPHook` type (simple GET/POST with optional headers/body) and a `PromptHook` type (shows a yes/no prompt to the user). Wire `HTTPHook` in the runner; `PromptHook` will be added to types but marked as not yet implemented in the runner to avoid UI-blocking complexity in this pass.
- Fire `PermissionRequest` right before the user confirm gate in `agent/loop.ts gateAndRun`.
- Fire `SubagentStart`/`SubagentStop` around `runSubAgentTurn`.
- Fire `CwdChanged` when the session `cwd` would change (currently static; hook is informational for future use).
- Fire `FileChanged` after a mutating `write_file`/`edit_file`/`bash` succeeds.
- Fire `ConfigChange` when `/model`, `/backend`, `/mode`, or `locode config set` changes config.
**Test:** Add `src/hooks/runner.test.ts` testing exit-code semantics (0/2/other), JSON output parsing, and tool-scoped matching.
### 6. Plugin collisions: MCP and skill name collisions are not resolved cleanly
**Files:** `src/mcp/config.ts`, `src/plugins/skillTool.ts`, `src/plugins/registry.ts`
**Approach:**
- MCP servers: when a plugin provides a server whose name collides with a user or project server, log/record the collision and keep the more-specific tier (project > user > plugin). Add a `collisions` field to the returned merge result and surface it in `/mcp`.
- Skills: namespace skills internally as `<pluginName>/<skillName>` in the skill tool, but still accept the bare skill name for the `/name` shortcut. If two skills share a bare name, prefer the first loaded plugin and emit a warning in `/skills` listing duplicates.
- Plugin agent names are already namespaced (`agent__<plugin>__<agent>`); commands are already namespaced by the fact that they share a single command namespace. Add a warning list for duplicate command names too.
- Add a `PluginCollisionWarning` type and expose it via `getLoadedPlugins()` metadata.
**Test:** Add `src/plugins/skillTool.test.ts` and `src/mcp/config.test.ts` for collision behavior.
### 7. Skill references: cannot load sibling reference files for a skill
**Files:** `src/plugins/loader.ts`, `src/plugins/types.ts`, `src/plugins/skillTool.ts`
**Approach:**
- When loading a skill, also read any `references/*.md` files in the same skill directory.
- Store them as `references: { name, content }[]` on `PluginSkill`.
- When the skill is invoked (via the `skill` tool or `/name`), concatenate the references after the main SKILL.md body under a clear header so the model sees them.
**Test:** Add `src/plugins/loader.test.ts` with a temporary in-memory plugin layout.
### 8. Git operations: `git_commit` covers a subset
**File:** `src/tools/git.ts`
**Approach:** Extend `git_commit` with these additional operations:
- `reset` — `git reset` (mixed by default) with optional `ref` and `mode` (soft/mixed/hard). Requires confirmation; preview shows affected commits/files.
- `stash` — `git stash push` (with optional message and paths) and `git stash pop` (with optional stash ref). Preview shows what will be stashed/popped.
- `merge` — `git merge <branchName>` with optional `--no-ff`/`--ff-only`. Preview shows branches and commits.
- `rebase` — `git rebase <branchName>` with optional `--onto`. Preview shows commits.
- `delete_branch` — `git branch -d/-D <branchName>`. Preview shows the branch and whether it has unmerged commits.
- Add `operation` union entries and the necessary parameters (`ref`, `mode`, `stashRef`, `branchName`, `strategy`, `message`, `paths`, `force`).
- Keep the existing preview/handler pattern.
**Test:** Add `src/tools/git.test.ts` that validates argument generation for each operation (no real git exec).
### 9. Context window: 85% auto-compact threshold is hardcoded
**Files:** `src/agent/loop.ts`, `src/config/config.ts`, `src/config/store.ts`, `src/config/defaults.ts`, `src/cli.ts`
**Approach:**
- Add `autoCompactThreshold` to `StoredConfig` and env var `LOCODE_AUTO_COMPACT_THRESHOLD`.
- Default remains 0.85; allow values 0.1–0.95.
- Add `locode config set autoCompactThreshold <0.0-1.0>` and `locode config get autoCompactThreshold`.
- Read it in `resolveAutoCompactThreshold()` and use it in `shouldAutoCompact` in `agent/loop.ts`.
- Pass the threshold into the `Session` object so sub-agents inherit it.
**Test:** Add `src/config/config.test.ts` for threshold resolution and `src/agent/loop.test.ts` for the compact check.
## Files to modify
1. `src/tools/grep.ts`
2. `src/persistence/sessionStore.ts`
3. `src/mcp/toolAdapter.ts`
4. `src/mcp/client.ts`
5. `src/mcp/types.ts` (add status/collision type)
6. `src/mcp/config.ts`
7. `src/mcp/manager.ts` (surface collision warnings)
8. `src/hooks/types.ts`
9. `src/hooks/runner.ts`
10. `src/hooks/config.ts`
11. `src/agent/loop.ts` (fire new hooks, use threshold)
12. `src/agent/session.ts` (store threshold)
13. `src/plugins/loader.ts`
14. `src/plugins/types.ts`
15. `src/plugins/skillTool.ts`
16. `src/plugins/registry.ts` (collision tracking)
17. `src/plugins/agentTool.ts` (add command dup tracking if needed)
18. `src/tools/git.ts`
19. `src/config/config.ts`
20. `src/config/store.ts`
21. `src/config/defaults.ts`
22. `src/config/types.ts`
23. `src/cli.ts` (add config key)
24. `src/ui/ink/App.tsx` (pass threshold, fire ConfigChange)
25. `src/ui/ink/HistoryItemView.tsx` (show MCP collision warnings)
26. `README.md` (update limitations)
## Files to add
1. `vitest.config.ts`
2. `src/tools/grep.test.ts`
3. `src/persistence/sessionStore.test.ts`
4. `src/plugins/expandTemplate.test.ts`
5. `src/mcp/toolAdapter.test.ts`
6. `src/hooks/runner.test.ts`
7. `src/plugins/skillTool.test.ts`
8. `src/mcp/config.test.ts`
9. `src/plugins/loader.test.ts`
10. `src/tools/git.test.ts`
11. `src/config/config.test.ts`
12. `src/agent/loop.test.ts`
## Validation
- Run `npm run typecheck` — must pass.
- Run `npm test` — must pass.
- Run `npm run build` — must succeed.
## Risks / trade-offs
- Expanding hooks to 12 events touches `agent/loop.ts` in several places; need to keep event payloads consistent.
- MCP content type support is best-effort; real vision models may still ignore audio/resource blocks.
- Git operation expansion increases the chance of destructive commands (`reset --hard`, `rebase`, `delete_branch`); previews must be clear and the tool remains mutating/confirm-gated.
- Making the auto-compact threshold configurable requires threading a new field through `Session` creation/resumption.
+2 -1
View File
@@ -2,4 +2,5 @@ node_modules/
dist/
*.log
.env
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*.txt
.claude/settings.local.json
+39 -5
View File
@@ -65,15 +65,39 @@ Project-level servers can also be checked into a repo via a `.mcp.json` file in
locode connects to every configured server on startup; their tools show up alongside the built-in ones, namespaced as `mcp__<server>__<tool>`.
locode can also install **Claude Code plugins directly** — the parts that map onto locode's own architecture, anyway (see below for what doesn't):
```sh
locode plugin add ./path/to/plugin # a local plugin directory
locode plugin add https://github.com/someone/some-plugin.git # cloned via git
locode plugin list
locode plugin remove <name>
```
Hooks (see below) are hand-edited JSON rather than added via a CLI subcommand:
```sh
locode hooks path # print the user-level hooks.json path to edit
locode hooks list # see every configured hook (plugin + user + project), merged
```
## How it works
locode is a full-screen terminal app built with [Ink](https://github.com/vadimdemedes/ink) (the same React-for-CLI framework Claude Code itself is built with) — it needs a real interactive terminal (piped/redirected input isn't supported). It takes over the terminal's alternate screen buffer (like `vim`/`htop`) — your prior scrollback is restored when you exit. The input box is always pinned to the last row of the window; the conversation fills the space above it and old messages scroll off the top as new ones arrive. Press Ctrl+C or type `/exit` to quit.
- **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.
- **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` run automatically. `write_file`, `edit_file`, `bash`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status`, `bash_output` run automatically. `write_file`, `edit_file`, `bash`, 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.
- **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import <path> [caption]`.
- **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text.
- **Git**: `git_status` covers read-only inspection (`status`, `diff`, `log`, `show`, `branches`) and runs automatically. `git_commit` covers `add`, `commit`, `create_branch`, `checkout`, and `push` — each shows the actual diff/status/commits it's about to affect before you confirm (e.g. a commit's preview is the staged diff plus the message, a push's preview is the list of commits it would send).
- **Sub-agents**: the `agent` tool lets the model delegate a self-contained task to a fresh, isolated tool loop (same tools, minus `agent` itself — no nested sub-agents) and get back only the final answer, keeping the main conversation's context focused. It shows up as a single `⏺ Agent(description)` / `⎿ Sub-agent finished (...)` line — the sub-agent's own intermediate steps aren't displayed. Any mutating tool calls it makes still go through the same permission prompts as the main conversation.
- **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>`. A server's tool is treated as mutating (confirmation required) unless it declares itself read-only via the MCP `readOnlyHint` annotation. 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.
- **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`.
@@ -86,11 +110,17 @@ Note: even models with genuine native tool-calling support occasionally emit a t
/backend <name> switch backend (ollama | lmstudio), keeps current model
/mode <name> view or force tool-call mode (native | fallback)
/status show current model, backend, tool-call mode, and cwd
/dashboard show session stats: token I/O, elapsed/model time, turns, tool calls
/tools list available tools
/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
/plugins show installed Claude Code-compatible plugins (commands, agents, skills, MCP servers)
/hooks show configured hooks per lifecycle event
/skills show installed skills; /<skill-name> [request] invokes one directly
/compact summarize the conversation now to free up context
/export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)
/import <path> [caption] attach a local file or image to your next message
/clear clear conversation history
/help show this help
/exit, /quit exit
@@ -98,13 +128,14 @@ Note: even models with genuine native tool-calling support occasionally emit a t
## Config
Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCODE_BASE_URL`, `LOCODE_CONTEXT_WINDOW`, `LOCODE_MAX_ITERATIONS`) > persisted config file > defaults.
Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCODE_BASE_URL`, `LOCODE_CONTEXT_WINDOW`, `LOCODE_MAX_ITERATIONS`, `LOCODE_AUTO_COMPACT_THRESHOLD`) > persisted config file > defaults.
```sh
locode config set backend ollama
locode config set model qwen3-coder:30b
locode config set contextWindow 32768 # fallback size when auto-detection fails
locode config set maxIterations 40 # max tool calls per turn before locode gives up (default 25)
locode config set autoCompactThreshold 0.85 # fraction of context window at which auto-compact triggers
locode config get
locode config path
```
@@ -117,6 +148,9 @@ locode config path
- 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).
- 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 only — no reset, stash, merge, rebase, or branch deletion. Use `bash` for anything beyond that.
- MCP tool results only support text content — image/audio/resource content blocks are shown as a placeholder note rather than rendered. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows.
- Compaction (`/compact` or automatic at 85%) 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 85% threshold isn't currently configurable.
- `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`.
- 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.
+7 -1
View File
@@ -1,7 +1,13 @@
export type AgentEvent =
| { type: "text_delta"; delta: string }
| { type: "text_done"; fullText: string }
/** Throw away any text streamed so far this turn without committing it as an assistant message —
* emitted when a partially-streamed native tool-call turn turns out to have malformed args and is
* retried non-streaming, so the UI doesn't carry the stale partial into the retry's output. */
| { type: "stream_discard" }
| { type: "tool_call"; label: string }
| { type: "tool_result"; summary: string; isError: boolean };
| { type: "tool_result"; summary: string; isError: boolean }
/** A hook (see hooks/runner.ts) blocked something or failed non-fatally — surfaced as a notice. */
| { type: "hook_notice"; text: string; isError: boolean };
export type AgentEventHandler = (event: AgentEvent) => void;
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it, vi } from "vitest";
import { shouldAutoCompact } from "./loop.js";
import type { Session } from "./session.js";
describe("shouldAutoCompact", () => {
it("triggers at the session's configured threshold", () => {
const session = {
lastContextTokens: 90,
contextWindow: 100,
autoCompactThreshold: 0.85,
} as unknown as Session;
expect(shouldAutoCompact(session)).toBe(true);
session.autoCompactThreshold = 0.95;
expect(shouldAutoCompact(session)).toBe(false);
});
});
+334 -79
View File
@@ -1,19 +1,80 @@
import { randomUUID } from "node:crypto";
import type { ChatCompletionChunk } from "openai/resources/chat/completions";
import type { CompletionUsage } from "openai/resources/completions";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ChatCompletionContentPart, ChatCompletionMessageParam } from "openai/resources/chat/completions";
/** A user turn's content — plain text, or (for image attachments, see /import and read_file's
* image results) a mix of text and image_url parts per the OpenAI multimodal message schema. */
export type ChatCompletionUserContent = ChatCompletionContentPart[];
import type { AgentEventHandler } from "./events.js";
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
import type { SubAgentTask } from "../tools/types.js";
import type { SubAgentOverrides, SubAgentTask } from "../tools/types.js";
import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
import { estimateTokens } from "../utils/tokens.js";
import { runHooksForEvent } from "../hooks/runner.js";
import { buildSystemPrompt } from "./systemPrompt.js";
import type { Session } from "./session.js";
function emitHookWarnings(warnings: string[], emit: AgentEventHandler): void {
for (const warning of warnings) {
emit({ type: "hook_notice", text: `Hook warning: ${warning}`, isError: false });
}
}
/** Stop fires once the assistant has produced its final answer for a turn — informational only
* (e.g. a desktop notification or logging hook); unlike PreToolUse it can't block, since there's
* nothing left in this turn to prevent. */
async function fireStopHook(session: Session, emit: AgentEventHandler, finalText: string): Promise<void> {
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
emitHookWarnings(result.warnings, emit);
}
/** Runs SessionStart hooks right after a session is created — any hook that exits 0 with stdout
* gets folded straight into the system prompt as extra context (e.g. current git status, a TODO
* list). Returns non-blocking warnings for the caller to surface however it displays notices. */
function foldHookContext(systemContent: string, result: Awaited<ReturnType<typeof runHooksForEvent>>): string {
const parts: string[] = [systemContent];
if (result.additionalContext) parts.push(result.additionalContext);
if (result.jsonContext?.length) {
parts.push(`Hook context (JSON):\n${result.jsonContext.map((j) => JSON.stringify(j)).join("\n")}`);
}
return parts.join("\n\n");
}
export async function fireSessionStartHook(session: Session): Promise<string[]> {
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
if (result.additionalContext || result.jsonContext?.length) {
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
systemMessage.content = foldHookContext(systemMessage.content, result);
}
return result.warnings;
}
export interface UserPromptSubmitResult {
blocked: boolean;
reason?: string;
additionalContext?: string;
jsonContext?: unknown[];
warnings: string[];
}
/** Runs UserPromptSubmit hooks for a message the user is about to send — can block it outright
* (e.g. a policy check) or inject extra context that gets appended alongside it. */
export async function fireUserPromptSubmitHook(session: Session, prompt: string): Promise<UserPromptSubmitResult> {
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
return {
blocked: result.blocked,
reason: result.reason,
additionalContext: result.additionalContext,
jsonContext: result.jsonContext,
warnings: result.warnings,
};
}
export class AgentError extends Error {}
const MAX_MALFORMED_RETRIES = 2;
@@ -24,9 +85,6 @@ const MAX_MALFORMED_RETRIES = 2;
const MAX_SUBAGENT_DEPTH = 1;
const SUBAGENT_TIMEOUT_MS = 120_000;
/** Fraction of the context window at which the caller should auto-compact (see shouldAutoCompact). */
export const AUTO_COMPACT_THRESHOLD = 0.85;
function updateContextTracking(session: Session, promptTokens: number | null | undefined): void {
if (typeof promptTokens === "number") {
session.lastContextTokens = promptTokens;
@@ -41,8 +99,17 @@ export function contextUsageRatio(session: Session): number {
return session.lastContextTokens / session.contextWindow;
}
/** Folds one backend request's usage into the session's cumulative `/dashboard` counters. */
function recordUsage(session: Session, usage: CompletionUsage | undefined): void {
session.stats.apiCalls++;
if (usage) {
session.stats.inputTokens += usage.prompt_tokens;
session.stats.outputTokens += usage.completion_tokens;
}
}
export function shouldAutoCompact(session: Session): boolean {
return contextUsageRatio(session) >= AUTO_COMPACT_THRESHOLD;
return contextUsageRatio(session) >= session.autoCompactThreshold;
}
/**
@@ -52,7 +119,7 @@ export function shouldAutoCompact(session: Session): boolean {
* summary as an assistant turn keeps proper role alternation with whatever real user message
* follows next.
*/
export async function compactSession(session: Session): Promise<void> {
export async function compactSession(session: Session): Promise<string> {
const requestMessages: ChatCompletionMessageParam[] = [
...session.messages,
{
@@ -64,12 +131,15 @@ export async function compactSession(session: Session): Promise<void> {
},
];
const requestStart = Date.now();
const res = await session.client.chat.completions.create({
model: session.model,
messages: requestMessages,
stream: false,
max_tokens: 1024,
});
session.stats.modelTimeMs += Date.now() - requestStart;
recordUsage(session, res.usage);
const summary = res.choices[0]?.message?.content;
if (!summary) {
throw new AgentError("Compaction failed: the model returned no summary.");
@@ -82,6 +152,7 @@ export async function compactSession(session: Session): Promise<void> {
// 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;
}
/** Accumulates streaming tool-call deltas into complete tool calls. */
@@ -91,12 +162,98 @@ interface AccumulatedToolCall {
arguments: string;
}
interface ImageAttachment {
mimeType: string;
base64: string;
}
/** There's no reliable way to probe an OpenAI-compatible backend for vision support up front (unlike
* tool-calling, whose probe response is unambiguous — a model that can't see an image typically
* still returns *some* text rather than erroring, so a "does it understand this test image" probe
* is unreliable). Instead, if a request that included image content fails, annotate the error so
* the user gets an actionable hint instead of an opaque backend error. */
function annotateIfImageRelated(err: unknown, messages: ChatCompletionMessageParam[]): unknown {
const hasImage = messages.some(
(m) => Array.isArray(m.content) && m.content.some((part: any) => part?.type === "image_url"),
);
if (!hasImage || !(err instanceof Error)) return err;
err.message = `${err.message}\n\n(This request included image content from read_file/@mention/import — the active model or backend may not support image input.)`;
return err;
}
/** Recognizes the `{ image: true, mimeType, base64 }` shape returned by read_file for image paths. */
function extractImageAttachment(result: unknown): ImageAttachment | null {
if (!result || typeof result !== "object") return null;
const r = result as Record<string, unknown>;
if (r.image === true && typeof r.mimeType === "string" && typeof r.base64 === "string") {
return { mimeType: r.mimeType, base64: r.base64 };
}
return null;
}
/** Tool results are serialized into the message text sent back to the model — the (huge)
* base64 payload travels separately as an image content part (see pushToolResultMessage), so
* strip it from the text; the model only needs to know an image was attached, not re-read it. */
function resultForHistory(result: unknown): unknown {
if (!extractImageAttachment(result)) return result;
const { base64: _base64, ...rest } = result as Record<string, unknown>;
return rest;
}
function imageContentPart(image: ImageAttachment) {
return { type: "image_url" as const, image_url: { url: `data:${image.mimeType};base64,${image.base64}` } };
}
/**
* Appends a tool's result to the conversation in whichever shape the active tool-calling mode
* expects (native: a `tool` message keyed by tool_call_id; fallback: a `user` message with a
* `tool_result` code block). If the raw result carries image data (see read_file), the actual
* pixel data travels as a real `image_url` content part so vision-capable models can see it —
* appended to the same message in fallback mode. In native mode a `tool`-role message can only
* carry text per the OpenAI schema, so the image can't live there; it's returned to the caller
* instead of being pushed immediately, so a batch of parallel tool calls can push all its `tool`
* messages contiguously first and the image `user` messages only after — an assistant message's
* tool responses must all immediately follow it with no other role interposed, or OpenAI-compatible
* backends reject the conversation as malformed on the next turn.
*/
function pushToolResultMessage(
session: Session,
mode: "native" | "fallback",
toolCallId: string,
name: string,
result: unknown,
): ImageAttachment | null {
const image = extractImageAttachment(result);
const historyResult = resultForHistory(result);
if (mode === "native") {
session.messages.push({ role: "tool", tool_call_id: toolCallId, content: JSON.stringify(historyResult) } as ChatCompletionMessageParam);
return image;
}
const text = `\`\`\`tool_result\n${JSON.stringify({ name, result: historyResult })}\n\`\`\``;
session.messages.push({
role: "user",
content: image ? [{ type: "text", text }, imageContentPart(image)] : text,
} as ChatCompletionMessageParam);
return null;
}
/** Appends the images deferred by pushToolResultMessage's native-mode calls, once all of the
* triggering assistant message's `tool` responses have already been pushed. */
function pushPendingImages(session: Session, images: ImageAttachment[]): void {
for (const image of images) {
session.messages.push({ role: "user", content: [imageContentPart(image)] } as ChatCompletionMessageParam);
}
}
async function gateAndRun(
resolved: ResolvedToolCall,
rawLabel: string,
session: Session,
emit: AgentEventHandler,
): Promise<unknown> {
session.stats.toolCalls++;
if ("error" in resolved) {
emit({ type: "tool_call", label: rawLabel });
emit({ type: "tool_result", summary: resolved.error, isError: true });
@@ -105,24 +262,77 @@ async function gateAndRun(
const { tool, args } = resolved;
emit({ type: "tool_call", label: formatCallLabel(tool.name, args) });
const ctx = { cwd: session.cwd, runSubAgent: (task: SubAgentTask) => runSubAgentTurn(session, task) };
// 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
// in `finally` so a denied/blocked call doesn't leave a stale non-null activeBackground behind.
const backgroundControl = tool.name === "bash" ? { requested: false } : undefined;
session.activeBackground = backgroundControl ?? null;
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const decision = await session.confirm({ toolName: tool.name, args, preview });
if (decision === "deny") {
emit({ type: "tool_result", summary: "Denied by user", isError: true });
return { error: "Denied by user." };
try {
const ctx = {
cwd: session.cwd,
runSubAgent: (task: SubAgentTask, overrides?: SubAgentOverrides) => runSubAgentTurn(session, task, overrides),
backgroundControl,
};
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
// auto-accept, same as real Claude Code hooks.
const hookCtx = { sessionId: session.id, cwd: session.cwd };
const preHook = await runHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
emitHookWarnings(preHook.warnings, emit);
if (preHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${preHook.reason}`, isError: true });
return { error: `Blocked by hook: ${preHook.reason}` };
}
if (decision === "session") {
session.permissions.allowForSession(tool.name);
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const permissionHook = await runHooksForEvent(
"PermissionRequest",
hookCtx,
{ tool_name: tool.name, tool_input: args, preview },
tool.name,
);
emitHookWarnings(permissionHook.warnings, emit);
if (permissionHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
return { error: `Blocked by hook: ${permissionHook.reason}` };
}
const decision = await session.confirm({ toolName: tool.name, args, preview });
if (decision === "deny") {
emit({ type: "tool_result", summary: "Denied by user", isError: true });
return { error: "Denied by user." };
}
if (decision === "session") {
session.permissions.allowForSession(tool.name);
}
}
const result = await runTool(tool, args, ctx);
const isError = !!(result && typeof result === "object" && "error" in (result as object));
// FileChanged fires for any mutating tool (built-in or MCP/plugin) — whether it touched the
// filesystem is left for the hook author to decide (e.g. an API-only mutating MCP tool may be
// meaningless here, but a generic mutating tool is the most useful signal locode has). It's
// informational only and fires whether the tool succeeded or failed, since the turn is done either way.
if (tool.mutating) {
runHooksForEvent("FileChanged", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result, isError }, tool.name)
.then((r) => emitHookWarnings(r.warnings, emit))
.catch(() => {});
}
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError });
// PostToolUse can't *veto* a tool call that already ran (unlike PreToolUse), but it IS awaited:
// an auto-format hook that rewrites the file the tool just wrote needs to finish before the next
// tool runs or the model reads the file again. The tradeoff is that a slow logging hook will stall
// the turn up to its own timeout — that's the cost of running formatting/logging synchronously.
const postHook = await runHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
emitHookWarnings(postHook.warnings, emit);
return result;
} finally {
session.activeBackground = null;
}
const result = await runTool(tool, args, ctx);
const isError = !!(result && typeof result === "object" && "error" in (result as object));
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError });
return result;
}
/**
@@ -136,20 +346,21 @@ async function gateAndRun(
* an explicit depth check (on top of the toolset already excluding `agent` for sub-agents) and a
* hard wall-clock timeout, so a misbehaving model can't hang the parent turn indefinitely.
*/
async function runSubAgentTurn(parent: Session, task: SubAgentTask): Promise<string> {
async function runSubAgentTurn(parent: Session, task: SubAgentTask, overrides?: SubAgentOverrides): Promise<string> {
if (parent.subAgentDepth >= MAX_SUBAGENT_DEPTH) {
throw new Error(`Sub-agents cannot spawn further sub-agents (max depth ${MAX_SUBAGENT_DEPTH}).`);
}
// Derived from the parent's own toolset (so MCP tools carry over) minus `agent` itself —
// sub-agents can't spawn further sub-agents, keeping recursion bounded to one level.
const subToolset = buildToolSet(parent.toolset.tools.filter((t) => t.name !== "agent"));
const subMessages: ChatCompletionMessageParam[] = [
{
role: "system",
content: `${buildSystemPrompt(subToolset.tools, parent.mode)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`,
},
];
// Derived from the parent's own toolset (so MCP tools carry over) minus `agent`/plugin-agent
// tools — sub-agents can't spawn further sub-agents, keeping recursion bounded to one level.
// A plugin agent's `tools:` frontmatter further restricts this to a named subset.
const inheritable = parent.toolset.tools.filter((t) => t.name !== "agent" && !t.name.startsWith("agent__"));
const restricted = overrides?.toolNames ? inheritable.filter((t) => overrides.toolNames!.includes(t.name)) : inheritable;
const subToolset = buildToolSet(restricted);
const systemPrompt = overrides?.systemPrompt
? `${overrides.systemPrompt}\n\nAvailable tools:\n${subToolset.tools.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
: `${buildSystemPrompt(subToolset.tools, parent.mode)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
const subMessages: ChatCompletionMessageParam[] = [{ role: "system", content: systemPrompt }];
const subSession: Session = {
id: randomUUID(),
createdAt: new Date().toISOString(),
@@ -167,19 +378,56 @@ async function runSubAgentTurn(parent: Session, task: SubAgentTask): Promise<str
contextWindowIsEstimate: parent.contextWindowIsEstimate,
lastContextTokens: estimateTokens(subMessages),
lastContextTokensIsEstimate: true,
autoCompactThreshold: parent.autoCompactThreshold,
// Shared (not cloned) so the sub-agent's own token/time/tool-call usage rolls straight into
// the parent's /dashboard totals — it's real cost incurred on the parent's behalf.
stats: parent.stats,
// Independent from the parent's — a sub-agent's own bash calls aren't backgroundable via the
// parent UI's Ctrl+B since sub-agent tool calls aren't shown mid-flight anyway (see class doc above).
activeBackground: null,
};
// Run (and honor) the SubagentStart hook before arming the timeout, so a slow hook doesn't eat
// into the turn's own time budget, and so a hook that blocks (exit 2) actually prevents the
// sub-agent from running instead of only being logged.
const hookCtx = { sessionId: parent.id, cwd: parent.cwd };
const startHook = await runHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
if (startHook.warnings.length) {
// eslint-disable-next-line no-console
console.error(`SubagentStart hook warnings: ${startHook.warnings.join("; ")}`);
}
if (startHook.blocked) {
throw new Error(startHook.reason ?? "Sub-agent blocked by SubagentStart hook.");
}
let timeoutId: ReturnType<typeof setTimeout>;
const ac = new AbortController();
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`Sub-agent timed out after ${SUBAGENT_TIMEOUT_MS / 1000}s.`)),
SUBAGENT_TIMEOUT_MS,
);
timeoutId = setTimeout(() => {
// Abort the in-flight request as we reject, so a runaway model can't keep streaming or
// spawning tool calls in the background after the parent has already given up on this turn.
ac.abort();
reject(new Error(`Sub-agent timed out after ${SUBAGENT_TIMEOUT_MS / 1000}s.`));
}, SUBAGENT_TIMEOUT_MS);
});
// The turn promise outlives the race when the timeout wins. Aborting cancels any still-pending
// request; the `.catch` swallows the resulting late rejection so it doesn't surface as an unhandled
// promise rejection after the parent already moved on.
const turnPromise = runTurn(subSession, task.prompt, () => {}, subToolset, ac.signal);
try {
return await Promise.race([runTurn(subSession, task.prompt, () => {}, subToolset), timeout]);
const answer = await Promise.race([turnPromise, timeout]);
return answer;
} finally {
clearTimeout(timeoutId!);
ac.abort(); // no-op if the turn finished on its own; cancels a still-pending request otherwise
turnPromise.catch(() => {});
runHooksForEvent("SubagentStop", hookCtx, { description: task.description, result: subSession.messages.at(-1)?.content })
.then((r) => {
// Sub-agent runs headless; warnings can't be emitted to a parent UI that isn't listening.
// eslint-disable-next-line no-console
if (r.warnings.length) console.error(`SubagentStop hook warnings: ${r.warnings.join("; ")}`);
})
.catch(() => {});
}
}
@@ -202,16 +450,15 @@ async function handleCompletedMessage(
tool_calls: message.tool_calls,
} as ChatCompletionMessageParam);
const pendingImages: ImageAttachment[] = [];
for (const call of message.tool_calls) {
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);
session.messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(result),
});
const image = pushToolResultMessage(session, "native", call.id, call.type === "function" ? call.function.name : call.type, result);
if (image) pendingImages.push(image);
}
pushPendingImages(session, pendingImages);
return { text: "", hadToolCalls: true };
}
@@ -228,10 +475,7 @@ async function handleCompletedMessage(
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);
session.messages.push({
role: "user",
content: `\`\`\`tool_result\n${JSON.stringify({ name: call.name, result })}\n\`\`\``,
});
pushToolResultMessage(session, "fallback", "", call.name, result);
}
return { text: "", hadToolCalls: true };
}
@@ -247,11 +491,15 @@ async function handleCompletedMessage(
export async function runTurn(
session: Session,
userInput: string,
userInput: string | ChatCompletionUserContent,
emit: AgentEventHandler,
toolset: ToolSet = session.toolset,
/** Optional abort signal — a parent sub-agent passes its timeout's controller so a runaway turn's
* in-flight HTTP request can be cancelled rather than streaming forever in the background. */
signal?: AbortSignal,
): Promise<string> {
session.messages.push({ role: "user", content: userInput });
session.messages.push({ role: "user", content: userInput } as ChatCompletionMessageParam);
if (session.subAgentDepth === 0) session.stats.turns++;
let malformedRetries = 0;
for (let i = 0; i < session.maxIterations; i++) {
@@ -261,16 +509,20 @@ export async function runTurn(
const accumulatedToolCalls: AccumulatedToolCall[] = [];
let usage: CompletionUsage | undefined;
const requestStart = Date.now();
try {
const stream = await session.client.chat.completions.create({
model: session.model,
messages: session.messages,
tools: session.mode === "native" ? toolset.openaiTools : undefined,
stream: true,
stream_options: { include_usage: true },
max_tokens: 4096,
});
const stream = await session.client.chat.completions.create(
{
model: session.model,
messages: session.messages,
tools: session.mode === "native" ? toolset.openaiTools : undefined,
stream: true,
stream_options: { include_usage: true },
max_tokens: 4096,
},
signal ? { signal } : undefined,
);
for await (const chunk of stream) {
// The usage-carrying final chunk has an empty `choices` array per spec — read it before
@@ -315,9 +567,11 @@ export async function runTurn(
if (fullText) {
emit({ type: "text_done", fullText });
}
throw streamErr;
throw annotateIfImageRelated(streamErr, session.messages);
}
session.stats.modelTimeMs += Date.now() - requestStart;
recordUsage(session, usage);
updateContextTracking(session, usage?.prompt_tokens);
// --- Handle native tool calls from streaming ---
@@ -335,31 +589,35 @@ export async function runTurn(
}
if (!allValid) {
// The partial text streamed before we detected the bad tool-call args is superseded by the
// non-streaming retry — tell the UI to drop it. Don't emit text_done (that would commit it as
// an assistant message); it was never added to session.messages, and the retry replaces it.
emit({ type: "stream_discard" });
// Retry non-streaming for this turn
const res = await session.client.chat.completions.create({
model: session.model,
messages: session.messages,
tools: toolset.openaiTools,
stream: false,
max_tokens: 4096,
});
const retryStart = Date.now();
const res = await session.client.chat.completions.create(
{
model: session.model,
messages: session.messages,
tools: toolset.openaiTools,
stream: false,
max_tokens: 4096,
},
signal ? { signal } : undefined,
);
session.stats.modelTimeMs += Date.now() - retryStart;
recordUsage(session, res.usage);
const message = res.choices[0]?.message;
if (!message) throw new AgentError("Empty response from model.");
updateContextTracking(session, res.usage?.prompt_tokens);
// If we streamed partial text, we need to tell the UI the text is done
// (it may have been partially displayed). The non-streaming response
// will have the complete text.
if (fullText) {
// The partial stream text is superseded by the non-streaming response
}
const result = await handleCompletedMessage(message, session, emit, toolset);
if (result.hadToolCalls) continue;
// Non-tool-call text from the retry
emit({ type: "text_done", fullText: result.text });
session.messages.push({ role: "assistant", content: result.text });
await fireStopHook(session, emit, result.text);
return result.text;
}
@@ -376,6 +634,7 @@ export async function runTurn(
})),
} as ChatCompletionMessageParam);
const pendingImages: ImageAttachment[] = [];
for (const tc of accumulatedToolCalls) {
const resolved = resolveToolCall(
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
@@ -383,12 +642,10 @@ export async function runTurn(
);
const label = `${tc.name}(${tc.arguments})`;
const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(result),
});
const image = pushToolResultMessage(session, "native", tc.id, tc.name, result);
if (image) pendingImages.push(image);
}
pushPendingImages(session, pendingImages);
continue;
}
@@ -405,10 +662,7 @@ export async function runTurn(
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);
session.messages.push({
role: "user",
content: `\`\`\`tool_result\n${JSON.stringify({ name: call.name, result })}\n\`\`\``,
});
pushToolResultMessage(session, "fallback", "", call.name, result);
}
continue;
}
@@ -425,6 +679,7 @@ export async function runTurn(
// Final text answer
emit({ type: "text_done", fullText });
session.messages.push({ role: "assistant", content: fullText });
await fireStopHook(session, emit, fullText);
return fullText;
}
+41 -1
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import type OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
import type { SessionRecord } from "../persistence/sessionStore.js";
import { PermissionManager } from "../permissions/permissionManager.js";
import type { ConfirmFn } from "../permissions/types.js";
@@ -12,6 +12,30 @@ import type { ToolDef } from "../tools/types.js";
import { estimateTokens } from "../utils/tokens.js";
import { buildSystemPrompt } from "./systemPrompt.js";
/** Cumulative counters for the dashboard (`/dashboard`) — reset whenever a fresh Session object is
* created (a resumed session starts these at zero rather than trying to recover historical usage,
* since none of this is persisted to SessionRecord). */
export interface SessionStats {
/** Sum of `usage.prompt_tokens` across every backend request this session has made (main turns,
* non-streaming retries, sub-agent turns, and compaction) — each request re-sends the whole
* history, so this is cumulative tokens billed/processed, not unique tokens. */
inputTokens: number;
/** Sum of `usage.completion_tokens` across every backend request. */
outputTokens: number;
/** Number of backend requests made (chat.completions.create calls). */
apiCalls: number;
/** Number of tool invocations attempted (successful, errored, or denied). */
toolCalls: number;
/** Number of user messages submitted (top-level turns; excludes sub-agent turns). */
turns: number;
/** Total wall-clock time spent waiting on backend requests, in milliseconds. */
modelTimeMs: number;
}
function initialStats(): SessionStats {
return { inputTokens: 0, outputTokens: 0, apiCalls: 0, toolCalls: 0, turns: 0, modelTimeMs: 0 };
}
export interface Session {
id: string;
createdAt: string;
@@ -38,6 +62,14 @@ export interface Session {
lastContextTokens: number;
/** True when `lastContextTokens` is a char-based estimate rather than real usage stats. */
lastContextTokensIsEstimate: boolean;
/** Fraction of the context window at which auto-compaction triggers. */
autoCompactThreshold: number;
/** Cumulative usage counters for the `/dashboard` command. See SessionStats. */
stats: SessionStats;
/** Set by gateAndRun (agent/loop.ts) only while a backgroundable tool call (currently just
* `bash`) is in flight; Ctrl+B in the UI flips `.requested` to detach it. Null the rest of the
* time, including while non-backgroundable tools run. */
activeBackground: { requested: boolean } | null;
}
export function createSession(
@@ -50,6 +82,7 @@ export function createSession(
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
contextWindowIsEstimate: boolean = true,
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
): Session {
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [{ role: "system", content: buildSystemPrompt(toolset.tools, mode) }];
@@ -70,6 +103,9 @@ export function createSession(
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
autoCompactThreshold,
stats: initialStats(),
activeBackground: null,
};
}
@@ -84,6 +120,7 @@ export function createSessionFromRecord(
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
contextWindowIsEstimate: boolean = true,
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
): Session {
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
@@ -107,6 +144,9 @@ export function createSessionFromRecord(
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
autoCompactThreshold,
stats: initialStats(),
activeBackground: null,
};
}
+156 -4
View File
@@ -1,10 +1,16 @@
import { execa } from "execa";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { Command } from "commander";
import { makeClient } from "./backend/client.js";
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js";
import { loadMergedHooks, userHooksFilePath } from "./hooks/config.js";
import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js";
import { isHttpServerConfig } from "./mcp/types.js";
import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
import { addInstalledPlugin, loadInstalledPlugins, pluginsDir, removeInstalledPlugin } from "./plugins/config.js";
import { loadPlugin } from "./plugins/loader.js";
import { runInkApp } from "./ui/ink/index.js";
const program = new Command();
@@ -108,10 +114,17 @@ configCmd
configCmd
.command("set <key> <value>")
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations)")
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold)")
.action((key: string, value: string) => {
if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow" && key !== "maxIterations") {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations`);
if (
key !== "backend" &&
key !== "model" &&
key !== "baseUrl" &&
key !== "contextWindow" &&
key !== "maxIterations" &&
key !== "autoCompactThreshold"
) {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold`);
process.exit(1);
}
const stored = loadStoredConfig();
@@ -122,6 +135,13 @@ configCmd
process.exit(1);
}
stored[key] = n;
} else if (key === "autoCompactThreshold") {
const n = Number(value);
if (!Number.isFinite(n) || n < 0.1 || n > 0.95) {
console.error(`autoCompactThreshold must be between 0.1 and 0.95, got "${value}".`);
process.exit(1);
}
stored[key] = n;
} else {
stored[key] = value;
}
@@ -233,7 +253,7 @@ mcpCmd
.command("list")
.description("List configured MCP servers (user-level + project .mcp.json)")
.action(() => {
const servers = loadMergedServers(process.cwd());
const { servers } = loadMergedServers(process.cwd());
const names = Object.keys(servers);
if (names.length === 0) {
console.log("No MCP servers configured.");
@@ -254,4 +274,136 @@ mcpCmd
console.log(userMcpFilePath());
});
function isRemoteSource(source: string): boolean {
return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(source) || source.startsWith("git@");
}
function sanitizeDirName(source: string): string {
const base = source.split(/[/\\]/).pop() ?? source;
return base.replace(/\.git$/, "").replace(/[^a-zA-Z0-9_-]/g, "_");
}
const pluginCmd = program
.command("plugin")
.description("Manage Claude Code-compatible plugins (slash commands, agents, and MCP servers)");
pluginCmd
.command("add <source>")
.description("Install a plugin from a local directory or a git URL")
.action(async (source: string) => {
try {
let pluginPath: string;
if (isRemoteSource(source)) {
const destDir = path.join(pluginsDir(), sanitizeDirName(source));
if (existsSync(destDir)) {
console.error(`A plugin is already cloned at ${destDir}. Run "locode plugin remove" first if you want to re-add it.`);
process.exit(1);
}
mkdirSync(pluginsDir(), { recursive: true });
console.log(`Cloning ${source}...`);
const result = await execa("git", ["clone", "--depth", "1", source, destDir], { reject: false });
if (result.exitCode !== 0) {
console.error(`git clone failed: ${result.stderr || `exit code ${result.exitCode}`}`);
process.exit(1);
}
pluginPath = destDir;
} else {
pluginPath = path.resolve(source);
if (!existsSync(pluginPath)) {
console.error(`No such directory: ${pluginPath}`);
process.exit(1);
}
}
const loaded = loadPlugin(pluginPath);
addInstalledPlugin({ name: loaded.name, path: pluginPath, source });
console.log(
`Added plugin "${loaded.name}": ${loaded.commands.length} command(s), ${loaded.agents.length} agent(s), ` +
`${loaded.skills.length} skill(s), ${Object.keys(loaded.mcpServers).length} MCP server(s).`,
);
} catch (err) {
console.error(`Failed to add plugin: ${(err as Error).message}`);
process.exit(1);
}
});
pluginCmd
.command("remove <name>")
.description("Uninstall a plugin (deregisters it — a git-cloned copy under locode's data dir is left on disk)")
.action((name: string) => {
if (removeInstalledPlugin(name)) {
console.log(`Removed plugin "${name}".`);
} else {
console.error(`No installed plugin found with name "${name}".`);
process.exit(1);
}
});
pluginCmd
.command("list")
.description("List installed plugins")
.action(() => {
const records = loadInstalledPlugins();
if (records.length === 0) {
console.log("No plugins installed.");
return;
}
for (const record of records) {
try {
const loaded = loadPlugin(record.path);
console.log(
`${loaded.name} [${record.path}] ${loaded.commands.length} command(s), ${loaded.agents.length} agent(s), ` +
`${loaded.skills.length} skill(s), ${Object.keys(loaded.mcpServers).length} MCP server(s)`,
);
} catch {
console.log(`${record.name} [${record.path}] (failed to load — the directory may be missing)`);
}
}
});
pluginCmd
.command("path")
.description("Print the directory where git-cloned plugins are stored")
.action(() => {
console.log(pluginsDir());
});
const hooksCmd = program
.command("hooks")
.description("Inspect configured hooks (edit hooks.json by hand — see `locode hooks path`)");
hooksCmd
.command("list")
.description("List configured hooks per lifecycle event (plugin + user + project, merged)")
.action(() => {
const config = loadMergedHooks(process.cwd());
const events = Object.entries(config);
if (events.length === 0) {
console.log("No hooks configured.");
return;
}
for (const [event, entries] of events) {
const count = entries!.reduce((sum, e) => sum + e.hooks.length, 0);
console.log(`${event}: ${count} command(s)`);
for (const entry of entries!) {
for (const hook of entry.hooks) {
if (hook.type === "command") {
console.log(` [${entry.matcher || "*"}] command: ${hook.command}`);
} else if (hook.type === "http") {
console.log(` [${entry.matcher || "*"}] http: ${hook.method ?? "POST"} ${hook.url}`);
} else if (hook.type === "prompt") {
console.log(` [${entry.matcher || "*"}] prompt: ${hook.message} (not yet implemented)`);
}
}
}
}
});
hooksCmd
.command("path")
.description("Print the path to the user-level hooks.json file")
.action(() => {
console.log(userHooksFilePath());
});
program.parseAsync();
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from "vitest";
import { loadStoredConfig, saveStoredConfig } from "./store.js";
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations } from "./config.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "./defaults.js";
describe("config resolution", () => {
afterEach(() => {
saveStoredConfig({});
delete process.env.LOCODE_AUTO_COMPACT_THRESHOLD;
delete process.env.LOCODE_CONTEXT_WINDOW;
delete process.env.LOCODE_MAX_ITERATIONS;
});
it("resolves auto-compact threshold default", () => {
expect(resolveAutoCompactThreshold()).toBe(DEFAULT_AUTO_COMPACT_THRESHOLD);
});
it("reads auto-compact threshold from env", () => {
process.env.LOCODE_AUTO_COMPACT_THRESHOLD = "0.75";
expect(resolveAutoCompactThreshold()).toBe(0.75);
});
it("reads auto-compact threshold from stored config", () => {
saveStoredConfig({ autoCompactThreshold: 0.6 });
expect(resolveAutoCompactThreshold()).toBe(0.6);
});
it("rejects out-of-range auto-compact thresholds", () => {
saveStoredConfig({ autoCompactThreshold: 0.05 });
expect(resolveAutoCompactThreshold()).toBe(DEFAULT_AUTO_COMPACT_THRESHOLD);
process.env.LOCODE_AUTO_COMPACT_THRESHOLD = "1.0";
expect(resolveAutoCompactThreshold()).toBe(DEFAULT_AUTO_COMPACT_THRESHOLD);
});
it("resolves context window default", () => {
expect(resolveContextWindowDefault()).toBe(DEFAULT_CONTEXT_WINDOW);
});
it("resolves max iterations default", () => {
expect(resolveMaxIterations()).toBe(DEFAULT_MAX_ITERATIONS);
});
});
+18 -1
View File
@@ -1,4 +1,10 @@
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, KNOWN_BACKENDS, type BackendName } from "./defaults.js";
import {
DEFAULT_AUTO_COMPACT_THRESHOLD,
DEFAULT_CONTEXT_WINDOW,
DEFAULT_MAX_ITERATIONS,
KNOWN_BACKENDS,
type BackendName,
} from "./defaults.js";
import { loadStoredConfig } from "./store.js";
export class ConfigError extends Error {}
@@ -50,3 +56,14 @@ export function resolveMaxIterations(): number {
if (typeof stored.maxIterations === "number" && stored.maxIterations > 0) return stored.maxIterations;
return DEFAULT_MAX_ITERATIONS;
}
/** Fraction of the context window at which locode auto-compacts the conversation. */
export function resolveAutoCompactThreshold(): number {
const stored = loadStoredConfig();
const envValue = Number(process.env.LOCODE_AUTO_COMPACT_THRESHOLD);
if (Number.isFinite(envValue) && envValue >= 0.1 && envValue <= 0.95) return envValue;
if (typeof stored.autoCompactThreshold === "number" && stored.autoCompactThreshold >= 0.1 && stored.autoCompactThreshold <= 0.95) {
return stored.autoCompactThreshold;
}
return DEFAULT_AUTO_COMPACT_THRESHOLD;
}
+4
View File
@@ -15,3 +15,7 @@ export const DEFAULT_CONTEXT_WINDOW = 8192;
/** Max tool calls per turn before locode gives up rather than looping forever. 25 gives real
* multi-file tasks room to breathe; still bounded so a genuinely stuck model fails fast. */
export const DEFAULT_MAX_ITERATIONS = 25;
/** 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;
+2
View File
@@ -10,6 +10,8 @@ export interface StoredConfig {
contextWindow?: number;
/** Max tool calls allowed per turn before locode gives up rather than looping forever. */
maxIterations?: number;
/** Fraction of the context window (0.0–1.0) at which locode auto-compacts the conversation. */
autoCompactThreshold?: number;
}
const paths = envPaths("locode", { suffix: "" });
+57
View File
@@ -0,0 +1,57 @@
import envPaths from "env-paths";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { getLoadedPlugins } from "../plugins/registry.js";
import type { HookEventName, HookMatcherEntry, HooksConfig, HooksFile } from "./types.js";
const paths = envPaths("locode", { suffix: "" });
const userFile = path.join(paths.config, "hooks.json");
export function userHooksFilePath(): string {
return userFile;
}
function readHooksFile(file: string): HooksConfig {
if (!existsSync(file)) return {};
try {
const parsed = JSON.parse(readFileSync(file, "utf-8")) as HooksFile;
return parsed.hooks ?? {};
} catch {
return {};
}
}
/** User-level hooks (hand-edited — see `locode hooks path`), available in every project. */
export function loadUserHooks(): HooksConfig {
return readHooksFile(userFile);
}
/** Project-level hooks, meant to be checked into a repo alongside `.mcp.json`. */
export function loadProjectHooks(cwd: string): HooksConfig {
return readHooksFile(path.join(cwd, ".locode", "hooks.json"));
}
/** Hooks bundled with installed plugins (`hooks/hooks.json`, same convention as Claude Code). */
export function loadPluginHooks(): HooksConfig {
const merged: HooksConfig = {};
for (const plugin of getLoadedPlugins()) {
mergeInto(merged, plugin.hooks);
}
return merged;
}
function mergeInto(target: HooksConfig, source: HooksConfig): void {
for (const [event, entries] of Object.entries(source) as [HookEventName, HookMatcherEntry[]][]) {
target[event] = [...(target[event] ?? []), ...entries];
}
}
/** Every configured hook fires — unlike MCP servers/commands, hooks aren't the kind of thing
* where one source should "win"; a plugin's formatter hook and your own logging hook both run. */
export function loadMergedHooks(cwd: string): HooksConfig {
const merged: HooksConfig = {};
mergeInto(merged, loadPluginHooks());
mergeInto(merged, loadUserHooks());
mergeInto(merged, loadProjectHooks(cwd));
return merged;
}
+14
View File
@@ -0,0 +1,14 @@
import { resolveToolName } from "../plugins/toolNameMap.js";
/** Whether a PreToolUse/PostToolUse `matcher` pattern matches a given (locode-native) tool name.
* Empty/"*"/undefined matches everything; "|" or "," separates alternatives; each alternative is
* run through the same Claude-tool-name mapping plugins use, so a matcher of "Bash" (Claude
* Code's name) matches locode's "bash" tool. */
export function matcherMatches(matcher: string | undefined, toolName: string): boolean {
const trimmed = matcher?.trim();
if (!trimmed || trimmed === "*") return true;
return trimmed
.split(/[|,]/)
.map((part) => resolveToolName(part.trim()))
.includes(toolName);
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from "vitest";
import { runHooksForEvent } from "./runner.js";
import type { HooksConfig } from "./types.js";
const loadMergedHooks = await vi.hoisted(async () => {
return { loadMergedHooks: vi.fn() };
});
vi.mock("./config.js", async () => {
return { loadMergedHooks: loadMergedHooks.loadMergedHooks };
});
const execa = await vi.hoisted(async () => {
return { execa: vi.fn() };
});
vi.mock("execa", async () => {
return { execa: execa.execa };
});
describe("runHooksForEvent", () => {
const ctx = { sessionId: "s1", cwd: "/repo" };
it("returns empty when no hooks are configured", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({});
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result).toEqual({ blocked: false, warnings: [] });
});
it("blocks on exit code 2", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
UserPromptSubmit: [{ hooks: [{ type: "command", command: "exit 2" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 2, stdout: "", stderr: "policy violation", timedOut: false });
const result = await runHooksForEvent("UserPromptSubmit", ctx, { prompt: "hi" });
expect(result.blocked).toBe(true);
expect(result.reason).toBe("policy violation");
});
it("warns on non-zero, non-2 exits", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "exit 1" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "boom", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.warnings).toContain("boom");
});
it("collects stdout as additional context on exit 0", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "echo hello" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "hello", stderr: "", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.additionalContext).toBe("hello");
});
it("parses JSON output when outputSchema is json", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "echo '{\"foo\":1}'", outputSchema: "json" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: '{"foo":1}', stderr: "", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.jsonContext).toEqual([{ foo: 1 }]);
expect(result.additionalContext).toBeUndefined();
});
it("warns when JSON output fails to parse", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "echo 'not json'", outputSchema: "json" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "not json", stderr: "", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.warnings.some((w) => w.includes("JSON output could not be parsed"))).toBe(true);
});
it("filters tool-scoped hooks by matcher", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
PreToolUse: [{ matcher: "bash,write_file", hooks: [{ type: "command", command: "echo ok" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "ok", stderr: "", timedOut: false });
const result = await runHooksForEvent("PreToolUse", ctx, { tool_name: "bash", tool_input: {} }, "bash");
expect(result.additionalContext).toBe("ok");
});
it("skips tool-scoped hooks when the matcher doesn't match", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
PreToolUse: [{ matcher: "bash", hooks: [{ type: "command", command: "echo ok" }] }],
});
const result = await runHooksForEvent("PreToolUse", ctx, { tool_name: "read_file", tool_input: {} }, "read_file");
expect(result.additionalContext).toBeUndefined();
});
it("warns for unsupported prompt hooks", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "prompt", message: "ok?" } as any] }],
});
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.warnings).toContain("1 prompt hook(s) skipped (not yet implemented).");
});
});
+178
View File
@@ -0,0 +1,178 @@
import { execa } from "execa";
import { loadMergedHooks } from "./config.js";
import { matcherMatches } from "./matcher.js";
import type { Hook, HookCommand, HookEventName, HookHttp } from "./types.js";
const DEFAULT_TIMEOUT_SECONDS = 30;
export interface HookRunContext {
sessionId: string;
cwd: string;
}
export interface HookResult {
/** True if any hook for this event exited 2 — the event should be blocked. */
blocked: boolean;
/** stderr from the (first) blocking hook, shown as the reason. */
reason?: string;
/** Concatenated stdout/response bodies from hooks that succeeded with output — only meaningful for
* SessionStart/UserPromptSubmit, where it's injected as extra context. */
additionalContext?: string;
/** Parsed JSON output from hooks whose outputSchema is "json". Kept separate from additionalContext
* so callers can choose how to fold it into the system prompt or transcript. */
jsonContext?: unknown[];
/** Non-blocking failures (non-zero, non-2 exit, a crash/timeout, or a JSON parse failure) — surfaced
* as UI notices, never fatal to the turn. */
warnings: string[];
}
const EMPTY_RESULT: HookResult = { blocked: false, warnings: [] };
function isCommandHook(hook: Hook): hook is HookCommand {
return hook.type === "command";
}
function isHttpHook(hook: Hook): hook is HookHttp {
return hook.type === "http";
}
function parseOutput(output: string, schema?: "json"): { text?: string; json?: unknown; warning?: string } {
if (!schema) return { text: output };
if (schema !== "json") return { text: output };
const trimmed = output.trim();
if (!trimmed) return {};
try {
return { json: JSON.parse(trimmed) };
} catch (err) {
return { warning: `Hook JSON output could not be parsed: ${(err as Error).message}` };
}
}
async function runCommandHook(
hook: HookCommand,
stdinPayload: string,
ctx: HookRunContext,
): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean; json?: unknown; warning?: string }> {
try {
const result = await execa(hook.command, {
shell: true,
cwd: ctx.cwd,
input: stdinPayload,
timeout: (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
reject: false,
});
const parsed = parseOutput(result.stdout ?? "", hook.outputSchema);
return {
exitCode: result.exitCode ?? 1,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
timedOut: result.timedOut ?? false,
json: parsed.json,
warning: parsed.warning,
};
} catch (err) {
return { exitCode: 1, stdout: "", stderr: (err as Error).message, timedOut: false };
}
}
async function runHttpHook(
hook: HookHttp,
stdinPayload: string,
ctx: HookRunContext,
): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean; json?: unknown; warning?: string }> {
const controller = new AbortController();
const timeoutMs = (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const hasPayload = stdinPayload.length > 0;
const method = hook.method ?? (hasPayload ? "POST" : "GET");
const headers: Record<string, string> = { ...hook.headers };
let body: string | undefined;
if (method === "POST" && hasPayload) {
headers["content-type"] = "application/json";
body = stdinPayload;
}
const res = await fetch(hook.url, { method, headers, body, signal: controller.signal });
const text = await res.text();
clearTimeout(timeoutId);
const parsed = parseOutput(text, hook.outputSchema);
return {
exitCode: res.ok ? 0 : 2,
stdout: text,
stderr: res.ok ? "" : `HTTP ${res.status} ${res.statusText}`,
timedOut: false,
json: parsed.json,
warning: parsed.warning,
};
} catch (err) {
clearTimeout(timeoutId);
const timedOut = err instanceof Error && err.name === "AbortError";
return {
exitCode: timedOut ? 1 : 1,
stdout: "",
stderr: timedOut ? "HTTP hook timed out." : (err as Error).message,
timedOut,
};
}
}
/** Runs every configured hook for `event` (filtered to `toolName` for tool-scoped events) and
* combines their outcomes. Hooks run in parallel — a session isn't meant to hang because one
* hook is slow, and each still gets its own timeout. */
export async function runHooksForEvent(
event: HookEventName,
ctx: HookRunContext,
payload: Record<string, unknown>,
toolName?: string,
): Promise<HookResult> {
const config = loadMergedHooks(ctx.cwd);
const entries = config[event];
if (!entries || entries.length === 0) return EMPTY_RESULT;
const hooks = entries
.filter((entry) => toolName === undefined || matcherMatches(entry.matcher, toolName))
.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) => {
if (isCommandHook(hook)) return runCommandHook(hook, stdinPayload, ctx);
if (isHttpHook(hook)) return runHttpHook(hook, stdinPayload, ctx);
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);
}
if (outcome.exitCode === 2) {
result.blocked = true;
result.reason ??= outcome.stderr.trim() || "Blocked by hook.";
} else if (outcome.timedOut) {
result.warnings.push(`Hook timed out during ${event}.`);
} else if (outcome.exitCode !== 0) {
result.warnings.push(outcome.stderr.trim() || `Hook exited ${outcome.exitCode} during ${event}.`);
} else if (outcome.stdout.trim()) {
if (outcome.json !== undefined) {
result.jsonContext ??= [];
result.jsonContext.push(outcome.json);
} else {
result.additionalContext = [result.additionalContext, outcome.stdout.trim()].filter(Boolean).join("\n\n");
}
}
}
return result;
}
+62
View File
@@ -0,0 +1,62 @@
/** The lifecycle events locode has real attachment points for. This covers the most useful subset of
* Claude Code's ~20 hook events; missing ones (PromptHook, AgentHook, more exotic events) can be
* added here and in the runner later without changing the file format. */
export type HookEventName =
| "SessionStart"
| "UserPromptSubmit"
| "PreToolUse"
| "PostToolUse"
| "PermissionRequest"
| "SubagentStart"
| "SubagentStop"
| "CwdChanged"
| "FileChanged"
| "ConfigChange"
| "Stop"
| "SessionEnd";
export interface HookBase {
/** Seconds before locode gives up on this hook and treats it as a non-blocking error. Default 30. */
timeout?: number;
}
export interface HookCommand extends HookBase {
type: "command";
command: string;
/** When "json", stdout is parsed as JSON and made available to the model/system as structured
* additional context. When omitted or any other value, stdout is treated as plain text. */
outputSchema?: "json";
}
export interface HookHttp extends HookBase {
type: "http";
/** HTTP method. Defaults to POST for events with a payload, GET for events without. */
method?: "GET" | "POST";
url: string;
headers?: Record<string, string>;
/** When "json", the response body is parsed as JSON and treated like command-hook stdout. */
outputSchema?: "json";
}
export interface HookPrompt extends HookBase {
type: "prompt";
message: string;
/** Not yet implemented — prompt hooks require a UI blocking flow the current runner doesn't support. */
}
export type Hook = HookCommand | HookHttp | HookPrompt;
export interface HookMatcherEntry {
/** Tool name pattern for PreToolUse/PostToolUse/PermissionRequest ("*", empty, or omitted = every
* tool; "|" or "," separated for a few specific tools). Ignored for events that aren't tool-scoped.
* Claude Code's built-in tool names (Read, Bash, Edit, ...) are mapped to locode's own — see
* plugins/toolNameMap.ts — so a plugin's hooks.json works unmodified. */
matcher?: string;
hooks: Hook[];
}
export type HooksConfig = Partial<Record<HookEventName, HookMatcherEntry[]>>;
export interface HooksFile {
hooks: HooksConfig;
}
+36
View File
@@ -4,6 +4,37 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { isHttpServerConfig, type McpServerConfig } from "./types.js";
export interface McpTextContentBlock {
type: "text";
text: string;
}
export interface McpImageContentBlock {
type: "image";
mimeType: string;
data: string; // base64
}
export interface McpAudioContentBlock {
type: "audio";
mimeType: string;
data: string; // base64
}
export interface McpResourceContentBlock {
type: "resource";
resource: {
uri: string;
mimeType?: string;
/** Present for text resources. */
text?: string;
/** Present for binary resources. */
blob?: string; // base64
};
}
export type McpContentBlock = McpTextContentBlock | McpImageContentBlock | McpAudioContentBlock | McpResourceContentBlock;
export interface McpToolInfo {
name: string;
description?: string;
@@ -11,6 +42,11 @@ export interface McpToolInfo {
annotations?: { readOnlyHint?: boolean };
}
export interface McpCallToolResult {
isError?: boolean;
content?: McpContentBlock[];
}
export interface ConnectedMcpServer {
name: string;
client: Client;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import envPaths from "env-paths";
import { loadMergedServers, saveUserServer, removeUserServer } from "./config.js";
const paths = envPaths("locode", { suffix: "" });
const userFile = path.join(paths.config, "mcp.json");
describe("loadMergedServers", () => {
let tempCwd: string;
beforeEach(() => {
mkdirSync(paths.config, { recursive: true });
if (existsSync(userFile)) rmSync(userFile);
tempCwd = mkdtempSync(path.join(os.tmpdir(), "locode-mcp-"));
});
afterEach(() => {
if (existsSync(userFile)) rmSync(userFile);
if (existsSync(tempCwd)) rmSync(tempCwd, { recursive: true, force: true });
});
it("merges user and project servers, project wins", () => {
saveUserServer("shared", { command: "user-cmd" });
writeFileSync(path.join(tempCwd, ".mcp.json"), JSON.stringify({ mcpServers: { shared: { command: "project-cmd" } } }));
const { servers, collisions } = loadMergedServers(tempCwd);
expect(servers.shared).toEqual({ command: "project-cmd" });
expect(collisions).toEqual([{ name: "shared", sources: ["user", "project"], winner: "project" }]);
});
it("reports no collisions when names are unique", () => {
saveUserServer("a", { command: "a-cmd" });
const { collisions } = loadMergedServers(tempCwd);
expect(collisions).toEqual([]);
});
it("removes user servers", () => {
saveUserServer("x", { command: "x-cmd" });
expect(removeUserServer("x")).toBe(true);
const { servers } = loadMergedServers(tempCwd);
expect(servers.x).toBeUndefined();
});
});
+65 -3
View File
@@ -1,11 +1,25 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getLoadedPlugins } from "../plugins/registry.js";
import type { McpServerConfig, McpServersFile } from "./types.js";
const paths = envPaths("locode", { suffix: "" });
const userFile = path.join(paths.config, "mcp.json");
export interface McpServerCollision {
name: string;
/** Sources that defined this server name, in merge order (plugin names, "user", "project"). */
sources: string[];
/** Which source won. */
winner: string;
}
export interface MergedServersResult {
servers: Record<string, McpServerConfig>;
collisions: McpServerCollision[];
}
export function userMcpFilePath(): string {
return userFile;
}
@@ -30,9 +44,57 @@ export function loadProjectServers(cwd: string): Record<string, McpServerConfig>
return readServersFile(path.join(cwd, ".mcp.json"));
}
/** User-level servers plus project-level ones, with project entries winning on name collisions. */
export function loadMergedServers(cwd: string): Record<string, McpServerConfig> {
return { ...loadUserServers(), ...loadProjectServers(cwd) };
/** MCP servers bundled with installed plugins (`locode plugin add`). */
export function loadPluginServers(): Record<string, { source: string; config: McpServerConfig }> {
const merged: Record<string, { source: string; config: McpServerConfig }> = {};
for (const plugin of getLoadedPlugins()) {
for (const [name, config] of Object.entries(plugin.mcpServers)) {
// First plugin wins for plugin-tier; collision detection happens in loadMergedServers.
if (!(name in merged)) {
merged[name] = { source: plugin.name, config };
}
}
}
return merged;
}
/** Plugin-provided servers, then user-level, then project-level — each tier can override the
* previous one's names, project-level (most specific to what you're working on) winning last.
* Returns the merged map plus a list of names that were defined by more than one source. */
export function loadMergedServers(cwd: string): MergedServersResult {
const pluginServers = loadPluginServers();
const userServers = loadUserServers();
const projectServers = loadProjectServers(cwd);
const sourcesByName: Record<string, string[]> = {};
for (const [name, { source }] of Object.entries(pluginServers)) {
sourcesByName[name] ??= [];
sourcesByName[name]!.push(source);
}
for (const name of Object.keys(userServers)) {
sourcesByName[name] ??= [];
sourcesByName[name]!.push("user");
}
for (const name of Object.keys(projectServers)) {
sourcesByName[name] ??= [];
sourcesByName[name]!.push("project");
}
const servers: Record<string, McpServerConfig> = {
...Object.fromEntries(Object.entries(pluginServers).map(([n, s]) => [n, s.config])),
...userServers,
...projectServers,
};
const collisions: McpServerCollision[] = Object.entries(sourcesByName)
.filter(([, sources]) => sources.length > 1)
.map(([name, sources]) => {
// Winner is the last tier that defined it: project > user > plugin.
const winner = name in projectServers ? "project" : name in userServers ? "user" : sources[0]!;
return { name, sources, winner };
});
return { servers, collisions };
}
export function saveUserServer(name: string, config: McpServerConfig): void {
+9 -2
View File
@@ -8,6 +8,8 @@ export interface McpServerStatus {
status: "connected" | "error";
toolCount: number;
error?: string;
/** Non-fatal: names defined by more than one source. Only shown when this server connected. */
collision?: { sources: string[]; winner: string };
}
let connections: ConnectedMcpServer[] = [];
@@ -16,7 +18,7 @@ let statuses: McpServerStatus[] = [];
/** Connects to every configured MCP server in parallel (one bad server doesn't block the rest)
* and returns the flattened, ready-to-use tools from whichever servers connected successfully. */
export async function connectConfiguredMcpServers(cwd: string): Promise<ToolDef[]> {
const servers = loadMergedServers(cwd);
const { servers, collisions } = loadMergedServers(cwd);
const entries = Object.entries(servers);
if (entries.length === 0) return [];
@@ -33,7 +35,12 @@ export async function connectConfiguredMcpServers(cwd: string): Promise<ToolDef[
nextConnections.push(server);
const toolDefs = server.tools.map((t) => mcpToolToToolDef(name, server.client, t));
allTools.push(...toolDefs);
nextStatuses.push({ name, status: "connected", toolCount: toolDefs.length });
nextStatuses.push({
name,
status: "connected",
toolCount: toolDefs.length,
collision: collisions.find((c) => c.name === name),
});
} else {
nextStatuses.push({ name, status: "error", toolCount: 0, error: (result.reason as Error).message });
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { mcpToolToToolDef } from "./toolAdapter.js";
import type { McpToolInfo } from "./client.js";
function fakeClient(result: unknown) {
return { callTool: vi.fn().mockResolvedValue(result) } as unknown as Parameters<typeof mcpToolToToolDef>[1];
}
describe("mcpToolToToolDef", () => {
const tool: McpToolInfo = {
name: "sample",
description: "A sample tool",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
};
it("namespaces the tool name", () => {
const def = mcpToolToToolDef("my-server", fakeClient({ content: [] }), tool);
expect(def.name).toBe("mcp__my-server__sample");
expect(def.mutating).toBe(false);
});
it("returns text blocks verbatim", async () => {
const client = fakeClient({ content: [{ type: "text", text: "hello" }] });
const def = mcpToolToToolDef("s", client, tool);
const result = await def.handler({}, { cwd: "/tmp" });
expect(result).toEqual({ content: "hello" });
});
it("returns an image in the read_file-compatible shape", async () => {
const client = fakeClient({ content: [{ type: "image", mimeType: "image/png", data: "base64data" }] });
const def = mcpToolToToolDef("s", client, tool);
const result = await def.handler({}, { cwd: "/tmp" });
expect(result).toMatchObject({ image: true, mimeType: "image/png", base64: "base64data", content: expect.stringContaining("image/png") });
});
it("returns audio as a text note", async () => {
const client = fakeClient({ content: [{ type: "audio", mimeType: "audio/wav", data: "snd" }] });
const def = mcpToolToToolDef("s", client, tool);
const result = await def.handler({}, { cwd: "/tmp" });
expect(result).toEqual({ content: "[MCP audio content: audio/wav]" });
});
it("inlines text resources", async () => {
const client = fakeClient({
content: [{ type: "resource", resource: { uri: "file:///x.txt", mimeType: "text/plain", text: "resource body" } }],
});
const def = mcpToolToToolDef("s", client, tool);
const result = await def.handler({}, { cwd: "/tmp" });
expect(result).toEqual({ content: "[MCP resource: file:///x.txt]\nresource body" });
});
it("notes binary resources without inlining", async () => {
const client = fakeClient({
content: [{ type: "resource", resource: { uri: "file:///x.bin", mimeType: "application/octet-stream", blob: "abc" } }],
});
const def = mcpToolToToolDef("s", client, tool);
const result = await def.handler({}, { cwd: "/tmp" });
expect(result).toEqual({ content: "[MCP resource: file:///x.bin (base64, application/octet-stream)]" });
});
it("marks errors", async () => {
const client = fakeClient({ isError: true, content: [{ type: "text", text: "boom" }] });
const def = mcpToolToToolDef("s", client, tool);
const result = await def.handler({}, { cwd: "/tmp" });
expect(result).toEqual({ error: "boom" });
});
});
+42 -9
View File
@@ -1,7 +1,7 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import type { McpToolInfo } from "./client.js";
import type { McpCallToolResult, McpContentBlock, McpToolInfo } from "./client.js";
function sanitize(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -15,6 +15,30 @@ export function mcpToolName(serverName: string, toolName: string): string {
const argsSchema = z.record(z.string(), z.unknown());
function renderContentBlock(block: McpContentBlock): { text: string; image?: { mimeType: string; base64: string } } {
switch (block.type) {
case "text":
return { text: block.text };
case "image":
return {
text: `[MCP image content: ${block.mimeType}]`,
image: { mimeType: block.mimeType, base64: block.data },
};
case "audio":
return { text: `[MCP audio content: ${block.mimeType}]` };
case "resource": {
const r = block.resource;
if (r.text !== undefined) {
return { text: `[MCP resource: ${r.uri}]\n${r.text}` };
}
const binaryNote = r.blob ? ` (base64, ${r.mimeType ?? "unknown mime"})` : "";
return { text: `[MCP resource: ${r.uri}${binaryNote}]` };
}
default:
return { text: `[unsupported MCP content type]` };
}
}
export function mcpToolToToolDef(serverName: string, client: Client, mcpTool: McpToolInfo): ToolDef<Record<string, unknown>> {
return {
name: mcpToolName(serverName, mcpTool.name),
@@ -25,18 +49,27 @@ export function mcpToolToToolDef(serverName: string, client: Client, mcpTool: Mc
mutating: mcpTool.annotations?.readOnlyHint !== true,
preview: async (args) => "```json\n" + JSON.stringify(args, null, 2) + "\n```",
handler: async (args) => {
const result = await client.callTool({ name: mcpTool.name, arguments: args });
const result = (await client.callTool({ name: mcpTool.name, arguments: args })) as McpCallToolResult;
const blocks = Array.isArray(result.content) ? result.content : [];
const text = blocks
.map((block) =>
block && typeof block === "object" && "type" in block && block.type === "text"
? (block as { text: string }).text
: `[unsupported MCP content type: ${(block as { type?: string })?.type ?? "unknown"}]`,
)
.join("\n");
const textParts: string[] = [];
const images: { mimeType: string; base64: string }[] = [];
for (const block of blocks as McpContentBlock[]) {
const rendered = renderContentBlock(block);
textParts.push(rendered.text);
if (rendered.image) images.push(rendered.image);
}
const text = textParts.join("\n");
if (result.isError) {
return { error: text || "MCP tool call failed." };
}
// Return image data in the same shape read_file uses so agent/loop.ts can thread it into
// the conversation as a real image_url content part for vision-capable models. If multiple
// images come back, the first one is sent as a real image and the rest stay as text notes.
if (images.length >= 1) {
return { content: text, image: true, mimeType: images[0]!.mimeType, base64: images[0]!.base64, bytes: 0 };
}
return { content: text };
},
};
+54
View File
@@ -0,0 +1,54 @@
import { existsSync, statSync } from "node:fs";
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. */
function messageSection(m: ChatCompletionMessageParam): string | null {
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
return `### You\n\n${m.content}`;
}
if (m.role === "assistant" && typeof m.content === "string" && m.content) {
return `### Assistant\n\n${m.content}`;
}
return null;
}
export interface ExportMeta {
model: string;
createdAt: string;
}
export function sessionToMarkdown(messages: ChatCompletionMessageParam[], meta: ExportMeta): string {
const header = [
"# locode conversation",
"",
`- model: ${meta.model}`,
`- started: ${meta.createdAt}`,
`- exported: ${new Date().toISOString()}`,
].join("\n");
const sections = messages.map(messageSection).filter((s): s is string => s !== null);
return [header, ...sections].join("\n\n");
}
export function defaultExportFilename(): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `locode-export-${stamp}.md`;
}
/** Writes the conversation to a markdown file and returns the resolved absolute path.
* `target` may be a bare filename, a relative path, or an absolute path; a bare directory
* (or nothing at all) falls back to an auto-generated filename inside `cwd`. Written atomically
* (temp file + rename), matching sessionStore's saves, so a crash mid-export can't leave a
* truncated file. */
export async function exportSession(messages: ChatCompletionMessageParam[], meta: ExportMeta, cwd: string, target?: string): Promise<string> {
const filename = target?.trim() || defaultExportFilename();
let resolved = path.isAbsolute(filename) ? filename : path.resolve(cwd, filename);
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
resolved = path.join(resolved, defaultExportFilename());
}
await writeFileAtomic(resolved, sessionToMarkdown(messages, meta));
return resolved;
}
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import envPaths from "env-paths";
import {
deleteSession,
deriveTitle,
listSessions,
loadSession,
safeSessionId,
saveSession,
sessionsDir,
} from "./sessionStore.js";
const paths = envPaths("locode", { suffix: "" });
const dir = sessionsDir();
function makeRecord(id: string): NonNullable<ReturnType<typeof loadSession>> {
return {
id,
createdAt: "2026-07-07T00:00:00.000Z",
updatedAt: "2026-07-07T00:00:00.000Z",
cwd: "/tmp",
baseURL: "http://localhost:11434/v1",
model: "qwen3-coder:30b",
mode: "native",
messages: [{ role: "user", content: "hello" }],
};
}
describe("sessionStore", () => {
beforeEach(() => {
mkdirSync(dir, { recursive: true });
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("sanitizes malicious session ids to prevent path traversal", () => {
expect(safeSessionId("../../etc/passwd")).toBe(".._.._etc_passwd");
expect(safeSessionId("a/b\\c")).toBe("a_b_c");
});
it("preserves safe session ids", () => {
expect(safeSessionId("abc-123.test_session")).toBe("abc-123.test_session");
});
it("saves, loads, lists, and deletes sessions by original id", async () => {
const record = makeRecord("my-session-1");
await saveSession(record);
expect(loadSession("my-session-1")?.id).toBe("my-session-1");
expect(listSessions()).toHaveLength(1);
expect(deleteSession("my-session-1")).toBe(true);
expect(loadSession("my-session-1")).toBeUndefined();
});
it("contains malicious ids within the sessions directory", async () => {
const record = makeRecord("../escape");
await saveSession(record);
const savedFile = path.join(dir, ".._escape.json");
expect(existsSync(savedFile)).toBe(true);
expect(loadSession("../escape")?.id).toBe("../escape");
expect(listSessions()[0]?.id).toBe("../escape");
expect(deleteSession("../escape")).toBe(true);
});
it("ignores non-json files in the sessions directory", () => {
writeFileSync(path.join(dir, "notes.txt"), "not a session");
expect(listSessions()).toHaveLength(0);
});
it("deriveTitle extracts the first user message", () => {
const title = deriveTitle([
{ role: "system", content: "sys" },
{ role: "user", content: " summarize this file " },
]);
expect(title).toBe("summarize this file");
});
});
+36 -5
View File
@@ -1,8 +1,9 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
import path from "node:path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
* current tool list on load, so a saved session always reflects whatever tools locode has today. */
@@ -33,8 +34,15 @@ export function sessionsDir(): string {
return dir;
}
/** Replace filesystem-unsafe characters (including path separators) in a user-supplied
* session id so it can't escape the sessions directory. The original id is preserved in the
* record itself; this sanitized form is only used for the filename. */
export function safeSessionId(id: string): string {
return id.replace(/[^a-zA-Z0-9_.-]/g, "_");
}
function filePath(id: string): string {
return path.join(dir, `${id}.json`);
return path.join(dir, `${safeSessionId(id)}.json`);
}
export function deriveTitle(messages: ChatCompletionMessageParam[]): string {
@@ -44,9 +52,32 @@ export function deriveTitle(messages: ChatCompletionMessageParam[]): string {
return text.length > 60 ? `${text.slice(0, 60)}...` : text;
}
export function saveSession(record: SessionRecord): void {
mkdirSync(dir, { recursive: true });
writeFileSync(filePath(record.id), JSON.stringify(record, null, 2));
/** In-flight saves per session id, chained so concurrent saves to the same file never overlap —
* each save runs after the prior one settles, so the last save always wins and writes never interleave. */
const saveQueues = new Map<string, Promise<void>>();
/** Writes the session atomically and off the main thread's synchronous path: serialize per-id (so
* rapid autosaves can't interleave/corrupt), write to a sibling temp file, then rename into place.
* A crash mid-write leaves the old file intact (rename is atomic within one directory) rather than a
* truncated one — which matters because every turn autosaves here, and a half-written file would
* otherwise load as "no session found" (see loadSession, which treats an unparseable file as absent). */
export async function saveSession(record: SessionRecord): Promise<void> {
const file = filePath(record.id);
const write = () => writeFileAtomic(file, JSON.stringify(record, null, 2));
// Run whether or not the previous save rejected, so one failure can't stall the chain.
const prev = saveQueues.get(record.id);
const next = (prev ?? Promise.resolve()).then(write, write);
saveQueues.set(record.id, next);
void next.finally(() => {
if (saveQueues.get(record.id) === next) saveQueues.delete(record.id);
});
return next;
}
/** Await every save currently in flight. Call this on any exit path so a fire-and-forget autosave
* (see App.tsx persistCurrentSession) isn't lost when the process exits a moment later. */
export async function flushPendingSaves(): Promise<void> {
await Promise.allSettled([...saveQueues.values()]);
}
export function loadSession(id: string): SessionRecord | undefined {
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import type { PluginAgentDef } from "./types.js";
function sanitize(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
}
const schema = z.object({
prompt: z
.string()
.describe("The full, self-contained task for this agent. It has no memory of the parent conversation."),
});
/** Namespaced as `agent__<plugin>__<agent>` (mirroring the `mcp__<server>__<tool>` convention)
* so agents from different plugins — or an agent and an MCP tool — can't collide. */
export function pluginAgentToolName(pluginName: string, agentName: string): string {
return `agent__${sanitize(pluginName)}__${sanitize(agentName)}`;
}
/** Wraps a plugin-defined agent (agents/*.md) as a tool the model can call — it runs through the
* same sub-agent tool-loop machinery as locode's own generic `agent` tool, but with the plugin's
* own system prompt (instead of the generic "delegate a task" framing) and, if the agent's
* frontmatter specifies `tools:`, a restricted toolset. */
export function buildPluginAgentTool(agent: PluginAgentDef): ToolDef<{ prompt: string }> {
return {
name: pluginAgentToolName(agent.pluginName, agent.name),
description: `[Plugin agent from "${agent.pluginName}"] ${agent.description}`,
schema,
mutating: false,
handler: async ({ prompt }, ctx) => {
if (!ctx.runSubAgent) {
throw new Error("Sub-agents are not available in this context.");
}
const result = await ctx.runSubAgent(
{ description: agent.name, prompt },
{ systemPrompt: agent.systemPrompt, toolNames: agent.tools },
);
return { agent: agent.name, result };
},
};
}
+48
View File
@@ -0,0 +1,48 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export interface InstalledPluginRecord {
name: string;
/** Absolute local path — either a local directory the user pointed at directly, or a git clone
* under pluginsDir(). */
path: string;
/** What the user originally passed to `locode plugin add` (path or git URL) — kept for display. */
source: string;
}
const paths = envPaths("locode", { suffix: "" });
const registryFile = path.join(paths.config, "plugins.json");
export function pluginsDir(): string {
return path.join(paths.data, "plugins");
}
export function loadInstalledPlugins(): InstalledPluginRecord[] {
if (!existsSync(registryFile)) return [];
try {
const parsed = JSON.parse(readFileSync(registryFile, "utf-8")) as { plugins: InstalledPluginRecord[] };
return parsed.plugins ?? [];
} catch {
return [];
}
}
function saveInstalledPlugins(records: InstalledPluginRecord[]): void {
mkdirSync(path.dirname(registryFile), { recursive: true });
writeFileSync(registryFile, JSON.stringify({ plugins: records }, null, 2));
}
export function addInstalledPlugin(record: InstalledPluginRecord): void {
const records = loadInstalledPlugins().filter((r) => r.name !== record.name);
records.push(record);
saveInstalledPlugins(records);
}
export function removeInstalledPlugin(name: string): boolean {
const records = loadInstalledPlugins();
const next = records.filter((r) => r.name !== name);
if (next.length === records.length) return false;
saveInstalledPlugins(next);
return true;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { expandCommandTemplate } from "./expandTemplate.js";
describe("expandCommandTemplate", () => {
it("expands $ARGUMENTS to the whole argument string", () => {
expect(expandCommandTemplate("Hello $ARGUMENTS!", "world")).toBe("Hello world!");
});
it("expands $1..$9 to individual arguments", () => {
expect(expandCommandTemplate("$1 and $2", "alice bob")).toBe("alice and bob");
});
it("leaves missing numbered arguments empty", () => {
expect(expandCommandTemplate("$1 $2 $3", "only")).toBe("only ");
});
it("handles no arguments", () => {
expect(expandCommandTemplate("Hi $ARGUMENTS", "")).toBe("Hi ");
});
});
+8
View File
@@ -0,0 +1,8 @@
/** Expands a Claude Code command template: `$ARGUMENTS` becomes the whole argument string, and
* `$1`.."$9" become individual whitespace-split arguments (empty string if not supplied). Matches
* are found in a single pass over the original template, so a substituted value that happens to
* contain a literal `$1`/`$ARGUMENTS`-like sequence is never re-scanned and re-substituted. */
export function expandCommandTemplate(template: string, argsText: string): string {
const args = argsText.length > 0 ? argsText.split(/\s+/) : [];
return template.replace(/\$ARGUMENTS|\$([1-9])/g, (match, digit) => (match === "$ARGUMENTS" ? argsText : (args[Number(digit) - 1] ?? "")));
}
+28
View File
@@ -0,0 +1,28 @@
export interface ParsedMarkdown {
frontmatter: Record<string, string>;
body: string;
}
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
/** Minimal frontmatter parser for Claude Code command/agent markdown files — these only ever use
* flat `key: value` pairs (description, argument-hint, tools, ...), so a full YAML parser would
* be overkill. Falls back to treating the whole file as body if there's no `---` header. */
export function parseFrontmatter(content: string): ParsedMarkdown {
const match = FRONTMATTER_RE.exec(content);
if (!match) return { frontmatter: {}, body: content.trim() };
const [, rawFrontmatter, body] = match;
const frontmatter: Record<string, string> = {};
for (const line of rawFrontmatter!.split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) frontmatter[key] = value;
}
return { frontmatter, body: (body ?? "").trim() };
}
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { loadPlugin } from "./loader.js";
describe("loadPlugin", () => {
let tempDir: string;
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `locode-plugin-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("loads a skill with references/*.md files", () => {
mkdirSync(path.join(tempDir, ".claude-plugin"), { recursive: true });
mkdirSync(path.join(tempDir, "skills", "dataviz", "references"), { recursive: true });
writeFileSync(
path.join(tempDir, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "test-plugin" }),
);
writeFileSync(path.join(tempDir, "skills", "dataviz", "SKILL.md"), "---\ndescription: dataviz skill\n---\nmain skill");
writeFileSync(path.join(tempDir, "skills", "dataviz", "references", "colors.md"), "use blue");
const plugin = loadPlugin(tempDir);
expect(plugin.skills).toHaveLength(1);
expect(plugin.skills[0]).toMatchObject({ name: "dataviz", content: "main skill" });
expect(plugin.skills[0]?.references).toEqual([{ name: "colors", content: "use blue" }]);
});
it("loads commands and agents", () => {
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", "hello.md"), "---\ndescription: Say hello\n---\nHello $ARGUMENTS");
mkdirSync(path.join(tempDir, "agents"), { recursive: true });
writeFileSync(path.join(tempDir, "agents", "review.md"), "---\ndescription: Code reviewer\n---\nYou review code.");
const plugin = loadPlugin(tempDir);
expect(plugin.commands).toHaveLength(1);
expect(plugin.commands[0]).toMatchObject({ name: "hello", description: "Say hello" });
expect(plugin.agents).toHaveLength(1);
expect(plugin.agents[0]).toMatchObject({ name: "review", description: "Code reviewer" });
});
});
+126
View File
@@ -0,0 +1,126 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import type { HooksConfig, HooksFile } from "../hooks/types.js";
import type { McpServerConfig, McpServersFile } from "../mcp/types.js";
import { parseFrontmatter } from "./frontmatter.js";
import { resolveToolName } from "./toolNameMap.js";
import type { LoadedPlugin, PluginAgentDef, PluginCommand, PluginManifest, PluginSkill } from "./types.js";
function readJson<T>(file: string): T | null {
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, "utf-8")) as T;
} catch {
return null;
}
}
function listMarkdownFiles(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((f) => f.endsWith(".md"));
}
function loadCommands(pluginRoot: string, pluginName: string): PluginCommand[] {
const dir = path.join(pluginRoot, "commands");
return listMarkdownFiles(dir).map((entry) => {
const { frontmatter, body } = parseFrontmatter(readFileSync(path.join(dir, entry), "utf-8"));
return {
pluginName,
name: entry.replace(/\.md$/, ""),
description: frontmatter.description,
argumentHint: frontmatter["argument-hint"],
template: body,
};
});
}
function loadAgents(pluginRoot: string, pluginName: string): PluginAgentDef[] {
const dir = path.join(pluginRoot, "agents");
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,
systemPrompt: body,
};
});
}
/** Skills live one directory deeper than commands/agents — skills/<name>/SKILL.md — so a skill
* can bundle extra reference files (see e.g. the built-in `dataviz` skill's references/) beside
* the entrypoint. Anything under skills/ that isn't a directory with a SKILL.md is ignored. */
function loadReferences(skillDir: string): { name: string; content: string }[] {
const refsDir = path.join(skillDir, "references");
if (!existsSync(refsDir)) return [];
return listMarkdownFiles(refsDir).map((file) => ({
name: file.replace(/\.md$/, ""),
content: readFileSync(path.join(refsDir, file), "utf-8"),
}));
}
function loadSkills(pluginRoot: string, pluginName: string): PluginSkill[] {
const dir = path.join(pluginRoot, "skills");
if (!existsSync(dir)) return [];
const skills: PluginSkill[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, "SKILL.md");
if (!existsSync(skillFile)) continue;
const { frontmatter, body } = parseFrontmatter(readFileSync(skillFile, "utf-8"));
const name = frontmatter.name || entry.name;
skills.push({
pluginName,
name,
description: frontmatter.description || name,
content: body,
references: loadReferences(skillDir),
});
}
return skills;
}
/** A plugin's `.mcp.json` shows up in the wild in two shapes: wrapped (`{"mcpServers": {...}}`,
* same as a project-level .mcp.json) and unwrapped (server configs directly at the top level —
* what Anthropic's own official example plugin actually ships). Support both rather than
* silently loading zero servers for whichever shape wasn't guessed first. */
function loadMcpServers(pluginRoot: string, manifest: PluginManifest & { mcpServers?: Record<string, McpServerConfig> }) {
const raw = readJson<McpServersFile | Record<string, McpServerConfig>>(path.join(pluginRoot, ".mcp.json"));
const fromFile =
raw && typeof (raw as McpServersFile).mcpServers === "object" ? (raw as McpServersFile).mcpServers : ((raw ?? {}) as Record<string, McpServerConfig>);
return { ...fromFile, ...(manifest.mcpServers ?? {}) };
}
function loadHooks(pluginRoot: string): HooksConfig {
const fromFile = readJson<HooksFile>(path.join(pluginRoot, "hooks", "hooks.json"));
return fromFile?.hooks ?? {};
}
/** Loads a single plugin from its root directory (a local path, or a git clone's working copy).
* Missing pieces (no manifest, no commands/agents dirs, no .mcp.json) are all treated as "this
* plugin just doesn't have that part" rather than errors — most real plugins only use one or
* two of the three. */
export function loadPlugin(pluginRoot: string): LoadedPlugin {
const manifest = readJson<PluginManifest & { mcpServers?: Record<string, McpServerConfig> }>(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
) ?? { name: path.basename(pluginRoot) };
const name = manifest.name || path.basename(pluginRoot);
return {
name,
path: pluginRoot,
manifest,
commands: loadCommands(pluginRoot, name),
agents: loadAgents(pluginRoot, name),
skills: loadSkills(pluginRoot, name),
mcpServers: loadMcpServers(pluginRoot, manifest),
hooks: loadHooks(pluginRoot),
};
}
+45
View File
@@ -0,0 +1,45 @@
import { loadInstalledPlugins } from "./config.js";
import { loadPlugin } from "./loader.js";
import type { LoadedPlugin, PluginCommand } from "./types.js";
export interface PluginCommandCollision {
name: string;
plugins: string[];
winner: string;
}
let cache: LoadedPlugin[] | null = null;
/** Loads every installed plugin (commands, agents, MCP servers) once per process — plugins are
* files on disk that don't change mid-session, so there's no need to re-read them repeatedly. */
export function getLoadedPlugins(): LoadedPlugin[] {
if (cache) return cache;
cache = loadInstalledPlugins().flatMap((record) => {
try {
return [loadPlugin(record.path)];
} catch {
// A plugin whose directory went missing or is malformed shouldn't block the others.
return [];
}
});
return cache;
}
/** Finds slash-command names defined by more than one plugin. The first plugin in load order wins. */
export function getPluginCommandCollisions(): PluginCommandCollision[] {
const byName: Record<string, { pluginName: string; commands: PluginCommand[] }> = {};
for (const plugin of getLoadedPlugins()) {
for (const command of plugin.commands) {
byName[command.name] ??= { pluginName: plugin.name, commands: [] };
byName[command.name]!.commands.push(command);
}
}
return Object.entries(byName)
.filter(([, entry]) => entry.commands.length > 1)
.map(([name, entry]) => ({ name, plugins: entry.commands.map((c) => c.pluginName), winner: entry.pluginName }));
}
/** Clear the in-memory plugin cache. Useful in tests that mutate the installed plugin registry. */
export function clearPluginCache(): void {
cache = null;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { buildSkillTool, findSkillCollisions } from "./skillTool.js";
import type { PluginSkill } from "./types.js";
function skill(pluginName: string, name: string, description = name, content = ""): PluginSkill {
return { pluginName, name, description, content };
}
describe("findSkillCollisions", () => {
it("detects duplicate bare skill names across plugins", () => {
const skills = [skill("p1", "dataviz"), skill("p2", "dataviz"), skill("p1", "unique")];
const collisions = findSkillCollisions(skills);
expect(collisions).toEqual([{ name: "dataviz", plugins: ["p1", "p2"] }]);
});
it("returns empty when there are no collisions", () => {
expect(findSkillCollisions([skill("p1", "a"), skill("p2", "b")])).toEqual([]);
});
});
describe("buildSkillTool", () => {
it("returns null for no skills", () => {
expect(buildSkillTool([])).toBeNull();
});
it("returns instructions for the first matching skill", async () => {
const skills = [skill("p1", "dataviz", "dv", "p1 body"), skill("p2", "dataviz", "dv", "p2 body")];
const tool = buildSkillTool(skills)!;
const result = (await tool.handler({ name: "dataviz" }, { cwd: "/tmp" })) as { skill: string; plugin: string; instructions: string };
expect(result).toMatchObject({ skill: "dataviz", plugin: "p1", instructions: "p1 body" });
});
it("includes references in the returned instructions", async () => {
const skills: PluginSkill[] = [
{ pluginName: "p1", name: "dataviz", description: "dv", content: "main body", references: [{ name: "colors", content: "use blue" }] },
];
const tool = buildSkillTool(skills)!;
const result = (await tool.handler({ name: "dataviz" }, { cwd: "/tmp" })) as { instructions: string };
expect(result.instructions).toContain("main body");
expect(result.instructions).toContain("## colors");
expect(result.instructions).toContain("use blue");
});
it("mentions collisions in the tool description", () => {
const skills = [skill("p1", "dataviz"), skill("p2", "dataviz")];
const tool = buildSkillTool(skills)!;
expect(tool.description).toContain("dataviz: p1, p2");
});
});
+67
View File
@@ -0,0 +1,67 @@
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import type { PluginSkill } from "./types.js";
export interface SkillCollision {
name: string;
plugins: string[];
}
/** Finds bare skill names that are defined by more than one plugin. */
export function findSkillCollisions(skills: PluginSkill[]): SkillCollision[] {
const byName: Record<string, string[]> = {};
for (const skill of skills) {
byName[skill.name] ??= [];
if (!byName[skill.name]!.includes(skill.pluginName)) {
byName[skill.name]!.push(skill.pluginName);
}
}
return Object.entries(byName)
.filter(([, plugins]) => plugins.length > 1)
.map(([name, plugins]) => ({ name, plugins }));
}
/**
* Unlike agents (their own sub-agent tool-loop) or hooks (shell commands on lifecycle events), a
* skill is just named instructions the model loads into the current turn — there's exactly one
* `skill` tool, parameterized by name, mirroring how Claude Code itself exposes skills as a single
* generic tool rather than one tool per skill. The list of available skills and when to use each
* lives in this tool's own *description*, since locode's system prompt already lists every tool's
* description automatically (see agent/systemPrompt.ts) — no separate wiring needed.
*
* Collisions: if two plugins define a skill with the same bare name, the first plugin in load order
* wins; the collision is surfaced in `/skills` so the user knows which plugin's instructions they're
* getting.
*/
export function buildSkillTool(skills: PluginSkill[]): ToolDef<{ name: string }> | null {
if (skills.length === 0) return null;
const names = skills.map((s) => s.name) as [string, ...string[]];
const collisions = findSkillCollisions(skills);
const collisionNote = collisions.length
? `\n\nNote: these skill names are defined by more than one plugin; the first loaded plugin wins:\n${collisions
.map((c) => `- ${c.name}: ${c.plugins.join(", ")}`)
.join("\n")}`
: "";
const list = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
return {
name: "skill",
description:
`Load a specialized skill's full instructions by name. Call this BEFORE starting a task whose description below ` +
`matches what the user is asking for, then follow the returned instructions for the rest of the turn.\n\n` +
`Available skills:\n${list}${collisionNote}`,
schema: z.object({ name: z.enum(names).describe("The skill's name, exactly as listed in this tool's description.") }),
mutating: false,
handler: async ({ name }) => {
const skill = skills.find((s) => s.name === name);
if (!skill) return { error: `Unknown skill "${name}".` };
let instructions = skill.content;
if (skill.references?.length) {
const refs = skill.references.map((r) => `## ${r.name}\n\n${r.content}`).join("\n\n");
instructions = `${instructions}\n\n---\n\n${refs}`;
}
return { skill: skill.name, plugin: skill.pluginName, instructions };
},
};
}
+20
View File
@@ -0,0 +1,20 @@
/** Claude Code's built-in tool names (as used in an agent's `tools:` frontmatter) mapped to
* locode's equivalent tool names. Without this, restricting a plugin agent to e.g. "Read, Grep"
* would silently produce a toolless sub-agent, since locode's own tools are named read_file/grep
* and would never match. Unrecognized names pass through as-is — they may already be a
* locode-native name (e.g. someone wrote `bash` directly) or an MCP tool name. */
const CLAUDE_TOOL_NAME_MAP: Record<string, string> = {
read: "read_file",
write: "write_file",
edit: "edit_file",
bash: "bash",
grep: "grep",
glob: "list_files",
webfetch: "web_fetch",
websearch: "web_search",
task: "agent",
};
export function resolveToolName(name: string): string {
return CLAUDE_TOOL_NAME_MAP[name.toLowerCase()] ?? name;
}
+57
View File
@@ -0,0 +1,57 @@
import type { HooksConfig } from "../hooks/types.js";
import type { McpServerConfig } from "../mcp/types.js";
export interface PluginManifest {
name: string;
description?: string;
version?: string;
}
export interface PluginCommand {
pluginName: string;
/** Slash command name, without the leading "/" — derived from the markdown filename. */
name: string;
description?: string;
argumentHint?: string;
/** 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;
}
export interface PluginAgentDef {
pluginName: string;
name: string;
description: string;
/** Tool names this agent is restricted to (already translated from Claude Code's built-in tool
* names to locode's equivalents — see toolNameMap.ts); undefined means no restriction. */
tools?: string[];
/** The markdown body — used verbatim as the sub-agent's system prompt instead of locode's
* generic one. */
systemPrompt: string;
}
export interface PluginSkill {
pluginName: string;
/** Skill name — from SKILL.md frontmatter `name`, falling back to the containing directory
* name (skills/<name>/SKILL.md). */
name: string;
/** Used both for display and, critically, folded into the `skill` tool's own description so
* the model knows when to invoke this skill — see plugins/skillTool.ts. */
description: string;
/** The full SKILL.md body — returned verbatim when the model (or `/name`) invokes this skill,
* for it to then read and follow. */
content: string;
/** Sibling reference files (references/*.md) loaded alongside SKILL.md. */
references?: { name: string; content: string }[];
}
export interface LoadedPlugin {
name: string;
path: string;
manifest: PluginManifest;
commands: PluginCommand[];
agents: PluginAgentDef[];
skills: PluginSkill[];
mcpServers: Record<string, McpServerConfig>;
hooks: HooksConfig;
}
+84
View File
@@ -0,0 +1,84 @@
import type { ResultPromise } from "execa";
import { truncate } from "../utils/truncate.js";
// Generous cap on buffered output per stream — well above the 20k default `truncate()` applies
// when `bash_output` reads the job, but bounded so a long-lived, verbose backgrounded process
// (a dev server, a watch build) can't grow its buffers without limit for the rest of the session.
const MAX_BUFFERED_CHARS = 200_000;
export interface BackgroundJob {
id: string;
command: string;
cwd: string;
status: "running" | "done";
stdout: string;
stderr: string;
exitCode: number | null;
/** Set when the process was killed by a signal rather than exiting on its own — `exitCode` is
* null in that case and must not be treated as a successful (0) exit. */
signal: string | null;
startedAt: number;
finishedAt?: number;
}
const jobs = new Map<string, BackgroundJob>();
let nextId = 1;
const listeners = new Set<(job: BackgroundJob) => void>();
/** Called once when a backgrounded job finishes, so the UI can surface a notice even though the
* turn that started it has long since ended. */
export function onBackgroundJobDone(listener: (job: BackgroundJob) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Detaches an already-running execa child process into the background registry. `stdout`/`stderr`
* seed the buffers with whatever was captured before the detach decision was made. */
export function registerBackgroundJob(command: string, cwd: string, child: ResultPromise, stdout: string, stderr: string): BackgroundJob {
const job: BackgroundJob = {
id: `bg-${nextId++}`,
command,
cwd,
status: "running",
stdout,
stderr,
exitCode: null,
signal: null,
startedAt: Date.now(),
};
jobs.set(job.id, job);
child.stdout?.on("data", (d: Buffer) => {
job.stdout = truncate(job.stdout + d.toString(), MAX_BUFFERED_CHARS);
});
child.stderr?.on("data", (d: Buffer) => {
job.stderr = truncate(job.stderr + d.toString(), MAX_BUFFERED_CHARS);
});
child.then(
(result) => {
job.status = "done";
// `reject: false` makes a signal-killed process resolve instead of reject, with
// exitCode: null — do not default that to 0, or a killed process reads as a clean success.
job.exitCode = result.exitCode ?? null;
job.signal = (result as { signal?: string | null }).signal ?? null;
job.finishedAt = Date.now();
for (const listener of listeners) listener(job);
},
(err) => {
job.status = "done";
job.exitCode = (err as { exitCode?: number | null }).exitCode ?? null;
job.signal = (err as { signal?: string | null }).signal ?? null;
job.finishedAt = Date.now();
for (const listener of listeners) listener(job);
},
);
return job;
}
export function listBackgroundJobs(): BackgroundJob[] {
return [...jobs.values()];
}
export function getBackgroundJob(id: string): BackgroundJob | undefined {
return jobs.get(id);
}
+58 -11
View File
@@ -1,6 +1,7 @@
import path from "node:path";
import { execa } from "execa";
import { z } from "zod";
import { registerBackgroundJob } from "./backgroundJobs.js";
import { truncate } from "../utils/truncate.js";
import type { ToolDef } from "./types.js";
@@ -10,6 +11,12 @@ const schema = z.object({
timeout_ms: z.number().int().min(1).max(300_000).optional().describe("Timeout in milliseconds (default 30000)."),
});
const BACKGROUND_POLL_MS = 150;
function delay(ms: number): Promise<"pending"> {
return new Promise((resolve) => setTimeout(() => resolve("pending"), ms));
}
export const bashTool: ToolDef<z.infer<typeof schema>> = {
name: "bash",
description: "Run a shell command and return its stdout, stderr, and exit code.",
@@ -18,17 +25,57 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
preview: async ({ command, cwd }) => `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 result = await execa(command, {
shell: true,
cwd: workDir,
timeout: timeout_ms ?? 30_000,
reject: false,
});
return {
exitCode: result.exitCode,
stdout: truncate(result.stdout ?? ""),
stderr: truncate(result.stderr ?? ""),
timedOut: result.timedOut ?? false,
// Timeout is enforced by our own timer rather than execa's built-in `timeout` option, so that
// backgrounding via Ctrl+B can cancel it below — execa's own timeout kills the process on a
// fixed schedule regardless of what happens to it afterward, which would silently kill a
// long-running command right after the user chose to keep it running in the background.
const child = execa(command, { shell: true, cwd: workDir, reject: false });
let stdout = "";
let stderr = "";
const onStdout = (d: Buffer) => {
stdout += d.toString();
};
const onStderr = (d: Buffer) => {
stderr += d.toString();
};
child.stdout?.on("data", onStdout);
child.stderr?.on("data", onStderr);
let timedOut = false;
let foregroundTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
timedOut = true;
child.kill();
}, timeout_ms ?? 30_000);
// Poll instead of a single `await child` so a mid-flight Ctrl+B (which flips
// ctx.backgroundControl.requested) can detach the process into the background registry
// without waiting for it to finish first.
for (;;) {
if (ctx.backgroundControl?.requested) {
clearTimeout(foregroundTimer);
// Detach our own capture listeners before handing the streams to the background registry —
// otherwise both this closure's listeners and registerBackgroundJob's keep appending to
// separate buffers forever, doubling the work and growing memory without bound for a
// long-running backgrounded job. The buffers captured so far seed the job.
child.stdout?.off("data", onStdout);
child.stderr?.off("data", onStderr);
const job = registerBackgroundJob(command, workDir, child, stdout, stderr);
return {
backgrounded: true,
jobId: job.id,
message: `Command moved to the background (job ${job.id}). Use bash_output with this jobId to check on it.`,
};
}
const settled = await Promise.race([child, delay(BACKGROUND_POLL_MS)]);
if (settled !== "pending") {
clearTimeout(foregroundTimer);
return {
exitCode: settled.exitCode,
stdout: truncate(stdout),
stderr: truncate(stderr),
timedOut,
};
}
}
},
};
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
import { getBackgroundJob } from "./backgroundJobs.js";
import { truncate } from "../utils/truncate.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
jobId: z.string().describe("The jobId returned by a bash call that was moved to the background."),
});
export const bashOutputTool: ToolDef<z.infer<typeof schema>> = {
name: "bash_output",
description: "Check the status and output-so-far of a bash command that was moved to the background.",
schema,
mutating: false,
handler: async ({ jobId }) => {
const job = getBackgroundJob(jobId);
if (!job) return { error: `No background job with id "${jobId}".` };
return {
status: job.status,
exitCode: job.exitCode,
signal: job.signal,
stdout: truncate(job.stdout),
stderr: truncate(job.stderr),
};
},
};
+14 -2
View File
@@ -1,5 +1,6 @@
import { createPatch } from "diff";
import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
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 type { ToolDef } from "./types.js";
@@ -58,7 +59,18 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
);
}
const updated = applyEdit(original, old_string, new_string, replace_all);
await fsWriteFile(resolved, updated, "utf-8");
// Write to a temp file in the same directory, then rename — rename is atomic within a single
// directory, so a crash mid-write can't leave the user's source file half-overwritten (the live
// file stays intact until the rename swaps in the full new content). Clean up the temp file if
// anything fails so a stray `.tmp` doesn't accumulate.
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
try {
await fsWriteFile(tmp, updated, "utf-8");
await fsRename(tmp, resolved);
} catch (err) {
await fsUnlink(tmp).catch(() => {});
throw err;
}
return { path: resolved, replacements: replace_all ? occurrences : 1 };
},
};
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import { gitCommitTool, gitStatusTool } from "./git.js";
const execa = await vi.hoisted(async () => {
return { execa: vi.fn() };
});
vi.mock("execa", async () => {
return { execa: execa.execa };
});
function mockGitResponse(stdout: string, stderr = "", exitCode = 0) {
execa.execa.mockResolvedValueOnce({ stdout, stderr, exitCode });
}
function lastGitArgs(): string[] {
return execa.execa.mock.calls[execa.execa.mock.calls.length - 1]![1] as string[];
}
describe("gitCommitTool", () => {
it("adds specific paths", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "add", paths: ["src/a.ts"] }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["add", "src/a.ts"]);
});
it("adds all changes when no paths are given", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "add" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["add", "-A"]);
});
it("commits with a message", async () => {
mockGitResponse("[main abc1234] msg");
const result = await gitCommitTool.handler({ operation: "commit", message: "msg" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["commit", "-m", "msg"]);
expect(result).toEqual({ output: "[main abc1234] msg" });
});
it("checks out a branch", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "checkout", branchName: "feature" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["checkout", "feature"]);
});
it("creates and checks out a branch", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "checkout", branchName: "feature", createIfMissing: true }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["checkout", "-b", "feature"]);
});
it("pushes with upstream", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "push", setUpstream: true }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["push", "-u", "origin", "HEAD"]);
});
it("resets hard to a ref", async () => {
mockGitResponse("");
const result = await gitCommitTool.handler({ operation: "reset", ref: "abc1234", mode: "hard" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["reset", "--hard", "abc1234"]);
expect(result).toEqual({ resetTo: "abc1234", mode: "hard" });
});
it("stashes with a message", async () => {
mockGitResponse("");
const result = await gitCommitTool.handler({ operation: "stash", message: "WIP" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["stash", "push", "-m", "WIP"]);
expect(result).toEqual({ stashed: "WIP" });
});
it("stashes specific paths", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "stash", paths: ["src/a.ts"] }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["stash", "push", "--", "src/a.ts"]);
});
it("pops a stash ref", async () => {
mockGitResponse("");
const result = await gitCommitTool.handler({ operation: "stash", stashRef: "stash@{1}" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["stash", "pop", "stash@{1}"]);
expect(result).toEqual({ popped: "stash@{1}" });
});
it("merges a branch with no-ff", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "merge", branchName: "feature", strategy: "no-ff" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["merge", "--no-ff", "feature"]);
});
it("rebases onto a branch", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "rebase", branchName: "main" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["rebase", "main"]);
});
it("deletes a branch", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "delete_branch", branchName: "old" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["branch", "-d", "old"]);
});
it("force-deletes a branch", async () => {
mockGitResponse("");
await gitCommitTool.handler({ operation: "delete_branch", branchName: "old", force: true }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["branch", "-D", "old"]);
});
});
describe("gitStatusTool", () => {
it("runs status", async () => {
mockGitResponse(" M src/a.ts");
const result = await gitStatusTool.handler({ operation: "status" }, { cwd: "/repo" });
expect(lastGitArgs()).toEqual(["status", "--short", "--branch"]);
expect(result).toEqual({ output: " M src/a.ts" });
});
});
+223 -75
View File
@@ -12,6 +12,10 @@ async function tryGit(args: string[], cwd: string): Promise<string> {
}
}
function runGit(args: string[], cwd: string) {
return execa("git", args, { cwd, reject: false });
}
// --- git_status: read-only inspection, auto-runs like grep/read_file ---
const statusSchema = z.object({
@@ -47,7 +51,7 @@ export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
}
})();
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
const result = await runGit(args, ctx.cwd);
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
}
@@ -57,94 +61,238 @@ export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
// --- git_commit: mutating operations, gated behind a confirmation prompt ---
// Ref-like arguments (branch names, refs, remote names) are passed to git as bare positional
// args with no `--` separator. A value starting with `-` would otherwise be parsed by git as a
// flag instead of a ref/branch (e.g. ref: "--hard" turning a plain reset into a hard reset) —
// reject it at the schema boundary so it can never reach execa's argv.
function refLike(description: string) {
return z
.string()
.refine((v) => !v.startsWith("-"), { message: 'must not start with "-" (would be parsed as a git flag, not a ref/branch name)' })
.describe(description)
.optional();
}
const commitSchema = z.object({
operation: z.enum(["add", "commit", "create_branch", "checkout", "push"]),
paths: z.array(z.string()).optional().describe("For `add`: paths to stage, relative to the working directory. Omit to stage all changes."),
message: z.string().optional().describe("Commit message. Required for `commit`."),
branchName: z.string().optional().describe("Branch name. Required for `create_branch`/`checkout`."),
operation: z.enum([
"add",
"commit",
"create_branch",
"checkout",
"push",
"reset",
"stash",
"merge",
"rebase",
"delete_branch",
]),
paths: z.array(z.string()).optional().describe("For `add`: paths to stage. For `stash push`: paths to stash. Omit to stage all changes."),
message: z.string().optional().describe("For `commit`: commit message. For `stash push`: stash message."),
branchName: refLike("Branch name. Required for create_branch/checkout/merge/rebase/delete_branch."),
createIfMissing: z.boolean().optional().describe("For `checkout`: create the branch if it doesn't exist yet (like `checkout -b`)."),
remote: z.string().optional().describe("For `push`: remote name (default \"origin\")."),
remote: refLike('For `push`: remote name (default "origin").'),
setUpstream: z.boolean().optional().describe("For `push`: set upstream tracking — needed the first time a new branch is pushed."),
ref: refLike("For `reset`: ref to reset to (default HEAD). For `merge`/`rebase`: alternative ref if branchName omitted."),
mode: z.enum(["soft", "mixed", "hard"]).optional().describe("For `reset`: reset mode (default mixed)."),
strategy: z.enum(["fast-forward", "no-ff", "ff-only"]).optional().describe("For `merge`: merge strategy."),
stashRef: refLike("For `stash pop`: stash reference (default stash@{0})."),
force: z.boolean().optional().describe("For `delete_branch`: force delete an unmerged branch."),
});
export const gitCommitTool: ToolDef<z.infer<typeof commitSchema>> = {
name: "git_commit",
description:
"Make changes to the git repository at the working directory: `add` (stage paths, or all changes if omitted), `commit` " +
"(requires `message`; commit the currently staged changes), `create_branch` (requires `branchName`; branch at current HEAD " +
"without switching), `checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), or " +
"`push` (push the current branch, optionally with setUpstream for a new branch's first push). Each use requires the user's " +
"confirmation, which shows the actual diff/status/commits affected.",
"(requires `message`; commit staged changes), `create_branch` (requires `branchName`; branch at current HEAD without switching), " +
"`checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), `push` (push current branch), " +
"`reset` (reset current branch; default mixed, default ref HEAD), `stash` (push or pop a stash), `merge` (merge branchName into " +
"current branch), `rebase` (rebase current branch onto branchName), or `delete_branch` (requires `branchName`; delete a local branch). " +
"Each mutating use requires the user's confirmation with a preview of the affected state.",
schema: commitSchema,
mutating: true,
preview: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
switch (operation) {
case "add": {
const target = paths?.length ? paths.join(", ") : "all changes";
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], ctx.cwd);
return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`;
}
case "commit": {
const staged = await tryGit(["diff", "--cached"], ctx.cwd);
if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first.";
return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`;
}
case "create_branch":
return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`;
case "checkout": {
const status = await tryGit(["status", "--short"], ctx.cwd);
const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`;
return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`;
}
case "push": {
const remoteName = remote ?? "origin";
const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], ctx.cwd)).trim() || "HEAD";
// `@{u}..` only resolves once upstream tracking exists — on a branch's first push
// (exactly when setUpstream is used) it doesn't, so fall back to recent commits on HEAD.
const ahead =
(await tryGit(["log", "--oneline", "@{u}.."], ctx.cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], ctx.cwd));
const upstreamNote = setUpstream ? " (setting upstream tracking)" : "";
return ahead
? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}`
: `Push "${branch}" to "${remoteName}"${upstreamNote}.`;
}
}
},
handler: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
const args: string[] = (() => {
switch (operation) {
case "add":
return ["add", ...(paths?.length ? paths : ["-A"])];
case "commit":
if (!message) throw new Error("`message` is required for the commit operation.");
return ["commit", "-m", message];
case "create_branch":
if (!branchName) throw new Error("`branchName` is required for the create_branch operation.");
return ["branch", branchName];
case "checkout":
if (!branchName) throw new Error("`branchName` is required for the checkout operation.");
return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName];
case "push":
return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"];
}
})();
preview: async (args, ctx) => buildPreview(args, ctx.cwd),
handler: async (args, ctx) => runMutatingOperation(args, ctx.cwd),
};
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
async function buildPreview(
{
operation,
paths,
message,
branchName,
createIfMissing,
remote,
setUpstream,
ref,
mode,
strategy,
stashRef,
force,
}: z.infer<typeof commitSchema>,
cwd: string,
): Promise<string> {
switch (operation) {
case "add": {
const target = paths?.length ? paths.join(", ") : "all changes";
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd);
return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`;
}
// `git push` writes its useful progress/summary output to stderr even on success.
const output = truncate(result.stdout || result.stderr);
case "commit": {
const staged = await tryGit(["diff", "--cached"], cwd);
if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first.";
return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`;
}
case "create_branch":
return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`;
case "checkout": {
const status = await tryGit(["status", "--short"], cwd);
const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`;
return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`;
}
case "push": {
const remoteName = remote ?? "origin";
const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
const ahead =
(await tryGit(["log", "--oneline", "@{u}.."], cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], cwd));
const upstreamNote = setUpstream ? " (setting upstream tracking)" : "";
return ahead
? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}`
: `Push "${branch}" to "${remoteName}"${upstreamNote}.`;
}
case "reset": {
const targetRef = ref ?? "HEAD";
const modeText = mode ?? "mixed";
const diff = modeText === "hard" ? await tryGit(["diff", "--stat", "HEAD"], cwd) : await tryGit(["diff", "--cached", "--stat"], cwd);
return `Reset (${modeText}) to ${targetRef}.${diff ? `\n\nFiles affected:\n${diff}` : ""}`;
}
case "stash": {
if (stashRef) {
const stash = await tryGit(["stash", "show", "-p", stashRef], cwd);
return stash ? `Pop stash ${stashRef}:\n\n${stash}` : `Pop stash ${stashRef}.`;
}
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd);
return status
? `Stash ${message ? `"${message}"` : "changes"}${paths?.length ? ` (paths: ${paths.join(", ")})` : ""}:\n\n${status}`
: "No local changes to stash.";
}
case "merge": {
const into = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
const target = branchName ?? ref;
const log = target ? await tryGit(["log", "--oneline", `${into}..${target}`], cwd) : "";
return target
? `Merge "${target}" into "${into}"${strategy ? ` (strategy: ${strategy})` : ""}.${log ? `\n\nCommits to merge:\n${log}` : ""}`
: "merge requires `branchName` or `ref`.";
}
case "rebase": {
const onto = branchName ?? ref;
const current = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
const log = onto ? await tryGit(["log", "--oneline", `${onto}..${current}`], cwd) : "";
return onto
? `Rebase "${current}" onto "${onto}".${log ? `\n\nCommits that will be replayed:\n${log}` : ""}`
: "rebase requires `branchName` or `ref`.";
}
case "delete_branch": {
const log = branchName ? await tryGit(["log", "--oneline", `${branchName}..HEAD`], cwd) : "";
const note = force ? " (force delete)" : "";
return branchName
? `Delete local branch "${branchName}"${note}.${log ? `\n\nCommits on ${branchName} not in HEAD:\n${log}` : ""}`
: "delete_branch requires `branchName`.";
}
}
}
async function runMutatingOperation(args: z.infer<typeof commitSchema>, cwd: string): Promise<unknown> {
const {
operation,
paths,
message,
branchName,
createIfMissing,
remote,
setUpstream,
ref,
mode,
strategy,
stashRef,
force,
} = args;
const gitArgs: string[] = (() => {
switch (operation) {
case "add":
return { staged: paths?.length ? paths : ["(all changes)"] };
case "create_branch":
return { created: branchName };
case "checkout":
return { switchedTo: branchName };
default:
return { output };
return ["add", ...(paths?.length ? paths : ["-A"])];
case "commit": {
if (!message) throw new Error("`message` is required for the commit operation.");
return ["commit", "-m", message];
}
case "create_branch": {
if (!branchName) throw new Error("`branchName` is required for the create_branch operation.");
return ["branch", branchName];
}
case "checkout": {
if (!branchName) throw new Error("`branchName` is required for the checkout operation.");
return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName];
}
case "push":
return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"];
case "reset": {
const targetRef = ref ?? "HEAD";
return ["reset", ...(mode ? [`--${mode}`] : []), targetRef];
}
case "stash": {
if (stashRef) {
return ["stash", "pop", stashRef];
}
const stashArgs = ["stash", "push"];
if (message) stashArgs.push("-m", message);
if (paths?.length) stashArgs.push("--", ...paths);
return stashArgs;
}
case "merge": {
const target = branchName ?? ref;
if (!target) throw new Error("`merge` requires `branchName` or `ref`.");
const mergeArgs = ["merge"];
if (strategy === "no-ff") mergeArgs.push("--no-ff");
if (strategy === "ff-only") mergeArgs.push("--ff-only");
mergeArgs.push(target);
return mergeArgs;
}
case "rebase": {
const onto = branchName ?? ref;
if (!onto) throw new Error("`rebase` requires `branchName` or `ref`.");
return ["rebase", onto];
}
case "delete_branch": {
if (!branchName) throw new Error("`branchName` is required for the delete_branch operation.");
return ["branch", force ? "-D" : "-d", branchName];
}
}
},
};
})();
const result = await runGit(gitArgs, cwd);
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
}
const output = truncate(result.stdout || result.stderr);
switch (operation) {
case "add":
return { staged: paths?.length ? paths : ["(all changes)"] };
case "create_branch":
return { created: branchName };
case "checkout":
return { switchedTo: branchName };
case "reset":
return { resetTo: ref ?? "HEAD", mode: mode ?? "mixed" };
case "stash":
return stashRef ? { popped: stashRef } : { stashed: message ?? "(no message)" };
case "merge":
return { merged: branchName ?? ref, output };
case "rebase":
return { rebasedOnto: branchName ?? ref, output };
case "delete_branch":
return { deleted: branchName };
default:
return { output };
}
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { rgPath } from "@vscode/ripgrep";
import { grepTool } from "./grep.js";
const execa = await vi.hoisted(async () => {
const { execa } = await import("execa");
return { execa: vi.fn() };
});
vi.mock("execa", async () => {
return { execa: execa.execa };
});
describe("grepTool", () => {
function lastArgs(): string[] {
return execa.execa.mock.calls[execa.execa.mock.calls.length - 1]![1] as string[];
}
it("uses -e and -- so a leading-dash pattern is not parsed as a flag", async () => {
execa.execa.mockResolvedValueOnce({ stdout: "" });
await grepTool.handler({ pattern: "-foo" }, { cwd: "/project" });
const args = lastArgs();
expect(args).toContain("-e");
expect(args[args.indexOf("-e") + 1]).toBe("-foo");
expect(args).toContain("--");
});
it("uses -e and -- for a double-dash pattern", async () => {
execa.execa.mockResolvedValueOnce({ stdout: "" });
await grepTool.handler({ pattern: "--foo" }, { cwd: "/project" });
const args = lastArgs();
expect(args).toContain("-e");
expect(args[args.indexOf("-e") + 1]).toBe("--foo");
expect(args).toContain("--");
});
it("returns matches and truncation info", async () => {
execa.execa.mockResolvedValueOnce({ stdout: "1\talpha\n2\tbeta\n3\tgamma\n" });
const result = await grepTool.handler({ pattern: "a", max_results: 2 }, { cwd: "/project" });
expect(result).toEqual({ matches: ["1\talpha", "2\tbeta"], truncated: true });
});
it("interprets ripgrep exit code 1 as no matches", async () => {
execa.execa.mockRejectedValueOnce({ exitCode: 1 });
const result = await grepTool.handler({ pattern: "nomatch" }, { cwd: "/project" });
expect(result).toEqual({ matches: [], truncated: false });
});
it("rethrows non-1 exit codes", async () => {
execa.execa.mockRejectedValueOnce({ exitCode: 2 });
await expect(grepTool.handler({ pattern: "x" }, { cwd: "/project" })).rejects.toEqual({ exitCode: 2 });
});
});
+3 -1
View File
@@ -21,7 +21,9 @@ export const grepTool: ToolDef<z.infer<typeof schema>> = {
const args = ["--line-number", "--no-heading", "--color", "never"];
if (case_insensitive) args.push("--ignore-case");
if (glob) args.push("--glob", glob);
args.push(pattern);
// Use -e for the pattern and -- before the path so a pattern beginning with "-"
// (or "--") is never parsed as a ripgrep flag.
args.push("-e", pattern, "--");
args.push(searchPath ? path.resolve(ctx.cwd, searchPath) : ctx.cwd);
try {
+2
View File
@@ -1,5 +1,6 @@
import { agentTool } from "./agentTool.js";
import { bashTool } from "./bash.js";
import { bashOutputTool } from "./bashOutput.js";
import { editFileTool } from "./editFile.js";
import { gitCommitTool, gitStatusTool } from "./git.js";
import { grepTool } from "./grep.js";
@@ -20,6 +21,7 @@ export const TOOLS: ToolDef[] = [
writeFileTool,
editFileTool,
bashTool,
bashOutputTool,
gitCommitTool,
agentTool,
];
+20 -4
View File
@@ -1,23 +1,39 @@
import { readFile as fsReadFile } from "node:fs/promises";
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { truncate } from "../utils/truncate.js";
import { imageMimeType, MAX_IMAGE_BYTES } from "../utils/image.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
path: z.string().describe("File path to read, relative to the working directory or absolute."),
offset: z.number().int().min(1).optional().describe("1-indexed line number to start reading from."),
limit: z.number().int().min(1).max(2000).optional().describe("Maximum number of lines to read."),
offset: z.number().int().min(1).optional().describe("1-indexed line number to start reading from (text files only)."),
limit: z.number().int().min(1).max(2000).optional().describe("Maximum number of lines to read (text files only)."),
});
export const readFileTool: ToolDef<z.infer<typeof schema>> = {
name: "read_file",
description:
"Read a text file from the local filesystem, optionally a specific line range. Returns content with 1-indexed line numbers.",
"Read a file from the local filesystem. Text files return content with 1-indexed line numbers, optionally a specific " +
"line range. Image files (png, jpg, jpeg, gif, webp, bmp) are returned as image content for the model to see directly " +
"— this requires a vision-capable model/backend; others may error on the request.",
schema,
mutating: false,
handler: async ({ path: filePath, offset, limit }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
const mimeType = imageMimeType(resolved);
if (mimeType) {
const stats = await fsStat(resolved);
if (stats.size > MAX_IMAGE_BYTES) {
throw new Error(
`${filePath} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1_048_576}MB limit for image reads.`,
);
}
const buffer = await fsReadFile(resolved);
return { path: resolved, image: true, mimeType, bytes: buffer.byteLength, base64: buffer.toString("base64") };
}
const content = await fsReadFile(resolved, "utf-8");
const lines = content.split("\n");
const start = offset ? offset - 1 : 0;
+15 -1
View File
@@ -7,10 +7,24 @@ export interface SubAgentTask {
prompt: string;
}
export interface SubAgentOverrides {
/** Replaces locode's generic sub-agent system prompt entirely — used by plugin-defined agents
* (agents/*.md) that ship their own identity/instructions instead of the generic "delegate a
* task" framing. */
systemPrompt?: string;
/** Restricts the sub-agent's toolset to tools with these names (unknown names are silently
* ignored); omit to inherit the parent's full toolset minus `agent`/plugin-agent tools. */
toolNames?: string[];
}
export interface ToolContext {
cwd: string;
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */
runSubAgent?: (task: SubAgentTask) => Promise<string>;
runSubAgent?: (task: SubAgentTask, overrides?: SubAgentOverrides) => Promise<string>;
/** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool
* polls `requested` and, once true, detaches into the background job registry instead of
* awaiting completion. Absent for tools that don't support backgrounding. */
backgroundControl?: { requested: boolean };
}
export interface ToolDef<T = any> {
+349 -93
View File
@@ -1,6 +1,15 @@
import { Box, Static, useApp } from "ink";
import { Box, Static, useApp, useInput } from "ink";
import { useCallback, useEffect, useRef, useState } from "react";
import { AgentError, compactSession, contextUsageRatio, runTurn, shouldAutoCompact } from "../../agent/loop.js";
import {
AgentError,
compactSession,
contextUsageRatio,
fireSessionStartHook,
fireUserPromptSubmitHook,
runTurn,
shouldAutoCompact,
type ChatCompletionUserContent,
} from "../../agent/loop.js";
import {
createSession,
createSessionFromRecord,
@@ -10,14 +19,23 @@ import {
type Session,
} from "../../agent/session.js";
import { makeClient } from "../../backend/client.js";
import { runHooksForEvent } from "../../hooks/runner.js";
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
import { setCachedMode } from "../../backend/capabilityCache.js";
import { resolveContextWindow } from "../../backend/contextWindow.js";
import { resolveToolCallMode } from "../../backend/resolveMode.js";
import { resolveMaxIterations } from "../../config/config.js";
import { resolveAutoCompactThreshold, resolveMaxIterations } from "../../config/config.js";
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
import { getMcpStatuses } 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";
import { expandCommandTemplate } from "../../plugins/expandTemplate.js";
import { getLoadedPlugins, getPluginCommandCollisions } from "../../plugins/registry.js";
import { getGitInfo, type GitInfo } from "../../utils/gitInfo.js";
import { findSkillCollisions } from "../../plugins/skillTool.js";
import { buildImportContent } from "../../utils/importFile.js";
import { extractMentionedFiles } from "../../utils/mentions.js";
import {
deriveTitle,
listSessions,
@@ -26,9 +44,11 @@ import {
type SessionRecord,
type SessionSummary,
} from "../../persistence/sessionStore.js";
import { onBackgroundJobDone } from "../../tools/backgroundJobs.js";
import { TOOLS } from "../../tools/index.js";
import type { ToolDef } from "../../tools/types.js";
import { ChatInput } from "./ChatInput.js";
import { ExportPrompt } from "./ExportPrompt.js";
import { HistoryItemView } from "./HistoryItemView.js";
import { ModelSelect } from "./ModelSelect.js";
import { PermissionPrompt } from "./PermissionPrompt.js";
@@ -45,7 +65,7 @@ export interface AppProps {
suggestedModel?: string;
resumeSessionId?: string;
interactiveResume?: boolean;
mcpToolsPromise: Promise<ToolDef[]>;
extraToolsPromise: Promise<ToolDef[]>;
}
interface PendingPermission {
@@ -65,7 +85,7 @@ export function App({
suggestedModel,
resumeSessionId,
interactiveResume,
mcpToolsPromise,
extraToolsPromise,
}: AppProps) {
const { exit } = useApp();
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
@@ -74,14 +94,24 @@ export function App({
);
const [inputValue, setInputValue] = useState("");
const [permission, setPermission] = useState<PendingPermission | null>(null);
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string } | null>(null);
const [streamingText, setStreamingText] = useState<string | null>(null);
const [isThinking, setIsThinking] = useState(false);
const [permMode, setPermMode] = useState<PermissionMode>("default");
const [modelList, setModelList] = useState<string[]>([]);
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
const baseURLRef = useRef(initialBaseURL);
const sessionRef = useRef<Session | null>(null);
// Static history items can't be retroactively expanded once printed (Ink's <Static> is
// append-only), so Ctrl+O doesn't edit the collapsed compact notice in place — it prints a new
// item with the full text on demand. This just remembers the most recent one to print.
const lastCompactSummaryRef = useRef<string | null>(null);
// Tracks whether the tool call currently in flight is `bash` (the only backgroundable tool),
// purely so the UI can show a "ctrl+b to background" hint — the actual gate lives on
// session.activeBackground, set by gateAndRun in agent/loop.ts.
const [runningToolIsBash, setRunningToolIsBash] = useState(false);
// Throttle streaming text updates to ~30fps to avoid excessive re-renders
const streamingAccumulatorRef = useRef("");
@@ -102,13 +132,53 @@ export function App({
const persistCurrentSession = useCallback(() => {
const session = sessionRef.current;
if (!session) return;
try {
saveSession(toSessionRecord(session, baseURLRef.current));
} catch {
// Best-effort — a failed save shouldn't crash the UI.
}
// saveSession is async and serialized per-id (see sessionStore.ts); fire-and-forget here, with
// flushPendingSaves() on exit guaranteeing the last turn isn't lost. A failed save shouldn't
// crash the UI.
void saveSession(toSessionRecord(session, baseURLRef.current)).catch(() => {});
}, []);
const refreshGitInfo = useCallback(() => {
getGitInfo(cwd).then(setGitInfo).catch(() => setGitInfo(null));
}, [cwd]);
// Fetch once up front; re-fetched after each turn (see submitTurn) since a tool call (git_commit,
// bash) can switch branches or change the dirty state mid-session.
useEffect(() => {
refreshGitInfo();
}, [refreshGitInfo]);
// A backgrounded bash job (see Ctrl+B below) can finish long after the turn that started it has
// ended — this is how its completion still reaches the transcript.
useEffect(() => {
return onBackgroundJobDone((job) => {
const outcome = job.exitCode === 0 ? "finished" : job.signal ? `was killed (${job.signal})` : `finished (exit ${job.exitCode})`;
push({ kind: "notice", text: `Background job ${job.id} ${outcome}: ${job.command}`, isError: job.exitCode !== 0 });
});
}, [push]);
// 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) => {
if (key.ctrl && input === "o") {
const summary = lastCompactSummaryRef.current;
push({
kind: "notice",
text: summary ? `Full compaction summary:\n\n${summary}` : "No compaction summary available yet.",
});
return;
}
if (key.ctrl && input === "b") {
const control = sessionRef.current?.activeBackground;
if (control) {
control.requested = true;
push({ kind: "notice", text: "Backgrounding the running command..." });
} else {
push({ kind: "notice", text: "Nothing backgroundable is running right now." });
}
}
});
const fetchModelsForPicker = useCallback(() => {
const client = makeClient({ baseURL: baseURLRef.current, model: "" });
client.models
@@ -141,19 +211,23 @@ export function App({
new Promise<PermissionDecision>((resolve) => {
setPermission({ ...opts, resolve });
});
const [mcpTools, contextWindow] = await Promise.all([mcpToolsPromise, resolveContextWindow(baseURLRef.current, model)]);
const [extraTools, contextWindow] = await Promise.all([extraToolsPromise, resolveContextWindow(baseURLRef.current, model)]);
sessionRef.current = createSession(
client,
model,
cwd,
confirmFn,
mode,
[...TOOLS, ...mcpTools],
[...TOOLS, ...extraTools],
contextWindow.value,
contextWindow.isEstimate,
resolveMaxIterations(),
resolveAutoCompactThreshold(),
);
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
for (const warning of await fireSessionStartHook(sessionRef.current)) {
push({ kind: "notice", text: `Hook warning: ${warning}` });
}
setPhase("input");
} catch (err) {
push({
@@ -168,7 +242,7 @@ export function App({
}
}
},
[cwd, toolModeOverride, modelList, exit, push, mcpToolsPromise],
[cwd, toolModeOverride, modelList, exit, push, extraToolsPromise],
);
const initSessionFromRecord = useCallback(
@@ -181,8 +255,8 @@ export function App({
new Promise<PermissionDecision>((resolve) => {
setPermission({ ...opts, resolve });
});
const [mcpTools, contextWindow] = await Promise.all([
mcpToolsPromise,
const [extraTools, contextWindow] = await Promise.all([
extraToolsPromise,
resolveContextWindow(record.baseURL, record.model),
]);
sessionRef.current = createSessionFromRecord(
@@ -190,10 +264,11 @@ export function App({
record,
cwd,
confirmFn,
[...TOOLS, ...mcpTools],
[...TOOLS, ...extraTools],
contextWindow.value,
contextWindow.isEstimate,
resolveMaxIterations(),
resolveAutoCompactThreshold(),
);
push({
kind: "banner",
@@ -202,6 +277,9 @@ export function App({
backend: record.baseURL,
resumedTitle: deriveTitle(record.messages),
});
for (const warning of await fireSessionStartHook(sessionRef.current)) {
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.
@@ -221,7 +299,7 @@ export function App({
fetchModelsForPicker();
}
},
[cwd, push, fetchModelsForPicker, mcpToolsPromise],
[cwd, push, fetchModelsForPicker, extraToolsPromise],
);
// Decide the startup path once on mount: resume a specific session, show a resume
@@ -293,6 +371,9 @@ export function App({
session.contextWindowIsEstimate = newContextWindow.isEstimate;
push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` });
persistCurrentSession();
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "model", previous: previousModel, value: name }).catch(
() => {},
);
} catch (err) {
session.model = previousModel;
push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true });
@@ -322,6 +403,9 @@ export function App({
text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`,
});
persistCurrentSession();
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "backend", previous: previousBaseURL, value: baseURLRef.current }).catch(
() => {},
);
} catch (err) {
baseURLRef.current = previousBaseURL;
session.client = previousClient;
@@ -331,6 +415,97 @@ export function App({
}
}
async function submitTurn(session: Session, input: string | ChatCompletionUserContent) {
const rollbackLength = session.messages.length;
setIsThinking(true);
setStreamingText(null);
streamingAccumulatorRef.current = "";
try {
const text = await runTurn(session, input, (event) => {
if (event.type === "text_delta") {
setIsThinking(false);
streamingAccumulatorRef.current += event.delta;
// Throttle renders to ~30fps
const now = Date.now();
if (now - lastStreamRenderRef.current > 33) {
lastStreamRenderRef.current = now;
setStreamingText(streamingAccumulatorRef.current);
} else {
if (streamRafRef.current === null) {
streamRafRef.current = setTimeout(flushStreamingText, 33) as any;
}
}
} else if (event.type === "text_done") {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
setStaticItems((prev) => [...prev, { id: nextId(), kind: "assistant", text: event.fullText } as HistoryItem]);
} else if (event.type === "stream_discard") {
// The turn is being retried non-streaming after a partially-streamed malformed tool call —
// drop the partial text without committing it as an assistant message (it was never added to
// session.messages, and the retry's output replaces it).
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
} else if (event.type === "tool_call") {
setStreamingText(null);
setIsThinking(true);
setRunningToolIsBash(event.label.startsWith("Bash("));
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_call", label: event.label } as HistoryItem]);
} else if (event.type === "tool_result") {
setRunningToolIsBash(false);
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
} else if (event.type === "hook_notice") {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "notice", text: event.text, isError: event.isError } as HistoryItem]);
}
});
// text_done already added the assistant message to staticItems
// No fallback needed — the streaming loop always emits text_done
void text;
persistCurrentSession();
refreshGitInfo();
if (shouldAutoCompact(session)) {
const percentBefore = Math.round(contextUsageRatio(session) * 100);
setIsThinking(true);
try {
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({
kind: "notice",
text: `Context was getting full (${percentBefore}%) — auto-compacted the conversation. (ctrl+o to see full summary)`,
});
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Auto-compact failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
} catch (err) {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
session.messages.length = rollbackLength;
const reason = err instanceof AgentError ? err.message : (err as Error).message;
push({ kind: "notice", text: `Request failed: ${reason}`, isError: true });
} finally {
setIsThinking(false);
setStreamingText(null);
streamingAccumulatorRef.current = "";
}
}
async function handleSubmit(raw: string) {
setInputValue("");
const trimmed = raw.trim();
@@ -370,11 +545,32 @@ export function App({
});
return;
}
if (trimmed === "/dashboard" || trimmed === "/stats") {
push({
kind: "dashboard",
model: session.model,
baseURL: baseURLRef.current,
sessionId: session.id,
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
turns: session.stats.turns,
apiCalls: session.stats.apiCalls,
toolCalls: session.stats.toolCalls,
inputTokens: session.stats.inputTokens,
outputTokens: session.stats.outputTokens,
modelTimeMs: session.stats.modelTimeMs,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
});
return;
}
if (trimmed === "/compact") {
setIsThinking(true);
try {
await compactSession(session);
push({ kind: "notice", text: "Conversation compacted to save context." });
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
@@ -383,6 +579,28 @@ export function App({
}
return;
}
if (trimmed.startsWith("/export")) {
const arg = trimmed.slice("/export".length).trim();
setExportPrompt({ defaultName: arg || defaultExportFilename() });
return;
}
if (trimmed.startsWith("/import")) {
const rest = trimmed.slice("/import".length).trim();
if (!rest) {
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
return;
}
const spaceIdx = rest.indexOf(" ");
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
try {
const content = await buildImportContent(cwd, filePathArg, caption);
await submitTurn(session, content);
} catch (err) {
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
}
return;
}
if (trimmed === "/tools") {
push({ kind: "tools", tools: session.toolset.tools });
return;
@@ -399,6 +617,19 @@ export function App({
push({ kind: "mcp", statuses: getMcpStatuses() });
return;
}
if (trimmed === "/plugins") {
push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() });
return;
}
if (trimmed === "/hooks") {
push({ kind: "hooks", config: loadMergedHooks(cwd) });
return;
}
if (trimmed === "/skills") {
const skills = getLoadedPlugins().flatMap((p) => p.skills);
push({ kind: "skills", skills, collisions: findSkillCollisions(skills) });
return;
}
if (trimmed.startsWith("/model")) {
const name = trimmed.slice("/model".length).trim();
if (!name) {
@@ -452,86 +683,82 @@ export function App({
} else if (name !== "native" && name !== "fallback") {
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
} else {
const previousMode = session.mode;
setMode(session, name);
setCachedMode(baseURLRef.current, session.model, name);
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
persistCurrentSession();
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "toolMode", previous: previousMode, value: name }).catch(
() => {},
);
}
return;
}
const rollbackLength = session.messages.length;
setIsThinking(true);
setStreamingText(null);
streamingAccumulatorRef.current = "";
try {
const text = await runTurn(session, trimmed, (event) => {
if (event.type === "text_delta") {
setIsThinking(false);
streamingAccumulatorRef.current += event.delta;
// Throttle renders to ~30fps
const now = Date.now();
if (now - lastStreamRenderRef.current > 33) {
lastStreamRenderRef.current = now;
setStreamingText(streamingAccumulatorRef.current);
} else {
if (streamRafRef.current === null) {
streamRafRef.current = setTimeout(flushStreamingText, 33) as any;
}
}
} else if (event.type === "text_done") {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
setStaticItems((prev) => [...prev, { id: nextId(), kind: "assistant", text: event.fullText } as HistoryItem]);
} else if (event.type === "tool_call") {
setStreamingText(null);
setIsThinking(true);
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_call", label: event.label } as HistoryItem]);
} else if (event.type === "tool_result") {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
}
});
// text_done already added the assistant message to staticItems
// No fallback needed — the streaming loop always emits text_done
void text;
persistCurrentSession();
if (shouldAutoCompact(session)) {
const percentBefore = Math.round(contextUsageRatio(session) * 100);
setIsThinking(true);
try {
await compactSession(session);
push({
kind: "notice",
text: `Context was getting full (${percentBefore}%) — auto-compacted the conversation.`,
});
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Auto-compact failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
} catch (err) {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
session.messages.length = rollbackLength;
const reason = err instanceof AgentError ? err.message : (err as Error).message;
push({ kind: "notice", text: `Request failed: ${reason}`, isError: true });
} finally {
setIsThinking(false);
setStreamingText(null);
streamingAccumulatorRef.current = "";
// UserPromptSubmit hooks see the raw text before plugin-command expansion or @mention
// resolution — a hook can block the message outright, or inject extra context (appended
// below wherever the message actually ends up going).
const promptHook = await fireUserPromptSubmitHook(session, trimmed);
for (const warning of promptHook.warnings) {
push({ kind: "notice", text: `Hook warning: ${warning}` });
}
if (promptHook.blocked) {
push({ kind: "notice", text: `Blocked by hook: ${promptHook.reason}`, isError: true });
return;
}
const extraContextParts: string[] = [];
if (promptHook.additionalContext) extraContextParts.push(promptHook.additionalContext);
if (promptHook.jsonContext?.length) {
extraContextParts.push(`Hook context (JSON):\n${promptHook.jsonContext.map((j) => JSON.stringify(j)).join("\n")}`);
}
const extraContext = extraContextParts.join("\n\n");
// Plugin-provided slash commands (commands/*.md), then skills (skills/*/SKILL.md) — checked
// only after every built-in above has had a chance to match, so a plugin can never shadow a
// built-in command name; commands win over a same-named skill (the model can still always
// reach a skill itself via the `skill` tool regardless of this ordering).
if (trimmed.startsWith("/")) {
const spaceIdx = trimmed.indexOf(" ");
const cmdName = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).slice(1);
const argsText = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
const plugins = getLoadedPlugins();
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);
return;
}
const pluginSkill = plugins.flatMap((p) => p.skills).find((s) => s.name === cmdName);
if (pluginSkill) {
// Real skills come in two flavors (confirmed against Anthropic's own official example
// plugin): "user-invoked" ones written just like a command, with $ARGUMENTS/$1../$9
// placeholders — those need actual template expansion, not just an appended trailer — and
// purely contextual ones with no placeholder at all, where appending the user's text is
// the only sensible thing to do with it.
const hasPlaceholder = /\$ARGUMENTS|\$[1-9]/.test(pluginSkill.content);
const expanded = hasPlaceholder ? expandCommandTemplate(pluginSkill.content, argsText) : pluginSkill.content;
const body = !hasPlaceholder && argsText ? `${expanded}\n\n---\n\nUser's request: ${argsText}` : expanded;
await submitTurn(session, extraContext ? `${body}\n\n${extraContext}` : body);
return;
}
}
const mentionedFiles = extractMentionedFiles(trimmed, cwd);
if (mentionedFiles.length > 0) {
try {
const attachments = await Promise.all(mentionedFiles.map((f) => buildImportContent(cwd, f, "")));
const parts = [{ type: "text" as const, text: trimmed }, ...attachments.flat()];
if (extraContext) parts.push({ type: "text" as const, text: extraContext });
await submitTurn(session, parts);
} catch (err) {
push({ kind: "notice", text: `Failed to load @mention: ${(err as Error).message}`, isError: true });
}
return;
}
await submitTurn(session, extraContext ? `${trimmed}\n\n${extraContext}` : trimmed);
}
function handlePermissionSelect(decision: PermissionDecision) {
@@ -541,6 +768,28 @@ export function App({
pending?.resolve(decision);
}
async function handleExportSubmit(filename: string) {
setExportPrompt(null);
const session = sessionRef.current;
if (!session) return;
const trimmedName = filename.trim();
if (!trimmedName) {
push({ kind: "notice", text: "Export cancelled (empty filename)." });
return;
}
try {
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName);
push({ kind: "notice", text: `Exported conversation to ${resolved}` });
} catch (err) {
push({ kind: "notice", text: `Export failed: ${(err as Error).message}`, isError: true });
}
}
function handleExportCancel() {
setExportPrompt(null);
push({ kind: "notice", text: "Export cancelled." });
}
function cyclePermMode() {
const session = sessionRef.current;
if (!session) return;
@@ -566,8 +815,8 @@ export function App({
{streamingText !== null && (
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} />
)}
{isThinking && streamingText === null && !permission && (
<ThinkingIndicator />
{isThinking && streamingText === null && !permission && !exportPrompt && (
<ThinkingIndicator label={runningToolIsBash ? "thinking... (ctrl+b to background)" : undefined} />
)}
{permission ? (
@@ -577,6 +826,8 @@ export function App({
preview={permission.preview}
onSelect={handlePermissionSelect}
/>
) : exportPrompt ? (
<ExportPrompt defaultName={exportPrompt.defaultName} onSubmit={handleExportSubmit} onCancel={handleExportCancel} />
) : phase === "starting" ? (
<ThinkingIndicator label="starting..." />
) : phase === "connecting" ? (
@@ -588,7 +839,7 @@ export function App({
) : phase === "model-select" ? (
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
) : (
<ChatInput value={inputValue} onChange={setInputValue} onSubmit={handleSubmit} onCyclePermMode={cyclePermMode} />
<ChatInput value={inputValue} onChange={setInputValue} onSubmit={handleSubmit} onCyclePermMode={cyclePermMode} cwd={cwd} />
)}
{sessionRef.current && phase === "input" && (
<StatusBar
@@ -599,6 +850,11 @@ export function App({
contextTokens={sessionRef.current.lastContextTokens}
contextWindow={sessionRef.current.contextWindow}
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
sessionId={sessionRef.current.id}
createdAt={sessionRef.current.createdAt}
inputTokens={sessionRef.current.stats.inputTokens}
outputTokens={sessionRef.current.stats.outputTokens}
gitInfo={gitInfo}
/>
)}
</Box>
+130 -10
View File
@@ -1,26 +1,146 @@
import { Box, Text, useInput } from "ink";
import TextInput from "ink-text-input";
import fg from "fast-glob";
import { useEffect, useState } from "react";
import { ACCENT_HEX } from "../theme.js";
import { getActiveMention } from "../../utils/mentions.js";
interface Props {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
onCyclePermMode?: () => void;
cwd: string;
}
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode }: Props) {
useInput((_input: string, key: { shift?: boolean; tab?: boolean; backTab?: boolean }) => {
// Shift+Tab: cycle permission mode
if ((key.shift && key.tab) || key.backTab) {
onCyclePermMode?.();
const MAX_MATCHES = 50;
const VISIBLE_SUGGESTIONS = 8;
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd }: Props) {
const [allFiles, setAllFiles] = useState<string[] | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
// ink-text-input tracks its cursor position internally and has no way to be told the value
// changed externally — without this, accepting a suggestion leaves the cursor at its old
// (now-wrong) offset, so further typing lands mid-string instead of at the end. Bumping this
// key remounts TextInput, which resets its cursor to the end of the new value.
const [inputKey, setInputKey] = useState(0);
const mention = getActiveMention(value);
// Glob the project's files lazily — only once a "@" is actually typed — and cache the result
// for the rest of the session rather than re-scanning on every keystroke.
useEffect(() => {
if (!mention || allFiles !== null) return;
let cancelled = false;
fg("**/*", { cwd, dot: false, onlyFiles: true, absolute: false, ignore: ["node_modules/**", ".git/**", "dist/**"] })
.then((files) => {
if (!cancelled) setAllFiles(files);
})
.catch(() => {
if (!cancelled) setAllFiles([]);
});
return () => {
cancelled = true;
};
}, [mention !== null, allFiles, cwd]);
// The full match set (capped at MAX_MATCHES for sanity) — separate from what's actually
// rendered, since only a VISIBLE_SUGGESTIONS-tall window of it is shown at once (see `visible`).
const matches =
mention && allFiles
? allFiles
.filter((f) => f.toLowerCase().includes(mention.query.toLowerCase()))
.sort((a, b) => a.length - b.length)
.slice(0, MAX_MATCHES)
: [];
useEffect(() => {
setSelectedIndex(0);
}, [mention?.query]);
// Keeps the selection centered in the visible window where possible, clamped so the window
// never scrolls past either end of the match list.
const windowStart = Math.max(
0,
Math.min(selectedIndex - Math.floor(VISIBLE_SUGGESTIONS / 2), Math.max(0, matches.length - VISIBLE_SUGGESTIONS)),
);
const visible = matches.slice(windowStart, windowStart + VISIBLE_SUGGESTIONS);
const hiddenAbove = windowStart;
const hiddenBelow = matches.length - (windowStart + visible.length);
function acceptSuggestion(file: string) {
if (!mention) return;
const before = value.slice(0, mention.start);
const after = value.slice(mention.start + 1 + mention.query.length);
onChange(`${before}@${file} ${after}`);
setInputKey((k) => k + 1);
}
// Drops the "@query" text (but keeps whatever was typed before the "@"), so a cancelled mention
// doesn't leave stray "@..." text sitting in the input — without this, anything typed afterward
// (e.g. a slash command) gets appended after it and never matches, silently doing nothing.
function cancelMention() {
if (!mention) return;
onChange(value.slice(0, mention.start));
setInputKey((k) => k + 1);
}
useInput(
(_input: string, key: { shift?: boolean; tab?: boolean; backTab?: boolean; upArrow?: boolean; downArrow?: boolean; escape?: boolean }) => {
// Shift+Tab: cycle permission mode (takes priority even while suggestions are open).
if ((key.shift && key.tab) || key.backTab) {
onCyclePermMode?.();
return;
}
if (key.escape) {
cancelMention();
return;
}
if (matches.length === 0) return;
if (key.downArrow) {
setSelectedIndex((i) => Math.min(i + 1, matches.length - 1));
} else if (key.upArrow) {
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (key.tab) {
acceptSuggestion(matches[selectedIndex] ?? matches[0]!);
}
},
);
function handleSubmit(raw: string) {
// Enter while the picker is open accepts the highlighted file instead of sending the message.
if (matches.length > 0) {
acceptSuggestion(matches[selectedIndex] ?? matches[0]!);
return;
}
});
onSubmit(raw);
}
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
<Text color={ACCENT_HEX}>{"> "}</Text>
<TextInput value={value} onChange={onChange} onSubmit={onSubmit} />
<Box flexDirection="column" width="100%">
{visible.length > 0 && (
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
{hiddenAbove > 0 && <Text dimColor>↑ {hiddenAbove} more</Text>}
{visible.map((f, i) => {
const actualIndex = windowStart + i;
return (
<Text key={f} color={actualIndex === selectedIndex ? ACCENT_HEX : undefined} bold={actualIndex === selectedIndex}>
{actualIndex === selectedIndex ? "❯ " : " "}
{f}
</Text>
);
})}
{hiddenBelow > 0 && <Text dimColor>↓ {hiddenBelow} more</Text>}
<Text dimColor>
↑↓ to navigate · Tab to select
{matches.length > VISIBLE_SUGGESTIONS ? ` · ${selectedIndex + 1}/${matches.length}` : ""}
</Text>
</Box>
)}
<Box borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
<Text color={ACCENT_HEX}>{"> "}</Text>
<TextInput key={inputKey} value={value} onChange={onChange} onSubmit={handleSubmit} />
</Box>
</Box>
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Box, Text, useInput } from "ink";
import TextInput from "ink-text-input";
import { useState } from "react";
import { ACCENT_HEX } from "../theme.js";
interface Props {
defaultName: string;
onSubmit: (filename: string) => void;
onCancel: () => void;
}
export function ExportPrompt({ defaultName, onSubmit, onCancel }: Props) {
const [value, setValue] = useState(defaultName);
useInput((_input, key) => {
if (key.escape) onCancel();
});
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
<Text bold>Export conversation</Text>
<Text> </Text>
<Text dimColor>Filename — edit as needed (Enter to save, Esc to cancel):</Text>
<Box>
<Text color={ACCENT_HEX}>{"> "}</Text>
<TextInput value={value} onChange={setValue} onSubmit={onSubmit} />
</Box>
</Box>
);
}
+174 -6
View File
@@ -10,19 +10,37 @@ const HELP_LINES = [
" /mode <name> view or force tool-call mode (native | fallback)",
" /perm [mode] cycle or set permission mode (default | auto-edit | auto-accept)",
" /status show current model, backend, tool-call mode, and cwd",
" /dashboard show session stats: token I/O, elapsed/model time, turns, tool calls",
" /tools list available tools",
" /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",
" /plugins show installed Claude Code-compatible plugins (commands, agents, MCP servers)",
" /hooks show configured hooks per lifecycle event",
" /skills show installed skills; /<skill-name> [request] invokes one directly",
" /compact summarize the conversation now to free up context",
" /export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)",
" /import <path> [caption] attach a local file or image to your next message",
" /clear clear conversation history",
" /help show this help",
" /exit, /quit exit",
"",
"Keyboard shortcuts:",
" Shift+Tab cycle permission mode",
" Ctrl+O print the full text of the last /compact summary",
" Ctrl+B background the currently-running bash command",
];
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function HistoryItemView({ item }: { item: HistoryItem }) {
switch (item.kind) {
case "banner":
@@ -94,6 +112,57 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
</Box>
);
case "dashboard": {
const totalTokens = item.inputTokens + item.outputTokens;
const contextPercent = Math.round((item.contextTokens / item.contextWindow) * 100);
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
<Text bold>Dashboard</Text>
<Text> </Text>
<Text>
<Text dimColor>session: </Text>
{item.sessionId}
</Text>
<Text>
<Text dimColor>model: </Text>
{item.model} <Text dimColor>({item.baseURL})</Text>
</Text>
<Text>
<Text dimColor>elapsed: </Text>
{formatDuration(item.elapsedMs)}
</Text>
<Text> </Text>
<Text>
<Text dimColor>turns: </Text>
{item.turns}
</Text>
<Text>
<Text dimColor>api calls: </Text>
{item.apiCalls} <Text dimColor>(model time {formatDuration(item.modelTimeMs)})</Text>
</Text>
<Text>
<Text dimColor>tool calls: </Text>
{item.toolCalls}
</Text>
<Text> </Text>
<Text>
<Text dimColor>tokens: </Text>
<Text color="green">↑ {item.inputTokens.toLocaleString()}</Text>
{" "}
<Text color="cyan">↓ {item.outputTokens.toLocaleString()}</Text>
{" "}
<Text dimColor>(total {totalTokens.toLocaleString()})</Text>
</Text>
<Text>
<Text dimColor>context: </Text>
{item.contextTokens.toLocaleString()}
{item.contextTokensIsEstimate ? " (est.)" : ""} / {item.contextWindow.toLocaleString()}
{item.contextWindowIsEstimate ? " (default)" : ""} tokens ({contextPercent}%)
</Text>
</Box>
);
}
case "user":
return (
<Text>
@@ -116,7 +185,7 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
case "tool_result":
return (
<Text color={item.isError ? "red" : undefined} dimColor={!item.isError}>
<Text color={item.isError ? "yellowBright" : undefined} dimColor={!item.isError}>
{" ⎿ "}
{item.summary}
</Text>
@@ -124,7 +193,7 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
case "notice":
return (
<Text color={item.isError ? "red" : undefined} dimColor={!item.isError}>
<Text color={item.isError ? "yellowBright" : undefined} dimColor={!item.isError}>
{item.text}
</Text>
);
@@ -186,13 +255,112 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
<Text dimColor> (none configured — see .mcp.json or `locode mcp add`)</Text>
) : (
item.statuses.map((s) => (
<Text key={s.name} color={s.status === "error" ? "red" : undefined} dimColor={s.status !== "error"}>
{" "}
{s.name}: {s.status === "connected" ? `connected, ${s.toolCount} tool(s)` : `error — ${s.error}`}
</Text>
<Box key={s.name} flexDirection="column">
<Text color={s.status === "error" ? "yellowBright" : undefined} dimColor={s.status !== "error"}>
{" "}
{s.name}: {s.status === "connected" ? `connected, ${s.toolCount} tool(s)` : `error — ${s.error}`}
</Text>
{s.collision ? (
<Text dimColor>
{" "}⚠ also defined by {s.collision.sources.join(", ")}; using {s.collision.winner}
</Text>
) : null}
</Box>
))
)}
</Box>
);
case "plugins":
return (
<Box flexDirection="column">
<Text bold>Installed ({item.plugins.length})</Text>
{item.plugins.length === 0 ? (
<Text dimColor> No plugins installed. Run `locode plugin add &lt;path-or-git-url&gt;` to install one.</Text>
) : (
item.plugins.map((p) => {
const hookCount = Object.values(p.hooks).reduce((sum, entries) => sum + entries.reduce((s, e) => s + e.hooks.length, 0), 0);
const capabilities: string[] = [];
if (p.commands.length) capabilities.push(`${p.commands.length} command(s)`);
if (p.agents.length) capabilities.push(`${p.agents.length} agent(s)`);
if (p.skills.length) capabilities.push(`${p.skills.length} skill(s)`);
if (hookCount) capabilities.push(`${hookCount} hook(s)`);
if (Object.keys(p.mcpServers).length) capabilities.push(`${Object.keys(p.mcpServers).length} MCP server(s)`);
return (
<Box key={p.name} flexDirection="column" marginBottom={1}>
<Text>
{" "}
<Text bold color={ACCENT_HEX}>
{p.name}
</Text>
{p.manifest.version ? ` v${p.manifest.version}` : ""}
</Text>
{p.manifest.description && <Text dimColor> {p.manifest.description}</Text>}
<Text dimColor> {capabilities.length ? capabilities.join(", ") : "(no commands, agents, skills, hooks, or MCP servers)"}</Text>
</Box>
);
})
)}
{item.commandCollisions?.length ? (
<Box flexDirection="column" marginTop={1}>
<Text color="yellowBright">⚠ Duplicate slash commands (first plugin wins):</Text>
{item.commandCollisions.map((c) => (
<Text key={c.name} dimColor>
{" "}/{c.name}: defined by {c.plugins.join(", ")}; using {c.winner}
</Text>
))}
</Box>
) : null}
</Box>
);
case "hooks": {
const events = Object.entries(item.config) as [string, { matcher?: string; hooks: unknown[] }[]][];
const totalCommands = events.reduce((sum, [, entries]) => sum + entries.reduce((s, e) => s + e.hooks.length, 0), 0);
return (
<Box flexDirection="column">
<Text>Configured hooks:</Text>
{events.length === 0 ? (
<Text dimColor> (none configured — see `locode hooks path`, or a plugin's hooks/hooks.json)</Text>
) : (
events.map(([event, entries]) => (
<Text key={event} dimColor>
{" "}
{event}: {entries.reduce((s, e) => s + e.hooks.length, 0)} command(s)
{entries.some((e) => e.matcher) ? ` (matchers: ${entries.map((e) => e.matcher || "*").join(", ")})` : ""}
</Text>
))
)}
{events.length > 0 && <Text dimColor> {totalCommands} total</Text>}
</Box>
);
}
case "skills":
return (
<Box flexDirection="column">
<Text>Installed skills (model can call the `skill` tool, or type /&lt;name&gt; directly):</Text>
{item.skills.length === 0 ? (
<Text dimColor> (none installed — see a plugin's skills/*/SKILL.md)</Text>
) : (
item.skills.map((s) => (
<Text key={`${s.pluginName}/${s.name}`} dimColor>
{" "}
{s.name} ({s.pluginName}): {s.description}
</Text>
))
)}
{item.collisions?.length ? (
<Box flexDirection="column" marginTop={1}>
<Text color="yellowBright">⚠ Duplicate skill names (first plugin wins):</Text>
{item.collisions.map((c) => (
<Text key={c.name} dimColor>
{" "}{c.name}: defined by {c.plugins.join(", ")}
</Text>
))}
</Box>
) : null}
</Box>
);
}
}
+22 -1
View File
@@ -16,6 +16,22 @@ const OPTIONS: Array<{ label: string; value: PermissionDecision }> = [
{ label: "No, and tell it what to do differently", value: "deny" },
];
// ink-select-input's defaults highlight the selected row in plain `blue`, which reads as
// low-contrast/invisible on many terminal themes. Use the app's own accent color instead so the
// current selection is unmistakable, and bold the label too (color alone isn't enough for
// terminals with limited color support).
function PermissionIndicator({ isSelected }: { isSelected?: boolean }) {
return <Text color={ACCENT_HEX}>{isSelected ? "❯ " : " "}</Text>;
}
function PermissionItem({ isSelected, label }: { isSelected?: boolean; label?: string }) {
return (
<Text color={isSelected ? ACCENT_HEX : undefined} bold={isSelected}>
{label}
</Text>
);
}
export function PermissionPrompt({ toolName, args, preview, onSelect }: Props) {
const previewLines = (preview ?? JSON.stringify(args)).split("\n");
@@ -28,7 +44,12 @@ export function PermissionPrompt({ toolName, args, preview, onSelect }: Props) {
))}
<Text> </Text>
<Text>Do you want to proceed?</Text>
<SelectInput items={OPTIONS} onSelect={(item) => onSelect(item.value)} />
<SelectInput
items={OPTIONS}
indicatorComponent={PermissionIndicator}
itemComponent={PermissionItem}
onSelect={(item) => onSelect(item.value)}
/>
</Box>
);
}
+84 -18
View File
@@ -1,6 +1,8 @@
import { Box, Text } from "ink";
import { useEffect, useState } from "react";
import { ACCENT_HEX } from "../theme.js";
import type { PermissionMode } from "../../permissions/types.js";
import type { GitInfo } from "../../utils/gitInfo.js";
const MODE_LABELS: Record<PermissionMode, string> = {
default: "default",
@@ -8,10 +10,12 @@ const MODE_LABELS: Record<PermissionMode, string> = {
"auto-accept": "auto-accept",
};
// Distinct per mode so cycling modes (Shift+Tab / /perm) is visibly reflected here — default and
// auto-edit previously shared "yellow", making it look like the mode hadn't changed at all.
const MODE_COLORS: Record<PermissionMode, string> = {
default: "yellow",
"auto-edit": "yellow",
"auto-accept": "red",
default: "gray",
"auto-edit": "green",
"auto-accept": "yellowBright",
};
interface Props {
@@ -22,6 +26,11 @@ interface Props {
contextTokens: number;
contextWindow: number;
contextIsEstimate: boolean;
sessionId: string;
createdAt: string;
inputTokens: number;
outputTokens: number;
gitInfo: GitInfo | null;
}
// Always a legible, visible color — never dimmed, since this is one of the most-referenced fields.
@@ -37,29 +46,86 @@ function formatTokenCount(n: number): string {
return String(n);
}
export function StatusBar({ model, mode, permMode, cwd, contextTokens, contextWindow, contextIsEstimate }: Props) {
// Show just the last segment of cwd for brevity
function renderBar(percent: number, width = 10): string {
const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)));
return "█".repeat(filled) + "░".repeat(width - filled);
}
// Compact — "57m", "1h23m" — distinct from HistoryItemView's formatDuration (which keeps seconds
// for the /dashboard detail view); the status bar only has room for the coarse shape.
function formatElapsed(ms: number): string {
const totalMinutes = Math.floor(ms / 60_000);
if (totalMinutes < 1) return `${Math.floor(ms / 1000)}s`;
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return h > 0 ? `${h}h${m}m` : `${m}m`;
}
export function StatusBar({
model,
mode,
permMode,
cwd,
contextTokens,
contextWindow,
contextIsEstimate,
sessionId,
createdAt,
inputTokens,
outputTokens,
gitInfo,
}: Props) {
// Ticks every 30s purely to keep "elapsed"/burn-rate live while otherwise idle — no other state
// here changes on its own, so without this the clock would freeze between messages.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(id);
}, []);
const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd;
const percent = Math.round((contextTokens / contextWindow) * 100);
const approx = contextIsEstimate ? "~" : "";
const elapsedMs = Math.max(0, now - new Date(createdAt).getTime());
const totalTokens = inputTokens + outputTokens;
const elapsedMinutes = elapsedMs / 60_000;
const burnRate = elapsedMinutes >= 0.1 ? totalTokens / elapsedMinutes : null;
return (
<Box flexDirection="row" width="100%" justifyContent="space-between" paddingX={1}>
<Box flexDirection="column" width="100%" paddingX={1}>
<Box gap={1}>
<Text color="yellow">{model}</Text>
<Text dimColor>·</Text>
<Text color="yellow">{mode}</Text>
<Text dimColor>·</Text>
<Text color="yellow">{shortCwd}</Text>
<Text dimColor>·</Text>
<Text color={MODE_COLORS[permMode]}>{MODE_LABELS[permMode]}</Text>
<Text dimColor>·</Text>
<Text color={contextColor(percent)}>
ctx {approx}
{formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)} ({percent}%)
<Text color={ACCENT_HEX}>
◆ {model} ({mode})
</Text>
<Text dimColor>│</Text>
<Text color={contextColor(percent)}>{renderBar(percent)}</Text>
<Text dimColor>│</Text>
<Text color={contextColor(percent)}>{percent}%</Text>
<Text dimColor>│</Text>
<Text dimColor>
{approx}
{formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)}
</Text>
</Box>
<Text color="yellow">/compact · /perm · /help · /exit</Text>
<Box gap={1}>
<Text dimColor>
📁 {shortCwd}
{gitInfo ? ` (${gitInfo.branch}${gitInfo.dirty ? "*" : ""})` : ""}
</Text>
<Text dimColor>│</Text>
<Text dimColor>🔑 {sessionId.slice(0, 8)}</Text>
<Text dimColor>│</Text>
<Text dimColor>⏱ {formatElapsed(elapsedMs)}</Text>
<Text dimColor>│</Text>
<Text dimColor>🔥 {burnRate === null ? "—" : `${formatTokenCount(burnRate)}/min`}</Text>
</Box>
<Box gap={1} justifyContent="space-between">
<Text color={MODE_COLORS[permMode]}>
⏵⏵ {MODE_LABELS[permMode]} (shift+tab to cycle)
</Text>
<Text color="yellow">/export · /compact · /help · /exit</Text>
</Box>
</Box>
);
}
+40 -4
View File
@@ -1,8 +1,13 @@
import { render } from "ink";
import { makeClient } from "../../backend/client.js";
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
import { runHooksForEvent } from "../../hooks/runner.js";
import { connectConfiguredMcpServers, disconnectAllMcpServers } from "../../mcp/manager.js";
import { buildPluginAgentTool } from "../../plugins/agentTool.js";
import { getLoadedPlugins } from "../../plugins/registry.js";
import { buildSkillTool } from "../../plugins/skillTool.js";
import type { ToolDef } from "../../tools/types.js";
import { flushPendingSaves } from "../../persistence/sessionStore.js";
import { App } from "./App.js";
export interface RunInkAppOptions {
@@ -24,8 +29,16 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
}
// Kick off MCP server connections immediately so they overlap with model listing/picking —
// by the time a session is actually created, this is usually already settled.
const mcpToolsPromise: Promise<ToolDef[]> = connectConfiguredMcpServers(opts.cwd).catch(() => []);
// by the time a session is actually created, this is usually already settled. Plugin-defined
// agents (agents/*.md) become tools too, alongside MCP-provided ones, and every installed
// plugin's skills (skills/*/SKILL.md) are exposed through one shared `skill` tool.
const plugins = getLoadedPlugins();
const pluginAgentTools = plugins.flatMap((plugin) => plugin.agents.map((agent) => buildPluginAgentTool(agent)));
const skillTool = buildSkillTool(plugins.flatMap((plugin) => plugin.skills));
const pluginTools = skillTool ? [...pluginAgentTools, skillTool] : pluginAgentTools;
const extraToolsPromise: Promise<ToolDef[]> = connectConfiguredMcpServers(opts.cwd)
.then((mcpTools) => [...mcpTools, ...pluginTools])
.catch(() => pluginTools);
// Start the UI immediately — no blocking network calls before rendering.
// Model listing happens inside the App component so the user sees the UI right away.
@@ -38,9 +51,32 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
suggestedModel={opts.suggestedModel}
resumeSessionId={opts.resumeSessionId}
interactiveResume={opts.interactiveResume}
mcpToolsPromise={mcpToolsPromise}
extraToolsPromise={extraToolsPromise}
/>,
);
// Ctrl+C is handled by Ink's exitOnCtrlC (default true): it calls exit(), which resolves
// waitUntilExit below, so the normal cleanup chain already runs and MCP stdio children get a clean
// shutdown. External SIGTERM/SIGHUP (e.g. `kill` from another terminal) bypasses that path, so
// unmount explicitly (to restore the terminal) and run the same teardown before exiting.
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
// Flush in-flight autosaves first so a fire-and-forget persist right before exit isn't lost.
await flushPendingSaves();
await disconnectAllMcpServers();
// Best-effort — fires on every exit path, so there isn't always a specific session id to attach
// (e.g. exiting from the model picker before a session ever started).
await runHooksForEvent("SessionEnd", { sessionId: "", cwd: opts.cwd }, {}).catch(() => {});
};
const onTerminate = () => {
instance.unmount();
void cleanup().finally(() => process.exit(0));
};
process.on("SIGTERM", onTerminate);
process.on("SIGHUP", onTerminate);
await instance.waitUntilExit();
await disconnectAllMcpServers();
await cleanup();
}
+32 -1
View File
@@ -13,6 +13,24 @@ export type HistoryItem =
contextTokensIsEstimate: boolean;
contextWindowIsEstimate: boolean;
}
| {
id: string;
kind: "dashboard";
model: string;
baseURL: string;
sessionId: string;
elapsedMs: number;
turns: number;
apiCalls: number;
toolCalls: number;
inputTokens: number;
outputTokens: number;
modelTimeMs: number;
contextTokens: number;
contextWindow: number;
contextTokensIsEstimate: boolean;
contextWindowIsEstimate: boolean;
}
| { id: string; kind: "user"; text: string }
| { id: string; kind: "assistant"; text: string }
| { id: string; kind: "streaming_text"; text: string }
@@ -23,7 +41,20 @@ export type HistoryItem =
| { id: string; kind: "tools"; tools: import("../../tools/types.js").ToolDef[] }
| { id: string; kind: "permissions"; allowed: string[] }
| { id: string; kind: "sessions"; sessions: import("../../persistence/sessionStore.js").SessionSummary[] }
| { id: string; kind: "mcp"; statuses: import("../../mcp/manager.js").McpServerStatus[] };
| { id: string; kind: "mcp"; statuses: import("../../mcp/manager.js").McpServerStatus[] }
| {
id: string;
kind: "plugins";
plugins: import("../../plugins/types.js").LoadedPlugin[];
commandCollisions?: import("../../plugins/registry.js").PluginCommandCollision[];
}
| { id: string; kind: "hooks"; config: import("../../hooks/types.js").HooksConfig }
| {
id: string;
kind: "skills";
skills: import("../../plugins/types.js").PluginSkill[];
collisions?: import("../../plugins/skillTool.js").SkillCollision[];
};
/** Plain Omit<Union, K> collapses to keys common to every member; this distributes over each branch instead. */
export type NewHistoryItem = HistoryItem extends infer T
+4
View File
@@ -37,6 +37,10 @@ export function summarizeToolResult(toolName: string, result: unknown): string {
const r = result as Record<string, unknown>;
switch (toolName) {
case "read_file":
if (r.image === true) {
const kb = typeof r.bytes === "number" ? `${(r.bytes / 1024).toFixed(0)} KB` : "";
return `Read image${r.mimeType ? ` (${r.mimeType}${kb ? `, ${kb}` : ""})` : ""}`;
}
return typeof r.totalLines === "number" ? `Read ${r.totalLines} lines` : "Read file";
case "list_files":
return Array.isArray(r.matches) ? `Found ${r.matches.length} file(s)` : "Listed files";
+22
View File
@@ -0,0 +1,22 @@
import { execa } from "execa";
export interface GitInfo {
branch: string;
dirty: boolean;
}
/** Best-effort current branch + dirty-state, for the status bar. Returns null outside a git repo
* (or if `git` itself isn't available) rather than throwing — the status bar just omits it. */
export async function getGitInfo(cwd: string): Promise<GitInfo | null> {
try {
const branchResult = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, reject: false });
if (branchResult.exitCode !== 0) return null;
const statusResult = await execa("git", ["status", "--porcelain"], { cwd, reject: false });
return {
branch: branchResult.stdout.trim(),
dirty: statusResult.exitCode === 0 && statusResult.stdout.trim().length > 0,
};
} catch {
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
import path from "node:path";
const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
/** 5MB — generous for a screenshot or diagram while keeping the base64-inflated (~1.37x) payload
* from blowing out the context window of small local models. */
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
/** Returns the MIME type for a recognized image extension, or null if `filePath` isn't one. */
export function imageMimeType(filePath: string): string | null {
return IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? null;
}
+45
View File
@@ -0,0 +1,45 @@
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
import path from "node:path";
import type { ChatCompletionUserContent } from "../agent/loop.js";
import { imageMimeType, MAX_IMAGE_BYTES } from "./image.js";
import { truncate } from "./truncate.js";
/** Hard cap for text imports — matching the image budget. A larger file is rejected outright (it
* would balloon memory and the context window); users should `read_file` large files instead, which
* paginates rather than inlining everything. */
const MAX_IMPORT_TEXT_BYTES = 5 * 1024 * 1024;
/** Once under the byte cap, still bound the inlined text by characters so a few-MB log doesn't eat
* the whole context window of a small local model. */
const MAX_IMPORT_TEXT_CHARS = 50_000;
/** Builds a multimodal user-turn content array for `/import <path> [caption]` — reads the file
* directly and attaches it to the user's own turn (rather than waiting for the model to call
* read_file itself), which is the most broadly-supported way for vision models to receive images. */
export async function buildImportContent(cwd: string, filePathArg: string, caption: string): Promise<ChatCompletionUserContent> {
const resolved = path.resolve(cwd, filePathArg);
const mimeType = imageMimeType(resolved);
if (mimeType) {
const imageStats = await fsStat(resolved);
if (imageStats.size > MAX_IMAGE_BYTES) {
throw new Error(
`${filePathArg} is ${(imageStats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1_048_576}MB limit for image imports.`,
);
}
const buffer = await fsReadFile(resolved);
return [
{ type: "text", text: caption || `Imported image: ${filePathArg}` },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` } },
];
}
const stats = await fsStat(resolved).catch(() => null);
if (stats && stats.size > MAX_IMPORT_TEXT_BYTES) {
throw new Error(
`${filePathArg} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_IMPORT_TEXT_BYTES / 1_048_576}MB text-import limit. Use read_file for large files (it paginates).`,
);
}
const text = await fsReadFile(resolved, "utf-8");
const body = truncate(text, MAX_IMPORT_TEXT_CHARS);
return [{ type: "text", text: `${caption ? `${caption}\n\n` : ""}Imported file ${filePathArg}:\n\n${body}` }];
}
+40
View File
@@ -0,0 +1,40 @@
import { existsSync, statSync } from "node:fs";
import path from "node:path";
/** The `@`-mention currently being typed at the end of an in-progress input, if any — e.g. for
* "look at @src/too" this is { query: "src/too", start: 9 }. A mention is "closed" (no longer
* active) as soon as whitespace follows the `@`, so completed mentions elsewhere in the text and
* unrelated `@` usage (e.g. an email address) don't reopen the picker. */
export interface ActiveMention {
query: string;
start: number;
}
export function getActiveMention(value: string): ActiveMention | null {
const at = value.lastIndexOf("@");
if (at === -1) return null;
const after = value.slice(at + 1);
if (/\s/.test(after)) return null;
return { query: after, start: at };
}
const MENTION_RE = /@(\S+)/g;
/** Extracts `@token` mentions from a submitted message that refer to real, existing files
* (relative to cwd, or absolute) — a bare `@` or a token that isn't an actual file (e.g. an
* email-like "@handle") is left as plain text rather than treated as an attachment. */
export function extractMentionedFiles(text: string, cwd: string): string[] {
const found = new Set<string>();
for (const match of text.matchAll(MENTION_RE)) {
const token = match[1]!;
const resolved = path.resolve(cwd, token);
try {
if (existsSync(resolved) && statSync(resolved).isFile()) {
found.add(token);
}
} catch {
// Unreadable/inaccessible path — not a usable mention.
}
}
return [...found];
}
+13
View File
@@ -0,0 +1,13 @@
import { mkdirSync } from "node:fs";
import { rename as fsRename, writeFile as fsWriteFile } from "node:fs/promises";
import path from "node:path";
/** Writes `content` to `file` atomically: write to a sibling temp file, then rename into place, so a
* crash mid-write can never leave a truncated/corrupt file at `file` — a reader always sees either
* the old complete content or the new complete content, never a partial write. */
export async function writeFileAtomic(file: string, content: string): Promise<void> {
mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
await fsWriteFile(tmp, content, "utf-8");
await fsRename(tmp, file);
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});