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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ");
|
||||
});
|
||||
});
|
||||
@@ -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] ?? "")));
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <path-or-git-url>` 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 /<name> 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` }];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user