From 2a35f40c73a63dc631d495826e17b594980dc5db Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 7 Jul 2026 18:17:45 +0900 Subject: [PATCH] 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 --- .claude/plans/resolve-known-issues.md | 144 +++++++++ .gitignore | 3 +- README.md | 44 ++- src/agent/events.ts | 8 +- src/agent/loop.test.ts | 17 + src/agent/loop.ts | 413 +++++++++++++++++++----- src/agent/session.ts | 42 ++- src/cli.ts | 160 +++++++++- src/config/config.test.ts | 42 +++ src/config/config.ts | 19 +- src/config/defaults.ts | 4 + src/config/store.ts | 2 + src/hooks/config.ts | 57 ++++ src/hooks/matcher.ts | 14 + src/hooks/runner.test.ts | 101 ++++++ src/hooks/runner.ts | 178 +++++++++++ src/hooks/types.ts | 62 ++++ src/mcp/client.ts | 36 +++ src/mcp/config.test.ts | 45 +++ src/mcp/config.ts | 68 +++- src/mcp/manager.ts | 11 +- src/mcp/toolAdapter.test.ts | 68 ++++ src/mcp/toolAdapter.ts | 51 ++- src/persistence/exportSession.ts | 54 ++++ src/persistence/sessionStore.test.ts | 80 +++++ src/persistence/sessionStore.ts | 41 ++- src/plugins/agentTool.ts | 42 +++ src/plugins/config.ts | 48 +++ src/plugins/expandTemplate.test.ts | 20 ++ src/plugins/expandTemplate.ts | 8 + src/plugins/frontmatter.ts | 28 ++ src/plugins/loader.test.ts | 49 +++ src/plugins/loader.ts | 126 ++++++++ src/plugins/registry.ts | 45 +++ src/plugins/skillTool.test.ts | 49 +++ src/plugins/skillTool.ts | 67 ++++ src/plugins/toolNameMap.ts | 20 ++ src/plugins/types.ts | 57 ++++ src/tools/backgroundJobs.ts | 84 +++++ src/tools/bash.ts | 69 +++- src/tools/bashOutput.ts | 26 ++ src/tools/editFile.ts | 16 +- src/tools/git.test.ts | 117 +++++++ src/tools/git.ts | 298 ++++++++++++----- src/tools/grep.test.ts | 53 +++ src/tools/grep.ts | 4 +- src/tools/index.ts | 2 + src/tools/readFile.ts | 24 +- src/tools/types.ts | 16 +- src/ui/ink/App.tsx | 442 ++++++++++++++++++++------ src/ui/ink/ChatInput.tsx | 140 +++++++- src/ui/ink/ExportPrompt.tsx | 30 ++ src/ui/ink/HistoryItemView.tsx | 180 ++++++++++- src/ui/ink/PermissionPrompt.tsx | 23 +- src/ui/ink/StatusBar.tsx | 102 ++++-- src/ui/ink/index.tsx | 44 ++- src/ui/ink/types.ts | 33 +- src/ui/toolSummary.ts | 4 + src/utils/gitInfo.ts | 22 ++ src/utils/image.ts | 19 ++ src/utils/importFile.ts | 45 +++ src/utils/mentions.ts | 40 +++ src/utils/writeFileAtomic.ts | 13 + vitest.config.ts | 9 + 64 files changed, 3840 insertions(+), 338 deletions(-) create mode 100644 .claude/plans/resolve-known-issues.md create mode 100644 src/agent/loop.test.ts create mode 100644 src/config/config.test.ts create mode 100644 src/hooks/config.ts create mode 100644 src/hooks/matcher.ts create mode 100644 src/hooks/runner.test.ts create mode 100644 src/hooks/runner.ts create mode 100644 src/hooks/types.ts create mode 100644 src/mcp/config.test.ts create mode 100644 src/mcp/toolAdapter.test.ts create mode 100644 src/persistence/exportSession.ts create mode 100644 src/persistence/sessionStore.test.ts create mode 100644 src/plugins/agentTool.ts create mode 100644 src/plugins/config.ts create mode 100644 src/plugins/expandTemplate.test.ts create mode 100644 src/plugins/expandTemplate.ts create mode 100644 src/plugins/frontmatter.ts create mode 100644 src/plugins/loader.test.ts create mode 100644 src/plugins/loader.ts create mode 100644 src/plugins/registry.ts create mode 100644 src/plugins/skillTool.test.ts create mode 100644 src/plugins/skillTool.ts create mode 100644 src/plugins/toolNameMap.ts create mode 100644 src/plugins/types.ts create mode 100644 src/tools/backgroundJobs.ts create mode 100644 src/tools/bashOutput.ts create mode 100644 src/tools/git.test.ts create mode 100644 src/tools/grep.test.ts create mode 100644 src/ui/ink/ExportPrompt.tsx create mode 100644 src/utils/gitInfo.ts create mode 100644 src/utils/image.ts create mode 100644 src/utils/importFile.ts create mode 100644 src/utils/mentions.ts create mode 100644 src/utils/writeFileAtomic.ts create mode 100644 vitest.config.ts diff --git a/.claude/plans/resolve-known-issues.md b/.claude/plans/resolve-known-issues.md new file mode 100644 index 0000000..4077f44 --- /dev/null +++ b/.claude/plans/resolve-known-issues.md @@ -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 `/` 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____`); 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 ` with optional `--no-ff`/`--ff-only`. Preview shows branches and commits. +- `rebase` — `git rebase ` with optional `--onto`. Preview shows commits. +- `delete_branch` — `git branch -d/-D `. 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. diff --git a/.gitignore b/.gitignore index eb8f751..7cca406 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index d2f4075..e497cb8 100644 --- a/README.md +++ b/README.md @@ -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____`. +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 +``` + +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 ` 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 [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____`. 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 ` 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____`; 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//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 `/ [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 `, 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 switch backend (ollama | lmstudio), keeps current model /mode 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; / [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-.md) +/import [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. diff --git a/src/agent/events.ts b/src/agent/events.ts index 05b90cb..e492e9e 100644 --- a/src/agent/events.ts +++ b/src/agent/events.ts @@ -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; \ No newline at end of file diff --git a/src/agent/loop.test.ts b/src/agent/loop.test.ts new file mode 100644 index 0000000..fe4482f --- /dev/null +++ b/src/agent/loop.test.ts @@ -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); + }); +}); diff --git a/src/agent/loop.ts b/src/agent/loop.ts index e79eb68..17849b9 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -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 { + 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>): 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 { + 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 { + 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 { +export async function compactSession(session: Session): Promise { const requestMessages: ChatCompletionMessageParam[] = [ ...session.messages, { @@ -64,12 +131,15 @@ export async function compactSession(session: Session): Promise { }, ]; + 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 { // 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; + 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; + 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 { + 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 { +async function runSubAgentTurn(parent: Session, task: SubAgentTask, overrides?: SubAgentOverrides): Promise { 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; + const ac = new AbortController(); const timeout = new Promise((_, 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 { - 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; } diff --git a/src/agent/session.ts b/src/agent/session.ts index 3bd8a04..d56d9fa 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -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, }; } diff --git a/src/cli.ts b/src/cli.ts index a09216b..cd1ea96 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ") - .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 ") + .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 ") + .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(); diff --git a/src/config/config.test.ts b/src/config/config.test.ts new file mode 100644 index 0000000..3674b50 --- /dev/null +++ b/src/config/config.test.ts @@ -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); + }); +}); diff --git a/src/config/config.ts b/src/config/config.ts index 5ec00d6..441cf9b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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; +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index b12b353..d4b2807 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -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; diff --git a/src/config/store.ts b/src/config/store.ts index 8eff76c..a22f07e 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -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: "" }); diff --git a/src/hooks/config.ts b/src/hooks/config.ts new file mode 100644 index 0000000..88172bd --- /dev/null +++ b/src/hooks/config.ts @@ -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; +} diff --git a/src/hooks/matcher.ts b/src/hooks/matcher.ts new file mode 100644 index 0000000..1915e70 --- /dev/null +++ b/src/hooks/matcher.ts @@ -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); +} diff --git a/src/hooks/runner.test.ts b/src/hooks/runner.test.ts new file mode 100644 index 0000000..1300184 --- /dev/null +++ b/src/hooks/runner.test.ts @@ -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)."); + }); +}); diff --git a/src/hooks/runner.ts b/src/hooks/runner.ts new file mode 100644 index 0000000..116f465 --- /dev/null +++ b/src/hooks/runner.ts @@ -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 = { ...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, + toolName?: string, +): Promise { + 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; +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000..6076352 --- /dev/null +++ b/src/hooks/types.ts @@ -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; + /** 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>; + +export interface HooksFile { + hooks: HooksConfig; +} diff --git a/src/mcp/client.ts b/src/mcp/client.ts index d202ca3..97d11b3 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -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; diff --git a/src/mcp/config.test.ts b/src/mcp/config.test.ts new file mode 100644 index 0000000..0e785c4 --- /dev/null +++ b/src/mcp/config.test.ts @@ -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(); + }); +}); diff --git a/src/mcp/config.ts b/src/mcp/config.ts index 7afe51f..56dc9f2 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -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; + collisions: McpServerCollision[]; +} + export function userMcpFilePath(): string { return userFile; } @@ -30,9 +44,57 @@ export function loadProjectServers(cwd: string): Record 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 { - return { ...loadUserServers(), ...loadProjectServers(cwd) }; +/** MCP servers bundled with installed plugins (`locode plugin add`). */ +export function loadPluginServers(): Record { + const merged: Record = {}; + 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 = {}; + 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 = { + ...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 { diff --git a/src/mcp/manager.ts b/src/mcp/manager.ts index 7e1a758..179b56f 100644 --- a/src/mcp/manager.ts +++ b/src/mcp/manager.ts @@ -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 { - 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 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 }); } diff --git a/src/mcp/toolAdapter.test.ts b/src/mcp/toolAdapter.test.ts new file mode 100644 index 0000000..275566c --- /dev/null +++ b/src/mcp/toolAdapter.test.ts @@ -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[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" }); + }); +}); diff --git a/src/mcp/toolAdapter.ts b/src/mcp/toolAdapter.ts index 4c865de..f1919b4 100644 --- a/src/mcp/toolAdapter.ts +++ b/src/mcp/toolAdapter.ts @@ -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> { 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 }; }, }; diff --git a/src/persistence/exportSession.ts b/src/persistence/exportSession.ts new file mode 100644 index 0000000..295bf7f --- /dev/null +++ b/src/persistence/exportSession.ts @@ -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 { + 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; +} diff --git a/src/persistence/sessionStore.test.ts b/src/persistence/sessionStore.test.ts new file mode 100644 index 0000000..f49fa04 --- /dev/null +++ b/src/persistence/sessionStore.test.ts @@ -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> { + 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"); + }); +}); diff --git a/src/persistence/sessionStore.ts b/src/persistence/sessionStore.ts index b903077..fe9d6ca 100644 --- a/src/persistence/sessionStore.ts +++ b/src/persistence/sessionStore.ts @@ -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>(); + +/** 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 { + 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 { + await Promise.allSettled([...saveQueues.values()]); } export function loadSession(id: string): SessionRecord | undefined { diff --git a/src/plugins/agentTool.ts b/src/plugins/agentTool.ts new file mode 100644 index 0000000..47ae9f2 --- /dev/null +++ b/src/plugins/agentTool.ts @@ -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____` (mirroring the `mcp____` 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 }; + }, + }; +} diff --git a/src/plugins/config.ts b/src/plugins/config.ts new file mode 100644 index 0000000..60fe39a --- /dev/null +++ b/src/plugins/config.ts @@ -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; +} diff --git a/src/plugins/expandTemplate.test.ts b/src/plugins/expandTemplate.test.ts new file mode 100644 index 0000000..29b0edc --- /dev/null +++ b/src/plugins/expandTemplate.test.ts @@ -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 "); + }); +}); diff --git a/src/plugins/expandTemplate.ts b/src/plugins/expandTemplate.ts new file mode 100644 index 0000000..53441c4 --- /dev/null +++ b/src/plugins/expandTemplate.ts @@ -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] ?? ""))); +} diff --git a/src/plugins/frontmatter.ts b/src/plugins/frontmatter.ts new file mode 100644 index 0000000..3d431d3 --- /dev/null +++ b/src/plugins/frontmatter.ts @@ -0,0 +1,28 @@ +export interface ParsedMarkdown { + frontmatter: Record; + 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 = {}; + 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() }; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts new file mode 100644 index 0000000..33a67dc --- /dev/null +++ b/src/plugins/loader.test.ts @@ -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" }); + }); +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts new file mode 100644 index 0000000..2d97fda --- /dev/null +++ b/src/plugins/loader.ts @@ -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(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//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 }) { + const raw = readJson>(path.join(pluginRoot, ".mcp.json")); + const fromFile = + raw && typeof (raw as McpServersFile).mcpServers === "object" ? (raw as McpServersFile).mcpServers : ((raw ?? {}) as Record); + return { ...fromFile, ...(manifest.mcpServers ?? {}) }; +} + +function loadHooks(pluginRoot: string): HooksConfig { + const fromFile = readJson(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 }>( + 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), + }; +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts new file mode 100644 index 0000000..d2f5ba8 --- /dev/null +++ b/src/plugins/registry.ts @@ -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 = {}; + 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; +} diff --git a/src/plugins/skillTool.test.ts b/src/plugins/skillTool.test.ts new file mode 100644 index 0000000..4b153ec --- /dev/null +++ b/src/plugins/skillTool.test.ts @@ -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"); + }); +}); diff --git a/src/plugins/skillTool.ts b/src/plugins/skillTool.ts new file mode 100644 index 0000000..cf966c3 --- /dev/null +++ b/src/plugins/skillTool.ts @@ -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 = {}; + 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 }; + }, + }; +} diff --git a/src/plugins/toolNameMap.ts b/src/plugins/toolNameMap.ts new file mode 100644 index 0000000..404e661 --- /dev/null +++ b/src/plugins/toolNameMap.ts @@ -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 = { + 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; +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts new file mode 100644 index 0000000..de735c5 --- /dev/null +++ b/src/plugins/types.ts @@ -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//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; + hooks: HooksConfig; +} diff --git a/src/tools/backgroundJobs.ts b/src/tools/backgroundJobs.ts new file mode 100644 index 0000000..ed5278e --- /dev/null +++ b/src/tools/backgroundJobs.ts @@ -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(); +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); +} diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 08b85c3..468de0c 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -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> = { name: "bash", description: "Run a shell command and return its stdout, stderr, and exit code.", @@ -18,17 +25,57 @@ export const bashTool: ToolDef> = { 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 | 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, + }; + } + } }, }; diff --git a/src/tools/bashOutput.ts b/src/tools/bashOutput.ts new file mode 100644 index 0000000..e5afc82 --- /dev/null +++ b/src/tools/bashOutput.ts @@ -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> = { + 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), + }; + }, +}; diff --git a/src/tools/editFile.ts b/src/tools/editFile.ts index 610dea4..1d4d135 100644 --- a/src/tools/editFile.ts +++ b/src/tools/editFile.ts @@ -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> = { ); } 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 }; }, }; diff --git a/src/tools/git.test.ts b/src/tools/git.test.ts new file mode 100644 index 0000000..6f60ade --- /dev/null +++ b/src/tools/git.test.ts @@ -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" }); + }); +}); diff --git a/src/tools/git.ts b/src/tools/git.ts index 688e1c2..5c28c87 100644 --- a/src/tools/git.ts +++ b/src/tools/git.ts @@ -12,6 +12,10 @@ async function tryGit(args: string[], cwd: string): Promise { } } +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> = { } })(); - 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> = { // --- 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> = { 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, + cwd: string, +): Promise { + 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, cwd: string): Promise { + 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 }; + } +} diff --git a/src/tools/grep.test.ts b/src/tools/grep.test.ts new file mode 100644 index 0000000..dcea995 --- /dev/null +++ b/src/tools/grep.test.ts @@ -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 }); + }); +}); diff --git a/src/tools/grep.ts b/src/tools/grep.ts index b168c2e..d260e2c 100644 --- a/src/tools/grep.ts +++ b/src/tools/grep.ts @@ -21,7 +21,9 @@ export const grepTool: ToolDef> = { 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 { diff --git a/src/tools/index.ts b/src/tools/index.ts index c207fbc..1ad9c5d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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, ]; diff --git a/src/tools/readFile.ts b/src/tools/readFile.ts index 0243f0a..608fce2 100644 --- a/src/tools/readFile.ts +++ b/src/tools/readFile.ts @@ -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> = { 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; diff --git a/src/tools/types.ts b/src/tools/types.ts index 3a7c287..ca6444b 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -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; + runSubAgent?: (task: SubAgentTask, overrides?: SubAgentOverrides) => Promise; + /** 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 { diff --git a/src/ui/ink/App.tsx b/src/ui/ink/App.tsx index bfaeb8f..dd33b2b 100644 --- a/src/ui/ink/App.tsx +++ b/src/ui/ink/App.tsx @@ -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; + extraToolsPromise: Promise; } interface PendingPermission { @@ -65,7 +85,7 @@ export function App({ suggestedModel, resumeSessionId, interactiveResume, - mcpToolsPromise, + extraToolsPromise, }: AppProps) { const { exit } = useApp(); const [staticItems, setStaticItems] = useState([]); @@ -74,14 +94,24 @@ export function App({ ); const [inputValue, setInputValue] = useState(""); const [permission, setPermission] = useState(null); + const [exportPrompt, setExportPrompt] = useState<{ defaultName: string } | null>(null); const [streamingText, setStreamingText] = useState(null); const [isThinking, setIsThinking] = useState(false); const [permMode, setPermMode] = useState("default"); const [modelList, setModelList] = useState([]); const [sessionList, setSessionList] = useState([]); + const [gitInfo, setGitInfo] = useState(null); const baseURLRef = useRef(initialBaseURL); const sessionRef = useRef(null); + // Static history items can't be retroactively expanded once printed (Ink's 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(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((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((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 [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 && ( )} - {isThinking && streamingText === null && !permission && ( - + {isThinking && streamingText === null && !permission && !exportPrompt && ( + )} {permission ? ( @@ -577,6 +826,8 @@ export function App({ preview={permission.preview} onSelect={handlePermissionSelect} /> + ) : exportPrompt ? ( + ) : phase === "starting" ? ( ) : phase === "connecting" ? ( @@ -588,7 +839,7 @@ export function App({ ) : phase === "model-select" ? ( ) : ( - + )} {sessionRef.current && phase === "input" && ( )} diff --git a/src/ui/ink/ChatInput.tsx b/src/ui/ink/ChatInput.tsx index 0176480..63c096f 100644 --- a/src/ui/ink/ChatInput.tsx +++ b/src/ui/ink/ChatInput.tsx @@ -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(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 ( - - {"> "} - + + {visible.length > 0 && ( + + {hiddenAbove > 0 && ↑ {hiddenAbove} more} + {visible.map((f, i) => { + const actualIndex = windowStart + i; + return ( + + {actualIndex === selectedIndex ? "❯ " : " "} + {f} + + ); + })} + {hiddenBelow > 0 && ↓ {hiddenBelow} more} + + ↑↓ to navigate · Tab to select + {matches.length > VISIBLE_SUGGESTIONS ? ` · ${selectedIndex + 1}/${matches.length}` : ""} + + + )} + + {"> "} + + ); -} \ No newline at end of file +} diff --git a/src/ui/ink/ExportPrompt.tsx b/src/ui/ink/ExportPrompt.tsx new file mode 100644 index 0000000..8fdb034 --- /dev/null +++ b/src/ui/ink/ExportPrompt.tsx @@ -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 ( + + Export conversation + + Filename — edit as needed (Enter to save, Esc to cancel): + + {"> "} + + + + ); +} diff --git a/src/ui/ink/HistoryItemView.tsx b/src/ui/ink/HistoryItemView.tsx index 56dfc68..7a28300 100644 --- a/src/ui/ink/HistoryItemView.tsx +++ b/src/ui/ink/HistoryItemView.tsx @@ -10,19 +10,37 @@ const HELP_LINES = [ " /mode 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; / [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-.md)", + " /import [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 }) { ); + case "dashboard": { + const totalTokens = item.inputTokens + item.outputTokens; + const contextPercent = Math.round((item.contextTokens / item.contextWindow) * 100); + return ( + + Dashboard + + + session: + {item.sessionId} + + + model: + {item.model} ({item.baseURL}) + + + elapsed: + {formatDuration(item.elapsedMs)} + + + + turns: + {item.turns} + + + api calls: + {item.apiCalls} (model time {formatDuration(item.modelTimeMs)}) + + + tool calls: + {item.toolCalls} + + + + tokens: + ↑ {item.inputTokens.toLocaleString()} + {" "} + ↓ {item.outputTokens.toLocaleString()} + {" "} + (total {totalTokens.toLocaleString()}) + + + context: + {item.contextTokens.toLocaleString()} + {item.contextTokensIsEstimate ? " (est.)" : ""} / {item.contextWindow.toLocaleString()} + {item.contextWindowIsEstimate ? " (default)" : ""} tokens ({contextPercent}%) + + + ); + } + case "user": return ( @@ -116,7 +185,7 @@ export function HistoryItemView({ item }: { item: HistoryItem }) { case "tool_result": return ( - + {" ⎿ "} {item.summary} @@ -124,7 +193,7 @@ export function HistoryItemView({ item }: { item: HistoryItem }) { case "notice": return ( - + {item.text} ); @@ -186,13 +255,112 @@ export function HistoryItemView({ item }: { item: HistoryItem }) { (none configured — see .mcp.json or `locode mcp add`) ) : ( item.statuses.map((s) => ( - - {" "} - {s.name}: {s.status === "connected" ? `connected, ${s.toolCount} tool(s)` : `error — ${s.error}`} - + + + {" "} + {s.name}: {s.status === "connected" ? `connected, ${s.toolCount} tool(s)` : `error — ${s.error}`} + + {s.collision ? ( + + {" "}⚠ also defined by {s.collision.sources.join(", ")}; using {s.collision.winner} + + ) : null} + )) )} ); + + case "plugins": + return ( + + Installed ({item.plugins.length}) + {item.plugins.length === 0 ? ( + No plugins installed. Run `locode plugin add <path-or-git-url>` to install one. + ) : ( + 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 ( + + + {" "} + + {p.name} + + {p.manifest.version ? ` v${p.manifest.version}` : ""} + + {p.manifest.description && {p.manifest.description}} + {capabilities.length ? capabilities.join(", ") : "(no commands, agents, skills, hooks, or MCP servers)"} + + ); + }) + )} + {item.commandCollisions?.length ? ( + + ⚠ Duplicate slash commands (first plugin wins): + {item.commandCollisions.map((c) => ( + + {" "}/{c.name}: defined by {c.plugins.join(", ")}; using {c.winner} + + ))} + + ) : null} + + ); + + 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 ( + + Configured hooks: + {events.length === 0 ? ( + (none configured — see `locode hooks path`, or a plugin's hooks/hooks.json) + ) : ( + events.map(([event, entries]) => ( + + {" "} + {event}: {entries.reduce((s, e) => s + e.hooks.length, 0)} command(s) + {entries.some((e) => e.matcher) ? ` (matchers: ${entries.map((e) => e.matcher || "*").join(", ")})` : ""} + + )) + )} + {events.length > 0 && {totalCommands} total} + + ); + } + + case "skills": + return ( + + Installed skills (model can call the `skill` tool, or type /<name> directly): + {item.skills.length === 0 ? ( + (none installed — see a plugin's skills/*/SKILL.md) + ) : ( + item.skills.map((s) => ( + + {" "} + {s.name} ({s.pluginName}): {s.description} + + )) + )} + {item.collisions?.length ? ( + + ⚠ Duplicate skill names (first plugin wins): + {item.collisions.map((c) => ( + + {" "}{c.name}: defined by {c.plugins.join(", ")} + + ))} + + ) : null} + + ); } } diff --git a/src/ui/ink/PermissionPrompt.tsx b/src/ui/ink/PermissionPrompt.tsx index 6a567c1..7f9a303 100644 --- a/src/ui/ink/PermissionPrompt.tsx +++ b/src/ui/ink/PermissionPrompt.tsx @@ -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 {isSelected ? "❯ " : " "}; +} + +function PermissionItem({ isSelected, label }: { isSelected?: boolean; label?: string }) { + return ( + + {label} + + ); +} + 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) { ))} Do you want to proceed? - onSelect(item.value)} /> + onSelect(item.value)} + /> ); } diff --git a/src/ui/ink/StatusBar.tsx b/src/ui/ink/StatusBar.tsx index 57db704..b547bea 100644 --- a/src/ui/ink/StatusBar.tsx +++ b/src/ui/ink/StatusBar.tsx @@ -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 = { default: "default", @@ -8,10 +10,12 @@ const MODE_LABELS: Record = { "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 = { - 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 ( - + - {model} - · - {mode} - · - {shortCwd} - · - {MODE_LABELS[permMode]} - · - - ctx {approx} - {formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)} ({percent}%) + + ◆ {model} ({mode}) + + │ + {renderBar(percent)} + │ + {percent}% + │ + + {approx} + {formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)} - /compact · /perm · /help · /exit + + + 📁 {shortCwd} + {gitInfo ? ` (${gitInfo.branch}${gitInfo.dirty ? "*" : ""})` : ""} + + │ + 🔑 {sessionId.slice(0, 8)} + │ + ⏱ {formatElapsed(elapsedMs)} + │ + 🔥 {burnRate === null ? "—" : `${formatTokenCount(burnRate)}/min`} + + + + ⏵⏵ {MODE_LABELS[permMode]} (shift+tab to cycle) + + /export · /compact · /help · /exit + ); } diff --git a/src/ui/ink/index.tsx b/src/ui/ink/index.tsx index 6ee90e0..383a98e 100644 --- a/src/ui/ink/index.tsx +++ b/src/ui/ink/index.tsx @@ -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 { } // 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 = 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 = 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 { 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(); } diff --git a/src/ui/ink/types.ts b/src/ui/ink/types.ts index ff11f4b..1d406be 100644 --- a/src/ui/ink/types.ts +++ b/src/ui/ink/types.ts @@ -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 collapses to keys common to every member; this distributes over each branch instead. */ export type NewHistoryItem = HistoryItem extends infer T diff --git a/src/ui/toolSummary.ts b/src/ui/toolSummary.ts index 7b8331c..9c86e94 100644 --- a/src/ui/toolSummary.ts +++ b/src/ui/toolSummary.ts @@ -37,6 +37,10 @@ export function summarizeToolResult(toolName: string, result: unknown): string { const r = result as Record; 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"; diff --git a/src/utils/gitInfo.ts b/src/utils/gitInfo.ts new file mode 100644 index 0000000..4a995d3 --- /dev/null +++ b/src/utils/gitInfo.ts @@ -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 { + 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; + } +} diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..9928b61 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,19 @@ +import path from "node:path"; + +const IMAGE_MIME_TYPES: Record = { + ".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; +} diff --git a/src/utils/importFile.ts b/src/utils/importFile.ts new file mode 100644 index 0000000..4b747c0 --- /dev/null +++ b/src/utils/importFile.ts @@ -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 [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 { + 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}` }]; +} diff --git a/src/utils/mentions.ts b/src/utils/mentions.ts new file mode 100644 index 0000000..3dc6b39 --- /dev/null +++ b/src/utils/mentions.ts @@ -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(); + 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]; +} diff --git a/src/utils/writeFileAtomic.ts b/src/utils/writeFileAtomic.ts new file mode 100644 index 0000000..b8bfbb6 --- /dev/null +++ b/src/utils/writeFileAtomic.ts @@ -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 { + mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp`; + await fsWriteFile(tmp, content, "utf-8"); + await fsRename(tmp, file); +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0f78dc0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + }, +});