From 613eb5224e6ae473e56bcdb12ef70c9628044043 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 20:45:03 +0800 Subject: [PATCH 01/20] docs: add v0.1 design record Capture the architecture decisions from the design interview before any code exists: protocol-everywhere process model (forced by Tauri's Rust backend), ACP core + minerva/* extensions, AI SDK v5 behind a kernel-owned provider interface, kernel-enforced permissions, JSONL event-sourced sessions, CLI-first with a shared client core, and Bun-first-Node-tolerant runtime policy. Future slices implement against this record. --- docs/DESIGN.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/DESIGN.md diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..42355ce --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,108 @@ +# Minerva — Design Record + +Minerva is a cross-platform, model-agnostic code agent: a headless kernel with +multiple frontends (CLI now, GUI later). It is a daily-driver tool first and the +foundation for an enterprise product later. This document records the v0.1 +design decisions and the reasoning behind them. + +The central constraint that shaped the architecture: **Tauri 2's backend is +Rust, so a TypeScript kernel can never run in-process in the GUI** — the kernel +must be protocol-fronted and out-of-process from day one. + +## Decisions + +| # | Decision | Choice | +|---|----------|--------| +| 1 | Purpose | Daily-driver now, enterprise foundation later — lean on proven libraries; protocol stability matters | +| 2 | Process model | **Protocol everywhere, transport varies**: kernel = library + host; CLI uses an in-process transport, Tauri spawns the kernel as a stdio sidecar, `minerva serve` (WebSocket) later for remote/enterprise. Every frontend speaks the protocol — no privileged in-process API | +| 3 | Protocol | **ACP (Agent Client Protocol) core + `minerva/*` extensions**. ACP verbatim for sessions / prompts / streaming updates / permission requests; namespaced extension methods for session browsing, usage/cost telemetry, config. Zed/Neovim become free frontends and conformance testers | +| 4 | Model layer | **Vercel AI SDK v5** behind a kernel-owned `ModelProvider` interface (only the adapter package imports `ai`). Anthropic first, then OpenAI; Bedrock/Vertex/Azure/Ollama are cheap adds later | +| 5 | Permissions | **Kernel-enforced rule engine + session modes** (plan / default / accept-edits / auto). Allow/deny/ask patterns like `Bash(git *)`, `Edit(src/**)`. Unmatched → ACP permission request to the frontend. Every decision appended to the audit log. "Always allow" persists as a rule | +| 6 | Tools | Built-ins: ReadFile, WriteFile, EditFile (string-replace), Glob, Grep, Bash (`spawn` pipes — **no PTY in v1**), Todo. Plus **MCP client in v1** (stdio transport first); MCP tools flow through the same permission engine | +| 7 | Sessions | **Append-only JSONL event logs** per session under `~/.minerva/projects//`; the event stream is the source of truth (replay → model context or UI view). Index file for fast session lists. Resume = replay. Manual `/compact` in v1; auto-compaction later | +| 8 | Frontends | **CLI first** (Ink). `@minerva/client` shared package: protocol client + session state reducer (events in → renderable view-model out, zero UI imports). Tauri 2 GUI is M2, reusing `@minerva/client`; webview = React + Vite | +| 9 | Runtime & dist | **Bun-first, Node-tolerant**: Bun is the blessed runtime; kernel core accesses spawn/fs through a thin runtime-adapter seam so a Node build stays cheap. CLI ships as `bun build --compile` single binaries; Tauri bundles the same binary as sidecar | +| 10 | v0.1 scope | **Full CLI feature set as the single public milestone.** No intermediate releases; integration risk managed via internal vertical slices | +| 11 | License | Private repo for now; open/closed + license decided at v0.1 (Apache-2.0 + CLA is the default candidate if opened) | + +Explicitly **not** in v1: subagents, PTY shells, OS sandboxing, auto-compaction, +WS/remote transport (designed-for, not built), web frontend. + +## Repository layout (Bun workspaces) + +``` +minerva/ + packages/ + protocol/ # ACP types + minerva/* extensions, JSON-RPC framing, + # Transport interface + in-proc & stdio implementations + kernel/ # agent loop, session engine (JSONL event log), tool registry, + # built-in tools, permission engine + modes, audit log, + # runtime-adapter seam (spawn/fs) + providers/ # ModelProvider interface + AI SDK v5 adapter (sole importer of `ai`) + mcp/ # MCP client (stdio first), tool bridging into the registry + client/ # frontend-agnostic protocol client + session view-model reducer + cli/ # Ink app: REPL, streaming render, approval prompts, slash commands + apps/ + gui/ # Tauri 2 + React + Vite (M2) +``` + +Config: `~/.minerva/settings.json` (global) + `.minerva/settings.json` +(project) — provider/model selection, permission rules, MCP server definitions. + +## v0.1 internal slices (checkpoints, not releases) + +1. **Hello loop** — kernel library + in-proc transport + minimal Ink REPL; + Anthropic via AI SDK; Read/Edit/Bash; ask-every-time approvals; JSONL event + logging on from the first turn. Proves every architectural seam end-to-end. +2. **Permissions & persistence** — rule engine + modes; "always allow" + persistence; session resume (replay); remaining built-ins; audit log. +3. **Protocol hardening** — stdio transport + `minerva acp` host command; ACP + conformance checked by connecting Zed as an external frontend; second + provider (OpenAI) + model switching; `minerva/*` extension methods. +4. **Ecosystem & polish** — MCP client; manual `/compact`; slash commands, + config UX, error/retry behavior; `bun build --compile` release binaries. + +**v0.1 exit criteria:** daily-drivable CLI for real work; Zed connects over ACP +and completes a session; two providers switchable mid-project; `kill -9` the +CLI → `minerva resume` restores the session; every side effect traceable in the +audit log. + +**M2 (post-v0.1):** Tauri 2 GUI — kernel as bundled sidecar, React UI consuming +`@minerva/client` view-models; session browser reads the JSONL index. + +## Development workflow + +All of v0.1 is developed on a single branch (`feat/v0.1`); `main` stays +untouched until v0.1 lands via one final, non-squashed PR. + +- One Conventional Commit per logical unit, each with a why-body. The branch's + `git log` is the development narrative. +- Review happens at slice boundaries (code review on the working tree when each + slice completes), not only at the final PR. +- Verify gate — `bun run verify` (typecheck + lint + tests) — must be green at + every slice boundary. + +## Risks / watchlist + +- **Ink on Bun** (yoga-wasm, raw-mode quirks): the Node-tolerant seam is the + fallback — develop the CLI under Node if Bun misbehaves, compile with Bun for + release. +- **ACP spec churn** (young standard): pin the protocol version; keep the ACP + mapping inside `packages/protocol` only. +- **No PTY** limits interactive commands (`vim`, watch modes): documented; PTY + is a v2 item. +- **Provider-specific features** (prompt caching, thinking budgets): the AI SDK + adapter must pass `providerOptions` through — this is why `ModelProvider` is + kernel-owned. +- **Windows**: compiled-binary paths, shell differences — test in slice 4, not + after. + +## Verification strategy + +- **Kernel**: unit tests with mock model providers; golden-transcript tests — + replay recorded JSONL event logs and assert reconstructed state. +- **Protocol**: ACP conformance via a scripted stdio harness, plus a live Zed + connection as the external check. +- **End-to-end**: scripted CLI session against a real provider on a sample repo + (edit → test → commit flow); resume-after-kill test; permission-rule matrix + test (allow/deny/ask × modes). From d61768ad94d5bccf354a02678fd5def3378687a4 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 20:45:03 +0800 Subject: [PATCH 02/20] chore: scaffold Bun workspace monorepo with verify gate Workspaces under packages/* and apps/* so the kernel, protocol, and frontends develop against each other without a build step (packages export TypeScript source directly; Bun runs it, tsc typechecks it). The verify gate (typecheck + biome lint + bun test) exists from the first commit so every slice boundary has the same green bar. Biome over eslint for zero-config speed on a greenfield repo. --- .gitignore | 7 +++++++ README.md | 19 ++++++++++++++++++- biome.json | 25 +++++++++++++++++++++++++ bun.lock | 43 +++++++++++++++++++++++++++++++++++++++++++ package.json | 18 ++++++++++++++++++ tsconfig.json | 22 ++++++++++++++++++++++ 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b754db1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +coverage/ +*.log +.env +.env.* +.DS_Store diff --git a/README.md b/README.md index dd7b66d..f5a3874 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ -# minerva +# Minerva + +A cross-platform, model-agnostic code agent — a headless kernel with multiple +frontends (CLI now, GUI later). + +- **Architecture**: protocol everywhere ([ACP](https://agentclientprotocol.com) + core + `minerva/*` extensions), one kernel, swappable transports. +- **Stack**: TypeScript, Bun, Vercel AI SDK, Ink (CLI), Tauri 2 (GUI, planned). + +See [docs/DESIGN.md](docs/DESIGN.md) for the design record. + +## Development + +```sh +bun install +bun run verify # typecheck + lint + tests +bun run --cwd packages/cli dev +``` diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..ec8544c --- /dev/null +++ b/biome.json @@ -0,0 +1,25 @@ +{ + "files": { + "ignoreUnknown": true, + "includes": ["**", "!**/node_modules", "!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..822f1eb --- /dev/null +++ b/bun.lock @@ -0,0 +1,43 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "minerva", + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/bun": "^1.3.4", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6adf8d2 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "minerva", + "private": true, + "type": "module", + "workspaces": ["packages/*", "apps/*"], + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "verify": "bun run typecheck && bun run lint && bun test" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/bun": "^1.3.4", + "typescript": "^5.9.3" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7e517d7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "Preserve", + "moduleResolution": "bundler", + "moduleDetection": "force", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["packages/**/*", "apps/**/*"], + "exclude": ["**/node_modules", "**/dist"] +} From bf52280bff4fb152ca6bff43f764a9f160a6224e Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 20:48:29 +0800 Subject: [PATCH 03/20] feat(protocol): JSON-RPC connection, in-proc transport, ACP-shaped types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol is the load-bearing seam of the architecture: every frontend (CLI in-proc today, Tauri sidecar and remote WS later) speaks it, so the Connection is symmetric — either side can issue requests, because the kernel must call session/request_permission on the frontend mid-turn. Types follow ACP naming and payload shapes (pinned via PROTOCOL_VERSION) so slice 3's Zed conformance work is a mapping exercise, not a redesign. The in-proc transport defers delivery to a microtask so it behaves like a real wire and can't hide reentrancy bugs. Also picks up biome migrate/format fixes surfaced by the first lint run. --- biome.json | 2 +- bun.lock | 6 + package.json | 5 +- packages/protocol/package.json | 9 + packages/protocol/src/index.ts | 3 + packages/protocol/src/jsonrpc.ts | 202 ++++++++++++++++++++++ packages/protocol/src/transport.ts | 52 ++++++ packages/protocol/src/types.ts | 156 +++++++++++++++++ packages/protocol/test/connection.test.ts | 104 +++++++++++ 9 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/src/jsonrpc.ts create mode 100644 packages/protocol/src/transport.ts create mode 100644 packages/protocol/src/types.ts create mode 100644 packages/protocol/test/connection.test.ts diff --git a/biome.json b/biome.json index ec8544c..9e61610 100644 --- a/biome.json +++ b/biome.json @@ -11,7 +11,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "suspicious": { "noExplicitAny": "warn" } diff --git a/bun.lock b/bun.lock index 822f1eb..a7fb3ad 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,10 @@ "typescript": "^5.9.3", }, }, + "packages/protocol": { + "name": "@minerva/protocol", + "version": "0.1.0", + }, }, "packages": { "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], @@ -30,6 +34,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], + "@minerva/protocol": ["@minerva/protocol@workspace:packages/protocol"], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], diff --git a/package.json b/package.json index 6adf8d2..4e98d99 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,10 @@ "name": "minerva", "private": true, "type": "module", - "workspaces": ["packages/*", "apps/*"], + "workspaces": [ + "packages/*", + "apps/*" + ], "scripts": { "test": "bun test", "typecheck": "tsc --noEmit", diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..358baea --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,9 @@ +{ + "name": "@minerva/protocol", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 0000000..411ff5f --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,3 @@ +export * from "./jsonrpc"; +export * from "./transport"; +export * from "./types"; diff --git a/packages/protocol/src/jsonrpc.ts b/packages/protocol/src/jsonrpc.ts new file mode 100644 index 0000000..c8f6e47 --- /dev/null +++ b/packages/protocol/src/jsonrpc.ts @@ -0,0 +1,202 @@ +/** + * JSON-RPC 2.0 message types and a bidirectional connection. + * + * Both sides of a Minerva connection can issue requests: the frontend calls + * `session/prompt` on the kernel, and mid-turn the kernel calls + * `session/request_permission` back on the frontend. One Connection class + * serves both roles. + */ + +export type JsonRpcId = number | string; + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: JsonRpcId; + method: string; + params?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: unknown; + error?: JsonRpcError; +} + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +export const JSON_RPC_ERROR_CODES = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, +} as const; + +/** Error thrown to callers when the remote side returns a JSON-RPC error. */ +export class RpcError extends Error { + readonly code: number; + readonly data?: unknown; + + constructor(code: number, message: string, data?: unknown) { + super(message); + this.name = "RpcError"; + this.code = code; + this.data = data; + } +} + +export function isRequest(message: JsonRpcMessage): message is JsonRpcRequest { + return "method" in message && "id" in message; +} + +export function isNotification(message: JsonRpcMessage): message is JsonRpcNotification { + return "method" in message && !("id" in message); +} + +export function isResponse(message: JsonRpcMessage): message is JsonRpcResponse { + return !("method" in message) && "id" in message; +} + +export interface Transport { + send(message: JsonRpcMessage): void; + onMessage(handler: (message: JsonRpcMessage) => void): void; + onClose(handler: () => void): void; + close(): void; +} + +type RequestHandler = (params: unknown) => unknown | Promise; +type NotificationHandler = (params: unknown) => void | Promise; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason: RpcError) => void; +} + +export class Connection { + #transport: Transport; + #requestHandlers = new Map(); + #notificationHandlers = new Map(); + #pending = new Map(); + #nextId = 1; + #closed = false; + + constructor(transport: Transport) { + this.#transport = transport; + transport.onMessage((message) => this.#dispatch(message)); + transport.onClose(() => this.#handleClose()); + } + + handleRequest(method: string, handler: RequestHandler): void { + this.#requestHandlers.set(method, handler); + } + + handleNotification(method: string, handler: NotificationHandler): void { + this.#notificationHandlers.set(method, handler); + } + + request(method: string, params?: unknown): Promise { + if (this.#closed) { + return Promise.reject(new RpcError(JSON_RPC_ERROR_CODES.INTERNAL_ERROR, "connection closed")); + } + const id = this.#nextId++; + const promise = new Promise((resolve, reject) => { + this.#pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); + }); + this.#transport.send({ jsonrpc: "2.0", id, method, params }); + return promise; + } + + notify(method: string, params?: unknown): void { + if (this.#closed) return; + this.#transport.send({ jsonrpc: "2.0", method, params }); + } + + close(): void { + this.#transport.close(); + } + + #handleClose(): void { + if (this.#closed) return; + this.#closed = true; + for (const pending of this.#pending.values()) { + pending.reject(new RpcError(JSON_RPC_ERROR_CODES.INTERNAL_ERROR, "connection closed")); + } + this.#pending.clear(); + } + + #dispatch(message: JsonRpcMessage): void { + if (isResponse(message)) { + this.#dispatchResponse(message); + } else if (isRequest(message)) { + void this.#dispatchRequest(message); + } else if (isNotification(message)) { + void this.#dispatchNotification(message); + } + } + + #dispatchResponse(response: JsonRpcResponse): void { + const pending = this.#pending.get(response.id); + if (!pending) return; + this.#pending.delete(response.id); + if (response.error) { + pending.reject( + new RpcError(response.error.code, response.error.message, response.error.data), + ); + } else { + pending.resolve(response.result); + } + } + + async #dispatchRequest(request: JsonRpcRequest): Promise { + const handler = this.#requestHandlers.get(request.method); + if (!handler) { + this.#transport.send({ + jsonrpc: "2.0", + id: request.id, + error: { + code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, + message: `method not found: ${request.method}`, + }, + }); + return; + } + try { + const result = await handler(request.params); + if (this.#closed) return; + this.#transport.send({ jsonrpc: "2.0", id: request.id, result: result ?? null }); + } catch (error) { + if (this.#closed) return; + const rpcError: JsonRpcError = + error instanceof RpcError + ? { code: error.code, message: error.message, data: error.data } + : { + code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, + message: error instanceof Error ? error.message : String(error), + }; + this.#transport.send({ jsonrpc: "2.0", id: request.id, error: rpcError }); + } + } + + async #dispatchNotification(notification: JsonRpcNotification): Promise { + const handler = this.#notificationHandlers.get(notification.method); + if (!handler) return; + try { + await handler(notification.params); + } catch { + // Notifications have no reply channel; errors are the handler's problem. + } + } +} diff --git a/packages/protocol/src/transport.ts b/packages/protocol/src/transport.ts new file mode 100644 index 0000000..48f3335 --- /dev/null +++ b/packages/protocol/src/transport.ts @@ -0,0 +1,52 @@ +import type { JsonRpcMessage, Transport } from "./jsonrpc"; + +/** + * In-process transport: a linked pair of endpoints for frontends that embed + * the kernel (the CLI). Delivery is deferred to a microtask so in-proc + * behaves like every other transport — a handler never runs synchronously + * inside the sender's stack frame, which would otherwise hide reentrancy + * bugs that only surface on stdio/WebSocket. + */ +class InProcEndpoint implements Transport { + peer!: InProcEndpoint; + #messageHandlers: Array<(message: JsonRpcMessage) => void> = []; + #closeHandlers: Array<() => void> = []; + #closed = false; + + send(message: JsonRpcMessage): void { + if (this.#closed || this.peer.#closed) return; + queueMicrotask(() => { + if (this.peer.#closed) return; + for (const handler of this.peer.#messageHandlers) handler(message); + }); + } + + onMessage(handler: (message: JsonRpcMessage) => void): void { + this.#messageHandlers.push(handler); + } + + onClose(handler: () => void): void { + this.#closeHandlers.push(handler); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + for (const handler of this.#closeHandlers) handler(); + this.peer.#notifyPeerClosed(); + } + + #notifyPeerClosed(): void { + if (this.#closed) return; + this.#closed = true; + for (const handler of this.#closeHandlers) handler(); + } +} + +export function createInProcTransportPair(): [Transport, Transport] { + const a = new InProcEndpoint(); + const b = new InProcEndpoint(); + a.peer = b; + b.peer = a; + return [a, b]; +} diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts new file mode 100644 index 0000000..3bd7f94 --- /dev/null +++ b/packages/protocol/src/types.ts @@ -0,0 +1,156 @@ +/** + * ACP-shaped protocol types (agentclientprotocol.com), plus minerva/* + * extensions. Names and payload shapes follow the ACP spec so that slice 3's + * conformance work (connecting Zed over stdio) is a mapping exercise, not a + * redesign. Only the subset the current slices need is defined; the spec + * version is pinned via PROTOCOL_VERSION. + */ + +export const PROTOCOL_VERSION = 1; + +// --- Methods ----------------------------------------------------------- + +/** Frontend → kernel. */ +export const AGENT_METHODS = { + initialize: "initialize", + sessionNew: "session/new", + sessionPrompt: "session/prompt", + /** Notification. */ + sessionCancel: "session/cancel", +} as const; + +/** Kernel → frontend. */ +export const CLIENT_METHODS = { + /** Notification. */ + sessionUpdate: "session/update", + sessionRequestPermission: "session/request_permission", +} as const; + +// --- Content ----------------------------------------------------------- + +export interface TextContentBlock { + type: "text"; + text: string; +} + +/** Slice 1 is text-only; image/audio/resource variants arrive with ACP conformance. */ +export type ContentBlock = TextContentBlock; + +// --- initialize -------------------------------------------------------- + +export interface InitializeParams { + protocolVersion: number; + clientCapabilities?: Record; +} + +export interface InitializeResult { + protocolVersion: number; + agentCapabilities: { + loadSession: boolean; + }; +} + +// --- session/new ------------------------------------------------------- + +export interface SessionNewParams { + cwd: string; +} + +export interface SessionNewResult { + sessionId: string; +} + +// --- session/prompt ---------------------------------------------------- + +export interface SessionPromptParams { + sessionId: string; + prompt: ContentBlock[]; +} + +export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"; + +export interface SessionPromptResult { + stopReason: StopReason; +} + +export interface SessionCancelParams { + sessionId: string; +} + +// --- session/update ---------------------------------------------------- + +export type ToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "other"; + +export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; + +export type ToolCallContent = + | { type: "content"; content: ContentBlock } + | { type: "diff"; path: string; oldText: string | null; newText: string }; + +export interface ToolCallStart { + sessionUpdate: "tool_call"; + toolCallId: string; + title: string; + kind: ToolKind; + status: ToolCallStatus; + rawInput?: unknown; +} + +export interface ToolCallUpdate { + sessionUpdate: "tool_call_update"; + toolCallId: string; + status?: ToolCallStatus; + title?: string; + content?: ToolCallContent[]; + rawOutput?: unknown; +} + +export type SessionUpdate = + | { sessionUpdate: "user_message_chunk"; content: ContentBlock } + | { sessionUpdate: "agent_message_chunk"; content: ContentBlock } + | { sessionUpdate: "agent_thought_chunk"; content: ContentBlock } + | ToolCallStart + | ToolCallUpdate; + +export interface SessionUpdateParams { + sessionId: string; + update: SessionUpdate; +} + +// --- session/request_permission ---------------------------------------- + +export type PermissionOptionKind = "allow_once" | "allow_always" | "reject_once" | "reject_always"; + +export interface PermissionOption { + optionId: string; + name: string; + kind: PermissionOptionKind; +} + +export interface RequestPermissionParams { + sessionId: string; + toolCall: { + toolCallId: string; + title: string; + kind: ToolKind; + rawInput?: unknown; + }; + options: PermissionOption[]; +} + +export type RequestPermissionOutcome = + | { outcome: "cancelled" } + | { outcome: "selected"; optionId: string }; + +export interface RequestPermissionResult { + outcome: RequestPermissionOutcome; +} diff --git a/packages/protocol/test/connection.test.ts b/packages/protocol/test/connection.test.ts new file mode 100644 index 0000000..f381c52 --- /dev/null +++ b/packages/protocol/test/connection.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { Connection, createInProcTransportPair, RpcError } from "../src"; + +function connectedPair(): [Connection, Connection] { + const [a, b] = createInProcTransportPair(); + return [new Connection(a), new Connection(b)]; +} + +describe("Connection over in-proc transport", () => { + test("request/response roundtrip", async () => { + const [client, server] = connectedPair(); + server.handleRequest("echo", (params) => ({ echoed: params })); + + const result = await client.request("echo", { value: 42 }); + expect(result).toEqual({ echoed: { value: 42 } }); + }); + + test("both sides can issue requests (bidirectional)", async () => { + const [frontend, kernel] = connectedPair(); + kernel.handleRequest("session/prompt", async () => { + // Mid-request, the kernel asks the frontend for permission. + const permission = await kernel.request("session/request_permission", { tool: "bash" }); + return { permission }; + }); + frontend.handleRequest("session/request_permission", () => ({ outcome: "selected" })); + + const result = await frontend.request("session/prompt", {}); + expect(result).toEqual({ permission: { outcome: "selected" } }); + }); + + test("notifications are delivered without a reply", async () => { + const [client, server] = connectedPair(); + const received: unknown[] = []; + server.handleNotification("session/update", (params) => { + received.push(params); + }); + + client.notify("session/update", { seq: 1 }); + client.notify("session/update", { seq: 2 }); + await Bun.sleep(0); + expect(received).toEqual([{ seq: 1 }, { seq: 2 }]); + }); + + test("handler throw becomes an RpcError for the caller", async () => { + const [client, server] = connectedPair(); + server.handleRequest("boom", () => { + throw new Error("kaputt"); + }); + + expect(client.request("boom")).rejects.toThrow("kaputt"); + }); + + test("custom RpcError code survives the wire", async () => { + const [client, server] = connectedPair(); + server.handleRequest("denied", () => { + throw new RpcError(-32001, "not allowed", { reason: "policy" }); + }); + + try { + await client.request("denied"); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(RpcError); + const rpcError = error as RpcError; + expect(rpcError.code).toBe(-32001); + expect(rpcError.data).toEqual({ reason: "policy" }); + } + }); + + test("unknown method rejects with METHOD_NOT_FOUND", async () => { + const [client] = connectedPair(); + try { + await client.request("nope"); + expect.unreachable(); + } catch (error) { + expect((error as RpcError).code).toBe(-32601); + } + }); + + test("closing rejects pending requests", async () => { + const [client, server] = connectedPair(); + server.handleRequest("hang", () => new Promise(() => {})); + + const pending = client.request("hang"); + client.close(); + expect(pending).rejects.toThrow("connection closed"); + }); + + test("concurrent requests correlate by id", async () => { + const [client, server] = connectedPair(); + server.handleRequest("delayed", async (params) => { + const { ms, tag } = params as { ms: number; tag: string }; + await Bun.sleep(ms); + return tag; + }); + + const [slow, fast] = await Promise.all([ + client.request("delayed", { ms: 20, tag: "slow" }), + client.request("delayed", { ms: 1, tag: "fast" }), + ]); + expect(slow).toBe("slow"); + expect(fast).toBe("fast"); + }); +}); From 2920465ff07c5dc2643a3108ec090392a3d0d12e Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 20:55:57 +0800 Subject: [PATCH 04/20] feat(providers): ModelProvider interface + AI SDK v6 Anthropic adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel owns the model abstraction (TurnRequest in, TurnEvent stream out) so 'model-agnostic' is a property of the kernel, not of whichever SDK is fashionable. The AI SDK adapter is the sole importer of 'ai' — provider quirks (tool-call streaming, message shapes, usage accounting) stop at this boundary. Tools are passed without execute() on purpose: the kernel runs tools itself so permissions and audit logging can't be bypassed by SDK auto-execution. createAiSdkProvider is split from createAnthropicProvider so tests can inject MockLanguageModelV3; createScriptedProvider gives kernel and e2e tests a deterministic model with zero network. --- bun.lock | 33 ++++++ packages/providers/package.json | 14 +++ packages/providers/src/ai-sdk.ts | 144 +++++++++++++++++++++++++ packages/providers/src/index.ts | 3 + packages/providers/src/scripted.ts | 21 ++++ packages/providers/src/types.ts | 57 ++++++++++ packages/providers/test/ai-sdk.test.ts | 134 +++++++++++++++++++++++ 7 files changed, 406 insertions(+) create mode 100644 packages/providers/package.json create mode 100644 packages/providers/src/ai-sdk.ts create mode 100644 packages/providers/src/index.ts create mode 100644 packages/providers/src/scripted.ts create mode 100644 packages/providers/src/types.ts create mode 100644 packages/providers/test/ai-sdk.test.ts diff --git a/bun.lock b/bun.lock index a7fb3ad..0c57537 100644 --- a/bun.lock +++ b/bun.lock @@ -14,8 +14,25 @@ "name": "@minerva/protocol", "version": "0.1.0", }, + "packages/providers": { + "name": "@minerva/providers", + "version": "0.1.0", + "dependencies": { + "@ai-sdk/anthropic": "^3", + "ai": "^6", + "zod": "^4", + }, + }, }, "packages": { + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.96", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6VQzaXQdm5FkX6NWOyKzV5GB11C8IqkgsKZE91lg/bdwyvnQJLDwal2qkE0+fC8CCGeW5d+VV8Mw/+H+OcDC1A=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.146", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QK922LzOfGeHdZ9QGvghDizQx/tPOolTQSHvMlnUPeaTW5qpiIqpfbLwYiyxEt5YOGLLM/0t232Dut+95udo/Q=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], @@ -36,14 +53,30 @@ "@minerva/protocol": ["@minerva/protocol@workspace:packages/protocol"], + "@minerva/providers": ["@minerva/providers@workspace:packages/providers"], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + + "ai": ["ai@6.0.222", "", { "dependencies": { "@ai-sdk/gateway": "3.0.146", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kUzBaIHIfaDgBX7X22V4tE48MyYyS2IgCuj/zfANx0JfDAwgKwzKjl9pZUpYSnYPChI7i7yRGRoeTkebAFx1VQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], } } diff --git a/packages/providers/package.json b/packages/providers/package.json new file mode 100644 index 0000000..fb22213 --- /dev/null +++ b/packages/providers/package.json @@ -0,0 +1,14 @@ +{ + "name": "@minerva/providers", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3", + "ai": "^6", + "zod": "^4" + } +} diff --git a/packages/providers/src/ai-sdk.ts b/packages/providers/src/ai-sdk.ts new file mode 100644 index 0000000..fa1b13c --- /dev/null +++ b/packages/providers/src/ai-sdk.ts @@ -0,0 +1,144 @@ +/** + * AI SDK adapter — the only module in Minerva that imports `ai`. + * The kernel never sees AI SDK types; everything crosses the boundary as + * the kernel-owned shapes in ./types. + */ + +import { createAnthropic } from "@ai-sdk/anthropic"; +import { + jsonSchema, + type LanguageModel, + type ModelMessage, + streamText, + type ToolSet, + tool, +} from "ai"; +import type { + ModelProvider, + ProviderMessage, + ToolDefinition, + TurnEvent, + TurnFinishReason, +} from "./types"; + +export const DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-8"; + +export interface AnthropicProviderOptions { + model?: string; + apiKey?: string; +} + +export function createAnthropicProvider(options: AnthropicProviderOptions = {}): ModelProvider { + const modelId = options.model ?? DEFAULT_ANTHROPIC_MODEL; + const anthropic = createAnthropic(options.apiKey ? { apiKey: options.apiKey } : {}); + return createAiSdkProvider(anthropic(modelId), `anthropic/${modelId}`); +} + +/** Wrap any AI SDK language model (including mocks in tests). */ +export function createAiSdkProvider(model: LanguageModel, id: string): ModelProvider { + return { + id, + async *streamTurn(request) { + const result = streamText({ + model, + system: request.system, + messages: request.messages.map(toModelMessage), + tools: toToolSet(request.tools), + abortSignal: request.abortSignal, + }); + + for await (const part of result.fullStream) { + const event = toTurnEvent(part); + if (event) yield event; + } + }, + }; +} + +function toToolSet(tools: ToolDefinition[]): ToolSet { + const set: ToolSet = {}; + for (const def of tools) { + // No `execute`: the kernel runs tools itself (permissions, audit log), + // so the model turn always ends at the tool-call boundary. + set[def.name] = tool({ + description: def.description, + inputSchema: jsonSchema(def.inputSchema), + }); + } + return set; +} + +function toModelMessage(message: ProviderMessage): ModelMessage { + switch (message.role) { + case "user": + return { role: "user", content: message.content }; + case "assistant": + return { + role: "assistant", + content: [ + ...(message.text ? [{ type: "text" as const, text: message.text }] : []), + ...message.toolCalls.map((call) => ({ + type: "tool-call" as const, + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + })), + ], + }; + case "tool": + return { + role: "tool", + content: message.results.map((result) => ({ + type: "tool-result" as const, + toolCallId: result.toolCallId, + toolName: result.toolName, + output: result.isError + ? { type: "error-text" as const, value: result.output } + : { type: "text" as const, value: result.output }, + })), + }; + } +} + +type StreamPart = + Awaited>["fullStream"] extends AsyncIterable ? P : never; + +function toTurnEvent(part: StreamPart): TurnEvent | null { + switch (part.type) { + case "text-delta": + return { type: "text-delta", text: part.text }; + case "tool-call": + return { + type: "tool-call", + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.input, + }; + case "finish": + return { + type: "finish", + finishReason: toFinishReason(part.finishReason), + usage: { + inputTokens: part.totalUsage.inputTokens, + outputTokens: part.totalUsage.outputTokens, + }, + }; + case "error": + return { type: "error", error: part.error }; + default: + return null; + } +} + +function toFinishReason(reason: string): TurnFinishReason { + switch (reason) { + case "stop": + return "stop"; + case "tool-calls": + return "tool-calls"; + case "length": + return "length"; + default: + return "other"; + } +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts new file mode 100644 index 0000000..ce0f812 --- /dev/null +++ b/packages/providers/src/index.ts @@ -0,0 +1,3 @@ +export * from "./ai-sdk"; +export * from "./scripted"; +export * from "./types"; diff --git a/packages/providers/src/scripted.ts b/packages/providers/src/scripted.ts new file mode 100644 index 0000000..4cc16b3 --- /dev/null +++ b/packages/providers/src/scripted.ts @@ -0,0 +1,21 @@ +import type { ModelProvider, TurnEvent } from "./types"; + +/** + * Deterministic provider for tests: plays back one scripted event sequence + * per turn, in order. Lets kernel and end-to-end tests drive the full agent + * loop (including tool calls) without any network or AI SDK involvement. + */ +export function createScriptedProvider(turns: TurnEvent[][]): ModelProvider { + let turnIndex = 0; + return { + id: "scripted", + async *streamTurn() { + const turn = turns[turnIndex]; + if (!turn) { + throw new Error(`scripted provider exhausted after ${turnIndex} turns`); + } + turnIndex += 1; + yield* turn; + }, + }; +} diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts new file mode 100644 index 0000000..7f4f1b4 --- /dev/null +++ b/packages/providers/src/types.ts @@ -0,0 +1,57 @@ +/** + * Kernel-owned model abstraction. The kernel drives the agent loop and talks + * only to these types; how a turn reaches an actual LLM (AI SDK, mock, + * future direct adapters) is an implementation detail behind ModelProvider. + */ + +/** A tool as the model sees it. inputSchema is JSON Schema (draft-07 subset). */ +export interface ToolDefinition { + name: string; + description: string; + inputSchema: Record; +} + +export interface ProviderToolCall { + toolCallId: string; + toolName: string; + input: unknown; +} + +export interface ProviderToolResult { + toolCallId: string; + toolName: string; + output: string; + isError?: boolean; +} + +export type ProviderMessage = + | { role: "user"; content: string } + | { role: "assistant"; text: string; toolCalls: ProviderToolCall[] } + | { role: "tool"; results: ProviderToolResult[] }; + +export interface TurnRequest { + system?: string; + messages: ProviderMessage[]; + tools: ToolDefinition[]; + abortSignal?: AbortSignal; +} + +export interface TurnUsage { + inputTokens?: number; + outputTokens?: number; +} + +export type TurnFinishReason = "stop" | "tool-calls" | "length" | "other"; + +export type TurnEvent = + | { type: "text-delta"; text: string } + | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } + | { type: "finish"; finishReason: TurnFinishReason; usage: TurnUsage } + | { type: "error"; error: unknown }; + +export interface ModelProvider { + /** Stable identifier, e.g. "anthropic/claude-opus-4-8". */ + readonly id: string; + /** Stream one model turn. Ends with a "finish" or "error" event. */ + streamTurn(request: TurnRequest): AsyncIterable; +} diff --git a/packages/providers/test/ai-sdk.test.ts b/packages/providers/test/ai-sdk.test.ts new file mode 100644 index 0000000..8ada42c --- /dev/null +++ b/packages/providers/test/ai-sdk.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { simulateReadableStream } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { createAiSdkProvider, type TurnEvent } from "../src"; + +const USAGE = { + inputTokens: { total: 3, noCache: 3, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, +}; + +async function collect(events: AsyncIterable): Promise { + const all: TurnEvent[] = []; + for await (const event of events) all.push(event); + return all; +} + +describe("AI SDK provider adapter", () => { + test("streams text deltas and a finish event", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "Hello" }, + { type: "text-delta", id: "t1", delta: " world" }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: undefined }, usage: USAGE }, + ], + }), + }), + }); + const provider = createAiSdkProvider(model, "mock"); + + const events = await collect( + provider.streamTurn({ messages: [{ role: "user", content: "hi" }], tools: [] }), + ); + + expect(events).toEqual([ + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: " world" }, + { type: "finish", finishReason: "stop", usage: { inputTokens: 3, outputTokens: 10 } }, + ]); + }); + + test("surfaces tool calls with parsed input", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "read_file", + input: JSON.stringify({ path: "src/index.ts" }), + }, + { + type: "finish", + finishReason: { unified: "tool-calls", raw: undefined }, + usage: USAGE, + }, + ], + }), + }), + }); + const provider = createAiSdkProvider(model, "mock"); + + const events = await collect( + provider.streamTurn({ + messages: [{ role: "user", content: "read the entrypoint" }], + tools: [ + { + name: "read_file", + description: "Read a file", + inputSchema: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + ], + }), + ); + + expect(events[0]).toEqual({ + type: "tool-call", + toolCallId: "call-1", + toolName: "read_file", + input: { path: "src/index.ts" }, + }); + expect(events[1]).toMatchObject({ type: "finish", finishReason: "tool-calls" }); + }); + + test("converts assistant tool calls and tool results into the prompt", async () => { + let seenPrompt: unknown; + const model = new MockLanguageModelV3({ + doStream: async (options) => { + seenPrompt = options.prompt; + return { + stream: simulateReadableStream({ + chunks: [ + { type: "finish", finishReason: { unified: "stop", raw: undefined }, usage: USAGE }, + ], + }), + }; + }, + }); + const provider = createAiSdkProvider(model, "mock"); + + await collect( + provider.streamTurn({ + system: "be terse", + messages: [ + { role: "user", content: "list files" }, + { + role: "assistant", + text: "Listing now.", + toolCalls: [{ toolCallId: "c1", toolName: "bash", input: { command: "ls" } }], + }, + { + role: "tool", + results: [{ toolCallId: "c1", toolName: "bash", output: "README.md" }], + }, + ], + tools: [{ name: "bash", description: "Run a command", inputSchema: { type: "object" } }], + }), + ); + + const prompt = seenPrompt as Array<{ role: string; content: unknown }>; + const roles = prompt.map((m) => m.role); + expect(roles).toEqual(["system", "user", "assistant", "tool"]); + expect(JSON.stringify(prompt)).toContain('"toolCallId":"c1"'); + expect(JSON.stringify(prompt)).toContain("README.md"); + }); +}); From a0458400710778f80f54fb4efa736239764815d7 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 21:01:13 +0800 Subject: [PATCH 05/20] feat(kernel): agent loop, event-sourced sessions, built-in tools, approvals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop is deliberately provider-dumb: it consumes the kernel-owned TurnEvent stream, so any model that speaks ModelProvider drives the same permission, audit, and update machinery. Every side effect flows through one choke point (executeToolCall) that appends tool.call / permission.decision / tool.result events before anything touches disk — the JSONL log is the source of truth the future resume/replay and audit features build on. Slice-1 permission policy: read-only tools allowed by policy, everything else round-trips an ACP session/request_permission to the frontend, with deny-by-default when the frontend can't answer. The rule engine + modes replace that branch in slice 2 without moving the choke point. Tools go through a runtime-adapter seam (node:* implementations that run on both Bun and Node) per the Bun-first-Node-tolerant decision. bash uses pipes, not a PTY — documented v1 limitation. --- bun.lock | 10 + packages/kernel/package.json | 13 ++ packages/kernel/src/agent-loop.ts | 276 +++++++++++++++++++++++++ packages/kernel/src/events.ts | 36 ++++ packages/kernel/src/index.ts | 5 + packages/kernel/src/kernel.ts | 140 +++++++++++++ packages/kernel/src/runtime.ts | 72 +++++++ packages/kernel/src/session.ts | 91 ++++++++ packages/kernel/src/tools/bash.ts | 52 +++++ packages/kernel/src/tools/edit-file.ts | 45 ++++ packages/kernel/src/tools/index.ts | 11 + packages/kernel/src/tools/read-file.ts | 34 +++ packages/kernel/src/tools/types.ts | 42 ++++ packages/kernel/test/kernel.test.ts | 222 ++++++++++++++++++++ packages/kernel/test/tools.test.ts | 93 +++++++++ 15 files changed, 1142 insertions(+) create mode 100644 packages/kernel/package.json create mode 100644 packages/kernel/src/agent-loop.ts create mode 100644 packages/kernel/src/events.ts create mode 100644 packages/kernel/src/index.ts create mode 100644 packages/kernel/src/kernel.ts create mode 100644 packages/kernel/src/runtime.ts create mode 100644 packages/kernel/src/session.ts create mode 100644 packages/kernel/src/tools/bash.ts create mode 100644 packages/kernel/src/tools/edit-file.ts create mode 100644 packages/kernel/src/tools/index.ts create mode 100644 packages/kernel/src/tools/read-file.ts create mode 100644 packages/kernel/src/tools/types.ts create mode 100644 packages/kernel/test/kernel.test.ts create mode 100644 packages/kernel/test/tools.test.ts diff --git a/bun.lock b/bun.lock index 0c57537..3b0e5b7 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,14 @@ "typescript": "^5.9.3", }, }, + "packages/kernel": { + "name": "@minerva/kernel", + "version": "0.1.0", + "dependencies": { + "@minerva/protocol": "workspace:*", + "@minerva/providers": "workspace:*", + }, + }, "packages/protocol": { "name": "@minerva/protocol", "version": "0.1.0", @@ -51,6 +59,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], + "@minerva/kernel": ["@minerva/kernel@workspace:packages/kernel"], + "@minerva/protocol": ["@minerva/protocol@workspace:packages/protocol"], "@minerva/providers": ["@minerva/providers@workspace:packages/providers"], diff --git a/packages/kernel/package.json b/packages/kernel/package.json new file mode 100644 index 0000000..f410f07 --- /dev/null +++ b/packages/kernel/package.json @@ -0,0 +1,13 @@ +{ + "name": "@minerva/kernel", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@minerva/protocol": "workspace:*", + "@minerva/providers": "workspace:*" + } +} diff --git a/packages/kernel/src/agent-loop.ts b/packages/kernel/src/agent-loop.ts new file mode 100644 index 0000000..04558e2 --- /dev/null +++ b/packages/kernel/src/agent-loop.ts @@ -0,0 +1,276 @@ +import { + CLIENT_METHODS, + type Connection, + type RequestPermissionResult, + type SessionUpdate, + type StopReason, +} from "@minerva/protocol"; +import type { + ModelProvider, + ProviderToolCall, + ProviderToolResult, + TurnFinishReason, + TurnUsage, +} from "@minerva/providers"; +import { now } from "./events"; +import type { Runtime } from "./runtime"; +import type { Session } from "./session"; +import type { KernelTool } from "./tools"; + +/** Backstop against runaway loops; becomes configurable with modes in slice 2. */ +const MAX_MODEL_TURNS = 40; + +export interface LoopContext { + session: Session; + connection: Connection; + provider: ModelProvider; + tools: KernelTool[]; + system: string; + runtime: Runtime; +} + +export async function runPrompt( + context: LoopContext, + promptText: string, +): Promise<{ stopReason: StopReason }> { + const { session, provider, tools, system } = context; + const signal = session.beginPrompt(); + const toolDefinitions = tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); + + try { + session.append({ type: "user.message", text: promptText, at: now() }); + session.messages.push({ role: "user", content: promptText }); + + for (let turn = 0; turn < MAX_MODEL_TURNS; turn++) { + let text = ""; + const toolCalls: ProviderToolCall[] = []; + let finishReason: TurnFinishReason = "other"; + let usage: TurnUsage | undefined; + let streamError: unknown; + + try { + const stream = provider.streamTurn({ + system, + messages: session.messages, + tools: toolDefinitions, + abortSignal: signal, + }); + for await (const event of stream) { + switch (event.type) { + case "text-delta": + text += event.text; + sendUpdate(context, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: event.text }, + }); + break; + case "tool-call": + toolCalls.push(event); + break; + case "finish": + finishReason = event.finishReason; + usage = event.usage; + break; + case "error": + streamError = event.error; + break; + } + } + } catch (error) { + if (session.cancelled) return finish(session, "cancelled"); + throw error; + } + if (session.cancelled) return finish(session, "cancelled", usage); + if (streamError !== undefined) { + throw streamError instanceof Error ? streamError : new Error(String(streamError)); + } + + if (text) session.append({ type: "assistant.message", text, at: now() }); + if (text || toolCalls.length > 0) { + session.messages.push({ role: "assistant", text, toolCalls }); + } + + if (toolCalls.length === 0) { + return finish(session, finishReason === "length" ? "max_tokens" : "end_turn", usage); + } + + const results: ProviderToolResult[] = []; + for (const call of toolCalls) { + if (session.cancelled) return finish(session, "cancelled", usage); + results.push(await executeToolCall(context, call, toolsByName.get(call.toolName))); + } + session.messages.push({ role: "tool", results }); + } + return finish(session, "max_turn_requests"); + } finally { + session.endPrompt(); + await session.flush(); + } +} + +async function executeToolCall( + context: LoopContext, + call: ProviderToolCall, + tool: KernelTool | undefined, +): Promise { + const { session } = context; + session.append({ + type: "tool.call", + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + at: now(), + }); + const kind = tool?.kind ?? "other"; + sendUpdate(context, { + sessionUpdate: "tool_call", + toolCallId: call.toolCallId, + title: titleFor(tool, call), + kind, + status: "pending", + rawInput: call.input, + }); + + if (!tool) { + return completeToolCall(context, call, { + output: `Unknown tool: ${call.toolName}`, + isError: true, + }); + } + + const decision = await checkPermission(context, call, tool); + session.append({ + type: "permission.decision", + toolCallId: call.toolCallId, + toolName: call.toolName, + decision: decision.allowed ? "allowed" : "denied", + source: decision.source, + at: now(), + }); + if (!decision.allowed) { + return completeToolCall(context, call, { + output: "The user denied permission for this tool call.", + isError: true, + }); + } + + sendUpdate(context, { + sessionUpdate: "tool_call_update", + toolCallId: call.toolCallId, + status: "in_progress", + }); + + let output: string; + let isError: boolean; + try { + const result = await tool.execute(call.input, { + cwd: session.cwd, + runtime: context.runtime, + }); + output = result.output; + isError = result.isError ?? false; + } catch (error) { + output = error instanceof Error ? error.message : String(error); + isError = true; + } + return completeToolCall(context, call, { output, isError }); +} + +function completeToolCall( + context: LoopContext, + call: ProviderToolCall, + result: { output: string; isError: boolean }, +): ProviderToolResult { + context.session.append({ + type: "tool.result", + toolCallId: call.toolCallId, + output: result.output, + isError: result.isError, + at: now(), + }); + sendUpdate(context, { + sessionUpdate: "tool_call_update", + toolCallId: call.toolCallId, + status: result.isError ? "failed" : "completed", + content: [{ type: "content", content: { type: "text", text: result.output } }], + rawOutput: result, + }); + return { + toolCallId: call.toolCallId, + toolName: call.toolName, + output: result.output, + isError: result.isError, + }; +} + +interface PermissionDecision { + allowed: boolean; + source: "policy" | "user"; +} + +/** + * Slice-1 policy: read-only tools are allowed outright; everything else asks + * the frontend via ACP session/request_permission. The rule engine and + * session modes (design decision #5) replace the first branch in slice 2. + */ +async function checkPermission( + context: LoopContext, + call: ProviderToolCall, + tool: KernelTool, +): Promise { + if (tool.readOnly) return { allowed: true, source: "policy" }; + + try { + const result = await context.connection.request( + CLIENT_METHODS.sessionRequestPermission, + { + sessionId: context.session.id, + toolCall: { + toolCallId: call.toolCallId, + title: titleFor(tool, call), + kind: tool.kind, + rawInput: call.input, + }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }, + ); + const allowed = result.outcome.outcome === "selected" && result.outcome.optionId === "allow"; + return { allowed, source: "user" }; + } catch { + // A frontend that can't answer permission requests gets deny-by-default. + return { allowed: false, source: "user" }; + } +} + +function titleFor(tool: KernelTool | undefined, call: ProviderToolCall): string { + if (!tool) return call.toolName; + try { + return tool.title(call.input); + } catch { + return tool.name; + } +} + +function sendUpdate(context: LoopContext, update: SessionUpdate): void { + context.connection.notify(CLIENT_METHODS.sessionUpdate, { + sessionId: context.session.id, + update, + }); +} + +function finish( + session: Session, + stopReason: StopReason, + usage?: TurnUsage, +): { stopReason: StopReason } { + session.append({ type: "turn.completed", stopReason, usage, at: now() }); + return { stopReason }; +} diff --git a/packages/kernel/src/events.ts b/packages/kernel/src/events.ts new file mode 100644 index 0000000..751f7ba --- /dev/null +++ b/packages/kernel/src/events.ts @@ -0,0 +1,36 @@ +import type { StopReason } from "@minerva/protocol"; +import type { TurnUsage } from "@minerva/providers"; + +/** + * Session event-log entries (design decision #7). The append-only JSONL + * stream of these events is the source of truth for a session: replaying it + * must be enough to rebuild model context, re-render any UI, and audit every + * side effect. Every variant therefore carries the full fact, not a diff. + */ + +export type SessionEvent = + | { + type: "session.created"; + sessionId: string; + cwd: string; + provider: string; + at: string; + } + | { type: "user.message"; text: string; at: string } + | { type: "assistant.message"; text: string; at: string } + | { type: "tool.call"; toolCallId: string; toolName: string; input: unknown; at: string } + | { type: "tool.result"; toolCallId: string; output: string; isError: boolean; at: string } + | { + type: "permission.decision"; + toolCallId: string; + toolName: string; + decision: "allowed" | "denied"; + /** "policy" = decided by kernel rules; "user" = decided via permission request. */ + source: "policy" | "user"; + at: string; + } + | { type: "turn.completed"; stopReason: StopReason; usage?: TurnUsage; at: string }; + +export function now(): string { + return new Date().toISOString(); +} diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts new file mode 100644 index 0000000..914b117 --- /dev/null +++ b/packages/kernel/src/index.ts @@ -0,0 +1,5 @@ +export * from "./events"; +export * from "./kernel"; +export * from "./runtime"; +export * from "./session"; +export * from "./tools"; diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts new file mode 100644 index 0000000..a1e6086 --- /dev/null +++ b/packages/kernel/src/kernel.ts @@ -0,0 +1,140 @@ +import { join } from "node:path"; +import { + AGENT_METHODS, + Connection, + type InitializeResult, + JSON_RPC_ERROR_CODES, + PROTOCOL_VERSION, + RpcError, + type SessionNewResult, + type SessionPromptResult, + type Transport, +} from "@minerva/protocol"; +import type { ModelProvider } from "@minerva/providers"; +import { runPrompt } from "./agent-loop"; +import { defaultRuntime, type Runtime } from "./runtime"; +import { Session } from "./session"; +import { builtinTools, type KernelTool } from "./tools"; + +export interface KernelOptions { + provider: ModelProvider; + runtime?: Runtime; + /** Root for session logs and config; defaults to ~/.minerva. */ + dataDir?: string; + tools?: KernelTool[]; + systemPrompt?: (cwd: string) => string; +} + +/** + * The kernel host: binds the agent side of the protocol onto a transport. + * Frontends only ever talk to this class through the wire protocol — + * there is no privileged in-process API (design decision #2). + */ +export class MinervaKernel { + #connection: Connection; + #sessions = new Map(); + #provider: ModelProvider; + #runtime: Runtime; + #dataDir: string; + #tools: KernelTool[]; + #systemPrompt: (cwd: string) => string; + + constructor(transport: Transport, options: KernelOptions) { + this.#provider = options.provider; + this.#runtime = options.runtime ?? defaultRuntime; + this.#dataDir = options.dataDir ?? join(this.#runtime.homedir(), ".minerva"); + this.#tools = options.tools ?? builtinTools(); + this.#systemPrompt = options.systemPrompt ?? defaultSystemPrompt; + + this.#connection = new Connection(transport); + this.#connection.handleRequest(AGENT_METHODS.initialize, () => this.#initialize()); + this.#connection.handleRequest(AGENT_METHODS.sessionNew, (params) => this.#sessionNew(params)); + this.#connection.handleRequest(AGENT_METHODS.sessionPrompt, (params) => + this.#sessionPrompt(params), + ); + this.#connection.handleNotification(AGENT_METHODS.sessionCancel, (params) => { + const { sessionId } = params as { sessionId?: string }; + if (sessionId) this.#sessions.get(sessionId)?.cancel(); + }); + } + + #initialize(): InitializeResult { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { loadSession: false }, + }; + } + + async #sessionNew(params: unknown): Promise { + const { cwd } = (params ?? {}) as { cwd?: string }; + if (typeof cwd !== "string" || cwd.length === 0) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, "session/new requires cwd"); + } + const session = await Session.create({ + cwd, + dataDir: this.#dataDir, + providerId: this.#provider.id, + runtime: this.#runtime, + }); + this.#sessions.set(session.id, session); + return { sessionId: session.id }; + } + + async #sessionPrompt(params: unknown): Promise { + const { sessionId, prompt } = (params ?? {}) as { + sessionId?: string; + prompt?: Array<{ type?: string; text?: string }>; + }; + const session = sessionId ? this.#sessions.get(sessionId) : undefined; + if (!session) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, `unknown session: ${sessionId}`); + } + if (session.promptActive) { + throw new RpcError( + JSON_RPC_ERROR_CODES.INVALID_REQUEST, + "a prompt is already running in this session", + ); + } + const text = (prompt ?? []) + .filter((block) => block.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join("\n"); + if (!text) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, "prompt has no text content"); + } + + return runPrompt( + { + session, + connection: this.#connection, + provider: this.#provider, + tools: this.#tools, + system: this.#systemPrompt(session.cwd), + runtime: this.#runtime, + }, + text, + ); + } + + /** For tests and diagnostics. */ + getSession(sessionId: string): Session | undefined { + return this.#sessions.get(sessionId); + } + + close(): void { + this.#connection.close(); + } +} + +export function createKernel(transport: Transport, options: KernelOptions): MinervaKernel { + return new MinervaKernel(transport, options); +} + +function defaultSystemPrompt(cwd: string): string { + return [ + `You are Minerva, a coding agent. Working directory: ${cwd}.`, + "Use the available tools to inspect, edit, and run code. Read a file before", + "editing it; edit_file requires the exact current text. Keep responses concise", + "and factual, and report command failures honestly instead of glossing over them.", + ].join(" "); +} diff --git a/packages/kernel/src/runtime.ts b/packages/kernel/src/runtime.ts new file mode 100644 index 0000000..d438c68 --- /dev/null +++ b/packages/kernel/src/runtime.ts @@ -0,0 +1,72 @@ +/** + * Runtime-adapter seam (design decision #9: Bun-first, Node-tolerant). + * The kernel touches the OS only through this interface; the default + * implementation uses node:* APIs, which run identically under Bun and + * Node, so a Node build of the kernel stays cheap. + */ + +import { spawn } from "node:child_process"; +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; + timedOut: boolean; +} + +export interface ExecOptions { + cwd: string; + timeoutMs: number; +} + +export interface Runtime { + readTextFile(path: string): Promise; + writeTextFile(path: string, content: string): Promise; + appendTextFile(path: string, content: string): Promise; + mkdirp(path: string): Promise; + exec(command: string, options: ExecOptions): Promise; + homedir(): string; +} + +export const defaultRuntime: Runtime = { + readTextFile: (path) => readFile(path, "utf8"), + writeTextFile: (path, content) => writeFile(path, content, "utf8"), + appendTextFile: (path, content) => appendFile(path, content, "utf8"), + mkdirp: async (path) => { + await mkdir(path, { recursive: true }); + }, + homedir: () => homedir(), + + exec(command, options) { + return new Promise((resolve, reject) => { + const child = spawn("/bin/bash", ["-c", command], { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, options.timeoutMs); + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ stdout, stderr, exitCode: code ?? -1, timedOut }); + }); + }); + }, +}; diff --git a/packages/kernel/src/session.ts b/packages/kernel/src/session.ts new file mode 100644 index 0000000..bca0608 --- /dev/null +++ b/packages/kernel/src/session.ts @@ -0,0 +1,91 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import type { ProviderMessage } from "@minerva/providers"; +import { now, type SessionEvent } from "./events"; +import type { Runtime } from "./runtime"; + +/** + * One conversation. The JSONL event log is the durable record; the + * in-memory provider messages are a projection of it, maintained + * incrementally (full replay/resume lands in slice 2). + */ +export class Session { + readonly id: string; + readonly cwd: string; + readonly messages: ProviderMessage[] = []; + promptActive = false; + + #logPath: string; + #runtime: Runtime; + #logChain: Promise = Promise.resolve(); + #abort: AbortController | null = null; + + private constructor(id: string, cwd: string, logPath: string, runtime: Runtime) { + this.id = id; + this.cwd = cwd; + this.#logPath = logPath; + this.#runtime = runtime; + } + + static async create(options: { + cwd: string; + dataDir: string; + providerId: string; + runtime: Runtime; + }): Promise { + const id = `ses_${randomUUID()}`; + const projectDir = join(options.dataDir, "projects", projectSlug(options.cwd)); + await options.runtime.mkdirp(projectDir); + const session = new Session(id, options.cwd, join(projectDir, `${id}.jsonl`), options.runtime); + session.append({ + type: "session.created", + sessionId: id, + cwd: options.cwd, + provider: options.providerId, + at: now(), + }); + return session; + } + + /** + * Append to the JSONL log. Writes are chained so events land in order + * even though callers don't await them; `flush()` awaits the chain. + */ + append(event: SessionEvent): void { + this.#logChain = this.#logChain.then(() => + this.#runtime.appendTextFile(this.#logPath, `${JSON.stringify(event)}\n`), + ); + } + + flush(): Promise { + return this.#logChain; + } + + get logPath(): string { + return this.#logPath; + } + + beginPrompt(): AbortSignal { + this.promptActive = true; + this.#abort = new AbortController(); + return this.#abort.signal; + } + + endPrompt(): void { + this.promptActive = false; + this.#abort = null; + } + + cancel(): void { + this.#abort?.abort(); + } + + get cancelled(): boolean { + return this.#abort?.signal.aborted ?? false; + } +} + +/** Filesystem-safe project identifier derived from the working directory. */ +export function projectSlug(cwd: string): string { + return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "root"; +} diff --git a/packages/kernel/src/tools/bash.ts b/packages/kernel/src/tools/bash.ts new file mode 100644 index 0000000..398c49d --- /dev/null +++ b/packages/kernel/src/tools/bash.ts @@ -0,0 +1,52 @@ +import type { KernelTool } from "./types"; +import { asRecord, requireString } from "./types"; + +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_TIMEOUT_MS = 600_000; +const MAX_OUTPUT_CHARS = 30_000; + +export const bashTool: KernelTool = { + name: "bash", + description: + "Run a shell command in the working directory (bash -c, pipes only — no TTY, so " + + "avoid interactive commands). Returns stdout, stderr, and the exit code. " + + "Default timeout 120s, maximum 600s.", + inputSchema: { + type: "object", + properties: { + command: { type: "string", description: "The shell command to run" }, + timeout_ms: { type: "number", description: "Timeout in milliseconds (max 600000)" }, + }, + required: ["command"], + }, + kind: "execute", + readOnly: false, + title(input) { + return requireString(asRecord(input), "command"); + }, + async execute(input, context) { + const record = asRecord(input); + const command = requireString(record, "command"); + const timeoutMs = Math.min( + typeof record.timeout_ms === "number" ? record.timeout_ms : DEFAULT_TIMEOUT_MS, + MAX_TIMEOUT_MS, + ); + + const result = await context.runtime.exec(command, { cwd: context.cwd, timeoutMs }); + + const parts: string[] = []; + if (result.stdout) parts.push(truncate(result.stdout)); + if (result.stderr) parts.push(`[stderr]\n${truncate(result.stderr)}`); + if (result.timedOut) parts.push(`[command timed out after ${timeoutMs}ms]`); + if (result.exitCode !== 0) parts.push(`[exit code: ${result.exitCode}]`); + return { + output: parts.join("\n") || "(no output)", + isError: result.exitCode !== 0 || result.timedOut, + }; + }, +}; + +function truncate(text: string): string { + if (text.length <= MAX_OUTPUT_CHARS) return text; + return `${text.slice(0, MAX_OUTPUT_CHARS)}\n[truncated: output is ${text.length} characters]`; +} diff --git a/packages/kernel/src/tools/edit-file.ts b/packages/kernel/src/tools/edit-file.ts new file mode 100644 index 0000000..52db840 --- /dev/null +++ b/packages/kernel/src/tools/edit-file.ts @@ -0,0 +1,45 @@ +import { resolve } from "node:path"; +import type { KernelTool } from "./types"; +import { asRecord, requireString } from "./types"; + +export const editFileTool: KernelTool = { + name: "edit_file", + description: + "Edit a text file by exact string replacement. old_string must match exactly one " + + "location in the file, including whitespace; include enough surrounding context to " + + "make it unique.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "File path to edit" }, + old_string: { type: "string", description: "Exact text to replace" }, + new_string: { type: "string", description: "Replacement text" }, + }, + required: ["path", "old_string", "new_string"], + }, + kind: "edit", + readOnly: false, + title(input) { + return `Edit ${requireString(asRecord(input), "path")}`; + }, + async execute(input, context) { + const record = asRecord(input); + const path = resolve(context.cwd, requireString(record, "path")); + const oldString = requireString(record, "old_string"); + const newString = typeof record.new_string === "string" ? record.new_string : ""; + + const content = await context.runtime.readTextFile(path); + const occurrences = content.split(oldString).length - 1; + if (occurrences === 0) { + return { output: `old_string not found in ${path}`, isError: true }; + } + if (occurrences > 1) { + return { + output: `old_string matches ${occurrences} locations in ${path}; add more context to make it unique`, + isError: true, + }; + } + await context.runtime.writeTextFile(path, content.replace(oldString, newString)); + return { output: `Edited ${path}` }; + }, +}; diff --git a/packages/kernel/src/tools/index.ts b/packages/kernel/src/tools/index.ts new file mode 100644 index 0000000..2612899 --- /dev/null +++ b/packages/kernel/src/tools/index.ts @@ -0,0 +1,11 @@ +import { bashTool } from "./bash"; +import { editFileTool } from "./edit-file"; +import { readFileTool } from "./read-file"; +import type { KernelTool } from "./types"; + +export * from "./types"; +export { bashTool, editFileTool, readFileTool }; + +export function builtinTools(): KernelTool[] { + return [readFileTool, editFileTool, bashTool]; +} diff --git a/packages/kernel/src/tools/read-file.ts b/packages/kernel/src/tools/read-file.ts new file mode 100644 index 0000000..364bc48 --- /dev/null +++ b/packages/kernel/src/tools/read-file.ts @@ -0,0 +1,34 @@ +import { resolve } from "node:path"; +import type { KernelTool } from "./types"; +import { asRecord, requireString } from "./types"; + +const MAX_CHARS = 50_000; + +export const readFileTool: KernelTool = { + name: "read_file", + description: + "Read a text file. The path may be absolute or relative to the working directory. " + + "Output is truncated after 50k characters.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "File path to read" }, + }, + required: ["path"], + }, + kind: "read", + readOnly: true, + title(input) { + return `Read ${requireString(asRecord(input), "path")}`; + }, + async execute(input, context) { + const path = resolve(context.cwd, requireString(asRecord(input), "path")); + const content = await context.runtime.readTextFile(path); + if (content.length > MAX_CHARS) { + return { + output: `${content.slice(0, MAX_CHARS)}\n[truncated: file is ${content.length} characters]`, + }; + } + return { output: content }; + }, +}; diff --git a/packages/kernel/src/tools/types.ts b/packages/kernel/src/tools/types.ts new file mode 100644 index 0000000..ae45fa5 --- /dev/null +++ b/packages/kernel/src/tools/types.ts @@ -0,0 +1,42 @@ +import type { ToolKind } from "@minerva/protocol"; +import type { Runtime } from "../runtime"; + +export interface ToolContext { + cwd: string; + runtime: Runtime; +} + +export interface ToolOutput { + output: string; + isError?: boolean; +} + +export interface KernelTool { + name: string; + description: string; + /** JSON Schema for the tool input, forwarded to the model provider. */ + inputSchema: Record; + /** ACP tool kind, used by frontends to pick rendering. */ + kind: ToolKind; + /** Read-only tools are auto-allowed by policy; others need permission. */ + readOnly: boolean; + /** Short human-readable label for a specific call, shown in UIs. */ + title(input: unknown): string; + execute(input: unknown, context: ToolContext): Promise; +} + +/** Tool inputs arrive as unknown; narrow to a record or fail the call. */ +export function asRecord(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + throw new Error("tool input must be an object"); + } + return input as Record; +} + +export function requireString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`missing required string parameter: ${key}`); + } + return value; +} diff --git a/packages/kernel/test/kernel.test.ts b/packages/kernel/test/kernel.test.ts new file mode 100644 index 0000000..e3f3729 --- /dev/null +++ b/packages/kernel/test/kernel.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENT_METHODS, + CLIENT_METHODS, + Connection, + createInProcTransportPair, + PROTOCOL_VERSION, + type RequestPermissionParams, + type SessionUpdateParams, +} from "@minerva/protocol"; +import { createScriptedProvider, type TurnEvent } from "@minerva/providers"; +import { createKernel, type SessionEvent } from "../src"; + +const FINISH_TOOLS: TurnEvent = { + type: "finish", + finishReason: "tool-calls", + usage: { inputTokens: 10, outputTokens: 5 }, +}; +const FINISH_STOP: TurnEvent = { + type: "finish", + finishReason: "stop", + usage: { inputTokens: 20, outputTokens: 8 }, +}; + +interface Harness { + client: Connection; + updates: SessionUpdateParams[]; + permissionRequests: RequestPermissionParams[]; + sessionId: string; + logEvents: () => SessionEvent[]; +} + +async function setup(options: { + turns: TurnEvent[][]; + permission?: "allow" | "reject"; +}): Promise { + const cwd = mkdtempSync(join(tmpdir(), "minerva-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-data-")); + writeFileSync(join(cwd, "hello.txt"), "hi from disk\n"); + + const [clientTransport, kernelTransport] = createInProcTransportPair(); + const kernel = createKernel(kernelTransport, { + provider: createScriptedProvider(options.turns), + dataDir, + }); + + const client = new Connection(clientTransport); + const updates: SessionUpdateParams[] = []; + const permissionRequests: RequestPermissionParams[] = []; + client.handleNotification(CLIENT_METHODS.sessionUpdate, (params) => { + updates.push(params as SessionUpdateParams); + }); + client.handleRequest(CLIENT_METHODS.sessionRequestPermission, (params) => { + permissionRequests.push(params as RequestPermissionParams); + return { + outcome: { outcome: "selected", optionId: options.permission ?? "allow" }, + }; + }); + + const init = await client.request<{ protocolVersion: number }>(AGENT_METHODS.initialize, { + protocolVersion: PROTOCOL_VERSION, + }); + expect(init.protocolVersion).toBe(PROTOCOL_VERSION); + + const { sessionId } = await client.request<{ sessionId: string }>(AGENT_METHODS.sessionNew, { + cwd, + }); + + return { + client, + updates, + permissionRequests, + sessionId, + logEvents: () => { + const logPath = kernel.getSession(sessionId)?.logPath; + if (!logPath) throw new Error("session log not found"); + return readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as SessionEvent); + }, + }; +} + +function prompt(harness: Harness, text: string) { + return harness.client.request<{ stopReason: string }>(AGENT_METHODS.sessionPrompt, { + sessionId: harness.sessionId, + prompt: [{ type: "text", text }], + }); +} + +describe("kernel over in-proc transport", () => { + test("full loop: streamed text, approved bash call, event log audit trail", async () => { + const harness = await setup({ + turns: [ + [ + { type: "text-delta", text: "Running " }, + { type: "text-delta", text: "a command." }, + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "echo from-tool" }, + }, + FINISH_TOOLS, + ], + [{ type: "text-delta", text: "All done." }, FINISH_STOP], + ], + }); + + const result = await prompt(harness, "run something"); + expect(result.stopReason).toBe("end_turn"); + + const kinds = harness.updates.map((u) => u.update.sessionUpdate); + expect(kinds).toEqual([ + "agent_message_chunk", + "agent_message_chunk", + "tool_call", + "tool_call_update", // in_progress + "tool_call_update", // completed + "agent_message_chunk", + ]); + const completed = harness.updates[4]?.update; + expect(completed).toMatchObject({ toolCallId: "c1", status: "completed" }); + expect(JSON.stringify(completed)).toContain("from-tool"); + expect(harness.permissionRequests).toHaveLength(1); + expect(harness.permissionRequests[0]?.toolCall.title).toBe("echo from-tool"); + + const eventTypes = harness.logEvents().map((event) => event.type); + expect(eventTypes).toEqual([ + "session.created", + "user.message", + "assistant.message", + "tool.call", + "permission.decision", + "tool.result", + "assistant.message", + "turn.completed", + ]); + const decision = harness.logEvents()[4]; + expect(decision).toMatchObject({ decision: "allowed", source: "user", toolName: "bash" }); + }); + + test("denied permission becomes an error tool result and the loop continues", async () => { + const harness = await setup({ + permission: "reject", + turns: [ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "edit_file", + input: { path: "hello.txt", old_string: "hi", new_string: "bye" }, + }, + FINISH_TOOLS, + ], + [{ type: "text-delta", text: "Understood, leaving the file alone." }, FINISH_STOP], + ], + }); + + const result = await prompt(harness, "edit the file"); + expect(result.stopReason).toBe("end_turn"); + + const failed = harness.updates.find( + (u) => u.update.sessionUpdate === "tool_call_update" && u.update.status === "failed", + ); + expect(failed).toBeDefined(); + const decision = harness.logEvents().find((event) => event.type === "permission.decision"); + expect(decision).toMatchObject({ decision: "denied", source: "user" }); + }); + + test("read-only tools bypass the permission request", async () => { + const harness = await setup({ + turns: [ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "read_file", + input: { path: "hello.txt" }, + }, + FINISH_TOOLS, + ], + [{ type: "text-delta", text: "The file says hi." }, FINISH_STOP], + ], + }); + + await prompt(harness, "what does hello.txt say?"); + expect(harness.permissionRequests).toHaveLength(0); + const decision = harness.logEvents().find((event) => event.type === "permission.decision"); + expect(decision).toMatchObject({ decision: "allowed", source: "policy" }); + const completed = harness.updates.find( + (u) => u.update.sessionUpdate === "tool_call_update" && u.update.status === "completed", + ); + expect(JSON.stringify(completed)).toContain("hi from disk"); + }); + + test("concurrent prompt on the same session is rejected", async () => { + const harness = await setup({ + turns: [ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "sleep 0.2" }, + }, + FINISH_TOOLS, + ], + [{ type: "text-delta", text: "done" }, FINISH_STOP], + ], + }); + + const first = prompt(harness, "slow one"); + await Bun.sleep(20); + expect(prompt(harness, "second")).rejects.toThrow("already running"); + await first; + }); +}); diff --git a/packages/kernel/test/tools.test.ts b/packages/kernel/test/tools.test.ts new file mode 100644 index 0000000..1324709 --- /dev/null +++ b/packages/kernel/test/tools.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bashTool, defaultRuntime, editFileTool, readFileTool } from "../src"; + +function tempProject(): string { + return mkdtempSync(join(tmpdir(), "minerva-tools-")); +} + +const ctx = (cwd: string) => ({ cwd, runtime: defaultRuntime }); + +describe("read_file", () => { + test("reads a file relative to cwd", async () => { + const cwd = tempProject(); + writeFileSync(join(cwd, "hello.txt"), "hi there\n"); + const result = await readFileTool.execute({ path: "hello.txt" }, ctx(cwd)); + expect(result.output).toBe("hi there\n"); + expect(result.isError).toBeUndefined(); + }); + + test("missing file surfaces as a thrown error for the loop to catch", async () => { + const cwd = tempProject(); + expect(readFileTool.execute({ path: "nope.txt" }, ctx(cwd))).rejects.toThrow(); + }); +}); + +describe("edit_file", () => { + test("replaces a unique occurrence", async () => { + const cwd = tempProject(); + const file = join(cwd, "code.ts"); + writeFileSync(file, "const a = 1;\nconst b = 2;\n"); + const result = await editFileTool.execute( + { path: "code.ts", old_string: "const b = 2;", new_string: "const b = 3;" }, + ctx(cwd), + ); + expect(result.isError).toBeUndefined(); + expect(await defaultRuntime.readTextFile(file)).toBe("const a = 1;\nconst b = 3;\n"); + }); + + test("rejects ambiguous matches without touching the file", async () => { + const cwd = tempProject(); + const file = join(cwd, "dup.txt"); + writeFileSync(file, "x\nx\n"); + const result = await editFileTool.execute( + { path: "dup.txt", old_string: "x", new_string: "y" }, + ctx(cwd), + ); + expect(result.isError).toBe(true); + expect(await defaultRuntime.readTextFile(file)).toBe("x\nx\n"); + }); + + test("rejects when old_string is absent", async () => { + const cwd = tempProject(); + writeFileSync(join(cwd, "a.txt"), "abc"); + const result = await editFileTool.execute( + { path: "a.txt", old_string: "zzz", new_string: "y" }, + ctx(cwd), + ); + expect(result.isError).toBe(true); + }); +}); + +describe("bash", () => { + test("captures stdout and exit code 0", async () => { + const result = await bashTool.execute({ command: "echo hello" }, ctx(tempProject())); + expect(result.output.trim()).toBe("hello"); + expect(result.isError).toBe(false); + }); + + test("nonzero exit is an error result with stderr", async () => { + const result = await bashTool.execute({ command: "echo oops >&2; exit 3" }, ctx(tempProject())); + expect(result.isError).toBe(true); + expect(result.output).toContain("oops"); + expect(result.output).toContain("[exit code: 3]"); + }); + + test("times out runaway commands", async () => { + const result = await bashTool.execute( + { command: "sleep 5", timeout_ms: 100 }, + ctx(tempProject()), + ); + expect(result.isError).toBe(true); + expect(result.output).toContain("timed out"); + }, 5000); + + test("runs in the session cwd", async () => { + const cwd = tempProject(); + const result = await bashTool.execute({ command: "pwd" }, ctx(cwd)); + // macOS tmpdir may resolve through /private; compare suffix. + expect(result.output.trim().endsWith(cwd.replace("/private", ""))).toBe(true); + }); +}); From d2e8cc5ff867f32af6ca281c5b250f089c1a3d6c Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 21:08:55 +0800 Subject: [PATCH 06/20] feat(client): frontend-agnostic protocol client + session view-model store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client package is the layer both frontends share (design decision #8): MinervaClient owns the connection and permission-request routing, SessionStore reduces protocol updates into an immutable, renderable view-model with zero UI imports. Ink consumes snapshots today; the Tauri webview consumes the identical store in M2, so update-handling never forks per frontend. Permission handling defaults to deny (cancelled outcome) when no handler is wired — a frontend must opt in to approving side effects. Integration tests drive a real kernel + scripted provider over the in-proc transport, asserting the store reaches the exact expected item sequence; the kernel is a devDependency only, keeping the runtime graph clean. --- bun.lock | 122 ++++++++++++++++++++++++++ packages/client/package.json | 16 ++++ packages/client/src/client.ts | 94 ++++++++++++++++++++ packages/client/src/index.ts | 2 + packages/client/src/store.ts | 131 ++++++++++++++++++++++++++++ packages/client/test/client.test.ts | 98 +++++++++++++++++++++ packages/client/test/store.test.ts | 77 ++++++++++++++++ 7 files changed, 540 insertions(+) create mode 100644 packages/client/package.json create mode 100644 packages/client/src/client.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/store.ts create mode 100644 packages/client/test/client.test.ts create mode 100644 packages/client/test/store.test.ts diff --git a/bun.lock b/bun.lock index 3b0e5b7..0b0522f 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,36 @@ "typescript": "^5.9.3", }, }, + "packages/cli": { + "name": "@minerva/cli", + "version": "0.1.0", + "bin": { + "minerva": "./src/index.tsx", + }, + "dependencies": { + "@minerva/client": "workspace:*", + "@minerva/kernel": "workspace:*", + "@minerva/protocol": "workspace:*", + "@minerva/providers": "workspace:*", + "ink": "^6", + "ink-text-input": "^6", + "react": "^19", + }, + "devDependencies": { + "@types/react": "^19", + }, + }, + "packages/client": { + "name": "@minerva/client", + "version": "0.1.0", + "dependencies": { + "@minerva/protocol": "workspace:*", + }, + "devDependencies": { + "@minerva/kernel": "workspace:*", + "@minerva/providers": "workspace:*", + }, + }, "packages/kernel": { "name": "@minerva/kernel", "version": "0.1.0", @@ -41,6 +71,8 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], @@ -59,6 +91,10 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], + "@minerva/cli": ["@minerva/cli@workspace:packages/cli"], + + "@minerva/client": ["@minerva/client@workspace:packages/client"], + "@minerva/kernel": ["@minerva/kernel@workspace:packages/kernel"], "@minerva/protocol": ["@minerva/protocol@workspace:packages/protocol"], @@ -73,20 +109,106 @@ "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "ai": ["ai@6.0.222", "", { "dependencies": { "@ai-sdk/gateway": "3.0.146", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kUzBaIHIfaDgBX7X22V4tE48MyYyS2IgCuj/zfANx0JfDAwgKwzKjl9pZUpYSnYPChI7i7yRGRoeTkebAFx1VQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..02de89c --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,16 @@ +{ + "name": "@minerva/client", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@minerva/protocol": "workspace:*" + }, + "devDependencies": { + "@minerva/kernel": "workspace:*", + "@minerva/providers": "workspace:*" + } +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000..98ac30e --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,94 @@ +import { + AGENT_METHODS, + CLIENT_METHODS, + Connection, + type InitializeResult, + PROTOCOL_VERSION, + type RequestPermissionParams, + type RequestPermissionResult, + type SessionNewResult, + type SessionPromptResult, + type SessionUpdateParams, + type StopReason, + type Transport, +} from "@minerva/protocol"; +import { SessionStore } from "./store"; + +export type PermissionHandler = ( + request: RequestPermissionParams, +) => Promise; + +export interface MinervaClientOptions { + /** + * Called when the kernel asks permission for a tool call. UIs resolve this + * from user input; leaving it unset rejects every request (safe default). + */ + onPermissionRequest?: PermissionHandler; +} + +/** + * Frontend-agnostic protocol client. Owns the connection and one + * SessionStore per session; UIs subscribe to stores and render snapshots. + */ +export class MinervaClient { + #connection: Connection; + #stores = new Map(); + #onPermissionRequest: PermissionHandler; + + constructor(transport: Transport, options: MinervaClientOptions = {}) { + this.#onPermissionRequest = + options.onPermissionRequest ?? (async () => ({ outcome: { outcome: "cancelled" } })); + + this.#connection = new Connection(transport); + this.#connection.handleNotification(CLIENT_METHODS.sessionUpdate, (params) => { + const { sessionId, update } = params as SessionUpdateParams; + this.#stores.get(sessionId)?.apply(update); + }); + this.#connection.handleRequest(CLIENT_METHODS.sessionRequestPermission, (params) => + this.#onPermissionRequest(params as RequestPermissionParams), + ); + } + + initialize(): Promise { + return this.#connection.request(AGENT_METHODS.initialize, { + protocolVersion: PROTOCOL_VERSION, + }); + } + + async newSession(cwd: string): Promise<{ sessionId: string; store: SessionStore }> { + const { sessionId } = await this.#connection.request( + AGENT_METHODS.sessionNew, + { cwd }, + ); + const store = new SessionStore(); + this.#stores.set(sessionId, store); + return { sessionId, store }; + } + + async prompt(sessionId: string, text: string): Promise { + const store = this.#stores.get(sessionId); + store?.addUserMessage(text); + store?.setBusy(true); + try { + const result = await this.#connection.request( + AGENT_METHODS.sessionPrompt, + { sessionId, prompt: [{ type: "text", text }] }, + ); + return result.stopReason; + } finally { + store?.setBusy(false); + } + } + + cancel(sessionId: string): void { + this.#connection.notify(AGENT_METHODS.sessionCancel, { sessionId }); + } + + getStore(sessionId: string): SessionStore | undefined { + return this.#stores.get(sessionId); + } + + close(): void { + this.#connection.close(); + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..e3c9b16 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./store"; diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts new file mode 100644 index 0000000..ee47470 --- /dev/null +++ b/packages/client/src/store.ts @@ -0,0 +1,131 @@ +import type { SessionUpdate, ToolCallContent, ToolCallStatus, ToolKind } from "@minerva/protocol"; + +/** + * Session view-model (design decision #8): protocol updates in, renderable + * state out, zero UI imports. The Ink CLI and the Tauri GUI both consume + * this store, so rendering differences never leak into update handling. + */ + +export type ViewItem = + | { kind: "user"; text: string } + | { kind: "assistant"; text: string; streaming: boolean } + | { + kind: "tool"; + toolCallId: string; + title: string; + toolKind: ToolKind; + status: ToolCallStatus; + output?: string; + } + | { kind: "info"; text: string }; + +export interface SessionViewModel { + items: ViewItem[]; + busy: boolean; +} + +const EMPTY: SessionViewModel = { items: [], busy: false }; + +export class SessionStore { + #viewModel: SessionViewModel = EMPTY; + #listeners = new Set<() => void>(); + + /** Stable snapshot for useSyncExternalStore-style consumers. */ + get snapshot(): SessionViewModel { + return this.#viewModel; + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + addUserMessage(text: string): void { + this.#push({ kind: "user", text }); + } + + addInfo(text: string): void { + this.#push({ kind: "info", text }); + } + + setBusy(busy: boolean): void { + const items = busy ? this.#viewModel.items : this.#viewModel.items.map(finalizeStreamingItem); + this.#set({ items, busy }); + } + + apply(update: SessionUpdate): void { + switch (update.sessionUpdate) { + case "agent_message_chunk": + this.#appendAssistantText(update.content.text); + break; + case "user_message_chunk": + this.addUserMessage(update.content.text); + break; + case "agent_thought_chunk": + // Not surfaced in slice 1. + break; + case "tool_call": + this.#push({ + kind: "tool", + toolCallId: update.toolCallId, + title: update.title, + toolKind: update.kind, + status: update.status, + }); + break; + case "tool_call_update": + this.#updateToolCall(update.toolCallId, (item) => ({ + ...item, + status: update.status ?? item.status, + title: update.title ?? item.title, + output: extractText(update.content) ?? item.output, + })); + break; + } + } + + #appendAssistantText(text: string): void { + const items = [...this.#viewModel.items]; + const last = items[items.length - 1]; + if (last?.kind === "assistant" && last.streaming) { + items[items.length - 1] = { ...last, text: last.text + text }; + } else { + items.push({ kind: "assistant", text, streaming: true }); + } + this.#set({ ...this.#viewModel, items }); + } + + #updateToolCall( + toolCallId: string, + change: (item: Extract) => ViewItem, + ): void { + const items = this.#viewModel.items.map((item) => + item.kind === "tool" && item.toolCallId === toolCallId ? change(item) : item, + ); + this.#set({ ...this.#viewModel, items }); + } + + #push(item: ViewItem): void { + // A non-assistant item ends the current streamed message: the next + // agent_message_chunk starts a fresh block after the tool call. + const items = [...this.#viewModel.items.map(finalizeStreamingItem), item]; + this.#set({ ...this.#viewModel, items }); + } + + #set(viewModel: SessionViewModel): void { + this.#viewModel = viewModel; + for (const listener of this.#listeners) listener(); + } +} + +function finalizeStreamingItem(item: ViewItem): ViewItem { + return item.kind === "assistant" && item.streaming ? { ...item, streaming: false } : item; +} + +function extractText(content: ToolCallContent[] | undefined): string | undefined { + if (!content) return undefined; + const texts = content + .map((entry) => (entry.type === "content" ? entry.content.text : undefined)) + .filter((text): text is string => typeof text === "string"); + return texts.length > 0 ? texts.join("\n") : undefined; +} diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts new file mode 100644 index 0000000..c7de04b --- /dev/null +++ b/packages/client/test/client.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createKernel } from "@minerva/kernel"; +import { createInProcTransportPair, PROTOCOL_VERSION } from "@minerva/protocol"; +import { createScriptedProvider } from "@minerva/providers"; +import { MinervaClient } from "../src"; + +describe("MinervaClient against a real kernel", () => { + test("prompt drives the store through a full tool-using exchange", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-cli-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-cli-data-")); + const [clientTransport, kernelTransport] = createInProcTransportPair(); + createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider([ + [ + { type: "text-delta", text: "Checking." }, + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "echo integration" }, + }, + { type: "finish", finishReason: "tool-calls", usage: {} }, + ], + [ + { type: "text-delta", text: "It printed integration." }, + { type: "finish", finishReason: "stop", usage: {} }, + ], + ]), + }); + + const approved: string[] = []; + const client = new MinervaClient(clientTransport, { + onPermissionRequest: async (request) => { + approved.push(request.toolCall.title); + return { outcome: { outcome: "selected", optionId: "allow" } }; + }, + }); + + const init = await client.initialize(); + expect(init.protocolVersion).toBe(PROTOCOL_VERSION); + + const { sessionId, store } = await client.newSession(cwd); + const stopReason = await client.prompt(sessionId, "run echo"); + + expect(stopReason).toBe("end_turn"); + expect(approved).toEqual(["echo integration"]); + expect(store.snapshot.busy).toBe(false); + expect(store.snapshot.items).toEqual([ + { kind: "user", text: "run echo" }, + { kind: "assistant", text: "Checking.", streaming: false }, + { + kind: "tool", + toolCallId: "c1", + title: "echo integration", + toolKind: "execute", + status: "completed", + output: "integration\n", + }, + { kind: "assistant", text: "It printed integration.", streaming: false }, + ]); + }); + + test("default permission handler denies", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-cli-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-cli-data-")); + const [clientTransport, kernelTransport] = createInProcTransportPair(); + createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider([ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "echo nope" }, + }, + { type: "finish", finishReason: "tool-calls", usage: {} }, + ], + [ + { type: "text-delta", text: "Okay, not running it." }, + { type: "finish", finishReason: "stop", usage: {} }, + ], + ]), + }); + + const client = new MinervaClient(clientTransport); + await client.initialize(); + const { sessionId, store } = await client.newSession(cwd); + await client.prompt(sessionId, "run echo"); + + const tool = store.snapshot.items.find((item) => item.kind === "tool"); + expect(tool).toMatchObject({ status: "failed" }); + }); +}); diff --git a/packages/client/test/store.test.ts b/packages/client/test/store.test.ts new file mode 100644 index 0000000..573bd3d --- /dev/null +++ b/packages/client/test/store.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { SessionStore } from "../src"; + +describe("SessionStore reducer", () => { + test("streams assistant chunks into one item, splits around tool calls", () => { + const store = new SessionStore(); + store.addUserMessage("do the thing"); + store.setBusy(true); + store.apply({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Wor" } }); + store.apply({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "king." }, + }); + store.apply({ + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "echo hi", + kind: "execute", + status: "pending", + }); + store.apply({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Done." }, + }); + store.setBusy(false); + + expect(store.snapshot.busy).toBe(false); + expect(store.snapshot.items).toEqual([ + { kind: "user", text: "do the thing" }, + { kind: "assistant", text: "Working.", streaming: false }, + { kind: "tool", toolCallId: "c1", title: "echo hi", toolKind: "execute", status: "pending" }, + { kind: "assistant", text: "Done.", streaming: false }, + ]); + }); + + test("tool_call_update merges status and text output", () => { + const store = new SessionStore(); + store.apply({ + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "Read a.txt", + kind: "read", + status: "pending", + }); + store.apply({ sessionUpdate: "tool_call_update", toolCallId: "c1", status: "in_progress" }); + store.apply({ + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "file contents" } }], + }); + + expect(store.snapshot.items).toEqual([ + { + kind: "tool", + toolCallId: "c1", + title: "Read a.txt", + toolKind: "read", + status: "completed", + output: "file contents", + }, + ]); + }); + + test("notifies subscribers on every change and snapshots are immutable", () => { + const store = new SessionStore(); + let notified = 0; + store.subscribe(() => { + notified += 1; + }); + const before = store.snapshot; + store.addUserMessage("hello"); + expect(notified).toBe(1); + expect(store.snapshot).not.toBe(before); + expect(before.items).toHaveLength(0); + }); +}); From 907fa54f12ab55a0e4c76f345407c5796565bd6c Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 21:08:55 +0800 Subject: [PATCH 07/20] feat(cli): Ink REPL over the in-proc transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real frontend: streams assistant text, renders tool calls with status and output preview, and answers kernel permission requests with a y/n prompt. The CLI embeds the kernel but still speaks only the protocol via the in-proc transport pair — the same messages a Tauri sidecar will see — so running the CLI exercises the wire contract on every keystroke. PermissionBridge decouples the client (constructed before React mounts) from the UI's lifetime; with no UI attached, requests deny by default. After Ink unmounts, stdin's raw-mode listener keeps Bun alive, so the entrypoint awaits waitUntilExit and exits explicitly. Verified under a real PTY (expect script): banner and input render on Bun, /exit terminates cleanly. --- packages/cli/package.json | 24 ++++ packages/cli/src/app.tsx | 188 ++++++++++++++++++++++++++ packages/cli/src/index.tsx | 32 +++++ packages/cli/src/permission-bridge.ts | 21 +++ 4 files changed, 265 insertions(+) create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/app.tsx create mode 100644 packages/cli/src/index.tsx create mode 100644 packages/cli/src/permission-bridge.ts diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..e2c8cb6 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "@minerva/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "minerva": "./src/index.tsx" + }, + "scripts": { + "dev": "bun run src/index.tsx" + }, + "dependencies": { + "@minerva/client": "workspace:*", + "@minerva/kernel": "workspace:*", + "@minerva/protocol": "workspace:*", + "@minerva/providers": "workspace:*", + "ink": "^6", + "ink-text-input": "^6", + "react": "^19" + }, + "devDependencies": { + "@types/react": "^19" + } +} diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx new file mode 100644 index 0000000..2386551 --- /dev/null +++ b/packages/cli/src/app.tsx @@ -0,0 +1,188 @@ +import type { MinervaClient, SessionStore, ViewItem } from "@minerva/client"; +import { Box, Text, useApp, useInput } from "ink"; +import TextInput from "ink-text-input"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import type { PendingPermission, PermissionBridge } from "./permission-bridge"; + +interface AppProps { + client: MinervaClient; + bridge: PermissionBridge; + model: string; + cwd: string; +} + +export function App({ client, bridge, model, cwd }: AppProps) { + const [session, setSession] = useState<{ id: string; store: SessionStore } | null>(null); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + bridge.handler = (request) => + new Promise((resolve) => { + setPending({ + request, + resolve: (result) => { + setPending(null); + resolve(result); + }, + }); + }); + client + .initialize() + .then(() => client.newSession(cwd)) + .then(({ sessionId, store }) => setSession({ id: sessionId, store })) + .catch((cause) => setError(String(cause))); + return () => { + bridge.handler = null; + }; + }, [client, bridge, cwd]); + + if (error) { + return Failed to start session: {error}; + } + if (!session) { + return Starting Minerva…; + } + return ( + + + Minerva · {model} · {cwd} · /exit to quit, esc to cancel + + + + ); +} + +function Chat({ + client, + session, + pending, +}: { + client: MinervaClient; + session: { id: string; store: SessionStore }; + pending: PendingPermission | null; +}) { + const subscribe = useCallback( + (listener: () => void) => session.store.subscribe(listener), + [session.store], + ); + const viewModel = useSyncExternalStore(subscribe, () => session.store.snapshot); + const [draft, setDraft] = useState(""); + const { exit } = useApp(); + + useInput((_input, key) => { + if (key.escape && viewModel.busy) client.cancel(session.id); + }); + + const submit = (value: string) => { + const text = value.trim(); + if (!text) return; + setDraft(""); + if (text === "/exit" || text === "/quit") { + exit(); + return; + } + client.prompt(session.id, text).catch((cause) => { + session.store.addInfo(`error: ${String(cause)}`); + }); + }; + + return ( + + {viewModel.items.map((item, index) => ( + + ))} + {pending ? ( + + ) : viewModel.busy ? ( + ✳ working… (esc to cancel) + ) : ( + + {"> "} + + + )} + + ); +} + +function itemKey(item: ViewItem, index: number): string { + return item.kind === "tool" ? `tool-${item.toolCallId}` : `${item.kind}-${index}`; +} + +function ItemView({ item }: { item: ViewItem }) { + switch (item.kind) { + case "user": + return ( + + {"> "} + {item.text} + + ); + case "assistant": + return ( + + {item.text} + + ); + case "tool": + return ; + case "info": + return ( + + {item.text} + + ); + } +} + +const STATUS_COLOR = { + pending: "yellow", + in_progress: "yellow", + completed: "green", + failed: "red", +} as const; + +function ToolView({ item }: { item: Extract }) { + const preview = item.output ? firstLines(item.output, 4) : null; + return ( + + + ⏺ + {item.title} + [{item.status}] + + {preview ? ( + + {preview} + + ) : null} + + ); +} + +function firstLines(text: string, count: number): string { + const lines = text.trimEnd().split("\n"); + if (lines.length <= count) return lines.join("\n"); + return `${lines.slice(0, count).join("\n")}\n… (${lines.length - count} more lines)`; +} + +function PermissionPrompt({ pending }: { pending: PendingPermission }) { + useInput((input) => { + const answer = input.toLowerCase(); + if (answer === "y") { + pending.resolve({ outcome: { outcome: "selected", optionId: "allow" } }); + } else if (answer === "n") { + pending.resolve({ outcome: { outcome: "selected", optionId: "reject" } }); + } + }); + return ( + + + Permission required + + {pending.request.toolCall.title} + allow? (y/n) + + ); +} diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx new file mode 100644 index 0000000..8c5ad62 --- /dev/null +++ b/packages/cli/src/index.tsx @@ -0,0 +1,32 @@ +#!/usr/bin/env bun +import { MinervaClient } from "@minerva/client"; +import { createKernel } from "@minerva/kernel"; +import { createInProcTransportPair } from "@minerva/protocol"; +import { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL } from "@minerva/providers"; +import { render } from "ink"; +import { App } from "./app"; +import { PermissionBridge } from "./permission-bridge"; + +if (!process.env.ANTHROPIC_API_KEY) { + console.error("ANTHROPIC_API_KEY is not set. Export it and try again:"); + console.error(' export ANTHROPIC_API_KEY="sk-ant-..."'); + process.exit(1); +} + +const model = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; +const cwd = process.cwd(); + +// The CLI embeds the kernel, but only across the protocol's in-proc +// transport — the same messages a Tauri sidecar or remote kernel would see. +const [clientTransport, kernelTransport] = createInProcTransportPair(); +createKernel(kernelTransport, { provider: createAnthropicProvider({ model }) }); + +const bridge = new PermissionBridge(); +const client = new MinervaClient(clientTransport, { + onPermissionRequest: bridge.onPermissionRequest, +}); + +const app = render(); +await app.waitUntilExit(); +// Ink unmounts but stdin's raw-mode listener keeps the runtime alive. +process.exit(0); diff --git a/packages/cli/src/permission-bridge.ts b/packages/cli/src/permission-bridge.ts new file mode 100644 index 0000000..2fa03a8 --- /dev/null +++ b/packages/cli/src/permission-bridge.ts @@ -0,0 +1,21 @@ +import type { PermissionHandler } from "@minerva/client"; +import type { RequestPermissionParams, RequestPermissionResult } from "@minerva/protocol"; + +export interface PendingPermission { + request: RequestPermissionParams; + resolve: (result: RequestPermissionResult) => void; +} + +/** + * Hands kernel permission requests to the currently mounted UI. The client + * is constructed before React renders, so the bridge decouples their + * lifetimes; with no UI attached, requests resolve as cancelled (deny). + */ +export class PermissionBridge { + handler: PermissionHandler | null = null; + + readonly onPermissionRequest: PermissionHandler = (request) => { + if (this.handler) return this.handler(request); + return Promise.resolve({ outcome: { outcome: "cancelled" } }); + }; +} From 22df3566a1d9f2c6ffb528a036513264b9778361 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 21:41:40 +0800 Subject: [PATCH 08/20] fix: apply slice-1 code-review findings (cancellation, exec, validation, audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An xhigh multi-agent review of the slice confirmed 15 defects; this applies all of them. Four groups: Cancellation plumbing — the prompt's AbortSignal now reaches permission requests (Connection.request accepts a signal) and tool execution (ToolContext.signal → runtime.exec), a cancel arriving during the permission round-trip wins over a late approval, and cancelled turns record streamed text and synthesize results for every pending tool call so the session history never contains dangling tool_use blocks that would 400 all later prompts. The ACP cancelled outcome now cancels the turn per spec instead of masquerading as a user denial. exec hardening — children run in their own process group so kills reach grandchildren; 'exit' plus a pipe-grace window prevents backgrounded daemons from hanging the CLI forever; setEncoding keeps multi-byte UTF-8 intact across chunk boundaries; per-stream accumulation is capped. Input validation — edit_file rejects non-string new_string instead of silently deleting code, replacement uses a replacer function so $-patterns can't corrupt files, bash falls back to the default timeout for 0/negative/NaN, and read_file is confined to the workspace because policy auto-allows it with no permission prompt. Audit fidelity — failed turns append turn.failed, permission decisions record who actually decided (policy/user/frontend/error), and one failed JSONL write no longer poisons the log chain (it fails that prompt loudly once, then recovers). The client also rejects overlapping prompts before touching the shared store. --- packages/cli/src/app.tsx | 7 +- packages/client/src/client.ts | 10 + packages/client/test/client.test.ts | 51 ++++- packages/kernel/src/agent-loop.ts | 244 +++++++++++++++-------- packages/kernel/src/events.ts | 11 +- packages/kernel/src/runtime.ts | 78 +++++++- packages/kernel/src/session.ts | 28 ++- packages/kernel/src/tools/bash.ts | 22 +- packages/kernel/src/tools/edit-file.ts | 14 +- packages/kernel/src/tools/read-file.ts | 10 +- packages/kernel/src/tools/types.ts | 18 ++ packages/kernel/test/kernel.test.ts | 50 ++++- packages/kernel/test/regressions.test.ts | 144 +++++++++++++ packages/protocol/src/jsonrpc.ts | 17 +- 14 files changed, 582 insertions(+), 122 deletions(-) create mode 100644 packages/kernel/test/regressions.test.ts diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index 2386551..4907df9 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -168,12 +168,15 @@ function firstLines(text: string, count: number): string { } function PermissionPrompt({ pending }: { pending: PendingPermission }) { - useInput((input) => { + useInput((input, key) => { const answer = input.toLowerCase(); if (answer === "y") { pending.resolve({ outcome: { outcome: "selected", optionId: "allow" } }); } else if (answer === "n") { pending.resolve({ outcome: { outcome: "selected", optionId: "reject" } }); + } else if (key.escape) { + // ACP cancelled outcome: abandon the whole turn, not just this call. + pending.resolve({ outcome: { outcome: "cancelled" } }); } }); return ( @@ -182,7 +185,7 @@ function PermissionPrompt({ pending }: { pending: PendingPermission }) { Permission required {pending.request.toolCall.title} - allow? (y/n) + allow? (y/n, esc cancels the turn) ); } diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 98ac30e..bb37fc7 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -33,6 +33,7 @@ export interface MinervaClientOptions { export class MinervaClient { #connection: Connection; #stores = new Map(); + #activePrompts = new Set(); #onPermissionRequest: PermissionHandler; constructor(transport: Transport, options: MinervaClientOptions = {}) { @@ -66,6 +67,14 @@ export class MinervaClient { } async prompt(sessionId: string, text: string): Promise { + // Reject overlapping prompts before touching the store: the kernel would + // reject them anyway, but by then the rejected call's echo and its + // finally-setBusy(false) would have clobbered the in-flight prompt's + // view state. + if (this.#activePrompts.has(sessionId)) { + throw new Error("a prompt is already running in this session"); + } + this.#activePrompts.add(sessionId); const store = this.#stores.get(sessionId); store?.addUserMessage(text); store?.setBusy(true); @@ -76,6 +85,7 @@ export class MinervaClient { ); return result.stopReason; } finally { + this.#activePrompts.delete(sessionId); store?.setBusy(false); } } diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index c7de04b..c4cddc7 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -64,7 +64,7 @@ describe("MinervaClient against a real kernel", () => { ]); }); - test("default permission handler denies", async () => { + test("default permission handler cancels the turn (ACP cancelled outcome)", async () => { const cwd = mkdtempSync(join(tmpdir(), "minerva-cli-proj-")); const dataDir = mkdtempSync(join(tmpdir(), "minerva-cli-data-")); const [clientTransport, kernelTransport] = createInProcTransportPair(); @@ -80,19 +80,58 @@ describe("MinervaClient against a real kernel", () => { }, { type: "finish", finishReason: "tool-calls", usage: {} }, ], - [ - { type: "text-delta", text: "Okay, not running it." }, - { type: "finish", finishReason: "stop", usage: {} }, - ], ]), }); const client = new MinervaClient(clientTransport); await client.initialize(); const { sessionId, store } = await client.newSession(cwd); - await client.prompt(sessionId, "run echo"); + const stopReason = await client.prompt(sessionId, "run echo"); + expect(stopReason).toBe("cancelled"); const tool = store.snapshot.items.find((item) => item.kind === "tool"); expect(tool).toMatchObject({ status: "failed" }); }); + + test("overlapping prompt is rejected without disturbing the store", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-cli-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-cli-data-")); + const [clientTransport, kernelTransport] = createInProcTransportPair(); + createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider([ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "sleep 0.2" }, + }, + { type: "finish", finishReason: "tool-calls", usage: {} }, + ], + [ + { type: "text-delta", text: "done" }, + { type: "finish", finishReason: "stop", usage: {} }, + ], + ]), + }); + + const client = new MinervaClient(clientTransport, { + onPermissionRequest: async () => ({ + outcome: { outcome: "selected", optionId: "allow" }, + }), + }); + await client.initialize(); + const { sessionId, store } = await client.newSession(cwd); + + const first = client.prompt(sessionId, "slow one"); + await Bun.sleep(20); + expect(client.prompt(sessionId, "second")).rejects.toThrow("already running"); + expect(store.snapshot.busy).toBe(true); + expect( + store.snapshot.items.filter((item) => item.kind === "user").map((item) => item.text), + ).toEqual(["slow one"]); + await first; + expect(store.snapshot.busy).toBe(false); + }); }); diff --git a/packages/kernel/src/agent-loop.ts b/packages/kernel/src/agent-loop.ts index 04558e2..fd1c15a 100644 --- a/packages/kernel/src/agent-loop.ts +++ b/packages/kernel/src/agent-loop.ts @@ -27,14 +27,37 @@ export interface LoopContext { tools: KernelTool[]; system: string; runtime: Runtime; + signal?: AbortSignal; } export async function runPrompt( context: LoopContext, promptText: string, ): Promise<{ stopReason: StopReason }> { - const { session, provider, tools, system } = context; + const { session } = context; const signal = session.beginPrompt(); + try { + return await runLoop({ ...context, signal }, promptText); + } catch (error) { + // Errored turns must still leave a terminal event, or replay/audit can't + // tell a crashed turn from one still in flight. + session.append({ + type: "turn.failed", + error: error instanceof Error ? error.message : String(error), + at: now(), + }); + throw error; + } finally { + session.endPrompt(); + await session.flush(); + } +} + +async function runLoop( + context: LoopContext, + promptText: string, +): Promise<{ stopReason: StopReason }> { + const { session, provider, tools, system, signal } = context; const toolDefinitions = tools.map((tool) => ({ name: tool.name, description: tool.description, @@ -42,75 +65,94 @@ export async function runPrompt( })); const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); - try { - session.append({ type: "user.message", text: promptText, at: now() }); - session.messages.push({ role: "user", content: promptText }); + session.append({ type: "user.message", text: promptText, at: now() }); + session.messages.push({ role: "user", content: promptText }); - for (let turn = 0; turn < MAX_MODEL_TURNS; turn++) { - let text = ""; - const toolCalls: ProviderToolCall[] = []; - let finishReason: TurnFinishReason = "other"; - let usage: TurnUsage | undefined; - let streamError: unknown; - - try { - const stream = provider.streamTurn({ - system, - messages: session.messages, - tools: toolDefinitions, - abortSignal: signal, - }); - for await (const event of stream) { - switch (event.type) { - case "text-delta": - text += event.text; - sendUpdate(context, { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: event.text }, - }); - break; - case "tool-call": - toolCalls.push(event); - break; - case "finish": - finishReason = event.finishReason; - usage = event.usage; - break; - case "error": - streamError = event.error; - break; - } - } - } catch (error) { - if (session.cancelled) return finish(session, "cancelled"); - throw error; - } - if (session.cancelled) return finish(session, "cancelled", usage); - if (streamError !== undefined) { - throw streamError instanceof Error ? streamError : new Error(String(streamError)); - } + for (let turn = 0; turn < MAX_MODEL_TURNS; turn++) { + let text = ""; + const toolCalls: ProviderToolCall[] = []; + let finishReason: TurnFinishReason = "other"; + let usage: TurnUsage | undefined; + let streamError: unknown; + // Everything streamed to the UI must also be recorded, even when the + // turn is cancelled mid-stream — the event log has to be able to + // re-render what the user actually saw. + const recordAssistantMessage = () => { if (text) session.append({ type: "assistant.message", text, at: now() }); if (text || toolCalls.length > 0) { session.messages.push({ role: "assistant", text, toolCalls }); } + }; + // A pushed assistant message with tool calls must always be followed by + // matching tool results, or the provider rejects the whole history on + // the next prompt. + const cancelToolBatch = () => { + if (toolCalls.length === 0) return; + session.messages.push({ + role: "tool", + results: toolCalls.map((call) => cancelToolCall(context, call)), + }); + }; - if (toolCalls.length === 0) { - return finish(session, finishReason === "length" ? "max_tokens" : "end_turn", usage); + try { + const stream = provider.streamTurn({ + system, + messages: session.messages, + tools: toolDefinitions, + abortSignal: signal, + }); + for await (const event of stream) { + switch (event.type) { + case "text-delta": + text += event.text; + sendUpdate(context, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: event.text }, + }); + break; + case "tool-call": + toolCalls.push(event); + break; + case "finish": + finishReason = event.finishReason; + usage = event.usage; + break; + case "error": + streamError = event.error; + break; + } } + } catch (error) { + if (!session.cancelled) throw error; + } + if (session.cancelled) { + recordAssistantMessage(); + cancelToolBatch(); + return finish(session, "cancelled", usage); + } + if (streamError !== undefined) { + throw streamError instanceof Error ? streamError : new Error(String(streamError)); + } - const results: ProviderToolResult[] = []; - for (const call of toolCalls) { - if (session.cancelled) return finish(session, "cancelled", usage); - results.push(await executeToolCall(context, call, toolsByName.get(call.toolName))); - } - session.messages.push({ role: "tool", results }); + recordAssistantMessage(); + + if (toolCalls.length === 0) { + return finish(session, finishReason === "length" ? "max_tokens" : "end_turn", usage); } - return finish(session, "max_turn_requests"); - } finally { - session.endPrompt(); - await session.flush(); + + const results: ProviderToolResult[] = []; + for (const call of toolCalls) { + results.push( + session.cancelled + ? cancelToolCall(context, call) + : await executeToolCall(context, call, toolsByName.get(call.toolName)), + ); + } + session.messages.push({ role: "tool", results }); + if (session.cancelled) return finish(session, "cancelled", usage); } + return finish(session, "max_turn_requests"); } async function executeToolCall( @@ -119,22 +161,7 @@ async function executeToolCall( tool: KernelTool | undefined, ): Promise { const { session } = context; - session.append({ - type: "tool.call", - toolCallId: call.toolCallId, - toolName: call.toolName, - input: call.input, - at: now(), - }); - const kind = tool?.kind ?? "other"; - sendUpdate(context, { - sessionUpdate: "tool_call", - toolCallId: call.toolCallId, - title: titleFor(tool, call), - kind, - status: "pending", - rawInput: call.input, - }); + startToolCall(context, call, tool); if (!tool) { return completeToolCall(context, call, { @@ -154,7 +181,18 @@ async function executeToolCall( }); if (!decision.allowed) { return completeToolCall(context, call, { - output: "The user denied permission for this tool call.", + output: + decision.source === "user" + ? "The user denied permission for this tool call." + : "Permission could not be granted for this tool call.", + isError: true, + }); + } + // The permission round-trip can take arbitrarily long; a cancel that + // arrived while the prompt was showing must win over a late approval. + if (session.cancelled) { + return completeToolCall(context, call, { + output: "Tool call cancelled by user.", isError: true, }); } @@ -171,6 +209,7 @@ async function executeToolCall( const result = await tool.execute(call.input, { cwd: session.cwd, runtime: context.runtime, + signal: context.signal, }); output = result.output; isError = result.isError ?? false; @@ -181,6 +220,41 @@ async function executeToolCall( return completeToolCall(context, call, { output, isError }); } +/** Resolve a tool call as cancelled without executing it. */ +function cancelToolCall(context: LoopContext, call: ProviderToolCall): ProviderToolResult { + startToolCall( + context, + call, + context.tools.find((tool) => tool.name === call.toolName), + ); + return completeToolCall(context, call, { + output: "Tool call cancelled by user.", + isError: true, + }); +} + +function startToolCall( + context: LoopContext, + call: ProviderToolCall, + tool: KernelTool | undefined, +): void { + context.session.append({ + type: "tool.call", + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + at: now(), + }); + sendUpdate(context, { + sessionUpdate: "tool_call", + toolCallId: call.toolCallId, + title: titleFor(tool, call), + kind: tool?.kind ?? "other", + status: "pending", + rawInput: call.input, + }); +} + function completeToolCall( context: LoopContext, call: ProviderToolCall, @@ -210,7 +284,9 @@ function completeToolCall( interface PermissionDecision { allowed: boolean; - source: "policy" | "user"; + /** Who actually decided — the audit log must not blame the user for + * transport failures or frontend defaults. */ + source: "policy" | "user" | "frontend" | "error"; } /** @@ -241,12 +317,18 @@ async function checkPermission( { optionId: "reject", name: "Reject", kind: "reject_once" }, ], }, + context.signal, ); - const allowed = result.outcome.outcome === "selected" && result.outcome.optionId === "allow"; - return { allowed, source: "user" }; + if (result.outcome.outcome === "cancelled") { + // ACP: the frontend is cancelling the turn, not answering the question. + context.session.cancel(); + return { allowed: false, source: "frontend" }; + } + return { allowed: result.outcome.optionId === "allow", source: "user" }; } catch { - // A frontend that can't answer permission requests gets deny-by-default. - return { allowed: false, source: "user" }; + // Transport failure or aborted prompt: deny by default, and don't + // attribute the denial to a user who was never asked. + return { allowed: false, source: "error" }; } } diff --git a/packages/kernel/src/events.ts b/packages/kernel/src/events.ts index 751f7ba..ee0c319 100644 --- a/packages/kernel/src/events.ts +++ b/packages/kernel/src/events.ts @@ -25,11 +25,16 @@ export type SessionEvent = toolCallId: string; toolName: string; decision: "allowed" | "denied"; - /** "policy" = decided by kernel rules; "user" = decided via permission request. */ - source: "policy" | "user"; + /** + * "policy" = kernel rules; "user" = explicit user choice via permission + * request; "frontend" = the frontend answered without a user choice + * (cancelled outcome); "error" = the request failed, denied by default. + */ + source: "policy" | "user" | "frontend" | "error"; at: string; } - | { type: "turn.completed"; stopReason: StopReason; usage?: TurnUsage; at: string }; + | { type: "turn.completed"; stopReason: StopReason; usage?: TurnUsage; at: string } + | { type: "turn.failed"; error: string; at: string }; export function now(): string { return new Date().toISOString(); diff --git a/packages/kernel/src/runtime.ts b/packages/kernel/src/runtime.ts index d438c68..5a27b7f 100644 --- a/packages/kernel/src/runtime.ts +++ b/packages/kernel/src/runtime.ts @@ -14,11 +14,13 @@ export interface ExecResult { stderr: string; exitCode: number; timedOut: boolean; + aborted: boolean; } export interface ExecOptions { cwd: string; timeoutMs: number; + signal?: AbortSignal; } export interface Runtime { @@ -30,6 +32,17 @@ export interface Runtime { homedir(): string; } +/** + * Cap per-stream accumulation well above the tool-level truncation limit; + * without it a runaway command buffers unbounded output in kernel memory + * for the whole timeout window. + */ +const MAX_STREAM_CHARS = 200_000; + +/** After the child exits, wait this long for pipes held open by orphaned + * grandchildren (e.g. `daemon &`) before settling with what we have. */ +const PIPE_GRACE_MS = 1_000; + export const defaultRuntime: Runtime = { readTextFile: (path) => readFile(path, "utf8"), writeTextFile: (path, content) => writeFile(path, content, "utf8"), @@ -41,31 +54,80 @@ export const defaultRuntime: Runtime = { exec(command, options) { return new Promise((resolve, reject) => { + // detached puts the child in its own process group so kills reach + // grandchildren, not just the bash wrapper. const child = spawn("/bin/bash", ["-c", command], { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], + detached: true, }); let stdout = ""; let stderr = ""; let timedOut = false; + let aborted = false; + let exitCode: number | null = null; + let settled = false; + let graceTimer: ReturnType | undefined; + + const killTree = () => { + if (child.pid === undefined) return; + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + }; + const timer = setTimeout(() => { timedOut = true; - child.kill("SIGKILL"); + killTree(); }, options.timeoutMs); - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); + const onAbort = () => { + aborted = true; + killTree(); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(graceTimer); + options.signal?.removeEventListener("abort", onAbort); + resolve({ stdout, stderr, exitCode: exitCode ?? -1, timedOut, aborted }); + }; + + // setEncoding decodes via StringDecoder, so multi-byte UTF-8 characters + // split across chunk boundaries survive intact. + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + if (stdout.length < MAX_STREAM_CHARS) stdout += chunk; }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); + child.stderr.on("data", (chunk: string) => { + if (stderr.length < MAX_STREAM_CHARS) stderr += chunk; }); child.on("error", (error) => { clearTimeout(timer); - reject(error); + clearTimeout(graceTimer); + options.signal?.removeEventListener("abort", onAbort); + if (!settled) { + settled = true; + reject(error); + } + }); + // 'close' waits for the pipes to drain — which never happens when the + // command backgrounds a process that inherits them. Settle on 'close' + // normally, but treat 'exit' + grace period as good enough. + child.on("exit", (code) => { + exitCode = code ?? -1; + graceTimer = setTimeout(settle, PIPE_GRACE_MS); }); child.on("close", (code) => { - clearTimeout(timer); - resolve({ stdout, stderr, exitCode: code ?? -1, timedOut }); + exitCode = exitCode ?? code ?? -1; + settle(); }); }); }, diff --git a/packages/kernel/src/session.ts b/packages/kernel/src/session.ts index bca0608..06a37f3 100644 --- a/packages/kernel/src/session.ts +++ b/packages/kernel/src/session.ts @@ -47,18 +47,32 @@ export class Session { return session; } + #logError: unknown = null; + /** - * Append to the JSONL log. Writes are chained so events land in order - * even though callers don't await them; `flush()` awaits the chain. + * Append to the JSONL log. Writes are chained so events land in order even + * though callers don't await them. A failed write must not poison the + * chain (later events still get their own attempt), but it must not be + * silent either: flush() throws the first failure once, so the prompt that + * hit it fails loudly while later prompts recover. */ append(event: SessionEvent): void { - this.#logChain = this.#logChain.then(() => - this.#runtime.appendTextFile(this.#logPath, `${JSON.stringify(event)}\n`), - ); + this.#logChain = this.#logChain + .then(() => this.#runtime.appendTextFile(this.#logPath, `${JSON.stringify(event)}\n`)) + .catch((error) => { + this.#logError ??= error; + }); } - flush(): Promise { - return this.#logChain; + async flush(): Promise { + await this.#logChain; + if (this.#logError !== null) { + const error = this.#logError; + this.#logError = null; + throw error instanceof Error + ? new Error(`session event log write failed: ${error.message}`) + : new Error(`session event log write failed: ${String(error)}`); + } } get logPath(): string { diff --git a/packages/kernel/src/tools/bash.ts b/packages/kernel/src/tools/bash.ts index 398c49d..6aeb3ab 100644 --- a/packages/kernel/src/tools/bash.ts +++ b/packages/kernel/src/tools/bash.ts @@ -27,21 +27,29 @@ export const bashTool: KernelTool = { async execute(input, context) { const record = asRecord(input); const command = requireString(record, "command"); - const timeoutMs = Math.min( - typeof record.timeout_ms === "number" ? record.timeout_ms : DEFAULT_TIMEOUT_MS, - MAX_TIMEOUT_MS, - ); + // 0, negative, and NaN would arm an instant kill timer — fall back to + // the default instead of trusting the model's arithmetic. + const requested = record.timeout_ms; + const timeoutMs = + typeof requested === "number" && Number.isFinite(requested) && requested > 0 + ? Math.min(requested, MAX_TIMEOUT_MS) + : DEFAULT_TIMEOUT_MS; - const result = await context.runtime.exec(command, { cwd: context.cwd, timeoutMs }); + const result = await context.runtime.exec(command, { + cwd: context.cwd, + timeoutMs, + signal: context.signal, + }); const parts: string[] = []; if (result.stdout) parts.push(truncate(result.stdout)); if (result.stderr) parts.push(`[stderr]\n${truncate(result.stderr)}`); - if (result.timedOut) parts.push(`[command timed out after ${timeoutMs}ms]`); + if (result.aborted) parts.push("[command cancelled by user]"); + else if (result.timedOut) parts.push(`[command timed out after ${timeoutMs}ms]`); if (result.exitCode !== 0) parts.push(`[exit code: ${result.exitCode}]`); return { output: parts.join("\n") || "(no output)", - isError: result.exitCode !== 0 || result.timedOut, + isError: result.exitCode !== 0 || result.timedOut || result.aborted, }; }, }; diff --git a/packages/kernel/src/tools/edit-file.ts b/packages/kernel/src/tools/edit-file.ts index 52db840..f1e90ee 100644 --- a/packages/kernel/src/tools/edit-file.ts +++ b/packages/kernel/src/tools/edit-file.ts @@ -26,7 +26,12 @@ export const editFileTool: KernelTool = { const record = asRecord(input); const path = resolve(context.cwd, requireString(record, "path")); const oldString = requireString(record, "old_string"); - const newString = typeof record.new_string === "string" ? record.new_string : ""; + // Unlike requireString, the empty string is a legal new_string (deletion) + // — but a missing/non-string value must fail, not silently delete. + const newString = record.new_string; + if (typeof newString !== "string") { + throw new Error("missing required string parameter: new_string"); + } const content = await context.runtime.readTextFile(path); const occurrences = content.split(oldString).length - 1; @@ -39,7 +44,12 @@ export const editFileTool: KernelTool = { isError: true, }; } - await context.runtime.writeTextFile(path, content.replace(oldString, newString)); + // Replacer function: a plain string argument would interpret $-patterns + // ($$, $&, $') in new_string and silently corrupt the file. + await context.runtime.writeTextFile( + path, + content.replace(oldString, () => newString), + ); return { output: `Edited ${path}` }; }, }; diff --git a/packages/kernel/src/tools/read-file.ts b/packages/kernel/src/tools/read-file.ts index 364bc48..b6deeb4 100644 --- a/packages/kernel/src/tools/read-file.ts +++ b/packages/kernel/src/tools/read-file.ts @@ -1,13 +1,13 @@ -import { resolve } from "node:path"; import type { KernelTool } from "./types"; -import { asRecord, requireString } from "./types"; +import { asRecord, requireString, resolveWithinWorkspace } from "./types"; const MAX_CHARS = 50_000; export const readFileTool: KernelTool = { name: "read_file", description: - "Read a text file. The path may be absolute or relative to the working directory. " + + "Read a text file inside the working directory. The path may be absolute or " + + "relative to the working directory, but must stay within it. " + "Output is truncated after 50k characters.", inputSchema: { type: "object", @@ -22,7 +22,9 @@ export const readFileTool: KernelTool = { return `Read ${requireString(asRecord(input), "path")}`; }, async execute(input, context) { - const path = resolve(context.cwd, requireString(asRecord(input), "path")); + // read_file is auto-allowed by policy (readOnly), so it must not be able + // to reach outside the workspace — no permission prompt would fire. + const path = resolveWithinWorkspace(context.cwd, requireString(asRecord(input), "path")); const content = await context.runtime.readTextFile(path); if (content.length > MAX_CHARS) { return { diff --git a/packages/kernel/src/tools/types.ts b/packages/kernel/src/tools/types.ts index ae45fa5..86d6273 100644 --- a/packages/kernel/src/tools/types.ts +++ b/packages/kernel/src/tools/types.ts @@ -1,9 +1,12 @@ +import { isAbsolute, relative, resolve } from "node:path"; import type { ToolKind } from "@minerva/protocol"; import type { Runtime } from "../runtime"; export interface ToolContext { cwd: string; runtime: Runtime; + /** Aborts when the prompt is cancelled; long-running tools should honor it. */ + signal?: AbortSignal; } export interface ToolOutput { @@ -40,3 +43,18 @@ export function requireString(record: Record, key: string): str } return value; } + +/** + * Resolve a model-supplied path and confine it to the workspace. Tool inputs + * are untrusted model output: without this check a policy-allowed read-only + * tool could read arbitrary files (~/.ssh/id_rsa) with no permission prompt. + * Symlink escapes remain possible in slice 1; the rule engine tightens this. + */ +export function resolveWithinWorkspace(cwd: string, inputPath: string): string { + const resolved = resolve(cwd, inputPath); + const rel = relative(cwd, resolved); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`path is outside the workspace: ${resolved}`); + } + return resolved; +} diff --git a/packages/kernel/test/kernel.test.ts b/packages/kernel/test/kernel.test.ts index e3f3729..5fbfe43 100644 --- a/packages/kernel/test/kernel.test.ts +++ b/packages/kernel/test/kernel.test.ts @@ -31,11 +31,12 @@ interface Harness { permissionRequests: RequestPermissionParams[]; sessionId: string; logEvents: () => SessionEvent[]; + sessionMessages: () => unknown[]; } async function setup(options: { turns: TurnEvent[][]; - permission?: "allow" | "reject"; + permission?: "allow" | "reject" | "cancel"; }): Promise { const cwd = mkdtempSync(join(tmpdir(), "minerva-proj-")); const dataDir = mkdtempSync(join(tmpdir(), "minerva-data-")); @@ -55,6 +56,9 @@ async function setup(options: { }); client.handleRequest(CLIENT_METHODS.sessionRequestPermission, (params) => { permissionRequests.push(params as RequestPermissionParams); + if (options.permission === "cancel") { + return { outcome: { outcome: "cancelled" } }; + } return { outcome: { outcome: "selected", optionId: options.permission ?? "allow" }, }; @@ -82,6 +86,11 @@ async function setup(options: { .split("\n") .map((line) => JSON.parse(line) as SessionEvent); }, + sessionMessages: () => { + const session = kernel.getSession(sessionId); + if (!session) throw new Error("session not found"); + return session.messages as unknown[]; + }, }; } @@ -198,6 +207,45 @@ describe("kernel over in-proc transport", () => { expect(JSON.stringify(completed)).toContain("hi from disk"); }); + test("cancelled permission outcome ends the turn with no dangling tool_use", async () => { + const harness = await setup({ + permission: "cancel", + turns: [ + [ + { type: "text-delta", text: "Editing now." }, + { + type: "tool-call", + toolCallId: "c1", + toolName: "edit_file", + input: { path: "hello.txt", old_string: "hi", new_string: "bye" }, + }, + FINISH_TOOLS, + ], + ], + }); + + const result = await prompt(harness, "edit the file"); + expect(result.stopReason).toBe("cancelled"); + + // ACP cancelled outcome is not a user denial and must be audited as such. + const decision = harness.logEvents().find((event) => event.type === "permission.decision"); + expect(decision).toMatchObject({ decision: "denied", source: "frontend" }); + + // Every tool_use in the pushed assistant message has a matching result, + // so the next prompt in this session sends a well-formed history. + const messages = harness.sessionMessages() as Array<{ + role: string; + toolCalls?: unknown[]; + results?: Array<{ toolCallId: string }>; + }>; + expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]); + expect(messages[2]?.results?.map((r) => r.toolCallId)).toEqual(["c1"]); + + // The streamed assistant text survived cancellation into the event log. + const assistant = harness.logEvents().find((event) => event.type === "assistant.message"); + expect(assistant).toMatchObject({ text: "Editing now." }); + }); + test("concurrent prompt on the same session is rejected", async () => { const harness = await setup({ turns: [ diff --git a/packages/kernel/test/regressions.test.ts b/packages/kernel/test/regressions.test.ts new file mode 100644 index 0000000..438b1cb --- /dev/null +++ b/packages/kernel/test/regressions.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + bashTool, + defaultRuntime, + editFileTool, + readFileTool, + resolveWithinWorkspace, + Session, +} from "../src"; + +function tempProject(): string { + return mkdtempSync(join(tmpdir(), "minerva-regr-")); +} + +const ctx = (cwd: string, signal?: AbortSignal) => ({ cwd, runtime: defaultRuntime, signal }); + +describe("edit_file input handling", () => { + test("$-replacement patterns in new_string are inserted literally", async () => { + const cwd = tempProject(); + const file = join(cwd, "Makefile"); + writeFileSync(file, "run:\n\techo hi\n"); + const result = await editFileTool.execute( + { path: "Makefile", old_string: "echo hi", new_string: "echo $$PID and $& and $'" }, + ctx(cwd), + ); + expect(result.isError).toBeUndefined(); + expect(readFileSync(file, "utf8")).toBe("run:\n\techo $$PID and $& and $'\n"); + }); + + test("non-string new_string is rejected, not treated as deletion", async () => { + const cwd = tempProject(); + const file = join(cwd, "a.txt"); + writeFileSync(file, "keep me"); + expect( + editFileTool.execute({ path: "a.txt", old_string: "keep me", new_string: 42 }, ctx(cwd)), + ).rejects.toThrow("new_string"); + expect(readFileSync(file, "utf8")).toBe("keep me"); + }); + + test("empty-string new_string is a legal deletion", async () => { + const cwd = tempProject(); + const file = join(cwd, "a.txt"); + writeFileSync(file, "drop this. keep this."); + const result = await editFileTool.execute( + { path: "a.txt", old_string: "drop this. ", new_string: "" }, + ctx(cwd), + ); + expect(result.isError).toBeUndefined(); + expect(readFileSync(file, "utf8")).toBe("keep this."); + }); +}); + +describe("read_file workspace confinement", () => { + test("paths escaping the workspace are rejected", async () => { + const cwd = tempProject(); + expect(readFileTool.execute({ path: "/etc/passwd" }, ctx(cwd))).rejects.toThrow( + "outside the workspace", + ); + expect(readFileTool.execute({ path: "../../etc/passwd" }, ctx(cwd))).rejects.toThrow( + "outside the workspace", + ); + }); + + test("absolute paths inside the workspace are allowed", async () => { + const cwd = tempProject(); + writeFileSync(join(cwd, "ok.txt"), "fine"); + const result = await readFileTool.execute({ path: join(cwd, "ok.txt") }, ctx(cwd)); + expect(result.output).toBe("fine"); + expect(resolveWithinWorkspace(cwd, "sub/../ok.txt")).toBe(join(cwd, "ok.txt")); + }); +}); + +describe("bash hardening", () => { + test("timeout_ms of 0 / NaN falls back to the default instead of instant kill", async () => { + const result = await bashTool.execute( + { command: "echo survived", timeout_ms: 0 }, + ctx(tempProject()), + ); + expect(result.isError).toBe(false); + expect(result.output.trim()).toBe("survived"); + }); + + test("a backgrounded child holding the pipes does not hang exec", async () => { + const started = Date.now(); + const result = await bashTool.execute( + { command: "sleep 30 & echo started" }, + ctx(tempProject()), + ); + expect(result.output).toContain("started"); + // exit fires immediately; the pipe grace period is 1s. + expect(Date.now() - started).toBeLessThan(5_000); + }, 10_000); + + test("aborting the signal kills the running command", async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 100); + const started = Date.now(); + const result = await bashTool.execute( + { command: "sleep 10; echo not-reached" }, + ctx(tempProject(), controller.signal), + ); + expect(Date.now() - started).toBeLessThan(5_000); + expect(result.isError).toBe(true); + expect(result.output).toContain("cancelled by user"); + }, 10_000); +}); + +describe("session event log resilience", () => { + test("one failed write does not poison later appends; flush throws once", async () => { + const cwd = tempProject(); + const dataDir = tempProject(); + let failNext = false; + const flakyRuntime = { + ...defaultRuntime, + appendTextFile: (path: string, content: string) => { + if (failNext) { + failNext = false; + return Promise.reject(new Error("disk full")); + } + return defaultRuntime.appendTextFile(path, content); + }, + }; + const session = await Session.create({ + cwd, + dataDir, + providerId: "test", + runtime: flakyRuntime, + }); + + failNext = true; + session.append({ type: "user.message", text: "lost", at: "t1" }); + session.append({ type: "user.message", text: "recorded", at: "t2" }); + + expect(session.flush()).rejects.toThrow("disk full"); + // The chain survived: the later event landed and future flushes succeed. + await session.flush(); + const lines = readFileSync(session.logPath, "utf8").trim().split("\n"); + const texts = lines.map((line) => JSON.parse(line)).map((event) => event.text ?? event.type); + expect(texts).toEqual(["session.created", "recorded"]); + }); +}); diff --git a/packages/protocol/src/jsonrpc.ts b/packages/protocol/src/jsonrpc.ts index c8f6e47..9ae5a6c 100644 --- a/packages/protocol/src/jsonrpc.ts +++ b/packages/protocol/src/jsonrpc.ts @@ -107,14 +107,29 @@ export class Connection { this.#notificationHandlers.set(method, handler); } - request(method: string, params?: unknown): Promise { + request(method: string, params?: unknown, signal?: AbortSignal): Promise { if (this.#closed) { return Promise.reject(new RpcError(JSON_RPC_ERROR_CODES.INTERNAL_ERROR, "connection closed")); } + if (signal?.aborted) { + return Promise.reject(new RpcError(JSON_RPC_ERROR_CODES.INTERNAL_ERROR, "request aborted")); + } const id = this.#nextId++; const promise = new Promise((resolve, reject) => { this.#pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); }); + if (signal) { + // JSON-RPC has no wire-level cancel; abort settles the caller locally + // and a late response for this id is dropped by #dispatchResponse. + const onAbort = () => { + const pending = this.#pending.get(id); + if (!pending) return; + this.#pending.delete(id); + pending.reject(new RpcError(JSON_RPC_ERROR_CODES.INTERNAL_ERROR, "request aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + promise.finally(() => signal.removeEventListener("abort", onAbort)).catch(() => {}); + } this.#transport.send({ jsonrpc: "2.0", id, method, params }); return promise; } From 6ad90bee77556da51b5dfa95ffb09204838477d6 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:06:59 +0800 Subject: [PATCH 09/20] feat(protocol): session/load, session/set_mode, modes, plan updates, sessions/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 needs four protocol surfaces: session/load for resume (kernel replays the transcript as session/update notifications before answering, per ACP), session/set_mode + a modes state on session/new for the permission modes, plan and current_mode_update session-update variants for todo lists and mode display, and minerva/sessions/list — the first namespaced extension method — because resume needs session discovery and ACP has no listing surface. All ACP-shaped except the explicitly namespaced minerva/* method, keeping the slice-3 conformance mapping clean. --- packages/protocol/src/types.ts | 77 +++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 3bd7f94..cf0c040 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -14,11 +14,22 @@ export const PROTOCOL_VERSION = 1; export const AGENT_METHODS = { initialize: "initialize", sessionNew: "session/new", + sessionLoad: "session/load", sessionPrompt: "session/prompt", + sessionSetMode: "session/set_mode", /** Notification. */ sessionCancel: "session/cancel", } as const; +/** + * minerva/* extension methods — the namespaced surface beyond ACP core + * (design decision #3). Kept separate so the ACP mapping stays clean. + */ +export const MINERVA_METHODS = { + /** Frontend → kernel: list persisted sessions for a project. */ + sessionsList: "minerva/sessions/list", +} as const; + /** Kernel → frontend. */ export const CLIENT_METHODS = { /** Notification. */ @@ -50,6 +61,26 @@ export interface InitializeResult { }; } +// --- session modes ----------------------------------------------------- + +export interface SessionMode { + id: string; + name: string; + description?: string; +} + +export interface SessionModeState { + currentModeId: string; + availableModes: SessionMode[]; +} + +export interface SessionSetModeParams { + sessionId: string; + modeId: string; +} + +export type SessionSetModeResult = null; + // --- session/new ------------------------------------------------------- export interface SessionNewParams { @@ -58,6 +89,40 @@ export interface SessionNewParams { export interface SessionNewResult { sessionId: string; + modes?: SessionModeState; +} + +// --- session/load ------------------------------------------------------ + +/** + * Resume a persisted session. The agent replays the conversation to the + * client as session/update notifications before responding. + */ +export interface SessionLoadParams { + sessionId: string; + cwd: string; +} + +export interface SessionLoadResult { + modes?: SessionModeState; +} + +// --- minerva/sessions/list --------------------------------------------- + +export interface SessionsListParams { + cwd: string; +} + +export interface SessionSummary { + sessionId: string; + cwd: string; + createdAt: string; + /** First user message, truncated — enough for a picker UI. */ + preview?: string; +} + +export interface SessionsListResult { + sessions: SessionSummary[]; } // --- session/prompt ---------------------------------------------------- @@ -114,12 +179,22 @@ export interface ToolCallUpdate { rawOutput?: unknown; } +export type PlanEntryStatus = "pending" | "in_progress" | "completed"; + +export interface PlanEntry { + content: string; + priority: "high" | "medium" | "low"; + status: PlanEntryStatus; +} + export type SessionUpdate = | { sessionUpdate: "user_message_chunk"; content: ContentBlock } | { sessionUpdate: "agent_message_chunk"; content: ContentBlock } | { sessionUpdate: "agent_thought_chunk"; content: ContentBlock } | ToolCallStart - | ToolCallUpdate; + | ToolCallUpdate + | { sessionUpdate: "plan"; entries: PlanEntry[] } + | { sessionUpdate: "current_mode_update"; currentModeId: string }; export interface SessionUpdateParams { sessionId: string; From 460fe97c9e68215d73f7c7cd9a131c71ed982575 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:06:59 +0800 Subject: [PATCH 10/20] feat(kernel): write_file, glob, grep, and todo_write tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the built-in tool surface planned for slice 2. glob and grep are read-only and therefore policy-allowed without prompts, so like read_file they are confined to the workspace; both cap output (200 paths, 100 matches, 1MB/file) so a broad search can't flood the model context. grep is pure TypeScript over tinyglobby rather than shelling out — portable across Bun/Node and testable, at some speed cost that a bundled ripgrep can address later. write_file is permission-gated like edit_file. todo_write turns plans into ACP plan updates: the tool validates entries and hands them to a loop-provided hook (ToolContext.updateTodos) so the tool itself never touches session or connection state. --- bun.lock | 7 ++ packages/kernel/package.json | 3 +- packages/kernel/src/tools/glob.ts | 49 ++++++++++ packages/kernel/src/tools/grep.ts | 86 +++++++++++++++++ packages/kernel/src/tools/index.ts | 8 +- packages/kernel/src/tools/todo.ts | 77 ++++++++++++++++ packages/kernel/src/tools/types.ts | 4 +- packages/kernel/src/tools/write-file.ts | 34 +++++++ packages/kernel/test/tools-slice2.test.ts | 107 ++++++++++++++++++++++ 9 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 packages/kernel/src/tools/glob.ts create mode 100644 packages/kernel/src/tools/grep.ts create mode 100644 packages/kernel/src/tools/todo.ts create mode 100644 packages/kernel/src/tools/write-file.ts create mode 100644 packages/kernel/test/tools-slice2.test.ts diff --git a/bun.lock b/bun.lock index 0b0522f..a38773b 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "dependencies": { "@minerva/protocol": "workspace:*", "@minerva/providers": "workspace:*", + "tinyglobby": "^0.2.17", }, }, "packages/protocol": { @@ -149,6 +150,8 @@ "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -169,6 +172,8 @@ "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], @@ -191,6 +196,8 @@ "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/packages/kernel/package.json b/packages/kernel/package.json index f410f07..e3926af 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@minerva/protocol": "workspace:*", - "@minerva/providers": "workspace:*" + "@minerva/providers": "workspace:*", + "tinyglobby": "^0.2.17" } } diff --git a/packages/kernel/src/tools/glob.ts b/packages/kernel/src/tools/glob.ts new file mode 100644 index 0000000..c450d96 --- /dev/null +++ b/packages/kernel/src/tools/glob.ts @@ -0,0 +1,49 @@ +import { glob } from "tinyglobby"; +import type { KernelTool } from "./types"; +import { asRecord, requireString, resolveWithinWorkspace } from "./types"; + +const MAX_RESULTS = 200; +const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"]; + +export const globTool: KernelTool = { + name: "glob", + description: + "Find files matching a glob pattern (e.g. src/**/*.ts), relative to the " + + "working directory or an optional subdirectory. Results are capped at 200 " + + "paths; node_modules and .git are ignored.", + inputSchema: { + type: "object", + properties: { + pattern: { type: "string", description: "Glob pattern to match files against" }, + path: { type: "string", description: "Directory to search in (default: working directory)" }, + }, + required: ["pattern"], + }, + kind: "search", + readOnly: true, + title(input) { + return `Glob ${requireString(asRecord(input), "pattern")}`; + }, + async execute(input, context) { + const record = asRecord(input); + const pattern = requireString(record, "pattern"); + // readOnly ⇒ policy-allowed with no prompt ⇒ must stay inside the workspace. + const base = resolveWithinWorkspace( + context.cwd, + typeof record.path === "string" ? record.path : ".", + ); + const matches = await glob(pattern, { + cwd: base, + ignore: DEFAULT_IGNORE, + onlyFiles: true, + }); + if (matches.length === 0) return { output: "No files matched." }; + matches.sort(); + const shown = matches.slice(0, MAX_RESULTS); + const suffix = + matches.length > shown.length + ? `\n… (${matches.length - shown.length} more matches not shown)` + : ""; + return { output: shown.join("\n") + suffix }; + }, +}; diff --git a/packages/kernel/src/tools/grep.ts b/packages/kernel/src/tools/grep.ts new file mode 100644 index 0000000..3422dc7 --- /dev/null +++ b/packages/kernel/src/tools/grep.ts @@ -0,0 +1,86 @@ +import { glob } from "tinyglobby"; +import type { KernelTool } from "./types"; +import { asRecord, requireString, resolveWithinWorkspace } from "./types"; + +const MAX_FILES = 500; +const MAX_MATCHES = 100; +const MAX_FILE_CHARS = 1_000_000; +const MAX_LINE_CHARS = 500; +const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"]; + +export const grepTool: KernelTool = { + name: "grep", + description: + "Search file contents with a JavaScript regular expression. Searches the " + + "working directory (or an optional subdirectory), optionally filtered by an " + + "include glob (e.g. **/*.ts). Returns path:line: text matches, capped at 100.", + inputSchema: { + type: "object", + properties: { + pattern: { type: "string", description: "Regular expression to search for" }, + path: { type: "string", description: "Directory to search in (default: working directory)" }, + include: { type: "string", description: "Only search files matching this glob" }, + }, + required: ["pattern"], + }, + kind: "search", + readOnly: true, + title(input) { + return `Grep /${requireString(asRecord(input), "pattern")}/`; + }, + async execute(input, context) { + const record = asRecord(input); + let regex: RegExp; + try { + regex = new RegExp(requireString(record, "pattern")); + } catch (error) { + return { + output: `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`, + isError: true, + }; + } + // readOnly ⇒ policy-allowed with no prompt ⇒ must stay inside the workspace. + const base = resolveWithinWorkspace( + context.cwd, + typeof record.path === "string" ? record.path : ".", + ); + const files = await glob(typeof record.include === "string" ? record.include : "**/*", { + cwd: base, + ignore: DEFAULT_IGNORE, + onlyFiles: true, + }); + files.sort(); + + const matches: string[] = []; + let scanned = 0; + let skipped = 0; + for (const file of files) { + if (scanned >= MAX_FILES || matches.length >= MAX_MATCHES) { + skipped = files.length - scanned; + break; + } + scanned += 1; + let content: string; + try { + content = await context.runtime.readTextFile(`${base}/${file}`); + } catch { + continue; + } + if (content.length > MAX_FILE_CHARS || content.includes("\0")) continue; + const lines = content.split("\n"); + for (let index = 0; index < lines.length && matches.length < MAX_MATCHES; index++) { + const line = lines[index] ?? ""; + if (regex.test(line)) { + matches.push(`${file}:${index + 1}: ${line.slice(0, MAX_LINE_CHARS).trimEnd()}`); + } + } + } + + if (matches.length === 0) return { output: "No matches found." }; + const notes: string[] = []; + if (matches.length >= MAX_MATCHES) notes.push(`capped at ${MAX_MATCHES} matches`); + if (skipped > 0) notes.push(`${skipped} files not scanned`); + const suffix = notes.length > 0 ? `\n… (${notes.join("; ")})` : ""; + return { output: matches.join("\n") + suffix }; + }, +}; diff --git a/packages/kernel/src/tools/index.ts b/packages/kernel/src/tools/index.ts index 2612899..b6712ba 100644 --- a/packages/kernel/src/tools/index.ts +++ b/packages/kernel/src/tools/index.ts @@ -1,11 +1,15 @@ import { bashTool } from "./bash"; import { editFileTool } from "./edit-file"; +import { globTool } from "./glob"; +import { grepTool } from "./grep"; import { readFileTool } from "./read-file"; +import { todoTool } from "./todo"; import type { KernelTool } from "./types"; +import { writeFileTool } from "./write-file"; export * from "./types"; -export { bashTool, editFileTool, readFileTool }; +export { bashTool, editFileTool, globTool, grepTool, readFileTool, todoTool, writeFileTool }; export function builtinTools(): KernelTool[] { - return [readFileTool, editFileTool, bashTool]; + return [readFileTool, writeFileTool, editFileTool, globTool, grepTool, bashTool, todoTool]; } diff --git a/packages/kernel/src/tools/todo.ts b/packages/kernel/src/tools/todo.ts new file mode 100644 index 0000000..4007ee4 --- /dev/null +++ b/packages/kernel/src/tools/todo.ts @@ -0,0 +1,77 @@ +import type { PlanEntry, PlanEntryStatus } from "@minerva/protocol"; +import type { KernelTool } from "./types"; +import { asRecord } from "./types"; + +const STATUSES: PlanEntryStatus[] = ["pending", "in_progress", "completed"]; +const PRIORITIES = ["high", "medium", "low"] as const; + +export const todoTool: KernelTool = { + name: "todo_write", + description: + "Replace the session todo list. Use it to plan multi-step work and keep the " + + "user informed: mark items in_progress when you start them and completed when " + + "done. Always send the complete list, not a diff.", + inputSchema: { + type: "object", + properties: { + todos: { + type: "array", + description: "The complete todo list", + items: { + type: "object", + properties: { + content: { type: "string", description: "The task, in imperative form" }, + status: { type: "string", enum: STATUSES }, + priority: { type: "string", enum: [...PRIORITIES] }, + }, + required: ["content", "status"], + }, + }, + }, + required: ["todos"], + }, + kind: "think", + // Touches only session state, never the machine — safe to auto-allow. + readOnly: true, + title(input) { + const entries = parseEntries(input); + const done = entries.filter((entry) => entry.status === "completed").length; + return `Update todos (${done}/${entries.length} done)`; + }, + async execute(input, context) { + const entries = parseEntries(input); + if (!context.updateTodos) { + throw new Error("todo tracking is not available in this context"); + } + context.updateTodos(entries); + return { + output: + entries.length === 0 ? "Todo list cleared." : `Todo list updated:\n${render(entries)}`, + }; + }, +}; + +function parseEntries(input: unknown): PlanEntry[] { + const record = asRecord(input); + if (!Array.isArray(record.todos)) { + throw new Error("missing required array parameter: todos"); + } + return record.todos.map((raw, index) => { + const todo = asRecord(raw); + if (typeof todo.content !== "string" || todo.content.length === 0) { + throw new Error(`todos[${index}].content must be a non-empty string`); + } + if (!STATUSES.includes(todo.status as PlanEntryStatus)) { + throw new Error(`todos[${index}].status must be one of ${STATUSES.join(", ")}`); + } + const priority = PRIORITIES.includes(todo.priority as (typeof PRIORITIES)[number]) + ? (todo.priority as PlanEntry["priority"]) + : "medium"; + return { content: todo.content, status: todo.status as PlanEntryStatus, priority }; + }); +} + +function render(entries: PlanEntry[]): string { + const mark = { pending: " ", in_progress: "~", completed: "x" } as const; + return entries.map((entry) => `[${mark[entry.status]}] ${entry.content}`).join("\n"); +} diff --git a/packages/kernel/src/tools/types.ts b/packages/kernel/src/tools/types.ts index 86d6273..64d2e18 100644 --- a/packages/kernel/src/tools/types.ts +++ b/packages/kernel/src/tools/types.ts @@ -1,5 +1,5 @@ import { isAbsolute, relative, resolve } from "node:path"; -import type { ToolKind } from "@minerva/protocol"; +import type { PlanEntry, ToolKind } from "@minerva/protocol"; import type { Runtime } from "../runtime"; export interface ToolContext { @@ -7,6 +7,8 @@ export interface ToolContext { runtime: Runtime; /** Aborts when the prompt is cancelled; long-running tools should honor it. */ signal?: AbortSignal; + /** Provided by the agent loop: persist + broadcast the session todo list. */ + updateTodos?(entries: PlanEntry[]): void; } export interface ToolOutput { diff --git a/packages/kernel/src/tools/write-file.ts b/packages/kernel/src/tools/write-file.ts new file mode 100644 index 0000000..200c370 --- /dev/null +++ b/packages/kernel/src/tools/write-file.ts @@ -0,0 +1,34 @@ +import { dirname, resolve } from "node:path"; +import type { KernelTool } from "./types"; +import { asRecord, requireString } from "./types"; + +export const writeFileTool: KernelTool = { + name: "write_file", + description: + "Create or overwrite a text file with the given content. Parent directories " + + "are created as needed. Prefer edit_file for changing existing files.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "File path to write" }, + content: { type: "string", description: "Full file content" }, + }, + required: ["path", "content"], + }, + kind: "edit", + readOnly: false, + title(input) { + return `Write ${requireString(asRecord(input), "path")}`; + }, + async execute(input, context) { + const record = asRecord(input); + const path = resolve(context.cwd, requireString(record, "path")); + const content = record.content; + if (typeof content !== "string") { + throw new Error("missing required string parameter: content"); + } + await context.runtime.mkdirp(dirname(path)); + await context.runtime.writeTextFile(path, content); + return { output: `Wrote ${content.length} characters to ${path}` }; + }, +}; diff --git a/packages/kernel/test/tools-slice2.test.ts b/packages/kernel/test/tools-slice2.test.ts new file mode 100644 index 0000000..48bac6b --- /dev/null +++ b/packages/kernel/test/tools-slice2.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PlanEntry } from "@minerva/protocol"; +import { defaultRuntime, globTool, grepTool, todoTool, writeFileTool } from "../src"; + +function tempProject(): string { + return mkdtempSync(join(tmpdir(), "minerva-t2-")); +} + +const ctx = (cwd: string, updateTodos?: (entries: PlanEntry[]) => void) => ({ + cwd, + runtime: defaultRuntime, + updateTodos, +}); + +describe("write_file", () => { + test("creates parent directories and writes content", async () => { + const cwd = tempProject(); + const result = await writeFileTool.execute( + { path: "deep/nested/new.txt", content: "hello" }, + ctx(cwd), + ); + expect(result.isError).toBeUndefined(); + expect(readFileSync(join(cwd, "deep/nested/new.txt"), "utf8")).toBe("hello"); + }); + + test("non-string content is rejected", async () => { + const cwd = tempProject(); + expect( + writeFileTool.execute({ path: "a.txt", content: { nested: true } }, ctx(cwd)), + ).rejects.toThrow("content"); + }); +}); + +describe("glob", () => { + test("matches files and ignores node_modules", async () => { + const cwd = tempProject(); + mkdirSync(join(cwd, "src"), { recursive: true }); + mkdirSync(join(cwd, "node_modules/pkg"), { recursive: true }); + writeFileSync(join(cwd, "src/a.ts"), ""); + writeFileSync(join(cwd, "src/b.md"), ""); + writeFileSync(join(cwd, "node_modules/pkg/c.ts"), ""); + + const result = await globTool.execute({ pattern: "**/*.ts" }, ctx(cwd)); + expect(result.output).toBe("src/a.ts"); + }); + + test("search base is confined to the workspace", async () => { + expect(globTool.execute({ pattern: "*", path: "/" }, ctx(tempProject()))).rejects.toThrow( + "outside the workspace", + ); + }); +}); + +describe("grep", () => { + test("returns path:line matches and respects include filter", async () => { + const cwd = tempProject(); + writeFileSync(join(cwd, "a.ts"), "const alpha = 1;\nconst beta = 2;\n"); + writeFileSync(join(cwd, "b.md"), "alpha in markdown\n"); + + const all = await grepTool.execute({ pattern: "alpha" }, ctx(cwd)); + expect(all.output).toContain("a.ts:1: const alpha = 1;"); + expect(all.output).toContain("b.md:1: alpha in markdown"); + + const filtered = await grepTool.execute({ pattern: "alpha", include: "**/*.ts" }, ctx(cwd)); + expect(filtered.output).toBe("a.ts:1: const alpha = 1;"); + }); + + test("invalid regex is an error result, not a crash", async () => { + const result = await grepTool.execute({ pattern: "([unclosed" }, ctx(tempProject())); + expect(result.isError).toBe(true); + expect(result.output).toContain("Invalid regular expression"); + }); +}); + +describe("todo_write", () => { + test("validates entries and hands them to the loop hook", async () => { + let received: PlanEntry[] = []; + const result = await todoTool.execute( + { + todos: [ + { content: "build the thing", status: "in_progress", priority: "high" }, + { content: "test the thing", status: "pending" }, + ], + }, + ctx(tempProject(), (entries) => { + received = entries; + }), + ); + expect(result.isError).toBeUndefined(); + expect(received).toEqual([ + { content: "build the thing", status: "in_progress", priority: "high" }, + { content: "test the thing", status: "pending", priority: "medium" }, + ]); + }); + + test("bad status is rejected", async () => { + expect( + todoTool.execute( + { todos: [{ content: "x", status: "done" }] }, + ctx(tempProject(), () => {}), + ), + ).rejects.toThrow("status"); + }); +}); From 767c91c7b163333f0dd668def2c704abec6781b0 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:07:19 +0800 Subject: [PATCH 11/20] feat(kernel): permission rule engine, session modes, resume via log replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two halves of slice 2 ('permissions & persistence') land together because they share the event schema. Permissions: a kernel-enforced engine evaluates allow/deny/ask rules (bash(git *), edit_file(src/*)) against a per-tool permission value, layered under four session modes (plan/default/acceptEdits/auto). Deny is absolute — it outranks read-only policy, allow rules, and modes — so a project deny list can't be talked around. Rules merge from global (/settings.json) and project (.minerva/settings.json) layers; an allow_always answer becomes a persisted project rule, and every decision is audited with the rule that made it. A corrupt settings file fails loudly rather than silently granting or dropping permissions. Persistence: session/load replays the JSONL event log to rebuild both the provider messages and the frontend transcript. assistant.message events now carry their toolCalls so a turn is reconstructible from the log alone; tool calls a crash left unresolved get synthesized error results so the resumed history is well-formed for the provider, and a torn final line (kill -9 mid-write) is skipped instead of blocking resume. A per-project index.jsonl backs minerva/sessions/list. --- packages/kernel/src/agent-loop.ts | 59 +++++++-- packages/kernel/src/events.ts | 28 ++++- packages/kernel/src/index.ts | 3 + packages/kernel/src/kernel.ts | 132 +++++++++++++++++++- packages/kernel/src/permissions.ts | 118 ++++++++++++++++++ packages/kernel/src/replay.ts | 150 +++++++++++++++++++++++ packages/kernel/src/session.ts | 108 +++++++++++++--- packages/kernel/src/settings.ts | 83 +++++++++++++ packages/kernel/test/kernel.test.ts | 95 +++++++++++++- packages/kernel/test/permissions.test.ts | 79 ++++++++++++ 10 files changed, 817 insertions(+), 38 deletions(-) create mode 100644 packages/kernel/src/permissions.ts create mode 100644 packages/kernel/src/replay.ts create mode 100644 packages/kernel/src/settings.ts create mode 100644 packages/kernel/test/permissions.test.ts diff --git a/packages/kernel/src/agent-loop.ts b/packages/kernel/src/agent-loop.ts index fd1c15a..674c13c 100644 --- a/packages/kernel/src/agent-loop.ts +++ b/packages/kernel/src/agent-loop.ts @@ -13,8 +13,10 @@ import type { TurnUsage, } from "@minerva/providers"; import { now } from "./events"; +import { formatRule, permissionValue } from "./permissions"; import type { Runtime } from "./runtime"; import type { Session } from "./session"; +import { persistAllowRule } from "./settings"; import type { KernelTool } from "./tools"; /** Backstop against runaway loops; becomes configurable with modes in slice 2. */ @@ -79,8 +81,10 @@ async function runLoop( // turn is cancelled mid-stream — the event log has to be able to // re-render what the user actually saw. const recordAssistantMessage = () => { - if (text) session.append({ type: "assistant.message", text, at: now() }); if (text || toolCalls.length > 0) { + // toolCalls ride on the event so replay can rebuild the provider + // message even for turns where the model emitted no text. + session.append({ type: "assistant.message", text, toolCalls, at: now() }); session.messages.push({ role: "assistant", text, toolCalls }); } }; @@ -177,14 +181,12 @@ async function executeToolCall( toolName: call.toolName, decision: decision.allowed ? "allowed" : "denied", source: decision.source, + rule: decision.rule, at: now(), }); if (!decision.allowed) { return completeToolCall(context, call, { - output: - decision.source === "user" - ? "The user denied permission for this tool call." - : "Permission could not be granted for this tool call.", + output: decision.reason ?? deniedMessage(decision.source), isError: true, }); } @@ -210,6 +212,11 @@ async function executeToolCall( cwd: session.cwd, runtime: context.runtime, signal: context.signal, + updateTodos: (entries) => { + session.todos = entries; + session.append({ type: "todo.updated", entries, at: now() }); + sendUpdate(context, { sessionUpdate: "plan", entries }); + }, }); output = result.output; isError = result.isError ?? false; @@ -287,25 +294,43 @@ interface PermissionDecision { /** Who actually decided — the audit log must not blame the user for * transport failures or frontend defaults. */ source: "policy" | "user" | "frontend" | "error"; + /** The permission rule that decided or was created, when there is one. */ + rule?: string; + /** Denial text for the model, when the default message won't do. */ + reason?: string; +} + +function deniedMessage(source: PermissionDecision["source"]): string { + return source === "user" + ? "The user denied permission for this tool call." + : "Permission could not be granted for this tool call."; } /** - * Slice-1 policy: read-only tools are allowed outright; everything else asks - * the frontend via ACP session/request_permission. The rule engine and - * session modes (design decision #5) replace the first branch in slice 2. + * Rule engine + session modes (design decision #5): the engine decides + * allow/deny/ask; only "ask" round-trips to the frontend via ACP + * session/request_permission. An allow_always answer becomes a persisted + * project rule. */ async function checkPermission( context: LoopContext, call: ProviderToolCall, tool: KernelTool, ): Promise { - if (tool.readOnly) return { allowed: true, source: "policy" }; + const { session } = context; + const verdict = session.permissions.evaluate(tool, call.input, session.mode); + if (verdict.action === "allow") { + return { allowed: true, source: "policy", rule: verdict.rule }; + } + if (verdict.action === "deny") { + return { allowed: false, source: "policy", rule: verdict.rule, reason: verdict.reason }; + } try { const result = await context.connection.request( CLIENT_METHODS.sessionRequestPermission, { - sessionId: context.session.id, + sessionId: session.id, toolCall: { toolCallId: call.toolCallId, title: titleFor(tool, call), @@ -314,6 +339,7 @@ async function checkPermission( }, options: [ { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "allow_always", name: "Allow always", kind: "allow_always" }, { optionId: "reject", name: "Reject", kind: "reject_once" }, ], }, @@ -321,9 +347,20 @@ async function checkPermission( ); if (result.outcome.outcome === "cancelled") { // ACP: the frontend is cancelling the turn, not answering the question. - context.session.cancel(); + session.cancel(); return { allowed: false, source: "frontend" }; } + if (result.outcome.optionId === "allow_always") { + const rule = formatRule(tool.name, permissionValue(call.input)); + session.permissions.addAllowRule(rule); + try { + await persistAllowRule(context.runtime, session.cwd, rule); + } catch { + // The in-memory rule still covers this session; persistence failure + // must not fail an approved tool call. + } + return { allowed: true, source: "user", rule }; + } return { allowed: result.outcome.optionId === "allow", source: "user" }; } catch { // Transport failure or aborted prompt: deny by default, and don't diff --git a/packages/kernel/src/events.ts b/packages/kernel/src/events.ts index ee0c319..49c7711 100644 --- a/packages/kernel/src/events.ts +++ b/packages/kernel/src/events.ts @@ -1,5 +1,5 @@ -import type { StopReason } from "@minerva/protocol"; -import type { TurnUsage } from "@minerva/providers"; +import type { PlanEntry, StopReason } from "@minerva/protocol"; +import type { ProviderToolCall, TurnUsage } from "@minerva/providers"; /** * Session event-log entries (design decision #7). The append-only JSONL @@ -16,8 +16,20 @@ export type SessionEvent = provider: string; at: string; } + | { type: "session.resumed"; provider: string; at: string } + | { type: "session.mode_changed"; modeId: string; at: string } | { type: "user.message"; text: string; at: string } - | { type: "assistant.message"; text: string; at: string } + | { + type: "assistant.message"; + text: string; + /** + * Tool calls issued in this assistant turn. Duplicates tool.call events + * on purpose: this event alone must reconstruct the provider message on + * replay, including calls the model made with no accompanying text. + */ + toolCalls: ProviderToolCall[]; + at: string; + } | { type: "tool.call"; toolCallId: string; toolName: string; input: unknown; at: string } | { type: "tool.result"; toolCallId: string; output: string; isError: boolean; at: string } | { @@ -26,13 +38,17 @@ export type SessionEvent = toolName: string; decision: "allowed" | "denied"; /** - * "policy" = kernel rules; "user" = explicit user choice via permission - * request; "frontend" = the frontend answered without a user choice - * (cancelled outcome); "error" = the request failed, denied by default. + * "policy" = kernel rules/modes; "user" = explicit user choice via + * permission request; "frontend" = the frontend answered without a user + * choice (cancelled outcome); "error" = the request failed, denied by + * default. */ source: "policy" | "user" | "frontend" | "error"; + /** The permission rule that decided, when one matched. */ + rule?: string; at: string; } + | { type: "todo.updated"; entries: PlanEntry[]; at: string } | { type: "turn.completed"; stopReason: StopReason; usage?: TurnUsage; at: string } | { type: "turn.failed"; error: string; at: string }; diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 914b117..1c188cd 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -1,5 +1,8 @@ export * from "./events"; export * from "./kernel"; +export * from "./permissions"; +export * from "./replay"; export * from "./runtime"; export * from "./session"; +export * from "./settings"; export * from "./tools"; diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index a1e6086..4b10c1b 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -1,19 +1,29 @@ import { join } from "node:path"; import { AGENT_METHODS, + CLIENT_METHODS, Connection, type InitializeResult, JSON_RPC_ERROR_CODES, + MINERVA_METHODS, PROTOCOL_VERSION, RpcError, + type SessionLoadResult, + type SessionModeState, type SessionNewResult, type SessionPromptResult, + type SessionSetModeResult, + type SessionSummary, + type SessionsListResult, type Transport, } from "@minerva/protocol"; import type { ModelProvider } from "@minerva/providers"; import { runPrompt } from "./agent-loop"; +import { now } from "./events"; +import { isSessionModeId, SESSION_MODES } from "./permissions"; +import { replayEvents } from "./replay"; import { defaultRuntime, type Runtime } from "./runtime"; -import { Session } from "./session"; +import { parseEventLog, projectDir, Session } from "./session"; import { builtinTools, type KernelTool } from "./tools"; export interface KernelOptions { @@ -49,9 +59,18 @@ export class MinervaKernel { this.#connection = new Connection(transport); this.#connection.handleRequest(AGENT_METHODS.initialize, () => this.#initialize()); this.#connection.handleRequest(AGENT_METHODS.sessionNew, (params) => this.#sessionNew(params)); + this.#connection.handleRequest(AGENT_METHODS.sessionLoad, (params) => + this.#sessionLoad(params), + ); this.#connection.handleRequest(AGENT_METHODS.sessionPrompt, (params) => this.#sessionPrompt(params), ); + this.#connection.handleRequest(AGENT_METHODS.sessionSetMode, (params) => + this.#sessionSetMode(params), + ); + this.#connection.handleRequest(MINERVA_METHODS.sessionsList, (params) => + this.#sessionsList(params), + ); this.#connection.handleNotification(AGENT_METHODS.sessionCancel, (params) => { const { sessionId } = params as { sessionId?: string }; if (sessionId) this.#sessions.get(sessionId)?.cancel(); @@ -61,7 +80,7 @@ export class MinervaKernel { #initialize(): InitializeResult { return { protocolVersion: PROTOCOL_VERSION, - agentCapabilities: { loadSession: false }, + agentCapabilities: { loadSession: true }, }; } @@ -77,7 +96,110 @@ export class MinervaKernel { runtime: this.#runtime, }); this.#sessions.set(session.id, session); - return { sessionId: session.id }; + return { sessionId: session.id, modes: modeState(session) }; + } + + async #sessionLoad(params: unknown): Promise { + const { sessionId, cwd } = (params ?? {}) as { sessionId?: string; cwd?: string }; + if (typeof sessionId !== "string" || typeof cwd !== "string" || cwd.length === 0) { + throw new RpcError( + JSON_RPC_ERROR_CODES.INVALID_PARAMS, + "session/load requires sessionId and cwd", + ); + } + if (this.#sessions.get(sessionId)?.promptActive) { + throw new RpcError( + JSON_RPC_ERROR_CODES.INVALID_REQUEST, + "cannot load a session while a prompt is running in it", + ); + } + let loaded: Awaited>; + try { + loaded = await Session.load( + sessionId, + { cwd, dataDir: this.#dataDir, providerId: this.#provider.id, runtime: this.#runtime }, + this.#tools, + ); + } catch (error) { + throw new RpcError( + JSON_RPC_ERROR_CODES.INVALID_PARAMS, + error instanceof Error ? error.message : String(error), + ); + } + this.#sessions.set(sessionId, loaded.session); + // ACP: replay the conversation as session/update notifications before + // answering, so the frontend can rebuild its transcript. + for (const update of replayEvents(loaded.events, this.#tools).updates) { + this.#connection.notify(CLIENT_METHODS.sessionUpdate, { sessionId, update }); + } + return { modes: modeState(loaded.session) }; + } + + async #sessionSetMode(params: unknown): Promise { + const { sessionId, modeId } = (params ?? {}) as { sessionId?: string; modeId?: string }; + const session = sessionId ? this.#sessions.get(sessionId) : undefined; + if (!session) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, `unknown session: ${sessionId}`); + } + if (!isSessionModeId(modeId)) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, `unknown mode: ${modeId}`); + } + session.mode = modeId; + session.append({ type: "session.mode_changed", modeId, at: now() }); + this.#connection.notify(CLIENT_METHODS.sessionUpdate, { + sessionId: session.id, + update: { sessionUpdate: "current_mode_update", currentModeId: modeId }, + }); + return null; + } + + async #sessionsList(params: unknown): Promise { + const { cwd } = (params ?? {}) as { cwd?: string }; + if (typeof cwd !== "string" || cwd.length === 0) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, "sessions/list requires cwd"); + } + const dir = projectDir(this.#dataDir, cwd); + let raw: string; + try { + raw = await this.#runtime.readTextFile(join(dir, "index.jsonl")); + } catch { + return { sessions: [] }; + } + const entries = raw + .split("\n") + .filter((line) => line.trim()) + .flatMap((line) => { + try { + return [JSON.parse(line) as SessionSummary]; + } catch { + return []; + } + }) + .filter((entry) => entry.cwd === cwd); + + // Newest first, previews only for the recent window to bound file reads. + const recent = entries.reverse().slice(0, 20); + const sessions = await Promise.all( + recent.map(async (entry) => ({ + ...entry, + preview: await this.#sessionPreview(dir, entry.sessionId), + })), + ); + return { sessions }; + } + + async #sessionPreview(dir: string, sessionId: string): Promise { + try { + const raw = await this.#runtime.readTextFile(join(dir, `${sessionId}.jsonl`)); + for (const event of parseEventLog(raw)) { + if (event.type === "user.message") { + return event.text.length > 80 ? `${event.text.slice(0, 80)}…` : event.text; + } + } + } catch { + // Index entry without a readable log — list it without a preview. + } + return undefined; } async #sessionPrompt(params: unknown): Promise { @@ -130,6 +252,10 @@ export function createKernel(transport: Transport, options: KernelOptions): Mine return new MinervaKernel(transport, options); } +function modeState(session: Session): SessionModeState { + return { currentModeId: session.mode, availableModes: SESSION_MODES }; +} + function defaultSystemPrompt(cwd: string): string { return [ `You are Minerva, a coding agent. Working directory: ${cwd}.`, diff --git a/packages/kernel/src/permissions.ts b/packages/kernel/src/permissions.ts new file mode 100644 index 0000000..12c8415 --- /dev/null +++ b/packages/kernel/src/permissions.ts @@ -0,0 +1,118 @@ +import type { SessionMode } from "@minerva/protocol"; +import type { PermissionRules } from "./settings"; +import type { KernelTool } from "./tools"; + +/** + * Kernel-enforced permission engine (design decision #5): allow/deny/ask + * rules like `bash(git *)` plus session modes. Policy lives here — behind + * the protocol — so no frontend can bypass it. + */ + +export type SessionModeId = "plan" | "default" | "acceptEdits" | "auto"; + +export const DEFAULT_MODE: SessionModeId = "default"; + +export const SESSION_MODES: SessionMode[] = [ + { id: "plan", name: "Plan", description: "Read and analyze only; mutating tools are blocked" }, + { id: "default", name: "Default", description: "Ask before side-effecting tool calls" }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "File edits run without asking; commands still ask", + }, + { id: "auto", name: "Auto", description: "Everything runs without asking, except deny rules" }, +]; + +export function isSessionModeId(value: unknown): value is SessionModeId { + return SESSION_MODES.some((mode) => mode.id === value); +} + +export type PermissionVerdict = + | { action: "allow"; rule?: string } + | { action: "deny"; rule?: string; reason: string } + | { action: "ask" }; + +export class PermissionEngine { + #rules: PermissionRules; + + constructor(rules: PermissionRules) { + this.#rules = rules; + } + + /** Session-local addition; the caller persists it to settings separately. */ + addAllowRule(rule: string): void { + this.#rules.allow.push(rule); + } + + evaluate(tool: KernelTool, input: unknown, mode: SessionModeId): PermissionVerdict { + const value = permissionValue(input); + + // Deny is absolute: it outranks read-only policy, allow rules, and modes, + // so an org/project deny list can't be talked around. + const denied = matchRules(this.#rules.deny, tool.name, value); + if (denied) return { action: "deny", rule: denied, reason: `denied by rule ${denied}` }; + + if (tool.readOnly) return { action: "allow" }; + + const asked = matchRules(this.#rules.ask, tool.name, value); + if (asked) return { action: "ask" }; + + const allowed = matchRules(this.#rules.allow, tool.name, value); + if (allowed) return { action: "allow", rule: allowed }; + + switch (mode) { + case "plan": + return { + action: "deny", + reason: + "Minerva is in plan mode: mutating tools are blocked. Present a plan instead of making changes.", + }; + case "auto": + return { action: "allow" }; + case "acceptEdits": + return tool.kind === "edit" ? { action: "allow" } : { action: "ask" }; + default: + return { action: "ask" }; + } + } +} + +/** + * The string a rule pattern matches against — and the rule persisted by an + * "always allow" answer. Command text for bash, the path for file tools. + */ +export function permissionValue(input: unknown): string { + if (typeof input === "object" && input !== null) { + const record = input as Record; + if (typeof record.command === "string") return record.command; + if (typeof record.path === "string") return record.path; + } + return JSON.stringify(input ?? null); +} + +export function formatRule(toolName: string, value: string): string { + return `${toolName}(${value})`; +} + +function matchRules(rules: string[], toolName: string, value: string): string | undefined { + return rules.find((rule) => ruleMatches(rule, toolName, value)); +} + +/** + * Rule forms: `bash` (bare tool name, matches every call) or + * `bash(git *)` — a wildcard pattern over the tool's permission value, + * where `*` matches any run of characters and `?` a single one. + */ +export function ruleMatches(rule: string, toolName: string, value: string): boolean { + const open = rule.indexOf("("); + if (open === -1) return rule.trim() === toolName; + if (!rule.endsWith(")")) return false; + if (rule.slice(0, open).trim() !== toolName) return false; + const pattern = rule.slice(open + 1, -1); + return wildcardToRegex(pattern).test(value); +} + +function wildcardToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped.replace(/\*/g, "[\\s\\S]*").replace(/\?/g, "[\\s\\S]")}$`); +} diff --git a/packages/kernel/src/replay.ts b/packages/kernel/src/replay.ts new file mode 100644 index 0000000..4e42bac --- /dev/null +++ b/packages/kernel/src/replay.ts @@ -0,0 +1,150 @@ +import type { PlanEntry, SessionUpdate } from "@minerva/protocol"; +import type { ProviderMessage, ProviderToolCall, ProviderToolResult } from "@minerva/providers"; +import type { SessionEvent } from "./events"; +import type { KernelTool } from "./tools"; + +export interface ReplayResult { + /** Provider messages, ready to continue the conversation. */ + messages: ProviderMessage[]; + /** Session updates that re-render the transcript in any frontend. */ + updates: SessionUpdate[]; + todos: PlanEntry[]; + /** Last mode recorded in the log, if any. */ + modeId?: string; +} + +/** + * Rebuild session state from the event log — the other half of design + * decision #7 (the event stream is the source of truth). A turn interrupted + * by a crash may leave tool calls without results; those are synthesized as + * errors so the resumed history is well-formed for the provider. + */ +export function replayEvents(events: SessionEvent[], tools: KernelTool[]): ReplayResult { + const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); + const messages: ProviderMessage[] = []; + const updates: SessionUpdate[] = []; + let todos: PlanEntry[] = []; + let modeId: string | undefined; + + let expected: ProviderToolCall[] = []; + let results: ProviderToolResult[] = []; + + // Close out an assistant turn: synthesize error results for tool calls the + // log never resolved, then emit the role:"tool" message. + const flushToolBatch = () => { + if (expected.length === 0) return; + for (const call of expected) { + if (results.some((result) => result.toolCallId === call.toolCallId)) continue; + results.push({ + toolCallId: call.toolCallId, + toolName: call.toolName, + output: "Tool call was interrupted before completing (session resumed).", + isError: true, + }); + updates.push({ + sessionUpdate: "tool_call_update", + toolCallId: call.toolCallId, + status: "failed", + }); + } + messages.push({ role: "tool", results }); + expected = []; + results = []; + }; + + for (const event of events) { + switch (event.type) { + case "user.message": + flushToolBatch(); + messages.push({ role: "user", content: event.text }); + updates.push({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: event.text }, + }); + break; + + case "assistant.message": { + flushToolBatch(); + const toolCalls = event.toolCalls ?? []; + messages.push({ role: "assistant", text: event.text, toolCalls }); + if (event.text) { + updates.push({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: event.text }, + }); + } + expected = [...toolCalls]; + break; + } + + case "tool.call": { + const tool = toolsByName.get(event.toolName); + updates.push({ + sessionUpdate: "tool_call", + toolCallId: event.toolCallId, + title: titleFor(tool, event.toolName, event.input), + kind: tool?.kind ?? "other", + status: "pending", + rawInput: event.input, + }); + break; + } + + case "tool.result": { + const call = expected.find((entry) => entry.toolCallId === event.toolCallId); + // A result with no expected call (foreign/damaged log) still renders + // in the UI but can't be attached to a provider message. + if (call) { + results.push({ + toolCallId: event.toolCallId, + toolName: call.toolName, + output: event.output, + isError: event.isError, + }); + } + updates.push({ + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + status: event.isError ? "failed" : "completed", + content: [{ type: "content", content: { type: "text", text: event.output } }], + }); + if (expected.length > 0 && results.length === expected.length) { + messages.push({ role: "tool", results }); + expected = []; + results = []; + } + break; + } + + case "todo.updated": + todos = event.entries; + updates.push({ sessionUpdate: "plan", entries: event.entries }); + break; + + case "session.mode_changed": + modeId = event.modeId; + break; + + case "turn.completed": + case "turn.failed": + flushToolBatch(); + break; + + default: + break; + } + } + flushToolBatch(); + + if (modeId) updates.push({ sessionUpdate: "current_mode_update", currentModeId: modeId }); + return { messages, updates, todos, modeId }; +} + +function titleFor(tool: KernelTool | undefined, toolName: string, input: unknown): string { + if (!tool) return toolName; + try { + return tool.title(input); + } catch { + return tool.name; + } +} diff --git a/packages/kernel/src/session.ts b/packages/kernel/src/session.ts index 06a37f3..12ea0fb 100644 --- a/packages/kernel/src/session.ts +++ b/packages/kernel/src/session.ts @@ -1,53 +1,110 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; +import type { PlanEntry } from "@minerva/protocol"; import type { ProviderMessage } from "@minerva/providers"; import { now, type SessionEvent } from "./events"; +import { DEFAULT_MODE, isSessionModeId, PermissionEngine, type SessionModeId } from "./permissions"; +import { replayEvents } from "./replay"; import type { Runtime } from "./runtime"; +import { loadSettings } from "./settings"; +import type { KernelTool } from "./tools"; + +export interface SessionOptions { + cwd: string; + dataDir: string; + providerId: string; + runtime: Runtime; +} /** * One conversation. The JSONL event log is the durable record; the - * in-memory provider messages are a projection of it, maintained - * incrementally (full replay/resume lands in slice 2). + * in-memory provider messages are a projection of it — maintained + * incrementally while live, rebuilt by replay on resume. */ export class Session { readonly id: string; readonly cwd: string; readonly messages: ProviderMessage[] = []; + readonly permissions: PermissionEngine; + mode: SessionModeId; + todos: PlanEntry[] = []; promptActive = false; #logPath: string; #runtime: Runtime; #logChain: Promise = Promise.resolve(); + #logError: unknown = null; #abort: AbortController | null = null; - private constructor(id: string, cwd: string, logPath: string, runtime: Runtime) { + private constructor( + id: string, + options: SessionOptions, + permissions: PermissionEngine, + mode: SessionModeId, + ) { this.id = id; - this.cwd = cwd; - this.#logPath = logPath; - this.#runtime = runtime; + this.cwd = options.cwd; + this.permissions = permissions; + this.mode = mode; + this.#logPath = join(projectDir(options.dataDir, options.cwd), `${id}.jsonl`); + this.#runtime = options.runtime; } - static async create(options: { - cwd: string; - dataDir: string; - providerId: string; - runtime: Runtime; - }): Promise { + static async create(options: SessionOptions): Promise { const id = `ses_${randomUUID()}`; - const projectDir = join(options.dataDir, "projects", projectSlug(options.cwd)); - await options.runtime.mkdirp(projectDir); - const session = new Session(id, options.cwd, join(projectDir, `${id}.jsonl`), options.runtime); + const dir = projectDir(options.dataDir, options.cwd); + await options.runtime.mkdirp(dir); + const settings = await loadSettings(options.runtime, options.dataDir, options.cwd); + const mode = isSessionModeId(settings.defaultMode) ? settings.defaultMode : DEFAULT_MODE; + const session = new Session(id, options, new PermissionEngine(settings.rules), mode); + + const createdAt = now(); + await options.runtime.appendTextFile( + join(dir, "index.jsonl"), + `${JSON.stringify({ sessionId: id, cwd: options.cwd, createdAt })}\n`, + ); session.append({ type: "session.created", sessionId: id, cwd: options.cwd, provider: options.providerId, - at: now(), + at: createdAt, }); return session; } - #logError: unknown = null; + /** + * Resume a persisted session: replay the event log to rebuild the model + * context, and hand the caller the events so it can re-render the UI. + */ + static async load( + sessionId: string, + options: SessionOptions, + tools: KernelTool[], + ): Promise<{ session: Session; events: SessionEvent[] }> { + const logPath = join(projectDir(options.dataDir, options.cwd), `${sessionId}.jsonl`); + let raw: string; + try { + raw = await options.runtime.readTextFile(logPath); + } catch { + throw new Error(`no persisted session ${sessionId} for ${options.cwd}`); + } + const events = parseEventLog(raw); + const replay = replayEvents(events, tools); + + const settings = await loadSettings(options.runtime, options.dataDir, options.cwd); + const mode = isSessionModeId(replay.modeId) + ? replay.modeId + : isSessionModeId(settings.defaultMode) + ? settings.defaultMode + : DEFAULT_MODE; + + const session = new Session(sessionId, options, new PermissionEngine(settings.rules), mode); + session.messages.push(...replay.messages); + session.todos = replay.todos; + session.append({ type: "session.resumed", provider: options.providerId, at: now() }); + return { session, events }; + } /** * Append to the JSONL log. Writes are chained so events land in order even @@ -99,7 +156,24 @@ export class Session { } } +export function projectDir(dataDir: string, cwd: string): string { + return join(dataDir, "projects", projectSlug(cwd)); +} + /** Filesystem-safe project identifier derived from the working directory. */ export function projectSlug(cwd: string): string { return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "root"; } + +export function parseEventLog(raw: string): SessionEvent[] { + const events: SessionEvent[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + events.push(JSON.parse(line) as SessionEvent); + } catch { + // A torn final line (kill -9 mid-write) must not block resume. + } + } + return events; +} diff --git a/packages/kernel/src/settings.ts b/packages/kernel/src/settings.ts new file mode 100644 index 0000000..ea79bd5 --- /dev/null +++ b/packages/kernel/src/settings.ts @@ -0,0 +1,83 @@ +import { dirname, join } from "node:path"; +import type { Runtime } from "./runtime"; + +/** + * Settings live in two layers (design record: Config): global + * `/settings.json` and project `/.minerva/settings.json`. + * Permission lists concatenate (deny always wins at evaluation time); + * scalar settings prefer the project layer. + */ + +export interface PermissionRules { + allow: string[]; + deny: string[]; + ask: string[]; +} + +export interface MinervaSettings { + permissions?: Partial; + defaultMode?: string; +} + +export interface ResolvedSettings { + rules: PermissionRules; + defaultMode?: string; +} + +export function globalSettingsPath(dataDir: string): string { + return join(dataDir, "settings.json"); +} + +export function projectSettingsPath(cwd: string): string { + return join(cwd, ".minerva", "settings.json"); +} + +export async function loadSettings( + runtime: Runtime, + dataDir: string, + cwd: string, +): Promise { + const global = await readSettingsFile(runtime, globalSettingsPath(dataDir)); + const project = await readSettingsFile(runtime, projectSettingsPath(cwd)); + return { + rules: { + allow: [...(global.permissions?.allow ?? []), ...(project.permissions?.allow ?? [])], + deny: [...(global.permissions?.deny ?? []), ...(project.permissions?.deny ?? [])], + ask: [...(global.permissions?.ask ?? []), ...(project.permissions?.ask ?? [])], + }, + defaultMode: project.defaultMode ?? global.defaultMode, + }; +} + +/** Persist an "always allow" decision into the project settings file. */ +export async function persistAllowRule(runtime: Runtime, cwd: string, rule: string): Promise { + const path = projectSettingsPath(cwd); + const settings = await readSettingsFile(runtime, path); + const allow = settings.permissions?.allow ?? []; + if (allow.includes(rule)) return; + const next: MinervaSettings = { + ...settings, + permissions: { ...settings.permissions, allow: [...allow, rule] }, + }; + await runtime.mkdirp(dirname(path)); + await runtime.writeTextFile(path, `${JSON.stringify(next, null, 2)}\n`); +} + +async function readSettingsFile(runtime: Runtime, path: string): Promise { + let raw: string; + try { + raw = await runtime.readTextFile(path); + } catch { + return {}; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null) return {}; + return parsed as MinervaSettings; + } catch (error) { + // A corrupt settings file must not silently grant or drop permissions. + throw new Error( + `invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/packages/kernel/test/kernel.test.ts b/packages/kernel/test/kernel.test.ts index 5fbfe43..1c5d1b9 100644 --- a/packages/kernel/test/kernel.test.ts +++ b/packages/kernel/test/kernel.test.ts @@ -30,13 +30,14 @@ interface Harness { updates: SessionUpdateParams[]; permissionRequests: RequestPermissionParams[]; sessionId: string; + cwd: string; logEvents: () => SessionEvent[]; sessionMessages: () => unknown[]; } async function setup(options: { turns: TurnEvent[][]; - permission?: "allow" | "reject" | "cancel"; + permission?: "allow" | "allow_always" | "reject" | "cancel"; }): Promise { const cwd = mkdtempSync(join(tmpdir(), "minerva-proj-")); const dataDir = mkdtempSync(join(tmpdir(), "minerva-data-")); @@ -78,6 +79,7 @@ async function setup(options: { updates, permissionRequests, sessionId, + cwd, logEvents: () => { const logPath = kernel.getSession(sessionId)?.logPath; if (!logPath) throw new Error("session log not found"); @@ -246,6 +248,97 @@ describe("kernel over in-proc transport", () => { expect(assistant).toMatchObject({ text: "Editing now." }); }); + test("allow_always persists a rule and later identical calls skip the prompt", async () => { + const call = (id: string): TurnEvent => ({ + type: "tool-call", + toolCallId: id, + toolName: "bash", + input: { command: "echo x" }, + }); + const harness = await setup({ + permission: "allow_always", + turns: [ + [call("c1"), FINISH_TOOLS], + [call("c2"), FINISH_TOOLS], + [{ type: "text-delta", text: "done" }, FINISH_STOP], + ], + }); + + await prompt(harness, "run it twice"); + expect(harness.permissionRequests).toHaveLength(1); + + const decisions = harness.logEvents().filter((event) => event.type === "permission.decision"); + expect(decisions[0]).toMatchObject({ + decision: "allowed", + source: "user", + rule: "bash(echo x)", + }); + expect(decisions[1]).toMatchObject({ + decision: "allowed", + source: "policy", + rule: "bash(echo x)", + }); + + const settings = JSON.parse( + readFileSync(join(harness.cwd, ".minerva", "settings.json"), "utf8"), + ); + expect(settings.permissions.allow).toEqual(["bash(echo x)"]); + }); + + test("plan mode denies mutating tools without asking; auto mode allows", async () => { + const harness = await setup({ + turns: [ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "echo blocked" }, + }, + FINISH_TOOLS, + ], + [{ type: "text-delta", text: "ok, planning only" }, FINISH_STOP], + [ + { + type: "tool-call", + toolCallId: "c2", + toolName: "bash", + input: { command: "echo allowed" }, + }, + FINISH_TOOLS, + ], + [{ type: "text-delta", text: "ran it" }, FINISH_STOP], + ], + }); + + await harness.client.request(AGENT_METHODS.sessionSetMode, { + sessionId: harness.sessionId, + modeId: "plan", + }); + await prompt(harness, "try something"); + expect(harness.permissionRequests).toHaveLength(0); + const denied = harness.logEvents().find((event) => event.type === "permission.decision"); + expect(denied).toMatchObject({ decision: "denied", source: "policy" }); + const failed = harness.updates.find( + (u) => u.update.sessionUpdate === "tool_call_update" && u.update.status === "failed", + ); + expect(JSON.stringify(failed)).toContain("plan mode"); + + await harness.client.request(AGENT_METHODS.sessionSetMode, { + sessionId: harness.sessionId, + modeId: "auto", + }); + await prompt(harness, "now really run it"); + expect(harness.permissionRequests).toHaveLength(0); + const completed = harness.updates.filter( + (u) => u.update.sessionUpdate === "tool_call_update" && u.update.status === "completed", + ); + expect(JSON.stringify(completed)).toContain("allowed"); + + const modeEvents = harness.logEvents().filter((event) => event.type === "session.mode_changed"); + expect(modeEvents.map((event) => event.modeId)).toEqual(["plan", "auto"]); + }); + test("concurrent prompt on the same session is rejected", async () => { const harness = await setup({ turns: [ diff --git a/packages/kernel/test/permissions.test.ts b/packages/kernel/test/permissions.test.ts new file mode 100644 index 0000000..c78757e --- /dev/null +++ b/packages/kernel/test/permissions.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { + bashTool, + editFileTool, + formatRule, + PermissionEngine, + permissionValue, + readFileTool, + ruleMatches, +} from "../src"; + +const engine = (rules: Partial<{ allow: string[]; deny: string[]; ask: string[] }> = {}) => + new PermissionEngine({ allow: [], deny: [], ask: [], ...rules }); + +describe("rule matching", () => { + test("bare tool name matches every call of that tool", () => { + expect(ruleMatches("bash", "bash", "rm -rf /")).toBe(true); + expect(ruleMatches("bash", "edit_file", "x")).toBe(false); + }); + + test("wildcard patterns match the permission value", () => { + expect(ruleMatches("bash(git *)", "bash", "git status")).toBe(true); + expect(ruleMatches("bash(git *)", "bash", "gitx")).toBe(false); + expect(ruleMatches("bash(git *)", "bash", "npm install")).toBe(false); + expect(ruleMatches("edit_file(src/*)", "edit_file", "src/deep/nested.ts")).toBe(true); + expect(ruleMatches("edit_file(src/*)", "edit_file", "test/x.ts")).toBe(false); + }); + + test("regex metacharacters in patterns are literal", () => { + expect(ruleMatches("bash(echo (hi))", "bash", "echo (hi)")).toBe(true); // nested parens stay literal + expect(ruleMatches("bash(git add .)", "bash", "git add .")).toBe(true); + expect(ruleMatches("bash(git add .)", "bash", "git add x")).toBe(false); + }); + + test("permissionValue picks command for bash and path for file tools", () => { + expect(permissionValue({ command: "ls -la" })).toBe("ls -la"); + expect(permissionValue({ path: "a.ts", old_string: "x" })).toBe("a.ts"); + expect(formatRule("bash", "git status")).toBe("bash(git status)"); + }); +}); + +describe("engine verdicts", () => { + test("deny outranks everything, including read-only policy", () => { + const e = engine({ deny: ["read_file(secrets/*)"], allow: ["read_file"] }); + const verdict = e.evaluate(readFileTool, { path: "secrets/key.pem" }, "auto"); + expect(verdict).toMatchObject({ action: "deny", rule: "read_file(secrets/*)" }); + }); + + test("read-only tools are allowed in every mode", () => { + for (const mode of ["plan", "default", "acceptEdits", "auto"] as const) { + expect(engine().evaluate(readFileTool, { path: "a.ts" }, mode).action).toBe("allow"); + } + }); + + test("allow rule grants without asking; ask rule outranks allow", () => { + const e = engine({ allow: ["bash(git *)"], ask: ["bash(git push*)"] }); + expect(e.evaluate(bashTool, { command: "git status" }, "default")).toMatchObject({ + action: "allow", + rule: "bash(git *)", + }); + expect(e.evaluate(bashTool, { command: "git push origin" }, "default").action).toBe("ask"); + }); + + test("modes decide unmatched mutating calls", () => { + const e = engine(); + expect(e.evaluate(bashTool, { command: "ls" }, "plan").action).toBe("deny"); + expect(e.evaluate(bashTool, { command: "ls" }, "default").action).toBe("ask"); + expect(e.evaluate(bashTool, { command: "ls" }, "auto").action).toBe("allow"); + expect(e.evaluate(editFileTool, { path: "a" }, "acceptEdits").action).toBe("allow"); + expect(e.evaluate(bashTool, { command: "ls" }, "acceptEdits").action).toBe("ask"); + }); + + test("addAllowRule takes effect immediately", () => { + const e = engine(); + expect(e.evaluate(bashTool, { command: "bun test" }, "default").action).toBe("ask"); + e.addAllowRule("bash(bun test)"); + expect(e.evaluate(bashTool, { command: "bun test" }, "default").action).toBe("allow"); + }); +}); From 31c9469b87e05c4d4f1f76056b656f32ad8a0baf Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:07:19 +0800 Subject: [PATCH 12/20] feat(client,cli): resume flags, session modes UI, always-allow, todo rendering The shared client grows loadSession (store registered before the request so replayed updates land in it), listSessions, and setMode; the store renders plan updates as a single in-place todo item and tracks the current mode. The CLI wires them up: --continue/-c resumes the latest session for the directory, --resume a specific one, /mode switches modes (shown next to the prompt when not default), the permission prompt gains 'a' for allow-always, and todo lists render as a checklist. Integration tests cover the v0.1 exit criterion directly: a session killed mid-tool-call (dangling tool call + torn log line) resumes with a well-formed history and keeps working. --- packages/cli/src/app.tsx | 81 +++++++++++++-- packages/cli/src/index.tsx | 35 ++++++- packages/client/src/client.ts | 46 ++++++++- packages/client/src/store.ts | 32 +++++- packages/client/test/resume.test.ts | 150 ++++++++++++++++++++++++++++ 5 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 packages/client/test/resume.test.ts diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index 4907df9..adcb3e8 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -9,9 +9,11 @@ interface AppProps { bridge: PermissionBridge; model: string; cwd: string; + /** null = new session; "latest" = most recent for cwd; else a session id. */ + resume: string | null; } -export function App({ client, bridge, model, cwd }: AppProps) { +export function App({ client, bridge, model, cwd, resume }: AppProps) { const [session, setSession] = useState<{ id: string; store: SessionStore } | null>(null); const [pending, setPending] = useState(null); const [error, setError] = useState(null); @@ -27,15 +29,24 @@ export function App({ client, bridge, model, cwd }: AppProps) { }, }); }); - client - .initialize() - .then(() => client.newSession(cwd)) + const establish = async () => { + await client.initialize(); + if (resume === "latest") { + const sessions = await client.listSessions(cwd); + const latest = sessions[0]; + if (!latest) throw new Error(`no previous sessions for ${cwd}`); + return client.loadSession(latest.sessionId, cwd); + } + if (resume) return client.loadSession(resume, cwd); + return client.newSession(cwd); + }; + establish() .then(({ sessionId, store }) => setSession({ id: sessionId, store })) - .catch((cause) => setError(String(cause))); + .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); return () => { bridge.handler = null; }; - }, [client, bridge, cwd]); + }, [client, bridge, cwd, resume]); if (error) { return Failed to start session: {error}; @@ -82,8 +93,21 @@ function Chat({ exit(); return; } + if (text === "/mode" || text.startsWith("/mode ")) { + const modeId = text.slice("/mode".length).trim(); + if (!modeId) { + session.store.addInfo( + `mode: ${viewModel.currentModeId ?? "default"} — /mode plan | default | acceptEdits | auto`, + ); + return; + } + client.setMode(session.id, modeId).catch((cause) => { + session.store.addInfo(`error: ${cause instanceof Error ? cause.message : String(cause)}`); + }); + return; + } client.prompt(session.id, text).catch((cause) => { - session.store.addInfo(`error: ${String(cause)}`); + session.store.addInfo(`error: ${cause instanceof Error ? cause.message : String(cause)}`); }); }; @@ -98,6 +122,9 @@ function Chat({ ✳ working… (esc to cancel) ) : ( + {viewModel.currentModeId && viewModel.currentModeId !== "default" ? ( + [{viewModel.currentModeId}] + ) : null} {"> "} @@ -107,7 +134,9 @@ function Chat({ } function itemKey(item: ViewItem, index: number): string { - return item.kind === "tool" ? `tool-${item.toolCallId}` : `${item.kind}-${index}`; + if (item.kind === "tool") return `tool-${item.toolCallId}`; + if (item.kind === "plan") return "plan"; + return `${item.kind}-${index}`; } function ItemView({ item }: { item: ViewItem }) { @@ -127,6 +156,8 @@ function ItemView({ item }: { item: ViewItem }) { ); case "tool": return ; + case "plan": + return ; case "info": return ( @@ -167,11 +198,43 @@ function firstLines(text: string, count: number): string { return `${lines.slice(0, count).join("\n")}\n… (${lines.length - count} more lines)`; } +function PlanView({ entries }: { entries: Extract["entries"] }) { + return ( + + Todos + {withUniqueKeys(entries).map(({ entry, key }) => ( + + {entry.status === "completed" ? "☑" : entry.status === "in_progress" ? "◐" : "☐"}{" "} + {entry.content} + + ))} + + ); +} + +/** Stable-ish keys from content, disambiguating duplicate entries. */ +function withUniqueKeys( + entries: T[], +): Array<{ entry: T; key: string }> { + const seen = new Map(); + return entries.map((entry) => { + const count = seen.get(entry.content) ?? 0; + seen.set(entry.content, count + 1); + return { entry, key: count === 0 ? entry.content : `${entry.content}#${count}` }; + }); +} + function PermissionPrompt({ pending }: { pending: PendingPermission }) { useInput((input, key) => { const answer = input.toLowerCase(); if (answer === "y") { pending.resolve({ outcome: { outcome: "selected", optionId: "allow" } }); + } else if (answer === "a") { + pending.resolve({ outcome: { outcome: "selected", optionId: "allow_always" } }); } else if (answer === "n") { pending.resolve({ outcome: { outcome: "selected", optionId: "reject" } }); } else if (key.escape) { @@ -185,7 +248,7 @@ function PermissionPrompt({ pending }: { pending: PendingPermission }) { Permission required {pending.request.toolCall.title} - allow? (y/n, esc cancels the turn) + y allow · a always allow · n reject · esc cancel turn ); } diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index 8c5ad62..acf47d4 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -13,7 +13,38 @@ if (!process.env.ANTHROPIC_API_KEY) { process.exit(1); } -const model = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; +const USAGE = `minerva — model-agnostic code agent + +Usage: minerva [options] + -c, --continue Resume the most recent session for this directory + -r, --resume Resume a specific session + -m, --model Model to use (default: ${DEFAULT_ANTHROPIC_MODEL}, env: MINERVA_MODEL) + -h, --help Show this help`; + +let model = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; +let resume: string | null = null; +const argv = process.argv.slice(2); +for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--continue" || arg === "-c") { + resume = "latest"; + } else if (arg === "--resume" || arg === "-r") { + resume = argv[++i] ?? null; + if (!resume) { + console.error("--resume requires a session id"); + process.exit(1); + } + } else if (arg === "--model" || arg === "-m") { + model = argv[++i] ?? model; + } else if (arg === "--help" || arg === "-h") { + console.log(USAGE); + process.exit(0); + } else { + console.error(`unknown option: ${arg}\n\n${USAGE}`); + process.exit(1); + } +} + const cwd = process.cwd(); // The CLI embeds the kernel, but only across the protocol's in-proc @@ -26,7 +57,7 @@ const client = new MinervaClient(clientTransport, { onPermissionRequest: bridge.onPermissionRequest, }); -const app = render(); +const app = render(); await app.waitUntilExit(); // Ink unmounts but stdin's raw-mode listener keeps the runtime alive. process.exit(0); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index bb37fc7..3d1b490 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -3,11 +3,15 @@ import { CLIENT_METHODS, Connection, type InitializeResult, + MINERVA_METHODS, PROTOCOL_VERSION, type RequestPermissionParams, type RequestPermissionResult, + type SessionLoadResult, type SessionNewResult, type SessionPromptResult, + type SessionSummary, + type SessionsListResult, type SessionUpdateParams, type StopReason, type Transport, @@ -57,15 +61,55 @@ export class MinervaClient { } async newSession(cwd: string): Promise<{ sessionId: string; store: SessionStore }> { - const { sessionId } = await this.#connection.request( + const { sessionId, modes } = await this.#connection.request( AGENT_METHODS.sessionNew, { cwd }, ); const store = new SessionStore(); + if (modes) store.setMode(modes.currentModeId); this.#stores.set(sessionId, store); return { sessionId, store }; } + /** + * Resume a persisted session. The store must be registered before the + * request goes out: the kernel replays the transcript as session/update + * notifications ahead of its response. + */ + async loadSession( + sessionId: string, + cwd: string, + ): Promise<{ sessionId: string; store: SessionStore }> { + const store = new SessionStore(); + this.#stores.set(sessionId, store); + try { + const { modes } = await this.#connection.request( + AGENT_METHODS.sessionLoad, + { sessionId, cwd }, + ); + if (modes) store.setMode(modes.currentModeId); + // The replayed transcript is settled history — close any item the + // replay left in the streaming state. + store.setBusy(false); + return { sessionId, store }; + } catch (error) { + this.#stores.delete(sessionId); + throw error; + } + } + + async listSessions(cwd: string): Promise { + const result = await this.#connection.request( + MINERVA_METHODS.sessionsList, + { cwd }, + ); + return result.sessions; + } + + async setMode(sessionId: string, modeId: string): Promise { + await this.#connection.request(AGENT_METHODS.sessionSetMode, { sessionId, modeId }); + } + async prompt(sessionId: string, text: string): Promise { // Reject overlapping prompts before touching the store: the kernel would // reject them anyway, but by then the rejected call's echo and its diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index ee47470..37d03cf 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -1,4 +1,10 @@ -import type { SessionUpdate, ToolCallContent, ToolCallStatus, ToolKind } from "@minerva/protocol"; +import type { + PlanEntry, + SessionUpdate, + ToolCallContent, + ToolCallStatus, + ToolKind, +} from "@minerva/protocol"; /** * Session view-model (design decision #8): protocol updates in, renderable @@ -17,11 +23,13 @@ export type ViewItem = status: ToolCallStatus; output?: string; } + | { kind: "plan"; entries: PlanEntry[] } | { kind: "info"; text: string }; export interface SessionViewModel { items: ViewItem[]; busy: boolean; + currentModeId?: string; } const EMPTY: SessionViewModel = { items: [], busy: false }; @@ -81,9 +89,31 @@ export class SessionStore { output: extractText(update.content) ?? item.output, })); break; + case "plan": + this.#upsertPlan(update.entries); + break; + case "current_mode_update": + this.setMode(update.currentModeId); + break; } } + setMode(modeId: string): void { + this.#set({ ...this.#viewModel, currentModeId: modeId }); + } + + /** One live plan per session: the latest update replaces it in place. */ + #upsertPlan(entries: PlanEntry[]): void { + const existing = this.#viewModel.items.findIndex((item) => item.kind === "plan"); + if (existing === -1) { + this.#push({ kind: "plan", entries }); + return; + } + const items = [...this.#viewModel.items]; + items[existing] = { kind: "plan", entries }; + this.#set({ ...this.#viewModel, items }); + } + #appendAssistantText(text: string): void { const items = [...this.#viewModel.items]; const last = items[items.length - 1]; diff --git a/packages/client/test/resume.test.ts b/packages/client/test/resume.test.ts new file mode 100644 index 0000000..b43d5c9 --- /dev/null +++ b/packages/client/test/resume.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { appendFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createKernel, type MinervaKernel, projectDir } from "@minerva/kernel"; +import { createInProcTransportPair } from "@minerva/protocol"; +import { createScriptedProvider, type TurnEvent } from "@minerva/providers"; +import { MinervaClient } from "../src"; + +const FINISH_TOOLS: TurnEvent = { type: "finish", finishReason: "tool-calls", usage: {} }; +const FINISH_STOP: TurnEvent = { type: "finish", finishReason: "stop", usage: {} }; + +function boot( + dataDir: string, + turns: TurnEvent[][], +): { client: MinervaClient; kernel: MinervaKernel } { + const [clientTransport, kernelTransport] = createInProcTransportPair(); + const kernel = createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider(turns), + }); + const client = new MinervaClient(clientTransport, { + onPermissionRequest: async () => ({ outcome: { outcome: "selected", optionId: "allow" } }), + }); + return { client, kernel }; +} + +describe("session resume across kernel restarts", () => { + test("load rebuilds the transcript and the conversation continues", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-resume-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-resume-data-")); + + // First process lifetime: one prompt with an approved tool call. + const first = boot(dataDir, [ + [ + { type: "text-delta", text: "Running." }, + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "echo persisted" }, + }, + FINISH_TOOLS, + ], + [{ type: "text-delta", text: "Done." }, FINISH_STOP], + ]); + await first.client.initialize(); + const { sessionId } = await first.client.newSession(cwd); + await first.client.prompt(sessionId, "run echo"); + first.client.close(); + + // Second process lifetime: same dataDir, fresh kernel + client. + const second = boot(dataDir, [[{ type: "text-delta", text: "Welcome back." }, FINISH_STOP]]); + await second.client.initialize(); + + const sessions = await second.client.listSessions(cwd); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ sessionId, preview: "run echo" }); + + const { store } = await second.client.loadSession(sessionId, cwd); + expect(store.snapshot.items).toEqual([ + { kind: "user", text: "run echo" }, + { kind: "assistant", text: "Running.", streaming: false }, + { + kind: "tool", + toolCallId: "c1", + title: "echo persisted", + toolKind: "execute", + status: "completed", + output: "persisted\n", + }, + { kind: "assistant", text: "Done.", streaming: false }, + ]); + + // The resumed session carries the prior context into the next turn. + const resumed = second.kernel.getSession(sessionId); + expect(resumed?.messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "tool", + "assistant", + ]); + const stopReason = await second.client.prompt(sessionId, "hello again"); + expect(stopReason).toBe("end_turn"); + expect(store.snapshot.items.at(-1)).toEqual({ + kind: "assistant", + text: "Welcome back.", + streaming: false, + }); + }); + + test("a turn killed mid-tool-call resumes with synthesized results and a torn line ignored", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-kill9-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-kill9-data-")); + + const first = boot(dataDir, []); + await first.client.initialize(); + const { sessionId } = await first.client.newSession(cwd); + first.client.close(); + + // Simulate kill -9 mid-turn: the assistant message with a tool call was + // logged, the result never arrived, and the final line is torn. The + // kernel's own log writes are async — give session.created a beat to + // land first so the synthetic events append in realistic order. + const dir = projectDir(dataDir, cwd); + await Bun.sleep(30); + const logPath = join(dir, `${sessionId}.jsonl`); + appendFileSync( + logPath, + `${JSON.stringify({ type: "user.message", text: "long task", at: "t" })}\n` + + `${JSON.stringify({ + type: "assistant.message", + text: "Starting", + toolCalls: [{ toolCallId: "c9", toolName: "bash", input: { command: "sleep 999" } }], + at: "t", + })}\n` + + `${JSON.stringify({ type: "tool.call", toolCallId: "c9", toolName: "bash", input: { command: "sleep 999" }, at: "t" })}\n` + + '{"type":"tool.res', // torn write + ); + + const second = boot(dataDir, [[{ type: "text-delta", text: "Recovered." }, FINISH_STOP]]); + await second.client.initialize(); + const { store } = await second.client.loadSession(sessionId, cwd); + + const tool = store.snapshot.items.find((item) => item.kind === "tool"); + expect(tool).toMatchObject({ toolCallId: "c9", status: "failed" }); + + // History is well-formed: the dangling tool call has a synthesized result. + const resumed = second.kernel.getSession(sessionId); + const toolMessage = resumed?.messages.at(-1); + expect(toolMessage).toMatchObject({ role: "tool" }); + expect(JSON.stringify(toolMessage)).toContain("interrupted"); + + const stopReason = await second.client.prompt(sessionId, "continue"); + expect(stopReason).toBe("end_turn"); + }); + + test("setMode round-trips into the store", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-mode-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-mode-data-")); + const { client } = boot(dataDir, []); + await client.initialize(); + const { sessionId, store } = await client.newSession(cwd); + expect(store.snapshot.currentModeId).toBe("default"); + await client.setMode(sessionId, "plan"); + await Bun.sleep(0); + expect(store.snapshot.currentModeId).toBe("plan"); + expect(client.setMode(sessionId, "yolo")).rejects.toThrow("unknown mode"); + }); +}); From 986f3d6c126a8379adcd1a1906bbae9715fcd55d Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:31:15 +0800 Subject: [PATCH 13/20] fix: apply slice-2 review findings (permission ordering, escapes, resume integrity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slice-boundary review (finder phase complete, verification partially run before the workflow was stopped) surfaced defects in three areas; all confirmed by inspection and now regression-tested. Permission engine ordering — plan mode now outranks allow rules, so 'mutating tools are blocked' holds even for calls covered by an earlier allow_always; ask rules now outrank the read-only fast path so a project can force confirmation on reads. allow_always escapes wildcard characters before persisting (approving 'git add *' no longer grants 'git add --force anything'), with backslash-escape support added to the rule matcher. Workspace escapes — glob/grep patterns are validated (no absolute paths, no '..' segments) since the glob engine never routes them through path resolution; write_file and edit_file are confined to the workspace because acceptEdits auto-allows edit-kind tools, making path confinement the backstop. Resume integrity — session/load validates the log's recorded cwd (slug collisions could resume a foreign project's session), flushes a live session's pending writes before rebuilding, and no longer replays the log twice; set_mode settles its write before acknowledging; the index is appended on resume and deduped on list, so --continue means most recently used, not created; loadSession refuses to clobber a live store registration. Plus CLI argv validation, surrogate-safe previews, and awaited rejects assertions in tests. --- packages/cli/src/index.tsx | 12 +++-- packages/client/src/client.ts | 5 +++ packages/client/test/client.test.ts | 2 +- packages/client/test/resume.test.ts | 24 +++++++++- packages/kernel/src/agent-loop.ts | 5 ++- packages/kernel/src/kernel.ts | 25 ++++++++--- packages/kernel/src/permissions.ts | 54 ++++++++++++++++++----- packages/kernel/src/session.ts | 24 +++++++--- packages/kernel/src/tools/edit-file.ts | 13 +++--- packages/kernel/src/tools/glob.ts | 4 +- packages/kernel/src/tools/grep.ts | 7 ++- packages/kernel/src/tools/types.ts | 12 +++++ packages/kernel/src/tools/write-file.ts | 13 +++--- packages/kernel/test/kernel.test.ts | 2 +- packages/kernel/test/permissions.test.ts | 20 +++++++++ packages/kernel/test/regressions.test.ts | 8 ++-- packages/kernel/test/tools-slice2.test.ts | 31 +++++++++++-- packages/kernel/test/tools.test.ts | 2 +- packages/protocol/test/connection.test.ts | 4 +- 19 files changed, 210 insertions(+), 57 deletions(-) diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index acf47d4..8d8b0a5 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -29,13 +29,19 @@ for (let i = 0; i < argv.length; i++) { if (arg === "--continue" || arg === "-c") { resume = "latest"; } else if (arg === "--resume" || arg === "-r") { - resume = argv[++i] ?? null; - if (!resume) { + const value = argv[++i]; + if (!value || value.startsWith("-")) { console.error("--resume requires a session id"); process.exit(1); } + resume = value; } else if (arg === "--model" || arg === "-m") { - model = argv[++i] ?? model; + const value = argv[++i]; + if (!value || value.startsWith("-")) { + console.error("--model requires a model id"); + process.exit(1); + } + model = value; } else if (arg === "--help" || arg === "-h") { console.log(USAGE); process.exit(0); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 3d1b490..28f59f5 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -80,6 +80,11 @@ export class MinervaClient { sessionId: string, cwd: string, ): Promise<{ sessionId: string; store: SessionStore }> { + // Overwriting a live registration would detach that session's store from + // the update stream (and the failure path would delete it outright). + if (this.#stores.has(sessionId)) { + throw new Error(`session ${sessionId} is already open in this client`); + } const store = new SessionStore(); this.#stores.set(sessionId, store); try { diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index c4cddc7..bb90fb5 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -126,7 +126,7 @@ describe("MinervaClient against a real kernel", () => { const first = client.prompt(sessionId, "slow one"); await Bun.sleep(20); - expect(client.prompt(sessionId, "second")).rejects.toThrow("already running"); + await expect(client.prompt(sessionId, "second")).rejects.toThrow("already running"); expect(store.snapshot.busy).toBe(true); expect( store.snapshot.items.filter((item) => item.kind === "user").map((item) => item.text), diff --git a/packages/client/test/resume.test.ts b/packages/client/test/resume.test.ts index b43d5c9..6bc8b1f 100644 --- a/packages/client/test/resume.test.ts +++ b/packages/client/test/resume.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { appendFileSync, mkdtempSync } from "node:fs"; +import { appendFileSync, mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createKernel, type MinervaKernel, projectDir } from "@minerva/kernel"; @@ -135,6 +135,26 @@ describe("session resume across kernel restarts", () => { expect(stopReason).toBe("end_turn"); }); + test("a session cannot be loaded from a slug-colliding foreign cwd", async () => { + const base = mkdtempSync(join(tmpdir(), "minerva-slug-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-slug-data-")); + // proj.a and proj-a collapse to the same project slug. + const cwdA = join(base, "proj.a"); + const cwdB = join(base, "proj-a"); + mkdirSync(cwdA); + mkdirSync(cwdB); + + const first = boot(dataDir, []); + await first.client.initialize(); + const { sessionId } = await first.client.newSession(cwdA); + await Bun.sleep(20); // let the async session.created write land + first.client.close(); + + const second = boot(dataDir, []); + await second.client.initialize(); + await expect(second.client.loadSession(sessionId, cwdB)).rejects.toThrow("belongs to"); + }); + test("setMode round-trips into the store", async () => { const cwd = mkdtempSync(join(tmpdir(), "minerva-mode-proj-")); const dataDir = mkdtempSync(join(tmpdir(), "minerva-mode-data-")); @@ -145,6 +165,6 @@ describe("session resume across kernel restarts", () => { await client.setMode(sessionId, "plan"); await Bun.sleep(0); expect(store.snapshot.currentModeId).toBe("plan"); - expect(client.setMode(sessionId, "yolo")).rejects.toThrow("unknown mode"); + await expect(client.setMode(sessionId, "yolo")).rejects.toThrow("unknown mode"); }); }); diff --git a/packages/kernel/src/agent-loop.ts b/packages/kernel/src/agent-loop.ts index 674c13c..d86ba27 100644 --- a/packages/kernel/src/agent-loop.ts +++ b/packages/kernel/src/agent-loop.ts @@ -13,7 +13,7 @@ import type { TurnUsage, } from "@minerva/providers"; import { now } from "./events"; -import { formatRule, permissionValue } from "./permissions"; +import { escapeRuleValue, formatRule, permissionValue } from "./permissions"; import type { Runtime } from "./runtime"; import type { Session } from "./session"; import { persistAllowRule } from "./settings"; @@ -351,7 +351,8 @@ async function checkPermission( return { allowed: false, source: "frontend" }; } if (result.outcome.optionId === "allow_always") { - const rule = formatRule(tool.name, permissionValue(call.input)); + // Escape wildcards: the user approved this exact call, not a pattern. + const rule = formatRule(tool.name, escapeRuleValue(permissionValue(call.input))); session.permissions.addAllowRule(rule); try { await persistAllowRule(context.runtime, session.cwd, rule); diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 4b10c1b..8414824 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -21,7 +21,6 @@ import type { ModelProvider } from "@minerva/providers"; import { runPrompt } from "./agent-loop"; import { now } from "./events"; import { isSessionModeId, SESSION_MODES } from "./permissions"; -import { replayEvents } from "./replay"; import { defaultRuntime, type Runtime } from "./runtime"; import { parseEventLog, projectDir, Session } from "./session"; import { builtinTools, type KernelTool } from "./tools"; @@ -107,12 +106,17 @@ export class MinervaKernel { "session/load requires sessionId and cwd", ); } - if (this.#sessions.get(sessionId)?.promptActive) { + const existing = this.#sessions.get(sessionId); + if (existing?.promptActive) { throw new RpcError( JSON_RPC_ERROR_CODES.INVALID_REQUEST, "cannot load a session while a prompt is running in it", ); } + // The live session's log writes are fire-and-forget; settle them before + // rebuilding from the file or the reload silently drops recent events. + await existing?.flush().catch(() => {}); + let loaded: Awaited>; try { loaded = await Session.load( @@ -129,7 +133,7 @@ export class MinervaKernel { this.#sessions.set(sessionId, loaded.session); // ACP: replay the conversation as session/update notifications before // answering, so the frontend can rebuild its transcript. - for (const update of replayEvents(loaded.events, this.#tools).updates) { + for (const update of loaded.replay.updates) { this.#connection.notify(CLIENT_METHODS.sessionUpdate, { sessionId, update }); } return { modes: modeState(loaded.session) }; @@ -146,6 +150,9 @@ export class MinervaKernel { } session.mode = modeId; session.append({ type: "session.mode_changed", modeId, at: now() }); + // Settle the write before acknowledging: a mode change that vanishes on + // restart (kill right after switching to plan) is a policy surprise. + await session.flush(); this.#connection.notify(CLIENT_METHODS.sessionUpdate, { sessionId: session.id, update: { sessionUpdate: "current_mode_update", currentModeId: modeId }, @@ -177,8 +184,12 @@ export class MinervaKernel { }) .filter((entry) => entry.cwd === cwd); - // Newest first, previews only for the recent window to bound file reads. - const recent = entries.reverse().slice(0, 20); + // The index is append-per-use (create and resume both write), so the + // last entry per session id reflects most-recent use. Return the 20 + // most recently used — a hard cap, matching the picker UIs it feeds. + const bySessionId = new Map(); + for (const entry of entries) bySessionId.set(entry.sessionId, entry); + const recent = [...bySessionId.values()].reverse().slice(0, 20); const sessions = await Promise.all( recent.map(async (entry) => ({ ...entry, @@ -193,7 +204,9 @@ export class MinervaKernel { const raw = await this.#runtime.readTextFile(join(dir, `${sessionId}.jsonl`)); for (const event of parseEventLog(raw)) { if (event.type === "user.message") { - return event.text.length > 80 ? `${event.text.slice(0, 80)}…` : event.text; + // Slice by code point so an emoji at the boundary isn't torn. + const chars = [...event.text]; + return chars.length > 80 ? `${chars.slice(0, 80).join("")}…` : event.text; } } } catch { diff --git a/packages/kernel/src/permissions.ts b/packages/kernel/src/permissions.ts index 12c8415..7ef1ab3 100644 --- a/packages/kernel/src/permissions.ts +++ b/packages/kernel/src/permissions.ts @@ -52,21 +52,27 @@ export class PermissionEngine { const denied = matchRules(this.#rules.deny, tool.name, value); if (denied) return { action: "deny", rule: denied, reason: `denied by rule ${denied}` }; - if (tool.readOnly) return { action: "allow" }; - + // Ask rules outrank the read-only fast path so a project can force + // confirmation even for reads. const asked = matchRules(this.#rules.ask, tool.name, value); if (asked) return { action: "ask" }; + if (tool.readOnly) return { action: "allow" }; + + // Plan mode outranks allow rules: "mutating tools are blocked" must hold + // even for calls a previous allow_always would have covered. + if (mode === "plan") { + return { + action: "deny", + reason: + "Minerva is in plan mode: mutating tools are blocked. Present a plan instead of making changes.", + }; + } + const allowed = matchRules(this.#rules.allow, tool.name, value); if (allowed) return { action: "allow", rule: allowed }; switch (mode) { - case "plan": - return { - action: "deny", - reason: - "Minerva is in plan mode: mutating tools are blocked. Present a plan instead of making changes.", - }; case "auto": return { action: "allow" }; case "acceptEdits": @@ -94,6 +100,15 @@ export function formatRule(toolName: string, value: string): string { return `${toolName}(${value})`; } +/** + * Escape wildcard characters in a literal value before embedding it in a + * rule. Without this, approving `git add *` once would persist a rule that + * matches `git add --force anything` — a silent over-grant. + */ +export function escapeRuleValue(value: string): string { + return value.replace(/[\\*?]/g, (char) => `\\${char}`); +} + function matchRules(rules: string[], toolName: string, value: string): string | undefined { return rules.find((rule) => ruleMatches(rule, toolName, value)); } @@ -101,7 +116,8 @@ function matchRules(rules: string[], toolName: string, value: string): string | /** * Rule forms: `bash` (bare tool name, matches every call) or * `bash(git *)` — a wildcard pattern over the tool's permission value, - * where `*` matches any run of characters and `?` a single one. + * where `*` matches any run of characters, `?` a single one, and a + * backslash escapes the next character (`\*` is a literal asterisk). */ export function ruleMatches(rule: string, toolName: string, value: string): boolean { const open = rule.indexOf("("); @@ -113,6 +129,22 @@ export function ruleMatches(rule: string, toolName: string, value: string): bool } function wildcardToRegex(pattern: string): RegExp { - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`^${escaped.replace(/\*/g, "[\\s\\S]*").replace(/\?/g, "[\\s\\S]")}$`); + let source = "^"; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i] as string; + if (char === "\\" && i + 1 < pattern.length) { + source += escapeRegexChar(pattern[++i] as string); + } else if (char === "*") { + source += "[\\s\\S]*"; + } else if (char === "?") { + source += "[\\s\\S]"; + } else { + source += escapeRegexChar(char); + } + } + return new RegExp(`${source}$`); +} + +function escapeRegexChar(char: string): string { + return /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char; } diff --git a/packages/kernel/src/session.ts b/packages/kernel/src/session.ts index 12ea0fb..73537e3 100644 --- a/packages/kernel/src/session.ts +++ b/packages/kernel/src/session.ts @@ -4,7 +4,7 @@ import type { PlanEntry } from "@minerva/protocol"; import type { ProviderMessage } from "@minerva/providers"; import { now, type SessionEvent } from "./events"; import { DEFAULT_MODE, isSessionModeId, PermissionEngine, type SessionModeId } from "./permissions"; -import { replayEvents } from "./replay"; +import { type ReplayResult, replayEvents } from "./replay"; import type { Runtime } from "./runtime"; import { loadSettings } from "./settings"; import type { KernelTool } from "./tools"; @@ -75,14 +75,16 @@ export class Session { /** * Resume a persisted session: replay the event log to rebuild the model - * context, and hand the caller the events so it can re-render the UI. + * context, handing back the replay so the caller can re-render the UI + * without a second pass over the log. */ static async load( sessionId: string, options: SessionOptions, tools: KernelTool[], - ): Promise<{ session: Session; events: SessionEvent[] }> { - const logPath = join(projectDir(options.dataDir, options.cwd), `${sessionId}.jsonl`); + ): Promise<{ session: Session; replay: ReplayResult }> { + const dir = projectDir(options.dataDir, options.cwd); + const logPath = join(dir, `${sessionId}.jsonl`); let raw: string; try { raw = await options.runtime.readTextFile(logPath); @@ -90,6 +92,12 @@ export class Session { throw new Error(`no persisted session ${sessionId} for ${options.cwd}`); } const events = parseEventLog(raw); + // Project slugs collapse punctuation (/a/b.c and /a/b-c share a dir), so + // the log's recorded cwd — not the file location — is authoritative. + const created = events.find((event) => event.type === "session.created"); + if (created && created.cwd !== options.cwd) { + throw new Error(`session ${sessionId} belongs to ${created.cwd}, not ${options.cwd}`); + } const replay = replayEvents(events, tools); const settings = await loadSettings(options.runtime, options.dataDir, options.cwd); @@ -102,8 +110,14 @@ export class Session { const session = new Session(sessionId, options, new PermissionEngine(settings.rules), mode); session.messages.push(...replay.messages); session.todos = replay.todos; + // Re-append to the index so "latest session" means most recently used, + // not most recently created; the list handler dedupes by id. + await options.runtime.appendTextFile( + join(dir, "index.jsonl"), + `${JSON.stringify({ sessionId, cwd: options.cwd, createdAt: now() })}\n`, + ); session.append({ type: "session.resumed", provider: options.providerId, at: now() }); - return { session, events }; + return { session, replay }; } /** diff --git a/packages/kernel/src/tools/edit-file.ts b/packages/kernel/src/tools/edit-file.ts index f1e90ee..898dc64 100644 --- a/packages/kernel/src/tools/edit-file.ts +++ b/packages/kernel/src/tools/edit-file.ts @@ -1,13 +1,12 @@ -import { resolve } from "node:path"; import type { KernelTool } from "./types"; -import { asRecord, requireString } from "./types"; +import { asRecord, requireString, resolveWithinWorkspace } from "./types"; export const editFileTool: KernelTool = { name: "edit_file", description: - "Edit a text file by exact string replacement. old_string must match exactly one " + - "location in the file, including whitespace; include enough surrounding context to " + - "make it unique.", + "Edit a text file inside the working directory by exact string replacement. " + + "old_string must match exactly one location in the file, including whitespace; " + + "include enough surrounding context to make it unique.", inputSchema: { type: "object", properties: { @@ -24,7 +23,9 @@ export const editFileTool: KernelTool = { }, async execute(input, context) { const record = asRecord(input); - const path = resolve(context.cwd, requireString(record, "path")); + // acceptEdits mode auto-allows edit-kind tools, so confinement to the + // workspace is the backstop against edits landing anywhere on disk. + const path = resolveWithinWorkspace(context.cwd, requireString(record, "path")); const oldString = requireString(record, "old_string"); // Unlike requireString, the empty string is a legal new_string (deletion) // — but a missing/non-string value must fail, not silently delete. diff --git a/packages/kernel/src/tools/glob.ts b/packages/kernel/src/tools/glob.ts index c450d96..24a9189 100644 --- a/packages/kernel/src/tools/glob.ts +++ b/packages/kernel/src/tools/glob.ts @@ -1,6 +1,6 @@ import { glob } from "tinyglobby"; import type { KernelTool } from "./types"; -import { asRecord, requireString, resolveWithinWorkspace } from "./types"; +import { asRecord, ensureConfinedPattern, requireString, resolveWithinWorkspace } from "./types"; const MAX_RESULTS = 200; const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"]; @@ -26,8 +26,8 @@ export const globTool: KernelTool = { }, async execute(input, context) { const record = asRecord(input); - const pattern = requireString(record, "pattern"); // readOnly ⇒ policy-allowed with no prompt ⇒ must stay inside the workspace. + const pattern = ensureConfinedPattern(requireString(record, "pattern")); const base = resolveWithinWorkspace( context.cwd, typeof record.path === "string" ? record.path : ".", diff --git a/packages/kernel/src/tools/grep.ts b/packages/kernel/src/tools/grep.ts index 3422dc7..101f227 100644 --- a/packages/kernel/src/tools/grep.ts +++ b/packages/kernel/src/tools/grep.ts @@ -1,6 +1,6 @@ import { glob } from "tinyglobby"; import type { KernelTool } from "./types"; -import { asRecord, requireString, resolveWithinWorkspace } from "./types"; +import { asRecord, ensureConfinedPattern, requireString, resolveWithinWorkspace } from "./types"; const MAX_FILES = 500; const MAX_MATCHES = 100; @@ -44,7 +44,10 @@ export const grepTool: KernelTool = { context.cwd, typeof record.path === "string" ? record.path : ".", ); - const files = await glob(typeof record.include === "string" ? record.include : "**/*", { + const include = ensureConfinedPattern( + typeof record.include === "string" ? record.include : "**/*", + ); + const files = await glob(include, { cwd: base, ignore: DEFAULT_IGNORE, onlyFiles: true, diff --git a/packages/kernel/src/tools/types.ts b/packages/kernel/src/tools/types.ts index 64d2e18..ec7d867 100644 --- a/packages/kernel/src/tools/types.ts +++ b/packages/kernel/src/tools/types.ts @@ -60,3 +60,15 @@ export function resolveWithinWorkspace(cwd: string, inputPath: string): string { } return resolved; } + +/** + * Glob patterns are matched by the glob engine, not resolved as paths, so + * resolveWithinWorkspace can't see an escape like `../**` or `/etc/*` — + * reject those shapes outright. + */ +export function ensureConfinedPattern(pattern: string): string { + if (isAbsolute(pattern) || pattern.split(/[\\/]/).includes("..")) { + throw new Error(`glob pattern must stay inside the workspace: ${pattern}`); + } + return pattern; +} diff --git a/packages/kernel/src/tools/write-file.ts b/packages/kernel/src/tools/write-file.ts index 200c370..a5d9e4e 100644 --- a/packages/kernel/src/tools/write-file.ts +++ b/packages/kernel/src/tools/write-file.ts @@ -1,12 +1,13 @@ -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; import type { KernelTool } from "./types"; -import { asRecord, requireString } from "./types"; +import { asRecord, requireString, resolveWithinWorkspace } from "./types"; export const writeFileTool: KernelTool = { name: "write_file", description: - "Create or overwrite a text file with the given content. Parent directories " + - "are created as needed. Prefer edit_file for changing existing files.", + "Create or overwrite a text file inside the working directory. Parent " + + "directories are created as needed. Prefer edit_file for changing existing " + + "files; use bash for files outside the workspace.", inputSchema: { type: "object", properties: { @@ -22,7 +23,9 @@ export const writeFileTool: KernelTool = { }, async execute(input, context) { const record = asRecord(input); - const path = resolve(context.cwd, requireString(record, "path")); + // acceptEdits mode auto-allows edit-kind tools, so confinement to the + // workspace is the backstop against writes landing anywhere on disk. + const path = resolveWithinWorkspace(context.cwd, requireString(record, "path")); const content = record.content; if (typeof content !== "string") { throw new Error("missing required string parameter: content"); diff --git a/packages/kernel/test/kernel.test.ts b/packages/kernel/test/kernel.test.ts index 1c5d1b9..1d9eec3 100644 --- a/packages/kernel/test/kernel.test.ts +++ b/packages/kernel/test/kernel.test.ts @@ -357,7 +357,7 @@ describe("kernel over in-proc transport", () => { const first = prompt(harness, "slow one"); await Bun.sleep(20); - expect(prompt(harness, "second")).rejects.toThrow("already running"); + await expect(prompt(harness, "second")).rejects.toThrow("already running"); await first; }); }); diff --git a/packages/kernel/test/permissions.test.ts b/packages/kernel/test/permissions.test.ts index c78757e..0c3f95e 100644 --- a/packages/kernel/test/permissions.test.ts +++ b/packages/kernel/test/permissions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { bashTool, editFileTool, + escapeRuleValue, formatRule, PermissionEngine, permissionValue, @@ -76,4 +77,23 @@ describe("engine verdicts", () => { e.addAllowRule("bash(bun test)"); expect(e.evaluate(bashTool, { command: "bun test" }, "default").action).toBe("allow"); }); + + test("plan mode outranks allow rules for mutating tools", () => { + const e = engine({ allow: ["bash(git *)"] }); + expect(e.evaluate(bashTool, { command: "git push" }, "plan").action).toBe("deny"); + expect(e.evaluate(bashTool, { command: "git push" }, "default").action).toBe("allow"); + }); + + test("ask rules apply to read-only tools too", () => { + const e = engine({ ask: ["read_file(*.env)"] }); + expect(e.evaluate(readFileTool, { path: "prod.env" }, "default").action).toBe("ask"); + expect(e.evaluate(readFileTool, { path: "src/a.ts" }, "default").action).toBe("allow"); + }); + + test("escaped wildcards in persisted rules match literally", () => { + const rule = formatRule("bash", escapeRuleValue("git add *")); + expect(rule).toBe("bash(git add \\*)"); + expect(ruleMatches(rule, "bash", "git add *")).toBe(true); + expect(ruleMatches(rule, "bash", "git add --force everything")).toBe(false); + }); }); diff --git a/packages/kernel/test/regressions.test.ts b/packages/kernel/test/regressions.test.ts index 438b1cb..c479bcc 100644 --- a/packages/kernel/test/regressions.test.ts +++ b/packages/kernel/test/regressions.test.ts @@ -34,7 +34,7 @@ describe("edit_file input handling", () => { const cwd = tempProject(); const file = join(cwd, "a.txt"); writeFileSync(file, "keep me"); - expect( + await expect( editFileTool.execute({ path: "a.txt", old_string: "keep me", new_string: 42 }, ctx(cwd)), ).rejects.toThrow("new_string"); expect(readFileSync(file, "utf8")).toBe("keep me"); @@ -56,10 +56,10 @@ describe("edit_file input handling", () => { describe("read_file workspace confinement", () => { test("paths escaping the workspace are rejected", async () => { const cwd = tempProject(); - expect(readFileTool.execute({ path: "/etc/passwd" }, ctx(cwd))).rejects.toThrow( + await expect(readFileTool.execute({ path: "/etc/passwd" }, ctx(cwd))).rejects.toThrow( "outside the workspace", ); - expect(readFileTool.execute({ path: "../../etc/passwd" }, ctx(cwd))).rejects.toThrow( + await expect(readFileTool.execute({ path: "../../etc/passwd" }, ctx(cwd))).rejects.toThrow( "outside the workspace", ); }); @@ -134,7 +134,7 @@ describe("session event log resilience", () => { session.append({ type: "user.message", text: "lost", at: "t1" }); session.append({ type: "user.message", text: "recorded", at: "t2" }); - expect(session.flush()).rejects.toThrow("disk full"); + await expect(session.flush()).rejects.toThrow("disk full"); // The chain survived: the later event landed and future flushes succeed. await session.flush(); const lines = readFileSync(session.logPath, "utf8").trim().split("\n"); diff --git a/packages/kernel/test/tools-slice2.test.ts b/packages/kernel/test/tools-slice2.test.ts index 48bac6b..6233c1f 100644 --- a/packages/kernel/test/tools-slice2.test.ts +++ b/packages/kernel/test/tools-slice2.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PlanEntry } from "@minerva/protocol"; -import { defaultRuntime, globTool, grepTool, todoTool, writeFileTool } from "../src"; +import { defaultRuntime, editFileTool, globTool, grepTool, todoTool, writeFileTool } from "../src"; function tempProject(): string { return mkdtempSync(join(tmpdir(), "minerva-t2-")); @@ -28,10 +28,20 @@ describe("write_file", () => { test("non-string content is rejected", async () => { const cwd = tempProject(); - expect( + await expect( writeFileTool.execute({ path: "a.txt", content: { nested: true } }, ctx(cwd)), ).rejects.toThrow("content"); }); + + test("writes and edits are confined to the workspace", async () => { + const cwd = tempProject(); + await expect( + writeFileTool.execute({ path: "/tmp/outside.txt", content: "x" }, ctx(cwd)), + ).rejects.toThrow("outside the workspace"); + await expect( + editFileTool.execute({ path: "../outside.txt", old_string: "a", new_string: "b" }, ctx(cwd)), + ).rejects.toThrow("outside the workspace"); + }); }); describe("glob", () => { @@ -48,10 +58,23 @@ describe("glob", () => { }); test("search base is confined to the workspace", async () => { - expect(globTool.execute({ pattern: "*", path: "/" }, ctx(tempProject()))).rejects.toThrow( + await expect(globTool.execute({ pattern: "*", path: "/" }, ctx(tempProject()))).rejects.toThrow( "outside the workspace", ); }); + + test("escaping patterns are rejected for glob and grep", async () => { + const cwd = tempProject(); + await expect(globTool.execute({ pattern: "../**" }, ctx(cwd))).rejects.toThrow( + "inside the workspace", + ); + await expect(globTool.execute({ pattern: "/etc/*" }, ctx(cwd))).rejects.toThrow( + "inside the workspace", + ); + await expect(grepTool.execute({ pattern: "x", include: "../../*" }, ctx(cwd))).rejects.toThrow( + "inside the workspace", + ); + }); }); describe("grep", () => { @@ -97,7 +120,7 @@ describe("todo_write", () => { }); test("bad status is rejected", async () => { - expect( + await expect( todoTool.execute( { todos: [{ content: "x", status: "done" }] }, ctx(tempProject(), () => {}), diff --git a/packages/kernel/test/tools.test.ts b/packages/kernel/test/tools.test.ts index 1324709..d4c5221 100644 --- a/packages/kernel/test/tools.test.ts +++ b/packages/kernel/test/tools.test.ts @@ -21,7 +21,7 @@ describe("read_file", () => { test("missing file surfaces as a thrown error for the loop to catch", async () => { const cwd = tempProject(); - expect(readFileTool.execute({ path: "nope.txt" }, ctx(cwd))).rejects.toThrow(); + await expect(readFileTool.execute({ path: "nope.txt" }, ctx(cwd))).rejects.toThrow(); }); }); diff --git a/packages/protocol/test/connection.test.ts b/packages/protocol/test/connection.test.ts index f381c52..7ebb57b 100644 --- a/packages/protocol/test/connection.test.ts +++ b/packages/protocol/test/connection.test.ts @@ -47,7 +47,7 @@ describe("Connection over in-proc transport", () => { throw new Error("kaputt"); }); - expect(client.request("boom")).rejects.toThrow("kaputt"); + await expect(client.request("boom")).rejects.toThrow("kaputt"); }); test("custom RpcError code survives the wire", async () => { @@ -83,7 +83,7 @@ describe("Connection over in-proc transport", () => { const pending = client.request("hang"); client.close(); - expect(pending).rejects.toThrow("connection closed"); + await expect(pending).rejects.toThrow("connection closed"); }); test("concurrent requests correlate by id", async () => { From 8a95a162207bcf8d0bd45ab0fea6e4f1258f0698 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:37:02 +0800 Subject: [PATCH 14/20] feat(protocol): stream transport with ACP stdio framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newline-delimited JSON-RPC over any Readable/Writable pair, per the ACP transport spec ('messages are delimited by newlines and MUST NOT contain embedded newlines' — JSON.stringify guarantees the latter by escaping). This is the second of the three planned transports: in-proc (CLI) landed in slice 1, stdio serves 'minerva acp' and the future Tauri sidecar. Partial chunks buffer until their newline arrives, batched messages in one chunk all dispatch, and a malformed line is skipped rather than killing the connection — there is no id to answer it with. --- packages/protocol/src/index.ts | 1 + packages/protocol/src/stdio.ts | 70 +++++++++++++++++++++++++++ packages/protocol/test/stdio.test.ts | 72 ++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 packages/protocol/src/stdio.ts create mode 100644 packages/protocol/test/stdio.test.ts diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 411ff5f..b0e1b0d 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,3 +1,4 @@ export * from "./jsonrpc"; +export * from "./stdio"; export * from "./transport"; export * from "./types"; diff --git a/packages/protocol/src/stdio.ts b/packages/protocol/src/stdio.ts new file mode 100644 index 0000000..baad57f --- /dev/null +++ b/packages/protocol/src/stdio.ts @@ -0,0 +1,70 @@ +import type { JsonRpcMessage, Transport } from "./jsonrpc"; + +/** + * Stream-based transport with ACP stdio framing: one JSON-RPC message per + * line, delimited by `\n`, no embedded newlines (JSON.stringify guarantees + * that — newlines inside strings are escaped). Used for `minerva acp` + * (kernel on stdin/stdout) and, later, the Tauri sidecar. + */ +export function createStreamTransport( + input: NodeJS.ReadableStream, + output: NodeJS.WritableStream, +): Transport { + const messageHandlers: Array<(message: JsonRpcMessage) => void> = []; + const closeHandlers: Array<() => void> = []; + let buffer = ""; + let closed = false; + + const emitClose = () => { + if (closed) return; + closed = true; + for (const handler of closeHandlers) handler(); + }; + + const onData = (chunk: Buffer | string) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + // Everything before the last newline is complete messages; the remainder + // stays buffered until the peer sends the rest of the line. + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) { + let message: JsonRpcMessage | null = null; + try { + message = JSON.parse(line) as JsonRpcMessage; + } catch { + // A malformed line has no id to answer; skip it rather than kill + // the connection. + } + if (message) { + for (const handler of messageHandlers) handler(message); + } + } + newline = buffer.indexOf("\n"); + } + }; + + input.on("data", onData); + input.on("end", emitClose); + input.on("close", emitClose); + input.on("error", emitClose); + output.on("error", emitClose); + + return { + send(message) { + if (closed) return; + output.write(`${JSON.stringify(message)}\n`); + }, + onMessage(handler) { + messageHandlers.push(handler); + }, + onClose(handler) { + closeHandlers.push(handler); + }, + close() { + input.off("data", onData); + emitClose(); + }, + }; +} diff --git a/packages/protocol/test/stdio.test.ts b/packages/protocol/test/stdio.test.ts new file mode 100644 index 0000000..61fa593 --- /dev/null +++ b/packages/protocol/test/stdio.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import { Connection, createStreamTransport, type JsonRpcMessage } from "../src"; + +function streamPair() { + const aToB = new PassThrough(); + const bToA = new PassThrough(); + const a = createStreamTransport(bToA, aToB); + const b = createStreamTransport(aToB, bToA); + return { a, b, aToB, bToA }; +} + +describe("stream transport (ACP stdio framing)", () => { + test("request/response roundtrip over streams", async () => { + const { a, b } = streamPair(); + const server = new Connection(b); + server.handleRequest("echo", (params) => ({ echoed: params })); + const client = new Connection(a); + + const result = await client.request("echo", { hello: "stdio" }); + expect(result).toEqual({ echoed: { hello: "stdio" } }); + }); + + test("messages split across chunks and batched in one chunk both parse", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const transport = createStreamTransport(input, output); + const received: JsonRpcMessage[] = []; + transport.onMessage((message) => received.push(message)); + + // Half a message, then the rest plus two complete messages in one chunk. + const m = (n: number) => JSON.stringify({ jsonrpc: "2.0", method: "m", params: { n } }); + const full = `${m(1)}\n${m(2)}\n${m(3)}\n`; + input.write(full.slice(0, 10)); + await Bun.sleep(1); + input.write(full.slice(10)); + await Bun.sleep(1); + + expect(received).toHaveLength(3); + expect(received.map((msg) => (msg as { params: { n: number } }).params.n)).toEqual([1, 2, 3]); + }); + + test("newlines inside string values survive framing", async () => { + const { a, b } = streamPair(); + const server = new Connection(b); + server.handleRequest("echo", (params) => params); + const client = new Connection(a); + + const result = await client.request("echo", { text: "line one\nline two\n" }); + expect(result).toEqual({ text: "line one\nline two\n" }); + }); + + test("malformed lines are skipped without killing the connection", async () => { + const input = new PassThrough(); + const transport = createStreamTransport(input, new PassThrough()); + const received: JsonRpcMessage[] = []; + transport.onMessage((message) => received.push(message)); + + input.write('this is not json\n{"jsonrpc":"2.0","method":"ok"}\n'); + await Bun.sleep(1); + expect(received).toEqual([{ jsonrpc: "2.0", method: "ok" }]); + }); + + test("input end closes the transport and rejects pending requests", async () => { + const input = new PassThrough(); + const transport = createStreamTransport(input, new PassThrough()); + const client = new Connection(transport); + const pending = client.request("never"); + input.end(); + await expect(pending).rejects.toThrow("connection closed"); + }); +}); From e6aafa9503009c28aa1ccfb3542280e3424e66e8 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:38:51 +0800 Subject: [PATCH 15/20] =?UTF-8?q?feat(cli):=20minerva=20acp=20=E2=80=94=20?= =?UTF-8?q?host=20the=20kernel=20on=20stdio=20for=20editors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third seam of design decision #2 becomes real: the same kernel that the Ink UI drives in-proc is now spawnable by an editor over stdio with ACP framing. stdout belongs to the protocol, so the host path renders no UI and keeps diagnostics on stderr; MINERVA_DATA_DIR overrides the session root so hosts and tests can isolate state. The conformance harness spawns a real child process and drives it through MinervaClient over the stream transport: initialize, session/new, a full prompt with a tool call and permission round-trip — every byte crossing a genuine process boundary. A second test exercises the actual "minerva acp" entrypoint. Live Zed validation still needs a human with Zed installed; the harness covers the wire contract. --- packages/cli/src/acp.ts | 16 ++++ packages/cli/src/index.tsx | 32 ++++++- packages/cli/test/acp.test.ts | 104 +++++++++++++++++++++ packages/cli/test/fixtures/acp-scripted.ts | 19 ++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/acp.ts create mode 100644 packages/cli/test/acp.test.ts create mode 100644 packages/cli/test/fixtures/acp-scripted.ts diff --git a/packages/cli/src/acp.ts b/packages/cli/src/acp.ts new file mode 100644 index 0000000..bfb6831 --- /dev/null +++ b/packages/cli/src/acp.ts @@ -0,0 +1,16 @@ +import { createKernel, type KernelOptions } from "@minerva/kernel"; +import { createStreamTransport } from "@minerva/protocol"; + +/** + * Host the kernel on stdio with ACP framing — this is what an editor (Zed, + * Neovim) spawns as its agent. stdout belongs to the protocol, so nothing + * here may ever write to it; diagnostics go to stderr. + */ +export async function runAcpHost(options: KernelOptions): Promise { + const transport = createStreamTransport(process.stdin, process.stdout); + const kernel = createKernel(transport, options); + await new Promise((resolve) => { + transport.onClose(resolve); + }); + kernel.close(); +} diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index 8d8b0a5..f80dc30 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -4,6 +4,7 @@ import { createKernel } from "@minerva/kernel"; import { createInProcTransportPair } from "@minerva/protocol"; import { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL } from "@minerva/providers"; import { render } from "ink"; +import { runAcpHost } from "./acp"; import { App } from "./app"; import { PermissionBridge } from "./permission-bridge"; @@ -15,18 +16,30 @@ if (!process.env.ANTHROPIC_API_KEY) { const USAGE = `minerva — model-agnostic code agent -Usage: minerva [options] +Usage: minerva [command] [options] + +Commands: + (default) Interactive terminal UI + acp Host the kernel on stdio (ACP framing) for editors + +Options: -c, --continue Resume the most recent session for this directory -r, --resume Resume a specific session -m, --model Model to use (default: ${DEFAULT_ANTHROPIC_MODEL}, env: MINERVA_MODEL) - -h, --help Show this help`; + -h, --help Show this help + +Environment: + MINERVA_DATA_DIR Session/config root (default: ~/.minerva)`; let model = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; let resume: string | null = null; +let command: "tui" | "acp" = "tui"; const argv = process.argv.slice(2); for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--continue" || arg === "-c") { + if (arg === "acp" && command === "tui") { + command = "acp"; + } else if (arg === "--continue" || arg === "-c") { resume = "latest"; } else if (arg === "--resume" || arg === "-r") { const value = argv[++i]; @@ -51,12 +64,23 @@ for (let i = 0; i < argv.length; i++) { } } +const kernelOptions = { + provider: createAnthropicProvider({ model }), + dataDir: process.env.MINERVA_DATA_DIR, +}; + +if (command === "acp") { + // stdout carries the protocol; the kernel must never render UI here. + await runAcpHost(kernelOptions); + process.exit(0); +} + const cwd = process.cwd(); // The CLI embeds the kernel, but only across the protocol's in-proc // transport — the same messages a Tauri sidecar or remote kernel would see. const [clientTransport, kernelTransport] = createInProcTransportPair(); -createKernel(kernelTransport, { provider: createAnthropicProvider({ model }) }); +createKernel(kernelTransport, kernelOptions); const bridge = new PermissionBridge(); const client = new MinervaClient(clientTransport, { diff --git a/packages/cli/test/acp.test.ts b/packages/cli/test/acp.test.ts new file mode 100644 index 0000000..dec24cc --- /dev/null +++ b/packages/cli/test/acp.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MinervaClient } from "@minerva/client"; +import { + AGENT_METHODS, + Connection, + createStreamTransport, + PROTOCOL_VERSION, +} from "@minerva/protocol"; +import type { TurnEvent } from "@minerva/providers"; + +const CLI_DIR = join(import.meta.dir, ".."); +const children: ChildProcess[] = []; + +afterEach(() => { + for (const child of children.splice(0)) child.kill("SIGKILL"); +}); + +function spawnHost(entry: string, args: string[], env: Record) { + const child = spawn("bun", ["run", entry, ...args], { + cwd: CLI_DIR, + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "inherit"], + }); + children.push(child); + if (!child.stdout || !child.stdin) throw new Error("child streams missing"); + return createStreamTransport(child.stdout, child.stdin); +} + +describe("minerva acp over a real process boundary", () => { + test("scripted host: full prompt flow with permission round-trip", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-acp-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-acp-data-")); + const turns: TurnEvent[][] = [ + [ + { type: "text-delta", text: "Checking the workspace." }, + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "echo across-stdio" }, + }, + { type: "finish", finishReason: "tool-calls", usage: {} }, + ], + [ + { type: "text-delta", text: "It works." }, + { type: "finish", finishReason: "stop", usage: {} }, + ], + ]; + const transport = spawnHost("test/fixtures/acp-scripted.ts", [], { + MINERVA_DATA_DIR: dataDir, + MINERVA_TEST_TURNS: JSON.stringify(turns), + }); + + const approved: string[] = []; + const client = new MinervaClient(transport, { + onPermissionRequest: async (request) => { + approved.push(request.toolCall.title); + return { outcome: { outcome: "selected", optionId: "allow" } }; + }, + }); + + const init = await client.initialize(); + expect(init.protocolVersion).toBe(PROTOCOL_VERSION); + expect(init.agentCapabilities.loadSession).toBe(true); + + const { sessionId, store } = await client.newSession(cwd); + const stopReason = await client.prompt(sessionId, "try the echo"); + + expect(stopReason).toBe("end_turn"); + expect(approved).toEqual(["echo across-stdio"]); + const tool = store.snapshot.items.find((item) => item.kind === "tool"); + expect(tool).toMatchObject({ status: "completed", output: "across-stdio\n" }); + + client.close(); + }, 20_000); + + test("real entrypoint: `minerva acp` answers initialize and session/new", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-acp2-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-acp2-data-")); + const transport = spawnHost("src/index.tsx", ["acp"], { + MINERVA_DATA_DIR: dataDir, + ANTHROPIC_API_KEY: "sk-ant-test-not-used", + }); + const connection = new Connection(transport); + + const init = await connection.request<{ protocolVersion: number }>(AGENT_METHODS.initialize, { + protocolVersion: PROTOCOL_VERSION, + }); + expect(init.protocolVersion).toBe(PROTOCOL_VERSION); + + const session = await connection.request<{ + sessionId: string; + modes: { currentModeId: string }; + }>(AGENT_METHODS.sessionNew, { cwd }); + expect(session.sessionId).toStartWith("ses_"); + expect(session.modes.currentModeId).toBe("default"); + + connection.close(); + }, 20_000); +}); diff --git a/packages/cli/test/fixtures/acp-scripted.ts b/packages/cli/test/fixtures/acp-scripted.ts new file mode 100644 index 0000000..e84e0cc --- /dev/null +++ b/packages/cli/test/fixtures/acp-scripted.ts @@ -0,0 +1,19 @@ +/** + * Test fixture: the `minerva acp` host wired to a scripted provider, so the + * conformance harness can drive full prompt flows across a real process + * boundary without a model API. Turns arrive as JSON via MINERVA_TEST_TURNS. + */ +import { createKernel } from "@minerva/kernel"; +import { createStreamTransport } from "@minerva/protocol"; +import { createScriptedProvider, type TurnEvent } from "@minerva/providers"; + +const turns = JSON.parse(process.env.MINERVA_TEST_TURNS ?? "[]") as TurnEvent[][]; +const transport = createStreamTransport(process.stdin, process.stdout); +createKernel(transport, { + provider: createScriptedProvider(turns), + dataDir: process.env.MINERVA_DATA_DIR, +}); +await new Promise((resolve) => { + transport.onClose(resolve); +}); +process.exit(0); From 9b03642b5af49fbc3660a980a8217104cc980ad7 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:43:09 +0800 Subject: [PATCH 16/20] feat(providers): OpenAI provider + provider/model references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second provider, completing the v0.1 exit criterion of two switchable providers. Model selection is now a reference — "openai/gpt-5.2", "anthropic/claude-opus-4-8" — with bare model ids defaulting to Anthropic so existing invocations keep working. The OpenAI adapter goes through the same createAiSdkProvider wrapper, so the kernel-owned ModelProvider boundary is untouched: adding the provider changed zero kernel code. The CLI resolves the ref before starting and requires the matching key env var (ANTHROPIC_API_KEY / OPENAI_API_KEY) instead of hardcoding Anthropic. @ai-sdk/openai is pinned to the v3 line — its v4 targets a newer language-model spec than ai@6 accepts. --- bun.lock | 3 ++ packages/cli/src/index.tsx | 34 ++++++++++---- packages/providers/package.json | 1 + packages/providers/src/index.ts | 1 + packages/providers/src/registry.ts | 59 ++++++++++++++++++++++++ packages/providers/test/registry.test.ts | 38 +++++++++++++++ 6 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 packages/providers/src/registry.ts create mode 100644 packages/providers/test/registry.test.ts diff --git a/bun.lock b/bun.lock index a38773b..d5f21cf 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "version": "0.1.0", "dependencies": { "@ai-sdk/anthropic": "^3", + "@ai-sdk/openai": "^3", "ai": "^6", "zod": "^4", }, @@ -68,6 +69,8 @@ "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.146", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QK922LzOfGeHdZ9QGvghDizQx/tPOolTQSHvMlnUPeaTW5qpiIqpfbLwYiyxEt5YOGLLM/0t232Dut+95udo/Q=="], + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index f80dc30..09c52be 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -2,18 +2,17 @@ import { MinervaClient } from "@minerva/client"; import { createKernel } from "@minerva/kernel"; import { createInProcTransportPair } from "@minerva/protocol"; -import { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL } from "@minerva/providers"; +import { + apiKeyEnvVar, + createProviderFromRef, + DEFAULT_ANTHROPIC_MODEL, + parseModelRef, +} from "@minerva/providers"; import { render } from "ink"; import { runAcpHost } from "./acp"; import { App } from "./app"; import { PermissionBridge } from "./permission-bridge"; -if (!process.env.ANTHROPIC_API_KEY) { - console.error("ANTHROPIC_API_KEY is not set. Export it and try again:"); - console.error(' export ANTHROPIC_API_KEY="sk-ant-..."'); - process.exit(1); -} - const USAGE = `minerva — model-agnostic code agent Usage: minerva [command] [options] @@ -25,10 +24,13 @@ Commands: Options: -c, --continue Resume the most recent session for this directory -r, --resume Resume a specific session - -m, --model Model to use (default: ${DEFAULT_ANTHROPIC_MODEL}, env: MINERVA_MODEL) + -m, --model Model as [provider/]model, e.g. openai/gpt-5.2 or + claude-opus-4-8 (default: ${DEFAULT_ANTHROPIC_MODEL}, env: MINERVA_MODEL) -h, --help Show this help Environment: + ANTHROPIC_API_KEY Key for anthropic/* models + OPENAI_API_KEY Key for openai/* models MINERVA_DATA_DIR Session/config root (default: ~/.minerva)`; let model = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; @@ -64,8 +66,22 @@ for (let i = 0; i < argv.length; i++) { } } +let providerName: ReturnType["provider"]; +try { + providerName = parseModelRef(model).provider; +} catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exit(1); +} +const keyVar = apiKeyEnvVar(providerName); +if (!process.env[keyVar]) { + console.error(`${keyVar} is not set (required for ${model}). Export it and try again:`); + console.error(` export ${keyVar}="..."`); + process.exit(1); +} + const kernelOptions = { - provider: createAnthropicProvider({ model }), + provider: createProviderFromRef(model), dataDir: process.env.MINERVA_DATA_DIR, }; diff --git a/packages/providers/package.json b/packages/providers/package.json index fb22213..83025ae 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@ai-sdk/anthropic": "^3", + "@ai-sdk/openai": "^3", "ai": "^6", "zod": "^4" } diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index ce0f812..be4f4bd 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -1,3 +1,4 @@ export * from "./ai-sdk"; +export * from "./registry"; export * from "./scripted"; export * from "./types"; diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts new file mode 100644 index 0000000..47ef742 --- /dev/null +++ b/packages/providers/src/registry.ts @@ -0,0 +1,59 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { createAiSdkProvider, createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL } from "./ai-sdk"; +import type { ModelProvider } from "./types"; + +/** + * Model references select both provider and model: "openai/gpt-5.2", + * "anthropic/claude-opus-4-8". A bare model id defaults to Anthropic, + * keeping slice-1 invocations (--model claude-opus-4-8) working. + */ + +export const DEFAULT_OPENAI_MODEL = "gpt-5.2"; + +export type ProviderName = "anthropic" | "openai"; + +export interface ModelRef { + provider: ProviderName; + model: string; +} + +export function parseModelRef(ref: string): ModelRef { + const slash = ref.indexOf("/"); + if (slash === -1) return { provider: "anthropic", model: ref }; + const provider = ref.slice(0, slash); + const model = ref.slice(slash + 1); + if (provider !== "anthropic" && provider !== "openai") { + throw new Error(`unknown provider "${provider}" — supported: anthropic, openai`); + } + if (!model) { + throw new Error(`model reference "${ref}" is missing a model id`); + } + return { provider, model }; +} + +/** The environment variable holding the API key for a provider. */ +export function apiKeyEnvVar(provider: ProviderName): string { + return provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY"; +} + +export interface ProviderFromRefOptions { + apiKey?: string; +} + +export function createProviderFromRef( + ref: string, + options: ProviderFromRefOptions = {}, +): ModelProvider { + const { provider, model } = parseModelRef(ref); + switch (provider) { + case "openai": { + const openai = createOpenAI(options.apiKey ? { apiKey: options.apiKey } : {}); + const modelId = model || DEFAULT_OPENAI_MODEL; + return createAiSdkProvider(openai(modelId), `openai/${modelId}`); + } + default: { + const modelId = model || DEFAULT_ANTHROPIC_MODEL; + return createAnthropicProvider({ model: modelId, ...options }); + } + } +} diff --git a/packages/providers/test/registry.test.ts b/packages/providers/test/registry.test.ts new file mode 100644 index 0000000..4f4950c --- /dev/null +++ b/packages/providers/test/registry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { apiKeyEnvVar, createProviderFromRef, parseModelRef } from "../src"; + +describe("model references", () => { + test("bare model ids default to anthropic", () => { + expect(parseModelRef("claude-opus-4-8")).toEqual({ + provider: "anthropic", + model: "claude-opus-4-8", + }); + }); + + test("provider/model refs parse", () => { + expect(parseModelRef("openai/gpt-5.2")).toEqual({ provider: "openai", model: "gpt-5.2" }); + expect(parseModelRef("anthropic/claude-opus-4-8")).toEqual({ + provider: "anthropic", + model: "claude-opus-4-8", + }); + }); + + test("unknown providers and empty models are rejected", () => { + expect(() => parseModelRef("mistral/large")).toThrow("unknown provider"); + expect(() => parseModelRef("openai/")).toThrow("missing a model id"); + }); + + test("createProviderFromRef builds ids with the provider prefix", () => { + expect(createProviderFromRef("openai/gpt-5.2", { apiKey: "sk-test" }).id).toBe( + "openai/gpt-5.2", + ); + expect(createProviderFromRef("claude-opus-4-8", { apiKey: "sk-test" }).id).toBe( + "anthropic/claude-opus-4-8", + ); + }); + + test("each provider maps to its key env var", () => { + expect(apiKeyEnvVar("anthropic")).toBe("ANTHROPIC_API_KEY"); + expect(apiKeyEnvVar("openai")).toBe("OPENAI_API_KEY"); + }); +}); From 4a19df1ae5e6d0cd37f6ce7ff81f9070b6fc6131 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:48:19 +0800 Subject: [PATCH 17/20] =?UTF-8?q?feat(kernel):=20MCP=20client=20=E2=80=94?= =?UTF-8?q?=20external=20tools=20through=20the=20same=20permission=20engin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the "any model, any tool" half of the design: MCP servers declared in settings (mcpServers, project overriding global by name) are connected per session over stdio, and their tools join the registry as mcp____ so permission rules can target them. Two deliberate policy choices: server-provided readOnly hints are not trusted for permission bypass — MCP tools always go through the engine (deny/ask/allow rules and modes apply, ask-by-default in default mode) — and a server that fails to start degrades to a stderr warning instead of failing session creation, because a broken config must not brick the project. Every MCP call lands in the audit log under its namespaced tool name like any built-in. Integration test spawns a real MCP server (SDK fixture) from project settings and drives a full prompt through permission approval to the tool result. --- bun.lock | 186 ++++++++++++++++++++ packages/kernel/package.json | 4 + packages/kernel/src/kernel.ts | 30 +++- packages/kernel/src/mcp.ts | 95 ++++++++++ packages/kernel/src/settings.ts | 11 ++ packages/kernel/test/fixtures/mcp-server.ts | 21 +++ packages/kernel/test/mcp.test.ts | 112 ++++++++++++ 7 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 packages/kernel/src/mcp.ts create mode 100644 packages/kernel/test/fixtures/mcp-server.ts create mode 100644 packages/kernel/test/mcp.test.ts diff --git a/bun.lock b/bun.lock index d5f21cf..c9a8a76 100644 --- a/bun.lock +++ b/bun.lock @@ -46,8 +46,12 @@ "dependencies": { "@minerva/protocol": "workspace:*", "@minerva/providers": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", "tinyglobby": "^0.2.17", }, + "devDependencies": { + "zod": "^4.4.3", + }, }, "packages/protocol": { "name": "@minerva/protocol", @@ -95,6 +99,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@minerva/cli": ["@minerva/cli@workspace:packages/cli"], "@minerva/client": ["@minerva/client@workspace:packages/client"], @@ -105,6 +111,8 @@ "@minerva/providers": ["@minerva/providers@workspace:packages/providers"], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -117,8 +125,14 @@ "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "ai": ["ai@6.0.222", "", { "dependencies": { "@ai-sdk/gateway": "3.0.146", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kUzBaIHIfaDgBX7X22V4tE48MyYyS2IgCuj/zfANx0JfDAwgKwzKjl9pZUpYSnYPChI7i7yRGRoeTkebAFx1VQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -127,8 +141,16 @@ "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -139,58 +161,204 @@ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.12.29", "", {}, "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -201,24 +369,42 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/packages/kernel/package.json b/packages/kernel/package.json index e3926af..e924eb1 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -9,6 +9,10 @@ "dependencies": { "@minerva/protocol": "workspace:*", "@minerva/providers": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", "tinyglobby": "^0.2.17" + }, + "devDependencies": { + "zod": "^4.4.3" } } diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 8414824..6143d1f 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -20,9 +20,11 @@ import { import type { ModelProvider } from "@minerva/providers"; import { runPrompt } from "./agent-loop"; import { now } from "./events"; +import { connectMcpServers, type McpConnection } from "./mcp"; import { isSessionModeId, SESSION_MODES } from "./permissions"; import { defaultRuntime, type Runtime } from "./runtime"; import { parseEventLog, projectDir, Session } from "./session"; +import { loadSettings } from "./settings"; import { builtinTools, type KernelTool } from "./tools"; export interface KernelOptions { @@ -42,6 +44,7 @@ export interface KernelOptions { export class MinervaKernel { #connection: Connection; #sessions = new Map(); + #mcp = new Map(); #provider: ModelProvider; #runtime: Runtime; #dataDir: string; @@ -95,9 +98,29 @@ export class MinervaKernel { runtime: this.#runtime, }); this.#sessions.set(session.id, session); + await this.#connectMcp(session.id, cwd); return { sessionId: session.id, modes: modeState(session) }; } + /** + * Connect the session's configured MCP servers. Failures degrade to + * warnings on stderr — a broken server config must not brick sessions. + */ + async #connectMcp(sessionId: string, cwd: string): Promise { + const settings = await loadSettings(this.#runtime, this.#dataDir, cwd); + if (Object.keys(settings.mcpServers).length === 0) return; + const connection = await connectMcpServers(settings.mcpServers); + for (const warning of connection.warnings) { + process.stderr.write(`minerva: ${warning}\n`); + } + this.#mcp.set(sessionId, connection); + } + + #toolsFor(sessionId: string): KernelTool[] { + const mcpTools = this.#mcp.get(sessionId)?.tools ?? []; + return mcpTools.length > 0 ? [...this.#tools, ...mcpTools] : this.#tools; + } + async #sessionLoad(params: unknown): Promise { const { sessionId, cwd } = (params ?? {}) as { sessionId?: string; cwd?: string }; if (typeof sessionId !== "string" || typeof cwd !== "string" || cwd.length === 0) { @@ -131,6 +154,7 @@ export class MinervaKernel { ); } this.#sessions.set(sessionId, loaded.session); + await this.#connectMcp(sessionId, cwd); // ACP: replay the conversation as session/update notifications before // answering, so the frontend can rebuild its transcript. for (const update of loaded.replay.updates) { @@ -243,7 +267,7 @@ export class MinervaKernel { session, connection: this.#connection, provider: this.#provider, - tools: this.#tools, + tools: this.#toolsFor(session.id), system: this.#systemPrompt(session.cwd), runtime: this.#runtime, }, @@ -257,6 +281,10 @@ export class MinervaKernel { } close(): void { + for (const connection of this.#mcp.values()) { + void connection.close(); + } + this.#mcp.clear(); this.#connection.close(); } } diff --git a/packages/kernel/src/mcp.ts b/packages/kernel/src/mcp.ts new file mode 100644 index 0000000..bebd52a --- /dev/null +++ b/packages/kernel/src/mcp.ts @@ -0,0 +1,95 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { McpServerConfig } from "./settings"; +import type { KernelTool } from "./tools"; +import { asRecord } from "./tools"; + +/** + * MCP client (design decision #6): external tools arrive via the ecosystem + * and flow through the same permission engine as built-ins. Tool names are + * namespaced mcp____ so rules can target them. + */ + +export interface McpConnection { + tools: KernelTool[]; + /** Servers that failed to connect, with the reason — callers surface these. */ + warnings: string[]; + close(): Promise; +} + +export async function connectMcpServers( + servers: Record, +): Promise { + const tools: KernelTool[] = []; + const warnings: string[] = []; + const clients: Client[] = []; + + for (const [serverName, config] of Object.entries(servers)) { + try { + const client = new Client({ name: "minerva", version: "0.1.0" }); + await client.connect( + new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + }), + ); + clients.push(client); + const listed = await client.listTools(); + for (const tool of listed.tools) { + tools.push(wrapMcpTool(client, serverName, tool)); + } + } catch (error) { + warnings.push( + `MCP server "${serverName}" failed to start: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + return { + tools, + warnings, + close: async () => { + await Promise.allSettled(clients.map((client) => client.close())); + }, + }; +} + +interface McpToolInfo { + name: string; + description?: string; + inputSchema: Record; +} + +function wrapMcpTool(client: Client, serverName: string, info: McpToolInfo): KernelTool { + const qualifiedName = `mcp__${serverName}__${info.name}`; + return { + name: qualifiedName, + description: info.description ?? `${info.name} (MCP server: ${serverName})`, + inputSchema: info.inputSchema, + kind: "other", + // Server-provided readOnlyHint is not trusted for permission bypass: + // MCP tools always go through the engine (deny/ask/allow rules + modes). + readOnly: false, + title() { + return `${serverName}:${info.name}`; + }, + async execute(input) { + const result = await client.callTool({ + name: info.name, + arguments: asRecord(input), + }); + const content = Array.isArray(result.content) ? result.content : []; + const text = content + .map((part) => + typeof part === "object" && part !== null && "text" in part + ? String((part as { text: unknown }).text) + : `[${String((part as { type?: unknown })?.type ?? "unknown")} content]`, + ) + .join("\n"); + return { output: text || "(no output)", isError: result.isError === true }; + }, + }; +} diff --git a/packages/kernel/src/settings.ts b/packages/kernel/src/settings.ts index ea79bd5..79ce228 100644 --- a/packages/kernel/src/settings.ts +++ b/packages/kernel/src/settings.ts @@ -14,14 +14,23 @@ export interface PermissionRules { ask: string[]; } +/** How to launch an MCP server (stdio transport). */ +export interface McpServerConfig { + command: string; + args?: string[]; + env?: Record; +} + export interface MinervaSettings { permissions?: Partial; defaultMode?: string; + mcpServers?: Record; } export interface ResolvedSettings { rules: PermissionRules; defaultMode?: string; + mcpServers: Record; } export function globalSettingsPath(dataDir: string): string { @@ -46,6 +55,8 @@ export async function loadSettings( ask: [...(global.permissions?.ask ?? []), ...(project.permissions?.ask ?? [])], }, defaultMode: project.defaultMode ?? global.defaultMode, + // Per-name override: a project redefining "github" replaces the global one. + mcpServers: { ...global.mcpServers, ...project.mcpServers }, }; } diff --git a/packages/kernel/test/fixtures/mcp-server.ts b/packages/kernel/test/fixtures/mcp-server.ts new file mode 100644 index 0000000..1d90fde --- /dev/null +++ b/packages/kernel/test/fixtures/mcp-server.ts @@ -0,0 +1,21 @@ +/** + * Minimal MCP server for integration tests: one "add" tool over stdio. + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const server = new McpServer({ name: "minerva-test-calc", version: "0.0.1" }); + +server.registerTool( + "add", + { + description: "Add two numbers", + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }) => ({ + content: [{ type: "text", text: String(a + b) }], + }), +); + +await server.connect(new StdioServerTransport()); diff --git a/packages/kernel/test/mcp.test.ts b/packages/kernel/test/mcp.test.ts new file mode 100644 index 0000000..da8f7b7 --- /dev/null +++ b/packages/kernel/test/mcp.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENT_METHODS, + CLIENT_METHODS, + Connection, + createInProcTransportPair, + type RequestPermissionParams, + type SessionUpdateParams, +} from "@minerva/protocol"; +import { createScriptedProvider } from "@minerva/providers"; +import { createKernel, type SessionEvent } from "../src"; + +const MCP_FIXTURE = join(import.meta.dir, "fixtures", "mcp-server.ts"); + +describe("MCP tools through the kernel", () => { + test("configured server's tool executes behind a permission prompt", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-mcp-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-mcp-data-")); + mkdirSync(join(cwd, ".minerva")); + writeFileSync( + join(cwd, ".minerva", "settings.json"), + JSON.stringify({ + mcpServers: { calc: { command: "bun", args: ["run", MCP_FIXTURE] } }, + }), + ); + + const [clientTransport, kernelTransport] = createInProcTransportPair(); + const kernel = createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider([ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "mcp__calc__add", + input: { a: 19, b: 23 }, + }, + { type: "finish", finishReason: "tool-calls", usage: {} }, + ], + [ + { type: "text-delta", text: "The sum is 42." }, + { type: "finish", finishReason: "stop", usage: {} }, + ], + ]), + }); + + const client = new Connection(clientTransport); + const updates: SessionUpdateParams[] = []; + const permissionRequests: RequestPermissionParams[] = []; + client.handleNotification(CLIENT_METHODS.sessionUpdate, (params) => { + updates.push(params as SessionUpdateParams); + }); + client.handleRequest(CLIENT_METHODS.sessionRequestPermission, (params) => { + permissionRequests.push(params as RequestPermissionParams); + return { outcome: { outcome: "selected", optionId: "allow" } }; + }); + + await client.request(AGENT_METHODS.initialize, { protocolVersion: 1 }); + const { sessionId } = await client.request<{ sessionId: string }>(AGENT_METHODS.sessionNew, { + cwd, + }); + const result = await client.request<{ stopReason: string }>(AGENT_METHODS.sessionPrompt, { + sessionId, + prompt: [{ type: "text", text: "add 19 and 23" }], + }); + + expect(result.stopReason).toBe("end_turn"); + // MCP tools are never auto-allowed: server hints don't bypass the engine. + expect(permissionRequests).toHaveLength(1); + expect(permissionRequests[0]?.toolCall.title).toBe("calc:add"); + + const completed = updates.find( + (u) => u.update.sessionUpdate === "tool_call_update" && u.update.status === "completed", + ); + expect(JSON.stringify(completed)).toContain("42"); + + // Audit trail records the namespaced tool. + const session = kernel.getSession(sessionId); + if (!session) throw new Error("session missing"); + await session.flush(); + const events = readFileSync(session.logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as SessionEvent); + const toolCall = events.find((event) => event.type === "tool.call"); + expect(toolCall).toMatchObject({ toolName: "mcp__calc__add", input: { a: 19, b: 23 } }); + + kernel.close(); + }, 30_000); + + test("a broken MCP server degrades to a warning, not a failed session", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-mcpbad-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-mcpbad-data-")); + mkdirSync(join(cwd, ".minerva")); + writeFileSync( + join(cwd, ".minerva", "settings.json"), + JSON.stringify({ + mcpServers: { broken: { command: "/nonexistent/binary" } }, + }), + ); + + const [clientTransport, kernelTransport] = createInProcTransportPair(); + createKernel(kernelTransport, { dataDir, provider: createScriptedProvider([]) }); + const client = new Connection(clientTransport); + await client.request(AGENT_METHODS.initialize, { protocolVersion: 1 }); + const session = await client.request<{ sessionId: string }>(AGENT_METHODS.sessionNew, { cwd }); + expect(session.sessionId).toStartWith("ses_"); + }, 15_000); +}); From 11be4392817d3ab82249d638e0670322c98be359 Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:51:40 +0800 Subject: [PATCH 18/20] feat: manual /compact + CLI command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit minerva/session/compact runs one summarization turn through the same provider, appends a session.compacted event, and resets the model context to the summary. The event log keeps the full history — compaction changes what the model sees, not the record — so replay rebuilds the compacted context on resume while the UI transcript still shows everything the user saw. Guarded against running mid-prompt or on an empty session, and cancellable like any prompt. The CLI grows a proper command palette: /help, /mode, /compact, /sessions (recent sessions with previews), /new (fresh session), /exit. --- packages/cli/src/app.tsx | 104 +++++++++++++++++++++------ packages/client/src/client.ts | 16 +++++ packages/client/test/compact.test.ts | 73 +++++++++++++++++++ packages/kernel/src/compact.ts | 58 +++++++++++++++ packages/kernel/src/events.ts | 9 +++ packages/kernel/src/index.ts | 2 + packages/kernel/src/kernel.ts | 24 +++++++ packages/kernel/src/replay.ts | 9 +++ packages/protocol/src/types.ts | 12 ++++ 9 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 packages/client/test/compact.test.ts create mode 100644 packages/kernel/src/compact.ts diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index adcb3e8..8fc25e3 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -54,24 +54,49 @@ export function App({ client, bridge, model, cwd, resume }: AppProps) { if (!session) { return Starting Minerva…; } + const startNewSession = () => { + client + .newSession(cwd) + .then(({ sessionId, store }) => setSession({ id: sessionId, store })) + .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); + }; return ( - Minerva · {model} · {cwd} · /exit to quit, esc to cancel + Minerva · {model} · {cwd} · /help for commands, esc to cancel - + ); } +const HELP_TEXT = [ + "/help show this help", + "/mode [id] show or set the session mode (plan | default | acceptEdits | auto)", + "/compact summarize the conversation and reset the model context", + "/sessions list recent sessions for this directory", + "/new start a fresh session", + "/exit quit", +].join("\n"); + function Chat({ client, session, pending, + cwd, + onNewSession, }: { client: MinervaClient; session: { id: string; store: SessionStore }; pending: PendingPermission | null; + cwd: string; + onNewSession: () => void; }) { const subscribe = useCallback( (listener: () => void) => session.store.subscribe(listener), @@ -85,30 +110,69 @@ function Chat({ if (key.escape && viewModel.busy) client.cancel(session.id); }); + const info = (text: string) => session.store.addInfo(text); + const reportError = (cause: unknown) => + info(`error: ${cause instanceof Error ? cause.message : String(cause)}`); + + const runCommand = (input: string) => { + const [command = "", ...rest] = input.slice(1).split(/\s+/); + const argument = rest.join(" "); + switch (command) { + case "exit": + case "quit": + exit(); + break; + case "help": + info(HELP_TEXT); + break; + case "mode": + if (!argument) { + info( + `mode: ${viewModel.currentModeId ?? "default"} — /mode plan | default | acceptEdits | auto`, + ); + break; + } + client.setMode(session.id, argument).catch(reportError); + break; + case "compact": + client + .compact(session.id) + .then((summary) => info(`context compacted — summary:\n${firstLines(summary, 8)}`)) + .catch(reportError); + break; + case "sessions": + client + .listSessions(cwd) + .then((sessions) => { + if (sessions.length === 0) { + info("no sessions for this directory yet"); + return; + } + info( + sessions + .map((entry) => `${entry.sessionId} ${entry.preview ?? "(no messages)"}`) + .join("\n"), + ); + }) + .catch(reportError); + break; + case "new": + onNewSession(); + break; + default: + info(`unknown command: /${command} — try /help`); + } + }; + const submit = (value: string) => { const text = value.trim(); if (!text) return; setDraft(""); - if (text === "/exit" || text === "/quit") { - exit(); - return; - } - if (text === "/mode" || text.startsWith("/mode ")) { - const modeId = text.slice("/mode".length).trim(); - if (!modeId) { - session.store.addInfo( - `mode: ${viewModel.currentModeId ?? "default"} — /mode plan | default | acceptEdits | auto`, - ); - return; - } - client.setMode(session.id, modeId).catch((cause) => { - session.store.addInfo(`error: ${cause instanceof Error ? cause.message : String(cause)}`); - }); + if (text.startsWith("/")) { + runCommand(text); return; } - client.prompt(session.id, text).catch((cause) => { - session.store.addInfo(`error: ${cause instanceof Error ? cause.message : String(cause)}`); - }); + client.prompt(session.id, text).catch(reportError); }; return ( diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 28f59f5..ce57184 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -7,6 +7,7 @@ import { PROTOCOL_VERSION, type RequestPermissionParams, type RequestPermissionResult, + type SessionCompactResult, type SessionLoadResult, type SessionNewResult, type SessionPromptResult, @@ -115,6 +116,21 @@ export class MinervaClient { await this.#connection.request(AGENT_METHODS.sessionSetMode, { sessionId, modeId }); } + /** Summarize and reset the model context; returns the summary. */ + async compact(sessionId: string): Promise { + const store = this.#stores.get(sessionId); + store?.setBusy(true); + try { + const result = await this.#connection.request( + MINERVA_METHODS.sessionCompact, + { sessionId }, + ); + return result.summary; + } finally { + store?.setBusy(false); + } + } + async prompt(sessionId: string, text: string): Promise { // Reject overlapping prompts before touching the store: the kernel would // reject them anyway, but by then the rejected call's echo and its diff --git a/packages/client/test/compact.test.ts b/packages/client/test/compact.test.ts new file mode 100644 index 0000000..0be8b08 --- /dev/null +++ b/packages/client/test/compact.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createKernel, type MinervaKernel } from "@minerva/kernel"; +import { createInProcTransportPair } from "@minerva/protocol"; +import { createScriptedProvider, type TurnEvent } from "@minerva/providers"; +import { MinervaClient } from "../src"; + +const FINISH_STOP: TurnEvent = { type: "finish", finishReason: "stop", usage: {} }; + +function boot(dataDir: string, turns: TurnEvent[][]) { + const [clientTransport, kernelTransport] = createInProcTransportPair(); + const kernel: MinervaKernel = createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider(turns), + }); + return { kernel, client: new MinervaClient(clientTransport) }; +} + +describe("/compact", () => { + test("summarizes, resets model context, survives resume; transcript keeps history", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-compact-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-compact-data-")); + + const first = boot(dataDir, [ + [{ type: "text-delta", text: "Refactored the parser." }, FINISH_STOP], + // The compaction turn: the scripted "model" produces the summary. + [{ type: "text-delta", text: "Summary: the user refactored the parser." }, FINISH_STOP], + [{ type: "text-delta", text: "Continuing from the summary." }, FINISH_STOP], + ]); + await first.client.initialize(); + const { sessionId, store } = await first.client.newSession(cwd); + await first.client.prompt(sessionId, "refactor the parser"); + + const summary = await first.client.compact(sessionId); + expect(summary).toBe("Summary: the user refactored the parser."); + expect(store.snapshot.busy).toBe(false); + + // Model context is now just the compacted summary… + const live = first.kernel.getSession(sessionId); + expect(live?.messages).toHaveLength(1); + expect(JSON.stringify(live?.messages[0])).toContain("refactored the parser"); + + // …and the conversation continues on top of it. + const stopReason = await first.client.prompt(sessionId, "keep going"); + expect(stopReason).toBe("end_turn"); + first.client.close(); + + // Resume in a fresh kernel: replay rebuilds from the compaction point, + // while the UI transcript still shows the pre-compaction exchange. + const second = boot(dataDir, []); + await second.client.initialize(); + const { store: resumedStore } = await second.client.loadSession(sessionId, cwd); + const resumed = second.kernel.getSession(sessionId); + + expect(JSON.stringify(resumed?.messages[0])).toContain("This session was compacted"); + expect(resumed?.messages.map((m) => m.role)).toEqual(["user", "user", "assistant"]); + const texts = resumedStore.snapshot.items + .filter((item) => item.kind === "assistant") + .map((item) => item.text); + expect(texts).toEqual(["Refactored the parser.", "Continuing from the summary."]); + }); + + test("compacting an empty session is rejected", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-compact2-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-compact2-data-")); + const { client } = boot(dataDir, []); + await client.initialize(); + const { sessionId } = await client.newSession(cwd); + await expect(client.compact(sessionId)).rejects.toThrow("nothing to compact"); + }); +}); diff --git a/packages/kernel/src/compact.ts b/packages/kernel/src/compact.ts new file mode 100644 index 0000000..43e5f90 --- /dev/null +++ b/packages/kernel/src/compact.ts @@ -0,0 +1,58 @@ +import type { ModelProvider, ProviderMessage } from "@minerva/providers"; +import { now } from "./events"; +import type { Session } from "./session"; + +const SUMMARIZE_SYSTEM = + "You are summarizing a coding-agent session so it can continue in a fresh " + + "context window. Capture: the user's goals and constraints, what was done " + + "(files touched, commands run, decisions made), current state, and any " + + "unfinished work or known problems. Be specific about file paths and names. " + + "Respond with the summary only."; + +const SUMMARIZE_PROMPT = + "Summarize this session now, following your instructions. Remember: " + + "summary only, no preamble."; + +/** The message that stands in for compacted history, shared with replay. */ +export function compactedContextMessage(summary: string): ProviderMessage { + return { + role: "user", + content: `[This session was compacted. Summary of the conversation so far:]\n\n${summary}`, + }; +} + +/** + * Manual /compact (design decision #7 scope for v1): one summarization turn, + * then the model context is replaced by the summary. The event log keeps the + * full history — compaction changes what the model sees, not the record. + */ +export async function runCompact(session: Session, provider: ModelProvider): Promise { + const signal = session.beginPrompt(); + try { + let summary = ""; + let streamError: unknown; + const stream = provider.streamTurn({ + system: SUMMARIZE_SYSTEM, + messages: [...session.messages, { role: "user", content: SUMMARIZE_PROMPT }], + tools: [], + abortSignal: signal, + }); + for await (const event of stream) { + if (event.type === "text-delta") summary += event.text; + if (event.type === "error") streamError = event.error; + } + if (streamError !== undefined) { + throw streamError instanceof Error ? streamError : new Error(String(streamError)); + } + summary = summary.trim(); + if (!summary) throw new Error("compaction produced an empty summary"); + + session.append({ type: "session.compacted", summary, at: now() }); + await session.flush(); + session.messages.length = 0; + session.messages.push(compactedContextMessage(summary)); + return summary; + } finally { + session.endPrompt(); + } +} diff --git a/packages/kernel/src/events.ts b/packages/kernel/src/events.ts index 49c7711..b22a7ba 100644 --- a/packages/kernel/src/events.ts +++ b/packages/kernel/src/events.ts @@ -49,6 +49,15 @@ export type SessionEvent = at: string; } | { type: "todo.updated"; entries: PlanEntry[]; at: string } + | { + /** + * Model context was reset to a summary. Replay rebuilds the compacted + * context from this event; the UI transcript keeps the full history. + */ + type: "session.compacted"; + summary: string; + at: string; + } | { type: "turn.completed"; stopReason: StopReason; usage?: TurnUsage; at: string } | { type: "turn.failed"; error: string; at: string }; diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 1c188cd..7bf8b42 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -1,5 +1,7 @@ +export * from "./compact"; export * from "./events"; export * from "./kernel"; +export * from "./mcp"; export * from "./permissions"; export * from "./replay"; export * from "./runtime"; diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 6143d1f..a1a9849 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -8,6 +8,7 @@ import { MINERVA_METHODS, PROTOCOL_VERSION, RpcError, + type SessionCompactResult, type SessionLoadResult, type SessionModeState, type SessionNewResult, @@ -19,6 +20,7 @@ import { } from "@minerva/protocol"; import type { ModelProvider } from "@minerva/providers"; import { runPrompt } from "./agent-loop"; +import { runCompact } from "./compact"; import { now } from "./events"; import { connectMcpServers, type McpConnection } from "./mcp"; import { isSessionModeId, SESSION_MODES } from "./permissions"; @@ -73,6 +75,9 @@ export class MinervaKernel { this.#connection.handleRequest(MINERVA_METHODS.sessionsList, (params) => this.#sessionsList(params), ); + this.#connection.handleRequest(MINERVA_METHODS.sessionCompact, (params) => + this.#sessionCompact(params), + ); this.#connection.handleNotification(AGENT_METHODS.sessionCancel, (params) => { const { sessionId } = params as { sessionId?: string }; if (sessionId) this.#sessions.get(sessionId)?.cancel(); @@ -275,6 +280,25 @@ export class MinervaKernel { ); } + async #sessionCompact(params: unknown): Promise { + const { sessionId } = (params ?? {}) as { sessionId?: string }; + const session = sessionId ? this.#sessions.get(sessionId) : undefined; + if (!session) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, `unknown session: ${sessionId}`); + } + if (session.promptActive) { + throw new RpcError( + JSON_RPC_ERROR_CODES.INVALID_REQUEST, + "cannot compact while a prompt is running", + ); + } + if (session.messages.length === 0) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_REQUEST, "nothing to compact yet"); + } + const summary = await runCompact(session, this.#provider); + return { summary }; + } + /** For tests and diagnostics. */ getSession(sessionId: string): Session | undefined { return this.#sessions.get(sessionId); diff --git a/packages/kernel/src/replay.ts b/packages/kernel/src/replay.ts index 4e42bac..9016921 100644 --- a/packages/kernel/src/replay.ts +++ b/packages/kernel/src/replay.ts @@ -1,5 +1,6 @@ import type { PlanEntry, SessionUpdate } from "@minerva/protocol"; import type { ProviderMessage, ProviderToolCall, ProviderToolResult } from "@minerva/providers"; +import { compactedContextMessage } from "./compact"; import type { SessionEvent } from "./events"; import type { KernelTool } from "./tools"; @@ -125,6 +126,14 @@ export function replayEvents(events: SessionEvent[], tools: KernelTool[]): Repla modeId = event.modeId; break; + case "session.compacted": + // The model context restarts from the summary; the UI transcript + // (already emitted above) keeps the full history. + flushToolBatch(); + messages.length = 0; + messages.push(compactedContextMessage(event.summary)); + break; + case "turn.completed": case "turn.failed": flushToolBatch(); diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index cf0c040..db57b89 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -28,6 +28,8 @@ export const AGENT_METHODS = { export const MINERVA_METHODS = { /** Frontend → kernel: list persisted sessions for a project. */ sessionsList: "minerva/sessions/list", + /** Frontend → kernel: summarize the conversation and reset model context. */ + sessionCompact: "minerva/session/compact", } as const; /** Kernel → frontend. */ @@ -107,6 +109,16 @@ export interface SessionLoadResult { modes?: SessionModeState; } +// --- minerva/session/compact ------------------------------------------- + +export interface SessionCompactParams { + sessionId: string; +} + +export interface SessionCompactResult { + summary: string; +} + // --- minerva/sessions/list --------------------------------------------- export interface SessionsListParams { From 10f49db5a6949cdf879831aeb44ef14928bf613b Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 22:56:46 +0800 Subject: [PATCH 19/20] docs: usage guide, config reference, release script, slice status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README now documents what actually shipped: flags and slash commands, provider refs and their key env vars, the settings schema (permission rules with precedence, defaultMode, mcpServers), session logs as the audit trail, and Zed agent-server setup for the acp host. build:release compiles the single-file executable per the runtime decision (#9). Honestly documented caveat: on this macOS arm64 setup Bun 1.3.12 emits an unsigned binary that the kernel SIGKILLs, and codesign rejects the format for re-signing — recorded in the design watchlist to revisit on a newer Bun; the CLI runs via bun in the meantime. DESIGN.md slice checklist updated: all four v0.1 slices built; exit criteria marked with what remains human-gated (live-model smoke, live Zed interop). --- README.md | 123 +++++++++++++++++++++++++++++++++++++++++++++++-- docs/DESIGN.md | 31 ++++++++----- package.json | 3 +- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f5a3874..1b14831 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,130 @@ A cross-platform, model-agnostic code agent — a headless kernel with multiple frontends (CLI now, GUI later). - **Architecture**: protocol everywhere ([ACP](https://agentclientprotocol.com) - core + `minerva/*` extensions), one kernel, swappable transports. + core + `minerva/*` extensions), one kernel, swappable transports (in-process, + stdio; WebSocket planned). - **Stack**: TypeScript, Bun, Vercel AI SDK, Ink (CLI), Tauri 2 (GUI, planned). -See [docs/DESIGN.md](docs/DESIGN.md) for the design record. +See [docs/DESIGN.md](docs/DESIGN.md) for the design record and current status. -## Development +## Quick start ```sh bun install -bun run verify # typecheck + lint + tests +export ANTHROPIC_API_KEY="sk-ant-..." bun run --cwd packages/cli dev ``` + +## Usage + +``` +minerva [command] [options] + +Commands: + (default) Interactive terminal UI + acp Host the kernel on stdio (ACP framing) for editors + +Options: + -c, --continue Resume the most recent session for this directory + -r, --resume Resume a specific session + -m, --model Model as [provider/]model, e.g. openai/gpt-5.2 or + claude-opus-4-8 (bare ids default to Anthropic) + -h, --help Show help +``` + +Inside the TUI: + +| Command | Effect | +|---|---| +| `/help` | List commands | +| `/mode [id]` | Show or set the session mode (`plan` \| `default` \| `acceptEdits` \| `auto`) | +| `/compact` | Summarize the conversation and reset the model context | +| `/sessions` | List recent sessions for this directory | +| `/new` | Start a fresh session | +| `/exit` | Quit | + +`esc` cancels the running turn — including while a permission prompt is open. +Permission prompts accept `y` (allow once), `a` (allow always — persisted as a +project rule), `n` (reject), `esc` (cancel the turn). + +## Providers + +Model references are `provider/model`; the matching key must be exported: + +| Provider | Example ref | Key | +|---|---|---| +| Anthropic (default) | `claude-opus-4-8` or `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` | +| OpenAI | `openai/gpt-5.2` | `OPENAI_API_KEY` | + +## Configuration + +Settings merge from `~/.minerva/settings.json` (global; override the root with +`MINERVA_DATA_DIR`) and `/.minerva/settings.json` (project). Example: + +```json +{ + "defaultMode": "default", + "permissions": { + "allow": ["bash(git status)", "bash(bun test*)"], + "deny": ["read_file(secrets/*)", "bash(rm -rf *)"], + "ask": ["bash(git push*)"] + }, + "mcpServers": { + "calc": { "command": "bun", "args": ["run", "./tools/mcp-calc.ts"] } + } +} +``` + +Permission rules are `tool` or `tool(pattern)` where `*` matches any run of +characters, `?` one character, and `\*` a literal asterisk. Precedence: +**deny** → **ask** → read-only auto-allow → **plan mode deny** → **allow** → +mode default. Rules match the bash command string or the file path; MCP tools +are named `mcp____` and are never auto-allowed. + +Every session is an append-only JSONL event log under +`~/.minerva/projects//` — the audit trail and the source of truth for +`--resume`. + +## Editors (ACP) + +`minerva acp` hosts the kernel on stdio with ACP framing. For Zed, add an +agent server along these lines: + +```json +{ + "agent_servers": { + "Minerva": { + "command": "bun", + "args": ["run", "/path/to/minerva/packages/cli/src/index.tsx", "acp"] + } + } +} +``` + +The stdio wire contract is covered by an automated harness +(`packages/cli/test/acp.test.ts`); live Zed interop has not been validated yet. + +## Development + +```sh +bun run verify # typecheck + lint + all tests +bun test packages/kernel +``` + +The repo is a Bun workspace: `packages/protocol` (JSON-RPC + ACP types + +transports), `packages/kernel` (agent loop, sessions, tools, permissions, +MCP), `packages/providers` (model adapters), `packages/client` (shared +frontend core), `packages/cli` (Ink UI + acp host), `apps/gui` (planned). + +## Release build + +```sh +bun run build:release # dist/minerva, single-file executable +``` + +Cross-compile with `--target` (e.g. `bun-linux-x64`, `bun-windows-x64`). + +> **Known issue (macOS arm64):** with Bun 1.3.12 the compiled binary comes out +> unsigned; the kernel kills unsigned arm64 binaries (SIGKILL on launch) and +> `codesign` rejects the file format for re-signing. Until this is resolved +> (try a newer Bun), run the CLI via `bun run packages/cli/src/index.tsx`. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 42355ce..de4aa4a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -51,21 +51,24 @@ Config: `~/.minerva/settings.json` (global) + `.minerva/settings.json` ## v0.1 internal slices (checkpoints, not releases) -1. **Hello loop** — kernel library + in-proc transport + minimal Ink REPL; +1. ✅ **Hello loop** — kernel library + in-proc transport + minimal Ink REPL; Anthropic via AI SDK; Read/Edit/Bash; ask-every-time approvals; JSONL event logging on from the first turn. Proves every architectural seam end-to-end. -2. **Permissions & persistence** — rule engine + modes; "always allow" +2. ✅ **Permissions & persistence** — rule engine + modes; "always allow" persistence; session resume (replay); remaining built-ins; audit log. -3. **Protocol hardening** — stdio transport + `minerva acp` host command; ACP - conformance checked by connecting Zed as an external frontend; second - provider (OpenAI) + model switching; `minerva/*` extension methods. -4. **Ecosystem & polish** — MCP client; manual `/compact`; slash commands, - config UX, error/retry behavior; `bun build --compile` release binaries. - -**v0.1 exit criteria:** daily-drivable CLI for real work; Zed connects over ACP -and completes a session; two providers switchable mid-project; `kill -9` the -CLI → `minerva resume` restores the session; every side effect traceable in the -audit log. +3. ✅ **Protocol hardening** — stdio transport (ACP newline-delimited JSON) + + `minerva acp` host command; wire contract covered by a spawned-process + conformance harness; second provider (OpenAI) via provider/model refs; + `minerva/*` extension methods (sessions/list, session/compact). +4. ✅ **Ecosystem & polish** — MCP client (stdio servers from settings, tools + permission-gated as `mcp__server__tool`); manual `/compact`; slash-command + palette; `build:release` script (see watchlist for the macOS signing issue). + +**v0.1 exit criteria:** daily-drivable CLI for real work *(pending live-model +smoke — needs an API key)*; Zed connects over ACP and completes a session +*(harness-verified; live Zed pending)*; two providers switchable mid-project ✅; +`kill -9` the CLI → resume restores the session ✅ (tested, including torn log +lines); every side effect traceable in the audit log ✅. **M2 (post-v0.1):** Tauri 2 GUI — kernel as bundled sidecar, React UI consuming `@minerva/client` view-models; session browser reads the JSONL index. @@ -96,6 +99,10 @@ untouched until v0.1 lands via one final, non-squashed PR. kernel-owned. - **Windows**: compiled-binary paths, shell differences — test in slice 4, not after. +- **macOS arm64 compiled binaries**: Bun 1.3.12's `--compile` output is + unsigned on this setup; the kernel SIGKILLs unsigned arm64 binaries and + `codesign` rejects the file format for re-signing. Revisit on a newer Bun + before cutting release artifacts. ## Verification strategy diff --git a/package.json b/package.json index 4e98d99..857e8f3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "typecheck": "tsc --noEmit", "lint": "biome check .", "lint:fix": "biome check --write .", - "verify": "bun run typecheck && bun run lint && bun test" + "verify": "bun run typecheck && bun run lint && bun test", + "build:release": "bun build --compile --minify --external react-devtools-core packages/cli/src/index.tsx --outfile dist/minerva" }, "devDependencies": { "@biomejs/biome": "^2.4.4", From 93d25802f5da70dbf2ea1f170c52dc80cdf7300c Mon Sep 17 00:00:00 2001 From: John Hu Date: Fri, 10 Jul 2026 23:09:38 +0800 Subject: [PATCH 20/20] fix: local review findings for slices 3-4 (MCP env/lifecycle, client guards) Manual review of the slice 3+4 diff surfaced four defects: - MCP server env: passing config.env to StdioClientTransport REPLACES the environment (the SDK only applies its safe defaults when env is unset), so a config that set one variable stripped PATH/HOME and broke the server's spawn. Now merged over getDefaultEnvironment(). - MCP connection lifecycle: re-loading a session overwrote its map entry without closing the old connection, leaking server processes for the kernel's lifetime. connectMcp now closes any existing connection first. - client.compact() was missing the overlap guard prompt() has: called during an active prompt, its finally would clear the busy flag mid-stream and split the streaming assistant message. Same guard, with a regression test. - parseModelRef("openai") silently became an Anthropic model named "openai" and failed confusingly at request time; a bare provider name now errors with the provider/ hint. --- packages/client/src/client.ts | 7 +++++ packages/client/test/compact.test.ts | 34 ++++++++++++++++++++++++ packages/kernel/src/kernel.ts | 5 ++++ packages/kernel/src/mcp.ts | 10 +++++-- packages/providers/src/registry.ts | 9 ++++++- packages/providers/test/registry.test.ts | 5 ++++ 6 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index ce57184..36d4700 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -118,6 +118,12 @@ export class MinervaClient { /** Summarize and reset the model context; returns the summary. */ async compact(sessionId: string): Promise { + // Same guard as prompt(): a rejected overlapping call must not clear the + // in-flight prompt's busy state on its way out. + if (this.#activePrompts.has(sessionId)) { + throw new Error("a prompt is already running in this session"); + } + this.#activePrompts.add(sessionId); const store = this.#stores.get(sessionId); store?.setBusy(true); try { @@ -127,6 +133,7 @@ export class MinervaClient { ); return result.summary; } finally { + this.#activePrompts.delete(sessionId); store?.setBusy(false); } } diff --git a/packages/client/test/compact.test.ts b/packages/client/test/compact.test.ts index 0be8b08..88fc61a 100644 --- a/packages/client/test/compact.test.ts +++ b/packages/client/test/compact.test.ts @@ -62,6 +62,40 @@ describe("/compact", () => { expect(texts).toEqual(["Refactored the parser.", "Continuing from the summary."]); }); + test("compact during an active prompt is rejected without touching busy state", async () => { + const cwd = mkdtempSync(join(tmpdir(), "minerva-compact3-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-compact3-data-")); + // Needs its own client: the shared boot() has no permission handler, and + // this test's slow bash call must actually be approved and run. + const [clientTransport, kernelTransport] = createInProcTransportPair(); + createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider([ + [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "bash", + input: { command: "sleep 0.2" }, + }, + { type: "finish", finishReason: "tool-calls", usage: {} }, + ], + [{ type: "text-delta", text: "done" }, FINISH_STOP], + ]), + }); + const client = new MinervaClient(clientTransport, { + onPermissionRequest: async () => ({ outcome: { outcome: "selected", optionId: "allow" } }), + }); + await client.initialize(); + const { sessionId, store } = await client.newSession(cwd); + + const running = client.prompt(sessionId, "slow one"); + await Bun.sleep(20); + await expect(client.compact(sessionId)).rejects.toThrow("already running"); + expect(store.snapshot.busy).toBe(true); + await running; + }); + test("compacting an empty session is rejected", async () => { const cwd = mkdtempSync(join(tmpdir(), "minerva-compact2-proj-")); const dataDir = mkdtempSync(join(tmpdir(), "minerva-compact2-data-")); diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index a1a9849..fb351a2 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -112,6 +112,11 @@ export class MinervaKernel { * warnings on stderr — a broken server config must not brick sessions. */ async #connectMcp(sessionId: string, cwd: string): Promise { + // Re-loading a session replaces its connection; close the old one or its + // server processes leak for the kernel's lifetime. + await this.#mcp.get(sessionId)?.close(); + this.#mcp.delete(sessionId); + const settings = await loadSettings(this.#runtime, this.#dataDir, cwd); if (Object.keys(settings.mcpServers).length === 0) return; const connection = await connectMcpServers(settings.mcpServers); diff --git a/packages/kernel/src/mcp.ts b/packages/kernel/src/mcp.ts index bebd52a..80a664a 100644 --- a/packages/kernel/src/mcp.ts +++ b/packages/kernel/src/mcp.ts @@ -1,5 +1,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { + getDefaultEnvironment, + StdioClientTransport, +} from "@modelcontextprotocol/sdk/client/stdio.js"; import type { McpServerConfig } from "./settings"; import type { KernelTool } from "./tools"; import { asRecord } from "./tools"; @@ -31,7 +34,10 @@ export async function connectMcpServers( new StdioClientTransport({ command: config.command, args: config.args, - env: config.env, + // Merge with the SDK's safe defaults: passing env alone REPLACES + // the environment, and a config that sets one variable would + // otherwise strip PATH/HOME and break the server's spawn. + env: config.env ? { ...getDefaultEnvironment(), ...config.env } : undefined, }), ); clients.push(client); diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index 47ef742..db31515 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -19,7 +19,14 @@ export interface ModelRef { export function parseModelRef(ref: string): ModelRef { const slash = ref.indexOf("/"); - if (slash === -1) return { provider: "anthropic", model: ref }; + if (slash === -1) { + // "--model openai" would otherwise become an Anthropic model literally + // named "openai" and fail confusingly at request time. + if (ref === "anthropic" || ref === "openai") { + throw new Error(`"${ref}" is a provider, not a model — use ${ref}/`); + } + return { provider: "anthropic", model: ref }; + } const provider = ref.slice(0, slash); const model = ref.slice(slash + 1); if (provider !== "anthropic" && provider !== "openai") { diff --git a/packages/providers/test/registry.test.ts b/packages/providers/test/registry.test.ts index 4f4950c..df79cfc 100644 --- a/packages/providers/test/registry.test.ts +++ b/packages/providers/test/registry.test.ts @@ -22,6 +22,11 @@ describe("model references", () => { expect(() => parseModelRef("openai/")).toThrow("missing a model id"); }); + test("a bare provider name is rejected with a hint", () => { + expect(() => parseModelRef("openai")).toThrow("openai/"); + expect(() => parseModelRef("anthropic")).toThrow("anthropic/"); + }); + test("createProviderFromRef builds ids with the provider prefix", () => { expect(createProviderFromRef("openai/gpt-5.2", { apiKey: "sk-test" }).id).toBe( "openai/gpt-5.2",