diff --git a/README.md b/README.md index 61ca20a1..54fe7408 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ These add a page to magpie's window, in the app and in `magpie web`, under Plugi | [agent-office](packages/agent-office) | What are my agents doing: live sessions as workers in a voxel office (3D/2D) or a list, with status, current step and resume commands | no `magpie.read`; its host part reads the local session files of Claude Code, Codex, dsh, Gemini CLI, Qwen Code, Pi, omp, Qoder, WorkBuddy and CodeBuddy, including prompts, replies and tool commands (`text:false` keeps statuses and tool names only) | | [fingerprint](packages/fingerprint) | Numeric response fingerprints to investigate suspected model substitution across providers, with shared challenges, candidate rankings, local evidence and JSON export | none (its local service calls magpie’s gateway; sampling uses provider quota) | | [chat](packages/chat) | Chat with magpie's models, your MCP servers, instructions and skills, by Magic_zero ([Magiczerowxy](https://github.com/Magiczerowxy)). Has a host part: a local service the page goes to | none (its service calls magpie's gateway) | +| [agent-console](packages/agent-console) | Drive the Agent CLIs on this computer (Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh) with a timeline, Git changes, files and a terminal, by [qingnian108](https://github.com/qingnian108). Has a host part: a gate and a daemon on 127.0.0.1, started when the page opens | none (its daemon runs the Agent CLIs you pick, in the projects you open) | ```sh magpie plugin add @magpie-community/page- diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..4446bbd1 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +[test] +# packages/agent-console has its own vitest suite with npm dependencies (npm ci +# in that folder, then npm test); the root `bun test` doesn't install them +pathIgnorePatterns = ["packages/agent-console/test/**", "packages/agent-console/web/**"] diff --git a/packages/agent-console/.gitattributes b/packages/agent-console/.gitattributes new file mode 100644 index 00000000..23ef6e6d --- /dev/null +++ b/packages/agent-console/.gitattributes @@ -0,0 +1,10 @@ +*.ts text eol=lf +*.tsx text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.css text eol=lf +*.html text eol=lf +*.json text eol=lf +*.md text eol=lf +app/** -text +daemon/dist/** -text diff --git a/packages/agent-console/.gitignore b/packages/agent-console/.gitignore new file mode 100644 index 00000000..41c73637 --- /dev/null +++ b/packages/agent-console/.gitignore @@ -0,0 +1,6 @@ +_tmp/ +node_modules/ +*.log +.DS_Store +page/session.js +*.tgz diff --git a/packages/agent-console/LICENSE b/packages/agent-console/LICENSE new file mode 100644 index 00000000..96ebe89b --- /dev/null +++ b/packages/agent-console/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 qingnian108 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/agent-console/README.md b/packages/agent-console/README.md new file mode 100644 index 00000000..a2f31a61 --- /dev/null +++ b/packages/agent-console/README.md @@ -0,0 +1,188 @@ +# Agent Console + +A Magpie page plugin that drives real local Agent CLIs (Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh). Each conversation is a **persistent Agent session** on a standalone Node daemon. Close or refresh the page — the Agent keeps running while Magpie is open. Reopen the page and you pick up where you left off. When Magpie quits, the daemon and its Agents stop; threads are persisted and can be resumed next time. + +## Install + +Requires [Magpie](https://usemagpie.ai) ≥ 0.1.1340 and Node.js ≥ 20. + +```bash +magpie plugin add @magpie-community/page-agent-console +``` + +Or in Magpie: **Settings → Plugins → Add plugin**, enter `@magpie-community/page-agent-console` (or find **Agent Console** in Discover). After install, **Agent Console** appears in the sidebar plugin group. + +Install the Agent CLIs you want to use (for example `claude` or `codex`). Missing CLIs show an install hint in Settings. + +A cold install is about **46 MB** of `node_modules`, mostly `node-pty`'s native terminal module (~26 MB). Claude Code is **not** bundled: to use the Claude Code Agent, install the `claude` CLI yourself (`npm i -g @anthropic-ai/claude-code` or Anthropic's native installer). / 冷安装约占 **46 MB** 的 `node_modules`,主要是 `node-pty` 原生终端模块(约 26 MB)。插件**不**附带 Claude Code:要用 Claude Code Agent,请自行安装 `claude` CLI(`npm i -g @anthropic-ai/claude-code` 或 Anthropic 的原生安装器)。 + +--- + +This is not a gateway chat box. The daemon talks to the real CLIs through `claude -p --input-format stream-json --output-format stream-json` (the user's own `claude`, with its control messages for permissions, interrupt, model and mode), `codex app-server`, and ACP, normalizes a structured event stream (tool calls, thinking, permission requests, usage), and pushes it to the React page over WebSocket. + +UI principle: **obvious at a glance, nothing extra**. Three columns — sidebar (projects × threads), conversation timeline, and a right panel (changes / files / terminal). + +## Features + +- **Persistent sessions**: each thread binds to a long-lived Agent process; after a daemon restart, running threads are marked closed and can be resumed (`--resume` / `thread/resume`) +- **Multiple Agents**: Claude Code, Codex, Cursor, Copilot, OpenCode, Pi, Devin, dsh; missing CLIs show an install hint +- **Model / permission / thinking**: per-thread; four permission modes and five thinking levels (what each Agent actually supports) +- **Structured timeline**: user message → thinking (expandable) → tool calls (read / edit / command, with line-level diffs) → todos → usage and duration; answer permission requests (tools / plan / questions) on the card +- **Steer and queue**: steer while running, queue the next message, interrupt +- **Changes panel**: git status refresh every 3s, unified per-file diffs; discarded untracked files go to trash instead of a hard delete +- **Files panel**: lazy directory tree (respects `.gitignore`), syntax-highlighted preview +- **Terminal panel**: real PTY via node-pty (pwsh / powershell); reconnects after reopening the page; degrades if PTY is unavailable +- **Images**: attach or paste images (and text files). Only PNG, JPEG, GIF and WebP are sent as images. Claude Code and Codex take them natively; Claude Code gets a text file (up to 1 MB) as a plain-text document, Codex gets its path; any other file (SVG, binaries, images over Claude's 5 MB limit) goes by path, with a notice. An ACP Agent (Cursor, Copilot, OpenCode, Pi, Devin CLI, dsh) gets image blocks when its ACP `initialize` advertises `promptCapabilities.image`; otherwise it gets each image's file path (as text and a `resource_link`), so it can open it with its file tools, and the timeline says it can't see the image itself. The composer says so before you send once the Agent has been asked. dsh answers `initialize` for the route its ACP starts on (its `acp` config row, which ships as a model dsh's catalog doesn't have) and refuses image blocks otherwise, so Agent Console starts dsh with a `--patch` overlay that puts that row on the conversation's model: on a model that sees images (Magpie writes those as `input: [text, image]`) dsh then takes image blocks; after a switch to another model in the same chat, images go by path and dsh opens them with its `read_image` tool. Checked on 2026-10-31 with the real CLIs, a throwaway HOME, no sign-in and a local stand-in for the model API (`npm run test:real`, see `test/real/`): the image reaches the model request from Claude Code 2.1.316 (stream-json), Codex 0.165.0, Copilot 1.0.105 (BYOK), OpenCode 1.19.5, Pi (pi-acp 0.0.34 + pi 1.4.0) and dsh 0.2.1-rc.5; Devin CLI 3000.11.234 and Cursor 2026.10.22 advertise images and Devin gets the image block, but both need a sign-in to run a model. Inline media is capped at 10 MB per file and 16 MB per message (an ACP SDK peer refuses messages over 32 MB); larger images go by path +- **Import history**: import matching sessions from `~/.claude/projects` and `~/.codex/sessions` as new threads (resumable) +- **Thread management**: search (Ctrl+K), pin, rename, archive, delete (trash), export Markdown, thread info (usage / cost / session ID) +- **Mock Agent**: `AGENT_CONSOLE_MOCK=1` registers a demo Agent with 8 scripts (`default` / `perm` / `plan` / `ask` / `slow` / `error` / `long` / `exit`) so UI work does not burn tokens + +## Architecture + +``` +Magpie window + └─ page/index.html (sandbox="allow-scripts" iframe, served by Magpie from /_magpie/plugin//) + │ reads page/session.js {port, key} (gate key only, written by the plugin host) + │ navigates itself to http://127.0.0.1:/open?k=&theme=… + ▼ +Magpie plugin host (Bun) ── index.mjs + lib/gate.mjs "gate" on 127.0.0.1: (default 3488) + │ first /open: spawns the daemon as a child process (node, --port 0, stdin lifeline, --parent-pid) + │ 302 → http://127.0.0.1:/?theme=…#t= + ▼ +daemon (Node, daemon/dist/main.js) ── HTTP + WebSocket on an ephemeral 127.0.0.1 port + └─ React app (app/) reads the token from location.hash, sends it as the x-agent-console header + and in the WebSocket hello +``` + +## Security model + +The daemon can run Agents with full access to your machine, so the token that unlocks it must never be readable by another web page. + +- **Two secrets per plugin-host run.** Each run generates two independent random 256-bit values: a **gate key** and the **daemon token**. Only the gate key is ever written to a file. +- **Why `session.js` holds only the gate key.** Magpie serves the page folder at `/_magpie/plugin//`, where the id is an HMAC that changes every time Magpie starts and is handed out only to Magpie's own window. Magpie does not check `Origin`, `Host`, `Sec-Fetch-*` or a cookie on these files, and sends no `Cross-Origin-Resource-Policy`, so the unguessable path is their only protection. A classic ` + + diff --git a/packages/agent-console/assets/icon.svg b/packages/agent-console/assets/icon.svg new file mode 100644 index 00000000..d75b7e0d --- /dev/null +++ b/packages/agent-console/assets/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/agent-console/daemon/dist/main.js b/packages/agent-console/daemon/dist/main.js new file mode 100644 index 00000000..322acbda --- /dev/null +++ b/packages/agent-console/daemon/dist/main.js @@ -0,0 +1,7952 @@ +import { createRequire } from 'module'; const require = createRequire(import.meta.url); +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __esm = (fn, res, err) => function __init() { + if (err) throw err[0]; + try { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; + } catch (e) { + throw err = [e], e; + } +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; + +// daemon/src/log.ts +import { appendFileSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { join as join3 } from "node:path"; +function fileName(d) { + const p = (n) => String(n).padStart(2, "0"); + return `daemon-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}.log`; +} +function initLog(dataDir) { + logDir = join3(dataDir, "logs"); + mkdirSync(logDir, { recursive: true }); + try { + const keep = /* @__PURE__ */ new Set(); + const now = /* @__PURE__ */ new Date(); + for (let i = 0; i < 7; i++) { + keep.add(fileName(new Date(now.getTime() - i * 864e5))); + } + for (const f of readdirSync(logDir)) { + if (/^daemon-\d{4}-\d{2}-\d{2}\.log$/.test(f) && !keep.has(f)) { + rmSync(join3(logDir, f), { force: true }); + } + } + } catch { + } +} +function log(level, msg, extra) { + const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${msg}${extra !== void 0 ? " " + safeJson(extra) : ""}`; + try { + const d = /* @__PURE__ */ new Date(); + const name = fileName(d); + if (name !== today && logDir) { + today = name; + logFile = join3(logDir, name); + } + if (logFile) appendFileSync(logFile, line + "\n", "utf8"); + } catch { + } + const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log; + fn(`[agent-console] ${msg}`, extra !== void 0 ? extra : ""); +} +function safeJson(v) { + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} +var logDir, today, logFile; +var init_log = __esm({ + "daemon/src/log.ts"() { + logDir = ""; + today = ""; + logFile = ""; + } +}); + +// daemon/src/util/paths.ts +import { realpathSync, existsSync as existsSync3 } from "node:fs"; +import { posix, sep, win32 } from "node:path"; +function normPath(p, platform = process.platform) { + let r = (platform === "win32" ? win32 : posix).resolve(p); + try { + r = realpathSync.native(r); + } catch { + try { + r = realpathSync(r); + } catch { + } + } + if (platform === "win32") { + r = r.replace(/\//g, "\\").replace(/^\\\\\?\\(?!UNC\\)/i, "").replace(/^\\\\\?\\UNC\\/i, "\\\\").toLowerCase(); + } else { + r = r.replace(/\\/g, "/"); + if (platform === "darwin") r = r.normalize("NFC").toLowerCase(); + } + while (r.length > 3 && (r.endsWith("/") || r.endsWith("\\"))) r = r.slice(0, -1); + return r; +} +function samePath(a, b) { + return normPath(a) === normPath(b); +} +function validId(id) { + return typeof id === "string" && ID_RE.test(id); +} +var ID_RE; +var init_paths = __esm({ + "daemon/src/util/paths.ts"() { + ID_RE = /^[\w-]{1,80}$/; + } +}); + +// daemon/src/util/queue.ts +var AsyncQueue; +var init_queue = __esm({ + "daemon/src/util/queue.ts"() { + AsyncQueue = class { + buf = []; + waiters = []; + closed = false; + err = null; + push(item) { + if (this.closed) return; + const w = this.waiters.shift(); + if (w) w({ value: item, done: false }); + else this.buf.push(item); + } + close() { + if (this.closed) return; + this.closed = true; + while (this.waiters.length) { + const w = this.waiters.shift(); + w({ value: void 0, done: true }); + } + } + fail(err) { + if (this.closed) return; + this.err = err; + this.closed = true; + while (this.waiters.length) { + const w = this.waiters.shift(); + w({ value: void 0, done: true }); + } + } + get isClosed() { + return this.closed; + } + [Symbol.asyncIterator]() { + return { + next: () => { + if (this.buf.length) return Promise.resolve({ value: this.buf.shift(), done: false }); + if (this.closed) { + if (this.err) return Promise.reject(this.err); + return Promise.resolve({ value: void 0, done: true }); + } + if (this.err) return Promise.reject(this.err); + return new Promise((res, rej) => { + this.waiters.push((r) => { + if (this.err) { + rej(this.err); + return; + } + res(r); + }); + }); + } + }; + } + }; + } +}); + +// daemon/src/agents/diff.ts +function diffLines(oldStr = "", newStr = "", { context = CONTEXT } = {}) { + const a = splitLines(oldStr); + const b = splitLines(newStr); + if (a.length > MAX_LINES || b.length > MAX_LINES) return coarseDiff(a, b); + const ops = lcsOps(a, b); + return buildHunks(ops, context); +} +function splitLines(s) { + s = String(s); + if (!s) return []; + return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); +} +function lcsOps(a, b) { + const n = a.length; + const m = b.length; + const dp = new Uint32Array((n + 1) * (m + 1)); + for (let i2 = n - 1; i2 >= 0; i2--) + for (let j2 = m - 1; j2 >= 0; j2--) + dp[i2 * (m + 1) + j2] = a[i2] === b[j2] ? dp[(i2 + 1) * (m + 1) + j2 + 1] + 1 : Math.max(dp[(i2 + 1) * (m + 1) + j2], dp[i2 * (m + 1) + j2 + 1]); + const ops = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + ops.push({ t: " ", s: a[i] }); + i++; + j++; + } else if (dp[(i + 1) * (m + 1) + j] >= dp[i * (m + 1) + j + 1]) { + ops.push({ t: "-", s: a[i] }); + i++; + } else { + ops.push({ t: "+", s: b[j] }); + j++; + } + } + while (i < n) ops.push({ t: "-", s: a[i++] }); + while (j < m) ops.push({ t: "+", s: b[j++] }); + return ops; +} +function buildHunks(ops, context) { + const changed = []; + ops.forEach((op, i) => { + if (op.t !== " ") changed.push(i); + }); + if (!changed.length) return { hunks: [], added: 0, removed: 0 }; + let added = 0; + let removed = 0; + for (const op of ops) { + if (op.t === "+") added++; + else if (op.t === "-") removed++; + } + const ranges = []; + for (const idx of changed) { + const s = Math.max(0, idx - context); + const e = Math.min(ops.length, idx + context + 1); + const last = ranges[ranges.length - 1]; + if (last && s <= last.e + MERGE_GAP) last.e = Math.max(last.e, e); + else ranges.push({ s, e }); + } + const hunks = []; + let oldLn = 1; + let newLn = 1; + const oldNos = []; + const newNos = []; + for (const op of ops) { + oldNos.push(oldLn); + newNos.push(newLn); + if (op.t !== "+") oldLn++; + if (op.t !== "-") newLn++; + } + for (const r of ranges) { + hunks.push({ + oldStart: oldNos[r.s], + newStart: newNos[r.s], + lines: ops.slice(r.s, r.e).map((op) => ({ t: op.t, s: op.s })) + }); + } + return { hunks, added, removed }; +} +function coarseDiff(a, b) { + return { + hunks: [ + { + oldStart: 1, + newStart: 1, + lines: [...a.map((s) => ({ t: "-", s })), ...b.map((s) => ({ t: "+", s }))].slice(0, 2e3) + } + ], + added: b.length, + removed: a.length, + truncated: true + }; +} +function editDetail(detail = {}) { + if (Array.isArray(detail.edits) && detail.edits.length) { + const hunks = []; + let added = 0; + let removed = 0; + for (const e of detail.edits) { + const d = diffLines(e.old_string ?? e.old ?? "", e.new_string ?? e.new ?? ""); + for (const h of d.hunks) hunks.push(h); + added += d.added; + removed += d.removed; + } + return { path: detail.path, hunks, added, removed }; + } + if (detail.old !== void 0 || detail.new !== void 0) { + const d = diffLines(String(detail.old ?? ""), String(detail.new ?? "")); + return { path: detail.path, hunks: d.hunks, added: d.added, removed: d.removed }; + } + return detail; +} +var CONTEXT, MERGE_GAP, MAX_LINES; +var init_diff = __esm({ + "daemon/src/agents/diff.ts"() { + CONTEXT = 3; + MERGE_GAP = 6; + MAX_LINES = 400; + } +}); + +// daemon/src/providers/slash.ts +function classifyClaudeSlashCommand(name) { + return CLAUDE_BUILTIN_COMMANDS.has(name) ? "command" : "skill"; +} +function parseSlashInput(text) { + const t = text.trim(); + if (!t.startsWith("/")) return null; + const body = t.slice(1).trim(); + if (!body) return null; + const sp = body.indexOf(" "); + if (sp === -1) return { name: body, args: "", raw: t }; + return { name: body.slice(0, sp), args: body.slice(sp + 1).trim(), raw: t }; +} +function mapAcpAvailableCommands(cmds) { + return cmds.map((c) => ({ + name: c.name, + description: c.description, + argumentHint: c.input?.hint, + kind: "skill" + })); +} +function sortSlashCommands(list) { + return [...list].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "command" ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} +var CLAUDE_BUILTIN_COMMANDS; +var init_slash = __esm({ + "daemon/src/providers/slash.ts"() { + CLAUDE_BUILTIN_COMMANDS = /* @__PURE__ */ new Set(["clear", "compact", "context", "usage", "init", "debug"]); + } +}); + +// daemon/src/providers/mock.ts +async function scriptDefault(ctx) { + const { emit } = ctx; + const sleepA = (ms) => sleepAbortable(ms, ctx); + emit({ type: "reasoning_delta", itemId: "r1", text: "\u8BA9\u6211\u770B\u770B\u9879\u76EE\u7ED3\u6784\u2026" }); + await sleepA(500); + emit({ type: "reasoning", itemId: "r1", text: "\u8BA9\u6211\u770B\u770B\u9879\u76EE\u7ED3\u6784\u2026\u9700\u8981\u5148\u8BFB\u51E0\u4E2A\u6587\u4EF6\u3002" }); + const tools = [ + { id: "t1", name: "Read", input: { file_path: ctx.input.text.includes("abs") ? "D:/x/a.ts" : "src/index.ts" } }, + { id: "t2", name: "Read", input: { file_path: "package.json" } } + ]; + for (const t of tools) { + emit({ type: "tool_call", itemId: t.id, kind: "read", title: `\u8BFB\u53D6 ${t.input.file_path}`, status: "running", input: t.input }); + if (await sleepA(300)) return; + emit({ type: "tool_call", itemId: t.id, kind: "read", title: `\u8BFB\u53D6 ${t.input.file_path}`, status: "ok", output: "\u2026\u6587\u4EF6\u5185\u5BB9\u2026", durationMs: 300 }); + } + emit({ type: "tool_call", itemId: "t3", kind: "search", title: '\u641C\u7D22 "TODO"', status: "running", input: { pattern: "TODO" } }); + await sleepA(200); + emit({ type: "tool_call", itemId: "t3", kind: "search", title: '\u641C\u7D22 "TODO"', status: "ok", output: "3 \u5904\u5339\u914D" }); + const oldStr = "const x = 1;\nconst y = 2;\nreturn x + y;"; + const newStr = "const x = 1;\nconst y = 2;\nconst z = 3;\nreturn x + y + z;"; + emit({ + type: "tool_call", + itemId: "t4", + kind: "edit", + title: "\u7F16\u8F91 src/index.ts", + status: "running", + input: { file_path: "src/index.ts", old_string: oldStr, new_string: newStr }, + detail: editDetail({ path: "src/index.ts", old: oldStr, new: newStr }) + }); + if (await sleepA(400)) return; + emit({ + type: "tool_call", + itemId: "t4", + kind: "edit", + title: "\u7F16\u8F91 src/index.ts", + status: "ok", + detail: editDetail({ path: "src/index.ts", old: oldStr, new: newStr }), + durationMs: 400 + }); + emit({ type: "tool_call", itemId: "t5", kind: "bash", title: "\u8FD0\u884C npm test", status: "running", input: { command: "npm test" } }); + for (let i = 1; i <= 20; i++) { + if (await sleepA(60)) return; + emit({ type: "tool_output_delta", itemId: "t5", text: `\u7B2C ${i} \u884C\u8F93\u51FA +` }); + } + emit({ type: "tool_call", itemId: "t5", kind: "bash", title: "\u8FD0\u884C npm test", status: "ok", output: "\u2713 \u5168\u90E8\u901A\u8FC7", exitCode: 0, durationMs: 1200 }); + emit({ + type: "todo", + items: [ + { text: "\u5206\u6790\u9700\u6C42", status: "done" }, + { text: "\u4FEE\u6539\u4EE3\u7801", status: "done" }, + { text: "\u8DD1\u6D4B\u8BD5", status: "done" }, + { text: "\u5199\u6587\u6863", status: "in_progress" } + ] + }); + await sleepA(300); + const reply = ctx.replyPrefix() + mockReply(ctx); + for (const ch of chunkText(reply, 40)) { + if (await sleepA(30)) return; + emit({ type: "assistant_delta", itemId: "m1", text: ch }); + } + emit({ type: "assistant_message", itemId: "m1", text: reply }); +} +async function scriptPerm(ctx) { + const { emit } = ctx; + const requestId = "perm-" + rid(); + emit({ + type: "permission_request", + requestId, + title: "\u8FD0\u884C npm test", + kind: "tool", + detail: { command: "npm test", cwd: ".", toolName: "Bash" }, + options: [ + { id: "accept", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "acceptForSession", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "decline", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const ans = await ctx.waitFor(requestId); + if (ans.cancelled || ans.optionId === "decline") { + emit({ type: "tool_call", itemId: "p1", kind: "bash", title: "\u8FD0\u884C npm test", status: "failed", output: "\u5DF2\u88AB\u7528\u6237\u62D2\u7EDD", exitCode: 1 }); + emit({ type: "assistant_message", itemId: "pm", text: "\u5DF2\u6309\u4F60\u7684\u8981\u6C42\u8DF3\u8FC7\u8BE5\u64CD\u4F5C\u3002" }); + return; + } + emit({ type: "tool_call", itemId: "p1", kind: "bash", title: "\u8FD0\u884C npm test", status: "running", input: { command: "npm test" } }); + if (await sleepAbortable(500, ctx)) return; + emit({ type: "tool_call", itemId: "p1", kind: "bash", title: "\u8FD0\u884C npm test", status: "ok", output: "\u2713 12 \u4E2A\u6D4B\u8BD5\u901A\u8FC7", exitCode: 0, durationMs: 500 }); + emit({ type: "assistant_message", itemId: "pm", text: "\u6D4B\u8BD5\u901A\u8FC7\u4E86\u3002" }); +} +async function scriptPlan(ctx) { + const { emit } = ctx; + emit({ + type: "plan", + itemId: "plan1", + markdown: "## \u5B9E\u65BD\u8BA1\u5212\n\n1. \u6539 `src/index.ts` \u5165\u53E3\n2. \u66F4\u65B0\u6D4B\u8BD5\n3. \u8DD1\u4E00\u904D e2e", + entries: [ + { step: "\u6539 src/index.ts \u5165\u53E3", status: "pending" }, + { step: "\u66F4\u65B0\u6D4B\u8BD5", status: "pending" }, + { step: "\u8DD1\u4E00\u904D e2e", status: "pending" } + ] + }); + const requestId = "perm-" + rid(); + emit({ + type: "permission_request", + requestId, + title: "\u5B9E\u65BD\u8FD9\u4E2A\u8BA1\u5212\uFF1F", + kind: "plan", + detail: { markdown: "\u89C1\u4E0A\u65B9\u8BA1\u5212" }, + options: [ + { id: "implement", label: "\u5F00\u59CB\u5B9E\u65BD", kind: "allow_once" }, + { id: "reject", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const ans = await ctx.waitFor(requestId); + if (ans.cancelled || ans.optionId === "reject") { + emit({ type: "assistant_message", itemId: "pm", text: "\u597D\u7684\uFF0C\u8BF7\u544A\u8BC9\u6211\u600E\u4E48\u6539\u3002" }); + return; + } + emit({ type: "tool_call", itemId: "pl1", kind: "edit", title: "\u7F16\u8F91 src/index.ts", status: "ok" }); + await sleepAbortable(400, ctx); + emit({ type: "assistant_message", itemId: "pm", text: "\u5DF2\u6309\u8BA1\u5212\u6539\u5B8C\u5165\u53E3\u548C\u6D4B\u8BD5\u3002" }); +} +async function scriptAsk(ctx) { + const { emit } = ctx; + const requestId = "q-" + rid(); + emit({ + type: "question", + requestId, + questions: [ + { + id: "q1", + header: "\u90E8\u7F72\u76EE\u6807", + question: "\u8981\u90E8\u7F72\u5230\u54EA\u4E2A\u73AF\u5883\uFF1F", + options: [ + { id: "staging", label: "Staging" }, + { id: "prod", label: "\u751F\u4EA7\u73AF\u5883" } + ] + }, + { + id: "q2", + header: "\u5907\u6CE8", + question: "\u8FD8\u6709\u4EC0\u4E48\u8981\u8865\u5145\u7684\uFF1F", + freeText: true + } + ] + }); + const ans = await ctx.waitFor(requestId); + const env = ans.answers?.q1 ?? "\uFF08\u672A\u9009\uFF09"; + const note = ans.answers?.q2 ?? ""; + emit({ type: "assistant_message", itemId: "am", text: `\u6536\u5230\uFF1A\u90E8\u7F72\u5230 ${env}${note ? `\uFF0C\u5907\u6CE8\u300C${note}\u300D` : ""}\u3002` }); +} +async function scriptSlow(ctx) { + const { emit } = ctx; + for (let i = 1; i <= 6; i++) { + const id = "s" + i; + emit({ type: "tool_call", itemId: id, kind: "bash", title: `\u6B65\u9AA4 ${i}/6`, status: "running", input: { command: `step ${i}` } }); + if (await sleepAbortable(2e3, ctx)) return; + emit({ type: "tool_call", itemId: id, kind: "bash", title: `\u6B65\u9AA4 ${i}/6`, status: "ok", output: `\u6B65\u9AA4 ${i} \u5B8C\u6210`, durationMs: 2e3 }); + } + emit({ type: "assistant_message", itemId: "sm", text: "6 \u4E2A\u6B65\u9AA4\u5168\u90E8\u8DD1\u5B8C\u3002" }); +} +async function scriptError(ctx) { + const { emit } = ctx; + emit({ type: "tool_call", itemId: "e1", kind: "bash", title: "\u8FD0\u884C ./build.sh", status: "running", input: { command: "./build.sh" } }); + await sleepAbortable(800, ctx); + throw new Error("\u6784\u5EFA\u5931\u8D25\uFF1A\u627E\u4E0D\u5230\u6A21\u5757 foo\uFF08\u6A21\u62DF\u9519\u8BEF\uFF09"); +} +async function scriptLong(ctx) { + const { emit } = ctx; + emit({ type: "assistant_delta", itemId: "L1", text: "" }); + for (let i = 0; i < 5e3; i++) { + if (i % 100 === 0 && ctx.interrupted()) return; + emit({ type: "assistant_delta", itemId: "L1", text: `\u5757${i} ` }); + if (i % 200 === 0) await sleep(0); + } + emit({ type: "assistant_message", itemId: "L1", text: "\uFF085000 \u4E2A\u589E\u91CF\u5408\u5E76\u540E\u7684\u957F\u6587\uFF09" }); +} +async function scriptExit(_ctx) { + await sleep(300); + throw new MockCrash(); +} +function pickScript(text) { + const t = text.trim(); + if (t.startsWith("/perm")) return scriptPerm; + if (t.startsWith("/plan")) return scriptPlan; + if (t.startsWith("/ask")) return scriptAsk; + if (t.startsWith("/slow")) return scriptSlow; + if (t.startsWith("/error")) return scriptError; + if (t.startsWith("/long")) return scriptLong; + if (t.startsWith("/exit")) return scriptExit; + return scriptDefault; +} +async function sleepAbortable(ms, ctx) { + const start = Date.now(); + while (Date.now() - start < ms) { + if (ctx.interrupted()) return true; + await sleep(Math.min(20, Math.max(1, ms - (Date.now() - start)))); + } + return ctx.interrupted(); +} +function chunkText(s, n) { + const out = []; + for (let i = 0; i < s.length; i += n) out.push(s.slice(i, i + n)); + return out; +} +function mockReply(ctx) { + const base = [ + "\u597D\u7684\uFF0C\u6211\u5DF2\u7ECF\u6309\u4F60\u7684\u8981\u6C42\u6539\u597D\u4E86\u3002", + "", + "## \u6539\u52A8", + "- `src/index.ts`\uFF1A\u628A\u8FD4\u56DE\u903B\u8F91\u8865\u6210\u4E09\u53D8\u91CF\u6C42\u548C", + "- \u65B0\u589E\u4E00\u884C\u5E38\u91CF `z`", + "", + "```ts", + "const z = 3;", + "return x + y + z;", + "```", + "", + "| \u6587\u4EF6 | \u53D8\u66F4 |", + "|---|---|", + "| src/index.ts | +2/-1 |", + "", + "\u6D4B\u8BD5\u5168\u90E8\u901A\u8FC7\u3002" + ].join("\n"); + return ctx.steerText() ? base + ` + +> \u63D2\u8BDD\uFF1A${ctx.steerText()}` : base; +} +function createMockProvider() { + return { + id: "mock", + name: "Mock\uFF08\u6F14\u793A\uFF09", + capabilities: mockCapabilities, + async detect() { + return { available: true, bin: "mock", version: "demo" }; + }, + async listModels() { + return [ + { id: "demo-fast", name: "\u6F14\u793A\xB7\u5FEB", isDefault: true, efforts: ["low", "medium", "high"] }, + { id: "demo-pro", name: "\u6F14\u793A\xB7\u5F3A", efforts: ["low", "medium", "high"] } + ]; + }, + async createSession(o) { + mockProviderMetrics.createSessionCalls++; + return new MockSession(o); + } + }; +} +var mockCapabilities, rid, sleep, MockCrash, MockSession, mockProviderMetrics; +var init_mock = __esm({ + "daemon/src/providers/mock.ts"() { + init_queue(); + init_diff(); + init_slash(); + mockCapabilities = { + resume: true, + interrupt: true, + steer: true, + images: true, + plan: true, + questions: true, + cost: true, + effort: ["low", "medium", "high"], + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + rid = () => Math.random().toString(36).slice(2, 10); + sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + MockCrash = class extends Error { + constructor() { + super("__mock_crash__"); + } + }; + MockSession = class { + constructor(o) { + this.o = o; + this.sessionId = o.resumeId ?? "mock-" + rid(); + this.resumed = o.resumeId !== null; + this.events = this.q; + this.q.push({ type: "provider_session_id", id: this.sessionId }); + } + o; + events; + q = new AsyncQueue(); + sessionId; + turnId = null; + turnNum = 0; + interruptFlag = false; + closed = false; + pendingReq = /* @__PURE__ */ new Map(); + steered = []; + resumed = false; + emit(ev) { + ev.turnId ??= this.turnId ?? void 0; + this.q.push(ev); + } + async send(input) { + if (this.closed) throw new Error("mock session closed"); + this.turnNum++; + this.turnId = "u_" + Date.now().toString(36) + "_" + this.turnNum; + this.interruptFlag = false; + this.steered = []; + this.emit({ + type: "turn_started", + turnId: this.turnId, + model: this.o.model ?? "demo-fast", + mode: this.o.mode, + effort: this.o.effort + }); + void this.runTurn(input); + } + async runTurn(input) { + const p = "i" + rid() + "_"; + const ctx = { + emit: (ev) => { + const e = ev; + if (e.itemId) e.itemId = p + e.itemId; + this.emit(ev); + }, + waitFor: (id) => this.waitFor(id), + interrupted: () => this.interruptFlag || this.closed, + steerText: () => this.steered.join("\uFF1B"), + replyPrefix: () => this.resumePrefix(), + input + }; + try { + const script = pickScript(input.text); + await script(ctx); + if (this.interruptFlag || this.closed) { + this.emit({ type: "turn_ended", turnId: this.turnId, status: "interrupted", durationMs: 0 }); + } else { + this.emit({ type: "usage", inputTokens: 1200, outputTokens: 340, cacheReadTokens: 800, costUsd: 0.0123 }); + this.emit({ type: "turn_ended", turnId: this.turnId, status: "completed", durationMs: 6e3, filesChanged: 1 }); + } + } catch (e) { + if (e instanceof MockCrash) { + this.q.close(); + return; + } + if (this.interruptFlag || this.closed) { + this.emit({ type: "turn_ended", turnId: this.turnId, status: "interrupted", durationMs: 0 }); + } else { + this.emit({ type: "turn_ended", turnId: this.turnId, status: "failed", error: String(e?.message ?? e) }); + } + } + } + respond(requestId, answer) { + const resolve2 = this.pendingReq.get(requestId); + if (resolve2) { + this.pendingReq.delete(requestId); + resolve2(answer); + } + } + waitFor(requestId) { + return new Promise((resolve2) => { + this.pendingReq.set(requestId, resolve2); + }); + } + async interrupt() { + this.interruptFlag = true; + for (const [id, resolve2] of this.pendingReq) { + resolve2({ cancelled: true }); + this.pendingReq.delete(id); + } + await sleep(120); + } + async steer(input) { + this.steered.push(input.text); + } + async setModel(model) { + this.o.model = model; + } + async setMode(mode) { + this.o.mode = mode; + } + async setEffort(effort) { + this.o.effort = effort; + } + async listCommands() { + return sortSlashCommands([ + { name: "perm", description: "\u6F14\u793A\u6388\u6743\u5F39\u7A97", kind: "command" }, + { name: "plan", description: "\u6F14\u793A\u8BA1\u5212\u5BA1\u6279", kind: "command" }, + { name: "ask", description: "\u6F14\u793A\u63D0\u95EE", kind: "command" }, + { name: "slow", description: "\u6162\u901F\u591A\u6B65\u5DE5\u5177", kind: "skill" }, + { name: "error", description: "\u6A21\u62DF\u5931\u8D25", kind: "skill" } + ]); + } + async close() { + this.closed = true; + for (const [, resolve2] of this.pendingReq) resolve2({ cancelled: true }); + this.pendingReq.clear(); + this.q.close(); + } + // With resumeId the first reply gets a "(已续接)" prefix + resumePrefix() { + if (this.resumed && this.turnNum === 1) return "\uFF08\u5DF2\u7EED\u63A5\uFF09"; + return ""; + } + }; + mockProviderMetrics = { createSessionCalls: 0 }; + } +}); + +// lib/pathscan.mjs +import { accessSync, constants, statSync as statSync2 } from "node:fs"; +import { homedir as homedir2 } from "node:os"; +import path from "node:path"; +function defaultExists(file, platform) { + try { + if (!statSync2(file).isFile()) return false; + if (platform !== "win32") accessSync(file, constants.X_OK); + return true; + } catch { + return false; + } +} +function envGet(env, name) { + if (env[name] !== void 0) return env[name]; + const lower = name.toLowerCase(); + for (const key of Object.keys(env)) if (key.toLowerCase() === lower) return env[key]; + return void 0; +} +function extraBinDirs({ platform = process.platform, env = process.env, home = homedir2() } = {}) { + const p = platform === "win32" ? path.win32 : path.posix; + const dirs = []; + if (platform === "win32") { + const appData = envGet(env, "APPDATA"); + const localAppData = envGet(env, "LOCALAPPDATA"); + const programFiles = envGet(env, "ProgramFiles") || "C:\\Program Files"; + const nvmSymlink = envGet(env, "NVM_SYMLINK"); + if (home) dirs.push(p.join(home, ".local", "bin")); + if (appData) dirs.push(p.join(appData, "npm")); + if (nvmSymlink) dirs.push(nvmSymlink); + dirs.push(p.join(programFiles, "nodejs")); + if (localAppData) dirs.push(p.join(localAppData, "Programs", "nodejs")); + if (home) { + dirs.push(p.join(home, ".volta", "bin")); + dirs.push(p.join(home, ".bun", "bin")); + } + return dirs; + } + if (home) { + dirs.push(p.join(home, ".local", "bin")); + dirs.push(p.join(home, ".npm-global", "bin")); + dirs.push(p.join(home, ".volta", "bin")); + dirs.push(p.join(home, ".bun", "bin")); + dirs.push(p.join(home, ".claude", "local")); + } + if (platform === "darwin") dirs.push("/opt/homebrew/bin"); + dirs.push("/usr/local/bin", "/usr/bin", "/bin"); + return dirs; +} +function searchDirs({ platform = process.platform, env = process.env, home = homedir2(), extra = true } = {}) { + const sep4 = platform === "win32" ? ";" : ":"; + const raw = String(envGet(env, "PATH") ?? "").split(sep4).map((d) => d.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean); + const all = extra ? [...raw, ...extraBinDirs({ platform, env, home })] : raw; + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const d of all) { + const key = platform === "win32" ? d.toLowerCase().replace(/[\\/]+$/, "") : d.replace(/\/+$/, ""); + if (seen.has(key)) continue; + seen.add(key); + out.push(d); + } + return out; +} +function windowsExts(env) { + const list = String(envGet(env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim().toLowerCase()).filter((e) => e.startsWith(".")); + if (!list.includes(".ps1")) list.push(".ps1"); + return list; +} +function findOnPath(name, { platform = process.platform, env = process.env, home = homedir2(), extra = true, exists } = {}) { + if (!name) return []; + const p = platform === "win32" ? path.win32 : path.posix; + const check = exists ?? ((file) => defaultExists(file, platform)); + if (name.includes("/") || platform === "win32" && name.includes("\\")) { + return check(name) ? [name] : []; + } + let names = [name]; + if (platform === "win32") { + const exts = windowsExts(env); + const ext = p.extname(name).toLowerCase(); + names = ext && exts.includes(ext) ? [name] : exts.map((e) => name + e); + } + const hits = []; + for (const dir of searchDirs({ platform, env, home, extra })) { + for (const n of names) { + const file = p.join(dir, n); + if (check(file) && !hits.includes(file)) hits.push(file); + } + } + return hits; +} +function findNode(opts = {}) { + return findOnPath("node", opts).find((file) => isNodeExecutable(file)) ?? null; +} +function isNodeExecutable(file) { + return /^node(\.exe)?$/i.test(String(file || "").split(/[\\/]/).pop() ?? ""); +} +var init_pathscan = __esm({ + "lib/pathscan.mjs"() { + } +}); + +// daemon/src/util/bin.ts +import { execFileSync, spawn } from "node:child_process"; +import { readFile as readFile3 } from "node:fs/promises"; +import { existsSync as existsSync4 } from "node:fs"; +import path2 from "node:path"; +function which(name) { + return findOnPath(name); +} +function escapeCmdCommand(cmd) { + return cmd.replace(CMD_META, "^$1"); +} +function escapeCmdArgument(arg, doubleEscape) { + let a = String(arg); + a = a.replace(/(\\*)"/g, '$1$1\\"'); + a = a.replace(/(\\*)$/, "$1$1"); + a = `"${a}"`; + a = a.replace(CMD_META, "^$1"); + if (doubleEscape) a = a.replace(CMD_META, "^$1"); + return a; +} +function argvFor(r, args) { + if (r.via === "cmd-shim" && r.shim) { + const line = [escapeCmdCommand(r.shim), ...args.map((a) => escapeCmdArgument(a, true))].join(" "); + return { cmd: r.cmd, args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true }; + } + return { cmd: r.cmd, args: [...r.prefixArgs, ...args] }; +} +function reapOnExit() { + for (const pid of liveTrees) { + try { + if (IS_WIN) execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 3e3 }); + else process.kill(-pid, "SIGKILL"); + } catch { + } + } + liveTrees.clear(); +} +function trackTree(child) { + if (child.pid == null) return; + const pid = child.pid; + liveTrees.add(pid); + child.once("exit", () => liveTrees.delete(pid)); + if (!exitHookInstalled) { + exitHookInstalled = true; + process.once("exit", reapOnExit); + } +} +function spawnBin(r, args, opts = {}) { + const a = argvFor(r, args); + const { group, ...rest } = opts; + const child = spawn(a.cmd, a.args, { + windowsHide: true, + ...rest, + ...a.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}, + ...group && !IS_WIN ? { detached: true } : {} + }); + if (group) trackTree(child); + return child; +} +function shimTarget(cmdText, cmdDir, deps = {}) { + const exists = deps.exists ?? existsSync4; + const P = deps.win ?? IS_WIN ? path2.win32 : path2; + const cands = []; + const re = /"([^"]+?\.(?:js|mjs|cjs|exe|cmd|bat))"/gi; + let m; + while (m = re.exec(cmdText)) cands.push(m[1]); + const re2 = /(?:%~?dp0%?)?[A-Za-z]:?[\\/\w.@-]*?\.(?:js|mjs|cjs|exe)\b/gi; + while (m = re2.exec(cmdText)) cands.push(m[0]); + const found = []; + for (const raw of cands) { + let p = raw.replace(/%~?dp0%?/gi, ""); + p = p.replace(/%[A-Z_]+%/gi, ""); + p = p.trim().replace(/^[\\/]+/, ""); + if (!p || /%\w+%/.test(p) || !/\.(js|mjs|cjs|exe)$/i.test(p)) continue; + if (!P.isAbsolute(p)) { + if (!cmdDir) continue; + p = P.join(cmdDir, p); + } + if (exists(p)) found.push(P.resolve(p)); + } + return found.find((f) => /\.(js|mjs|cjs)$/i.test(f)) ?? found.find((f) => /\.exe$/i.test(f) && !isNodeExecutable(P.basename(f))) ?? null; +} +function findNode2() { + if (isNodeExecutable(process.execPath)) return process.execPath; + return findNode() ?? "node"; +} +async function resolveBin(name) { + const hit = cache.get(name); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.out; + const out = await resolveBinUncached(name); + cache.set(name, { out, at: Date.now() }); + return out; +} +async function resolveFirstBin(names) { + const uniq = [...new Set(names.filter(Boolean))]; + for (const binName of uniq) { + const r = await resolveBin(binName); + if (r.ok) return { ...r, binName }; + } + const label = uniq[0] ?? "cli"; + return { ok: false, reason: `PATH \u91CC\u627E\u4E0D\u5230 ${uniq.length > 1 ? uniq.join(" \u6216 ") : label}` }; +} +function clearBinCache(name) { + if (name) cache.delete(name); + else cache.clear(); +} +function resolveBinUncached(name) { + return resolveBinWith(name); +} +async function resolveBinWith(name, deps = {}) { + const platform = deps.platform ?? process.platform; + const win = platform === "win32"; + const P = win ? path2.win32 : path2.posix; + const exists = deps.exists ?? existsSync4; + const hits = (deps.find ?? which)(name); + if (!hits.length) return { ok: false, reason: `PATH \u91CC\u627E\u4E0D\u5230 ${name}` }; + const first = hits[0]; + if (!win) return { ok: true, cmd: first, prefixArgs: [], display: first, via: "path" }; + const ext = P.extname(first).toLowerCase(); + if (ext === ".exe" || ext === ".com") return { ok: true, cmd: first, prefixArgs: [], display: first, via: "exe" }; + if (ext === ".ps1") { + return { + ok: true, + cmd: "powershell.exe", + prefixArgs: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", first], + display: first, + via: "ps1" + }; + } + const dir = P.dirname(first); + if (name.toLowerCase() === "npx") { + const node = P.join(dir, "node.exe"); + const cli = P.join(dir, "node_modules", "npm", "bin", "npx-cli.js"); + if (exists(node) && exists(cli)) + return { ok: true, cmd: node, prefixArgs: [cli], display: `${node} ${cli}`, via: "npx-shim" }; + } + const text = await (deps.readText ?? ((f) => readFile3(f, "utf8")))(first).catch(() => ""); + const target = shimTarget(text, dir, { exists, win: true }); + if (target && /\.(js|mjs|cjs)$/i.test(target)) { + const node = (deps.node ?? findNode2)(); + return { ok: true, cmd: node, prefixArgs: [target], display: `${node} ${target}`, via: "shim-js" }; + } + if (target && /\.exe$/i.test(target)) { + return { ok: true, cmd: target, prefixArgs: [], display: target, via: "shim-exe" }; + } + const comspec = deps.comspec ?? (process.env.ComSpec || "cmd.exe"); + return { ok: true, cmd: comspec, prefixArgs: [], display: first, via: "cmd-shim", shim: first }; +} +function killTree(child, { graceMs = 3e3 } = {}) { + if (!child || child.pid == null) return Promise.resolve(); + const pid = child.pid; + if (IS_WIN) { + return new Promise((resolvePromise) => { + const tk = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true }); + tk.on("error", () => { + try { + child.kill(); + } catch { + } + resolvePromise(); + }); + tk.on("close", () => resolvePromise()); + setTimeout(resolvePromise, graceMs + 2e3); + }); + } + return new Promise((resolvePromise) => { + let done = false; + const finish = () => { + if (!done) { + done = true; + resolvePromise(); + } + }; + try { + process.kill(-pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch { + } + } + if (child.exitCode !== null || child.signalCode !== null) { + finish(); + return; + } + const t = setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + child.kill("SIGKILL"); + } catch { + } + } + finish(); + }, graceMs); + t.unref?.(); + child.on("close", () => { + clearTimeout(t); + finish(); + }); + }); +} +var IS_WIN, cache, CACHE_MS, CMD_META, liveTrees, exitHookInstalled; +var init_bin = __esm({ + "daemon/src/util/bin.ts"() { + init_pathscan(); + IS_WIN = process.platform === "win32"; + cache = /* @__PURE__ */ new Map(); + CACHE_MS = 60 * 1e3; + CMD_META = /([()\][%!^"`<>&|;, *?])/g; + liveTrees = /* @__PURE__ */ new Set(); + exitHookInstalled = false; + } +}); + +// daemon/src/agents/kinds.ts +function relPath(p, cwd) { + if (!p) return ""; + const s = String(p); + if (!cwd) return s; + const norm = (x) => x.replace(/\\/g, "/").replace(/\/+$/, ""); + const a = norm(s); + const b = norm(cwd); + if (a.toLowerCase().startsWith(b.toLowerCase() + "/")) return a.slice(b.length + 1); + if (a.toLowerCase() === b.toLowerCase()) return "."; + return s; +} +function callInfo(name, input = {}, cwd = "") { + const t = String(name || ""); + const f = (kind, title, detail) => ({ kind, title, detail }); + const file = String(input.file_path ?? input.path ?? input.notebook_path ?? ""); + switch (t) { + case "Read": + return f("read", `\u8BFB\u53D6 ${relPath(file, cwd)}`, { path: file, offset: input.offset, limit: input.limit }); + case "Edit": + return f("edit", `\u7F16\u8F91 ${relPath(file, cwd)}`, { path: file, old: input.old_string, new: input.new_string }); + case "MultiEdit": + return f("edit", `\u7F16\u8F91 ${relPath(file, cwd)}\uFF08${(input.edits || []).length} \u5904\uFF09`, { path: file, edits: input.edits ?? [] }); + case "Write": + return f("write", `${input._exists ? "\u8986\u76D6" : "\u65B0\u5EFA"} ${relPath(file, cwd)}`, { path: file, content: input.content }); + case "NotebookEdit": + return f("edit", `\u7F16\u8F91\u7B14\u8BB0\u672C ${relPath(String(input.notebook_path || file), cwd)}`, { path: input.notebook_path ?? file }); + case "Bash": + return f("bash", `\u8FD0\u884C ${trunc(String(input.command ?? ""))}`, { command: input.command, description: input.description }); + case "BashOutput": + return f("bash", `\u67E5\u770B\u8F93\u51FA ${String(input.bash_id ?? "")}`, { bashId: input.bash_id }); + case "KillShell": + return f("bash", `\u7ED3\u675F\u8FDB\u7A0B ${String(input.shell_id ?? "")}`, { shellId: input.shell_id }); + case "Grep": + return f("search", `\u641C\u7D22 "${trunc(String(input.pattern ?? ""), 40)}"`, { pattern: input.pattern, path: input.path, glob: input.glob }); + case "Glob": + case "LS": + return f("glob", `\u67E5\u627E ${trunc(String(input.pattern ?? input.path ?? ""), 40)}`, { pattern: input.pattern, path: input.path }); + case "WebFetch": + return f("web", `\u6253\u5F00 ${hostOf(String(input.url ?? ""))}`, { url: input.url }); + case "WebSearch": + return f("web", `\u641C\u7D22\u7F51\u9875 "${trunc(String(input.query ?? ""), 40)}"`, { query: input.query }); + case "TodoWrite": { + const items = todoItems(input.todos ?? []); + const done = items.filter((x) => x.status === "done").length; + return f("todo", `\u66F4\u65B0\u5F85\u529E\uFF08${done}/${items.length}\uFF09`, { items }); + } + case "Task": + case "Agent": + return f("task", `\u5B50\u4EFB\u52A1\uFF1A${trunc(String(input.description ?? input.prompt ?? ""), 40)}`, { description: input.description }); + default: + break; + } + const m = /^mcp__([^_]+)__(.+)$/.exec(t); + if (m) return f("mcp", `${m[1]} \xB7 ${m[2]}`, { server: m[1], tool: m[2] }); + return f("other", t || "\u5DE5\u5177", {}); +} +function hostOf(url) { + try { + return new URL(url).host; + } catch { + return String(url || "").slice(0, 40); + } +} +function todoItems(todos = []) { + return todos.map((x) => ({ + text: x.content || x.text || "", + status: x.status === "completed" ? "done" : x.status === "in_progress" ? "in_progress" : "pending" + })); +} +var MAX_CMD, trunc; +var init_kinds = __esm({ + "daemon/src/agents/kinds.ts"() { + MAX_CMD = 80; + trunc = (s, n = MAX_CMD) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s; + } +}); + +// daemon/src/providers/claude/map.ts +function newMapState() { + return { blocks: /* @__PURE__ */ new Map(), msgId: "", itemIdByBlock: /* @__PURE__ */ new Map(), typeSeq: /* @__PURE__ */ new Map(), sawInterrupt: false }; +} +function itemIdFor(st, blockIdx, blkType) { + const k = blockKind(blkType); + const n = st.typeSeq.get(k) ?? 0; + st.typeSeq.set(k, n + 1); + const id = `${st.msgId}:${k}${n}`; + st.itemIdByBlock.set(blockIdx, id); + return id; +} +function mapSdkMessage(m, st, cwd) { + const out = []; + if (!m || typeof m !== "object") return out; + const r = m; + if (r.type === "system") { + if (r.subtype === "init") { + const sid = str(r.session_id) ?? ""; + if (sid) out.push({ type: "provider_session_id", id: sid }); + } + if (r.subtype === "api_retry") { + out.push({ type: "notice", level: "warn", message: `API \u91CD\u8BD5\u4E2D\uFF08\u7B2C ${r.attempt ?? "?"} \u6B21\uFF09` }); + } + if (r.subtype === "compact_boundary") { + out.push({ type: "notice", level: "info", message: "\u4E0A\u4E0B\u6587\u5DF2\u538B\u7F29" }); + } + return out; + } + if (r.type === "stream_event") { + const e = rec(r.event); + if (e.type === "message_start") { + const mid = str(rec(e.message).id); + if (mid) st.msgId = mid; + st.blocks.clear(); + st.itemIdByBlock.clear(); + st.typeSeq.clear(); + return out; + } + if (e.type === "content_block_start") { + const idx = typeof e.index === "number" ? e.index : 0; + const bt = str(rec(e.content_block).type) ?? ""; + st.blocks.set(idx, bt); + itemIdFor(st, idx, bt); + return out; + } + if (e.type === "content_block_delta") { + const idx = typeof e.index === "number" ? e.index : 0; + const d = rec(e.delta); + const itemId = st.itemIdByBlock.get(idx) ?? itemIdFor(st, idx, d.type === "thinking_delta" ? "thinking" : "text"); + if (d.type === "text_delta" && typeof d.text === "string" && d.text) { + out.push({ type: "assistant_delta", itemId, text: d.text }); + } else if (d.type === "thinking_delta") { + const t = str(d.thinking) ?? str(d.text); + if (t) out.push({ type: "reasoning_delta", itemId, text: t }); + } + } + return out; + } + if (r.type === "assistant") { + const mid = str(rec(r.message).id) ?? st.msgId ?? ""; + const content = rec(r.message).content; + const parts = Array.isArray(content) ? content : []; + const seqs = /* @__PURE__ */ new Map(); + parts.forEach((p) => { + const blk = rec(p); + const t = str(blk.type) ?? ""; + const k = blockKind(t); + const n = seqs.get(k) ?? 0; + seqs.set(k, n + 1); + const itemId = `${mid}:${k}${n}`; + if (blk.type === "text" && typeof blk.text === "string" && blk.text) { + out.push({ type: "assistant_message", itemId, text: blk.text }); + } else if (blk.type === "thinking" || blk.type === "redacted_thinking") { + const t2 = str(blk.thinking) ?? str(blk.text); + if (t2) out.push({ type: "reasoning", itemId, text: t2 }); + } else if (blk.type === "tool_use") { + out.push(...mapToolUse(blk, cwd)); + } + }); + return out; + } + if (r.type === "user") { + const content = rec(r.message).content; + const parts = Array.isArray(content) ? content : []; + for (const p of parts) { + const blk = rec(p); + if (blk.type === "tool_result") { + const output = clean(contentText(blk.content)); + out.push({ + type: "tool_call", + itemId: str(blk.tool_use_id) ?? "", + kind: "other", + title: "", + status: blk.is_error ? "failed" : "ok", + output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output + }); + } + } + return out; + } + if (r.type === "result") { + const usage = rec(r.usage); + const num = (k) => typeof usage[k] === "number" ? usage[k] : 0; + out.push({ + type: "usage", + inputTokens: num("input_tokens"), + outputTokens: num("output_tokens"), + cacheReadTokens: num("cache_read_input_tokens"), + costUsd: typeof r.total_cost_usd === "number" ? r.total_cost_usd : 0 + }); + const subtype = str(r.subtype) ?? ""; + const interrupted = st.sawInterrupt || /interrupt/i.test(subtype); + const isError = r.is_error === true; + out.push({ + type: "turn_ended", + turnId: "", + // The session fills in the current turnId + status: interrupted ? "interrupted" : isError ? "failed" : "completed", + durationMs: typeof r.duration_ms === "number" ? r.duration_ms : void 0, + error: isError && !interrupted ? str(r.result) ?? subtype : void 0, + filesChanged: typeof r.num_turns === "number" ? void 0 : void 0 + }); + st.sawInterrupt = false; + return out; + } + return out; +} +function mapToolUse(blk, cwd) { + const input = rec(blk.input); + const name = str(blk.name) ?? "tool"; + const id = str(blk.id) ?? ""; + const info = callInfo(name, { ...input, _exists: true }, cwd); + let detail = info.detail; + if (info.kind === "edit" && (input.old_string !== void 0 || Array.isArray(input.edits))) { + detail = editDetail({ path: info.detail.path, old: input.old_string, new: input.new_string, edits: input.edits }); + } + if (info.kind === "write" && typeof input.content === "string") { + const lines = input.content.split("\n"); + detail = { path: info.detail.path, lines: lines.slice(0, 200), totalLines: lines.length }; + } + const out = [ + { + type: "tool_call", + itemId: id, + kind: info.kind, + title: info.title, + status: "running", + input, + detail + } + ]; + if (name === "TodoWrite") { + out.push({ type: "todo", items: todoItems(input.todos ?? []) }); + } + return out; +} +function contentText(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((p) => str(rec(p).text) ?? "").join(""); + return ""; +} +var MAX_OUTPUT, ANSI_RE, clean, blockKind, rec, str; +var init_map = __esm({ + "daemon/src/providers/claude/map.ts"() { + init_kinds(); + init_diff(); + MAX_OUTPUT = 20 * 1024; + ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g; + clean = (s) => String(s ?? "").replace(ANSI_RE, ""); + blockKind = (t) => t === "thinking" || t === "redacted_thinking" ? "k" : t === "text" ? "t" : "x"; + rec = (v) => v && typeof v === "object" ? v : {}; + str = (v) => typeof v === "string" ? v : void 0; + } +}); + +// daemon/src/util/i18n.ts +function normalizeLang(raw) { + const s = String(raw ?? "").trim().toLowerCase(); + if (!s) return "zh"; + if (s === "zh" || s.startsWith("zh-")) return "zh"; + return "en"; +} +function setAppLocale(lang) { + locale = normalizeLang(lang); +} +function tr(key, params) { + let s = dict[locale][key] ?? dict.en[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v)); + } + return s; +} +var locale, dict; +var init_i18n = __esm({ + "daemon/src/util/i18n.ts"() { + locale = "zh"; + dict = { + zh: { + "thread.defaultTitle": "\u65B0\u5BF9\u8BDD", + "notice.daemonRestart": "\u540E\u53F0\u5DF2\u91CD\u542F\uFF0C\u672C\u8F6E\u5DF2\u4E2D\u65AD", + "notice.sessionStartFailed": "\u4F1A\u8BDD\u542F\u52A8\u5931\u8D25\uFF1A{detail}", + "notice.sendFailed": "\u53D1\u9001\u5931\u8D25\uFF1A{detail}", + "notice.effortNextTurn": "\u601D\u8003\u5F3A\u5EA6\u5DF2\u4FDD\u5B58\uFF0C\u5C06\u5728\u4E0B\u4E00\u8F6E\u751F\u6548", + "notice.effortResetOnModel": "\u5F53\u524D\u6A21\u578B\u4E0D\u652F\u6301\u8BE5\u601D\u8003\u5F3A\u5EA6\uFF0C\u5DF2\u6062\u590D\u4E3A Default", + "turn.agentExit": "Agent \u8FDB\u7A0B\u610F\u5916\u9000\u51FA", + "attachment.missing": "\u9644\u4EF6\u4E0D\u5B58\u5728\u6216\u5DF2\u5931\u6548\uFF08{ref}\uFF09\uFF0C\u8BF7\u91CD\u65B0\u4E0A\u4F20", + "notice.acpImageByPath": "{agent} \u4E0D\u80FD\u76F4\u63A5\u770B\u56FE\u7247\uFF08\u5B83\u7684 ACP \u521D\u59CB\u5316\u672A\u58F0\u660E\u652F\u6301\u56FE\u7247\u8F93\u5165\uFF09\uFF0C\u5DF2\u6539\u4E3A\u53D1\u9001\u56FE\u7247\u7684\u6587\u4EF6\u8DEF\u5F84\uFF1A{paths}", + "notice.acpImageByPathSwitched": "{agent} \u662F\u5728\u53E6\u4E00\u4E2A\u6A21\u578B\u4E0A\u542F\u52A8\u7684\uFF0C\u53EA\u5BF9\u90A3\u4E2A\u6A21\u578B\u63A5\u6536\u56FE\u7247\uFF0C\u8FD9\u6B21\u6539\u4E3A\u53D1\u9001\u56FE\u7247\u7684\u6587\u4EF6\u8DEF\u5F84\uFF08\u5F53\u524D\u6A21\u578B\u80FD\u770B\u56FE\u65F6\u5B83\u4F1A\u7528 read_image \u6253\u5F00\uFF09\uFF1A{paths}", + "notice.acpSessionNotRestored": "{agent} \u4E0D\u80FD\u6062\u590D\u4E0A\u4E00\u8F6E\u5BF9\u8BDD\uFF08\u5B83\u7684 ACP \u65E2\u4E0D\u652F\u6301 session/load \u4E5F\u4E0D\u652F\u6301 session/resume\uFF09\uFF0C\u5DF2\u65B0\u5F00\u4E00\u8F6E\uFF1B\u5148\u524D\u7684\u4E0A\u4E0B\u6587\u4E0D\u4F1A\u5E26\u56DE\u3002", + "notice.attachmentIssue": "\u9644\u4EF6\u95EE\u9898\uFF1A{detail}", + "claude.notFound": "\u627E\u4E0D\u5230 claude\uFF08{detail}\uFF09\u3002\u672C\u63D2\u4EF6\u4E0D\u9644\u5E26 Claude Code\uFF0C\u8BF7\u5148\u5B89\u88C5\uFF1A{hint}", + "claude.startFailed": "\u65E0\u6CD5\u542F\u52A8 claude\uFF1A{detail}", + "claude.exited": "claude \u8FDB\u7A0B\u610F\u5916\u9000\u51FA\uFF08{code}\uFF09", + "claude.notRunning": "claude \u6CA1\u6709\u5728\u8FD0\u884C\uFF0C\u6D88\u606F\u672A\u53D1\u51FA", + "claude.closed": "\u4F1A\u8BDD\u5DF2\u5173\u95ED", + "export.defaultModel": "\u9ED8\u8BA4" + }, + en: { + "thread.defaultTitle": "New chat", + "notice.daemonRestart": "Daemon restarted \u2014 this turn was interrupted", + "notice.sessionStartFailed": "Session start failed: {detail}", + "notice.sendFailed": "Send failed: {detail}", + "notice.effortNextTurn": "Reasoning effort saved \u2014 takes effect on the next turn", + "notice.effortResetOnModel": "This model does not support that effort level; reset to Default", + "turn.agentExit": "Agent process exited unexpectedly", + "attachment.missing": "Attachment missing or expired ({ref}) \u2014 upload again", + "notice.acpImageByPath": "{agent} can't see images directly (its ACP initialize doesn't advertise image input); sent the image file path instead: {paths}", + "notice.acpImageByPathSwitched": "{agent} was started on another model and takes image blocks only for that one, so the image went as its file path (it opens it with read_image when this model sees images): {paths}", + "notice.acpSessionNotRestored": "{agent} can't restore the previous conversation (its ACP advertises neither session/load nor session/resume); started a new one \u2014 prior context won't come back.", + "notice.attachmentIssue": "Attachment issue: {detail}", + "claude.notFound": "claude was not found ({detail}). Claude Code is not bundled with this plugin; install it first: {hint}", + "claude.startFailed": "Could not start claude: {detail}", + "claude.exited": "claude exited unexpectedly ({code})", + "claude.notRunning": "claude is not running; the message was not sent", + "claude.closed": "The session is closed", + "export.defaultModel": "Default" + } + }; + } +}); + +// daemon/src/util/attachmentRefs.ts +var attachmentRefs_exports = {}; +__export(attachmentRefs_exports, { + bindAttachmentStore: () => bindAttachmentStore, + resolveAttachmentRefs: () => resolveAttachmentRefs, + resolveImagePath: () => resolveImagePath +}); +import { existsSync as existsSync5 } from "node:fs"; +function bindAttachmentStore(store) { + globalAttachmentStore = store; +} +async function lookupAttachmentPath(ref) { + const store = globalAttachmentStore; + if (!store || !validId(ref)) return null; + const att = await store.get(ref); + return att?.path ?? null; +} +async function resolveAttachmentRefs(store, refs) { + const out = []; + for (const a of refs) { + const att = validId(a.path) ? await store.get(a.path) : null; + if (att) { + out.push({ path: att.path, mime: att.mime || a.mime }); + continue; + } + const viaGlobal = await lookupAttachmentPath(a.path); + if (viaGlobal) { + out.push({ path: viaGlobal, mime: a.mime }); + continue; + } + if (existsSync5(a.path)) { + out.push(a); + continue; + } + throw new Error(tr("attachment.missing", { ref: a.path })); + } + return out; +} +async function resolveImagePath(ref) { + if (existsSync5(ref)) return ref; + const p = await lookupAttachmentPath(ref); + if (p) return p; + throw new Error(tr("attachment.missing", { ref })); +} +var globalAttachmentStore; +var init_attachmentRefs = __esm({ + "daemon/src/util/attachmentRefs.ts"() { + init_paths(); + init_i18n(); + globalAttachmentStore = null; + } +}); + +// daemon/src/util/attachmentKinds.ts +function baseMime(mime) { + return String(mime ?? "").split(";")[0].trim().toLowerCase(); +} +function attachmentKind(mime) { + const m = baseMime(mime); + if (MODEL_IMAGE_MIMES.has(m)) return "image"; + if (m.startsWith("text/") || TEXT_APP.has(m)) return "text"; + return "other"; +} +function pathNote(kind, file) { + return `${kind === "image" ? "Attached image" : "Attached file"}: ${file}`; +} +var MODEL_IMAGE_MIMES, TEXT_APP; +var init_attachmentKinds = __esm({ + "daemon/src/util/attachmentKinds.ts"() { + MODEL_IMAGE_MIMES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); + TEXT_APP = /* @__PURE__ */ new Set(["application/json", "application/xml", "application/yaml", "application/x-yaml", "application/toml", "application/javascript"]); + } +}); + +// daemon/src/providers/claude/content.ts +import { readFile as readFile4, stat as stat3 } from "node:fs/promises"; +import { basename } from "node:path"; +async function buildClaudeContent(text, attachments) { + const media = []; + const notes = []; + const warnings = []; + for (const a of attachments) { + let file; + let size; + try { + file = await resolveImagePath(a.path); + size = (await stat3(file)).size; + } catch (e) { + warnings.push(`${a.path}: ${String(e?.message ?? e)}`); + continue; + } + const kind = attachmentKind(a.mime); + try { + if (kind === "image" && size <= CLAUDE_MAX_IMAGE_BYTES) { + const data = (await readFile4(file)).toString("base64"); + media.push({ type: "image", source: { type: "base64", media_type: baseMime(a.mime), data } }); + continue; + } + if (kind === "text" && size <= CLAUDE_MAX_TEXT_BYTES) { + const data = await readFile4(file, "utf8"); + media.push({ type: "document", source: { type: "text", media_type: "text/plain", data }, title: basename(file), context: file }); + continue; + } + } catch (e) { + warnings.push(`${basename(file)}: ${String(e?.message ?? e)}`); + } + notes.push(pathNote(kind, file)); + if (kind === "other") warnings.push(`${basename(file)} (${baseMime(a.mime) || "unknown type"}) is not an image or a text file; sent its path`); + else if (kind === "image" && size > CLAUDE_MAX_IMAGE_BYTES) warnings.push(`${basename(file)}: ${(size / 1024 / 1024).toFixed(1)} MB, over Claude's 5 MB image limit; sent its path`); + else if (kind === "text" && size > CLAUDE_MAX_TEXT_BYTES) warnings.push(`${basename(file)}: ${(size / 1024 / 1024).toFixed(1)} MB, too large to attach inline; sent its path`); + } + const fullText = [text, ...notes.length ? [notes.join("\n")] : []].filter(Boolean).join("\n\n"); + return { blocks: [...media, { type: "text", text: fullText }], warnings }; +} +var CLAUDE_MAX_IMAGE_BYTES, CLAUDE_MAX_TEXT_BYTES; +var init_content = __esm({ + "daemon/src/providers/claude/content.ts"() { + init_attachmentRefs(); + init_attachmentKinds(); + CLAUDE_MAX_IMAGE_BYTES = 5 * 1024 * 1024; + CLAUDE_MAX_TEXT_BYTES = 1024 * 1024; + } +}); + +// daemon/src/providers/claude/cli.ts +function claudeArgs(o) { + const args = [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--permission-prompt-tool", + "stdio", + "--setting-sources", + "user,project,local" + ]; + const put = (flag, v) => { + if (!v) return; + if (!SAFE_ARG.test(v)) throw new Error(`Refusing to pass an unexpected value to claude ${flag}: ${JSON.stringify(v).slice(0, 80)}`); + args.push(flag, v); + }; + put("--permission-mode", o.permissionMode); + if (o.permissionMode === "bypassPermissions") args.push("--allow-dangerously-skip-permissions"); + put("--model", o.model); + put("--effort", o.effort); + put("--resume", o.resume); + return args; +} +var SAFE_ARG, ClaudeCli; +var init_cli = __esm({ + "daemon/src/providers/claude/cli.ts"() { + init_bin(); + init_queue(); + SAFE_ARG = /^[A-Za-z0-9][A-Za-z0-9._:\[\]\/@+-]{0,199}$/; + ClaudeCli = class { + constructor(o, canUseTool) { + this.o = o; + this.canUseTool = canUseTool; + const args = claudeArgs(o); + this.child = spawnBin(o.bin, args, { + cwd: o.cwd, + env: { ...o.env ?? process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, + stdio: ["pipe", "pipe", "pipe"], + group: true + }); + this.child.stdout?.setEncoding("utf8"); + this.child.stdout?.on("data", (d) => this.onData(d)); + this.child.stderr?.on("data", (d) => { + this.stderrTail = (this.stderrTail + d.toString()).slice(-4096); + }); + this.child.stdin?.on("error", () => { + }); + this.exited = new Promise((resolve2) => { + this.child.on("error", (e) => { + this.spawnError = e; + this.finish(); + resolve2({ code: null, signal: null }); + }); + this.child.on("close", (code, signal) => { + this.finish(); + resolve2({ code, signal }); + }); + }); + this.initialized = this.request({ subtype: "initialize" }, 6e4).catch(() => null); + } + o; + canUseTool; + /** Conversation messages (everything that is not a control message) */ + messages = new AsyncQueue(); + exited; + /** The answer to initialize (commands, models, account …), null when it failed */ + initialized; + child; + buf = ""; + seq = 0; + pending = /* @__PURE__ */ new Map(); + cancels = /* @__PURE__ */ new Map(); + stderrTail = ""; + dead = false; + spawnError = null; + get stderr() { + return this.stderrTail; + } + get failure() { + return this.spawnError; + } + get alive() { + return !this.dead; + } + finish() { + if (this.dead) return; + this.dead = true; + for (const [, p] of this.pending) { + if (p.timer) clearTimeout(p.timer); + p.reject(new Error("claude exited")); + } + this.pending.clear(); + for (const [, c] of this.cancels) c.abort(); + this.cancels.clear(); + this.messages.close(); + } + onData(d) { + this.buf += d; + let i; + while ((i = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, i).trim(); + this.buf = this.buf.slice(i + 1); + if (!line) continue; + let m; + try { + m = JSON.parse(line); + } catch { + continue; + } + if (m && typeof m === "object") this.onMessage(m); + } + } + onMessage(m) { + if (m.type === "control_response") { + const r = m.response ?? {}; + const id = String(r.request_id ?? ""); + const p = this.pending.get(id); + if (!p) return; + this.pending.delete(id); + if (p.timer) clearTimeout(p.timer); + if (r.subtype === "success") p.resolve(r.response ?? {}); + else p.reject(new Error(String(r.error ?? "control request failed"))); + return; + } + if (m.type === "control_request") { + void this.onControlRequest(String(m.request_id ?? ""), m.request ?? {}); + return; + } + if (m.type === "control_cancel_request") { + const id = String(m.request_id ?? ""); + this.cancels.get(id)?.abort(); + this.cancels.delete(id); + return; + } + if (m.type === "keep_alive") return; + this.messages.push(m); + } + async onControlRequest(id, req) { + if (req.subtype !== "can_use_tool") { + this.write({ type: "control_response", response: { subtype: "error", request_id: id, error: `Unsupported control request: ${String(req.subtype)}` } }); + return; + } + const ac = new AbortController(); + this.cancels.set(id, ac); + try { + const decision = await this.canUseTool({ + toolName: String(req.tool_name ?? ""), + input: req.input && typeof req.input === "object" ? req.input : {}, + toolUseId: typeof req.tool_use_id === "string" ? req.tool_use_id : void 0, + signal: ac.signal + }); + if (ac.signal.aborted && !this.cancels.has(id)) return; + this.write({ type: "control_response", response: { subtype: "success", request_id: id, response: decision } }); + } catch (e) { + this.write({ type: "control_response", response: { subtype: "error", request_id: id, error: String(e?.message ?? e) } }); + } finally { + this.cancels.delete(id); + } + } + write(o) { + if (this.dead || !this.child.stdin || this.child.stdin.destroyed) return false; + try { + this.child.stdin.write(JSON.stringify(o) + "\n"); + return true; + } catch { + return false; + } + } + /** Send one control request and wait for its answer */ + request(request, timeoutMs = 1e4) { + if (this.dead) return Promise.reject(new Error("claude is not running")); + const id = `ac_${++this.seq}_${Math.random().toString(36).slice(2, 8)}`; + return new Promise((resolve2, reject) => { + const p = { resolve: resolve2, reject }; + p.timer = setTimeout(() => { + if (this.pending.delete(id)) reject(new Error(`claude did not answer ${String(request.subtype)} in ${Math.round(timeoutMs / 1e3)} s`)); + }, timeoutMs); + this.pending.set(id, p); + if (!this.write({ type: "control_request", request_id: id, request })) { + this.pending.delete(id); + clearTimeout(p.timer); + reject(new Error("claude is not running")); + } + }); + } + /** One user message into the conversation */ + sendUser(content, sessionId) { + return this.write({ type: "user", message: { role: "user", content }, parent_tool_use_id: null, session_id: sessionId ?? "" }); + } + interrupt() { + return this.request({ subtype: "interrupt" }, 5e3); + } + setModel(model) { + return this.request(model ? { subtype: "set_model", model } : { subtype: "set_model" }); + } + setPermissionMode(mode) { + return this.request({ subtype: "set_permission_mode", mode }); + } + /** End stdin (the CLI finishes and exits), then end the process tree if it lingers */ + async close(graceMs = 2e3) { + try { + this.child.stdin?.end(); + } catch { + } + const done = await Promise.race([this.exited.then(() => true), new Promise((r) => setTimeout(() => r(false), graceMs))]); + if (!done) await killTree(this.child, { graceMs: 500 }); + this.finish(); + } + }; + } +}); + +// daemon/src/providers/claude/session.ts +var EFFORTS, MODES, rid2, ClaudeSession; +var init_session = __esm({ + "daemon/src/providers/claude/session.ts"() { + init_queue(); + init_kinds(); + init_diff(); + init_map(); + init_log(); + init_content(); + init_i18n(); + init_slash(); + init_cli(); + EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + MODES = ["plan", "default", "acceptEdits", "bypassPermissions"]; + rid2 = () => Math.random().toString(36).slice(2, 10); + ClaudeSession = class { + constructor(o) { + this.o = o; + this.sessionId = o.resumeId; + this.events = this.q; + } + o; + events; + q = new AsyncQueue(); + cli = null; + sessionId; + turnId = null; + turnNum = 0; + st = newMapState(); + pending = /* @__PURE__ */ new Map(); + needsRestart = false; + restarting = null; + closed = false; + stopping = /* @__PURE__ */ new WeakSet(); + pumpDone = null; + /** Mode the running CLI was started with (bypassPermissions has to be allowed at launch) */ + launchedMode = null; + // The CLI is only started before the first send (or a command list request) + ensureCli() { + if (this.cli && !this.needsRestart) return this.cli; + this.st = newMapState(); + const effort = this.o.effort && EFFORTS.includes(this.o.effort) ? this.o.effort : null; + const cli = new ClaudeCli( + { + bin: this.o.bin, + cwd: this.o.cwd, + model: this.o.model, + permissionMode: MODES.includes(this.o.mode) ? this.o.mode : "default", + effort, + resume: this.sessionId, + env: this.o.env + }, + (req) => this.canUseTool(req) + ); + this.cli = cli; + this.launchedMode = this.o.mode; + this.needsRestart = false; + this.pumpDone = this.pump(cli); + return cli; + } + async pump(cli) { + for await (const m of cli.messages) { + for (const ev of mapSdkMessage(m, this.st, this.o.cwd)) this.emit(ev); + } + const exit = await cli.exited; + if (this.closed || this.stopping.has(cli)) return; + const detail = cli.failure ? tr("claude.startFailed", { detail: String(cli.failure.message) }) : tr("claude.exited", { code: String(exit.code ?? exit.signal ?? "?") }); + const tail = cli.stderr.trim().slice(-400); + this.emit({ type: "notice", level: "error", message: tail ? `${detail} \u2014 stderr: ${tail}` : detail }); + if (this.turnId) this.emit({ type: "turn_ended", turnId: this.turnId, status: "failed", error: detail }); + this.turnId = null; + if (this.cli === cli) { + this.needsRestart = true; + this.q.close(); + } + } + emit(ev) { + if (ev.type === "turn_ended" && !ev.turnId) ev.turnId = this.turnId ?? "unknown"; + if (ev.type === "turn_ended") this.turnId = null; + if (ev.type === "provider_session_id" && ev.id) this.sessionId = ev.id; + this.q.push(ev); + } + async send(input) { + if (this.closed) throw new Error(tr("claude.closed")); + if (this.needsRestart) await this.restart(); + const cli = this.ensureCli(); + this.turnNum++; + this.turnId = "u_" + Date.now().toString(36) + "_" + this.turnNum; + this.st.sawInterrupt = false; + this.emit({ + type: "turn_started", + turnId: this.turnId, + model: this.o.model, + mode: this.o.mode, + effort: this.o.effort + }); + const built = await buildClaudeContent(input.text, input.images ?? []); + for (const w of built.warnings) this.emit({ type: "notice", level: "warn", message: tr("notice.attachmentIssue", { detail: w }) }); + if (!cli.sendUser(built.blocks, this.sessionId)) throw new Error(tr("claude.notRunning")); + } + async restart() { + if (this.restarting) return this.restarting; + this.restarting = (async () => { + const old = this.cli; + this.cli = null; + if (old) { + this.stopping.add(old); + await old.close().catch(() => { + }); + } + if (this.pumpDone) await this.pumpDone.catch(() => { + }); + try { + this.ensureCli(); + } finally { + this.restarting = null; + } + })(); + return this.restarting; + } + // ---- permissions (§7.2.4) ---- + canUseTool(req) { + const { toolName, input, signal } = req; + const requestId = "perm_" + rid2(); + const opts = { toolUseID: req.toolUseId }; + if (toolName === "ExitPlanMode") { + return this.handlePlanMode(requestId, input, signal, opts.toolUseID); + } + if (toolName === "AskUserQuestion" && Array.isArray(input.questions)) { + return this.handleQuestion(requestId, input, signal); + } + const info = callInfo(toolName, { ...input, _exists: true }, this.o.cwd); + let detail = { ...info.detail, toolName }; + if (info.kind === "edit" && (input.old_string !== void 0 || Array.isArray(input.edits))) { + detail = { ...editDetail({ path: info.detail.path, old: input.old_string, new: input.new_string, edits: input.edits }), toolName }; + } + this.emit({ + type: "permission_request", + requestId, + title: info.title, + kind: "tool", + detail, + options: [ + { id: "allow", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "allow_always", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "deny", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + return new Promise((resolve2) => { + this.pending.set(requestId, { kind: "permission", resolve: (a) => resolve2(this.toPermissionDecision(a, input)) }); + signal.addEventListener("abort", () => { + if (this.pending.delete(requestId)) { + this.emit({ type: "permission_resolved", requestId, cancelled: true }); + resolve2({ behavior: "deny", message: "\u5DF2\u53D6\u6D88", interrupt: true }); + } + }, { once: true }); + }); + } + toPermissionDecision(a, input) { + if (a.cancelled || a.optionId === "deny") { + return { behavior: "deny", message: "\u7528\u6237\u62D2\u7EDD\u4E86\u6B64\u64CD\u4F5C", ...a.cancelled ? { interrupt: true } : {} }; + } + return { behavior: "allow", updatedInput: input }; + } + async handlePlanMode(requestId, input, signal, toolUseID) { + const planText = typeof input.plan === "string" ? input.plan : ""; + if (planText) { + this.emit({ type: "plan", itemId: toolUseID ?? requestId, markdown: planText }); + } + this.emit({ + type: "permission_request", + requestId, + title: "Agent \u63D0\u4EA4\u4E86\u8BA1\u5212", + kind: "plan", + detail: { plan: planText }, + options: [ + { id: "implement", label: "\u5B9E\u65BD", kind: "allow_once" }, + { id: "reject", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const answer = await new Promise((resolve2) => { + this.pending.set(requestId, { kind: "permission", resolve: (a) => resolve2(a) }); + signal.addEventListener("abort", () => { + if (this.pending.delete(requestId)) { + this.emit({ type: "permission_resolved", requestId, cancelled: true }); + resolve2({ cancelled: true }); + } + }, { once: true }); + }); + if (answer.cancelled || answer.optionId === "reject") { + return { behavior: "deny", message: "\u7528\u6237\u62D2\u7EDD\u4E86\u8BA1\u5212\uFF0C\u8BF7\u6839\u636E\u53CD\u9988\u4FEE\u6539", ...answer.cancelled ? { interrupt: true } : {} }; + } + try { + await this.cli?.setPermissionMode("default"); + this.o.mode = "default"; + this.o.onModeChange?.("default"); + } catch (e) { + log("warn", "setPermissionMode(default) \u5931\u8D25", { err: String(e) }); + } + return { behavior: "allow", updatedInput: input }; + } + async handleQuestion(requestId, input, signal) { + this.emit({ type: "question", requestId, questions: input.questions }); + const answer = await new Promise((resolve2) => { + this.pending.set(requestId, { kind: "question", resolve: (a) => resolve2(a) }); + signal.addEventListener("abort", () => { + if (this.pending.delete(requestId)) { + this.emit({ type: "permission_resolved", requestId, cancelled: true }); + resolve2({ cancelled: true }); + } + }, { once: true }); + }); + if (answer.cancelled) { + return { behavior: "deny", message: "\u7528\u6237\u53D6\u6D88\u4E86\u63D0\u95EE", interrupt: true }; + } + const questions = input.questions ?? []; + const normalized = {}; + for (const ques of questions) { + const text = ques.question; + if (!text) continue; + const v = answer.answers?.[text] ?? (ques.header ? answer.answers?.[ques.header] : void 0); + if (v) normalized[text] = v; + } + return { behavior: "allow", updatedInput: { ...input, answers: normalized } }; + } + respond(requestId, answer) { + const p = this.pending.get(requestId); + if (p) { + this.pending.delete(requestId); + p.resolve(answer); + } + } + async interrupt() { + this.st.sawInterrupt = true; + for (const [id, p] of this.pending) { + this.emit({ type: "permission_resolved", requestId: id, cancelled: true }); + p.resolve({ cancelled: true }); + } + this.pending.clear(); + const cli = this.cli; + if (!cli || !cli.alive) return; + try { + await cli.interrupt(); + } catch (e) { + log("warn", "claude interrupt failed", { err: String(e) }); + this.stopping.add(cli); + this.needsRestart = true; + void cli.close(500); + if (this.turnId) this.emit({ type: "turn_ended", turnId: this.turnId, status: "interrupted" }); + } + } + async setModel(model) { + this.o.model = model; + if (!this.cli?.alive) return; + try { + await this.cli.setModel(model); + } catch (e) { + log("warn", "claude set_model failed", { err: String(e) }); + this.needsRestart = true; + } + } + async setMode(mode) { + this.o.mode = mode; + if (!this.cli?.alive) return; + if (mode === "bypassPermissions" && this.launchedMode !== "bypassPermissions") { + this.needsRestart = true; + return; + } + try { + await this.cli.setPermissionMode(mode); + } catch (e) { + log("warn", "claude set_permission_mode failed", { err: String(e) }); + this.needsRestart = true; + } + } + async setEffort(effort) { + this.o.effort = effort; + this.needsRestart = true; + } + async listCommands() { + try { + const init = await this.ensureCli().initialized; + const commands = Array.isArray(init?.commands) ? init.commands : []; + const map = /* @__PURE__ */ new Map(); + for (const cmd of commands) { + if (cmd?.name && !map.has(cmd.name)) { + map.set(cmd.name, { + name: cmd.name, + description: cmd.description ?? "", + argumentHint: cmd.argumentHint, + kind: classifyClaudeSlashCommand(cmd.name) + }); + } + } + return sortSlashCommands([...map.values()]); + } catch { + return []; + } + } + async close() { + this.closed = true; + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + const cli = this.cli; + if (cli) { + this.stopping.add(cli); + await cli.close().catch(() => { + }); + } + if (this.pumpDone) { + await Promise.race([this.pumpDone.catch(() => { + }), new Promise((r) => setTimeout(r, 3e3))]); + } + this.q.close(); + } + }; + } +}); + +// daemon/src/providers/claude/models.ts +async function listClaudeModels() { + return []; +} +var init_models = __esm({ + "daemon/src/providers/claude/models.ts"() { + } +}); + +// daemon/src/history/import.ts +import { readdir, readFile as readFile5, stat as stat4 } from "node:fs/promises"; +import { existsSync as existsSync6 } from "node:fs"; +import { join as join6 } from "node:path"; +import { homedir as homedir3 } from "node:os"; +function parseLine(line) { + let r; + try { + r = rec2(JSON.parse(line)); + } catch { + return null; + } + const cwd = str2(r.cwd); + let userText; + if (r.type === "user") { + const content = rec2(r.message).content; + if (typeof content === "string") userText = content; + else if (Array.isArray(content)) { + const t = content.map((p) => rec2(p).type === "text" ? str2(rec2(p).text) : void 0).find(Boolean); + userText = t; + } + } + return { cwd, userText }; +} +async function* linesOf(path3) { + const text = await readFile5(path3, "utf8").catch(() => ""); + for (const line of text.split("\n")) { + if (line.trim()) yield line; + } +} +async function listClaudeHistory(cwd) { + const out = []; + const dirs = await readdir(CLAUDE_ROOT).catch(() => []); + for (const d of dirs) { + const dirPath = join6(CLAUDE_ROOT, d); + const files = (await readdir(dirPath).catch(() => [])).filter((f) => f.endsWith(".jsonl")); + for (const f of files) { + const path3 = join6(dirPath, f); + let match = false; + let title = ""; + let count = 0; + for await (const line of linesOf(path3)) { + const p = parseLine(line); + if (!p) continue; + if (!match) { + if (!p.cwd) continue; + match = samePath(p.cwd, cwd); + if (!match) break; + } + if (p.userText && !title) title = p.userText.slice(0, 40); + if (p.userText) count++; + } + if (!match) continue; + const s = await stat4(path3).catch(() => null); + out.push({ + sessionId: f.replace(/\.jsonl$/, ""), + title: title || "\uFF08\u65E0\u6807\u9898\uFF09", + updatedAt: s ? Math.round(s.mtimeMs) : 0, + messageCount: count + }); + } + } + out.sort((a, b) => b.updatedAt - a.updatedAt); + return out; +} +async function readClaudeHistory(sessionId, cwd) { + const dirs = await readdir(CLAUDE_ROOT).catch(() => []); + for (const d of dirs) { + const path3 = join6(CLAUDE_ROOT, d, `${sessionId}.jsonl`); + if (!existsSync6(path3)) continue; + const msgs = []; + let matched = false; + for await (const line of linesOf(path3)) { + const p = parseLine(line); + if (!matched) { + if (p?.cwd && samePath(p.cwd, cwd)) matched = true; + else continue; + } + let r; + try { + r = rec2(JSON.parse(line)); + } catch { + continue; + } + if (r.type === "user" || r.type === "assistant") msgs.push(r); + } + return msgs; + } + return []; +} +var CLAUDE_ROOT, rec2, str2; +var init_import = __esm({ + "daemon/src/history/import.ts"() { + init_paths(); + CLAUDE_ROOT = join6(homedir3(), ".claude", "projects"); + rec2 = (v) => v && typeof v === "object" ? v : {}; + str2 = (v) => typeof v === "string" ? v : void 0; + } +}); + +// daemon/src/providers/claude/provider.ts +function runVersion(r, args, timeoutMs = 5e3) { + return new Promise((resolvePromise, reject) => { + let out = ""; + const p = spawnBin(r, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" } }); + const t = setTimeout(() => { + try { + void killTree(p, { graceMs: 500 }); + } catch { + } + reject(new Error("timeout")); + }, timeoutMs); + p.stdout?.on("data", (c) => out += c.toString()); + p.stderr?.on("data", (c) => out += c.toString()); + p.on("error", reject); + p.on("close", (code) => { + clearTimeout(t); + if (code === 0) resolvePromise(out); + else reject(new Error("exit " + code)); + }); + }); +} +function createClaudeProvider() { + return { + id: "claude", + name: "Claude Code", + capabilities: claudeCapabilities, + async detect() { + const bin = await resolveBin("claude"); + if (!bin.ok) return { available: false, reason: bin.reason, installHint: INSTALL_HINT }; + try { + const version = await runVersion(bin, ["--version"]); + return { available: true, bin: bin.display, version: version.trim().split("\n")[0] || "" }; + } catch { + return { available: true, bin: bin.display, version: "" }; + } + }, + async listModels() { + return listClaudeModels(); + }, + async createSession(o) { + const bin = await resolveBin("claude"); + if (!bin.ok) throw new Error(tr("claude.notFound", { detail: bin.reason, hint: INSTALL_HINT })); + return new ClaudeSession({ + threadId: o.threadId, + cwd: o.cwd, + model: o.model, + mode: o.mode, + effort: o.effort, + resumeId: o.resumeId, + bin + }); + }, + // §7.6: jsonl sessions under ~/.claude/projects matched by cwd + async listHistory(cwd) { + const items = await listClaudeHistory(cwd); + return items.map((i) => ({ provider: "claude", ...i, imported: false })); + }, + async readHistory(sessionId, cwd) { + const msgs = await readClaudeHistory(sessionId, cwd); + const st = newMapState(); + const out = []; + for (const m of msgs) { + const r = m; + if (r.type === "user") { + const content = r.message?.content; + const text = typeof content === "string" ? content : Array.isArray(content) ? content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("") : ""; + if (text.trim()) out.push({ type: "user_message", text }); + else out.push(...mapSdkMessage(m, st, cwd)); + continue; + } + out.push(...mapSdkMessage(m, st, cwd)); + } + out.push({ type: "turn_ended", turnId: "", status: "completed" }); + return out; + } + }; +} +var INSTALL_HINT, claudeCapabilities; +var init_provider = __esm({ + "daemon/src/providers/claude/provider.ts"() { + init_bin(); + init_session(); + init_models(); + init_import(); + init_map(); + init_i18n(); + INSTALL_HINT = "npm i -g @anthropic-ai/claude-code"; + claudeCapabilities = { + resume: true, + interrupt: true, + steer: false, + // Claude doesn't support real steering — the manager queues instead (§7.2.2) + images: true, + plan: true, + questions: true, + cost: true, + effort: ["low", "medium", "high", "xhigh", "max"], + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + } +}); + +// daemon/src/history/codex.ts +import { readdir as readdir2, readFile as readFile6, stat as stat5 } from "node:fs/promises"; +import { join as join7 } from "node:path"; +import { homedir as homedir4 } from "node:os"; +function textOf(content, kind) { + if (!Array.isArray(content)) return ""; + return content.filter((p) => p.type === kind || p.type === "input_text" || p.type === "output_text").map((p) => str3(p.text) ?? "").join("\n").trim(); +} +async function* walk(dir, depth = 0) { + if (depth > 6) return; + for (const name of await readdir2(dir).catch(() => [])) { + const p = join7(dir, name); + const s = await stat5(p).catch(() => null); + if (!s) continue; + if (s.isDirectory()) yield* walk(p, depth + 1); + else if (/^rollout-.*\.jsonl$/.test(name)) yield p; + } +} +async function listCodexHistory(cwd) { + const out = []; + for await (const path3 of walk(CODEX_ROOT)) { + const m = /rollout-.*-([0-9a-f-]{36})\.jsonl$/i.exec(path3); + const sessionId = m?.[1]; + if (!sessionId) continue; + const text = await readFile6(path3, "utf8").catch(() => ""); + let match = false; + let title = ""; + let count = 0; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let r; + try { + r = rec3(JSON.parse(line)); + } catch { + continue; + } + if (r.type === "session_meta") { + const cwdInFile = str3(rec3(r.payload).cwd); + if (cwdInFile && samePath(cwdInFile, cwd)) match = true; + else break; + continue; + } + if (!match) continue; + if (r.type === "response_item") { + const p = rec3(r.payload); + if (p.type === "message" && p.role === "user") { + const t = textOf(p.content, "input_text"); + if (t && !WRAPPERS.test(t)) { + count++; + if (!title) title = t.slice(0, 40); + } + } + } + } + if (!match) continue; + const s = await stat5(path3).catch(() => null); + out.push({ sessionId, title: title || "\uFF08\u65E0\u6807\u9898\uFF09", updatedAt: s ? Math.round(s.mtimeMs) : 0, messageCount: count }); + } + out.sort((a, b) => b.updatedAt - a.updatedAt); + return out; +} +async function readCodexHistory(sessionId, cwd) { + const out = []; + let file = null; + for await (const p of walk(CODEX_ROOT)) { + if (p.endsWith(`${sessionId}.jsonl`)) { + file = p; + break; + } + } + if (!file) return out; + const text = await readFile6(file, "utf8").catch(() => ""); + let matched = false; + let aid = 0; + const seenText = /* @__PURE__ */ new Set(); + const pushText = (type, t) => { + const key = type + ":" + t; + if (!t || seenText.has(key)) return; + seenText.add(key); + out.push(type === "user_message" ? { type, text: t } : { type, itemId: `imp_a${aid++}`, text: t }); + }; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let r; + try { + r = rec3(JSON.parse(line)); + } catch { + continue; + } + if (r.type === "session_meta") { + const c = str3(rec3(r.payload).cwd); + if (c && samePath(c, cwd)) matched = true; + continue; + } + if (!matched) continue; + if (r.type === "response_item") { + const p = rec3(r.payload); + if (p.type === "message") { + const role = str3(p.role); + const phase = str3(p.phase); + if (role === "user") { + const t = textOf(p.content, "input_text"); + if (t && !WRAPPERS.test(t)) pushText("user_message", t); + } else if (role === "assistant" && phase !== "commentary") { + const t = textOf(p.content, "output_text"); + if (t) pushText("assistant_message", t); + } + } else if (p.type === "function_call" || p.type === "custom_tool_call") { + const name = str3(p.name) ?? "tool"; + out.push({ type: "notice", level: "info", message: `\uFF08\u5386\u53F2\uFF09\u8C03\u7528 ${name}` }); + } + continue; + } + if (r.type === "event_msg") { + const p = rec3(r.payload); + if (p.type === "item_completed") { + const it = rec3(p.item); + if (it.type === "AgentMessage" && it.phase !== "commentary") { + const t = textOf(it.content, "Text"); + if (t) pushText("assistant_message", t); + } else if (it.type === "UserMessage") { + const t = textOf(it.content, "text"); + if (t && !WRAPPERS.test(t)) pushText("user_message", t); + } + } else if (p.type === "task_complete") { + const last = str3(p.last_agent_message); + if (last) pushText("assistant_message", last); + } + continue; + } + const p2 = rec3(r.payload); + if (p2.type === "function_call" || p2.type === "custom_tool_call") { + const name = str3(p2.name) ?? "tool"; + out.push({ type: "notice", level: "info", message: `\uFF08\u5386\u53F2\uFF09\u8C03\u7528 ${name}` }); + } + } + out.push({ type: "turn_ended", turnId: "import", status: "completed" }); + return out; +} +var CODEX_ROOT, rec3, str3, WRAPPERS; +var init_codex = __esm({ + "daemon/src/history/codex.ts"() { + init_paths(); + CODEX_ROOT = join7(homedir4(), ".codex", "sessions"); + rec3 = (v) => v && typeof v === "object" ? v : {}; + str3 = (v) => typeof v === "string" ? v : void 0; + WRAPPERS = /^\s*<(environment_context|user_instructions|skills_instructions|permissions instructions)>/i; + } +}); + +// daemon/src/providers/codex/rpc.ts +import { spawn as spawn2 } from "node:child_process"; +var CodexRpcError, CodexProcessExitedError, CodexRpc; +var init_rpc = __esm({ + "daemon/src/providers/codex/rpc.ts"() { + init_log(); + init_bin(); + CodexRpcError = class extends Error { + code; + constructor(message, code) { + super(message); + this.code = code; + } + }; + CodexProcessExitedError = class extends CodexRpcError { + constructor() { + super("Codex \u8FDB\u7A0B\u5DF2\u9000\u51FA"); + } + }; + CodexRpc = class { + proc; + nextId = 1; + pending = /* @__PURE__ */ new Map(); + buf = ""; + exited = false; + onServerRequest; + onNotification; + onExit; + constructor(argv, opts = {}) { + this.proc = spawn2(argv.cmd, argv.args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + ...argv.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}, + // Non-Windows: its own process group, so after a close() timeout killTree also ends the commands codex started + ...process.platform !== "win32" ? { detached: true } : {} + }); + trackTree(this.proc); + this.proc.stdout.setEncoding("utf8"); + this.proc.stdout.on("data", (chunk) => this.onData(chunk)); + this.proc.stderr.setEncoding("utf8"); + this.proc.stderr.on("data", (chunk) => { + this.stderrTail = (this.stderrTail + chunk).slice(-4096); + }); + this.proc.on("error", () => this.failAll()); + this.proc.on("close", (code) => { + this.exited = true; + this.failAll(); + this.onExit?.(code); + }); + } + stderrTail = ""; + get stderr() { + return this.stderrTail; + } + get isExited() { + return this.exited; + } + onData(chunk) { + this.buf += chunk; + let i; + while ((i = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, i).trim(); + this.buf = this.buf.slice(i + 1); + if (!line) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + log("warn", "codex rpc: \u975E JSON \u884C", { line: line.slice(0, 200) }); + continue; + } + void this.dispatch(msg); + } + } + async dispatch(msg) { + const hasId = msg.id !== void 0 && msg.id !== null; + if (hasId && ("result" in msg || "error" in msg)) { + const id = Number(msg.id); + const p = this.pending.get(id); + if (p) { + this.pending.delete(id); + if (msg.error) { + const e = msg.error; + p.reject(new CodexRpcError(e.message ?? "RPC error", e.code)); + } else { + p.resolve(msg.result); + } + } + return; + } + if (hasId && msg.method) { + const id = Number(msg.id); + try { + const result = this.onServerRequest ? await this.onServerRequest(String(msg.method), msg.params ?? {}) : void 0; + this.send({ id, result: result ?? null }); + } catch (e) { + this.send({ id, error: { code: -32603, message: String(e?.message ?? e) } }); + } + return; + } + if (!hasId && msg.method) { + try { + this.onNotification?.(String(msg.method), msg.params ?? {}); + } catch (e) { + log("warn", "codex rpc: \u901A\u77E5\u5904\u7406\u5931\u8D25", { method: msg.method, err: String(e) }); + } + } + } + send(obj) { + if (this.exited) return; + try { + this.proc.stdin.write(JSON.stringify(obj) + "\n"); + } catch { + } + } + request(method, params, timeoutMs = 6e4) { + if (this.exited) return Promise.reject(new CodexProcessExitedError()); + const id = this.nextId++; + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new CodexRpcError(`\u8BF7\u6C42\u8D85\u65F6\uFF1A${method}`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (v) => { + clearTimeout(timer); + resolvePromise(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + } + }); + this.send({ jsonrpc: "2.0", id, method, params: params ?? {} }); + }); + } + notify(method, params) { + this.send({ jsonrpc: "2.0", method, params: params ?? {} }); + } + failAll() { + const err = new CodexProcessExitedError(); + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + } + async close() { + try { + this.proc.stdin?.end(); + } catch { + } + await new Promise((resolvePromise) => { + const t = setTimeout(() => { + void killTree(this.proc, { graceMs: 1500 }); + resolvePromise(); + }, 3e3); + this.proc.on("close", () => { + clearTimeout(t); + resolvePromise(); + }); + if (this.exited) { + clearTimeout(t); + resolvePromise(); + } + }); + } + }; + } +}); + +// daemon/src/providers/codex/input.ts +import { basename as basename2 } from "node:path"; +async function buildCodexInput(text, attachments) { + const images = []; + const notes = []; + const warnings = []; + for (const a of attachments) { + let file; + try { + file = await resolveImagePath(a.path); + } catch (e) { + warnings.push(`${a.path}: ${String(e?.message ?? e)}`); + continue; + } + const kind = attachmentKind(a.mime); + if (kind === "image") { + images.push({ type: "localImage", path: file }); + continue; + } + notes.push(pathNote(kind, file)); + if (kind === "other") warnings.push(`${basename2(file)} (${baseMime(a.mime) || "unknown type"}) is not an image or a text file; sent its path`); + } + const fullText = [text, ...notes.length ? [notes.join("\n")] : []].filter(Boolean).join("\n\n"); + return { items: [{ type: "text", text: fullText }, ...images], warnings }; +} +var init_input = __esm({ + "daemon/src/providers/codex/input.ts"() { + init_attachmentRefs(); + init_attachmentKinds(); + } +}); + +// daemon/src/providers/codex/map.ts +function newCodexMapState() { + return { currentTurnId: null, filesChanged: 0, diffText: "" }; +} +function mapNotification(method, params, st) { + const out = []; + const turnId = str4(params.turnId); + switch (method) { + case "turn/started": { + const turn = rec4(params.turn); + const tid = str4(turn.id); + if (tid) st.currentTurnId = tid; + st.filesChanged = 0; + st.diffText = ""; + break; + } + case "item/agentMessage/delta": { + const itemId = str4(params.itemId) ?? ""; + const delta = str4(params.delta) ?? ""; + if (itemId && delta) out.push({ type: "assistant_delta", itemId, text: delta }); + break; + } + case "item/plan/delta": { + break; + } + case "item/reasoning/summaryTextDelta": + case "item/reasoning/textDelta": { + const itemId = str4(params.itemId) ?? ""; + const delta = str4(params.delta) ?? ""; + if (itemId && delta) out.push({ type: "reasoning_delta", itemId, text: delta }); + break; + } + case "item/commandExecution/outputDelta": + case "item/fileChange/outputDelta": + case "command/exec/outputDelta": { + const itemId = str4(params.itemId) ?? ""; + const delta = str4(params.delta) ?? ""; + if (itemId && delta) out.push({ type: "tool_output_delta", itemId, text: delta }); + break; + } + case "item/started": { + const item = rec4(params.item); + const type = str4(item.type) ?? ""; + const itemId = str4(item.id) ?? ""; + if (type === "commandExecution") { + const cmd = str4(item.command) ?? ""; + out.push({ + type: "tool_call", + itemId, + kind: "bash", + title: `\u8FD0\u884C ${trunc2(cmd)}`, + status: "running", + detail: { command: cmd, cwd: str4(item.cwd) } + }); + } else if (type === "fileChange") { + const changes = Array.isArray(item.changes) ? item.changes : []; + out.push({ + type: "tool_call", + itemId, + kind: "edit", + title: `\u4FEE\u6539 ${changes.length} \u4E2A\u6587\u4EF6`, + status: "running", + detail: { changes } + }); + } else if (type === "mcpToolCall" || type === "dynamicToolCall") { + const tool = str4(item.tool) ?? str4(item.name) ?? ""; + const server = str4(item.server) ?? ""; + out.push({ + type: "tool_call", + itemId, + kind: "mcp", + title: `${server || "mcp"} \xB7 ${tool}`, + status: "running" + }); + } else if (type === "webSearch") { + out.push({ + type: "tool_call", + itemId, + kind: "web", + title: `\u641C\u7D22\u7F51\u9875 "${trunc2(str4(item.query) ?? "", 40)}"`, + status: "running" + }); + } + break; + } + case "item/completed": { + const item = rec4(params.item); + const type = str4(item.type) ?? ""; + const itemId = str4(item.id) ?? ""; + if (type === "agentMessage") { + const text = str4(item.text) ?? ""; + if (text) out.push({ type: "assistant_message", itemId, text }); + } else if (type === "reasoning") { + const summary = Array.isArray(item.summary) ? item.summary : []; + const text = summary.filter((s) => typeof s === "string").join("\n"); + if (text) out.push({ type: "reasoning", itemId, text }); + } else if (type === "commandExecution") { + const cmd = str4(item.command) ?? ""; + const exitCode = typeof item.exitCode === "number" ? item.exitCode : void 0; + const output = str4(item.aggregatedOutput) ?? ""; + out.push({ + type: "tool_call", + itemId, + kind: "bash", + title: `\u8FD0\u884C ${trunc2(cmd)}`, + status: exitCode === 0 ? "ok" : "failed", + output: output.length > MAX_OUTPUT2 ? output.slice(0, MAX_OUTPUT2) : output, + exitCode, + durationMs: typeof item.durationMs === "number" ? item.durationMs : void 0 + }); + } else if (type === "fileChange") { + const changes = Array.isArray(item.changes) ? item.changes : []; + const istatus = str4(item.status); + st.filesChanged += changes.length; + out.push({ + type: "tool_call", + itemId, + kind: "edit", + title: `\u4FEE\u6539 ${changes.length} \u4E2A\u6587\u4EF6`, + status: istatus === "failed" ? "failed" : "ok", + detail: { changes } + }); + } else if (type === "mcpToolCall" || type === "dynamicToolCall") { + const tool = str4(item.tool) ?? str4(item.name) ?? ""; + const server = str4(item.server) ?? ""; + const istatus = str4(item.status); + const err = str4(item.error); + out.push({ + type: "tool_call", + itemId, + kind: "mcp", + title: `${server || "mcp"} \xB7 ${tool}`, + status: istatus === "failed" || err ? "failed" : "ok", + output: err ?? void 0 + }); + } else if (type === "webSearch") { + out.push({ + type: "tool_call", + itemId, + kind: "web", + title: `\u641C\u7D22\u7F51\u9875 "${trunc2(str4(item.query) ?? "", 40)}"`, + status: "ok" + }); + } else if (type === "plan") { + const text = str4(item.text) ?? ""; + if (text) out.push({ type: "plan", itemId, markdown: text }); + } + break; + } + case "turn/plan/updated": { + const steps = Array.isArray(params.plan) ? params.plan : []; + const items = steps.map((s) => { + const step = rec4(s); + const status = str4(step.status) ?? "pending"; + return { + text: str4(step.step) ?? "", + status: status === "completed" ? "done" : status === "inProgress" || status === "in_progress" ? "in_progress" : "pending" + }; + }); + if (items.length) out.push({ type: "todo", items }); + break; + } + case "turn/diff/updated": { + const diff = str4(params.diff) ?? ""; + if (diff) st.diffText = diff; + break; + } + case "thread/tokenUsage/updated": { + const usage = rec4(params.tokenUsage); + const last = rec4(usage.last) || usage; + const input = typeof last.inputTokens === "number" ? last.inputTokens : 0; + const output = typeof last.outputTokens === "number" ? last.outputTokens : 0; + const cached = typeof last.cachedInputTokens === "number" ? last.cachedInputTokens : 0; + out.push({ + type: "usage", + inputTokens: input, + outputTokens: output, + cacheReadTokens: cached, + costUsd: 0 + }); + break; + } + case "turn/completed": { + const turn = rec4(params.turn); + const status = str4(turn.status) ?? "completed"; + const error = rec4(turn.error); + out.push({ + type: "turn_ended", + turnId: str4(turn.id) ?? turnId ?? st.currentTurnId ?? "", + status: status === "completed" ? "completed" : status === "interrupted" ? "interrupted" : "failed", + durationMs: typeof turn.durationMs === "number" ? turn.durationMs : void 0, + error: str4(error.message), + filesChanged: st.filesChanged || void 0 + }); + break; + } + case "error": { + const msg = str4(params.message) ?? "Codex \u9519\u8BEF"; + out.push({ type: "notice", level: "error", message: msg }); + break; + } + default: + break; + } + return out; +} +var MAX_OUTPUT2, trunc2, rec4, str4; +var init_map2 = __esm({ + "daemon/src/providers/codex/map.ts"() { + MAX_OUTPUT2 = 20 * 1024; + trunc2 = (s, n = 80) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s; + rec4 = (v) => v && typeof v === "object" ? v : {}; + str4 = (v) => typeof v === "string" ? v : void 0; + } +}); + +// daemon/src/providers/codex/commands.ts +import { readdir as readdir3, readFile as readFile7, stat as stat6 } from "node:fs/promises"; +import { homedir as homedir5 } from "node:os"; +import { basename as basename3, join as join8 } from "node:path"; +function parseFrontMatter(raw) { + if (!raw.startsWith("---\n")) return { meta: {}, body: raw }; + const end = raw.indexOf("\n---\n", 4); + if (end === -1) return { meta: {}, body: raw }; + const fm = raw.slice(4, end); + const meta = {}; + for (const line of fm.split(/\r?\n/)) { + const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/); + if (m) meta[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); + } + return { meta, body: raw.slice(end + 5) }; +} +function firstLine(body) { + for (const line of body.split(/\r?\n/)) { + const t = line.trim(); + if (t && !t.startsWith("#")) return t.replace(/^#+\s*/, "").slice(0, 120); + } + return ""; +} +async function readPromptFiles(dir) { + const out = []; + let names = []; + try { + names = await readdir3(dir); + } catch { + return out; + } + for (const f of names) { + if (!f.endsWith(".md")) continue; + const path3 = join8(dir, f); + try { + const st = await stat6(path3); + if (!st.isFile()) continue; + const raw = await readFile7(path3, "utf8"); + const { meta, body } = parseFrontMatter(raw); + const name = basename3(f, ".md"); + out.push({ + name, + description: meta.description || firstLine(body) || name, + argumentHint: meta.argumentHint || meta["argument-hint"], + kind: "command", + body + }); + } catch { + } + } + return out; +} +async function readSkillDirs(root) { + const out = []; + let dirs = []; + try { + dirs = await readdir3(root); + } catch { + return out; + } + for (const d of dirs) { + const skillPath = join8(root, d, "SKILL.md"); + try { + const st = await stat6(skillPath); + if (!st.isFile()) continue; + const raw = await readFile7(skillPath, "utf8"); + const { meta, body } = parseFrontMatter(raw); + const name = meta.name || d; + out.push({ + name, + description: meta.description || firstLine(body) || name, + argumentHint: meta.argumentHint, + kind: "skill", + body + }); + } catch { + } + } + return out; +} +async function loadCodexSlashEntries(cwd, opts) { + const home = homedir5(); + const parts = await Promise.all([ + opts?.skipHome ? Promise.resolve([]) : readPromptFiles(join8(home, ".codex", "prompts")), + readPromptFiles(join8(cwd, ".codex", "prompts")), + opts?.skipHome ? Promise.resolve([]) : readSkillDirs(join8(home, ".codex", "skills")), + readSkillDirs(join8(cwd, ".codex", "skills")) + ]); + const byName = /* @__PURE__ */ new Map(); + for (const list of parts) { + for (const e of list) { + if (!byName.has(e.name)) byName.set(e.name, e); + } + } + return sortSlashCommands([...byName.values()]); +} +function expandCodexSlash(text, entries) { + const parsed = parseSlashInput(text); + if (!parsed) return text; + const hit = entries.find((e) => e.name === parsed.name); + if (!hit) return text; + return hit.body.replace(/\$ARGUMENTS/g, parsed.args); +} +function codexSlashCommandsFromEntries(entries) { + return entries.map(({ body: _b, ...rest }) => rest); +} +var init_commands = __esm({ + "daemon/src/providers/codex/commands.ts"() { + init_slash(); + } +}); + +// daemon/src/providers/codex/session.ts +var rid3, MODE_PRESETS, CodexSession; +var init_session2 = __esm({ + "daemon/src/providers/codex/session.ts"() { + init_input(); + init_i18n(); + init_queue(); + init_rpc(); + init_bin(); + init_map2(); + init_commands(); + init_log(); + rid3 = () => Math.random().toString(36).slice(2, 10); + MODE_PRESETS = { + plan: { approvalPolicy: "on-request", sandbox: "read-only", sandboxPolicy: { type: "readOnly" } }, + default: { approvalPolicy: "on-request", sandbox: "read-only", sandboxPolicy: { type: "readOnly" } }, + acceptEdits: { approvalPolicy: "on-request", sandbox: "workspace-write", sandboxPolicy: { type: "workspaceWrite" } }, + bypassPermissions: { approvalPolicy: "never", sandbox: "danger-full-access", sandboxPolicy: { type: "dangerFullAccess" } }, + ask: { approvalPolicy: "on-request", sandbox: "read-only", sandboxPolicy: { type: "readOnly" } } + }; + CodexSession = class { + constructor(o) { + this.o = o; + this.events = this.q; + } + o; + events; + q = new AsyncQueue(); + rpc = null; + st = newCodexMapState(); + sessionThreadId = null; + pending = /* @__PURE__ */ new Map(); + closed = false; + started = null; + interruptWanted = false; + ourTurnId = null; + // The current turn's turnId in the timeline (generated on send) + staleCodexTurns = /* @__PURE__ */ new Set(); + codexSlash = null; + emit(ev) { + if (ev.type === "turn_ended") { + if (ev.turnId && this.staleCodexTurns.delete(ev.turnId)) return; + if (this.ourTurnId) ev.turnId = this.ourTurnId; + this.ourTurnId = null; + this.st.currentTurnId = null; + this.interruptWanted = false; + } + this.q.push(ev); + } + async start() { + if (this.started) return this.started; + const attempt = (async () => { + const rpc = new CodexRpc( + argvFor( + { ok: true, cmd: this.o.binCmd, prefixArgs: this.o.binPrefixArgs, display: this.o.binCmd, via: this.o.binVia ?? "path", shim: this.o.binShim }, + ["app-server"] + ), + { cwd: this.o.cwd } + ); + this.rpc = rpc; + rpc.onNotification = (m, p) => { + for (const ev of mapNotification(m, p, this.st)) this.emit(ev); + if (this.interruptWanted && this.st.currentTurnId) void this.interrupt(); + }; + rpc.onServerRequest = (m, p) => this.onServerRequest(m, p); + rpc.onExit = () => { + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + if (!this.closed) this.q.close(); + }; + await rpc.request("initialize", { + clientInfo: { name: "agent-console", title: "Agent Console", version: this.o.version }, + capabilities: { experimentalApi: true } + }); + rpc.notify("initialized", {}); + const preset = MODE_PRESETS[this.o.mode]; + let threadId; + if (this.o.resumeId) { + const res = await rpc.request("thread/resume", { + threadId: this.o.resumeId, + cwd: this.o.cwd, + model: this.o.model, + approvalPolicy: preset.approvalPolicy, + sandbox: preset.sandbox + }); + threadId = res.thread?.id ?? this.o.resumeId; + } else { + const res = await rpc.request("thread/start", { + cwd: this.o.cwd, + model: this.o.model, + approvalPolicy: preset.approvalPolicy, + sandbox: preset.sandbox + }); + threadId = res.thread?.id ?? ""; + } + if (threadId) { + this.sessionThreadId = threadId; + this.emit({ type: "provider_session_id", id: threadId }); + } + })(); + this.started = attempt.catch((e) => { + if (this.started === guarded) { + this.started = null; + const rpc = this.rpc; + this.rpc = null; + if (rpc) { + rpc.onExit = () => { + }; + void Promise.resolve(rpc.close()).catch(() => { + }); + } + } + throw e; + }); + const guarded = this.started; + return this.started; + } + async send(input) { + if (this.closed) throw new Error("\u4F1A\u8BDD\u5DF2\u5173\u95ED"); + await this.start(); + const rpc = this.rpc; + const preset = MODE_PRESETS[this.o.mode]; + const entries = this.codexSlash ?? await loadCodexSlashEntries(this.o.cwd); + this.codexSlash = entries; + const wireText = expandCodexSlash(input.text, entries); + const { items, warnings } = await buildCodexInput(wireText, input.images ?? []); + this.interruptWanted = false; + const turnId = "u_" + Date.now().toString(36) + "_" + rid3(); + if (this.ourTurnId && this.st.currentTurnId) this.staleCodexTurns.add(this.st.currentTurnId); + this.ourTurnId = turnId; + this.st.currentTurnId = null; + this.emit({ + type: "turn_started", + turnId, + model: this.o.model, + mode: this.o.mode, + effort: this.o.effort + }); + for (const w of warnings) this.emit({ type: "notice", level: "warn", message: tr("notice.attachmentIssue", { detail: w }) }); + try { + await rpc.request("turn/start", { + threadId: this.sessionThreadId ?? void 0, + input: items, + model: this.o.model, + effort: this.o.effort, + approvalPolicy: preset.approvalPolicy, + sandboxPolicy: preset.sandboxPolicy + }); + } catch (e) { + this.emit({ + type: "turn_ended", + turnId, + status: "failed", + error: String(e?.message ?? e) + }); + } + } + async steer(input) { + if (!this.rpc || !this.st.currentTurnId) throw new Error("\u5F53\u524D\u8F6E\u6B21\u8FD8\u6CA1\u5F00\u59CB\uFF0C\u65E0\u6CD5\u63D2\u8BDD"); + const { items, warnings } = await buildCodexInput(input.text, input.images ?? []); + for (const w of warnings) this.emit({ type: "notice", level: "warn", message: tr("notice.attachmentIssue", { detail: w }) }); + try { + await this.rpc.request("turn/steer", { + threadId: this.threadId(), + expectedTurnId: this.st.currentTurnId, + input: items + }); + } catch (e) { + log("warn", "codex steer \u5931\u8D25", { err: String(e) }); + } + } + async interrupt() { + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + if (!this.rpc) return; + if (!this.st.currentTurnId) { + this.interruptWanted = true; + return; + } + this.interruptWanted = false; + try { + await this.rpc.request( + "turn/interrupt", + { threadId: this.threadId(), turnId: this.st.currentTurnId }, + 5e3 + ); + } catch (e) { + const msg = String(e?.message ?? e); + if (!/idle|not running|no.*turn/i.test(msg)) { + log("warn", "codex interrupt \u5931\u8D25", { err: msg }); + } + } + } + threadId() { + return this.sessionThreadId ?? void 0; + } + // ---- server requests (§7.3.5 approvals) ---- + async onServerRequest(method, params) { + switch (method) { + case "item/commandExecution/requestApproval": { + const command = String(params.command ?? ""); + const requestId = "perm_" + rid3(); + this.emit({ + type: "permission_request", + requestId, + title: `\u8FD0\u884C ${command.slice(0, 80)}`, + kind: "tool", + detail: { command, cwd: params.cwd, reason: params.reason, approvalId: params.approvalId, itemId: params.itemId }, + options: [ + { id: "accept", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "acceptForSession", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "decline", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const a = await this.waitFor(requestId); + if (a.cancelled) return { decision: "cancel" }; + const opt = a.optionId === "acceptForSession" ? "acceptForSession" : a.optionId === "decline" ? "decline" : "accept"; + return { decision: opt }; + } + case "item/fileChange/requestApproval": { + const changes = Array.isArray(params.changes) ? params.changes : []; + const requestId = "perm_" + rid3(); + this.emit({ + type: "permission_request", + requestId, + title: "\u4FEE\u6539\u6587\u4EF6", + kind: "tool", + detail: { changes, approvalId: params.approvalId, itemId: params.itemId, reason: params.reason }, + options: [ + { id: "accept", label: "\u5141\u8BB8", kind: "allow_once" }, + { id: "acceptForSession", label: "\u672C\u5BF9\u8BDD\u90FD\u5141\u8BB8", kind: "allow_always" }, + { id: "decline", label: "\u62D2\u7EDD", kind: "reject_once" } + ] + }); + const a = await this.waitFor(requestId); + if (a.cancelled) return { decision: "cancel" }; + const opt = a.optionId === "acceptForSession" ? "acceptForSession" : a.optionId === "decline" ? "decline" : "accept"; + return { decision: opt }; + } + case "item/tool/requestUserInput": { + const requestId = "q_" + rid3(); + this.emit({ + type: "question", + requestId, + questions: params.questions ?? [] + }); + const a = await this.waitFor(requestId); + if (a.cancelled) return { answers: {} }; + const raw = a.answers ?? {}; + const shaped = {}; + for (const [k, v] of Object.entries(raw)) shaped[k] = { answers: [v] }; + return { answers: shaped }; + } + default: + throw new Error(`\u672A\u8BC6\u522B\u7684\u670D\u52A1\u7AEF\u8BF7\u6C42 ${method}`); + } + } + waitFor(requestId) { + return new Promise((resolve2) => { + this.pending.set(requestId, { kind: "permission", resolve: resolve2 }); + }); + } + respond(requestId, answer) { + const p = this.pending.get(requestId); + if (p) { + this.pending.delete(requestId); + p.resolve(answer); + } + } + async setModel(model) { + this.o.model = model; + } + async setMode(mode) { + this.o.mode = mode; + } + async setEffort(effort) { + this.o.effort = effort; + } + async listCommands() { + try { + const entries = this.codexSlash ?? await loadCodexSlashEntries(this.o.cwd); + this.codexSlash = entries; + return codexSlashCommandsFromEntries(entries); + } catch { + return []; + } + } + async close() { + this.closed = true; + for (const [, p] of this.pending) p.resolve({ cancelled: true }); + this.pending.clear(); + if (this.rpc) await this.rpc.close(); + this.q.close(); + } + }; + } +}); + +// daemon/src/providers/codex/provider.ts +function runVersion2(r, args, timeoutMs = 5e3) { + return new Promise((resolvePromise, reject) => { + let out = ""; + const p = spawnBin(r, args, { stdio: ["ignore", "pipe", "pipe"] }); + const t = setTimeout(() => { + try { + void killTree(p, { graceMs: 500 }); + } catch { + } + reject(new Error("timeout")); + }, timeoutMs); + p.stdout?.on("data", (c) => out += c.toString()); + p.stderr?.on("data", (c) => out += c.toString()); + p.on("error", reject); + p.on("close", (code) => { + clearTimeout(t); + if (code === 0) resolvePromise(out); + else reject(new Error("exit " + code)); + }); + }); +} +function normalizeEfforts(raw) { + const out = []; + for (const e of raw ?? []) { + const v = typeof e === "string" ? e : e?.reasoningEffort; + if (typeof v === "string" && v && !out.includes(v)) out.push(v); + } + return out; +} +async function fetchModels(r, cwd) { + const rpc = new CodexRpc(argvFor(r, ["app-server"]), { cwd }); + try { + await rpc.request("initialize", { + clientInfo: { name: "agent-console", title: "Agent Console", version: "0" }, + capabilities: { experimentalApi: true } + }, 15e3); + rpc.notify("initialized", {}); + const res = await rpc.request("model/list", {}, 15e3); + const rows = res.data ?? []; + return rows.filter((m) => typeof m.id === "string" && m.id).map((m) => { + const efforts = normalizeEfforts(m.supportedReasoningEfforts); + const thinkingOptions = efforts.map((e) => ({ id: e, label: e })); + const defaultThinking = (typeof m.defaultReasoningEffort === "string" ? m.defaultReasoningEffort : null) ?? thinkingOptions[0]?.id ?? null; + return { + id: m.id, + name: m.displayName ?? m.id, + isDefault: m.isDefault, + efforts, + thinkingOptions, + defaultThinking + }; + }); + } finally { + await rpc.close(); + } +} +function createCodexProvider(version) { + return { + id: "codex", + name: "Codex", + capabilities: codexCapabilities, + async detect() { + const bin = await resolveBin("codex"); + if (!bin.ok) { + return { available: false, reason: bin.reason, installHint: INSTALL_HINT2 }; + } + try { + const v = await runVersion2(bin, ["--version"]); + return { available: true, bin: bin.display, version: v.trim().split("\n")[0] || "" }; + } catch (e) { + return { available: true, bin: bin.display, version: "", reason: String(e) }; + } + }, + async listModels() { + if (modelCache && Date.now() - modelCache.at < MODEL_CACHE_MS) return modelCache.models; + const bin = await resolveBin("codex"); + if (!bin.ok) return [{ id: "", name: "\u9ED8\u8BA4" }]; + try { + const models2 = await fetchModels(bin, process.cwd()); + modelCache = { at: Date.now(), models: models2 }; + return models2; + } catch (e) { + log("warn", "codex model/list \u5931\u8D25", { err: String(e) }); + return [{ id: "", name: "\u9ED8\u8BA4" }]; + } + }, + async createSession(o) { + const bin = await resolveBin("codex"); + if (!bin.ok) throw new Error(`\u627E\u4E0D\u5230 codex\uFF1A${bin.reason}`); + return new CodexSession({ + threadId: o.threadId, + cwd: o.cwd, + model: o.model, + mode: o.mode, + effort: o.effort, + resumeId: o.resumeId, + binCmd: bin.cmd, + binPrefixArgs: bin.prefixArgs, + binVia: bin.via, + binShim: bin.shim, + version + }); + }, + // §7.6: ~/.codex/sessions/**/rollout-*.jsonl matched by cwd + async listHistory(cwd) { + const items = await listCodexHistory(cwd); + return items.map((i) => ({ provider: "codex", ...i, imported: false })); + }, + async readHistory(sessionId, cwd) { + return await readCodexHistory(sessionId, cwd); + } + }; +} +var INSTALL_HINT2, codexCapabilities, modelCache, MODEL_CACHE_MS; +var init_provider2 = __esm({ + "daemon/src/providers/codex/provider.ts"() { + init_codex(); + init_bin(); + init_rpc(); + init_session2(); + init_log(); + INSTALL_HINT2 = "npm i -g @openai/codex"; + codexCapabilities = { + resume: true, + interrupt: true, + steer: true, + images: true, + plan: true, + questions: true, + cost: false, + // tokenUsage yes, cost no + effort: [], + // effort values depend on the model — filled from model/list after detect + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + modelCache = null; + MODEL_CACHE_MS = 10 * 60 * 1e3; + } +}); + +// daemon/src/providers/catalog.ts +function catalogById(id) { + return PROVIDER_CATALOG.find((p) => p.id === id); +} +function providerCreatable(status) { + return status !== "planned"; +} +var PROVIDER_CATALOG; +var init_catalog = __esm({ + "daemon/src/providers/catalog.ts"() { + PROVIDER_CATALOG = [ + { id: "claude", name: "Claude Code", bins: ["claude"], installHint: "npm i -g @anthropic-ai/claude-code", status: "verified" }, + { id: "codex", name: "Codex", bins: ["codex"], installHint: "\u89C1 OpenAI Codex CLI \u5B89\u88C5\u8BF4\u660E", status: "verified" }, + { id: "cursor", name: "Cursor", bins: ["cursor-agent", "cursor"], installHint: "\u5B89\u88C5 Cursor \u5E76\u786E\u4FDD CLI \u5728 PATH", status: "verified" }, + { id: "copilot", name: "Copilot", bins: ["copilot"], installHint: "GitHub Copilot CLI \u5B89\u88C5\u8BF4\u660E", status: "verified" }, + { id: "opencode", name: "OpenCode", bins: ["opencode"], installHint: "npm i -g opencode / \u89C1 OpenCode \u6587\u6863", status: "verified" }, + { id: "pi", name: "Pi", bins: ["pi-acp", "pi", "pi-coding-agent"], installHint: "npm i -g pi-acp\uFF08\u6216 Pi coding agent + ACP \u9002\u914D\u5668\uFF09", status: "verified" }, + { id: "devin", name: "Devin CLI", bins: ["devin"], installHint: "Devin CLI \u5B98\u65B9\u5B89\u88C5", status: "verified" }, + { id: "dsh", name: "dsh", bins: ["dsh"], installHint: "npm i -g @deepseek-ai/dsh", status: "verified" } + ]; + } +}); + +// daemon/src/providers/acp/spawn.ts +import { Readable, Writable } from "node:stream"; +import { resolve as resolvePath } from "node:path"; +import * as acp from "@agentclientprotocol/sdk"; +function spawnResolved(r, args, o = {}) { + return spawnBin(r, args, { + cwd: o.cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, + group: true + }); +} +function acpStreamFromChild(child) { + if (!child.stdin || !child.stdout) throw new Error("ACP \u5B50\u8FDB\u7A0B\u7F3A\u5C11 stdio \u7BA1\u9053"); + const output = Writable.toWeb(child.stdin); + const input = Readable.toWeb(child.stdout); + return acp.ndJsonStream(output, input); +} +function absCwd(cwd) { + return resolvePath(cwd); +} +async function killAcpChild(child) { + await killTree(child); +} +var init_spawn = __esm({ + "daemon/src/providers/acp/spawn.ts"() { + init_bin(); + } +}); + +// daemon/src/providers/acp/clientHandlers.ts +import { readFile as readFile8, writeFile as writeFile2 } from "node:fs/promises"; +import { methods } from "@agentclientprotocol/sdk"; +function registerAcpClientHandlers(app, bridge) { + app.onRequest(methods.client.fs.readTextFile, async (ctx) => { + const text = await readFile8(ctx.params.path, "utf8"); + return { content: text }; + }); + app.onRequest(methods.client.fs.writeTextFile, async (ctx) => { + await writeFile2(ctx.params.path, ctx.params.content, "utf8"); + return {}; + }); + app.onRequest(methods.client.session.requestPermission, async (ctx) => { + const tc = ctx.params.toolCall; + const options = ctx.params.options.map((o) => ({ + id: o.optionId, + label: o.name, + kind: o.kind + })); + if (bridge.autoAcceptPermissions?.()) { + const allow = options.find((o) => o.kind === "allow_once" || o.kind === "allow_always") ?? options[0]; + if (allow) return { outcome: { outcome: "selected", optionId: allow.id } }; + } + const requestId = "p" + Math.random().toString(36).slice(2, 10); + bridge.push({ + type: "permission_request", + requestId, + title: tc.title ?? tc.name ?? "\u9700\u8981\u6388\u6743", + kind: tc.kind ?? "tool", + detail: tc.rawInput ?? void 0, + options + }); + const answer = await bridge.waitFor(requestId); + if (answer.cancelled) return { outcome: { outcome: "cancelled" } }; + const optionId = answer.optionId ?? options[0]?.id; + if (!optionId) return { outcome: { outcome: "cancelled" } }; + return { outcome: { outcome: "selected", optionId } }; + }); +} +var init_clientHandlers = __esm({ + "daemon/src/providers/acp/clientHandlers.ts"() { + } +}); + +// daemon/src/providers/acp/mapUpdate.ts +function mapToolKind(k) { + switch (k) { + case "read": + case "edit": + case "search": + return k; + case "execute": + return "bash"; + case "fetch": + return "web"; + default: + return "other"; + } +} +function mapToolStatus(s) { + if (s === "completed") return "ok"; + if (s === "failed") return "failed"; + return "running"; +} +function textFromContent(content) { + if (content.type === "text") return content.text; + return ""; +} +function newMapCtx() { + return { itemPrefix: "a" + rid4() + "_", messageIds: /* @__PURE__ */ new Map() }; +} +function mapSessionUpdate(update, ctx) { + const out = []; + const mid = "messageId" in update && update.messageId ? String(update.messageId) : null; + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const text = textFromContent(update.content); + if (!text) break; + const itemId = mid ? ctx.messageIds.get(mid) ?? ctx.itemPrefix + mid : ctx.itemPrefix + "msg"; + if (mid) ctx.messageIds.set(mid, itemId); + out.push({ type: "assistant_delta", itemId, text }); + break; + } + case "agent_thought_chunk": { + const text = textFromContent(update.content); + if (!text) break; + const itemId = mid ? ctx.itemPrefix + "t_" + mid : ctx.itemPrefix + "think"; + out.push({ type: "reasoning_delta", itemId, text }); + break; + } + case "tool_call": + out.push({ + type: "tool_call", + itemId: ctx.itemPrefix + update.toolCallId, + kind: mapToolKind(update.kind), + title: update.title, + status: mapToolStatus(update.status), + input: update.rawInput + }); + break; + case "tool_call_update": + out.push({ + type: "tool_call", + itemId: ctx.itemPrefix + update.toolCallId, + kind: mapToolKind(update.kind), + title: update.title ?? update.name ?? "tool", + status: mapToolStatus(update.status), + input: update.rawInput, + output: typeof update.rawOutput === "string" ? update.rawOutput : void 0 + }); + break; + case "plan": + out.push({ + type: "plan", + itemId: ctx.itemPrefix + "plan", + markdown: update.entries.map((e) => `- ${e.content}`).join("\n"), + entries: update.entries.map((e) => ({ step: e.content, status: e.status ?? "" })) + }); + break; + case "notice": + out.push({ + type: "notice", + level: update.severity === "error" ? "error" : update.severity === "warning" ? "warn" : "info", + message: update.description ? `${update.title}: ${update.description}` : update.title + }); + break; + case "usage_update": + out.push({ + type: "usage", + inputTokens: update.used, + outputTokens: 0, + cacheReadTokens: 0, + costUsd: typeof update.cost?.amount === "number" ? update.cost.amount : 0, + contextWindow: update.size + }); + break; + default: + break; + } + return out; +} +function stopToTurnEnd(stopReason, turnId, err) { + if (stopReason === "cancelled") { + return { type: "turn_ended", turnId, status: "interrupted" }; + } + if (stopReason === "refusal" || stopReason === "max_tokens") { + return { type: "turn_ended", turnId, status: "failed", error: err ?? stopReason }; + } + return { type: "turn_ended", turnId, status: "completed" }; +} +var rid4; +var init_mapUpdate = __esm({ + "daemon/src/providers/acp/mapUpdate.ts"() { + rid4 = () => Math.random().toString(36).slice(2, 8); + } +}); + +// daemon/src/providers/acp/promptCaps.ts +function promptCapsFromInitialize(res) { + const pc = res?.agentCapabilities?.promptCapabilities; + return { + image: pc?.image === true, + audio: pc?.audio === true, + embeddedContext: pc?.embeddedContext === true + }; +} +function rememberPromptCaps(providerId, caps) { + if (providerId) learned.set(providerId, caps); +} +function knownPromptCaps(providerId) { + return learned.get(providerId); +} +var learned; +var init_promptCaps = __esm({ + "daemon/src/providers/acp/promptCaps.ts"() { + learned = /* @__PURE__ */ new Map(); + } +}); + +// daemon/src/providers/acp/sessionCaps.ts +function sessionOpenFromInitialize(res) { + const caps = res?.agentCapabilities; + const resume = caps?.sessionCapabilities?.resume != null && typeof caps.sessionCapabilities.resume === "object"; + return { load: caps?.loadSession === true, resume }; +} +function pickReconnect(open) { + if (open.load) return "load"; + if (open.resume) return "resume"; + return "none"; +} +var init_sessionCaps = __esm({ + "daemon/src/providers/acp/sessionCaps.ts"() { + } +}); + +// daemon/src/providers/acp/prompt.ts +import { readFile as readFile9, stat as stat7 } from "node:fs/promises"; +import { basename as basename4 } from "node:path"; +import { pathToFileURL } from "node:url"; +async function buildAcpPrompt(text, attachments, caps) { + const media = []; + const links = []; + const pathLines = []; + const imagesByPath = []; + const warnings = []; + let inlineBytes = 0; + const byPath = (file, mime, size, label) => { + links.push({ type: "resource_link", uri: pathToFileURL(file).href, name: basename4(file), mimeType: mime, ...size !== null ? { size } : {} }); + pathLines.push(`${label}: ${file}`); + }; + for (const a of attachments) { + const mime = a.mime || "application/octet-stream"; + let file; + let size = null; + try { + file = await resolveImagePath(a.path); + size = (await stat7(file)).size; + } catch (e) { + warnings.push(`${a.path}: ${String(e?.message ?? e)}`); + continue; + } + const image = attachmentKind(mime) === "image"; + const audio = mime.startsWith("audio/"); + if (image && caps.image || audio && caps.audio) { + if (size > MAX_INLINE_MEDIA_BYTES || inlineBytes + size > MAX_INLINE_TOTAL_BYTES) { + const limit = size > MAX_INLINE_MEDIA_BYTES ? MAX_INLINE_MEDIA_BYTES : MAX_INLINE_TOTAL_BYTES; + warnings.push(`${basename4(file)}: ${(size / 1024 / 1024).toFixed(1)} MB, over the ${limit / 1024 / 1024} MB inline limit; sent as a file path`); + byPath(file, mime, size, image ? "Attached image" : "Attached audio"); + if (image) imagesByPath.push(file); + continue; + } + try { + const data = (await readFile9(file)).toString("base64"); + media.push(image ? { type: "image", data, mimeType: mime } : { type: "audio", data, mimeType: mime }); + inlineBytes += size; + } catch (e) { + warnings.push(`${basename4(file)}: ${String(e?.message ?? e)}`); + byPath(file, mime, size, image ? "Attached image" : "Attached audio"); + if (image) imagesByPath.push(file); + } + continue; + } + if (image || audio) { + byPath(file, mime, size, image ? "Attached image" : "Attached audio"); + if (image) imagesByPath.push(file); + continue; + } + if (isText(mime) && caps.embeddedContext && size <= MAX_EMBED_TEXT_BYTES && inlineBytes + size <= MAX_INLINE_TOTAL_BYTES) { + try { + const body = await readFile9(file, "utf8"); + media.push({ type: "resource", resource: { uri: pathToFileURL(file).href, mimeType: mime, text: body } }); + inlineBytes += size; + continue; + } catch { + } + } + byPath(file, mime, size, "Attached file"); + } + const fullText = [text, ...pathLines.length ? [pathLines.join("\n")] : []].filter(Boolean).join("\n\n"); + const blocks = []; + if (fullText) blocks.push({ type: "text", text: fullText }); + blocks.push(...media, ...links); + return { blocks, imagesByPath, warnings }; +} +var MAX_INLINE_MEDIA_BYTES, MAX_INLINE_TOTAL_BYTES, MAX_EMBED_TEXT_BYTES, isText; +var init_prompt = __esm({ + "daemon/src/providers/acp/prompt.ts"() { + init_attachmentRefs(); + init_attachmentKinds(); + MAX_INLINE_MEDIA_BYTES = 10 * 1024 * 1024; + MAX_INLINE_TOTAL_BYTES = 16 * 1024 * 1024; + MAX_EMBED_TEXT_BYTES = 1024 * 1024; + isText = (mime) => attachmentKind(mime) === "text"; + } +}); + +// daemon/src/providers/acp/session.ts +import { client, methods as methods2, PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; +var AcpSession; +var init_session3 = __esm({ + "daemon/src/providers/acp/session.ts"() { + init_slash(); + init_queue(); + init_bin(); + init_spawn(); + init_clientHandlers(); + init_mapUpdate(); + init_promptCaps(); + init_sessionCaps(); + init_prompt(); + init_i18n(); + init_log(); + AcpSession = class { + constructor(o) { + this.o = o; + this.events = this.q; + this.startModel = o.model; + this.cursorFast = o.cursorFast ?? false; + this.cursorAutoReview = false; + this.commandsReady = new Promise((r) => this.settleCommands = r); + this.initPromise = this.boot(); + } + o; + events; + q = new AsyncQueue(); + child = null; + conn = null; + ctx = null; + active = null; + initErr = null; + initPromise; + closed = false; + turnId = null; + turnNum = 0; + running = false; + permPending = /* @__PURE__ */ new Map(); + mapCtx = newMapCtx(); + modelConfigId = null; + thoughtConfigId = null; + cursorFast = false; + cursorAutoReview = false; + slashCommands = []; + activeTurnId = null; + configOptions = null; + // initialize → agentCapabilities.promptCapabilities (ACP baseline until initialize answers: text + resource_link) + promptCaps = { image: false, audio: false, embeddedContext: false }; + initCaps = this.promptCaps; + startModel = null; + wakePump = null; + // listCommands waits for the first batch of commands (or confirmation there are none), following Paseo's waitForInitialCommands + commandsReady; + settleCommands; + async boot() { + const bin = this.o.launch ? { + ok: true, + cmd: this.o.launch.cmd, + prefixArgs: this.o.launch.prefixArgs, + display: this.o.launch.cmd, + via: "custom", + binName: "custom" + } : await resolveFirstBin(this.o.binCandidates?.length ? this.o.binCandidates : [this.o.binName]); + if (!bin.ok) throw new Error(bin.reason); + try { + this.child = spawnResolved(bin, this.o.acpArgs, { cwd: this.o.cwd }); + this.child.stderr?.on("data", (d) => { + const t = d.toString().trim(); + if (t) log("info", `[${this.o.binName} acp] ${t.slice(0, 500)}`); + }); + this.child.on("error", (e) => this.failInit(e)); + this.child.on("close", (code) => { + if (!this.closed && code !== 0) { + this.q.push({ type: "notice", level: "error", message: `${this.o.binName} \u8FDB\u7A0B\u9000\u51FA (${code})` }); + } + }); + const app = client({ name: "magpie-agent-console" }); + registerAcpClientHandlers(app, { + push: (ev) => this.q.push(ev), + waitFor: (id) => this.waitPerm(id), + autoAcceptPermissions: () => this.cursorAutoReview + }); + const stream = acpStreamFromChild(this.child); + this.conn = app.connect(stream); + this.ctx = this.conn.agent; + const initRes = await this.ctx.request(methods2.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + // Cursor: declare support for the parameterized model picker (as Paseo does); only then does it expose levels / Fast as separate config options + clientCapabilities: this.o.flavor === "cursor" ? { _meta: { parameterizedModelPicker: true } } : {}, + clientInfo: { name: "Agent Console", version: this.o.appVersion } + }); + this.promptCaps = promptCapsFromInitialize(initRes); + this.initCaps = this.promptCaps; + rememberPromptCaps(this.o.providerId, this.promptCaps); + const cwd = absCwd(this.o.cwd); + const ctx = this.ctx; + if (!ctx) throw new Error("ACP \u672A\u8FDE\u63A5"); + if (this.o.resumeId) { + const how = pickReconnect(sessionOpenFromInitialize(initRes)); + if (how === "load" || how === "resume") { + const method = how === "load" ? methods2.agent.session.load : methods2.agent.session.resume; + const restored = await ctx.request(method, { + sessionId: this.o.resumeId, + cwd, + mcpServers: [] + }); + this.active = ctx.attachSession({ + sessionId: this.o.resumeId, + modes: restored.modes, + configOptions: restored.configOptions + }); + if (Array.isArray(restored.configOptions)) this.configOptions = restored.configOptions; + } else { + this.active = await ctx.buildSession(cwd).start(); + const agent = this.o.providerName ?? this.o.binName; + this.q.push({ type: "notice", level: "warn", message: tr("notice.acpSessionNotRestored", { agent }) }); + } + } else { + this.active = await ctx.buildSession(cwd).start(); + } + this.syncModelConfigId(); + this.syncThoughtConfigId(); + await this.applySessionMode(this.o.mode); + await this.applySessionModel(this.o.model); + await this.applyCursorAutoReviewConfig(this.cursorAutoReview); + this.q.push({ type: "provider_session_id", id: this.active.sessionId }); + const init = this.active.newSessionResponse?.availableCommands; + if (init?.length) this.applyAvailableCommands(init); + void this.pumpUpdates(); + } catch (e) { + this.failInit(e); + throw e; + } + } + failInit(e) { + this.initErr = e instanceof Error ? e : new Error(String(e)); + } + applyAvailableCommands(raw) { + this.slashCommands = sortSlashCommands(mapAcpAvailableCommands(raw)); + this.q.push({ type: "slash_commands", commands: this.slashCommands }); + this.settleCommands(); + } + async ready() { + await this.initPromise.catch(() => { + }); + if (this.initErr) throw this.initErr; + if (!this.active) throw new Error("ACP \u4F1A\u8BDD\u672A\u5C31\u7EEA"); + } + /** The config options currently in effect: each set_config_option response returns the latest set (levels change after switching models); otherwise use session/new's */ + currentConfig() { + return this.configOptions ?? this.active?.newSessionResponse.configOptions ?? []; + } + syncModelConfigId() { + for (const o of this.currentConfig()) { + if (o.type === "select" && (o.category === "model" || o.id === "model")) { + this.modelConfigId = o.id; + return; + } + } + if (this.o.flavor === "cursor") this.modelConfigId = "model"; + } + // Level options belong to the current model: recompute on every refresh (cleared when switching to a model without levels) + syncThoughtConfigId() { + this.thoughtConfigId = null; + const opts = this.currentConfig().filter((o) => o.type === "select"); + const hit = opts.find((o) => o.category === "thought_level") ?? opts.find((o) => /thought|thinking|effort|reasoning/i.test(o.id ?? "")); + if (hit) this.thoughtConfigId = hit.id; + } + /** set_config_option, then refresh local state from the latest config options it returns */ + async setOption(configId, value) { + if (!this.ctx || !this.active) return false; + try { + const res = await this.ctx.request(methods2.agent.session.setConfigOption, { + sessionId: this.active.sessionId, + configId, + value + }); + if (Array.isArray(res?.configOptions)) { + this.configOptions = res.configOptions; + this.syncThoughtConfigId(); + } + return true; + } catch (e) { + log("info", `ACP set_config_option(${configId}=${value}): ${String(e)}`); + return false; + } + } + async applySessionThought(effort) { + if (!effort || !this.thoughtConfigId) return; + const opt = this.currentConfig().find((o) => o.id === this.thoughtConfigId); + const values = (opt?.options ?? []).flatMap((x) => x.options ? x.options : [x]).map((x) => x.value); + if (values.length && !values.includes(effort)) return; + await this.setOption(this.thoughtConfigId, effort); + } + /** Mode choices the Agent reports: prefer a category=mode select in configOptions, otherwise ACP modes.availableModes */ + modeChoices() { + const opt = this.currentConfig().find( + (o) => o.type === "select" && (o.category === "mode" || o.id === "mode") + ); + if (opt?.options?.length) { + return opt.options.filter((x) => typeof x.value === "string").map((x) => ({ id: x.value, name: x.name ?? x.value, configId: opt.id })); + } + return (this.active?.modes?.availableModes ?? []).map((m) => ({ id: m.id, name: m.name, configId: null })); + } + resolveAcpModeId(mode) { + if (this.o.modeMap) { + const want = this.o.modeMap[mode]; + if (!want) return null; + const hit = this.modeChoices().find( + (m) => m.id === want || m.id.endsWith(`#${want}`) || m.name.toLowerCase() === want + ); + return hit?.id ?? want; + } + if (this.o.flavor === "cursor") { + const map = { default: "agent", plan: "plan", ask: "ask" }; + const want = map[mode] ?? "agent"; + const avail = this.active?.modes?.availableModes ?? []; + const hit = avail.find((m) => m.id === want || m.name.toLowerCase() === want); + return hit?.id ?? want; + } + return mode === "plan" ? "plan" : "default"; + } + async applySessionMode(mode) { + if (!this.ctx || !this.active) return; + const hasAllowAll = this.currentConfig().some((o) => o.id === "allow_all"); + if (this.o.modeMap?.[mode] === "allow-all") { + const agentId = this.modeChoices().find((m) => m.id === "agent" || m.id.endsWith("#agent")); + if (agentId?.configId) await this.setOption(agentId.configId, agentId.id); + if (hasAllowAll) await this.setOption("allow_all", "on"); + return; + } + if (hasAllowAll && this.o.modeMap) await this.setOption("allow_all", "off"); + const modeId = this.resolveAcpModeId(mode); + if (!modeId) return; + try { + const viaConfig = this.modeChoices().find((m) => m.id === modeId)?.configId; + if (viaConfig) { + await this.setOption(viaConfig, modeId); + return; + } + await this.ctx.request(methods2.agent.session.setMode, { + sessionId: this.active.sessionId, + modeId + }); + } catch (e) { + log("info", `ACP setMode(${mode}): ${String(e)}`); + } + } + /** + * Switch model → refresh level options from the new config options it returns → then apply the level (as Paseo does). + * Cursor connects with parameterizedModelPicker: only the base model name is sent (grok-4.6); level / Fast are separate config options. + */ + async applySessionModel(model) { + if (!this.ctx || !this.active) return; + const value = model && this.o.flavor === "cursor" ? model.replace(/\[.*\]$/u, "") : model; + if (value) { + const modelOption = this.currentConfig().find((o) => o.id === (this.modelConfigId ?? "model")); + const values = (modelOption?.options ?? []).flatMap((x) => x.options ? x.options : [x]).map((x) => x.value).filter((x) => typeof x === "string"); + if (!values.length || values.includes(value)) { + await this.setOption(this.modelConfigId ?? "model", value); + } else { + log("info", `\u5FFD\u7565 ${this.o.binName} \u7684\u65E0\u6548\u6A21\u578B\uFF1A${value}`); + } + } + if (this.o.flavor === "cursor" && this.cursorFast && this.currentConfig().some((o) => o.id === "fast")) { + await this.setOption("fast", "true"); + } + await this.applySessionThought(this.o.effort); + } + async applyCursorAutoReviewConfig(on) { + if (this.o.flavor !== "cursor" || !this.ctx || !this.active) return; + for (const configId of ["auto_review", "autoReview", "smart_auto"]) { + try { + await this.ctx.request(methods2.agent.session.setConfigOption, { + sessionId: this.active.sessionId, + configId, + type: "boolean", + value: on + }); + return; + } catch { + } + } + } + /** Cursor only: Fast / Auto-review (can be changed while the session is open) */ + async updateCursorOpts(fast, autoReview) { + this.cursorFast = fast; + void autoReview; + await this.ready(); + await this.applySessionModel(this.o.model); + await this.applyCursorAutoReviewConfig(false); + } + waitPerm(requestId) { + return new Promise((resolve2) => { + this.permPending.set(requestId, resolve2); + }); + } + respond(requestId, answer) { + const r = this.permPending.get(requestId); + if (r) { + this.permPending.delete(requestId); + r(answer); + this.q.push({ + type: "permission_resolved", + requestId, + optionId: answer.optionId, + cancelled: answer.cancelled + }); + } + } + async send(input) { + await this.ready(); + if (this.running) throw new Error("\u5F53\u524D\u8F6E\u6B21\u5C1A\u672A\u7ED3\u675F"); + this.turnNum++; + this.turnId = "u_" + Date.now().toString(36) + "_" + this.turnNum; + this.mapCtx = newMapCtx(); + this.running = true; + this.q.push({ + type: "turn_started", + turnId: this.turnId, + model: this.o.model, + mode: this.o.mode, + effort: this.o.effort + }); + const text = input.text.trim(); + const attachments = input.images ?? []; + if (!text && !attachments.length) { + this.running = false; + this.q.push({ type: "turn_ended", turnId: this.turnId, status: "completed" }); + return; + } + const built = await buildAcpPrompt(text, attachments, this.promptCaps); + const agent = this.o.providerName ?? this.o.binName; + if (built.imagesByPath.length && !this.promptCaps.image) { + const switched = this.o.imagesFollowStartModel && this.initCaps.image && this.o.model !== this.startModel; + this.q.push({ type: "notice", level: "warn", message: tr(switched ? "notice.acpImageByPathSwitched" : "notice.acpImageByPath", { agent, paths: built.imagesByPath.join(", ") }) }); + } + for (const w of built.warnings) this.q.push({ type: "notice", level: "warn", message: tr("notice.attachmentIssue", { detail: w }) }); + if (!built.blocks.length) { + this.running = false; + this.q.push({ type: "turn_ended", turnId: this.turnId, status: "completed" }); + return; + } + void this.runTurn(built.blocks); + } + // Send this turn's prompt; reading updates is the job of the persistent pumpUpdates() (following Paseo: session/update is handled at session level) + async runTurn(prompt) { + this.activeTurnId = this.turnId; + this.wakePump?.(); + this.wakePump = null; + this.active.prompt(prompt).catch(() => { + }); + } + endTurn(ev) { + this.q.push(ev); + this.activeTurnId = null; + this.running = false; + } + /** + * Read session/update for the whole lifetime of the session. + * It used to read only during a turn: the available_commands_update an Agent pushes after session/new and before the first message went unread, + * so the slash menu stayed empty. Now command updates are cached at any time; conversation content becomes timeline events only while a turn is in progress. + */ + async pumpUpdates() { + const session = this.active; + if (!session) return; + while (!this.closed) { + let msg; + try { + msg = await session.nextUpdate(); + } catch (e) { + if (this.closed) return; + const turnId = this.activeTurnId; + if (turnId) { + this.endTurn({ type: "turn_ended", turnId, status: "failed", error: String(e?.message ?? e) }); + } + await new Promise((r) => this.wakePump = r); + continue; + } + if (msg.kind === "session_update") { + const u = msg.update; + if (u.sessionUpdate === "available_commands_update") { + this.applyAvailableCommands(u.availableCommands ?? []); + continue; + } + if (!this.activeTurnId) continue; + for (const ev of mapSessionUpdate(u, this.mapCtx)) this.q.push(ev); + } else if (this.activeTurnId) { + this.endTurn(stopToTurnEnd(msg.stopReason, this.activeTurnId)); + } + } + } + async interrupt() { + await this.ready(); + if (!this.ctx || !this.active) return; + try { + await this.ctx.request(methods2.agent.session.cancel, { sessionId: this.active.sessionId }); + } catch (e) { + log("info", `ACP cancel: ${String(e)}`); + } + } + async setModel(model) { + this.o.model = model; + await this.ready(); + if (this.o.imagesFollowStartModel) { + this.promptCaps = model === this.startModel ? this.initCaps : { ...this.initCaps, image: false }; + } + await this.applySessionModel(model); + } + async setMode(mode) { + this.o.mode = mode; + await this.ready(); + await this.applySessionMode(mode); + } + async setEffort(effort) { + this.o.effort = effort; + await this.ready(); + await this.applySessionThought(effort); + } + async listCommands() { + try { + await this.ready(); + } catch { + return []; + } + if (!this.slashCommands.length) { + await Promise.race([this.commandsReady, new Promise((r) => setTimeout(r, 1e4))]); + } + return this.slashCommands; + } + async close() { + this.closed = true; + this.wakePump?.(); + this.settleCommands(); + for (const r of this.permPending.values()) r({ cancelled: true }); + this.permPending.clear(); + try { + this.active?.dispose(); + } catch { + } + try { + this.conn?.close(); + } catch { + } + await killAcpChild(this.child); + this.child = null; + this.q.close(); + } + }; + } +}); + +// daemon/src/providers/acp/provider.ts +function runVersion3(r, args, timeoutMs = 8e3) { + return new Promise((resolvePromise, reject) => { + let out = ""; + const p = spawnBin(r, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" } }); + const t = setTimeout(() => { + try { + void killTree(p, { graceMs: 500 }); + } catch { + } + reject(new Error("timeout")); + }, timeoutMs); + p.stdout?.on("data", (c) => out += c.toString()); + p.stderr?.on("data", (c) => out += c.toString()); + p.on("error", reject); + p.on("close", (code) => { + clearTimeout(t); + if (code === 0 || out.trim()) resolvePromise(out.trim()); + else reject(new Error("exit " + code)); + }); + }); +} +async function resolveProviderBin(cfg2) { + const names = cfg2.binCandidates?.length ? cfg2.binCandidates : [cfg2.binName]; + return resolveFirstBin(names); +} +function createAcpProvider(cfg2) { + const base = cfg2.capabilities ?? acpCapabilities; + const ORDER = ["plan", "default", "acceptEdits", "bypassPermissions"]; + const caps = cfg2.modeMap ? { ...base, modes: ORDER.filter((m) => cfg2.modeMap[m] !== void 0), modeLabels: cfg2.modeLabels } : base; + return { + id: cfg2.id, + name: cfg2.name, + // images is what the Agent's own initialize says (probe at warm-up / providers.list, or any session); until then unknown + get capabilities() { + const learned2 = knownPromptCaps(cfg2.id); + return learned2 ? { ...caps, images: learned2.image, imagesKnown: true } : { ...caps, images: false, imagesKnown: false }; + }, + async detect() { + const r = await resolveProviderBin(cfg2); + if (!r.ok) { + return { available: false, reason: r.reason, installHint: cfg2.installHint }; + } + let version; + try { + if (cfg2.id === "cursor") { + version = (await runVersion3(r, ["--version"])).split(/\r?\n/)[0]?.trim(); + } else if (cfg2.id === "devin") { + version = (await runVersion3(r, ["version"])).split(/\r?\n/)[0]?.trim(); + } else if (cfg2.id === "opencode") { + version = (await runVersion3(r, ["--version"])).split(/\r?\n/)[0]?.trim(); + } + } catch { + } + return { available: true, bin: r.display, version }; + }, + async listModels() { + const r = await resolveProviderBin(cfg2); + if (!r.ok) return [{ id: "default", name: "\u9ED8\u8BA4", isDefault: true }]; + if (cfg2.fetchModels) return cfg2.fetchModels(r); + return [{ id: "default", name: "\u9ED8\u8BA4", isDefault: true }]; + }, + async createSession(o) { + return new AcpSession({ + threadId: o.threadId, + providerId: cfg2.id, + providerName: cfg2.name, + cwd: o.cwd, + model: o.model, + mode: o.mode, + effort: o.effort, + resumeId: o.resumeId, + binName: cfg2.binName, + binCandidates: cfg2.binCandidates ?? [cfg2.binName], + acpArgs: cfg2.argsFor ? await cfg2.argsFor(o.model) : cfg2.acpArgs, + imagesFollowStartModel: cfg2.imagesFollowStartModel, + appVersion: cfg2.appVersion ?? "0.0.1", + flavor: cfg2.flavor ?? "generic", + modeMap: cfg2.modeMap, + cursorFast: o.cursorFast, + cursorAutoReview: o.cursorAutoReview, + launch: o.launch + }); + } + }; +} +var acpCapabilities, cursorAcpCapabilities; +var init_provider3 = __esm({ + "daemon/src/providers/acp/provider.ts"() { + init_bin(); + init_bin(); + init_session3(); + init_promptCaps(); + acpCapabilities = { + resume: true, + interrupt: true, + steer: false, + images: false, + plan: true, + questions: false, + cost: false, + effort: [], + modes: ["plan", "default", "acceptEdits", "bypassPermissions"] + }; + cursorAcpCapabilities = { + ...acpCapabilities, + questions: true, + modes: ["default", "plan", "ask"] + }; + } +}); + +// daemon/src/providers/acp/dsh.ts +import { createHash as createHash2 } from "node:crypto"; +import { mkdir as mkdir2, writeFile as writeFile3 } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join as join9 } from "node:path"; +function dshRoute(model) { + if (!model) return null; + try { + const v = JSON.parse(model); + if (Array.isArray(v) && v.length === 2 && typeof v[0] === "string" && typeof v[1] === "string" && v[0] && v[1]) { + return { provider: v[0], model: v[1] }; + } + } catch { + } + return null; +} +function dshRoutePatch(route) { + return [ + "# Agent Console: the route dsh's ACP starts on, so initialize advertises image input for it", + "- id: acp", + " config:", + ` provider: ${JSON.stringify(route.provider)}`, + ` model: ${JSON.stringify(route.model)}`, + "" + ].join("\n"); +} +async function dshAcpArgs(model, dir = join9(tmpdir(), "agent-console-dsh")) { + const route = dshRoute(model); + if (!route) return [...DSH_ACP_ARGS]; + const body = dshRoutePatch(route); + const file = join9(dir, `acp-route-${createHash2("sha256").update(body).digest("hex").slice(0, 16)}.yml`); + await mkdir2(dir, { recursive: true }); + await writeFile3(file, body, { mode: 384 }); + return [...DSH_ACP_ARGS, "--patch", file]; +} +function dshDefaultModel(models2, current) { + return models2.find((m) => /magpie/i.test(m.id))?.id ?? current ?? models2[0]?.id ?? null; +} +var DSH_ACP_ARGS; +var init_dsh = __esm({ + "daemon/src/providers/acp/dsh.ts"() { + DSH_ACP_ARGS = ["--profile", "acp"]; + } +}); + +// daemon/src/providers/acp/probe.ts +import { tmpdir as tmpdir2 } from "node:os"; +function flatten(opts) { + const out = []; + for (const o of opts ?? []) { + if (o.options) out.push(...flatten(o.options)); + else if (typeof o.value === "string" && o.value) { + out.push({ id: o.value, name: typeof o.name === "string" && o.name ? o.name : o.value }); + } + } + return out.filter((m, i) => out.findIndex((x) => x.id === m.id) === i); +} +function parseAcpSession(res) { + const opts = res.configOptions ?? []; + const modelOpt = opts.find((o) => o.type === "select" && (o.category === "model" || o.id === "model")); + const thoughtOpt = opts.find((o) => o.type === "select" && (o.category === "thought_level" || o.id === "thought_level")); + let models2 = flatten(modelOpt?.options); + let currentModel = typeof modelOpt?.currentValue === "string" ? modelOpt.currentValue : null; + if (!models2.length && res.models?.availableModels?.length) { + models2 = res.models.availableModels.filter((m) => typeof m.modelId === "string" && m.modelId).map((m) => ({ id: m.modelId, name: m.name || m.modelId })); + models2 = models2.filter((m, i) => models2.findIndex((x) => x.id === m.id) === i); + currentModel = res.models.currentModelId ?? currentModel; + } + const thinking = flatten(thoughtOpt?.options).map((t) => ({ id: t.id, label: t.name })); + const currentThinking = typeof thoughtOpt?.currentValue === "string" ? thoughtOpt.currentValue : null; + return { models: models2, currentModel, thinking, currentThinking }; +} +function probeAcp(r, acpArgs, timeoutMs = 3e4, providerId) { + return probeAcpRaw(r, acpArgs, timeoutMs).then((x) => { + rememberPromptCaps(providerId, x.promptCaps); + return x.parsed; + }); +} +function probeAcpRaw(r, acpArgs, timeoutMs = 3e4, extMethod) { + return new Promise((resolvePromise, reject) => { + const child = spawnResolved(r, acpArgs, { cwd: tmpdir2() }); + let buf = ""; + let nextId = 0; + const pending = /* @__PURE__ */ new Map(); + let done = false; + const finish = (err, v) => { + if (done) return; + done = true; + clearTimeout(timer); + void killAcpChild(child).catch(() => { + }); + if (err) reject(err); + else resolvePromise(v); + }; + const timer = setTimeout(() => finish(new Error("ACP \u63A2\u6D4B\u8D85\u65F6")), timeoutMs); + const send2 = (method, params) => new Promise((res, rej) => { + const id = ++nextId; + pending.set(id, { res, rej }); + child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + child.on("error", (e) => finish(e)); + child.on("close", () => finish(new Error("ACP \u8FDB\u7A0B\u63D0\u524D\u9000\u51FA"))); + child.stderr?.on("data", () => { + }); + child.stdout?.on("data", (d) => { + buf += d.toString(); + let n; + while ((n = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, n).trim(); + buf = buf.slice(n + 1); + if (!line) continue; + let m; + try { + m = JSON.parse(line); + } catch { + continue; + } + if (m.id !== void 0 && !m.method && pending.has(m.id)) { + const p = pending.get(m.id); + pending.delete(m.id); + if (m.error) p.rej(new Error(JSON.stringify(m.error))); + else p.res(m.result); + } else if (m.id !== void 0 && m.method) { + child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, result: null }) + "\n"); + } + } + }); + void (async () => { + try { + const init = await send2("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "Agent Console", version: "probe" } }); + const res = await send2("session/new", { cwd: tmpdir2(), mcpServers: [] }); + let ext = null; + if (extMethod) ext = await send2(extMethod, {}).catch(() => null); + finish(null, { parsed: parseAcpSession(res ?? {}), ext, promptCaps: promptCapsFromInitialize(init) }); + } catch (e) { + finish(e instanceof Error ? e : new Error(String(e))); + } + })(); + }); +} +function probeToModels(p) { + return p.models.map((m) => ({ + id: m.id, + name: m.name, + isDefault: m.id === p.currentModel, + ...p.thinking.length ? { thinkingOptions: p.thinking, defaultThinking: p.currentThinking ?? void 0 } : {} + })); +} +function cursorCatalogToModels(catalog, currentModel) { + const currentBase = currentModel?.replace(/\[.*\]$/u, "") ?? null; + const out = []; + for (const m of catalog.models ?? []) { + if (typeof m.value !== "string" || !m.value) continue; + const id = m.value.replace(/\[.*\]$/u, ""); + const thought = (m.configOptions ?? []).find((o) => o.category === "thought_level"); + const levels = flatten(thought?.options); + out.push({ + id, + name: typeof m.name === "string" && m.name ? m.name : id, + isDefault: id === currentBase, + ...thought && levels.length ? { + thinkingOptions: levels.map((l) => ({ id: l.id, label: EFFORT_LABEL[l.id] ?? l.name })), + defaultThinking: typeof thought.currentValue === "string" ? thought.currentValue : void 0, + thinkingParam: thought.id + } : {} + }); + } + return out; +} +function cursorProbeModels(acpArgs, providerId) { + return async (r) => { + try { + const { parsed, ext, promptCaps } = await probeAcpRaw(r, acpArgs, 4e4, "cursor/list_available_models"); + rememberPromptCaps(providerId, promptCaps); + const fromCatalog = ext ? cursorCatalogToModels(ext, parsed.currentModel) : []; + return fromCatalog.length ? fromCatalog : probeToModels(parsed); + } catch (e) { + log("warn", "Cursor \u6A21\u578B\u63A2\u6D4B\u5931\u8D25", { err: String(e) }); + return []; + } + }; +} +function dshProbeModels(providerId) { + return async (r) => { + try { + const first = await probeAcpRaw(r, DSH_ACP_ARGS, 3e4); + let caps = first.promptCaps; + const start = dshDefaultModel(first.parsed.models, first.parsed.currentModel); + if (start && start !== first.parsed.currentModel && dshRoute(start)) { + caps = await probeAcpRaw(r, await dshAcpArgs(start), 3e4).then((x) => x.promptCaps, () => caps); + } + rememberPromptCaps(providerId, caps); + return probeToModels(first.parsed); + } catch (e) { + log("warn", "ACP \u63A2\u6D4B\u6A21\u578B\u5931\u8D25", { err: String(e) }); + return []; + } + }; +} +function acpProbeModels(acpArgs, providerId) { + return async (r) => { + try { + return probeToModels(await probeAcp(r, acpArgs, 3e4, providerId)); + } catch (e) { + log("warn", "ACP \u63A2\u6D4B\u6A21\u578B\u5931\u8D25", { err: String(e) }); + return []; + } + }; +} +var EFFORT_LABEL; +var init_probe = __esm({ + "daemon/src/providers/acp/probe.ts"() { + init_spawn(); + init_log(); + init_promptCaps(); + init_dsh(); + EFFORT_LABEL = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max" + }; + } +}); + +// daemon/src/providers/acp/specs.ts +function acpConfigForEntry(entry, appVersion) { + if (!ACP_IDS.has(entry.id)) return null; + const base = { + id: entry.id, + name: entry.name, + binName: entry.bins[0] ?? entry.id, + binCandidates: entry.bins, + installHint: entry.installHint, + appVersion + }; + switch (entry.id) { + case "cursor": + return { + ...base, + acpArgs: ["acp"], + flavor: "cursor", + capabilities: cursorAcpCapabilities, + // Each model's own thinking levels come from cursor/list_available_models (as Paseo does) + fetchModels: cursorProbeModels(["acp"], entry.id) + }; + case "devin": + return { + ...base, + acpArgs: ["acp"], + fetchModels: acpProbeModels(["acp"], entry.id), + modeMap: { plan: "plan", default: "ask", acceptEdits: "accept-edits", bypassPermissions: "bypass" }, + modeLabels: { plan: "Plan", default: "Ask", acceptEdits: "Code", bypassPermissions: "Bypass Permissions" } + }; + case "opencode": + return { + ...base, + acpArgs: ["acp"], + fetchModels: acpProbeModels(["acp"], entry.id), + modeMap: { plan: "plan", default: "build" }, + modeLabels: { plan: "Plan", default: "Build" } + }; + case "copilot": + return { + ...base, + acpArgs: ["--acp"], + fetchModels: acpProbeModels(["--acp"], entry.id), + modeMap: { plan: "plan", default: "agent", bypassPermissions: "allow-all" }, + modeLabels: { plan: "Plan", default: "Agent", bypassPermissions: "Allow All" } + }; + case "pi": + return { ...base, acpArgs: [], fetchModels: acpProbeModels([], entry.id), modeMap: { default: "" } }; + case "dsh": + return { + ...base, + acpArgs: DSH_ACP_ARGS, + argsFor: (model) => dshAcpArgs(model), + imagesFollowStartModel: true, + fetchModels: dshProbeModels(entry.id) + }; + default: + return null; + } +} +var ACP_IDS; +var init_specs = __esm({ + "daemon/src/providers/acp/specs.ts"() { + init_provider3(); + init_probe(); + init_dsh(); + ACP_IDS = /* @__PURE__ */ new Set(["cursor", "devin", "opencode", "dsh", "pi", "copilot"]); + } +}); + +// daemon/src/providers/registry.ts +function registerProvider(p) { + providers.set(p.id, p); +} +function getProvider(id) { + return providers.get(id); +} +function listProviders() { + return [...providers.values()]; +} +function initProviders(version = "") { + providers.clear(); + for (const entry of PROVIDER_CATALOG) { + if (entry.id === "claude") registerProvider(createClaudeProvider()); + else if (entry.id === "codex") registerProvider(createCodexProvider(version)); + else { + const acp2 = acpConfigForEntry(entry, version); + if (acp2) registerProvider(createAcpProvider(acp2)); + } + } + if (process.env.AGENT_CONSOLE_MOCK === "1") { + registerProvider(createMockProvider()); + } +} +var providers; +var init_registry = __esm({ + "daemon/src/providers/registry.ts"() { + init_mock(); + init_provider(); + init_provider2(); + init_catalog(); + init_provider3(); + init_specs(); + providers = /* @__PURE__ */ new Map(); + } +}); + +// daemon/src/providers/enabled.ts +function isProviderEnabled(settings, id) { + const m = settings.providerEnabled; + if (m && typeof m === "object" && typeof m[id] === "boolean") return m[id]; + const entry = catalogById(id); + if (!entry) return true; + return entry.status === "verified"; +} +var init_enabled = __esm({ + "daemon/src/providers/enabled.ts"() { + init_catalog(); + } +}); + +// daemon/src/util/pickFolderWin.ts +import { execFile } from "node:child_process"; +function decodePickFolderStdout(stdout) { + const buf = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout, "utf8"); + const line = buf.toString("utf8").trim().split(/\r?\n/).pop()?.trim() ?? ""; + if (!line) return null; + if (/^[A-Za-z0-9+/]+=*$/.test(line)) { + const decoded = Buffer.from(line, "base64").toString("utf8"); + if (decoded && !decoded.includes("\uFFFD")) return decoded; + } + if (!line.includes("\uFFFD")) return line; + const gbkLine = new TextDecoder("gbk").decode(buf).trim().split(/\r?\n/).pop()?.trim() ?? ""; + return gbkLine || null; +} +function pickFolderWindows(initial) { + return new Promise((resolvePromise) => { + if (process.platform !== "win32") return resolvePromise({ available: false, path: null }); + const init = (initial ?? "").replace(/'/g, "''"); + const script = "Add-Type -AssemblyName System.Windows.Forms;$d = New-Object System.Windows.Forms.FolderBrowserDialog;$d.Description = '\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939';$d.ShowNewFolderButton = $true;" + (init ? `$d.SelectedPath = '${init}';` : "") + "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($d.SelectedPath)) }"; + execFile( + "powershell.exe", + ["-NoProfile", "-STA", "-NonInteractive", "-Command", script], + { timeout: 12e4, windowsHide: false, encoding: "buffer" }, + (e, stdout) => { + if (e) return resolvePromise({ available: true, path: null }); + resolvePromise({ available: true, path: decodePickFolderStdout(stdout ?? Buffer.alloc(0)) }); + } + ); + }); +} +var init_pickFolderWin = __esm({ + "daemon/src/util/pickFolderWin.ts"() { + } +}); + +// daemon/src/util/pickFolder.ts +import { execFile as execFile2 } from "node:child_process"; +import { existsSync as existsSync7, statSync as statSync3 } from "node:fs"; +function appleScriptString(s) { + return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; +} +function isDir(p) { + try { + return !!p && existsSync7(p) && statSync3(p).isDirectory(); + } catch { + return false; + } +} +function pickFolderCommand(platform, initial, find = (n) => findOnPath(n)[0] ?? null, env = process.env) { + const init = isDir(initial) ? initial : void 0; + if (platform === "darwin") { + const prompt = appleScriptString("\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939 / Choose a project folder"); + const loc = init ? ` default location (POSIX file ${appleScriptString(init)})` : ""; + return { cmd: "/usr/bin/osascript", args: ["-e", `POSIX path of (choose folder with prompt ${prompt}${loc})`] }; + } + if (platform === "linux" || platform === "freebsd" || platform === "openbsd") { + if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return null; + const zenity = find("zenity"); + if (zenity) { + const args = ["--file-selection", "--directory", "--title=\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939 / Choose a project folder"]; + if (init) args.push(`--filename=${init.endsWith("/") ? init : init + "/"}`); + return { cmd: zenity, args }; + } + const kdialog = find("kdialog"); + if (kdialog) return { cmd: kdialog, args: ["--getexistingdirectory", init ?? env.HOME ?? "/"] }; + } + return null; +} +function pickFolder(initial) { + if (process.platform === "win32") return pickFolderWindows(initial); + const c = pickFolderCommand(process.platform, initial); + if (!c) return Promise.resolve({ available: false, path: null }); + return new Promise((resolvePromise) => { + execFile2(c.cmd, c.args, { timeout: TIMEOUT, encoding: "utf8" }, (e, stdout) => { + if (e) return resolvePromise({ available: true, path: null }); + let p = String(stdout ?? "").replace(/\r?\n$/, "").trim(); + if (p.length > 1) p = p.replace(/\/+$/, ""); + resolvePromise({ available: true, path: p || null }); + }).on("error", () => resolvePromise({ available: false, path: null })); + }); +} +var TIMEOUT; +var init_pickFolder = __esm({ + "daemon/src/util/pickFolder.ts"() { + init_pathscan(); + init_pickFolderWin(); + TIMEOUT = 10 * 60 * 1e3; + } +}); + +// daemon/src/providers/magpieGateway.ts +import { readFile as readFile10 } from "node:fs/promises"; +import { homedir as homedir6 } from "node:os"; +import { join as join10 } from "node:path"; +async function readJson(path3) { + try { + return JSON.parse(await readFile10(path3, "utf8")); + } catch { + return null; + } +} +async function readApplied() { + return await readJson(join10(homedir6(), ".config", "magpie", "applied.json")) ?? {}; +} +async function readClaudeEnv() { + const s = await readJson(join10(homedir6(), ".claude", "settings.json")); + return s?.env ?? {}; +} +async function gatewayBase() { + const env = await readClaudeEnv(); + const raw = env.ANTHROPIC_BASE_URL?.trim(); + return (raw && /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw) ? raw : "http://127.0.0.1:3425").replace(/\/+$/, ""); +} +async function fetchGatewayModelIds() { + const base = await gatewayBase(); + try { + const res = await fetch(`${base}/v1/models`, { signal: AbortSignal.timeout(5e3) }); + if (!res.ok) return []; + const j = await res.json(); + return (j.data ?? []).map((m) => m.id).filter((id) => typeof id === "string" && !!id); + } catch (e) { + log("warn", "\u8BFB\u53D6 magpie \u7F51\u5173\u6A21\u578B\u5217\u8868\u5931\u8D25", { err: String(e) }); + return []; + } +} +function parseCapabilities(raw) { + const out = /* @__PURE__ */ new Map(); + for (const part of (raw ?? "").split(";")) { + const i = part.indexOf("="); + if (i <= 0) continue; + const flags = new Set(part.slice(i + 1).split(",").map((s) => s.trim())); + if (!flags.has("effort")) continue; + const levels = ["low", "medium", "high"]; + if (flags.has("xhigh_effort")) levels.push("xhigh"); + if (flags.has("max_effort")) levels.push("max"); + out.set(part.slice(0, i).trim(), levels); + } + return out; +} +function isPlaceholder(m) { + return !m.id || /^(default|auto)$/i.test(m.id); +} +async function withMagpie(providerId, own) { + const tool = MAGPIE_TOOLS[providerId]; + if (!tool) return own; + const applied = (await readApplied())[tool.key]?.fields; + if (!applied?.model) return own; + const caps = parseCapabilities((await readClaudeEnv()).CLAUDE_CODE_MODEL_CAPABILITIES); + const real = own.filter((m) => !isPlaceholder(m)); + if (providerId === "dsh") { + const appliedId = applied.model.replace(/^magpie\//, ""); + const selected = real.find( + (m) => m.id === applied.model || m.id === appliedId || appliedId.includes("/") && m.id.includes(appliedId) + ); + const fallback = selected ?? real.find((m) => m.isDefault) ?? real[0]; + return real.map((m) => ({ ...m, isDefault: m.id === fallback?.id })); + } + const knowsMagpie = tool.prefix ? real.some((m) => m.id.startsWith(tool.prefix)) : real.length > 0; + let list; + if (knowsMagpie) { + const mine = real.filter((m) => !tool.prefix || m.id.startsWith(tool.prefix)); + const others = tool.prefix ? real.filter((m) => !m.id.startsWith(tool.prefix)) : []; + list = [...mine, ...others]; + } else { + list = (await fetchGatewayModelIds()).map((gid) => ({ id: tool.prefix + gid, name: gid })); + } + const defId = applied.model; + if (!list.some((m) => m.id === defId)) list.unshift({ id: defId, name: defId.slice(tool.prefix.length) || defId }); + list = list.map((m) => ({ ...m, isDefault: m.id === defId })); + list.sort((a, b) => Number(!!b.isDefault) - Number(!!a.isDefault)); + return list.map((m) => { + const name = tool.prefix && m.name.startsWith(tool.prefix) ? m.name.slice(tool.prefix.length) : m.name; + if (m.thinkingOptions?.length && providerId !== "claude") return { ...m, name }; + const levels = caps.get(m.id.slice(tool.prefix.length) || m.id); + if (!levels) return { ...m, name, thinkingOptions: providerId === "claude" ? [] : m.thinkingOptions }; + const def = applied.effort && levels.includes(applied.effort) ? applied.effort : levels.includes("high") ? "high" : levels[0]; + return { + ...m, + name, + thinkingOptions: levels.map((id) => ({ id, label: EFFORT_LABEL2[id] ?? id })), + defaultThinking: m.isDefault ? def : m.defaultThinking ?? def + }; + }); +} +var MAGPIE_TOOLS, EFFORT_LABEL2; +var init_magpieGateway = __esm({ + "daemon/src/providers/magpieGateway.ts"() { + init_log(); + MAGPIE_TOOLS = { + claude: { key: "claude", prefix: "" }, + codex: { key: "codex", prefix: "" }, + opencode: { key: "opencode", prefix: "magpie/" }, + pi: { key: "pi", prefix: "magpie/" }, + dsh: { key: "dsh", prefix: "magpie/" } + }; + EFFORT_LABEL2 = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max" + }; + } +}); + +// daemon/src/providers/infoCache.ts +function withTimeout(p, ms, fb) { + return Promise.race([p, new Promise((r) => setTimeout(() => r(fb), ms))]); +} +function fresh(e, ttl) { + return !!e && Date.now() - e.at < ttl; +} +async function cachedDetect(p) { + const hit = detects.get(p.id); + if (fresh(hit, DETECT_MS)) return hit.value; + let job = inflightDetect.get(p.id); + if (!job) { + job = withTimeout( + p.detect().catch((e) => ({ available: false, reason: String(e?.message ?? e) })), + STEP_TIMEOUT_MS, + { available: false, reason: "\u68C0\u6D4B\u8D85\u65F6" } + ).then((v) => { + if (v.reason !== "\u68C0\u6D4B\u8D85\u65F6") detects.set(p.id, { at: Date.now(), value: v }); + return v; + }).finally(() => inflightDetect.delete(p.id)); + inflightDetect.set(p.id, job); + } + return job; +} +async function cachedModels(p) { + const hit = models.get(p.id); + if (fresh(hit, MODELS_MS)) return hit.value; + let job = inflightModels.get(p.id); + if (!job) { + job = withTimeout(p.listModels().catch(() => []), MODELS_TIMEOUT_MS, null).then(async (v) => { + if (v === null) return models.get(p.id)?.value ?? []; + const list = await withMagpie(p.id, dropPlaceholder(v)).catch(() => dropPlaceholder(v)); + models.set(p.id, { at: Date.now(), value: list }); + return list; + }).finally(() => inflightModels.delete(p.id)); + inflightModels.set(p.id, job); + } + return job; +} +function dropPlaceholder(list) { + if (list.length === 1 && list[0].id === "default" && list[0].name === "\u9ED8\u8BA4") return []; + return list; +} +function clearProviderCache(id) { + if (id) { + detects.delete(id); + models.delete(id); + } else { + detects.clear(); + models.clear(); + } +} +var DETECT_MS, MODELS_MS, STEP_TIMEOUT_MS, MODELS_TIMEOUT_MS, detects, models, inflightDetect, inflightModels; +var init_infoCache = __esm({ + "daemon/src/providers/infoCache.ts"() { + init_magpieGateway(); + DETECT_MS = 60 * 1e3; + MODELS_MS = 10 * 60 * 1e3; + STEP_TIMEOUT_MS = 15e3; + MODELS_TIMEOUT_MS = 35e3; + detects = /* @__PURE__ */ new Map(); + models = /* @__PURE__ */ new Map(); + inflightDetect = /* @__PURE__ */ new Map(); + inflightModels = /* @__PURE__ */ new Map(); + } +}); + +// daemon/src/ws/methods.ts +import { z } from "zod"; +import { existsSync as existsSync8, statSync as statSync4 } from "node:fs"; +import { mkdir as mkdir3, writeFile as writeFile4 } from "node:fs/promises"; +import { homedir as homedir7 } from "node:os"; +import { dirname as dirname3, join as join11, win32 as win32Path } from "node:path"; +import { execFile as execFile3 } from "node:child_process"; +function wrap(schema, fn) { + return (params, ctx) => { + const r = schema.safeParse(params ?? {}); + if (!r.success) { + throw new ApiError("bad_params", "\u53C2\u6570\u4E0D\u5BF9\uFF1A" + r.error.issues.map((i) => i.message).join("\uFF1B")); + } + return fn(r.data, ctx); + }; +} +function registerMethods(gw, svc) { + const zEmpty = z.object({}).passthrough().optional(); + gw.method("app.info", wrap(zEmpty, () => ({ + version: svc.cfg.version, + dataDir: svc.cfg.dataDir, + pid: process.pid, + port: svc.cfg.port + }))); + gw.method("app.shutdown", wrap(zEmpty, () => { + setTimeout(() => process.exit(0), 100); + return { ok: true }; + })); + gw.method("settings.get", wrap(zEmpty, () => svc.settings.get())); + gw.method("settings.set", wrap( + z.object({ patch: z.record(z.string(), z.unknown()) }), + (p) => svc.settings.set(p.patch) + )); + gw.method("sub.add", wrap( + z.object({ ch: z.string().min(1).max(120), afterSeq: z.number().int().nonnegative().optional() }), + async (p, ctx) => { + svc.gateway.subscribe(ctx.conn, p.ch); + const m = /^thread:(.+)$/.exec(p.ch); + if (m && p.afterSeq != null) { + const events = await svc.threads.readEvents(m[1], p.afterSeq, 5e3).catch(() => []); + for (const ev of events) ctx.conn.send({ t: "evt", ch: p.ch, d: { type: "event", event: ev } }); + } + return { ok: true }; + } + )); + gw.method("sub.remove", wrap(z.object({ ch: z.string().min(1).max(120) }), (p, ctx) => { + svc.gateway.unsubscribe(ctx.conn, p.ch); + return { ok: true }; + })); + gw.method("providers.list", wrap( + z.object({ refresh: z.boolean().optional() }).passthrough().optional(), + async (p) => { + if (p?.refresh) { + clearBinCache(); + clearProviderCache(); + } + const settings = await svc.settings.get(); + const rows = await Promise.all( + listProviders().map(async (p2) => { + const entry = catalogById(p2.id); + const enabled = isProviderEnabled(settings, p2.id); + const skip = !enabled || entry?.status === "planned"; + const det = skip ? { available: false, reason: !enabled ? "\u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528" : "\u5C1A\u672A\u63A5\u5165" } : await cachedDetect(p2); + const models2 = !skip && det.available ? await cachedModels(p2) : []; + return { + id: p2.id, + name: p2.name, + available: det.available, + enabled, + status: entry?.status ?? "verified", + ready: entry?.status === "verified", + bin: det.bin, + version: det.version, + reason: !enabled ? "\u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528" : det.reason, + installHint: det.installHint, + capabilities: p2.capabilities, + models: models2 + }; + }) + ); + const byId = new Map(rows.map((r) => [r.id, r])); + const ordered = PROVIDER_CATALOG.map((e) => byId.get(e.id)).filter(Boolean); + for (const r of rows) { + if (!PROVIDER_CATALOG.some((e) => e.id === r.id)) ordered.push(r); + } + return ordered; + } + )); + gw.method("projects.list", wrap(zEmpty, () => svc.projects.list())); + gw.method("projects.create", wrap( + z.object({ path: z.string().min(1), name: z.string().optional() }), + async (p) => { + const proj = await svc.projects.create(p.path, p.name); + const s = await svc.settings.get(); + const dirs = (s.recentDirs ?? []).filter((d) => d.toLowerCase() !== p.path.toLowerCase()); + await svc.settings.set({ recentDirs: [p.path, ...dirs].slice(0, 12) }); + gw.publish("sidebar", { type: "project.upsert", project: proj }); + return proj; + } + )); + gw.method("projects.rename", wrap(z.object({ id: z.string(), name: z.string() }), async (p) => { + const proj = await svc.projects.rename(p.id, p.name); + gw.publish("sidebar", { type: "project.upsert", project: proj }); + return proj; + })); + gw.method("projects.remove", wrap(z.object({ id: z.string() }), async (p) => { + const proj = await svc.projects.get(p.id); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + for (const t of await svc.threads.list()) { + if (t.projectId === p.id) { + await svc.threads.delete(t.id); + gw.publish("sidebar", { type: "thread.remove", id: t.id }); + } + } + await svc.projects.remove(p.id); + gw.publish("sidebar", { type: "project.remove", id: p.id }); + return { ok: true }; + })); + gw.method("projects.open", wrap(z.object({ id: z.string() }), async (p) => { + const proj = await svc.projects.get(p.id); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + if (!existsSync8(proj.path)) throw new ApiError("bad_path", "\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u662F\u6587\u4EF6\u5939"); + const cmd = process.platform === "win32" ? "explorer.exe" : process.platform === "darwin" ? "open" : "xdg-open"; + const target = process.platform === "win32" ? win32Path.normalize(proj.path) : proj.path; + execFile3(cmd, [target], { windowsHide: true }, () => { + }).on("error", () => { + }); + return { ok: true }; + })); + gw.method("fs.check", wrap(z.object({ path: z.string().min(1) }), (p) => { + let exists = false; + let isDir2 = false; + try { + exists = existsSync8(p.path); + isDir2 = exists && statSync4(p.path).isDirectory(); + } catch { + } + return { exists, isDir: isDir2 }; + })); + gw.method("fs.recentDirs", wrap(zEmpty, async () => { + const s = await svc.settings.get(); + return (s.recentDirs ?? []).slice(0, 12); + })); + gw.method("fs.pickFolder", wrap( + z.object({ initial: z.string().optional() }).passthrough().optional(), + (p) => pickFolder(p?.initial) + )); + gw.method("threads.list", wrap(zEmpty, () => svc.threads.list())); + gw.method("threads.create", wrap( + z.object({ + projectId: z.string().min(1), + provider: z.string().min(1), + model: z.string().nullable().optional(), + mode: threadModeSchema.optional(), + effort: z.string().nullable().optional(), + title: z.string().optional(), + providerSessionId: z.string().nullable().optional(), + importedFrom: z.object({ provider: z.string(), sessionId: z.string() }).optional() + }), + async (p) => { + const proj = await svc.projects.get(p.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + if (!existsSync8(proj.path)) throw new ApiError("bad_path", "\u9879\u76EE\u76EE\u5F55\u5DF2\u4E0D\u5B58\u5728"); + if (!p.providerSessionId && !isProviderEnabled(await svc.settings.get(), p.provider)) { + throw new ApiError("bad_params", "\u8BE5 Agent \u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528"); + } + const cat = catalogById(p.provider); + if (!p.providerSessionId && cat && !providerCreatable(cat.status)) { + throw new ApiError("bad_params", `${cat.name} \u5C1A\u672A\u63A5\u5165\uFF0C\u6682\u4E0D\u80FD\u65B0\u5EFA\u5BF9\u8BDD`); + } + const meta = await svc.threads.create(p); + gw.publish("sidebar", { type: "thread.upsert", thread: meta }); + return meta; + } + )); + gw.method("threads.get", wrap( + z.object({ id: z.string(), afterSeq: z.number().int().nonnegative().optional() }), + async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const events = await svc.threads.readEvents(p.id, p.afterSeq ?? 0, 5e3); + const rt = svc.runtime?.(p.id) ?? null; + return { + meta, + events, + queue: rt?.queue ?? [], + pending: rt?.pending ?? [], + runtimeStatus: rt?.status ?? meta.status + }; + } + )); + gw.method("threads.update", wrap( + z.object({ + id: z.string(), + title: z.string().optional(), + pinned: z.boolean().optional(), + archived: z.boolean().optional() + }), + async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const patch = {}; + if (p.title !== void 0) { + patch.title = p.title.trim() || meta.title; + patch.titleAuto = false; + } + if (p.pinned !== void 0) patch.pinned = p.pinned; + if (p.archived !== void 0) patch.archived = p.archived; + const next = await svc.threads.patch(p.id, patch); + gw.publish("sidebar", { type: "thread.upsert", thread: next }); + return next; + } + )); + gw.method("threads.delete", wrap(z.object({ id: z.string() }), async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + if (svc.manager) await svc.manager.deleteThread(p.id); + else await svc.threads.delete(p.id); + gw.publish("sidebar", { type: "thread.remove", id: p.id }); + return { ok: true }; + })); + const projectPath = async (projectId) => { + const proj = await svc.projects.get(projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + if (!existsSync8(proj.path)) throw new ApiError("bad_path", "\u9879\u76EE\u76EE\u5F55\u5DF2\u4E0D\u5B58\u5728"); + return proj.path; + }; + gw.method("git.status", wrap( + z.object({ projectId: z.string() }), + async (p) => svc.git.status(await projectPath(p.projectId)) + )); + gw.method("git.diff", wrap( + z.object({ projectId: z.string(), path: z.string() }), + async (p) => svc.git.diff(await projectPath(p.projectId), p.path) + )); + gw.method("git.discard", wrap( + z.object({ projectId: z.string(), path: z.string() }), + async (p) => svc.git.discard(await projectPath(p.projectId), p.path, svc.cfg.dataDir) + )); + gw.method("files.tree", wrap( + z.object({ projectId: z.string(), dir: z.string().default(".") }), + async (p) => svc.files.tree(await projectPath(p.projectId), p.dir) + )); + gw.method("files.read", wrap( + z.object({ projectId: z.string(), path: z.string() }), + async (p) => svc.files.read(await projectPath(p.projectId), p.path) + )); + gw.method("history.list", wrap(z.object({ projectId: z.string() }), async (p) => { + const proj = await svc.projects.get(p.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + const imported = new Set( + (await svc.threads.list()).filter((t) => t.projectId === p.projectId && t.providerSessionId).map((t) => t.providerSessionId) + ); + const out = []; + for (const provider of listProviders()) { + if (!provider.listHistory) continue; + const items = await provider.listHistory(proj.path).catch(() => []); + for (const i of items) out.push({ ...i, imported: imported.has(i.sessionId) }); + } + return out; + })); + gw.method("history.import", wrap( + z.object({ + projectId: z.string(), + items: z.array(z.object({ provider: z.string(), sessionId: z.string(), title: z.string().optional() })).min(1).max(20) + }), + async (p) => { + const proj = await svc.projects.get(p.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + const metas = []; + for (const item of p.items) { + const provider = getProvider(item.provider); + if (!provider?.readHistory) continue; + const existing = (await svc.threads.list()).find( + (t) => t.projectId === p.projectId && t.provider === item.provider && t.providerSessionId === item.sessionId + ); + if (existing) { + metas.push(existing); + continue; + } + const meta = await svc.threads.create({ + projectId: p.projectId, + provider: item.provider, + title: item.title, + providerSessionId: item.sessionId, + importedFrom: { provider: item.provider, sessionId: item.sessionId } + }); + const events = await provider.readHistory(item.sessionId, proj.path).catch(() => []); + for (const ev of events) { + await svc.threads.appendEvent(meta.id, ev); + } + await svc.threads.patch(meta.id, { status: "closed" }); + gw.publish("sidebar", { type: "thread.upsert", thread: await svc.threads.get(meta.id) }); + metas.push(await svc.threads.get(meta.id)); + } + return metas; + } + )); + gw.method("terminal.list", wrap( + z.object({ projectId: z.string().optional() }).passthrough().optional(), + (p) => svc.terminals.list(p?.projectId) + )); + gw.method("terminal.create", wrap( + z.object({ projectId: z.string(), cols: z.number().int().min(20).max(500).default(80), rows: z.number().int().min(5).max(200).default(24) }), + async (p) => { + const path3 = await projectPath(p.projectId); + const r = await svc.terminals.create(p.projectId, path3, p.cols, p.rows); + if ("error" in r) throw new ApiError("bad_params", r.error); + return r; + } + )); + gw.method("terminal.attach", wrap(z.object({ id: z.string() }), async (p, ctx) => { + const r = svc.terminals.attach(p.id); + if (r.error || r.index == null) throw new ApiError("not_found", r.error ?? "\u7EC8\u7AEF\u4E0D\u5B58\u5728"); + svc.gateway.subscribe(ctx.conn, `terminal:${r.index}`); + return r; + })); + gw.method("terminal.resize", wrap( + z.object({ id: z.string(), cols: z.number().int(), rows: z.number().int() }), + (p) => svc.terminals.resize(p.id, p.cols, p.rows) + )); + gw.method("terminal.kill", wrap(z.object({ id: z.string() }), (p) => svc.terminals.kill(p.id))); + const needManager = () => { + if (!svc.manager) throw new ApiError("unavailable", "\u4F1A\u8BDD\u7BA1\u7406\u5668\u672A\u542F\u52A8"); + return svc.manager; + }; + gw.method("thread.send", wrap( + z.object({ + id: z.string(), + text: z.string(), + attachments: z.array(z.object({ path: z.string(), mime: z.string(), name: z.string().optional() })).optional(), + behavior: z.enum(["queue", "steer"]).optional() + }), + async (p) => { + await needManager().send(p.id, { text: p.text, attachments: p.attachments }, p.behavior ?? "queue"); + return { ok: true }; + } + )); + gw.method("thread.interrupt", wrap(z.object({ id: z.string() }), async (p) => { + await needManager().interrupt(p.id); + return { ok: true }; + })); + gw.method("thread.respond", wrap( + z.object({ + id: z.string(), + requestId: z.string(), + optionId: z.string().optional(), + answers: z.record(z.string(), z.string()).optional(), + cancelled: z.boolean().optional() + }), + async (p) => { + await needManager().respond(p.id, p.requestId, { optionId: p.optionId, answers: p.answers, cancelled: p.cancelled }); + return { ok: true }; + } + )); + gw.method("thread.setModel", wrap(z.object({ id: z.string(), model: z.string().nullable() }), async (p) => { + await needManager().setModel(p.id, p.model); + return { ok: true }; + })); + gw.method("thread.setMode", wrap(z.object({ id: z.string(), mode: threadModeSchema }), async (p) => { + await needManager().setMode(p.id, p.mode); + return { ok: true }; + })); + gw.method("thread.setEffort", wrap(z.object({ id: z.string(), effort: z.string().nullable() }), async (p) => { + await needManager().setEffort(p.id, p.effort); + return { ok: true }; + })); + gw.method("thread.commands", wrap(z.object({ id: z.string(), load: z.boolean().optional() }), async (p) => { + const commands = await needManager().listCommands(p.id, { load: p.load }); + return { commands }; + })); + gw.method("thread.setCursorOpts", wrap( + z.object({ + id: z.string(), + cursorFast: z.boolean().optional(), + cursorAutoReview: z.boolean().optional() + }), + async (p) => { + await needManager().setCursorOpts(p.id, { + cursorFast: p.cursorFast, + cursorAutoReview: p.cursorAutoReview + }); + return { ok: true }; + } + )); + gw.method("thread.setProvider", wrap(z.object({ id: z.string(), provider: z.string().min(1) }), async (p) => { + await needManager().setProvider(p.id, p.provider); + return { ok: true }; + })); + gw.method("thread.close", wrap(z.object({ id: z.string() }), async (p) => { + await needManager().close(p.id); + return { ok: true }; + })); + gw.method("thread.exportMarkdown", wrap(z.object({ id: z.string() }), async (p) => { + const meta = await svc.threads.get(p.id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const events = await svc.threads.readEvents(p.id, 0, 2e4); + const L = []; + L.push(`# ${meta.title}`, ""); + L.push(`> ${meta.provider} \xB7 ${meta.model ?? "\u9ED8\u8BA4"} \xB7 ${meta.mode} \xB7 ${new Date(meta.createdAt).toLocaleString("zh-CN")}`, ""); + for (const e of events) { + switch (e.type) { + case "user_message": + L.push(`**\u6211**${e.steered ? "\uFF08\u63D2\u8BDD\uFF09" : ""}\uFF1A + +${e.text}`, ""); + break; + case "assistant_message": + L.push(e.text, ""); + break; + case "reasoning": + L.push(`
\u601D\u8003 + +${e.text} +
`, ""); + break; + case "tool_call": { + const icon = e.status === "ok" ? "\u2713" : e.status === "failed" ? "\u2717" : "\u2026"; + L.push(`- \`${e.kind}\` ${icon} ${e.title}${e.output ? ` + \`\`\` + ${e.output.slice(0, 2e3)} + \`\`\`` : ""}`); + break; + } + case "plan": + L.push("### \u8BA1\u5212\n\n" + e.markdown, ""); + break; + case "todo": + L.push("### \u5F85\u529E\n\n" + e.items.map((i) => `- [${i.status === "done" || i.status === "completed" ? "x" : " "}] ${i.text}`).join("\n"), ""); + break; + case "notice": + L.push(`_${e.message}_`, ""); + break; + case "turn_ended": + L.push(`\u2014 ${e.status}${e.durationMs ? ` \xB7 ${Math.round(e.durationMs / 1e3)}s` : ""}${e.error ? ` \xB7 ${e.error}` : ""} \u2014`, ""); + break; + default: + break; + } + } + const markdown = L.join("\n"); + const safe = meta.title.replace(/[\\/:*?"<>|\s]+/g, "_").slice(0, 60) || "thread"; + const filename = `${safe}.md`; + const dir = join11(homedir7(), "Downloads"); + await mkdir3(dir, { recursive: true }); + let dest = join11(dir, filename); + if (existsSync8(dest)) { + const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + dest = join11(dir, `${safe}-${stamp2}.md`); + } + await writeFile4(dest, markdown, "utf8"); + revealInFileManager(dest); + return { markdown, filename, path: dest }; + })); +} +function revealInFileManager(filePath) { + if (process.platform === "win32") { + execFile3("explorer.exe", ["/select," + win32Path.normalize(filePath)], { windowsHide: true }, () => { + }).on("error", () => { + }); + return; + } + if (process.platform === "darwin") { + execFile3("open", ["-R", filePath], () => { + }).on("error", () => { + }); + return; + } + execFile3("xdg-open", [dirname3(filePath)], () => { + }).on("error", () => { + }); +} +var threadModeSchema, ApiError; +var init_methods = __esm({ + "daemon/src/ws/methods.ts"() { + init_registry(); + init_catalog(); + init_enabled(); + init_pickFolder(); + init_bin(); + init_infoCache(); + threadModeSchema = z.enum(["plan", "default", "acceptEdits", "bypassPermissions", "ask"]); + ApiError = class extends Error { + code; + constructor(code, message) { + super(message); + this.code = code; + } + }; + } +}); + +// daemon/src/util/atomic.ts +import { writeFile as writeFile5, rename, mkdir as mkdir4 } from "node:fs/promises"; +import { dirname as dirname4 } from "node:path"; +async function writeJsonAtomic(file, obj) { + try { + await mkdir4(dirname4(file), { recursive: true }); + const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + await writeFile5(tmp, JSON.stringify(obj, null, 2), "utf8"); + await rename(tmp, file); + } catch (e) { + if (e instanceof ApiError) throw e; + throw new ApiError("io_error", `\u5199\u5165\u6570\u636E\u5931\u8D25\uFF08${file}\uFF09\uFF1A${e?.message ?? e}`); + } +} +var init_atomic = __esm({ + "daemon/src/util/atomic.ts"() { + init_methods(); + } +}); + +// daemon/src/util/jsonl.ts +import { appendFile, readFile as readFile13, mkdir as mkdir5, rename as rename2, writeFile as writeFile6 } from "node:fs/promises"; +import { dirname as dirname5 } from "node:path"; +async function readJsonl(file) { + let text = ""; + try { + text = await readFile13(file, "utf8"); + } catch { + return []; + } + const out = []; + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t) continue; + try { + out.push(JSON.parse(t)); + } catch { + } + } + return out; +} +function appendJsonl(file, obj) { + const prev = writers.get(file) ?? Promise.resolve(); + const next = prev.then(async () => { + try { + await mkdir5(dirname5(file), { recursive: true }); + await appendFile(file, JSON.stringify(obj) + "\n", "utf8"); + } catch (e) { + if (e instanceof ApiError) throw e; + throw new ApiError("io_error", `\u5199\u5165\u6570\u636E\u5931\u8D25\uFF08${file}\uFF09\uFF1A${e?.message ?? e}`); + } + }); + writers.set( + file, + next.catch(() => { + }) + ); + return next; +} +var writers; +var init_jsonl = __esm({ + "daemon/src/util/jsonl.ts"() { + init_methods(); + writers = /* @__PURE__ */ new Map(); + } +}); + +// daemon/src/migrate/legacy.ts +var legacy_exports = {}; +__export(legacy_exports, { + migrateLegacy: () => migrateLegacy +}); +import { cp as cp2, mkdir as mkdir8, readdir as readdir6, readFile as readFile17 } from "node:fs/promises"; +import { existsSync as existsSync12 } from "node:fs"; +import { join as join18 } from "node:path"; +function stamp() { + const d = /* @__PURE__ */ new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; +} +async function migrateLegacy(dataDir, stores) { + const settings = await stores.settings.get(); + if (settings.v1Migrated) return { migrated: 0, skipped: true }; + const oldDir = join18(dataDir, "sessions"); + const oldSettingsPath = join18(dataDir, "settings.json"); + if (!existsSync12(oldDir) && !existsSync12(oldSettingsPath)) { + await stores.settings.set({ v1Migrated: true }); + return { migrated: 0, skipped: true }; + } + const backup = join18(dataDir, "trash", `legacy-${stamp()}`); + await mkdir8(backup, { recursive: true }); + if (existsSync12(oldDir)) await cp2(oldDir, join18(backup, "sessions"), { recursive: true }).catch(() => { + }); + if (existsSync12(oldSettingsPath)) await cp2(oldSettingsPath, join18(backup, "settings.json")).catch(() => { + }); + const oldSettings = existsSync12(oldSettingsPath) ? JSON.parse(await readFile17(oldSettingsPath, "utf8").catch(() => "{}")) : {}; + const projectIdByPath = /* @__PURE__ */ new Map(); + const lc = (p) => p.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, ""); + for (const p of oldSettings.projects ?? []) { + if (!p?.path) continue; + try { + const proj = await stores.projects.create(p.path, p.name); + projectIdByPath.set(lc(proj.path), proj.id); + } catch (e) { + log("warn", "\u8FC1\u79FB\uFF1A\u5EFA\u9879\u76EE\u5931\u8D25", { path: p.path, err: String(e) }); + } + } + let sessionIds = []; + try { + sessionIds = (await readdir6(oldDir)).filter((d) => validId(d) && existsSync12(join18(oldDir, d, "meta.json"))); + } catch { + } + const metas = []; + for (const id of sessionIds) { + try { + const m = JSON.parse(await readFile17(join18(oldDir, id, "meta.json"), "utf8")); + if (m?.cwd) metas.push(m); + } catch { + } + } + const extraDirs = []; + const projPaths = [...projectIdByPath.keys()]; + for (const m of metas) { + const covered = projPaths.some((pp) => belongsTo(m.cwd, pp)) || extraDirs.some((d) => belongsTo(m.cwd, d)); + if (covered) continue; + for (let i = extraDirs.length - 1; i >= 0; i--) if (belongsTo(extraDirs[i], m.cwd)) extraDirs.splice(i, 1); + extraDirs.push(m.cwd); + } + for (const d of extraDirs) { + if (!existsSync12(d)) continue; + try { + const name = d.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? d; + const proj = await stores.projects.create(d, name); + projectIdByPath.set(lc(proj.path), proj.id); + projPaths.push(lc(proj.path)); + } catch (e) { + log("warn", "\u8FC1\u79FB\uFF1A\u8865\u5EFA\u5B64\u513F\u9879\u76EE\u5931\u8D25", { path: d, err: String(e) }); + } + } + const projectOf = (cwd) => { + const l = lc(cwd); + let best = null; + for (const [pp, id] of projectIdByPath) { + if (belongsTo(l, pp) && (best == null || pp.length > best.length)) best = pp; + } + return best ? projectIdByPath.get(best) : null; + }; + let migrated = 0; + const threadsDir = join18(dataDir, "threads"); + for (const m of metas) { + try { + const projectId = projectOf(m.cwd); + if (!projectId) { + log("warn", "\u8FC1\u79FB\uFF1A\u65E0\u9879\u76EE\u53EF\u5F52\uFF0C\u8DF3\u8FC7", { id: m.id, cwd: m.cwd }); + continue; + } + const testTitle = SKIP_TITLE.test(m.title ?? "") || m.title === "\u9ED8\u8BA4\u5267\u672C\u6F14\u793A"; + const provider = m.agent === "claude" || m.agent === "codex" ? m.agent : "mock"; + const archived = testTitle || m.agent !== "claude" && m.agent !== "codex"; + const now = Date.now(); + const meta = { + id: `t_${m.id.replace(/^s?_?/, "")}`.slice(0, 80), + projectId, + title: m.title || "\u65E7\u5BF9\u8BDD", + titleAuto: false, + provider, + model: m.model || null, + mode: MODE_MAP[m.permission] ?? "default", + effort: null, + providerSessionId: m.cliSessionId || null, + status: "closed", + pinned: !!m.pinned, + archived, + createdAt: m.created || now, + updatedAt: m.updated || now, + lastTurnStatus: m.lastStatus === "ok" ? "completed" : m.lastStatus === "error" ? "failed" : null, + usage: { + inputTokens: m.totalUsage?.inputTokens ?? 0, + outputTokens: m.totalUsage?.outputTokens ?? 0, + cacheReadTokens: m.totalUsage?.cacheReadTokens ?? 0, + costUsd: m.totalCost ?? 0 + }, + importedFrom: m.cliSessionId ? { provider, sessionId: m.cliSessionId } : void 0, + seq: 0 + }; + const tdir = join18(threadsDir, meta.id); + if (existsSync12(tdir)) meta.id = `t_${m.id}_v1`.slice(0, 80); + await mkdir8(join18(threadsDir, meta.id), { recursive: true }); + const oldEvts = (await readFile17(join18(oldDir, m.id, "events.jsonl"), "utf8").catch(() => "")).split("\n").filter((l) => l.trim()).map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }).filter((x) => !!x); + const lines = []; + const openTools = /* @__PURE__ */ new Map(); + let seq = 0; + const push = (ev) => { + seq++; + lines.push({ ...ev, seq, ts: Date.now(), threadId: meta.id }); + }; + for (const e of oldEvts) { + switch (e.type) { + case "user": + push({ type: "user_message", text: String(e.text ?? "") }); + break; + case "text": + push({ type: "assistant_message", itemId: `lg_${seq}`, text: String(e.text ?? "") }); + break; + case "thinking": + push({ type: "reasoning", itemId: `lg_${seq}`, text: String(e.text ?? "") }); + break; + case "tool.call": { + const key = String(e.itemId ?? e.callId ?? `c${seq}`); + openTools.set(key, { kind: String(e.kind ?? "other"), title: String(e.title ?? e.kind ?? "\u5DE5\u5177"), input: e.input, detail: e.detail }); + push({ type: "tool_call", itemId: key, kind: "other", title: String(e.title ?? "\u5DE5\u5177"), status: "running", input: e.input, detail: e.detail }); + break; + } + case "tool.result": { + const key = String(e.itemId ?? e.callId ?? `c${seq}`); + const open = openTools.get(key); + openTools.delete(key); + push({ + type: "tool_call", + itemId: key, + kind: open?.kind ?? "other", + title: open?.title ?? "\u5DE5\u5177", + status: e.ok === false || e.error ? "failed" : "ok", + output: typeof e.output === "string" ? e.output.slice(0, 2e4) : void 0, + detail: open?.detail + }); + break; + } + case "todo": { + const items = e.items ?? []; + push({ type: "todo", items: items.map((i) => ({ text: String(i.text ?? ""), status: i.status === "done" ? "done" : i.status === "in_progress" ? "in_progress" : "todo" })) }); + break; + } + case "permission.denied": + push({ type: "notice", level: "warn", message: `\u5DF2\u62D2\u7EDD\uFF1A${String(e.title ?? e.kind ?? "")}` }); + break; + case "permission.request": + case "permission.resolved": + break; + // Old permission cards are stale; don't recreate them + case "notice": + push({ type: "notice", level: e.level ?? "info", message: String(e.message ?? "") }); + break; + case "run.end": { + const u = e.usage; + if (u) push({ type: "usage", turnId: `lg_t${seq}`, inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0, cacheReadTokens: u.cacheReadTokens ?? 0, costUsd: e.costUsd ?? 0 }); + push({ type: "turn_ended", turnId: `lg_t${seq}`, status: e.status === "error" ? "failed" : e.status === "interrupted" ? "interrupted" : "completed", durationMs: e.ms ?? void 0, error: e.error }); + break; + } + case "run.start": + break; + case "init": + break; + default: + break; + } + } + for (const [key, o] of openTools) { + seq++; + lines.push({ type: "tool_call", itemId: key, kind: "other", title: o.title, status: "failed", seq, ts: Date.now(), threadId: meta.id }); + } + for (const ev of lines) await appendJsonl(join18(threadsDir, meta.id, "timeline.jsonl"), ev); + meta.seq = seq; + await writeJsonAtomic(join18(threadsDir, meta.id, "meta.json"), meta); + migrated++; + } catch (e) { + log("warn", "\u8FC1\u79FB\uFF1A\u4F1A\u8BDD\u5931\u8D25", { id: m.id, err: String(e) }); + } + } + const patch = { v1Migrated: true }; + if (typeof oldSettings.theme === "string") patch.theme = oldSettings.theme; + if (typeof oldSettings.sendKey === "string") patch.sendKey = oldSettings.sendKey; + if (typeof oldSettings.showThinking === "boolean") patch.showThinking = oldSettings.showThinking; + if (typeof oldSettings.defaultAgent === "string") patch.defaultProvider = oldSettings.defaultAgent === "claude" || oldSettings.defaultAgent === "codex" ? oldSettings.defaultAgent : "claude"; + if (typeof oldSettings.defaultPermission === "string") patch.defaultMode = MODE_MAP[oldSettings.defaultPermission] ?? "default"; + await stores.settings.set(patch); + log("info", `\u65E7\u6570\u636E\u8FC1\u79FB\u5B8C\u6210\uFF1A${migrated} \u4E2A\u4F1A\u8BDD\u3001${projectIdByPath.size} \u4E2A\u9879\u76EE\uFF0C\u5907\u4EFD\u5728 ${backup}`); + return { migrated, skipped: false }; +} +var SKIP_TITLE, MODE_MAP, belongsTo; +var init_legacy = __esm({ + "daemon/src/migrate/legacy.ts"() { + init_jsonl(); + init_atomic(); + init_paths(); + init_log(); + SKIP_TITLE = /^\/(slow|deny|error|long|codex)/; + MODE_MAP = { + plan: "plan", + default: "default", + acceptEdits: "acceptEdits", + bypassPermissions: "bypassPermissions", + auto: "acceptEdits", + full: "bypassPermissions" + }; + belongsTo = (cwd, parent) => { + const c = cwd.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, ""); + const p = parent.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, ""); + return c === p || c.startsWith(p + "/"); + }; + } +}); + +// daemon/src/providers/warm.ts +var warm_exports = {}; +__export(warm_exports, { + warmProviderCache: () => warmProviderCache +}); +async function warmProviderCache(settings) { + await Promise.all( + listProviders().map(async (p) => { + if (!isProviderEnabled(settings, p.id) || catalogById(p.id)?.status === "planned") return; + const det = await cachedDetect(p); + if (det.available) await cachedModels(p); + }) + ); +} +var init_warm = __esm({ + "daemon/src/providers/warm.ts"() { + init_registry(); + init_catalog(); + init_enabled(); + init_infoCache(); + } +}); + +// daemon/src/main.ts +import { mkdirSync as mkdirSync2, rmSync as rmSync2, chmodSync as chmodSync2 } from "node:fs"; +import { join as join19, dirname as dirname7 } from "node:path"; +import { fileURLToPath as fileURLToPath3 } from "node:url"; + +// daemon/src/config.ts +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join as join2, resolve, dirname as dirname2 } from "node:path"; +import { fileURLToPath as fileURLToPath2 } from "node:url"; + +// daemon/src/util/buildStamp.ts +import { existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +function resolveDaemonMainJs() { + const self = fileURLToPath(import.meta.url); + const norm = self.replace(/\\/g, "/"); + if (norm.endsWith("/daemon/dist/main.js") || norm.endsWith("/dist/main.js")) return self; + const here = dirname(self); + const candidates = [ + join(here, "..", "..", "dist", "main.js"), + // src/util → daemon/dist/main.js (vitest/tsc) + join(here, "main.js"), + // Already in the dist directory + join(here, "..", "main.js") + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return null; +} +function daemonBuildStamp() { + try { + const mainJs = resolveDaemonMainJs(); + if (!mainJs) return "0"; + return String(statSync(mainJs).mtimeMs); + } catch { + return "0"; + } +} + +// daemon/src/config.ts +function pkgVersion() { + try { + const here = dirname2(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync(join2(here, "..", "..", "package.json"), "utf8")); + return String(pkg.version || "0.0.0"); + } catch { + return "0.0.0"; + } +} +function parseConfig(argv) { + let port = null; + let data = null; + let token = null; + let parentPid; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--port" && argv[i + 1]) port = Number(argv[++i]); + else if (a.startsWith("--port=")) port = Number(a.slice(7)); + else if (a === "--data" && argv[i + 1]) data = argv[++i]; + else if (a.startsWith("--data=")) data = a.slice(7); + else if (a === "--token" && argv[i + 1]) token = argv[++i]; + else if (a.startsWith("--token=")) token = a.slice(8); + else if (a === "--parent-pid" && argv[i + 1]) parentPid = Number(argv[++i]); + else if (a.startsWith("--parent-pid=")) parentPid = Number(a.slice(13)); + } + if (port == null || !Number.isInteger(port) || port < 0 || port > 65535) { + const envPort = Number(process.env.AGENT_CONSOLE_PORT || ""); + port = Number.isInteger(envPort) && envPort >= 0 && envPort <= 65535 ? envPort : 3488; + } + const xdg = data ?? process.env.XDG_CONFIG_HOME ?? join2(homedir(), ".config"); + const dataDir = resolve(join2(xdg, "magpie", "agent-console")); + const idleEnv = Number(process.env.AGENT_CONSOLE_IDLE_EXIT_MIN || ""); + return { + port, + dataDir, + token: token ?? process.env.AGENT_CONSOLE_TOKEN ?? randomBytes(16).toString("hex"), + version: pkgVersion(), + idleExitMinutes: Number.isFinite(idleEnv) && idleEnv > 0 ? idleEnv : 60, + buildStamp: daemonBuildStamp(), + parentPid: Number.isInteger(parentPid) ? parentPid : void 0 + }; +} + +// daemon/src/main.ts +init_log(); + +// daemon/src/http.ts +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { existsSync as existsSync2 } from "node:fs"; +import { join as join4, normalize, extname } from "node:path"; + +// daemon/src/security.ts +import { timingSafeEqual, createHash } from "node:crypto"; +function checkHost(host, port) { + if (!host) return { ok: false, code: 421, message: "Host \u4E0D\u5BF9" }; + const h = host.toLowerCase(); + if (h === `127.0.0.1:${port}` || h === `localhost:${port}`) return { ok: true }; + return { ok: false, code: 421, message: "Host \u4E0D\u5BF9" }; +} +function classifyOrigin(origin, port) { + if (origin === void 0 || origin === "") return "none"; + if (origin === "null") return "opaque"; + const o = origin.toLowerCase(); + if (o === `http://127.0.0.1:${port}` || o === `http://localhost:${port}`) return "self"; + return "foreign"; +} +function tokenEqual(a, b) { + const ha = createHash("sha256").update(a).digest(); + const hb = createHash("sha256").update(b).digest(); + return timingSafeEqual(ha, hb); +} + +// daemon/src/http.ts +init_log(); +var MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".woff2": "font/woff2", + ".map": "application/json", + ".json": "application/json; charset=utf-8" +}; +var MAX_UPLOAD = 50 * 1024 * 1024; +var MAX_MSG = 200 * 1024; +function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return null; + } +} +function send(res, code, body, headers = {}) { + res.writeHead(code, headers); + res.end(body); +} +function sendJson(res, code, obj, headers = {}) { + send(res, code, JSON.stringify(obj), { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers }); +} +var STATIC_HEADERS = { + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer" +}; +function createHttpServer(cfg2, appDir, attachments, hooks = {}) { + const server = createServer(async (req, res) => { + try { + await route(req, res); + } catch (e) { + log("error", "HTTP \u5904\u7406\u5F02\u5E38", { url: req.url, err: String(e) }); + if (!res.headersSent) sendJson(res, 500, { ok: false, e: { code: "internal", message: "\u670D\u52A1\u5185\u90E8\u9519\u8BEF" } }); + else res.end(); + } + }); + async function route(req, res) { + const hostCheck = checkHost(req.headers.host, cfg2.port); + if (!hostCheck.ok) { + sendJson(res, hostCheck.code, { ok: false, e: { code: "bad_host", message: hostCheck.message } }); + return; + } + const origin = classifyOrigin(req.headers.origin, cfg2.port); + if (origin === "foreign") { + sendJson(res, 403, { ok: false, e: { code: "bad_origin", message: "\u6765\u6E90\u4E0D\u5141\u8BB8" } }); + return; + } + const url = new URL(req.url ?? "/", `http://127.0.0.1:${cfg2.port}`); + const pathname = url.pathname; + const isApi = pathname.startsWith("/api/"); + const isAttachment = pathname.startsWith("/attachments/"); + if (req.method === "OPTIONS") { + if (pathname !== "/api/upload" || origin === "none") { + sendJson(res, 403, { ok: false, e: { code: "bad_preflight", message: "\u9884\u68C0\u4E0D\u5141\u8BB8" } }); + return; + } + send(res, 204, "", { + "access-control-allow-origin": origin === "opaque" ? "null" : `http://127.0.0.1:${cfg2.port}`, + "access-control-allow-headers": "x-agent-console, content-type", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-max-age": "600", + vary: "origin" + }); + return; + } + if (isApi || isAttachment) { + const headerToken = req.headers["x-agent-console"]; + const queryToken = isAttachment ? url.searchParams.get("t") : null; + const validToken = typeof headerToken === "string" && tokenEqual(headerToken, cfg2.token) || typeof queryToken === "string" && tokenEqual(queryToken, cfg2.token); + if (!validToken) { + sendJson(res, 403, { ok: false, e: { code: "bad_token", message: "\u4EE4\u724C\u65E0\u6548" } }); + return; + } + if (origin === "opaque") { + res.setHeader("access-control-allow-origin", "null"); + res.setHeader("vary", "origin"); + } + if (isApi) { + await routeApi(req, res, pathname, url); + return; + } + const id = safeDecode(pathname.slice("/attachments/".length)); + const a = id === null ? null : await attachments.get(id); + if (!a) { + sendJson(res, 404, { ok: false, e: { code: "not_found", message: "\u9644\u4EF6\u4E0D\u5B58\u5728" } }); + return; + } + const data2 = await attachments.read(a.id); + send(res, 200, data2 ?? Buffer.alloc(0), { ...STATIC_HEADERS, "content-type": a.mime }); + return; + } + if (origin === "opaque") { + sendJson(res, 403, { ok: false, e: { code: "bad_origin", message: "\u6765\u6E90\u4E0D\u5141\u8BB8" } }); + return; + } + if (req.method !== "GET" && req.method !== "HEAD") { + sendJson(res, 405, { ok: false, e: { code: "method", message: "\u65B9\u6CD5\u4E0D\u5141\u8BB8" } }); + return; + } + if (pathname === "/" || pathname === "/index.html") { + try { + const html = await readFile(join4(appDir, "index.html"), "utf8"); + send(res, 200, html, { ...STATIC_HEADERS, "content-type": "text/html; charset=utf-8" }); + } catch { + send(res, 503, "app \u8FD8\u6CA1\u6784\u5EFA\uFF08\u7F3A app/index.html\uFF09", { ...STATIC_HEADERS, "content-type": "text/plain; charset=utf-8" }); + } + return; + } + const decoded = safeDecode(pathname); + if (decoded === null) { + sendJson(res, 400, { ok: false, e: { code: "bad_path", message: "\u8DEF\u5F84\u4E0D\u5408\u6CD5" } }); + return; + } + const rel = normalize(decoded).replace(/^([\\/])+/, ""); + const full = normalize(join4(appDir, rel)); + if (!full.startsWith(normalize(appDir)) || rel.includes("..")) { + sendJson(res, 403, { ok: false, e: { code: "bad_path", message: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" } }); + return; + } + if (!existsSync2(full) || !(await stat(full)).isFile()) { + sendJson(res, 404, { ok: false, e: { code: "not_found", message: "\u6587\u4EF6\u4E0D\u5B58\u5728" } }); + return; + } + const data = await readFile(full); + send(res, 200, data, { ...STATIC_HEADERS, "content-type": MIME[extname(full).toLowerCase()] ?? "application/octet-stream" }); + } + async function routeApi(req, res, pathname, url) { + if (pathname === "/api/ping" && req.method === "GET") { + sendJson(res, 200, { ok: true, version: cfg2.version, port: cfg2.port, pid: process.pid, dataDir: cfg2.dataDir, buildStamp: cfg2.buildStamp }); + return; + } + if (pathname === "/api/upload" && req.method === "POST") { + const name = url.searchParams.get("name") ?? ""; + if (!attachments.allowedExt(name)) { + sendJson(res, 400, { ok: false, e: { code: "bad_type", message: "\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u7C7B\u578B" } }); + return; + } + const declared = Number(req.headers["content-length"] ?? 0); + if (declared > MAX_UPLOAD) { + res.setHeader("connection", "close"); + sendJson(res, 413, { ok: false, e: { code: "too_large", message: "\u6587\u4EF6\u592A\u5927\uFF08\u4E0A\u9650 50MB\uFF09" } }); + req.resume(); + return; + } + const chunks = []; + let size = 0; + for await (const c of req) { + const b = c; + size += b.length; + if (size > MAX_UPLOAD) { + res.setHeader("connection", "close"); + sendJson(res, 413, { ok: false, e: { code: "too_large", message: "\u6587\u4EF6\u592A\u5927\uFF08\u4E0A\u9650 50MB\uFF09" } }); + return; + } + chunks.push(b); + } + const a = await attachments.save(name, Buffer.concat(chunks)); + sendJson(res, 200, { ok: true, id: a.id, name: a.name, mime: a.mime, size: a.size }); + return; + } + sendJson(res, 404, { ok: false, e: { code: "not_found", message: "\u63A5\u53E3\u4E0D\u5B58\u5728" } }); + } + if (hooks.onUpgrade) { + server.on("upgrade", hooks.onUpgrade); + } + return server; +} + +// daemon/src/store/attachments.ts +init_paths(); +import { writeFile, mkdir, stat as stat2, readFile as readFile2 } from "node:fs/promises"; +import { join as join5, extname as extname2 } from "node:path"; +import { randomBytes as randomBytes2 } from "node:crypto"; +var IMG_EXT = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); +var TEXT_EXT = /* @__PURE__ */ new Set([".txt", ".md", ".log", ".json", ".csv", ".xml", ".yaml", ".yml", ".html", ".css", ".js", ".ts", ".py", ".toml", ".ini", ".conf"]); +var MIME2 = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp" +}; +var TEXT_MIME = "text/plain; charset=utf-8"; +var AttachmentStore = class { + constructor(dir) { + this.dir = dir; + } + dir; + // id → file name: avoids a readdir of the whole directory on every lookup (slow with many attachments) + files = /* @__PURE__ */ new Map(); + safeExt(name) { + const ext = extname2(name).toLowerCase(); + if (IMG_EXT.has(ext) || TEXT_EXT.has(ext)) return ext; + if (!ext) return ".txt"; + return null; + } + allowedExt(name) { + return this.safeExt(name) !== null; + } + async save(name, data) { + const ext = this.safeExt(name); + if (!ext) throw new Error("\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u7C7B\u578B"); + const id = "a_" + Date.now().toString(36) + randomBytes2(3).toString("hex"); + await mkdir(this.dir, { recursive: true }); + const file = join5(this.dir, id + ext); + await writeFile(file, data); + this.files.set(id, id + ext); + const mime = MIME2[ext] ?? TEXT_MIME; + return { id, name: name || "\u672A\u547D\u540D" + ext, mime, size: data.length, path: file }; + } + async get(id) { + if (!validId(id)) return null; + const known = this.files.get(id); + if (known) { + const hit = await this.describe(id, known); + if (hit) return hit; + this.files.delete(id); + } + const { readdir: readdir7 } = await import("node:fs/promises"); + try { + for (const f of await readdir7(this.dir)) { + if (f.startsWith(id + ".")) { + this.files.set(id, f); + return await this.describe(id, f); + } + } + } catch { + } + return null; + } + async describe(id, f) { + const p = join5(this.dir, f); + const s = await stat2(p).catch(() => null); + if (!s?.isFile()) return null; + const ext = extname2(f).toLowerCase(); + return { id, name: f.slice(id.length + 1) || f, mime: MIME2[ext] ?? TEXT_MIME, size: s.size, path: p }; + } + async read(id) { + const a = await this.get(id); + if (!a) return null; + return readFile2(a.path); + } +}; + +// daemon/src/store/settings.ts +init_atomic(); +import { readFile as readFile11 } from "node:fs/promises"; +import { join as join12 } from "node:path"; +var DEFAULT_SETTINGS = { + defaultProvider: "claude", + defaultMode: "default", + defaultEffort: null, + sendKey: "enter", + runningSend: "queue", + showThinking: true, + density: "comfortable", + theme: "auto", + idleCloseMinutes: 30, + idleExitMinutes: 60, + collapsedProjects: [], + panel: { open: false, tab: "changes", width: 440 }, + sidebarWidth: 248, + lastProjectId: null, + lastThreadId: null, + recentDirs: [] +}; +var SettingsStore = class { + file; + cache = null; + writing = Promise.resolve(); + constructor(dataDir) { + this.file = join12(dataDir, "settings.json"); + } + async get() { + if (this.cache) return this.cache; + let raw = {}; + try { + raw = JSON.parse(await readFile11(this.file, "utf8")); + } catch { + } + this.cache = { ...DEFAULT_SETTINGS, ...raw, panel: { ...DEFAULT_SETTINGS.panel, ...raw.panel } }; + return this.cache; + } + async set(patch) { + const cur = await this.get(); + const next = { ...cur, ...patch, panel: { ...cur.panel, ...patch.panel } }; + this.cache = next; + const snap = next; + this.writing = this.writing.then(() => writeJsonAtomic(this.file, snap)); + await this.writing; + return next; + } +}; + +// daemon/src/store/projects.ts +init_atomic(); +init_paths(); +init_methods(); +import { readFile as readFile12 } from "node:fs/promises"; +import { existsSync as existsSync9, statSync as statSync5 } from "node:fs"; +import { join as join13 } from "node:path"; +import { randomBytes as randomBytes3 } from "node:crypto"; +function newId() { + return "p_" + Date.now().toString(36) + randomBytes3(3).toString("hex"); +} +var ProjectsStore = class { + file; + cache = null; + writing = Promise.resolve(); + constructor(dataDir) { + this.file = join13(dataDir, "projects.json"); + } + async list() { + if (this.cache) return this.cache; + try { + this.cache = JSON.parse(await readFile12(this.file, "utf8")); + if (!Array.isArray(this.cache)) this.cache = []; + } catch { + this.cache = []; + } + return this.cache; + } + async get(id) { + if (!validId(id)) return null; + return (await this.list()).find((p) => p.id === id) ?? null; + } + async save(list) { + this.cache = list; + const snap = list; + this.writing = this.writing.then(() => writeJsonAtomic(this.file, snap)); + await this.writing; + } + async create(path3, name) { + if (!path3 || typeof path3 !== "string") throw new ApiError("bad_params", "\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A"); + if (!existsSync9(path3) || !statSync5(path3).isDirectory()) { + throw new ApiError("bad_path", "\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u662F\u6587\u4EF6\u5939"); + } + const np = normPath(path3); + const list = await this.list(); + const dup = list.find((p) => normPath(p.path) === np); + if (dup) return dup; + const proj = { + id: newId(), + name: name?.trim() || path3.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path3, + path: path3, + createdAt: Date.now() + }; + list.push(proj); + await this.save(list); + return proj; + } + async rename(id, name) { + const list = await this.list(); + const p = list.find((x) => x.id === id); + if (!p) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + p.name = name.trim() || p.name; + await this.save(list); + return p; + } + async remove(id) { + const list = await this.list(); + const idx = list.findIndex((x) => x.id === id); + if (idx < 0) return; + list.splice(idx, 1); + await this.save(list); + } +}; + +// daemon/src/store/threads.ts +init_atomic(); +init_jsonl(); +init_paths(); +init_methods(); +init_i18n(); +import { mkdir as mkdir6, rename as rename3, readdir as readdir4, readFile as readFile14 } from "node:fs/promises"; +import { join as join14 } from "node:path"; +import { randomBytes as randomBytes4 } from "node:crypto"; +function newId2() { + return "t_" + Date.now().toString(36) + randomBytes4(3).toString("hex"); +} +var ThreadsStore = class { + dir; + trashDir; + metas = /* @__PURE__ */ new Map(); + loaded = false; + // Serialize meta writes: concurrent patch/appendEvent would overwrite each other (setStatus/patch/provider_session_id run concurrently in the manager) + writeQ = Promise.resolve(); + serial(fn) { + const p = this.writeQ.then(fn, fn); + this.writeQ = p.catch(() => { + }); + return p; + } + constructor(dataDir) { + this.dir = join14(dataDir, "threads"); + this.trashDir = join14(dataDir, "trash", "threads"); + } + tdir(id) { + return join14(this.dir, id); + } + metaPath(id) { + return join14(this.tdir(id), "meta.json"); + } + timelinePath(id) { + return join14(this.tdir(id), "timeline.jsonl"); + } + async loadAll() { + if (this.loaded) return; + this.loaded = true; + let ids = []; + try { + ids = (await readdir4(this.dir)).filter((d) => validId(d)); + } catch { + return; + } + for (const id of ids) { + try { + const m = JSON.parse(await readFile14(this.metaPath(id), "utf8")); + if (m && typeof m.id === "string") this.metas.set(m.id, m); + } catch { + } + } + } + async list() { + await this.loadAll(); + return [...this.metas.values()].sort((a, b) => b.updatedAt - a.updatedAt); + } + async get(id) { + if (!validId(id)) return null; + await this.loadAll(); + return this.metas.get(id) ?? null; + } + async saveMeta(meta) { + this.metas.set(meta.id, meta); + await writeJsonAtomic(this.metaPath(meta.id), meta); + } + async create(o) { + const now = Date.now(); + const meta = { + id: newId2(), + projectId: o.projectId, + title: o.title ?? tr("thread.defaultTitle"), + titleAuto: o.title == null, + provider: o.provider, + model: o.model ?? null, + mode: o.mode ?? "default", + effort: o.effort ?? null, + providerSessionId: o.providerSessionId ?? null, + status: "idle", + pinned: false, + archived: false, + createdAt: now, + updatedAt: now, + lastTurnStatus: null, + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0 }, + importedFrom: o.importedFrom, + seq: 0 + }; + try { + await mkdir6(this.tdir(meta.id), { recursive: true }); + } catch (e) { + throw new ApiError("io_error", `\u521B\u5EFA\u5BF9\u8BDD\u76EE\u5F55\u5931\u8D25\uFF1A${e?.message ?? e}`); + } + await this.saveMeta(meta); + return meta; + } + async patch(id, patch) { + return this.serial(async () => { + const meta = await this.get(id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + if (patch.title !== void 0 && patch.titleAuto === void 0) patch = { ...patch, titleAuto: false }; + const next = { ...meta, ...patch, id: meta.id, updatedAt: Date.now() }; + await this.saveMeta(next); + return next; + }); + } + // Append an event: inside the serialized section take meta.seq+1 as seq, write the jsonl and update meta. + // A passed-in seq is ignored (keeps it strictly increasing); ts/threadId are filled in when missing. + async appendEvent(id, ev) { + return this.serial(async () => { + const meta = await this.get(id); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const full = { + ...ev, + seq: meta.seq + 1, + ts: ev.ts ?? Date.now(), + threadId: id + }; + await appendJsonl(this.timelinePath(id), full); + meta.seq = full.seq; + meta.updatedAt = Date.now(); + this.metas.set(id, meta); + await writeJsonAtomic(this.metaPath(id), meta); + return full; + }); + } + async readEvents(id, afterSeq = 0, limit = 5e3) { + const rows = await readJsonl(this.timelinePath(id)); + const out = rows.filter((e) => typeof e.seq === "number" && e.seq > afterSeq); + return out.slice(-limit); + } + async delete(id) { + const meta = await this.get(id); + if (!meta) return; + const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + await mkdir6(this.trashDir, { recursive: true }); + for (let attempt = 0; ; attempt++) { + try { + await rename3(this.tdir(id), join14(this.trashDir, `${id}-${stamp2}`)); + break; + } catch (e) { + const code = e?.code; + if ((code === "EPERM" || code === "EBUSY" || code === "EACCES") && attempt < 8) { + await new Promise((r) => setTimeout(r, 250 * (attempt + 1))); + continue; + } + throw new ApiError("internal", "\u79FB\u5165\u56DE\u6536\u76EE\u5F55\u5931\u8D25\uFF1A" + String(e)); + } + } + this.metas.delete(id); + } +}; + +// daemon/src/ws/gateway.ts +import { WebSocketServer, WebSocket } from "ws"; +import { randomBytes as randomBytes5 } from "node:crypto"; +init_log(); +init_i18n(); +var Conn = class { + id; + ws; + authed = false; + subs = /* @__PURE__ */ new Set(); + alive = true; + lastMsg = Date.now(); + constructor(ws) { + this.id = "c_" + randomBytes5(4).toString("hex"); + this.ws = ws; + } + send(obj) { + if (this.ws.readyState !== WebSocket.OPEN) return; + try { + const s = JSON.stringify(obj); + if (this.overflow(s.length)) { + log("warn", `\u8FDE\u63A5 ${this.id} \u51FA\u7AD9\u7F13\u51B2\u8D85 8MB\uFF0C\u65AD\u5F00`); + this.ws.close(4e3, "buffer_overflow"); + return; + } + this.ws.send(s); + } catch { + } + } + // Look at bytes really still unsent in the socket (only grows when the client is stuck), not the cumulative amount sent + overflow(next) { + return this.ws.bufferedAmount + next > 8 * 1024 * 1024; + } + sendBinary(frame) { + if (this.ws.readyState !== WebSocket.OPEN) return; + if (this.overflow(frame.length)) { + log("warn", `\u8FDE\u63A5 ${this.id} \u51FA\u7AD9\u7F13\u51B2\u8D85 8MB\uFF0C\u65AD\u5F00`); + this.ws.close(4e3, "buffer_overflow"); + return; + } + try { + this.ws.send(frame); + } catch { + } + } +}; +var Gateway = class { + constructor(cfg2) { + this.cfg = cfg2; + this.wss = new WebSocketServer({ noServer: true }); + } + cfg; + wss; + conns = /* @__PURE__ */ new Map(); + methods = /* @__PURE__ */ new Map(); + helloTimers = /* @__PURE__ */ new Map(); + onTerminalInput; + method(name, fn) { + this.methods.set(name, fn); + } + get connectionCount() { + return this.conns.size; + } + handleUpgrade(req, socket, head) { + const hostCheck = checkHost(req.headers.host, this.cfg.port); + if (!hostCheck.ok) { + socket.write("HTTP/1.1 421 Misdirected Request\r\n\r\n"); + socket.destroy(); + return; + } + if (classifyOrigin(req.headers.origin, this.cfg.port) === "foreign") { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.cfg.port}`); + if (url.pathname !== "/ws") { + socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); + socket.destroy(); + return; + } + this.wss.handleUpgrade(req, socket, head, (ws) => this.onConn(ws)); + } + onConn(ws) { + const conn = new Conn(ws); + this.conns.set(conn.id, conn); + const timer = setTimeout(() => { + if (!conn.authed) ws.close(4401, "auth_timeout"); + }, 5e3); + this.helloTimers.set(conn.id, timer); + ws.on("message", (data, isBinary) => { + conn.lastMsg = Date.now(); + if (isBinary) { + this.onBinary(conn, data); + return; + } + let msg; + try { + msg = JSON.parse(String(data)); + } catch { + conn.send({ t: "res", id: 0, ok: false, e: { code: "bad_json", message: "\u6D88\u606F\u4E0D\u662F\u5408\u6CD5 JSON" } }); + return; + } + this.onMessage(conn, msg); + }); + ws.on("close", () => { + this.conns.delete(conn.id); + const t = this.helloTimers.get(conn.id); + if (t) clearTimeout(t); + this.helloTimers.delete(conn.id); + }); + ws.on("error", () => { + }); + } + onMessage(conn, msg) { + if (msg.t === "hello") { + if (!conn.authed) { + if (typeof msg.token === "string" && tokenEqual(msg.token, this.cfg.token)) { + conn.authed = true; + setAppLocale(msg.lang); + const t = this.helloTimers.get(conn.id); + if (t) clearTimeout(t); + conn.send({ t: "hello.ok", version: this.cfg.version, serverTime: Date.now() }); + } else { + conn.send({ t: "res", id: 0, ok: false, e: { code: "bad_token", message: "\u4EE4\u724C\u65E0\u6548" } }); + conn.ws.close(4401, "bad_token"); + } + } + return; + } + if (msg.t === "ping") { + conn.send({ t: "pong" }); + return; + } + if (msg.t === "req") { + if (!conn.authed) { + conn.send({ t: "res", id: msg.id, ok: false, e: { code: "unauthorized", message: "\u672A\u9274\u6743" } }); + return; + } + void this.dispatch(conn, msg); + return; + } + } + async dispatch(conn, msg) { + const fn = this.methods.get(msg.m); + if (!fn) { + conn.send({ t: "res", id: msg.id, ok: false, e: { code: "not_found", message: `\u672A\u77E5\u65B9\u6CD5 ${msg.m}` } }); + return; + } + try { + const r = await fn(msg.p, { connId: conn.id, conn }); + conn.send({ t: "res", id: msg.id, ok: true, r: r ?? null }); + } catch (e) { + const err = e; + conn.send({ + t: "res", + id: msg.id, + ok: false, + e: { code: err.code ?? "internal", message: err.message ?? "\u670D\u52A1\u5185\u90E8\u9519\u8BEF" } + }); + } + } + // Terminal binary frame: [op:1][terminalIndex:2 BE][payload] + onBinary(conn, buf) { + if (buf.length < 3) return; + const op = buf.readUInt8(0); + const index = buf.readUInt16BE(1); + this.onTerminalInput?.(conn, index, op, buf.subarray(3)); + } + publish(ch, data) { + for (const c of this.conns.values()) { + if (c.authed && c.subs.has(ch)) c.send({ t: "evt", ch, d: data }); + } + } + publishTerminal(index, payload) { + const frame = Buffer.concat([Buffer.from([1, index >> 8 & 255, index & 255]), payload]); + for (const c of this.conns.values()) { + if (c.authed && c.subs.has(`terminal:${index}`)) c.sendBinary(frame); + } + } + subscribe(conn, ch) { + conn.subs.add(ch); + } + unsubscribe(conn, ch) { + conn.subs.delete(ch); + } + closeAll() { + for (const c of this.conns.values()) { + try { + c.ws.close(1001, "server_shutdown"); + } catch { + } + } + this.conns.clear(); + } +}; + +// daemon/src/main.ts +init_methods(); +init_atomic(); +init_registry(); + +// daemon/src/agents/manager.ts +init_attachmentRefs(); +init_registry(); +init_catalog(); +init_enabled(); +init_infoCache(); + +// daemon/src/timeline/coalescer.ts +var Coalescer = class { + windowMs; + acc = /* @__PURE__ */ new Map(); + onEmit; + constructor(windowMs, onEmit) { + this.windowMs = windowMs; + this.onEmit = onEmit; + } + // Push a delta; the first goes out immediately, the rest are merged within the window and sent trailing + push(itemId, text) { + const acc = this.acc.get(itemId); + if (!acc) { + this.onEmit(itemId, text); + this.acc.set(itemId, { text: "", timer: setTimeout(() => this.flush(itemId), this.windowMs) }); + return; + } + acc.text += text; + } + // Must flush before the complete event arrives to keep ordering (deltas before the complete event) + flush(itemId) { + const acc = this.acc.get(itemId); + if (!acc) return; + if (acc.timer) { + clearTimeout(acc.timer); + acc.timer = null; + } + if (acc.text.length > 0) this.onEmit(itemId, acc.text); + this.acc.delete(itemId); + } + // Call before an itemId's complete event arrives: flush the deltas not yet sent + flushItem(itemId) { + this.flush(itemId); + } + dispose() { + for (const id of [...this.acc.keys()]) this.flush(id); + } +}; + +// daemon/src/agents/manager.ts +init_methods(); +init_log(); +init_i18n(); +init_commands(); +var SLASH_CACHE_TTL_MS = 10 * 60 * 1e3; +var DELTA_TYPES = ["assistant_delta", "reasoning_delta", "tool_output_delta"]; +var deltaKey = (type, itemId) => `${type}\0${itemId}`; +var AgentManager = class { + constructor(gw, threads, projects, settings, attachments) { + this.gw = gw; + this.threads = threads; + this.projects = projects; + this.settings = settings; + this.attachments = attachments; + } + gw; + threads; + projects; + settings; + attachments; + runtimes = /* @__PURE__ */ new Map(); + recycleTimer = null; + slashCache = /* @__PURE__ */ new Map(); + /** attachment.path in the frontend's thread.send is really an upload id; resolve it to the file under dataDir/attachments */ + resolveAttachments(refs) { + return resolveAttachmentRefs(this.attachments, refs); + } + attachmentLabels(refs) { + return refs.map((a) => ({ id: a.path, name: (a.name ?? "").trim() || a.path, mime: a.mime })); + } + // Called at startup: running/initializing conversations are recovered as closed (§5.7.6) + async recoverOnStart() { + for (const meta of await this.threads.list()) { + if (meta.status === "running" || meta.status === "initializing") { + await this.appendRaw(meta.id, { + type: "notice", + level: "warn", + message: tr("notice.daemonRestart") + }); + const events = await this.threads.readEvents(meta.id, 0, 5e4); + let open = null; + for (const e of events) { + if (e.type === "turn_started") open = e.turnId ?? "unknown"; + else if (e.type === "user_message" && !e.steered) open = open ?? "unknown"; + else if (e.type === "turn_ended") open = null; + } + if (open !== null) { + await this.appendRaw(meta.id, { type: "turn_ended", status: "interrupted", ...open && open !== "unknown" ? { turnId: open } : {} }); + } + const resolved = new Set( + events.filter((e) => e.type === "permission_resolved").map((e) => e.requestId) + ); + for (const e of events) { + if (e.type === "permission_request" && !resolved.has(e.requestId)) { + await this.appendRaw(meta.id, { + type: "permission_resolved", + requestId: e.requestId, + cancelled: true + }); + } + } + await this.threads.patch(meta.id, { status: "closed", lastTurnStatus: "interrupted" }); + } + } + this.recycleTimer = setInterval(() => void this.recycleIdle(), 6e4); + this.recycleTimer.unref?.(); + } + /** Close every session; the returned Promise resolves once each close finishes (or fails). Callers add their own timeout. */ + shutdown() { + if (this.recycleTimer) clearInterval(this.recycleTimer); + const all = [...this.runtimes.values()].map((rt) => rt.session?.close().catch(() => { + })); + this.runtimes.clear(); + return Promise.all(all).then(() => { + }); + } + // Idle-exit check (§5.2.7): busy if any runtime is not idle + anyBusy() { + for (const rt of this.runtimes.values()) { + if (rt.status === "running" || rt.status === "initializing") return true; + } + return false; + } + runtimeInfo(threadId) { + const rt = this.runtimes.get(threadId); + if (!rt) return null; + return { + status: rt.status, + queue: rt.queue, + pending: [...rt.pending.values()].map((p) => ({ + requestId: p.requestId, + kind: p.kind, + options: p.options + })) + }; + } + rt(threadId) { + let r = this.runtimes.get(threadId); + if (!r) { + r = { + threadId, + session: null, + status: "idle", + currentTurnId: null, + queue: [], + pending: /* @__PURE__ */ new Map(), + allowAlways: /* @__PURE__ */ new Set(), + lastActivity: Date.now(), + coalescer: this.newCoalescer(threadId), + interruptTimer: null, + endedTurnIds: /* @__PURE__ */ new Set(), + starting: false, + commands: [], + sessionCreating: null + }; + this.runtimes.set(threadId, r); + } + return r; + } + newCoalescer(threadId) { + return new Coalescer(60, (key, text) => { + const i = key.indexOf("\0"); + const type = key.slice(0, i); + void this.pushEvent(threadId, { type, itemId: key.slice(i + 1), text }); + }); + } + flushDeltas(rt, itemId) { + for (const t of DELTA_TYPES) rt.coalescer.flushItem(deltaKey(t, itemId)); + } + // ---- send ---- + async send(threadId, input, behavior = "queue") { + const meta = await this.mustThread(threadId); + if (meta.archived) { + await this.threads.patch(threadId, { archived: false }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + const rt = this.rt(threadId); + rt.lastActivity = Date.now(); + const attachmentRefs = input.attachments ?? []; + if (meta.status === "closed" && rt.session) { + try { + await rt.session.close(); + } catch { + } + rt.session = null; + rt.commands = []; + } + if (!rt.starting && rt.session === null && (rt.status === "running" || rt.status === "initializing" || rt.status === "error")) { + rt.status = "idle"; + } + if (rt.status === "running" || rt.starting || rt.status === "initializing") { + const provider = this.providerOf(meta); + if (behavior === "steer" && provider?.capabilities.steer && rt.session?.steer && !rt.starting) { + try { + const images = await this.resolveAttachments(attachmentRefs); + await rt.session.steer({ text: input.text, images }); + await this.appendRaw(threadId, { + type: "user_message", + text: input.text, + steered: true, + attachments: this.attachmentLabels(attachmentRefs) + }); + return; + } catch (e) { + log("info", `thread ${threadId} \u63D2\u8BDD\u5931\u8D25\uFF0C\u6539\u4E3A\u6392\u961F\uFF1A${String(e)}`); + } + } + const q = { id: "q" + Date.now().toString(36), text: input.text, attachments: attachmentRefs }; + rt.queue.push(q); + this.gw.publish(`thread:${threadId}`, { type: "queue", queue: rt.queue }); + return; + } + await this.startTurn(threadId, input.text, attachmentRefs); + } + async startTurn(threadId, text, attachmentRefs) { + const rt = this.rt(threadId); + rt.starting = true; + try { + await this.startTurnInner(threadId, text, attachmentRefs); + } finally { + rt.starting = false; + } + } + async startTurnInner(threadId, text, attachmentRefs) { + const meta = await this.mustThread(threadId); + const rt = this.rt(threadId); + const provider = await this.assertProviderUsable(meta.provider, { allowDisabled: !!meta.providerSessionId }); + if (!rt.session) { + await this.setStatus(threadId, "initializing"); + try { + await this.createSessionIfNeeded(threadId, { updateStatus: true }); + } catch (e) { + await this.appendRaw(threadId, { type: "notice", level: "error", message: tr("notice.sessionStartFailed", { detail: String(e?.message ?? e) }) }); + await this.appendRaw(threadId, { type: "turn_ended", status: "failed", error: String(e?.message ?? e) }); + await this.setStatus(threadId, "error"); + throw e; + } + } + const trimmed = text.trim(); + if (meta.titleAuto && trimmed) { + await this.threads.patch(threadId, { title: trimmed.slice(0, 40), titleAuto: true }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + await this.appendRaw(threadId, { + type: "user_message", + text, + attachments: this.attachmentLabels(attachmentRefs) + }); + await this.setStatus(threadId, "running"); + try { + const images = await this.resolveAttachments(attachmentRefs); + await rt.session.send({ text, images }); + } catch (e) { + const msg = String(e?.message ?? e); + await this.appendRaw(threadId, { type: "notice", level: "error", message: tr("notice.sendFailed", { detail: msg }) }); + await this.appendRaw(threadId, { type: "turn_ended", status: "failed", error: msg }); + await this.threads.patch(threadId, { lastTurnStatus: "failed" }); + rt.currentTurnId = null; + await this.setStatus(threadId, "idle"); + throw e; + } + } + // ---- event pump ---- + async pumpEvents(threadId, session) { + const rt = this.rt(threadId); + let ended = false; + try { + for await (const ev of session.events) { + await this.onProviderEvent(threadId, ev); + if (ev.type === "turn_started") ended = false; + if (ev.type === "turn_ended") ended = true; + } + } catch (e) { + log("warn", `thread ${threadId} \u4E8B\u4EF6\u6D41\u5F02\u5E38\uFF1A${String(e)}`); + } + if (!ended && this.rt(threadId).session === session) { + await this.onProviderEvent(threadId, { + type: "turn_ended", + turnId: rt.currentTurnId ?? "unknown", + status: "failed", + error: tr("turn.agentExit") + }); + rt.session = null; + await this.setStatus(threadId, "closed"); + } + } + async onProviderEvent(threadId, ev) { + const rt = this.rt(threadId); + rt.lastActivity = Date.now(); + if (ev.type === "provider_session_id") { + await this.threads.patch(threadId, { providerSessionId: ev.id }); + return; + } + if (ev.type === "slash_commands") { + rt.commands = ev.commands; + const meta = await this.mustThread(threadId); + const proj = await this.projects.get(meta.projectId); + if (proj) this.putSlashCache(meta.provider, proj.path, ev.commands); + this.gw.publish(`thread:${threadId}`, { type: "commands", commands: ev.commands }); + return; + } + if (ev.type === "turn_started") { + rt.currentTurnId = ev.turnId; + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "assistant_delta" || ev.type === "reasoning_delta" || ev.type === "tool_output_delta") { + rt.coalescer.push(deltaKey(ev.type, ev.itemId), ev.text); + return; + } + const evItemId = ev.itemId; + if (typeof evItemId === "string") this.flushDeltas(rt, evItemId); + if (ev.type === "assistant_message" || ev.type === "reasoning") { + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "usage") { + const meta = await this.mustThread(threadId); + const u = meta.usage; + await this.threads.patch(threadId, { + usage: { + inputTokens: u.inputTokens + ev.inputTokens, + outputTokens: u.outputTokens + ev.outputTokens, + cacheReadTokens: u.cacheReadTokens + ev.cacheReadTokens, + costUsd: u.costUsd + ev.costUsd + } + }); + await this.pushEvent(threadId, ev); + return; + } + if (ev.type === "permission_request" || ev.type === "question") { + const options = ev.type === "permission_request" ? ev.options : []; + const pending = { + kind: ev.type === "permission_request" ? "permission" : "question", + requestId: ev.requestId, + options, + resolve: () => { + }, + event: ev + }; + const toolName = ev.detail?.toolName; + if (toolName && rt.allowAlways.has(toolName)) { + rt.session?.respond(ev.requestId, { optionId: "accept" }); + return; + } + rt.pending.set(ev.requestId, pending); + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "permission_resolved" || ev.type === "question_answered") { + if (!rt.pending.delete(ev.requestId)) return; + await this.appendRaw(threadId, ev); + return; + } + if (ev.type === "turn_ended") { + if (ev.turnId && ev.turnId !== "unknown" && rt.endedTurnIds.has(ev.turnId)) return; + if (rt.interruptTimer) { + clearTimeout(rt.interruptTimer); + rt.interruptTimer = null; + } + if (ev.turnId && ev.turnId !== "unknown") rt.endedTurnIds.add(ev.turnId); + rt.coalescer.dispose(); + rt.coalescer = this.newCoalescer(threadId); + await this.appendRaw(threadId, ev); + await this.threads.patch(threadId, { lastTurnStatus: ev.status }); + rt.currentTurnId = null; + if (ev.status === "failed" && rt.session && !this.alive(rt.session)) rt.session = null; + await this.setStatus(threadId, "idle"); + const next = rt.queue.shift(); + if (next) { + this.gw.publish(`thread:${threadId}`, { type: "queue", queue: rt.queue }); + await this.startTurn(threadId, next.text, next.attachments).catch((e) => { + log("warn", `thread ${threadId} \u6392\u961F\u6D88\u606F\u542F\u52A8\u5931\u8D25\uFF1A${String(e)}`); + }); + } + return; + } + await this.appendRaw(threadId, ev); + } + alive(_s) { + return true; + } + // ---- respond (permission/question answers) ---- + async respond(threadId, requestId, answer) { + const rt = this.rt(threadId); + const pending = rt.pending.get(requestId); + if (!pending) throw new ApiError("not_found", "\u6388\u6743/\u63D0\u95EE\u4E0D\u5B58\u5728\u6216\u5DF2\u56DE\u5E94"); + if (pending.kind === "permission" && answer.optionId) { + const valid = pending.options.some((o) => o.id === answer.optionId); + if (!valid) throw new ApiError("bad_params", `optionId \u975E\u6CD5\uFF1A${answer.optionId}`); + } + rt.pending.delete(requestId); + if (pending.kind === "permission") { + await this.appendRaw(threadId, { + type: "permission_resolved", + requestId, + optionId: answer.optionId, + cancelled: answer.cancelled + }); + const opt = pending.options.find((o) => o.id === answer.optionId); + if (opt?.kind === "allow_always") { + const toolName = pending.event.detail?.toolName; + if (toolName) rt.allowAlways.add(toolName); + } + } else { + await this.appendRaw(threadId, { + type: "question_answered", + requestId, + answers: answer.answers ?? {} + }); + } + rt.session?.respond(requestId, answer); + } + // ---- interrupt ---- + async interrupt(threadId) { + const rt = this.rt(threadId); + if (!rt.session || rt.status !== "running") return; + for (const [id, p] of rt.pending) { + await this.appendRaw(threadId, { type: "permission_resolved", requestId: id, cancelled: true }); + p.resolve({ cancelled: true }); + } + rt.pending.clear(); + await rt.session.interrupt(); + const turnId = rt.currentTurnId; + rt.interruptTimer = setTimeout(() => { + rt.interruptTimer = null; + if (rt.currentTurnId === turnId && turnId) { + void this.onProviderEvent(threadId, { type: "turn_ended", turnId, status: "interrupted" }).catch((e) => { + log("warn", `thread ${threadId} \u6253\u65AD\u515C\u5E95\u5931\u8D25\uFF1A${String(e)}`); + }); + } + }, 1e3); + } + // ---- setModel / setMode / setEffort ---- + async setModel(threadId, model) { + const meta = await this.mustThread(threadId); + let effort = meta.effort; + const p = getProvider(meta.provider); + if (p) { + const models2 = await cachedModels(p); + const row = models2.find((m) => model ? m.id === model : m.isDefault) ?? models2[0]; + const opts = row?.thinkingOptions ?? []; + if (effort && opts.length && !opts.some((o) => o.id === effort)) { + effort = null; + await this.appendRaw(threadId, { + type: "notice", + level: "info", + message: tr("notice.effortResetOnModel") + }); + } + } + await this.threads.patch(threadId, { model, effort }); + const rt = this.rt(threadId); + if (rt.session) await rt.session.setModel(model); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async setMode(threadId, mode) { + await this.threads.patch(threadId, { mode }); + const rt = this.rt(threadId); + if (rt.session) await rt.session.setMode(mode); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async setEffort(threadId, effort) { + const meta = await this.mustThread(threadId); + await this.threads.patch(threadId, { effort }); + const rt = this.rt(threadId); + if (rt.session) await rt.session.setEffort(effort); + if (meta.provider === "claude" && (rt.status === "running" || rt.status === "initializing")) { + await this.appendRaw(threadId, { + type: "notice", + level: "info", + message: tr("notice.effortNextTurn") + }); + } + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async setCursorOpts(threadId, patch) { + const meta = await this.mustThread(threadId); + if (meta.provider !== "cursor") throw new ApiError("bad_params", "\u4EC5 Cursor \u5BF9\u8BDD\u652F\u6301\u8BE5\u9009\u9879"); + const next = await this.threads.patch(threadId, patch); + const rt = this.rt(threadId); + const sess = rt.session; + if (sess?.updateCursorOpts) { + await sess.updateCursorOpts(!!next.cursorFast, !!next.cursorAutoReview); + } + this.gw.publish("sidebar", { type: "thread.upsert", thread: next }); + } + /** The Agent can be switched only before any user message and while not running */ + async setProvider(threadId, providerId) { + await this.mustThread(threadId); + const meta = await this.threads.get(threadId); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + const rt = this.rt(threadId); + if (rt.status === "running" || rt.status === "initializing" || rt.starting) { + throw new ApiError("busy", "\u5BF9\u8BDD\u8FD0\u884C\u4E2D\uFF0C\u4E0D\u80FD\u66F4\u6362 Agent"); + } + const events = await this.threads.readEvents(threadId, 0, 5e3); + if (events.some((e) => e.type === "user_message")) { + throw new ApiError("bad_params", "\u5DF2\u6709\u6D88\u606F\uFF0C\u4E0D\u80FD\u66F4\u6362 Agent"); + } + const p = await this.assertProviderUsable(providerId); + if (rt.session) { + try { + await rt.session.close(); + } catch { + } + rt.session = null; + rt.commands = []; + } + rt.status = "idle"; + await this.threads.patch(threadId, { + provider: providerId, + model: null, + mode: p.capabilities.modes.includes(meta.mode) ? meta.mode : p.capabilities.modes[0] ?? "default", + effort: null, + providerSessionId: null, + status: "idle" + }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + async close(threadId) { + const rt = this.rt(threadId); + if (rt.session) { + try { + await rt.session.close(); + } catch { + } + rt.session = null; + rt.commands = []; + } + await this.setStatus(threadId, "closed"); + } + /** Slash commands: no session by default; only with load:true do we create a session for providers that need one to list them */ + async listCommands(threadId, opts) { + let meta; + try { + meta = await this.mustThread(threadId); + } catch { + return []; + } + const rt = this.rt(threadId); + const proj = await this.projects.get(meta.projectId); + if (!proj) return []; + if (meta.provider === "codex") { + try { + const commands = codexSlashCommandsFromEntries(await loadCodexSlashEntries(proj.path)); + rt.commands = commands; + this.putSlashCache(meta.provider, proj.path, commands); + return commands; + } catch { + return this.getSlashCache(meta.provider, proj.path) ?? rt.commands; + } + } + if (rt.session) { + try { + const commands = await rt.session.listCommands(); + rt.commands = commands; + this.putSlashCache(meta.provider, proj.path, commands); + return commands; + } catch { + return rt.commands.length ? rt.commands : this.getSlashCache(meta.provider, proj.path) ?? []; + } + } + const cached = this.getSlashCache(meta.provider, proj.path); + if (cached) { + rt.commands = cached; + return cached; + } + if (!opts?.load) return []; + try { + const session = await this.createSessionIfNeeded(threadId, { updateStatus: false }); + const commands = await session.listCommands(); + rt.commands = commands; + this.putSlashCache(meta.provider, proj.path, commands); + return commands; + } catch { + return []; + } + } + slashCacheKey(provider, projectPath) { + return `${provider}\0${projectPath}`; + } + getSlashCache(provider, projectPath) { + const hit = this.slashCache.get(this.slashCacheKey(provider, projectPath)); + if (!hit) return null; + if (Date.now() > hit.expires) { + this.slashCache.delete(this.slashCacheKey(provider, projectPath)); + return null; + } + return hit.commands; + } + putSlashCache(provider, projectPath, commands) { + if (!commands.length) return; + this.slashCache.set(this.slashCacheKey(provider, projectPath), { + commands, + expires: Date.now() + SLASH_CACHE_TTL_MS + }); + } + async createSessionIfNeeded(threadId, opts) { + const rt = this.rt(threadId); + if (rt.session) return rt.session; + if (rt.sessionCreating) return rt.sessionCreating; + const creating = this.createSessionInner(threadId, opts); + rt.sessionCreating = creating; + try { + return await creating; + } finally { + if (rt.sessionCreating === creating) rt.sessionCreating = null; + } + } + async createSessionInner(threadId, opts) { + const meta = await this.mustThread(threadId); + const rt = this.rt(threadId); + if (rt.session) return rt.session; + const provider = await this.assertProviderUsable(meta.provider, { allowDisabled: !!meta.providerSessionId }); + const proj = await this.projects.get(meta.projectId); + if (!proj) throw new ApiError("not_found", "\u9879\u76EE\u4E0D\u5B58\u5728"); + let model = meta.model; + if (meta.provider === "dsh" && model) { + const available = await cachedModels(provider).catch(() => []); + if (available.length && !available.some((m) => m.id === model)) { + model = available.find((m) => /magpie/i.test(m.id))?.id ?? available.find((m) => m.isDefault)?.id ?? null; + await this.threads.patch(threadId, { model }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: await this.threads.get(threadId) }); + } + } else if (meta.provider === "dsh" && !model) { + const available = await cachedModels(provider).catch(() => []); + const magpie = available.filter((m) => /magpie/i.test(m.id)); + model = (magpie.find((m) => m.isDefault) ?? magpie[0] ?? available.find((m) => m.isDefault))?.id ?? null; + } + if (opts.updateStatus) await this.setStatus(threadId, "initializing"); + const settings = await this.settings.get(); + const custom = settings.providerCommand?.[meta.provider]; + const launch = custom?.executable?.trim() ? { cmd: custom.executable.trim(), prefixArgs: custom.args ?? [] } : void 0; + rt.session = await provider.createSession({ + threadId, + cwd: proj.path, + model, + mode: meta.mode, + effort: meta.effort, + resumeId: meta.providerSessionId, + cursorFast: meta.cursorFast, + cursorAutoReview: meta.cursorAutoReview, + launch + }); + void this.pumpEvents(threadId, rt.session); + return rt.session; + } + async deleteThread(threadId) { + await this.close(threadId); + this.runtimes.delete(threadId); + await this.threads.delete(threadId); + } + // ---- internals ---- + async mustThread(threadId) { + const meta = await this.threads.get(threadId); + if (!meta) throw new ApiError("not_found", "\u5BF9\u8BDD\u4E0D\u5B58\u5728"); + return meta; + } + providerOf(meta) { + return getProvider(meta.provider); + } + async assertProviderUsable(providerId, opts = {}) { + const p = getProvider(providerId); + if (!p) throw new ApiError("bad_params", `\u672A\u77E5 Agent\uFF1A${providerId}`); + const s = await this.settings.get(); + if (!opts.allowDisabled && !isProviderEnabled(s, providerId)) { + throw new ApiError("bad_params", `${p.name} \u5DF2\u5728\u8BBE\u7F6E\u4E2D\u7981\u7528`); + } + const entry = catalogById(providerId); + if (entry && !providerCreatable(entry.status)) { + throw new ApiError("bad_params", `${p.name} \u5C1A\u672A\u63A5\u5165\uFF0C\u6682\u4E0D\u80FD\u65B0\u5EFA\u5BF9\u8BDD`); + } + const det = await cachedDetect(p); + if (!det.available) throw new ApiError("bad_params", det.reason ?? `${p.name} \u672A\u5B89\u88C5\u6216\u4E0D\u53EF\u7528`); + return p; + } + async setStatus(threadId, status) { + const rt = this.rt(threadId); + rt.status = status; + const meta = await this.threads.patch(threadId, { status }); + this.gw.publish("sidebar", { type: "thread.upsert", thread: meta }); + this.gw.publish(`thread:${threadId}`, { type: "status", status }); + } + // Append an event: seq is assigned in the store's serialized section, persisted, then pushed to thread: + async appendRaw(threadId, ev) { + const full = await this.threads.appendEvent(threadId, { ...ev, ts: Date.now() }); + this.gw.publish(`thread:${threadId}`, { type: "event", event: full }); + } + // Push without persisting (for coalescer deltas; usage is already accumulated above). + // Unpersisted events take no seq: seq=-1 so the frontend dedupes only by persisted seq, + // otherwise deltas would push the client's maxSeq up and the following persisted events would be dropped by mistake. + async pushEvent(threadId, ev) { + const full = { ...ev, seq: -1, ts: Date.now(), threadId }; + this.gw.publish(`thread:${threadId}`, { type: "event", event: full }); + } + async recycleIdle() { + const s = await this.settings.get(); + const limit = (s.idleCloseMinutes ?? 30) * 6e4; + for (const rt of this.runtimes.values()) { + if (rt.status === "idle" && rt.pending.size === 0 && Date.now() - rt.lastActivity > limit) { + log("info", `\u7A7A\u95F2\u56DE\u6536 thread ${rt.threadId}`); + await this.close(rt.threadId); + } + } + } +}; + +// daemon/src/git/service.ts +import { execFile as execFile4 } from "node:child_process"; +import { existsSync as existsSync10 } from "node:fs"; +import { cp, mkdir as mkdir7, readFile as readFile15, rename as rename4, rm, stat as stat9 } from "node:fs/promises"; +import { join as join15, normalize as normalize2, sep as sep2 } from "node:path"; +var TIMEOUT2 = 15e3; +var DIFF_LIMIT = 1024 * 1024; +function runGit(cwd, args) { + return new Promise((res) => { + execFile4("git", ["-c", "core.quotepath=false", ...args], { cwd, timeout: TIMEOUT2, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (e, stdout, stderr) => { + const code = e && typeof e.code === "number" ? e.code : e ? 1 : 0; + const missing = e?.code === "ENOENT"; + res({ code, out: String(stdout ?? ""), err: missing ? "\u627E\u4E0D\u5230 git\uFF08\u8BF7\u5148\u5B89\u88C5 git \u5E76\u52A0\u5165 PATH\uFF09" : String(stderr || (e?.message ?? "")) }); + }); + }); +} +function insidePath(projectPath, p) { + const rel = normalize2(p).replace(/\\/g, "/"); + const abs = normalize2(join15(projectPath, rel)); + const proj = normalize2(projectPath); + if (abs !== proj && !abs.startsWith(proj + sep2)) return null; + return rel.replace(/\\/g, "/"); +} +async function moveAcrossDevices(from, to, ops = {}) { + const ren = ops.rename ?? rename4; + try { + await ren(from, to); + } catch (e) { + const code = e?.code; + if (code !== "EXDEV") throw e; + await (ops.cp ?? cp)(from, to, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true }); + await (ops.rm ?? rm)(from, { recursive: true, force: true }); + } +} +var GitService = class { + // projectPath is resolved from projectId by the caller (the service doesn't know about the store) + async status(projectPath) { + const inside = await runGit(projectPath, ["rev-parse", "--is-inside-work-tree"]); + if (inside.code !== 0 || inside.out.trim() !== "true") return { isRepo: false }; + const br = await runGit(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]); + const st = await runGit(projectPath, ["status", "--porcelain=v1", "-z", "-uall"]); + if (st.code !== 0) return { isRepo: true, branch: br.out.trim() || void 0, error: st.err.trim() || "status \u5931\u8D25" }; + const files = []; + const parts = st.out.split("\0"); + for (let i = 0; i < parts.length; i++) { + const rec5 = parts[i]; + if (!rec5 || rec5.length < 4) continue; + const index = rec5[0]; + const worktree = rec5[1]; + let path3 = rec5.slice(3); + let kind = "modified"; + if (index === "?" && worktree === "?") kind = "untracked"; + else if (index === "R" || worktree === "R") { + kind = "renamed"; + i++; + } else if (index === "A" || worktree === "A") kind = "added"; + else if (index === "D" || worktree === "D") kind = "deleted"; + files.push({ path: path3, index, worktree, kind }); + } + return { isRepo: true, branch: br.out.trim() || void 0, files }; + } + // Unified diff text; {tooLarge:true} when the diff is > 1MB + async diff(projectPath, p) { + const rel = insidePath(projectPath, p); + if (!rel) return { error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const ls = await runGit(projectPath, ["ls-files", "--error-unmatch", "--", rel]); + if (ls.code !== 0) { + const full = join15(projectPath, rel); + if (!existsSync10(full)) return { error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }; + const s = await stat9(full); + if (!s.isFile()) return { error: "\u4E0D\u662F\u6587\u4EF6" }; + if (s.size > DIFF_LIMIT) return { tooLarge: true }; + const text = await readFile15(full, "utf8").catch(() => null); + if (text === null) return { error: "\u4E8C\u8FDB\u5236\u6216\u65E0\u6CD5\u8BFB\u53D6" }; + const lines = text.split("\n").map((l) => "+" + l).join("\n"); + return { untracked: true, diff: `--- /dev/null ++++ b/${rel} +@@ -0,0 +1,${text.split("\n").length} @@ +${lines}` }; + } + let d = await runGit(projectPath, ["diff", "--no-color", "--", rel]); + if (d.code !== 0) return { error: d.err.trim() || "diff \u5931\u8D25" }; + if (!d.out.trim()) { + d = await runGit(projectPath, ["diff", "--cached", "--no-color", "--", rel]); + if (d.code !== 0) return { error: d.err.trim() || "diff \u5931\u8D25" }; + } + if (d.out.length > DIFF_LIMIT) return { tooLarge: true }; + return { diff: d.out }; + } + // Tracked → git restore; untracked → move to trash/discarded// + async discard(projectPath, p, dataDir) { + const rel = insidePath(projectPath, p); + if (!rel) return { ok: false, error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + if (rel === "." || rel === "" || rel === "./") return { ok: false, error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const ls = await runGit(projectPath, ["ls-files", "--error-unmatch", "--", rel]); + if (ls.code === 0) { + let r = await runGit(projectPath, ["restore", "--staged", "--worktree", "--", rel]); + if (r.code !== 0) r = await runGit(projectPath, ["checkout", "--", rel]); + if (r.code !== 0) return { ok: false, error: r.err.trim() || "\u653E\u5F03\u5931\u8D25" }; + return { ok: true }; + } + const full = join15(projectPath, rel); + if (!existsSync10(full)) return { ok: false, error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }; + const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + const dest = join15(dataDir, "trash", "discarded", stamp2, rel); + await mkdir7(join15(dest, ".."), { recursive: true }); + try { + await moveAcrossDevices(full, dest); + } catch (e) { + return { ok: false, error: "\u79FB\u52A8\u5931\u8D25\uFF1A" + String(e?.message ?? e) }; + } + return { ok: true, trashedTo: dest }; + } +}; + +// daemon/src/files/service.ts +import { readdir as readdir5, readFile as readFile16, stat as stat10 } from "node:fs/promises"; +import { join as join16, normalize as normalize3, sep as sep3, extname as extname3 } from "node:path"; +import ignore from "ignore"; +function decodeText(buf) { + try { + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(buf); + return { text, encoding: "utf8" }; + } catch { + } + try { + const text = new TextDecoder("gbk", { fatal: true }).decode(buf); + return { text, encoding: "gbk" }; + } catch { + } + return { text: new TextDecoder("utf-8").decode(buf), encoding: "utf8" }; +} +var MAX_READ = 1024 * 1024; +var MAX_PER_LEVEL = 2e3; +var ALWAYS_IGNORE = /* @__PURE__ */ new Set([".git", "node_modules"]); +var LANG = { + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".json": "json", + ".md": "markdown", + ".py": "python", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".hpp": "cpp", + ".cs": "csharp", + ".css": "css", + ".scss": "scss", + ".html": "xml", + ".htm": "xml", + ".xml": "xml", + ".svg": "xml", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "ini", + ".ini": "ini", + ".sh": "bash", + ".bash": "bash", + ".ps1": "powershell", + ".sql": "sql", + ".lua": "lua", + ".rb": "ruby", + ".php": "php", + ".swift": "swift", + ".kt": "kotlin", + ".vue": "xml", + ".svelte": "xml", + ".dockerfile": "dockerfile", + ".txt": "plaintext", + ".log": "plaintext" +}; +function insidePath2(projectPath, p) { + const rel = normalize3(p || ".").replace(/\\/g, "/"); + const abs = normalize3(join16(projectPath, rel)); + const proj = normalize3(projectPath); + if (abs !== proj && !abs.startsWith(proj + sep3)) return null; + return rel; +} +var FileService = class { + // Cache an ignore instance per project (.gitignore changes are picked up on the next daemon restart; acceptable cost) + // Invalidate by .gitignore mtime: an edited .gitignore takes effect the next time a directory is expanded + igCache = /* @__PURE__ */ new Map(); + async igFor(projectPath) { + const gi = join16(projectPath, ".gitignore"); + const st = await stat10(gi).catch(() => null); + const mtime = st?.isFile() ? st.mtimeMs : -1; + const hit = this.igCache.get(projectPath); + if (hit && hit.mtime === mtime) return hit.ig; + let ig = null; + if (mtime >= 0) { + try { + ig = ignore().add(await readFile16(gi, "utf8")); + } catch { + ig = null; + } + } + this.igCache.set(projectPath, { mtime, ig }); + return ig; + } + async tree(projectPath, dir) { + const rel = insidePath2(projectPath, dir); + if (rel === null) return { error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const abs = join16(projectPath, rel); + const s = await stat10(abs).catch(() => null); + if (!s?.isDirectory()) return { error: "\u76EE\u5F55\u4E0D\u5B58\u5728" }; + const ig = await this.igFor(projectPath); + const dirents = await readdir5(abs, { withFileTypes: true }).catch(() => null); + if (!dirents) return { error: "\u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55" }; + const out = []; + for (const d of dirents) { + if (out.length >= MAX_PER_LEVEL) break; + const name = d.name; + if (ALWAYS_IGNORE.has(name)) continue; + const childRel = rel === "." ? name : `${rel}/${name}`; + let isDir2 = d.isDirectory(); + if (d.isSymbolicLink()) { + const st = await stat10(join16(projectPath, childRel)).catch(() => null); + if (!st) continue; + isDir2 = st.isDirectory(); + } else if (!d.isFile() && !isDir2) { + continue; + } + if (ig) { + if (ig.ignores(isDir2 ? childRel + "/" : childRel)) continue; + } + out.push({ name, path: childRel, isDir: isDir2 }); + } + out.sort((a, b) => a.isDir === b.isDir ? a.name.localeCompare(b.name, "zh-CN") : a.isDir ? -1 : 1); + return { entries: out }; + } + async read(projectPath, p) { + const rel = insidePath2(projectPath, p); + if (rel === null) return { error: "\u8DEF\u5F84\u4E0D\u5141\u8BB8" }; + const abs = join16(projectPath, rel); + const s = await stat10(abs).catch(() => null); + if (!s) return { error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }; + if (!s.isFile()) return { error: "\u4E0D\u662F\u6587\u4EF6" }; + if (s.size > MAX_READ) return { tooLarge: true }; + const buf = await readFile16(abs).catch(() => null); + if (!buf) return { error: "\u65E0\u6CD5\u8BFB\u53D6" }; + const head = buf.subarray(0, 8192); + if (head.includes(0)) return { binary: true }; + const { text, encoding } = decodeText(buf); + return { text, encoding, language: LANG[extname3(rel).toLowerCase()] ?? "plaintext" }; + } +}; + +// daemon/src/terminal/service.ts +init_pathscan(); +init_log(); +import { spawn as spawn3, execFileSync as execFileSync2 } from "node:child_process"; +import { accessSync as accessSync2, chmodSync, constants as fsc, existsSync as existsSync11, statSync as statSync6 } from "node:fs"; +import { createRequire as moduleRequire } from "node:module"; +import { release } from "node:os"; +import { dirname as dirname6, join as join17 } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +var MAX_TERMS_PER_PROJECT = 8; +var BUF_MAX = 2 * 1024 * 1024; +var SNAP_LINES = 5e3; +function isExecFile(file) { + try { + accessSync2(file, fsc.X_OK); + return statSync6(file).isFile(); + } catch { + return false; + } +} +function pickShell(o = {}) { + const platform = o.platform ?? process.platform; + const env = o.env ?? process.env; + const find = o.find ?? ((n) => findOnPath(n, { platform, env })[0] ?? null); + const isExec = o.isExec ?? isExecFile; + if (platform === "win32") { + for (const s of ["pwsh", "powershell"]) { + const hit = find(s); + if (hit) return hit; + } + const sysRoot = env.SystemRoot || env.SYSTEMROOT || env.windir || "C:\\Windows"; + const ps = `${sysRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + if (isExec(ps)) return ps; + return env.ComSpec || env.COMSPEC || "cmd.exe"; + } + if (env.SHELL && env.SHELL.startsWith("/") && isExec(env.SHELL)) return env.SHELL; + const order = platform === "darwin" ? ["/bin/zsh", "/bin/bash", "/bin/sh"] : ["/bin/bash", "/usr/bin/bash", "/bin/zsh", "/bin/sh"]; + for (const s of order) if (isExec(s)) return s; + return "/bin/sh"; +} +function simpleShellArgs(shell, platform = process.platform) { + if (platform === "win32") { + if (/(^|[\\/])cmd(\.exe)?$/i.test(shell)) return ["/Q"]; + return ["-NoLogo"]; + } + return ["-i"]; +} +function fixSpawnHelper() { + if (process.platform !== "darwin") return; + try { + const req = moduleRequire(import.meta.url); + const root = dirname6(req.resolve("node-pty/package.json")); + for (const sub of [`prebuilds/darwin-${process.arch}`, "build/Release"]) { + const f = join17(root, sub, "spawn-helper"); + if (existsSync11(f) && !isExecFile(f)) { + chmodSync(f, 493); + log("info", "\u5DF2\u4FEE\u590D node-pty spawn-helper \u7684\u53EF\u6267\u884C\u4F4D", { file: f }); + } + } + } catch { + } +} +function conptyInfo() { + const build = Number(release().split(".")[2]); + return { backend: "conpty", buildNumber: Number.isFinite(build) ? build : 0 }; +} +var nextIndex = 1; +var rid5 = () => "t_" + Math.random().toString(36).slice(2, 10); +var TerminalService = class { + constructor(gw) { + this.gw = gw; + gw.onTerminalInput = (conn, index, op, payload) => this.onInput(conn, index, op, payload); + } + gw; + terms = /* @__PURE__ */ new Map(); + byIndex = /* @__PURE__ */ new Map(); + ptyLib; + async loadPty() { + if (this.ptyLib !== void 0) return this.ptyLib; + try { + fixSpawnHelper(); + this.ptyLib = await import("node-pty"); + } catch (e) { + log("warn", "node-pty \u52A0\u8F7D\u5931\u8D25\uFF0C\u7EC8\u7AEF\u9000\u5316\u65E0 PTY \u65B9\u6848", { err: String(e) }); + this.ptyLib = null; + } + return this.ptyLib; + } + list(projectId) { + return [...this.terms.values()].filter((t) => !projectId || t.projectId === projectId).map((t) => ({ id: t.id, index: t.index, projectId: t.projectId, title: t.title, alive: t.alive, simple: t.simple })); + } + get count() { + return [...this.terms.values()].filter((t) => t.alive).length; + } + async create(projectId, cwd, cols, rows) { + const alive = [...this.terms.values()].filter((t) => t.projectId === projectId && t.alive); + if (alive.length >= MAX_TERMS_PER_PROJECT) return { error: `\u6BCF\u4E2A\u9879\u76EE\u6700\u591A ${MAX_TERMS_PER_PROJECT} \u4E2A\u7EC8\u7AEF` }; + const shell = pickShell(); + const term = { + id: rid5(), + index: nextIndex++, + projectId, + cwd, + title: `\u7EC8\u7AEF ${alive.length + 1}`, + alive: true, + simple: false, + pty: null, + proc: null, + buf: "", + pend: "", + timer: null + }; + const pty = await this.loadPty(); + if (pty) { + try { + const p = pty.spawn(shell, [], { + name: "xterm-256color", + cols: Math.max(20, cols), + rows: Math.max(5, rows), + cwd, + env: process.env + }); + term.pty = { write: (d) => p.write(d), resize: (c, r) => p.resize(c, r), kill: () => p.kill(), onData: (cb) => p.onData(cb), onExit: (cb) => p.onExit(cb), pid: p.pid }; + term.pty.onData((d) => this.onData(term, d)); + term.pty.onExit(() => this.onExit(term)); + } catch (e) { + log("warn", "pty.spawn \u5931\u8D25\uFF0C\u9000\u5316\u65E0 PTY \u65B9\u6848", { err: String(e) }); + } + } + if (!term.pty) { + term.simple = true; + const args = simpleShellArgs(shell); + const proc = spawn3(shell, args, { + cwd, + stdio: "pipe", + windowsHide: true, + env: process.env, + detached: process.platform !== "win32" + }); + term.proc = proc; + const outDec = new StringDecoder("utf8"); + const errDec = new StringDecoder("utf8"); + proc.stdout.on("data", (d) => this.onData(term, outDec.write(d))); + proc.stderr.on("data", (d) => this.onData(term, errDec.write(d))); + proc.on("exit", () => this.onExit(term)); + proc.on("error", () => this.onExit(term)); + } + this.terms.set(term.id, term); + this.byIndex.set(term.index, term); + return { id: term.id, index: term.index, simple: term.simple }; + } + attach(id) { + const t = this.terms.get(id); + if (!t) return { error: "\u7EC8\u7AEF\u4E0D\u5B58\u5728" }; + const lines = t.buf.split("\n"); + const snapshot = lines.length > SNAP_LINES ? lines.slice(-SNAP_LINES).join("\n") : t.buf; + const windowsPty = process.platform === "win32" && t.pty ? conptyInfo() : void 0; + return { index: t.index, snapshot, simple: t.simple, ...windowsPty ? { windowsPty } : {} }; + } + indexOf(id) { + return this.terms.get(id)?.index ?? null; + } + resize(id, cols, rows) { + const t = this.terms.get(id); + if (!t || !t.alive) return { ok: false }; + t.pty?.resize(Math.max(20, cols), Math.max(5, rows)); + return { ok: true }; + } + kill(id) { + const t = this.terms.get(id); + if (!t) return { ok: false }; + this.killTerm(t); + return { ok: true }; + } + killAll() { + for (const t of this.terms.values()) this.killTerm(t, true); + } + // ---- internals ---- + // Killing only the shell would orphan the npm run dev etc. it started → on Windows taskkill /T kills the whole tree + killTree(pid, sync, group) { + if (!pid) return; + if (process.platform !== "win32") { + if (group) { + try { + process.kill(-pid, "SIGTERM"); + } catch { + } + const t = setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + } + }, 1500); + t.unref?.(); + } + return; + } + const args = ["/pid", String(pid), "/T", "/F"]; + try { + if (sync) execFileSync2("taskkill", args, { windowsHide: true, stdio: "ignore", timeout: 5e3 }); + else spawn3("taskkill", args, { windowsHide: true, stdio: "ignore" }).on("error", () => { + }); + } catch { + } + } + killTerm(t, sync = false) { + t.alive = false; + this.killTree(t.pty?.pid ?? t.proc?.pid, sync, !t.pty && !!t.proc); + if (t.timer) { + clearTimeout(t.timer); + t.timer = null; + } + try { + t.pty?.kill(); + } catch { + } + try { + t.proc?.kill(); + } catch { + } + this.terms.delete(t.id); + this.byIndex.delete(t.index); + } + onExit(t) { + if (t.timer) { + clearTimeout(t.timer); + t.timer = null; + if (t.pend) { + this.gw.publishTerminal(t.index, Buffer.from(t.pend, "utf8")); + t.pend = ""; + } + } + if (t.exited) return; + t.exited = true; + t.alive = false; + this.terms.delete(t.id); + this.byIndex.delete(t.index); + this.gw.publish("terminals", { type: "terminal.exit", id: t.id, index: t.index }); + } + onData(t, d) { + t.buf += d; + if (t.buf.length > BUF_MAX) t.buf = t.buf.slice(-BUF_MAX / 2); + t.pend += d; + if (!t.timer) { + t.timer = setTimeout(() => { + t.timer = null; + if (t.pend) { + this.gw.publishTerminal(t.index, Buffer.from(t.pend, "utf8")); + t.pend = ""; + } + }, 16); + } + } + // Input frames: op 2=input, 3=resize + onInput(conn, index, op, payload) { + const t = this.byIndex.get(index); + if (!t || !t.alive) return; + if (!conn.subs.has(`terminal:${index}`)) return; + if (op === 2) { + const s = payload.toString("utf8"); + if (t.pty) t.pty.write(s); + else t.proc?.stdin.write(s); + } else if (op === 3) { + try { + const { cols, rows } = JSON.parse(payload.toString("utf8")); + if (cols && rows) this.resize(t.id, cols, rows); + } catch { + } + } + } +}; + +// daemon/src/util/proc.ts +init_pathscan(); +import { delimiter } from "node:path"; +function pidAlive(pid, kill = (p, s) => process.kill(p, s)) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + kill(pid, 0); + return true; + } catch (e) { + return e?.code === "EPERM"; + } +} +function augmentPath(env, nodeDir, platform = process.platform, home) { + const win = platform === "win32"; + const sep4 = win ? ";" : platform === process.platform ? delimiter : ":"; + const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH"; + const cur = (env[key] ?? "").split(sep4).filter(Boolean); + const norm = (d) => win ? d.replace(/[\\/]+$/, "").toLowerCase() : d.replace(/\/+$/, ""); + const seen = new Set(cur.map(norm)); + const out = [...cur]; + for (const d of [nodeDir, ...extraBinDirs({ platform, env, home })]) { + if (!d || seen.has(norm(d))) continue; + seen.add(norm(d)); + out.push(d); + } + return out.join(sep4); +} + +// daemon/src/main.ts +var nodeMajor = Number(/^v?(\d+)/.exec(process.versions.node ?? "")?.[1] ?? 0); +if (nodeMajor < 20) { + console.error(`[agent-console] \u9700\u8981 Node.js 20 \u6216\u66F4\u9AD8\u7248\u672C\uFF0C\u5F53\u524D ${process.version}\uFF08${process.execPath}\uFF09`); + process.exit(3); +} +process.env.PATH = augmentPath(process.env, dirname7(process.execPath)); +var cfg = parseConfig(process.argv.slice(2)); +process.on("uncaughtException", (e) => { + log("error", "uncaughtException", { err: String(e?.stack ?? e) }); +}); +process.on("unhandledRejection", (e) => { + log("error", "unhandledRejection", { err: String(e) }); +}); +async function main() { + initLog(cfg.dataDir); + for (const d of ["threads", "attachments", "trash", "logs"]) { + mkdirSync2(join19(cfg.dataDir, d), { recursive: true }); + } + const appDir = join19(dirname7(fileURLToPath3(import.meta.url)), "..", "..", "app"); + const attachments = new AttachmentStore(join19(cfg.dataDir, "attachments")); + const { bindAttachmentStore: bindAttachmentStore2 } = await Promise.resolve().then(() => (init_attachmentRefs(), attachmentRefs_exports)); + bindAttachmentStore2(attachments); + const settings = new SettingsStore(cfg.dataDir); + const projects = new ProjectsStore(cfg.dataDir); + const threads = new ThreadsStore(cfg.dataDir); + const gateway = new Gateway(cfg); + initProviders(cfg.version); + const { migrateLegacy: migrateLegacy2 } = await Promise.resolve().then(() => (init_legacy(), legacy_exports)); + try { + await migrateLegacy2(cfg.dataDir, { settings, projects, threads }); + } catch (e) { + log("error", "\u65E7\u6570\u636E\u8FC1\u79FB\u5931\u8D25", { err: String(e) }); + } + const manager = new AgentManager(gateway, threads, projects, settings, attachments); + await manager.recoverOnStart(); + const terminals = new TerminalService(gateway); + registerMethods(gateway, { + cfg, + settings, + gateway, + projects, + threads, + manager, + git: new GitService(), + files: new FileService(), + terminals, + runtime: (id) => manager.runtimeInfo(id) + }); + const server = createHttpServer(cfg, appDir, attachments, { + onUpgrade: (req, socket, head) => gateway.handleUpgrade(req, socket, head) + }); + let attempts = 0; + await new Promise((resolveListen, rejectListen) => { + const tryListen = () => { + attempts++; + server.once("error", (e) => { + if (e.code === "EADDRINUSE" && attempts < 6) { + log("warn", `\u7AEF\u53E3 ${cfg.port} \u88AB\u5360\u7528\uFF0C500ms \u540E\u91CD\u8BD5\uFF08\u7B2C ${attempts} \u6B21\uFF09`); + setTimeout(tryListen, 500); + } else { + rejectListen(e); + } + }); + server.listen(cfg.port, "127.0.0.1"); + }; + server.once("listening", () => resolveListen()); + tryListen(); + }).catch((e) => { + log("error", `\u7AEF\u53E3 ${cfg.port} \u65E0\u6CD5\u76D1\u542C`, { err: String(e) }); + process.exit(2); + }); + const bound = server.address(); + if (bound && typeof bound === "object") cfg.port = bound.port; + process.stdout.write(JSON.stringify({ agentConsoleReady: true, port: cfg.port, pid: process.pid }) + "\n"); + void (async () => { + const { warmProviderCache: warmProviderCache2 } = await Promise.resolve().then(() => (init_warm(), warm_exports)); + await warmProviderCache2(await settings.get()); + })().catch((e) => log("warn", "Provider \u9884\u70ED\u5931\u8D25", { err: String(e) })); + const daemonJsonPath = join19(cfg.dataDir, "daemon.json"); + await writeJsonAtomic(daemonJsonPath, { + pid: process.pid, + port: cfg.port, + token: cfg.token, + version: cfg.version, + buildStamp: cfg.buildStamp, + startedAt: Date.now() + }); + chmodSync2(daemonJsonPath, 384); + log("info", `daemon \u5DF2\u542F\u52A8 \u7AEF\u53E3 ${cfg.port} \u6570\u636E\u76EE\u5F55 ${cfg.dataDir} \u7248\u672C ${cfg.version} pid ${process.pid}`); + if (!cfg.parentPid) console.log(`[agent-console] \u6253\u5F00 http://127.0.0.1:${cfg.port}/#t=${cfg.token}`); + let idleSince = Date.now(); + setInterval(() => { + const busy = gateway.connectionCount > 0 || manager.anyBusy() || terminals.count > 0; + if (busy) idleSince = Date.now(); + if (Date.now() - idleSince > cfg.idleExitMinutes * 60 * 1e3) { + log("info", "\u7A7A\u95F2\u8D85\u65F6\uFF0Cdaemon \u9000\u51FA"); + shutdown(0); + } + }, 60 * 1e3).unref(); + let shuttingDown = false; + function shutdown(code) { + if (shuttingDown) return; + shuttingDown = true; + log("info", "daemon \u5173\u95ED\u4E2D"); + terminals.killAll(); + gateway.closeAll(); + try { + rmSync2(daemonJsonPath, { force: true }); + } catch { + } + const sessionsClosed = Promise.race([manager.shutdown(), new Promise((r) => setTimeout(r, 2500))]); + void sessionsClosed.then(() => server.close(() => process.exit(code))); + setTimeout(() => process.exit(code), 4e3).unref(); + } + process.on("SIGINT", () => shutdown(0)); + process.on("SIGTERM", () => shutdown(0)); + if (cfg.parentPid) { + const parentPid = cfg.parentPid; + const parentWatch = setInterval(() => { + if (!pidAlive(parentPid)) { + clearInterval(parentWatch); + log("info", `\u7236\u8FDB\u7A0B ${parentPid} \u5DF2\u9000\u51FA\uFF0Cdaemon \u8DDF\u968F\u9000\u51FA`); + shutdown(0); + } + }, 2e3); + parentWatch.unref(); + process.stdin.on("end", () => shutdown(0)); + process.stdin.on("close", () => shutdown(0)); + process.stdin.on("error", () => shutdown(0)); + process.stdin.resume(); + } +} +main().catch((e) => { + console.error("[agent-console] \u542F\u52A8\u5931\u8D25", e); + process.exit(1); +}); +//# sourceMappingURL=main.js.map diff --git a/packages/agent-console/daemon/dist/main.js.map b/packages/agent-console/daemon/dist/main.js.map new file mode 100644 index 00000000..8d1a75d4 --- /dev/null +++ b/packages/agent-console/daemon/dist/main.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/log.ts","../src/util/paths.ts","../src/util/queue.ts","../src/agents/diff.ts","../src/providers/slash.ts","../src/providers/mock.ts","../../lib/pathscan.mjs","../src/util/bin.ts","../src/agents/kinds.ts","../src/providers/claude/map.ts","../src/util/i18n.ts","../src/util/attachmentRefs.ts","../src/util/attachmentKinds.ts","../src/providers/claude/content.ts","../src/providers/claude/cli.ts","../src/providers/claude/session.ts","../src/providers/claude/models.ts","../src/history/import.ts","../src/providers/claude/provider.ts","../src/history/codex.ts","../src/providers/codex/rpc.ts","../src/providers/codex/input.ts","../src/providers/codex/map.ts","../src/providers/codex/commands.ts","../src/providers/codex/session.ts","../src/providers/codex/provider.ts","../src/providers/catalog.ts","../src/providers/acp/spawn.ts","../src/providers/acp/clientHandlers.ts","../src/providers/acp/mapUpdate.ts","../src/providers/acp/promptCaps.ts","../src/providers/acp/sessionCaps.ts","../src/providers/acp/prompt.ts","../src/providers/acp/session.ts","../src/providers/acp/provider.ts","../src/providers/acp/dsh.ts","../src/providers/acp/probe.ts","../src/providers/acp/specs.ts","../src/providers/registry.ts","../src/providers/enabled.ts","../src/util/pickFolderWin.ts","../src/util/pickFolder.ts","../src/providers/magpieGateway.ts","../src/providers/infoCache.ts","../src/ws/methods.ts","../src/util/atomic.ts","../src/util/jsonl.ts","../src/migrate/legacy.ts","../src/providers/warm.ts","../src/main.ts","../src/config.ts","../src/util/buildStamp.ts","../src/http.ts","../src/security.ts","../src/store/attachments.ts","../src/store/settings.ts","../src/store/projects.ts","../src/store/threads.ts","../src/ws/gateway.ts","../src/agents/manager.ts","../src/timeline/coalescer.ts","../src/git/service.ts","../src/files/service.ts","../src/terminal/service.ts","../src/util/proc.ts"],"sourcesContent":["// Simple logging: writes dataDir/logs/daemon-YYYY-MM-DD.log (kept 7 days) and to the console.\nimport { appendFileSync, mkdirSync, readdirSync, rmSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nlet logDir = \"\";\nlet today = \"\";\nlet logFile = \"\";\n\nfunction fileName(d: Date): string {\n const p = (n: number) => String(n).padStart(2, \"0\");\n return `daemon-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}.log`;\n}\n\nexport function initLog(dataDir: string): void {\n logDir = join(dataDir, \"logs\");\n mkdirSync(logDir, { recursive: true });\n // Keep 7 days\n try {\n const keep = new Set();\n const now = new Date();\n for (let i = 0; i < 7; i++) {\n keep.add(fileName(new Date(now.getTime() - i * 86400000)));\n }\n for (const f of readdirSync(logDir)) {\n if (/^daemon-\\d{4}-\\d{2}-\\d{2}\\.log$/.test(f) && !keep.has(f)) {\n rmSync(join(logDir, f), { force: true });\n }\n }\n } catch {}\n}\n\nexport function log(level: \"info\" | \"warn\" | \"error\", msg: string, extra?: unknown): void {\n const line = `[${new Date().toISOString()}] [${level}] ${msg}${extra !== undefined ? \" \" + safeJson(extra) : \"\"}`;\n try {\n const d = new Date();\n const name = fileName(d);\n if (name !== today && logDir) {\n today = name;\n logFile = join(logDir, name);\n }\n if (logFile) appendFileSync(logFile, line + \"\\n\", \"utf8\");\n } catch {}\n const fn = level === \"error\" ? console.error : level === \"warn\" ? console.warn : console.log;\n fn(`[agent-console] ${msg}`, extra !== undefined ? extra : \"\");\n}\n\nfunction safeJson(v: unknown): string {\n try {\n return JSON.stringify(v);\n } catch {\n return String(v);\n }\n}\n","// Path utilities: normalization, samePath, isInside (decided after resolving realpath).\nimport { realpathSync, existsSync } from \"node:fs\";\nimport { posix, sep, win32 } from \"node:path\";\n\n// Normalization: realpath (when it exists) + unified separators + case-insensitive on Windows/macOS (NTFS and APFS are case-insensitive by default).\n// Only for comparison/dedupe, never for display or storage.\nexport function normPath(p: string, platform: NodeJS.Platform = process.platform): string {\n // Resolve with the target platform's rules: normalizing a macOS/Linux path on Windows must not prepend the host drive (D:\\…)\n let r = (platform === \"win32\" ? win32 : posix).resolve(p);\n try {\n // native restores 8.3 short names (C:\\PROGRA~1), subst drives and letter case to what's on disk; fall back to the JS implementation on failure\n r = realpathSync.native(r);\n } catch {\n try {\n r = realpathSync(r);\n } catch {}\n }\n if (platform === \"win32\") {\n // Strip the \\\\?\\ long-path prefix; UNC stays \\\\server\\share\n r = r.replace(/\\//g, \"\\\\\").replace(/^\\\\\\\\\\?\\\\(?!UNC\\\\)/i, \"\").replace(/^\\\\\\\\\\?\\\\UNC\\\\/i, \"\\\\\\\\\").toLowerCase();\n } else {\n r = r.replace(/\\\\/g, \"/\");\n if (platform === \"darwin\") r = r.normalize(\"NFC\").toLowerCase();\n }\n // Strip trailing separators (except for the root)\n while (r.length > 3 && (r.endsWith(\"/\") || r.endsWith(\"\\\\\"))) r = r.slice(0, -1);\n return r;\n}\n\nexport function samePath(a: string, b: string): boolean {\n return normPath(a) === normPath(b);\n}\n\n// Whether child is inside the parent directory (including equal).\nexport function isInside(child: string, parent: string): boolean {\n const c = normPath(child);\n const p = normPath(parent);\n if (c === p) return true;\n return c.startsWith(p + sep) || c.startsWith(p + \"/\") || c.startsWith(p + \"\\\\\");\n}\n\n// id check: only safe characters, prevents path traversal.\nconst ID_RE = /^[\\w-]{1,80}$/;\nexport function validId(id: unknown): id is string {\n return typeof id === \"string\" && ID_RE.test(id);\n}\n\nexport function pathExists(p: string): boolean {\n return existsSync(p);\n}\n","// AsyncQueue: push/close/[Symbol.asyncIterator], the prompt input stream for the Claude SDK.\nexport class AsyncQueue implements AsyncIterable {\n private buf: T[] = [];\n private waiters: ((v: IteratorResult) => void)[] = [];\n private closed = false;\n private err: unknown = null;\n\n push(item: T): void {\n if (this.closed) return;\n const w = this.waiters.shift();\n if (w) w({ value: item, done: false });\n else this.buf.push(item);\n }\n\n close(): void {\n if (this.closed) return;\n this.closed = true;\n while (this.waiters.length) {\n const w = this.waiters.shift()!;\n w({ value: undefined as never, done: true });\n }\n }\n\n fail(err: unknown): void {\n if (this.closed) return;\n this.err = err;\n this.closed = true;\n while (this.waiters.length) {\n const w = this.waiters.shift()!;\n // Make waiters throw\n w({ value: undefined as never, done: true });\n }\n }\n\n get isClosed(): boolean {\n return this.closed;\n }\n\n [Symbol.asyncIterator](): AsyncIterator {\n return {\n next: (): Promise> => {\n if (this.buf.length) return Promise.resolve({ value: this.buf.shift()!, done: false });\n if (this.closed) {\n if (this.err) return Promise.reject(this.err);\n return Promise.resolve({ value: undefined as never, done: true });\n }\n if (this.err) return Promise.reject(this.err);\n return new Promise>((res, rej) => {\n this.waiters.push((r) => {\n if (this.err) {\n rej(this.err);\n return;\n }\n res(r);\n });\n });\n },\n };\n }\n}\n","// Line-level diff (ported from lib/diff.mjs): oldStr/newStr → { hunks, added, removed }\n// hunk: { oldStart, newStart, lines: [{ t: \" \"|\"+\"|\"-\", s }] }\n// LCS minimal edit script; keep context lines between changes and merge nearby small blocks into one hunk.\n\nconst CONTEXT = 3;\nconst MERGE_GAP = 6;\nconst MAX_LINES = 400;\n\nexport type DiffLine = { t: \" \" | \"+\" | \"-\"; s: string };\nexport type Hunk = { oldStart: number; newStart: number; lines: DiffLine[] };\nexport type DiffResult = { hunks: Hunk[]; added: number; removed: number; truncated?: boolean };\n\nexport function diffLines(oldStr = \"\", newStr = \"\", { context = CONTEXT } = {}): DiffResult {\n const a = splitLines(oldStr);\n const b = splitLines(newStr);\n if (a.length > MAX_LINES || b.length > MAX_LINES) return coarseDiff(a, b);\n const ops = lcsOps(a, b);\n return buildHunks(ops, context);\n}\n\nfunction splitLines(s: string): string[] {\n s = String(s);\n if (!s) return [];\n return s.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n}\n\ntype Op = { t: \" \" | \"+\" | \"-\"; s: string };\n\nfunction lcsOps(a: string[], b: string[]): Op[] {\n const n = a.length;\n const m = b.length;\n const dp = new Uint32Array((n + 1) * (m + 1));\n for (let i = n - 1; i >= 0; i--)\n for (let j = m - 1; j >= 0; j--)\n dp[i * (m + 1) + j] = a[i] === b[j]\n ? dp[(i + 1) * (m + 1) + j + 1] + 1\n : Math.max(dp[(i + 1) * (m + 1) + j], dp[i * (m + 1) + j + 1]);\n const ops: Op[] = [];\n let i = 0;\n let j = 0;\n while (i < n && j < m) {\n if (a[i] === b[j]) {\n ops.push({ t: \" \", s: a[i] });\n i++; j++;\n } else if (dp[(i + 1) * (m + 1) + j] >= dp[i * (m + 1) + j + 1]) {\n ops.push({ t: \"-\", s: a[i] });\n i++;\n } else {\n ops.push({ t: \"+\", s: b[j] });\n j++;\n }\n }\n while (i < n) ops.push({ t: \"-\", s: a[i++] });\n while (j < m) ops.push({ t: \"+\", s: b[j++] });\n return ops;\n}\n\nfunction buildHunks(ops: Op[], context: number): DiffResult {\n const changed: number[] = [];\n ops.forEach((op, i) => {\n if (op.t !== \" \") changed.push(i);\n });\n if (!changed.length) return { hunks: [], added: 0, removed: 0 };\n\n let added = 0;\n let removed = 0;\n for (const op of ops) {\n if (op.t === \"+\") added++;\n else if (op.t === \"-\") removed++;\n }\n\n const ranges: { s: number; e: number }[] = [];\n for (const idx of changed) {\n const s = Math.max(0, idx - context);\n const e = Math.min(ops.length, idx + context + 1);\n const last = ranges[ranges.length - 1];\n if (last && s <= last.e + MERGE_GAP) last.e = Math.max(last.e, e);\n else ranges.push({ s, e });\n }\n\n const hunks: Hunk[] = [];\n let oldLn = 1;\n let newLn = 1;\n const oldNos: number[] = [];\n const newNos: number[] = [];\n for (const op of ops) {\n oldNos.push(oldLn);\n newNos.push(newLn);\n if (op.t !== \"+\") oldLn++;\n if (op.t !== \"-\") newLn++;\n }\n for (const r of ranges) {\n hunks.push({\n oldStart: oldNos[r.s],\n newStart: newNos[r.s],\n lines: ops.slice(r.s, r.e).map((op) => ({ t: op.t, s: op.s })),\n });\n }\n return { hunks, added, removed };\n}\n\nfunction coarseDiff(a: string[], b: string[]): DiffResult {\n return {\n hunks: [\n {\n oldStart: 1,\n newStart: 1,\n lines: [...a.map((s) => ({ t: \"-\" as const, s })), ...b.map((s) => ({ t: \"+\" as const, s }))].slice(0, 2000),\n },\n ],\n added: b.length,\n removed: a.length,\n truncated: true,\n };\n}\n\n// Build the detail for Edit/MultiEdit: compute hunks from old/new or edits[]\nexport function editDetail(detail: Record = {}) {\n if (Array.isArray(detail.edits) && detail.edits.length) {\n const hunks: Hunk[] = [];\n let added = 0;\n let removed = 0;\n for (const e of detail.edits as { old_string?: string; old?: string; new_string?: string; new?: string }[]) {\n const d = diffLines(e.old_string ?? e.old ?? \"\", e.new_string ?? e.new ?? \"\");\n for (const h of d.hunks) hunks.push(h);\n added += d.added;\n removed += d.removed;\n }\n return { path: detail.path, hunks, added, removed };\n }\n if (detail.old !== undefined || detail.new !== undefined) {\n const d = diffLines(String(detail.old ?? \"\"), String(detail.new ?? \"\"));\n return { path: detail.path, hunks: d.hunks, added: d.added, removed: d.removed };\n }\n return detail;\n}\n","export type SlashCommandKind = \"command\" | \"skill\";\n\nexport type SlashCommand = {\n name: string;\n description: string;\n argumentHint?: string;\n kind: SlashCommandKind;\n};\n\nconst CLAUDE_BUILTIN_COMMANDS = new Set([\"clear\", \"compact\", \"context\", \"usage\", \"init\", \"debug\"]);\n\nexport function classifyClaudeSlashCommand(name: string): SlashCommandKind {\n return CLAUDE_BUILTIN_COMMANDS.has(name) ? \"command\" : \"skill\";\n}\n\n/** Parse \"/name args\" (must start with /) */\nexport function parseSlashInput(text: string): { name: string; args: string; raw: string } | null {\n const t = text.trim();\n if (!t.startsWith(\"/\")) return null;\n const body = t.slice(1).trim();\n if (!body) return null;\n const sp = body.indexOf(\" \");\n if (sp === -1) return { name: body, args: \"\", raw: t };\n return { name: body.slice(0, sp), args: body.slice(sp + 1).trim(), raw: t };\n}\n\nexport function mapAcpAvailableCommands(\n cmds: { name: string; description: string; input?: { hint?: string } | null }[],\n): SlashCommand[] {\n return cmds.map((c) => ({\n name: c.name,\n description: c.description,\n argumentHint: c.input?.hint,\n kind: \"skill\" as const,\n }));\n}\n\nexport function sortSlashCommands(list: SlashCommand[]): SlashCommand[] {\n return [...list].sort((a, b) => {\n if (a.kind !== b.kind) return a.kind === \"command\" ? -1 : 1;\n return a.name.localeCompare(b.name);\n });\n}\n","// MockProvider: registered only with AGENT_CONSOLE_MOCK=1. Persistent session, interruptible, with permissions and steering.\n// Scripts by message prefix: default /perm /plan /ask /slow /error /long /exit; supports steer/interrupt/resume.\nimport type { Provider, ProviderSession, DetectResult, ModelInfo,\n Mode, SendInput, RespondAnswer, SlashCommand,\n} from \"./types.js\";\nimport type { ProviderEvent } from \"../timeline/types.js\";\nimport { AsyncQueue } from \"../util/queue.js\";\nimport { editDetail } from \"../agents/diff.js\";\nimport { sortSlashCommands } from \"./slash.js\";\n\nexport const mockCapabilities = {\n resume: true,\n interrupt: true,\n steer: true,\n images: true,\n plan: true,\n questions: true,\n cost: true,\n effort: [\"low\", \"medium\", \"high\"],\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"] as Mode[],\n};\n\nconst rid = () => Math.random().toString(36).slice(2, 10);\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\nclass MockCrash extends Error {\n constructor() {\n super(\"__mock_crash__\");\n }\n}\n\ntype Ctx = {\n emit: (ev: ProviderEvent) => void;\n waitFor: (requestId: string) => Promise;\n interrupted: () => boolean;\n steerText: () => string;\n replyPrefix: () => string;\n input: SendInput;\n};\n\nexport class MockSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private sessionId: string;\n private turnId: string | null = null;\n private turnNum = 0;\n private interruptFlag = false;\n private closed = false;\n private pendingReq = new Map void>();\n private steered: string[] = [];\n private resumed = false;\n\n constructor(private o: {\n threadId: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n }) {\n this.sessionId = o.resumeId ?? \"mock-\" + rid();\n this.resumed = o.resumeId !== null;\n this.events = this.q;\n this.q.push({ type: \"provider_session_id\", id: this.sessionId });\n }\n\n private emit(ev: ProviderEvent): void {\n (ev as { turnId?: string }).turnId ??= this.turnId ?? undefined;\n this.q.push(ev);\n }\n\n async send(input: SendInput): Promise {\n if (this.closed) throw new Error(\"mock session closed\");\n this.turnNum++;\n this.turnId = \"u_\" + Date.now().toString(36) + \"_\" + this.turnNum;\n this.interruptFlag = false;\n this.steered = [];\n this.emit({\n type: \"turn_started\",\n turnId: this.turnId,\n model: this.o.model ?? \"demo-fast\",\n mode: this.o.mode,\n effort: this.o.effort,\n });\n void this.runTurn(input);\n }\n\n private async runTurn(input: SendInput): Promise {\n // itemIds are globally unique (a real provider creates new itemIds each time); sessionId can't be used:\n // a resumed session keeps the same providerSessionId and would collide with the old session's itemIds.\n // Same ids would merge the new turn's events into old entries, and the new reply would \"fly\" into an old bubble mid-timeline.\n const p = \"i\" + rid() + \"_\";\n const ctx: Ctx = {\n emit: (ev) => {\n const e = ev as { itemId?: string };\n if (e.itemId) e.itemId = p + e.itemId;\n this.emit(ev);\n },\n waitFor: (id) => this.waitFor(id),\n interrupted: () => this.interruptFlag || this.closed,\n steerText: () => this.steered.join(\";\"),\n replyPrefix: () => this.resumePrefix(),\n input,\n };\n try {\n const script = pickScript(input.text);\n await script(ctx);\n if (this.interruptFlag || this.closed) {\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"interrupted\", durationMs: 0 });\n } else {\n this.emit({ type: \"usage\", inputTokens: 1200, outputTokens: 340, cacheReadTokens: 800, costUsd: 0.0123 });\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"completed\", durationMs: 6000, filesChanged: 1 });\n }\n } catch (e) {\n if (e instanceof MockCrash) {\n this.q.close(); // Crash: no turn_ended; the manager appends failed\n return;\n }\n if (this.interruptFlag || this.closed) {\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"interrupted\", durationMs: 0 });\n } else {\n this.emit({ type: \"turn_ended\", turnId: this.turnId!, status: \"failed\", error: String((e as Error)?.message ?? e) });\n }\n }\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const resolve = this.pendingReq.get(requestId);\n if (resolve) {\n this.pendingReq.delete(requestId);\n resolve(answer);\n }\n }\n\n private waitFor(requestId: string): Promise {\n return new Promise((resolve) => {\n this.pendingReq.set(requestId, resolve);\n });\n }\n\n async interrupt(): Promise {\n this.interruptFlag = true;\n // Pending permissions/questions are cancelled directly\n for (const [id, resolve] of this.pendingReq) {\n resolve({ cancelled: true });\n this.pendingReq.delete(id);\n }\n await sleep(120); // Guarantees interrupted within <1s\n }\n\n async steer(input: SendInput): Promise {\n this.steered.push(input.text);\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model;\n }\n async setMode(mode: Mode): Promise {\n this.o.mode = mode;\n }\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort;\n }\n\n async listCommands(): Promise {\n return sortSlashCommands([\n { name: \"perm\", description: \"演示授权弹窗\", kind: \"command\" },\n { name: \"plan\", description: \"演示计划审批\", kind: \"command\" },\n { name: \"ask\", description: \"演示提问\", kind: \"command\" },\n { name: \"slow\", description: \"慢速多步工具\", kind: \"skill\" },\n { name: \"error\", description: \"模拟失败\", kind: \"skill\" },\n ]);\n }\n\n async close(): Promise {\n this.closed = true;\n for (const [, resolve] of this.pendingReq) resolve({ cancelled: true });\n this.pendingReq.clear();\n this.q.close();\n }\n\n // With resumeId the first reply gets a \"(已续接)\" prefix\n private resumePrefix(): string {\n if (this.resumed && this.turnNum === 1) return \"(已续接)\";\n return \"\";\n }\n}\n\n// ---------- scripts ----------\n\nasync function scriptDefault(ctx: Ctx): Promise {\n const { emit } = ctx;\n const sleepA = (ms: number) => sleepAbortable(ms, ctx);\n\n emit({ type: \"reasoning_delta\", itemId: \"r1\", text: \"让我看看项目结构…\" });\n await sleepA(500);\n emit({ type: \"reasoning\", itemId: \"r1\", text: \"让我看看项目结构…需要先读几个文件。\" });\n\n const tools = [\n { id: \"t1\", name: \"Read\", input: { file_path: ctx.input.text.includes(\"abs\") ? \"D:/x/a.ts\" : \"src/index.ts\" } },\n { id: \"t2\", name: \"Read\", input: { file_path: \"package.json\" } },\n ];\n for (const t of tools) {\n emit({ type: \"tool_call\", itemId: t.id, kind: \"read\", title: `读取 ${t.input.file_path}`, status: \"running\", input: t.input });\n if (await sleepA(300)) return;\n emit({ type: \"tool_call\", itemId: t.id, kind: \"read\", title: `读取 ${t.input.file_path}`, status: \"ok\", output: \"…文件内容…\", durationMs: 300 });\n }\n\n emit({ type: \"tool_call\", itemId: \"t3\", kind: \"search\", title: '搜索 \"TODO\"', status: \"running\", input: { pattern: \"TODO\" } });\n await sleepA(200);\n emit({ type: \"tool_call\", itemId: \"t3\", kind: \"search\", title: '搜索 \"TODO\"', status: \"ok\", output: \"3 处匹配\" });\n\n const oldStr = \"const x = 1;\\nconst y = 2;\\nreturn x + y;\";\n const newStr = \"const x = 1;\\nconst y = 2;\\nconst z = 3;\\nreturn x + y + z;\";\n emit({\n type: \"tool_call\", itemId: \"t4\", kind: \"edit\", title: \"编辑 src/index.ts\", status: \"running\",\n input: { file_path: \"src/index.ts\", old_string: oldStr, new_string: newStr },\n detail: editDetail({ path: \"src/index.ts\", old: oldStr, new: newStr }),\n });\n if (await sleepA(400)) return;\n emit({\n type: \"tool_call\", itemId: \"t4\", kind: \"edit\", title: \"编辑 src/index.ts\", status: \"ok\",\n detail: editDetail({ path: \"src/index.ts\", old: oldStr, new: newStr }), durationMs: 400,\n });\n\n emit({ type: \"tool_call\", itemId: \"t5\", kind: \"bash\", title: \"运行 npm test\", status: \"running\", input: { command: \"npm test\" } });\n for (let i = 1; i <= 20; i++) {\n if (await sleepA(60)) return;\n emit({ type: \"tool_output_delta\", itemId: \"t5\", text: `第 ${i} 行输出\\n` });\n }\n emit({ type: \"tool_call\", itemId: \"t5\", kind: \"bash\", title: \"运行 npm test\", status: \"ok\", output: \"✓ 全部通过\", exitCode: 0, durationMs: 1200 });\n\n emit({\n type: \"todo\",\n items: [\n { text: \"分析需求\", status: \"done\" },\n { text: \"修改代码\", status: \"done\" },\n { text: \"跑测试\", status: \"done\" },\n { text: \"写文档\", status: \"in_progress\" },\n ],\n });\n await sleepA(300);\n\n const reply = ctx.replyPrefix() + mockReply(ctx);\n for (const ch of chunkText(reply, 40)) {\n if (await sleepA(30)) return;\n emit({ type: \"assistant_delta\", itemId: \"m1\", text: ch });\n }\n emit({ type: \"assistant_message\", itemId: \"m1\", text: reply });\n}\n\nasync function scriptPerm(ctx: Ctx): Promise {\n const { emit } = ctx;\n const requestId = \"perm-\" + rid();\n emit({\n type: \"permission_request\",\n requestId,\n title: \"运行 npm test\",\n kind: \"tool\",\n detail: { command: \"npm test\", cwd: \".\", toolName: \"Bash\" },\n options: [\n { id: \"accept\", label: \"允许\", kind: \"allow_once\" },\n { id: \"acceptForSession\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"decline\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const ans = await ctx.waitFor(requestId);\n if (ans.cancelled || ans.optionId === \"decline\") {\n emit({ type: \"tool_call\", itemId: \"p1\", kind: \"bash\", title: \"运行 npm test\", status: \"failed\", output: \"已被用户拒绝\", exitCode: 1 });\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"已按你的要求跳过该操作。\" });\n return;\n }\n emit({ type: \"tool_call\", itemId: \"p1\", kind: \"bash\", title: \"运行 npm test\", status: \"running\", input: { command: \"npm test\" } });\n if (await sleepAbortable(500, ctx)) return;\n emit({ type: \"tool_call\", itemId: \"p1\", kind: \"bash\", title: \"运行 npm test\", status: \"ok\", output: \"✓ 12 个测试通过\", exitCode: 0, durationMs: 500 });\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"测试通过了。\" });\n}\n\nasync function scriptPlan(ctx: Ctx): Promise {\n const { emit } = ctx;\n emit({\n type: \"plan\",\n itemId: \"plan1\",\n markdown: \"## 实施计划\\n\\n1. 改 `src/index.ts` 入口\\n2. 更新测试\\n3. 跑一遍 e2e\",\n entries: [\n { step: \"改 src/index.ts 入口\", status: \"pending\" },\n { step: \"更新测试\", status: \"pending\" },\n { step: \"跑一遍 e2e\", status: \"pending\" },\n ],\n });\n const requestId = \"perm-\" + rid();\n emit({\n type: \"permission_request\",\n requestId,\n title: \"实施这个计划?\",\n kind: \"plan\",\n detail: { markdown: \"见上方计划\" },\n options: [\n { id: \"implement\", label: \"开始实施\", kind: \"allow_once\" },\n { id: \"reject\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const ans = await ctx.waitFor(requestId);\n if (ans.cancelled || ans.optionId === \"reject\") {\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"好的,请告诉我怎么改。\" });\n return;\n }\n emit({ type: \"tool_call\", itemId: \"pl1\", kind: \"edit\", title: \"编辑 src/index.ts\", status: \"ok\" });\n await sleepAbortable(400, ctx);\n emit({ type: \"assistant_message\", itemId: \"pm\", text: \"已按计划改完入口和测试。\" });\n}\n\nasync function scriptAsk(ctx: Ctx): Promise {\n const { emit } = ctx;\n const requestId = \"q-\" + rid();\n emit({\n type: \"question\",\n requestId,\n questions: [\n {\n id: \"q1\",\n header: \"部署目标\",\n question: \"要部署到哪个环境?\",\n options: [\n { id: \"staging\", label: \"Staging\" },\n { id: \"prod\", label: \"生产环境\" },\n ],\n },\n {\n id: \"q2\",\n header: \"备注\",\n question: \"还有什么要补充的?\",\n freeText: true,\n },\n ],\n });\n const ans = await ctx.waitFor(requestId);\n const env = ans.answers?.q1 ?? \"(未选)\";\n const note = ans.answers?.q2 ?? \"\";\n emit({ type: \"assistant_message\", itemId: \"am\", text: `收到:部署到 ${env}${note ? `,备注「${note}」` : \"\"}。` });\n}\n\nasync function scriptSlow(ctx: Ctx): Promise {\n const { emit } = ctx;\n for (let i = 1; i <= 6; i++) {\n const id = \"s\" + i;\n emit({ type: \"tool_call\", itemId: id, kind: \"bash\", title: `步骤 ${i}/6`, status: \"running\", input: { command: `step ${i}` } });\n if (await sleepAbortable(2000, ctx)) return;\n emit({ type: \"tool_call\", itemId: id, kind: \"bash\", title: `步骤 ${i}/6`, status: \"ok\", output: `步骤 ${i} 完成`, durationMs: 2000 });\n }\n emit({ type: \"assistant_message\", itemId: \"sm\", text: \"6 个步骤全部跑完。\" });\n}\n\nasync function scriptError(ctx: Ctx): Promise {\n const { emit } = ctx;\n emit({ type: \"tool_call\", itemId: \"e1\", kind: \"bash\", title: \"运行 ./build.sh\", status: \"running\", input: { command: \"./build.sh\" } });\n await sleepAbortable(800, ctx);\n throw new Error(\"构建失败:找不到模块 foo(模拟错误)\");\n}\n\nasync function scriptLong(ctx: Ctx): Promise {\n const { emit } = ctx;\n emit({ type: \"assistant_delta\", itemId: \"L1\", text: \"\" });\n for (let i = 0; i < 5000; i++) {\n if (i % 100 === 0 && ctx.interrupted()) return;\n emit({ type: \"assistant_delta\", itemId: \"L1\", text: `块${i} ` });\n if (i % 200 === 0) await sleep(0);\n }\n emit({ type: \"assistant_message\", itemId: \"L1\", text: \"(5000 个增量合并后的长文)\" });\n}\n\nasync function scriptExit(_ctx: Ctx): Promise {\n await sleep(300);\n throw new MockCrash();\n}\n\nfunction pickScript(text: string): (ctx: Ctx) => Promise {\n const t = text.trim();\n if (t.startsWith(\"/perm\")) return scriptPerm;\n if (t.startsWith(\"/plan\")) return scriptPlan;\n if (t.startsWith(\"/ask\")) return scriptAsk;\n if (t.startsWith(\"/slow\")) return scriptSlow;\n if (t.startsWith(\"/error\")) return scriptError;\n if (t.startsWith(\"/long\")) return scriptLong;\n if (t.startsWith(\"/exit\")) return scriptExit;\n return scriptDefault;\n}\n\nasync function sleepAbortable(ms: number, ctx: Ctx): Promise {\n const start = Date.now();\n while (Date.now() - start < ms) {\n if (ctx.interrupted()) return true;\n await sleep(Math.min(20, Math.max(1, ms - (Date.now() - start))));\n }\n return ctx.interrupted();\n}\n\nfunction chunkText(s: string, n: number): string[] {\n const out: string[] = [];\n for (let i = 0; i < s.length; i += n) out.push(s.slice(i, i + n));\n return out;\n}\n\nfunction mockReply(ctx: Ctx): string {\n const base = [\n \"好的,我已经按你的要求改好了。\",\n \"\",\n \"## 改动\",\n \"- `src/index.ts`:把返回逻辑补成三变量求和\",\n \"- 新增一行常量 `z`\",\n \"\",\n \"```ts\",\n \"const z = 3;\",\n \"return x + y + z;\",\n \"```\",\n \"\",\n \"| 文件 | 变更 |\",\n \"|---|---|\",\n \"| src/index.ts | +2/-1 |\",\n \"\",\n \"测试全部通过。\",\n ].join(\"\\n\");\n return ctx.steerText() ? base + `\\n\\n> 插话:${ctx.steerText()}` : base;\n}\n\n// ---------- Provider ----------\n\n/** For tests: counts createSession calls */\nexport const mockProviderMetrics = { createSessionCalls: 0 };\n\nexport function createMockProvider(): Provider {\n return {\n id: \"mock\",\n name: \"Mock(演示)\",\n capabilities: mockCapabilities,\n async detect(): Promise {\n return { available: true, bin: \"mock\", version: \"demo\" };\n },\n async listModels(): Promise {\n return [\n { id: \"demo-fast\", name: \"演示·快\", isDefault: true, efforts: [\"low\", \"medium\", \"high\"] },\n { id: \"demo-pro\", name: \"演示·强\", efforts: [\"low\", \"medium\", \"high\"] },\n ];\n },\n async createSession(o) {\n mockProviderMetrics.createSessionCalls++;\n return new MockSession(o);\n },\n };\n}\n","// Cross-platform executable lookup in PATH (shared by the plugin host index.mjs and the daemon; esbuild bundles it into dist for the daemon).\n//\n// which / where.exe are no longer used:\n// - some minimal Linux systems have no which;\n// - where.exe writes in the console code page (CP936 on Chinese Windows), so a Chinese user name or path is garbled when decoded as UTF-8,\n// existsSync fails and the CLI \"can't be found\".\n// Windows searches in PATHEXT order (.COM;.EXE;.BAT;.CMD), with .ps1 last; extensionless npm sh shims are never returned.\n// Besides PATH, add a set of common install directories: GUI apps launched from the macOS Dock/Finder only get launchd's minimal PATH\n// (Magpie adds the login shell's PATH itself; this is the fallback): Homebrew, npm global, Volta, Claude's native install, etc.\nimport { accessSync, constants, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport path from \"node:path\";\n\nfunction defaultExists(file, platform) {\n try {\n if (!statSync(file).isFile()) return false;\n if (platform !== \"win32\") accessSync(file, constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction envGet(env, name) {\n if (env[name] !== undefined) return env[name];\n // Windows environment variable names are case-insensitive (Path / PATH)\n const lower = name.toLowerCase();\n for (const key of Object.keys(env)) if (key.toLowerCase() === lower) return env[key];\n return undefined;\n}\n\n/** Common install directories that aren't necessarily in a GUI process's PATH (candidates only; callers check whether they exist). */\nexport function extraBinDirs({ platform = process.platform, env = process.env, home = homedir() } = {}) {\n const p = platform === \"win32\" ? path.win32 : path.posix;\n const dirs = [];\n if (platform === \"win32\") {\n const appData = envGet(env, \"APPDATA\");\n const localAppData = envGet(env, \"LOCALAPPDATA\");\n const programFiles = envGet(env, \"ProgramFiles\") || \"C:\\\\Program Files\";\n const nvmSymlink = envGet(env, \"NVM_SYMLINK\");\n if (home) dirs.push(p.join(home, \".local\", \"bin\"));\n if (appData) dirs.push(p.join(appData, \"npm\"));\n if (nvmSymlink) dirs.push(nvmSymlink);\n dirs.push(p.join(programFiles, \"nodejs\"));\n if (localAppData) dirs.push(p.join(localAppData, \"Programs\", \"nodejs\"));\n if (home) {\n dirs.push(p.join(home, \".volta\", \"bin\"));\n dirs.push(p.join(home, \".bun\", \"bin\"));\n }\n return dirs;\n }\n if (home) {\n dirs.push(p.join(home, \".local\", \"bin\"));\n dirs.push(p.join(home, \".npm-global\", \"bin\"));\n dirs.push(p.join(home, \".volta\", \"bin\"));\n dirs.push(p.join(home, \".bun\", \"bin\"));\n dirs.push(p.join(home, \".claude\", \"local\"));\n }\n if (platform === \"darwin\") dirs.push(\"/opt/homebrew/bin\");\n dirs.push(\"/usr/local/bin\", \"/usr/bin\", \"/bin\");\n return dirs;\n}\n\n/** PATH's directories followed by extraBinDirs; deduped (case-insensitive on Windows). */\nexport function searchDirs({ platform = process.platform, env = process.env, home = homedir(), extra = true } = {}) {\n const sep = platform === \"win32\" ? \";\" : \":\";\n const raw = String(envGet(env, \"PATH\") ?? \"\")\n .split(sep)\n .map((d) => d.trim().replace(/^\"(.*)\"$/, \"$1\"))\n .filter(Boolean);\n const all = extra ? [...raw, ...extraBinDirs({ platform, env, home })] : raw;\n const seen = new Set();\n const out = [];\n for (const d of all) {\n const key = platform === \"win32\" ? d.toLowerCase().replace(/[\\\\/]+$/, \"\") : d.replace(/\\/+$/, \"\");\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(d);\n }\n return out;\n}\n\nfunction windowsExts(env) {\n const list = String(envGet(env, \"PATHEXT\") || \".COM;.EXE;.BAT;.CMD\")\n .split(\";\")\n .map((e) => e.trim().toLowerCase())\n .filter((e) => e.startsWith(\".\"));\n if (!list.includes(\".ps1\")) list.push(\".ps1\");\n return list;\n}\n\n/**\n * Return the full paths of every hit, in order (PATH first, then extraBinDirs).\n * @param {string} name command name (may include an extension on Windows)\n */\nexport function findOnPath(\n name,\n { platform = process.platform, env = process.env, home = homedir(), extra = true, exists } = {},\n) {\n if (!name) return [];\n const p = platform === \"win32\" ? path.win32 : path.posix;\n const check = exists ?? ((file) => defaultExists(file, platform));\n // Already a path: check it directly\n if (name.includes(\"/\") || (platform === \"win32\" && name.includes(\"\\\\\"))) {\n return check(name) ? [name] : [];\n }\n let names = [name];\n if (platform === \"win32\") {\n const exts = windowsExts(env);\n const ext = p.extname(name).toLowerCase();\n names = ext && exts.includes(ext) ? [name] : exts.map((e) => name + e);\n }\n const hits = [];\n for (const dir of searchDirs({ platform, env, home, extra })) {\n for (const n of names) {\n const file = p.join(dir, n);\n if (check(file) && !hits.includes(file)) hits.push(file);\n }\n }\n return hits;\n}\n\n/** The resolved node executable, or null if not found (the plugin host is Bun, so process.execPath can't serve as node). */\nexport function findNode(opts = {}) {\n return findOnPath(\"node\", opts).find((file) => isNodeExecutable(file)) ?? null;\n}\n\n/** Whether process.execPath itself is node (rather than Bun / Electron etc.). */\nexport function isNodeExecutable(file) {\n return /^node(\\.exe)?$/i.test(String(file || \"\").split(/[\\\\/]/).pop() ?? \"\");\n}\n\nexport const NODE_MISSING_MESSAGE =\n \"找不到 Node.js(需要 20 或更高版本)。请安装 Node.js(https://nodejs.org)后完全退出并重新打开 Magpie。\" +\n \" / Node.js 20+ was not found on PATH. Install it, then quit and reopen Magpie.\";\nexport const NODE_TOO_OLD_MESSAGE =\n \"Node.js 版本太旧,需要 20 或更高版本。升级后完全退出并重新打开 Magpie。\" +\n \" / Node.js 20+ is required. Upgrade it, then quit and reopen Magpie.\";\n\n/**\n * For the plugin host: return the real node path (the host itself is Bun). If none is found, throw an error with a userMessage,\n * which the gate's 503 page displays; the daemon is no longer silently run on Bun.\n */\nexport function requireNode({ execPath = process.execPath, find = findNode } = {}) {\n if (isNodeExecutable(execPath)) return execPath;\n const hit = find();\n if (hit) return hit;\n const error = new Error(\"node not found on PATH\");\n error.code = \"ENODE\";\n error.userMessage = NODE_MISSING_MESSAGE;\n throw error;\n}\n","// Cross-platform executable resolution + process tree termination (ported from lib/bin.mjs).\n// Goal: start .cmd-shim CLIs like claude/codex without shell:true.\n\nimport { execFileSync, spawn, type ChildProcess, type SpawnOptions } from \"node:child_process\";\nimport { findOnPath, findNode as findNodeOnPath, isNodeExecutable } from \"../../../lib/pathscan.mjs\";\nimport { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\n\nconst IS_WIN = process.platform === \"win32\";\nconst cache = new Map();\nconst CACHE_MS = 60 * 1000;\n\nexport type ResolveResult =\n | {\n ok: true;\n cmd: string;\n prefixArgs: string[];\n display: string;\n via: string;\n /** via === \"cmd-shim\": a .cmd/.bat that must be started through cmd.exe (spawnBin / argvFor handle escaping) */\n shim?: string;\n }\n | { ok: false; reason: string };\n\n// PATH lookup lives in lib/pathscan.mjs: which / where.exe are no longer used (where's output uses the console code page, which garbles Chinese paths).\nfunction which(name: string): string[] {\n return findOnPath(name);\n}\n\n// ---- Windows: escaping when starting a .cmd/.bat through cmd.exe (same rules as cross-spawn) ----\nconst CMD_META = /([()\\][%!^\"`<>&|;, *?])/g;\n\nexport function escapeCmdCommand(cmd: string): string {\n return cmd.replace(CMD_META, \"^$1\");\n}\n\nexport function escapeCmdArgument(arg: string, doubleEscape: boolean): string {\n let a = String(arg);\n // First quote per CommandLineToArgvW rules (backslashes + quotes)\n a = a.replace(/(\\\\*)\"/g, '$1$1\\\\\"');\n a = a.replace(/(\\\\*)$/, \"$1$1\");\n a = `\"${a}\"`;\n // Then escape cmd metacharacters; batch files parse arguments again, so escape twice\n a = a.replace(CMD_META, \"^$1\");\n if (doubleEscape) a = a.replace(CMD_META, \"^$1\");\n return a;\n}\n\nexport type Argv = { cmd: string; args: string[]; windowsVerbatimArguments?: boolean };\n\n/** Resolution result + arguments → the command line actually given to spawn. */\nexport function argvFor(r: ResolveResult & { ok: true }, args: string[]): Argv {\n if (r.via === \"cmd-shim\" && r.shim) {\n const line = [escapeCmdCommand(r.shim), ...args.map((a) => escapeCmdArgument(a, true))].join(\" \");\n return { cmd: r.cmd, args: [\"/d\", \"/s\", \"/c\", `\"${line}\"`], windowsVerbatimArguments: true };\n }\n return { cmd: r.cmd, args: [...r.prefixArgs, ...args] };\n}\n\n// ---- Safety net: end Agent process trees still alive when the daemon exits ----\nconst liveTrees = new Set();\nlet exitHookInstalled = false;\n\nfunction reapOnExit(): void {\n for (const pid of liveTrees) {\n try {\n if (IS_WIN) execFileSync(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { stdio: \"ignore\", windowsHide: true, timeout: 3000 });\n else process.kill(-pid, \"SIGKILL\");\n } catch {}\n }\n liveTrees.clear();\n}\n\n/** Register a process to clean up when the daemon exits (on non-Windows it must lead a detached process group). */\nexport function trackTree(child: ChildProcess): void {\n if (child.pid == null) return;\n const pid = child.pid;\n liveTrees.add(pid);\n child.once(\"exit\", () => liveTrees.delete(pid));\n if (!exitHookInstalled) {\n exitHookInstalled = true;\n process.once(\"exit\", reapOnExit);\n }\n}\n\n/** Start a CLI from a resolution result. On non-Windows it gets its own process group (detached) so killTree can also end grandchildren. */\nexport function spawnBin(\n r: ResolveResult & { ok: true },\n args: string[],\n opts: SpawnOptions & { group?: boolean } = {},\n): ChildProcess {\n const a = argvFor(r, args);\n const { group, ...rest } = opts;\n const child = spawn(a.cmd, a.args, {\n windowsHide: true,\n ...rest,\n ...(a.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),\n ...(group && !IS_WIN ? { detached: true } : {}),\n });\n if (group) trackTree(child);\n return child;\n}\n\n// Find the script/program path a .cmd calls from its text.\nexport function shimTarget(\n cmdText: string,\n cmdDir: string | null,\n deps: { exists?: (p: string) => boolean; win?: boolean } = {},\n): string | null {\n const exists = deps.exists ?? existsSync;\n const P = (deps.win ?? IS_WIN) ? path.win32 : path;\n const cands: string[] = [];\n const re = /\"([^\"]+?\\.(?:js|mjs|cjs|exe|cmd|bat))\"/gi;\n let m: RegExpExecArray | null;\n while ((m = re.exec(cmdText))) cands.push(m[1]);\n const re2 = /(?:%~?dp0%?)?[A-Za-z]:?[\\\\/\\w.@-]*?\\.(?:js|mjs|cjs|exe)\\b/gi;\n while ((m = re2.exec(cmdText))) cands.push(m[0]);\n const found: string[] = [];\n for (const raw of cands) {\n let p = raw.replace(/%~?dp0%?/gi, \"\");\n p = p.replace(/%[A-Z_]+%/gi, \"\");\n p = p.trim().replace(/^[\\\\/]+/, \"\");\n if (!p || /%\\w+%/.test(p) || !/\\.(js|mjs|cjs|exe)$/i.test(p)) continue;\n if (!P.isAbsolute(p)) {\n if (!cmdDir) continue;\n p = P.join(cmdDir, p);\n }\n if (exists(p)) found.push(P.resolve(p));\n }\n // npm shims first write IF EXIST \"%dp0%\\node.exe\" (it really exists when the global dir is the node install dir, e.g. nvm-windows),\n // and only then the real cli.js: the script wins, node.exe itself is not the target\n return (\n found.find((f) => /\\.(js|mjs|cjs)$/i.test(f)) ??\n found.find((f) => /\\.exe$/i.test(f) && !isNodeExecutable(P.basename(f))) ??\n null\n );\n}\n\n// Which node to use when the shim points at a .js: the daemon itself runs on node, so prefer that one; otherwise node from PATH\nfunction findNode(): string {\n if (isNodeExecutable(process.execPath)) return process.execPath;\n return findNodeOnPath() ?? \"node\";\n}\n\nexport async function resolveBin(name: string): Promise {\n const hit = cache.get(name);\n if (hit && Date.now() - hit.at < CACHE_MS) return hit.out;\n const out = await resolveBinUncached(name);\n cache.set(name, { out, at: Date.now() });\n return out;\n}\n\nexport type ResolvedBin = ResolveResult & { ok: true; binName: string };\n\n/** Try several executable names from the catalog in order and return the first hit. */\nexport async function resolveFirstBin(names: string[]): Promise {\n const uniq = [...new Set(names.filter(Boolean))];\n for (const binName of uniq) {\n const r = await resolveBin(binName);\n if (r.ok) return { ...r, binName };\n }\n const label = uniq[0] ?? \"cli\";\n return { ok: false, reason: `PATH 里找不到 ${uniq.length > 1 ? uniq.join(\" 或 \") : label}` };\n}\n\nexport function clearBinCache(name?: string): void {\n if (name) cache.delete(name);\n else cache.clear();\n}\n\nexport type ResolveDeps = {\n platform?: NodeJS.Platform;\n find?: (name: string) => string[];\n readText?: (file: string) => Promise;\n exists?: (file: string) => boolean;\n node?: () => string;\n comspec?: string;\n};\n\nfunction resolveBinUncached(name: string): Promise {\n return resolveBinWith(name);\n}\n\n/** Implementation of resolveBin with injectable dependencies (tests simulate a Windows PATH / shims). */\nexport async function resolveBinWith(name: string, deps: ResolveDeps = {}): Promise {\n const platform = deps.platform ?? process.platform;\n const win = platform === \"win32\";\n const P = win ? path.win32 : path.posix;\n const exists = deps.exists ?? existsSync;\n const hits = (deps.find ?? which)(name);\n if (!hits.length) return { ok: false, reason: `PATH 里找不到 ${name}` };\n // Take the first hit in PATH order (same as typing the command in a terminal; within one directory on Windows, PATHEXT order)\n const first = hits[0];\n if (!win) return { ok: true, cmd: first, prefixArgs: [], display: first, via: \"path\" };\n\n const ext = P.extname(first).toLowerCase();\n if (ext === \".exe\" || ext === \".com\") return { ok: true, cmd: first, prefixArgs: [], display: first, via: \"exe\" };\n if (ext === \".ps1\") {\n return {\n ok: true,\n cmd: \"powershell.exe\",\n prefixArgs: [\"-NoProfile\", \"-NonInteractive\", \"-ExecutionPolicy\", \"Bypass\", \"-File\", first],\n display: first,\n via: \"ps1\",\n };\n }\n // .cmd / .bat\n const dir = P.dirname(first);\n if (name.toLowerCase() === \"npx\") {\n const node = P.join(dir, \"node.exe\");\n const cli = P.join(dir, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\");\n if (exists(node) && exists(cli))\n return { ok: true, cmd: node, prefixArgs: [cli], display: `${node} ${cli}`, via: \"npx-shim\" };\n }\n const text = await (deps.readText ?? ((f: string) => readFile(f, \"utf8\")))(first).catch(() => \"\");\n const target = shimTarget(text, dir, { exists, win: true });\n if (target && /\\.(js|mjs|cjs)$/i.test(target)) {\n const node = (deps.node ?? findNode)();\n return { ok: true, cmd: node, prefixArgs: [target], display: `${node} ${target}`, via: \"shim-js\" };\n }\n if (target && /\\.exe$/i.test(target)) {\n return { ok: true, cmd: target, prefixArgs: [], display: target, via: \"shim-exe\" };\n }\n // PowerShell / other .cmd wrappers: start through cmd.exe (e.g. cursor-agent); escaping happens in argvFor\n const comspec = deps.comspec ?? (process.env.ComSpec || \"cmd.exe\");\n return { ok: true, cmd: comspec, prefixArgs: [], display: first, via: \"cmd-shim\", shim: first };\n}\n\n// Windows: taskkill /T kills the whole tree; elsewhere: detached process group, kill(-pid).\nexport function killTree(child: ChildProcess | null | undefined, { graceMs = 3000 } = {}): Promise {\n if (!child || child.pid == null) return Promise.resolve();\n const pid = child.pid;\n if (IS_WIN) {\n return new Promise((resolvePromise) => {\n const tk = spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { windowsHide: true });\n tk.on(\"error\", () => {\n try {\n child.kill();\n } catch {}\n resolvePromise();\n });\n tk.on(\"close\", () => resolvePromise());\n setTimeout(resolvePromise, graceMs + 2000);\n });\n }\n return new Promise((resolvePromise) => {\n let done = false;\n const finish = () => {\n if (!done) {\n done = true;\n resolvePromise();\n }\n };\n try {\n process.kill(-pid, \"SIGTERM\");\n } catch {\n try {\n child.kill(\"SIGTERM\");\n } catch {}\n }\n // A child that has already exited won't emit close again: no need to wait out graceMs\n if (child.exitCode !== null || child.signalCode !== null) {\n finish();\n return;\n }\n const t = setTimeout(() => {\n try {\n process.kill(-pid, \"SIGKILL\");\n } catch {\n try {\n child.kill(\"SIGKILL\");\n } catch {}\n }\n finish();\n }, graceMs);\n t.unref?.();\n child.on(\"close\", () => {\n clearTimeout(t);\n finish();\n });\n });\n}\n","// Tool name → kind / title / detail mapping (ported from lib/agents/kinds.mjs).\n// Adapters call callInfo(name, input, cwd) to get {kind, title, detail}; the frontend renders by kind.\nimport type { ToolKind } from \"../timeline/types.js\";\n\nconst MAX_CMD = 80;\nconst trunc = (s: string, n = MAX_CMD): string => (s.length > n ? s.slice(0, n - 1) + \"…\" : s);\n\nexport function relPath(p: string, cwd: string): string {\n if (!p) return \"\";\n const s = String(p);\n if (!cwd) return s;\n const norm = (x: string) => x.replace(/\\\\/g, \"/\").replace(/\\/+$/, \"\");\n const a = norm(s);\n const b = norm(cwd);\n if (a.toLowerCase().startsWith(b.toLowerCase() + \"/\")) return a.slice(b.length + 1);\n if (a.toLowerCase() === b.toLowerCase()) return \".\";\n return s;\n}\n\nexport type CallInfo = { kind: ToolKind; title: string; detail: Record };\n\ntype ToolInput = Record & { _exists?: boolean };\n\nexport function callInfo(name: string, input: ToolInput = {}, cwd = \"\"): CallInfo {\n const t = String(name || \"\");\n const f = (kind: ToolKind, title: string, detail: Record): CallInfo => ({ kind, title, detail });\n const file = String(input.file_path ?? input.path ?? input.notebook_path ?? \"\");\n\n switch (t) {\n case \"Read\":\n return f(\"read\", `读取 ${relPath(file, cwd)}`, { path: file, offset: input.offset, limit: input.limit });\n case \"Edit\":\n return f(\"edit\", `编辑 ${relPath(file, cwd)}`, { path: file, old: input.old_string, new: input.new_string });\n case \"MultiEdit\":\n return f(\"edit\", `编辑 ${relPath(file, cwd)}(${((input.edits as unknown[]) || []).length} 处)`, { path: file, edits: input.edits ?? [] });\n case \"Write\":\n return f(\"write\", `${input._exists ? \"覆盖\" : \"新建\"} ${relPath(file, cwd)}`, { path: file, content: input.content });\n case \"NotebookEdit\":\n return f(\"edit\", `编辑笔记本 ${relPath(String(input.notebook_path || file), cwd)}`, { path: input.notebook_path ?? file });\n case \"Bash\":\n return f(\"bash\", `运行 ${trunc(String(input.command ?? \"\"))}`, { command: input.command, description: input.description });\n case \"BashOutput\":\n return f(\"bash\", `查看输出 ${String(input.bash_id ?? \"\")}`, { bashId: input.bash_id });\n case \"KillShell\":\n return f(\"bash\", `结束进程 ${String(input.shell_id ?? \"\")}`, { shellId: input.shell_id });\n case \"Grep\":\n return f(\"search\", `搜索 \"${trunc(String(input.pattern ?? \"\"), 40)}\"`, { pattern: input.pattern, path: input.path, glob: input.glob });\n case \"Glob\":\n case \"LS\":\n return f(\"glob\", `查找 ${trunc(String(input.pattern ?? input.path ?? \"\"), 40)}`, { pattern: input.pattern, path: input.path });\n case \"WebFetch\":\n return f(\"web\", `打开 ${hostOf(String(input.url ?? \"\"))}`, { url: input.url });\n case \"WebSearch\":\n return f(\"web\", `搜索网页 \"${trunc(String(input.query ?? \"\"), 40)}\"`, { query: input.query });\n case \"TodoWrite\": {\n const items = todoItems((input.todos as { content?: string; text?: string; status?: string }[]) ?? []);\n const done = items.filter((x) => x.status === \"done\").length;\n return f(\"todo\", `更新待办(${done}/${items.length})`, { items });\n }\n case \"Task\":\n case \"Agent\":\n return f(\"task\", `子任务:${trunc(String(input.description ?? input.prompt ?? \"\"), 40)}`, { description: input.description });\n default:\n break;\n }\n const m = /^mcp__([^_]+)__(.+)$/.exec(t);\n if (m) return f(\"mcp\", `${m[1]} · ${m[2]}`, { server: m[1], tool: m[2] });\n return f(\"other\", t || \"工具\", {});\n}\n\nfunction hostOf(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return String(url || \"\").slice(0, 40);\n }\n}\n\nexport function todoItems(todos: { content?: string; text?: string; status?: string }[] = []): { text: string; status: string }[] {\n return todos.map((x) => ({\n text: x.content || x.text || \"\",\n status: x.status === \"completed\" ? \"done\" : x.status === \"in_progress\" ? \"in_progress\" : \"pending\",\n }));\n}\n","// claude stream-json messages (and ~/.claude/projects jsonl lines) → ProviderEvent (§7.2.3). Ported from normalize in lib/agents/claude.mjs.\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { callInfo, todoItems } from \"../../agents/kinds.js\";\nimport { editDetail } from \"../../agents/diff.js\";\n\nconst MAX_OUTPUT = 20 * 1024;\nconst ANSI_RE = /\\x1b\\[[0-9;]*[a-zA-Z]|\\x1b\\][^\\x07]*\\x07/g;\nconst clean = (s: string): string => String(s ?? \"\").replace(ANSI_RE, \"\");\n\nexport type MapState = {\n blocks: Map; // content_block_start index → block type\n msgId: string; // Current streaming message.id\n // Streaming block index → unified itemId (msgId:type:ordinal within type).\n // claude's content_block index interleaves thinking/text, while the content array of the assistant event\n // may be renumbered (when thinking is split into another message, text goes back to 0) — a bare index would make\n // the delta and complete event itemIds diverge and the frontend render two duplicate replies. Align both sides by type + ordinal within type.\n itemIdByBlock: Map;\n typeSeq: Map; // Count of each block type in the current message (delta side)\n sawInterrupt: boolean; // After interrupt() the next result counts as interrupted\n};\n\nexport function newMapState(): MapState {\n return { blocks: new Map(), msgId: \"\", itemIdByBlock: new Map(), typeSeq: new Map(), sawInterrupt: false };\n}\n\n// Block type → short code and channel for itemIds; thinking/reasoning are one kind, text another; tool_use uses the tool id and doesn't go through here\nconst blockKind = (t: string): string =>\n t === \"thinking\" || t === \"redacted_thinking\" ? \"k\" : t === \"text\" ? \"t\" : \"x\";\n\nfunction itemIdFor(st: MapState, blockIdx: number, blkType: string): string {\n const k = blockKind(blkType);\n const n = st.typeSeq.get(k) ?? 0;\n st.typeSeq.set(k, n + 1);\n const id = `${st.msgId}:${k}${n}`;\n st.itemIdByBlock.set(blockIdx, id);\n return id;\n}\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\n// One stream-json message → 0..n unified events\nexport function mapSdkMessage(m: unknown, st: MapState, cwd: string): ProviderEvent[] {\n const out: ProviderEvent[] = [];\n if (!m || typeof m !== \"object\") return out;\n const r = m as unknown as Rec;\n\n if (r.type === \"system\") {\n if (r.subtype === \"init\") {\n const sid = str(r.session_id) ?? \"\";\n if (sid) out.push({ type: \"provider_session_id\", id: sid });\n }\n // compact_boundary / status / api_retry etc. → lightweight notices\n if (r.subtype === \"api_retry\") {\n out.push({ type: \"notice\", level: \"warn\", message: `API 重试中(第 ${r.attempt ?? \"?\"} 次)` });\n }\n if (r.subtype === \"compact_boundary\") {\n out.push({ type: \"notice\", level: \"info\", message: \"上下文已压缩\" });\n }\n return out;\n }\n\n if (r.type === \"stream_event\") {\n const e = rec(r.event);\n if (e.type === \"message_start\") {\n const mid = str(rec(e.message).id);\n if (mid) st.msgId = mid;\n st.blocks.clear();\n st.itemIdByBlock.clear();\n st.typeSeq.clear();\n return out;\n }\n if (e.type === \"content_block_start\") {\n const idx = typeof e.index === \"number\" ? e.index : 0;\n const bt = str(rec(e.content_block).type) ?? \"\";\n st.blocks.set(idx, bt);\n itemIdFor(st, idx, bt); // Register index → unified itemId\n return out;\n }\n if (e.type === \"content_block_delta\") {\n const idx = typeof e.index === \"number\" ? e.index : 0;\n const d = rec(e.delta);\n // Fallback when a delta arrives before its start: infer and register from the delta type\n const itemId = st.itemIdByBlock.get(idx) ?? itemIdFor(st, idx, d.type === \"thinking_delta\" ? \"thinking\" : \"text\");\n if (d.type === \"text_delta\" && typeof d.text === \"string\" && d.text) {\n out.push({ type: \"assistant_delta\", itemId, text: d.text });\n } else if (d.type === \"thinking_delta\") {\n const t = str(d.thinking) ?? str(d.text);\n if (t) out.push({ type: \"reasoning_delta\", itemId, text: t });\n }\n // Ignore input_json_delta: wait for the complete tool_use in the assistant message\n }\n return out;\n }\n\n if (r.type === \"assistant\") {\n const mid = str(rec(r.message).id) ?? st.msgId ?? \"\";\n const content = rec(r.message).content;\n const parts = Array.isArray(content) ? content : [];\n // Same rule as the streaming side: build itemIds from type + ordinal within type so they overwrite the matching deltas\n const seqs = new Map();\n parts.forEach((p) => {\n const blk = rec(p);\n const t = str(blk.type) ?? \"\";\n const k = blockKind(t);\n const n = seqs.get(k) ?? 0;\n seqs.set(k, n + 1);\n const itemId = `${mid}:${k}${n}`;\n if (blk.type === \"text\" && typeof blk.text === \"string\" && blk.text) {\n out.push({ type: \"assistant_message\", itemId, text: blk.text });\n } else if (blk.type === \"thinking\" || blk.type === \"redacted_thinking\") {\n const t2 = str(blk.thinking) ?? str(blk.text);\n if (t2) out.push({ type: \"reasoning\", itemId, text: t2 });\n } else if (blk.type === \"tool_use\") {\n out.push(...mapToolUse(blk, cwd));\n }\n });\n return out;\n }\n\n if (r.type === \"user\") {\n const content = rec(r.message).content;\n const parts = Array.isArray(content) ? content : [];\n for (const p of parts) {\n const blk = rec(p);\n if (blk.type === \"tool_result\") {\n const output = clean(contentText(blk.content));\n out.push({\n type: \"tool_call\",\n itemId: str(blk.tool_use_id) ?? \"\",\n kind: \"other\",\n title: \"\",\n status: blk.is_error ? \"failed\" : \"ok\",\n output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output,\n });\n }\n }\n return out;\n }\n\n if (r.type === \"result\") {\n const usage = rec(r.usage);\n const num = (k: string): number => (typeof usage[k] === \"number\" ? (usage[k] as number) : 0);\n out.push({\n type: \"usage\",\n inputTokens: num(\"input_tokens\"),\n outputTokens: num(\"output_tokens\"),\n cacheReadTokens: num(\"cache_read_input_tokens\"),\n costUsd: typeof r.total_cost_usd === \"number\" ? r.total_cost_usd : 0,\n });\n const subtype = str(r.subtype) ?? \"\";\n const interrupted = st.sawInterrupt || /interrupt/i.test(subtype);\n const isError = r.is_error === true;\n out.push({\n type: \"turn_ended\",\n turnId: \"\", // The session fills in the current turnId\n status: interrupted ? \"interrupted\" : isError ? \"failed\" : \"completed\",\n durationMs: typeof r.duration_ms === \"number\" ? r.duration_ms : undefined,\n error: isError && !interrupted ? str(r.result) ?? subtype : undefined,\n filesChanged: typeof r.num_turns === \"number\" ? undefined : undefined,\n });\n st.sawInterrupt = false;\n return out;\n }\n return out;\n}\n\nfunction mapToolUse(blk: Rec, cwd: string): ProviderEvent[] {\n const input = rec(blk.input);\n const name = str(blk.name) ?? \"tool\";\n const id = str(blk.id) ?? \"\";\n const info = callInfo(name, { ...input, _exists: true }, cwd);\n let detail: unknown = info.detail;\n if (info.kind === \"edit\" && (input.old_string !== undefined || Array.isArray(input.edits))) {\n detail = editDetail({ path: (info.detail as Rec).path as string, old: input.old_string, new: input.new_string, edits: input.edits });\n }\n if (info.kind === \"write\" && typeof input.content === \"string\") {\n const lines = input.content.split(\"\\n\");\n detail = { path: (info.detail as Rec).path, lines: lines.slice(0, 200), totalLines: lines.length };\n }\n const out: ProviderEvent[] = [\n {\n type: \"tool_call\",\n itemId: id,\n kind: info.kind,\n title: info.title,\n status: \"running\",\n input,\n detail,\n },\n ];\n if (name === \"TodoWrite\") {\n out.push({ type: \"todo\", items: todoItems((input.todos as { content?: string; status?: string }[]) ?? []) });\n }\n return out;\n}\n\nfunction contentText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) return content.map((p) => str(rec(p).text) ?? \"\").join(\"\");\n return \"\";\n}\n","/** Same as the frontend's normalizeLang */\nexport type AppLocale = \"zh\" | \"en\";\n\nlet locale: AppLocale = \"zh\";\n\nexport function normalizeLang(raw: string | null | undefined): AppLocale {\n const s = String(raw ?? \"\").trim().toLowerCase();\n if (!s) return \"zh\";\n if (s === \"zh\" || s.startsWith(\"zh-\")) return \"zh\";\n return \"en\";\n}\n\nexport function setAppLocale(lang: string | undefined): void {\n locale = normalizeLang(lang);\n}\n\nexport function getAppLocale(): AppLocale {\n return locale;\n}\n\nexport function intlLocale(): string {\n return locale === \"zh\" ? \"zh-CN\" : \"en-US\";\n}\n\nconst dict: Record> = {\n zh: {\n \"thread.defaultTitle\": \"新对话\",\n \"notice.daemonRestart\": \"后台已重启,本轮已中断\",\n \"notice.sessionStartFailed\": \"会话启动失败:{detail}\",\n \"notice.sendFailed\": \"发送失败:{detail}\",\n \"notice.effortNextTurn\": \"思考强度已保存,将在下一轮生效\",\n \"notice.effortResetOnModel\": \"当前模型不支持该思考强度,已恢复为 Default\",\n \"turn.agentExit\": \"Agent 进程意外退出\",\n \"attachment.missing\": \"附件不存在或已失效({ref}),请重新上传\",\n \"notice.acpImageByPath\": \"{agent} 不能直接看图片(它的 ACP 初始化未声明支持图片输入),已改为发送图片的文件路径:{paths}\",\n \"notice.acpImageByPathSwitched\": \"{agent} 是在另一个模型上启动的,只对那个模型接收图片,这次改为发送图片的文件路径(当前模型能看图时它会用 read_image 打开):{paths}\",\n \"notice.acpSessionNotRestored\": \"{agent} 不能恢复上一轮对话(它的 ACP 既不支持 session/load 也不支持 session/resume),已新开一轮;先前的上下文不会带回。\",\n \"notice.attachmentIssue\": \"附件问题:{detail}\",\n \"claude.notFound\": \"找不到 claude({detail})。本插件不附带 Claude Code,请先安装:{hint}\",\n \"claude.startFailed\": \"无法启动 claude:{detail}\",\n \"claude.exited\": \"claude 进程意外退出({code})\",\n \"claude.notRunning\": \"claude 没有在运行,消息未发出\",\n \"claude.closed\": \"会话已关闭\",\n \"export.defaultModel\": \"默认\",\n },\n en: {\n \"thread.defaultTitle\": \"New chat\",\n \"notice.daemonRestart\": \"Daemon restarted — this turn was interrupted\",\n \"notice.sessionStartFailed\": \"Session start failed: {detail}\",\n \"notice.sendFailed\": \"Send failed: {detail}\",\n \"notice.effortNextTurn\": \"Reasoning effort saved — takes effect on the next turn\",\n \"notice.effortResetOnModel\": \"This model does not support that effort level; reset to Default\",\n \"turn.agentExit\": \"Agent process exited unexpectedly\",\n \"attachment.missing\": \"Attachment missing or expired ({ref}) — upload again\",\n \"notice.acpImageByPath\": \"{agent} can't see images directly (its ACP initialize doesn't advertise image input); sent the image file path instead: {paths}\",\n \"notice.acpImageByPathSwitched\": \"{agent} was started on another model and takes image blocks only for that one, so the image went as its file path (it opens it with read_image when this model sees images): {paths}\",\n \"notice.acpSessionNotRestored\": \"{agent} can't restore the previous conversation (its ACP advertises neither session/load nor session/resume); started a new one — prior context won't come back.\",\n \"notice.attachmentIssue\": \"Attachment issue: {detail}\",\n \"claude.notFound\": \"claude was not found ({detail}). Claude Code is not bundled with this plugin; install it first: {hint}\",\n \"claude.startFailed\": \"Could not start claude: {detail}\",\n \"claude.exited\": \"claude exited unexpectedly ({code})\",\n \"claude.notRunning\": \"claude is not running; the message was not sent\",\n \"claude.closed\": \"The session is closed\",\n \"export.defaultModel\": \"Default\",\n },\n};\n\nexport function tr(key: string, params?: Record): string {\n let s = dict[locale][key] ?? dict.en[key] ?? key;\n if (params) {\n for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v));\n }\n return s;\n}\n","import { existsSync } from \"node:fs\";\nimport type { AttachmentStore } from \"../store/attachments.js\";\nimport { validId } from \"./paths.js\";\nimport { tr } from \"./i18n.js\";\n\nlet globalAttachmentStore: AttachmentStore | null = null;\n\n/** Registered at main startup so Providers can resolve upload ids when reading images */\nexport function bindAttachmentStore(store: AttachmentStore): void {\n globalAttachmentStore = store;\n}\n\nasync function lookupAttachmentPath(ref: string): Promise {\n const store = globalAttachmentStore;\n if (!store || !validId(ref)) return null;\n const att = await store.get(ref);\n return att?.path ?? null;\n}\n\nexport async function resolveAttachmentRefs(\n store: AttachmentStore,\n refs: { path: string; mime: string }[],\n): Promise<{ path: string; mime: string }[]> {\n const out: { path: string; mime: string }[] = [];\n for (const a of refs) {\n const att = validId(a.path) ? await store.get(a.path) : null;\n if (att) {\n out.push({ path: att.path, mime: att.mime || a.mime });\n continue;\n }\n const viaGlobal = await lookupAttachmentPath(a.path);\n if (viaGlobal) {\n out.push({ path: viaGlobal, mime: a.mime });\n continue;\n }\n if (existsSync(a.path)) {\n out.push(a);\n continue;\n }\n throw new Error(tr(\"attachment.missing\", { ref: a.path }));\n }\n return out;\n}\n\n/** One path: absolute path / upload id → the file under the attachments directory; throws on failure */\nexport async function resolveImagePath(ref: string): Promise {\n if (existsSync(ref)) return ref;\n const p = await lookupAttachmentPath(ref);\n if (p) return p;\n throw new Error(tr(\"attachment.missing\", { ref: ref }));\n}\n","// What an attachment is, for deciding how each Agent gets it.\n// Only PNG, JPEG, GIF and WebP are images to the model APIs behind Claude Code, Codex and the ACP Agents\n// (Anthropic's Base64ImageSource.media_type is exactly these four); anything else is a file the Agent opens by path.\nexport const MODEL_IMAGE_MIMES = new Set([\"image/png\", \"image/jpeg\", \"image/gif\", \"image/webp\"]);\n\nexport type AttachmentKind = \"image\" | \"text\" | \"other\";\n\n/** \"text/plain; charset=utf-8\" → \"text/plain\" */\nexport function baseMime(mime: string | undefined | null): string {\n return String(mime ?? \"\").split(\";\")[0].trim().toLowerCase();\n}\n\nconst TEXT_APP = new Set([\"application/json\", \"application/xml\", \"application/yaml\", \"application/x-yaml\", \"application/toml\", \"application/javascript\"]);\n\nexport function attachmentKind(mime: string | undefined | null): AttachmentKind {\n const m = baseMime(mime);\n if (MODEL_IMAGE_MIMES.has(m)) return \"image\";\n if (m.startsWith(\"text/\") || TEXT_APP.has(m)) return \"text\";\n return \"other\";\n}\n\n/** The line an Agent gets for an attachment sent by path */\nexport function pathNote(kind: AttachmentKind, file: string): string {\n return `${kind === \"image\" ? \"Attached image\" : \"Attached file\"}: ${file}`;\n}\n","// One user message → the content blocks of a claude stream-json user message (Anthropic Messages content).\n// - PNG / JPEG / GIF / WebP (Base64ImageSource's media types), up to 5 MB each: an image block\n// - text files up to 1 MB: a document block with a plain-text source (DocumentBlockParam + PlainTextSource)\n// - anything else, or too large: the file's absolute path in the text, which Claude Code can Read; with a warning\n// for files that aren't images or text, so the user knows\nimport { readFile, stat } from \"node:fs/promises\";\nimport { basename } from \"node:path\";\nimport { resolveImagePath } from \"../../util/attachmentRefs.js\";\nimport { attachmentKind, baseMime, pathNote } from \"../../util/attachmentKinds.js\";\n\n/** Anthropic's per-image limit */\nexport const CLAUDE_MAX_IMAGE_BYTES = 5 * 1024 * 1024;\nexport const CLAUDE_MAX_TEXT_BYTES = 1024 * 1024;\n\ntype ImageMedia = \"image/png\" | \"image/jpeg\" | \"image/gif\" | \"image/webp\";\nexport type ClaudeBlock =\n | { type: \"text\"; text: string }\n | { type: \"image\"; source: { type: \"base64\"; media_type: ImageMedia; data: string } }\n | { type: \"document\"; source: { type: \"text\"; media_type: \"text/plain\"; data: string }; title?: string; context?: string };\n\nexport async function buildClaudeContent(\n text: string,\n attachments: { path: string; mime: string }[],\n): Promise<{ blocks: ClaudeBlock[]; warnings: string[] }> {\n const media: ClaudeBlock[] = [];\n const notes: string[] = [];\n const warnings: string[] = [];\n for (const a of attachments) {\n let file: string;\n let size: number;\n try {\n file = await resolveImagePath(a.path);\n size = (await stat(file)).size;\n } catch (e) {\n warnings.push(`${a.path}: ${String((e as Error)?.message ?? e)}`);\n continue;\n }\n const kind = attachmentKind(a.mime);\n try {\n if (kind === \"image\" && size <= CLAUDE_MAX_IMAGE_BYTES) {\n const data = (await readFile(file)).toString(\"base64\");\n media.push({ type: \"image\", source: { type: \"base64\", media_type: baseMime(a.mime) as ImageMedia, data } });\n continue;\n }\n if (kind === \"text\" && size <= CLAUDE_MAX_TEXT_BYTES) {\n const data = await readFile(file, \"utf8\");\n media.push({ type: \"document\", source: { type: \"text\", media_type: \"text/plain\", data }, title: basename(file), context: file });\n continue;\n }\n } catch (e) {\n warnings.push(`${basename(file)}: ${String((e as Error)?.message ?? e)}`);\n }\n notes.push(pathNote(kind, file));\n if (kind === \"other\") warnings.push(`${basename(file)} (${baseMime(a.mime) || \"unknown type\"}) is not an image or a text file; sent its path`);\n else if (kind === \"image\" && size > CLAUDE_MAX_IMAGE_BYTES) warnings.push(`${basename(file)}: ${(size / 1024 / 1024).toFixed(1)} MB, over Claude's 5 MB image limit; sent its path`);\n else if (kind === \"text\" && size > CLAUDE_MAX_TEXT_BYTES) warnings.push(`${basename(file)}: ${(size / 1024 / 1024).toFixed(1)} MB, too large to attach inline; sent its path`);\n }\n const fullText = [text, ...(notes.length ? [notes.join(\"\\n\")] : [])].filter(Boolean).join(\"\\n\\n\");\n // Media first, as before; the text block always comes last (claude needs at least one block)\n return { blocks: [...media, { type: \"text\", text: fullText }], warnings };\n}\n","// The user's own `claude` CLI driven over its stream-json protocol (no SDK):\n// claude -p --input-format stream-json --output-format stream-json --verbose --include-partial-messages\n// --permission-prompt-tool stdio [--model m] [--permission-mode p] [--effort e] [--resume id]\n// stdin and stdout carry one JSON object per line. Besides the conversation messages (user / assistant / system /\n// stream_event / result), both sides send control messages:\n// → {type:\"control_request\", request_id, request:{subtype:\"initialize\" | \"interrupt\" | \"set_model\" | \"set_permission_mode\", ...}}\n// ← {type:\"control_response\", response:{subtype:\"success\" | \"error\", request_id, response? | error?}}\n// ← {type:\"control_request\", request_id, request:{subtype:\"can_use_tool\", tool_name, input, tool_use_id, ...}}\n// → {type:\"control_response\", response:{subtype:\"success\", request_id, response:{behavior:\"allow\", updatedInput} | {behavior:\"deny\", message, interrupt?}}}\n// ← {type:\"control_cancel_request\", request_id} (the CLI no longer waits for that answer)\nimport type { ChildProcess } from \"node:child_process\";\nimport { killTree, spawnBin, type ResolveResult } from \"../../util/bin.js\";\nimport { AsyncQueue } from \"../../util/queue.js\";\n\nexport type ClaudeBin = ResolveResult & { ok: true };\nexport type Json = Record;\n\nexport type ClaudeCliOpts = {\n bin: ClaudeBin;\n cwd: string;\n model: string | null;\n permissionMode: string;\n effort: string | null;\n resume: string | null;\n env?: NodeJS.ProcessEnv;\n};\n\nexport type PermissionDecision =\n | { behavior: \"allow\"; updatedInput: Json }\n | { behavior: \"deny\"; message: string; interrupt?: boolean };\n\nexport type ToolRequest = { toolName: string; input: Json; toolUseId?: string; signal: AbortSignal };\nexport type CanUseTool = (req: ToolRequest) => Promise;\n\n/** Session ids and model / mode / effort names become argv: plain tokens only, so nothing can turn into another flag. */\nconst SAFE_ARG = /^[A-Za-z0-9][A-Za-z0-9._:\\[\\]\\/@+-]{0,199}$/;\n\nexport function claudeArgs(o: Pick): string[] {\n const args = [\n \"-p\",\n \"--input-format\", \"stream-json\",\n \"--output-format\", \"stream-json\",\n \"--verbose\",\n \"--include-partial-messages\",\n \"--permission-prompt-tool\", \"stdio\",\n \"--setting-sources\", \"user,project,local\",\n ];\n const put = (flag: string, v: string | null | undefined) => {\n if (!v) return;\n if (!SAFE_ARG.test(v)) throw new Error(`Refusing to pass an unexpected value to claude ${flag}: ${JSON.stringify(v).slice(0, 80)}`);\n args.push(flag, v);\n };\n put(\"--permission-mode\", o.permissionMode);\n // claude only takes bypassPermissions when it is allowed at launch; it is not allowed otherwise (a later switch\n // into it restarts the CLI with --resume)\n if (o.permissionMode === \"bypassPermissions\") args.push(\"--allow-dangerously-skip-permissions\");\n put(\"--model\", o.model);\n put(\"--effort\", o.effort);\n put(\"--resume\", o.resume);\n return args;\n}\n\ntype Pending = { resolve: (v: Json) => void; reject: (e: Error) => void; timer?: NodeJS.Timeout };\n\nexport class ClaudeCli {\n /** Conversation messages (everything that is not a control message) */\n readonly messages = new AsyncQueue();\n readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;\n /** The answer to initialize (commands, models, account …), null when it failed */\n readonly initialized: Promise;\n private child: ChildProcess;\n private buf = \"\";\n private seq = 0;\n private pending = new Map();\n private cancels = new Map();\n private stderrTail = \"\";\n private dead = false;\n private spawnError: Error | null = null;\n\n constructor(private o: ClaudeCliOpts, private canUseTool: CanUseTool) {\n const args = claudeArgs(o);\n this.child = spawnBin(o.bin, args, {\n cwd: o.cwd,\n env: { ...(o.env ?? process.env), NO_COLOR: \"1\", FORCE_COLOR: \"0\" },\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n group: true,\n });\n this.child.stdout?.setEncoding(\"utf8\");\n this.child.stdout?.on(\"data\", (d: string) => this.onData(d));\n this.child.stderr?.on(\"data\", (d: Buffer) => {\n this.stderrTail = (this.stderrTail + d.toString()).slice(-4096);\n });\n this.child.stdin?.on(\"error\", () => {}); // EPIPE after the CLI exits: reported through exited\n this.exited = new Promise((resolve) => {\n this.child.on(\"error\", (e) => {\n this.spawnError = e;\n this.finish();\n resolve({ code: null, signal: null });\n });\n this.child.on(\"close\", (code, signal) => {\n this.finish();\n resolve({ code, signal });\n });\n });\n this.initialized = this.request({ subtype: \"initialize\" }, 60000).catch(() => null);\n }\n\n get stderr(): string {\n return this.stderrTail;\n }\n\n get failure(): Error | null {\n return this.spawnError;\n }\n\n get alive(): boolean {\n return !this.dead;\n }\n\n private finish(): void {\n if (this.dead) return;\n this.dead = true;\n for (const [, p] of this.pending) {\n if (p.timer) clearTimeout(p.timer);\n p.reject(new Error(\"claude exited\"));\n }\n this.pending.clear();\n for (const [, c] of this.cancels) c.abort();\n this.cancels.clear();\n this.messages.close();\n }\n\n private onData(d: string): void {\n this.buf += d;\n let i: number;\n while ((i = this.buf.indexOf(\"\\n\")) >= 0) {\n const line = this.buf.slice(0, i).trim();\n this.buf = this.buf.slice(i + 1);\n if (!line) continue;\n let m: Json;\n try {\n m = JSON.parse(line);\n } catch {\n continue; // a stray non-JSON line (warnings some versions print) is not part of the protocol\n }\n if (m && typeof m === \"object\") this.onMessage(m);\n }\n }\n\n private onMessage(m: Json): void {\n if (m.type === \"control_response\") {\n const r = (m.response ?? {}) as Json;\n const id = String(r.request_id ?? \"\");\n const p = this.pending.get(id);\n if (!p) return;\n this.pending.delete(id);\n if (p.timer) clearTimeout(p.timer);\n if (r.subtype === \"success\") p.resolve((r.response ?? {}) as Json);\n else p.reject(new Error(String(r.error ?? \"control request failed\")));\n return;\n }\n if (m.type === \"control_request\") {\n void this.onControlRequest(String(m.request_id ?? \"\"), (m.request ?? {}) as Json);\n return;\n }\n if (m.type === \"control_cancel_request\") {\n const id = String(m.request_id ?? \"\");\n this.cancels.get(id)?.abort();\n this.cancels.delete(id);\n return;\n }\n if (m.type === \"keep_alive\") return;\n this.messages.push(m);\n }\n\n private async onControlRequest(id: string, req: Json): Promise {\n if (req.subtype !== \"can_use_tool\") {\n // hooks, in-process MCP servers, elicitation …: none are registered, so say so instead of leaving the CLI waiting\n this.write({ type: \"control_response\", response: { subtype: \"error\", request_id: id, error: `Unsupported control request: ${String(req.subtype)}` } });\n return;\n }\n const ac = new AbortController();\n this.cancels.set(id, ac);\n try {\n const decision = await this.canUseTool({\n toolName: String(req.tool_name ?? \"\"),\n input: (req.input && typeof req.input === \"object\" ? req.input : {}) as Json,\n toolUseId: typeof req.tool_use_id === \"string\" ? req.tool_use_id : undefined,\n signal: ac.signal,\n });\n if (ac.signal.aborted && !this.cancels.has(id)) return; // cancelled by the CLI: it no longer reads this answer\n this.write({ type: \"control_response\", response: { subtype: \"success\", request_id: id, response: decision } });\n } catch (e) {\n this.write({ type: \"control_response\", response: { subtype: \"error\", request_id: id, error: String((e as Error)?.message ?? e) } });\n } finally {\n this.cancels.delete(id);\n }\n }\n\n private write(o: Json): boolean {\n if (this.dead || !this.child.stdin || this.child.stdin.destroyed) return false;\n try {\n this.child.stdin.write(JSON.stringify(o) + \"\\n\");\n return true;\n } catch {\n return false;\n }\n }\n\n /** Send one control request and wait for its answer */\n request(request: Json, timeoutMs = 10000): Promise {\n if (this.dead) return Promise.reject(new Error(\"claude is not running\"));\n const id = `ac_${++this.seq}_${Math.random().toString(36).slice(2, 8)}`;\n return new Promise((resolve, reject) => {\n const p: Pending = { resolve, reject };\n p.timer = setTimeout(() => {\n if (this.pending.delete(id)) reject(new Error(`claude did not answer ${String(request.subtype)} in ${Math.round(timeoutMs / 1000)} s`));\n }, timeoutMs);\n this.pending.set(id, p);\n if (!this.write({ type: \"control_request\", request_id: id, request })) {\n this.pending.delete(id);\n clearTimeout(p.timer);\n reject(new Error(\"claude is not running\"));\n }\n });\n }\n\n /** One user message into the conversation */\n sendUser(content: unknown[], sessionId: string | null): boolean {\n return this.write({ type: \"user\", message: { role: \"user\", content }, parent_tool_use_id: null, session_id: sessionId ?? \"\" });\n }\n\n interrupt(): Promise {\n return this.request({ subtype: \"interrupt\" }, 5000);\n }\n\n setModel(model: string | null): Promise {\n return this.request(model ? { subtype: \"set_model\", model } : { subtype: \"set_model\" });\n }\n\n setPermissionMode(mode: string): Promise {\n return this.request({ subtype: \"set_permission_mode\", mode });\n }\n\n /** End stdin (the CLI finishes and exits), then end the process tree if it lingers */\n async close(graceMs = 2000): Promise {\n try {\n this.child.stdin?.end();\n } catch {}\n const done = await Promise.race([this.exited.then(() => true), new Promise((r) => setTimeout(() => r(false), graceMs))]);\n if (!done) await killTree(this.child, { graceMs: 500 });\n this.finish();\n }\n}\n","// Claude ProviderSession (§7.2.2/7.2.4/7.2.5/7.2.6): the user's own `claude` CLI over stream-json (./cli.ts),\n// one process per conversation, streaming, permissions, interrupt, hot switching. No SDK.\nimport type { ProviderSession, SendInput, RespondAnswer, Mode, SlashCommand } from \"../types.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { AsyncQueue } from \"../../util/queue.js\";\nimport { callInfo } from \"../../agents/kinds.js\";\nimport { editDetail } from \"../../agents/diff.js\";\nimport { mapSdkMessage, newMapState, type MapState } from \"./map.js\";\nimport { log } from \"../../log.js\";\nimport { buildClaudeContent } from \"./content.js\";\nimport { tr } from \"../../util/i18n.js\";\nimport { classifyClaudeSlashCommand, sortSlashCommands } from \"../slash.js\";\nimport { ClaudeCli, type ClaudeBin, type PermissionDecision, type ToolRequest } from \"./cli.js\";\n\nconst EFFORTS = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"];\nconst MODES = [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"];\nconst rid = () => Math.random().toString(36).slice(2, 10);\n\ntype PendingReq = {\n kind: \"permission\" | \"question\";\n resolve: (r: RespondAnswer) => void;\n};\n\nexport type ClaudeSessionOpts = {\n threadId: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n /** The user's claude, as resolveBin found it (a .cmd / .ps1 shim is started through cmd.exe / PowerShell by spawnBin) */\n bin: ClaudeBin;\n /** Environment for the CLI (tests point it at a stand-in); process.env when absent */\n env?: NodeJS.ProcessEnv;\n onModeChange?: (mode: Mode) => void; // Callback when a plan is approved → default (the manager updates meta)\n};\n\nexport class ClaudeSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private cli: ClaudeCli | null = null;\n private sessionId: string | null;\n private turnId: string | null = null;\n private turnNum = 0;\n private st: MapState = newMapState();\n private pending = new Map();\n private needsRestart = false;\n private restarting: Promise | null = null;\n private closed = false;\n private stopping = new WeakSet();\n private pumpDone: Promise | null = null;\n /** Mode the running CLI was started with (bypassPermissions has to be allowed at launch) */\n private launchedMode: string | null = null;\n\n constructor(private o: ClaudeSessionOpts) {\n this.sessionId = o.resumeId;\n this.events = this.q;\n }\n\n // The CLI is only started before the first send (or a command list request)\n private ensureCli(): ClaudeCli {\n if (this.cli && !this.needsRestart) return this.cli;\n this.st = newMapState();\n const effort = this.o.effort && EFFORTS.includes(this.o.effort) ? this.o.effort : null;\n const cli = new ClaudeCli(\n {\n bin: this.o.bin,\n cwd: this.o.cwd,\n model: this.o.model,\n permissionMode: MODES.includes(this.o.mode) ? this.o.mode : \"default\",\n effort,\n resume: this.sessionId,\n env: this.o.env,\n },\n (req) => this.canUseTool(req),\n );\n this.cli = cli;\n this.launchedMode = this.o.mode;\n this.needsRestart = false;\n this.pumpDone = this.pump(cli);\n return cli;\n }\n\n private async pump(cli: ClaudeCli): Promise {\n for await (const m of cli.messages) {\n for (const ev of mapSdkMessage(m, this.st, this.o.cwd)) this.emit(ev);\n }\n const exit = await cli.exited;\n // A CLI this session stopped on purpose (restart / close) is not a crash\n if (this.closed || this.stopping.has(cli)) return;\n const detail = cli.failure\n ? tr(\"claude.startFailed\", { detail: String(cli.failure.message) })\n : tr(\"claude.exited\", { code: String(exit.code ?? exit.signal ?? \"?\") });\n const tail = cli.stderr.trim().slice(-400);\n this.emit({ type: \"notice\", level: \"error\", message: tail ? `${detail} — stderr: ${tail}` : detail });\n if (this.turnId) this.emit({ type: \"turn_ended\", turnId: this.turnId, status: \"failed\", error: detail });\n this.turnId = null;\n // The event stream ended: an old CLI that restart() replaced must not close the event queue\n if (this.cli === cli) {\n this.needsRestart = true; // Rebuild with --resume on the next send\n this.q.close();\n }\n }\n\n private emit(ev: ProviderEvent): void {\n if (ev.type === \"turn_ended\" && !ev.turnId) ev.turnId = this.turnId ?? \"unknown\";\n if (ev.type === \"turn_ended\") this.turnId = null;\n if (ev.type === \"provider_session_id\" && ev.id) this.sessionId = ev.id;\n this.q.push(ev);\n }\n\n async send(input: SendInput): Promise {\n if (this.closed) throw new Error(tr(\"claude.closed\"));\n if (this.needsRestart) await this.restart();\n const cli = this.ensureCli();\n this.turnNum++;\n this.turnId = \"u_\" + Date.now().toString(36) + \"_\" + this.turnNum;\n this.st.sawInterrupt = false; // A new turn clears the old interrupt flag\n this.emit({\n type: \"turn_started\",\n turnId: this.turnId,\n model: this.o.model,\n mode: this.o.mode,\n effort: this.o.effort,\n });\n const built = await buildClaudeContent(input.text, input.images ?? []);\n for (const w of built.warnings) this.emit({ type: \"notice\", level: \"warn\", message: tr(\"notice.attachmentIssue\", { detail: w }) });\n if (!cli.sendUser(built.blocks, this.sessionId)) throw new Error(tr(\"claude.notRunning\"));\n }\n\n private async restart(): Promise {\n if (this.restarting) return this.restarting;\n this.restarting = (async () => {\n const old = this.cli;\n this.cli = null;\n if (old) {\n this.stopping.add(old);\n await old.close().catch(() => {});\n }\n if (this.pumpDone) await this.pumpDone.catch(() => {});\n try {\n this.ensureCli();\n } finally {\n this.restarting = null;\n }\n })();\n return this.restarting;\n }\n\n // ---- permissions (§7.2.4) ----\n private canUseTool(req: ToolRequest): Promise {\n const { toolName, input, signal } = req;\n const requestId = \"perm_\" + rid();\n const opts = { toolUseID: req.toolUseId };\n\n if (toolName === \"ExitPlanMode\") {\n return this.handlePlanMode(requestId, input, signal, opts.toolUseID);\n }\n if (toolName === \"AskUserQuestion\" && Array.isArray(input.questions)) {\n return this.handleQuestion(requestId, input, signal);\n }\n\n const info = callInfo(toolName, { ...input, _exists: true }, this.o.cwd);\n let detail: Record = { ...(info.detail as Record), toolName };\n if (info.kind === \"edit\" && (input.old_string !== undefined || Array.isArray(input.edits))) {\n detail = { ...editDetail({ path: info.detail.path as string, old: input.old_string, new: input.new_string, edits: input.edits }), toolName };\n }\n this.emit({\n type: \"permission_request\",\n requestId,\n title: info.title,\n kind: \"tool\",\n detail,\n options: [\n { id: \"allow\", label: \"允许\", kind: \"allow_once\" },\n { id: \"allow_always\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"deny\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n return new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"permission\", resolve: (a) => resolve(this.toPermissionDecision(a, input)) });\n signal.addEventListener(\"abort\", () => {\n if (this.pending.delete(requestId)) {\n this.emit({ type: \"permission_resolved\", requestId, cancelled: true });\n resolve({ behavior: \"deny\", message: \"已取消\", interrupt: true });\n }\n }, { once: true });\n });\n }\n\n private toPermissionDecision(a: RespondAnswer, input: Record): PermissionDecision {\n if (a.cancelled || a.optionId === \"deny\") {\n return { behavior: \"deny\", message: \"用户拒绝了此操作\", ...(a.cancelled ? { interrupt: true } : {}) };\n }\n return { behavior: \"allow\", updatedInput: input };\n }\n\n private async handlePlanMode(requestId: string, input: Record, signal: AbortSignal, toolUseID?: string): Promise {\n const planText = typeof input.plan === \"string\" ? input.plan : \"\";\n if (planText) {\n this.emit({ type: \"plan\", itemId: toolUseID ?? requestId, markdown: planText });\n }\n this.emit({\n type: \"permission_request\",\n requestId,\n title: \"Agent 提交了计划\",\n kind: \"plan\",\n detail: { plan: planText },\n options: [\n { id: \"implement\", label: \"实施\", kind: \"allow_once\" },\n { id: \"reject\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const answer = await new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"permission\", resolve: (a) => resolve(a as RespondAnswer) });\n signal.addEventListener(\"abort\", () => {\n if (this.pending.delete(requestId)) {\n this.emit({ type: \"permission_resolved\", requestId, cancelled: true });\n resolve({ cancelled: true });\n }\n }, { once: true });\n });\n if (answer.cancelled || answer.optionId === \"reject\") {\n return { behavior: \"deny\", message: \"用户拒绝了计划,请根据反馈修改\", ...(answer.cancelled ? { interrupt: true } : {}) };\n }\n // implement: switch back to default mode\n try {\n await this.cli?.setPermissionMode(\"default\");\n this.o.mode = \"default\";\n this.o.onModeChange?.(\"default\");\n } catch (e) {\n log(\"warn\", \"setPermissionMode(default) 失败\", { err: String(e) });\n }\n return { behavior: \"allow\", updatedInput: input };\n }\n\n private async handleQuestion(requestId: string, input: Record, signal: AbortSignal): Promise {\n this.emit({ type: \"question\", requestId, questions: input.questions as unknown[] });\n const answer = await new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"question\", resolve: (a) => resolve(a as RespondAnswer) });\n signal.addEventListener(\"abort\", () => {\n if (this.pending.delete(requestId)) {\n this.emit({ type: \"permission_resolved\", requestId, cancelled: true });\n resolve({ cancelled: true });\n }\n }, { once: true });\n });\n if (answer.cancelled) {\n return { behavior: \"deny\", message: \"用户取消了提问\", interrupt: true };\n }\n // answers are keyed by the question text (Claude's AskUserQuestion schema keys answers by question text)\n const questions = (input.questions as { question?: string; header?: string }[]) ?? [];\n const normalized: Record = {};\n for (const ques of questions) {\n const text = ques.question;\n if (!text) continue;\n const v = answer.answers?.[text] ?? (ques.header ? answer.answers?.[ques.header] : undefined);\n if (v) normalized[text] = v;\n }\n return { behavior: \"allow\", updatedInput: { ...input, answers: normalized } };\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const p = this.pending.get(requestId);\n if (p) {\n this.pending.delete(requestId);\n p.resolve(answer);\n }\n }\n\n async interrupt(): Promise {\n this.st.sawInterrupt = true;\n // Cancel every pending permission/question\n for (const [id, p] of this.pending) {\n this.emit({ type: \"permission_resolved\", requestId: id, cancelled: true });\n p.resolve({ cancelled: true });\n }\n this.pending.clear();\n const cli = this.cli;\n if (!cli || !cli.alive) return;\n try {\n await cli.interrupt();\n } catch (e) {\n // No answer: stop this CLI; the next send starts it again with --resume\n log(\"warn\", \"claude interrupt failed\", { err: String(e) });\n this.stopping.add(cli);\n this.needsRestart = true;\n void cli.close(500);\n if (this.turnId) this.emit({ type: \"turn_ended\", turnId: this.turnId, status: \"interrupted\" });\n }\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model;\n if (!this.cli?.alive) return;\n try {\n await this.cli.setModel(model);\n } catch (e) {\n log(\"warn\", \"claude set_model failed\", { err: String(e) });\n this.needsRestart = true;\n }\n }\n\n async setMode(mode: Mode): Promise {\n this.o.mode = mode;\n if (!this.cli?.alive) return;\n // bypassPermissions is only accepted by a CLI started with it allowed: restart into it (--resume keeps the context)\n if (mode === \"bypassPermissions\" && this.launchedMode !== \"bypassPermissions\") {\n this.needsRestart = true;\n return;\n }\n try {\n await this.cli.setPermissionMode(mode);\n } catch (e) {\n log(\"warn\", \"claude set_permission_mode failed\", { err: String(e) });\n this.needsRestart = true;\n }\n }\n\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort;\n // --effort is a launch flag → restart (continued via --resume)\n this.needsRestart = true;\n }\n\n async listCommands(): Promise {\n try {\n const init = await this.ensureCli().initialized;\n const commands = Array.isArray(init?.commands) ? (init!.commands as { name?: string; description?: string; argumentHint?: string }[]) : [];\n const map = new Map();\n for (const cmd of commands) {\n if (cmd?.name && !map.has(cmd.name)) {\n map.set(cmd.name, {\n name: cmd.name,\n description: cmd.description ?? \"\",\n argumentHint: cmd.argumentHint,\n kind: classifyClaudeSlashCommand(cmd.name),\n });\n }\n }\n return sortSlashCommands([...map.values()]);\n } catch {\n return [];\n }\n }\n\n async close(): Promise {\n this.closed = true;\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n const cli = this.cli;\n if (cli) {\n this.stopping.add(cli);\n await cli.close().catch(() => {});\n }\n if (this.pumpDone) {\n await Promise.race([this.pumpDone.catch(() => {}), new Promise((r) => setTimeout(r, 3000))]);\n }\n this.q.close();\n }\n}\n","// Model list (§7.2.7): Claude Code has no model query API.\n// Behind the magpie gateway, gateway models, the default model and thinking levels are filled in by providers/magpieGateway.ts;\n// without magpie only \"Default\" is offered (the frontend always shows it; no empty-id placeholder is added here anymore).\nimport type { ModelInfo } from \"../types.js\";\n\nexport async function listClaudeModels(): Promise {\n return [];\n}\n","// History import (§7.6): Claude sessions live in ~/.claude/projects//.jsonl.\n// Don't guess the encoding rule: list every directory, read the first record with a cwd from each jsonl, match the project with samePath.\n// Read-only throughout.\nimport { readdir, readFile, stat } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { samePath } from \"../util/paths.js\";\n\nconst CLAUDE_ROOT = join(homedir(), \".claude\", \"projects\");\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\n// One jsonl line → {cwd?, title?, isUserText}\nfunction parseLine(line: string): { cwd?: string; userText?: string } | null {\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n return null;\n }\n const cwd = str(r.cwd);\n let userText: string | undefined;\n if (r.type === \"user\") {\n const content = rec(r.message).content;\n if (typeof content === \"string\") userText = content;\n else if (Array.isArray(content)) {\n const t = content.map((p) => (rec(p).type === \"text\" ? str(rec(p).text) : undefined)).find(Boolean);\n userText = t;\n }\n }\n return { cwd, userText };\n}\n\nasync function* linesOf(path: string): AsyncGenerator {\n const text = await readFile(path, \"utf8\").catch(() => \"\");\n for (const line of text.split(\"\\n\")) {\n if (line.trim()) yield line;\n }\n}\n\n// Project cwd → that project's past sessions\nexport async function listClaudeHistory(cwd: string): Promise<{ sessionId: string; title: string; updatedAt: number; messageCount: number }[]> {\n const out: { sessionId: string; title: string; updatedAt: number; messageCount: number }[] = [];\n const dirs = await readdir(CLAUDE_ROOT).catch(() => [] as string[]);\n for (const d of dirs) {\n const dirPath = join(CLAUDE_ROOT, d);\n const files = (await readdir(dirPath).catch(() => [] as string[])).filter((f) => f.endsWith(\".jsonl\"));\n for (const f of files) {\n const path = join(dirPath, f);\n let match = false;\n let title = \"\";\n let count = 0;\n for await (const line of linesOf(path)) {\n const p = parseLine(line);\n if (!p) continue;\n if (!match) {\n if (!p.cwd) continue;\n match = samePath(p.cwd, cwd);\n if (!match) break;\n }\n if (p.userText && !title) title = p.userText.slice(0, 40);\n if (p.userText) count++;\n }\n if (!match) continue;\n const s = await stat(path).catch(() => null);\n out.push({\n sessionId: f.replace(/\\.jsonl$/, \"\"),\n title: title || \"(无标题)\",\n updatedAt: s ? Math.round(s.mtimeMs) : 0,\n messageCount: count,\n });\n }\n }\n out.sort((a, b) => b.updatedAt - a.updatedAt);\n return out;\n}\n\n// Read one session's jsonl → SDK message shape (so mapSdkMessage can be reused)\nexport async function readClaudeHistory(sessionId: string, cwd: string): Promise {\n const dirs = await readdir(CLAUDE_ROOT).catch(() => [] as string[]);\n for (const d of dirs) {\n const path = join(CLAUDE_ROOT, d, `${sessionId}.jsonl`);\n if (!existsSync(path)) continue;\n // Confirm it belongs to this project (the first cwd record matches)\n const msgs: Rec[] = [];\n let matched = false;\n for await (const line of linesOf(path)) {\n const p = parseLine(line);\n if (!matched) {\n if (p?.cwd && samePath(p.cwd, cwd)) matched = true;\n else continue;\n }\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n continue;\n }\n // Keep only user/assistant/result — same shape as the SDK's streamed messages, so mapSdkMessage can digest them\n if (r.type === \"user\" || r.type === \"assistant\") msgs.push(r);\n }\n return msgs;\n }\n return [];\n}\n","// Claude Provider (§7.2): detection + createSession(ClaudeSession).\nimport type { Provider, ProviderSession, DetectResult, ModelInfo, HistoryItem } from \"../types.js\";\nimport { resolveBin, killTree, spawnBin, type ResolveResult } from \"../../util/bin.js\";\nimport { ClaudeSession } from \"./session.js\";\nimport { listClaudeModels } from \"./models.js\";\nimport { listClaudeHistory, readClaudeHistory } from \"../../history/import.js\";\nimport { mapSdkMessage, newMapState } from \"./map.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { tr } from \"../../util/i18n.js\";\n\nconst INSTALL_HINT = \"npm i -g @anthropic-ai/claude-code\";\n\nexport const claudeCapabilities = {\n resume: true,\n interrupt: true,\n steer: false, // Claude doesn't support real steering — the manager queues instead (§7.2.2)\n images: true,\n plan: true,\n questions: true,\n cost: true,\n effort: [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"],\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"] as (\"plan\" | \"default\" | \"acceptEdits\" | \"bypassPermissions\")[],\n};\n\nfunction runVersion(r: ResolveResult & { ok: true }, args: string[], timeoutMs = 5000): Promise {\n return new Promise((resolvePromise, reject) => {\n let out = \"\";\n const p = spawnBin(r, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"], env: { ...process.env, NO_COLOR: \"1\", FORCE_COLOR: \"0\" } });\n const t = setTimeout(() => {\n try {\n void killTree(p, { graceMs: 500 }); // On Windows a .cmd wrapper must be killed together with its child processes\n } catch {}\n reject(new Error(\"timeout\"));\n }, timeoutMs);\n p.stdout?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.stderr?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.on(\"error\", reject);\n p.on(\"close\", (code) => {\n clearTimeout(t);\n if (code === 0) resolvePromise(out);\n else reject(new Error(\"exit \" + code));\n });\n });\n}\n\nexport function createClaudeProvider(): Provider {\n return {\n id: \"claude\",\n name: \"Claude Code\",\n capabilities: claudeCapabilities,\n async detect(): Promise {\n const bin = await resolveBin(\"claude\");\n if (!bin.ok) return { available: false, reason: bin.reason, installHint: INSTALL_HINT };\n try {\n const version = await runVersion(bin, [\"--version\"]);\n return { available: true, bin: bin.display, version: version.trim().split(\"\\n\")[0] || \"\" };\n } catch {\n return { available: true, bin: bin.display, version: \"\" };\n }\n },\n async listModels(): Promise {\n return listClaudeModels();\n },\n async createSession(o): Promise {\n const bin = await resolveBin(\"claude\");\n if (!bin.ok) throw new Error(tr(\"claude.notFound\", { detail: bin.reason, hint: INSTALL_HINT }));\n return new ClaudeSession({\n threadId: o.threadId,\n cwd: o.cwd,\n model: o.model,\n mode: o.mode,\n effort: o.effort,\n resumeId: o.resumeId,\n bin,\n });\n },\n // §7.6: jsonl sessions under ~/.claude/projects matched by cwd\n async listHistory(cwd: string): Promise {\n const items = await listClaudeHistory(cwd);\n return items.map((i) => ({ provider: \"claude\", ...i, imported: false }));\n },\n async readHistory(sessionId: string, cwd: string): Promise {\n const msgs = await readClaudeHistory(sessionId, cwd);\n const st = newMapState();\n const out: ProviderEvent[] = [];\n for (const m of msgs) {\n const r = m as { type?: string; message?: { content?: unknown } };\n // User text messages don't appear as events in claude's stream-json output (the manager writes user_message itself); emit them here\n if (r.type === \"user\") {\n const content = r.message?.content;\n const text = typeof content === \"string\"\n ? content\n : Array.isArray(content)\n ? (content as { type?: string; text?: string }[]).filter((b) => b.type === \"text\").map((b) => b.text ?? \"\").join(\"\")\n : \"\";\n if (text.trim()) out.push({ type: \"user_message\", text });\n else out.push(...mapSdkMessage(m as never, st, cwd)); // tool_result and the like still go through the mapping\n continue;\n }\n out.push(...mapSdkMessage(m as never, st, cwd));\n }\n // Finish: an imported session is closed\n out.push({ type: \"turn_ended\", turnId: \"\", status: \"completed\" });\n return out;\n },\n };\n}\n","// Codex history import (§7.6): ~/.codex/sessions/**/rollout-*.jsonl\n// Layout: session_meta (with cwd) → event_msg → response_item (message/function_call/...)\n// Import only user messages (minus environment_context and similar wrappers) + the assistant's final reply text; tool steps become notices. Read-only throughout.\nimport { readdir, readFile, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { samePath } from \"../util/paths.js\";\n\nconst CODEX_ROOT = join(homedir(), \".codex\", \"sessions\");\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\n// Environment wrappers in user messages: anything whose first line starts with or isn't user input\nconst WRAPPERS = /^\\s*<(environment_context|user_instructions|skills_instructions|permissions instructions)>/i;\n\nfunction textOf(content: unknown, kind: string): string {\n if (!Array.isArray(content)) return \"\";\n return (content as Rec[])\n .filter((p) => p.type === kind || p.type === \"input_text\" || p.type === \"output_text\")\n .map((p) => str(p.text) ?? \"\")\n .join(\"\\n\")\n .trim();\n}\n\n// Find rollout-*.jsonl recursively\nasync function* walk(dir: string, depth = 0): AsyncGenerator {\n if (depth > 6) return;\n for (const name of await readdir(dir).catch(() => [] as string[])) {\n const p = join(dir, name);\n const s = await stat(p).catch(() => null);\n if (!s) continue;\n if (s.isDirectory()) yield* walk(p, depth + 1);\n else if (/^rollout-.*\\.jsonl$/.test(name)) yield p;\n }\n}\n\nexport type CodexHistItem = { sessionId: string; title: string; updatedAt: number; messageCount: number };\n\nexport async function listCodexHistory(cwd: string): Promise {\n const out: CodexHistItem[] = [];\n for await (const path of walk(CODEX_ROOT)) {\n const m = /rollout-.*-([0-9a-f-]{36})\\.jsonl$/i.exec(path);\n const sessionId = m?.[1];\n if (!sessionId) continue;\n const text = await readFile(path, \"utf8\").catch(() => \"\");\n let match = false;\n let title = \"\";\n let count = 0;\n for (const line of text.split(\"\\n\")) {\n if (!line.trim()) continue;\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n continue;\n }\n // session_meta records payload.cwd\n if (r.type === \"session_meta\") {\n const cwdInFile = str(rec(r.payload).cwd);\n if (cwdInFile && samePath(cwdInFile, cwd)) match = true;\n else break; // Not this project: stop reading\n continue;\n }\n if (!match) continue;\n if (r.type === \"response_item\") {\n const p = rec(r.payload);\n if (p.type === \"message\" && p.role === \"user\") {\n const t = textOf(p.content, \"input_text\");\n if (t && !WRAPPERS.test(t)) {\n count++;\n if (!title) title = t.slice(0, 40);\n }\n }\n }\n }\n if (!match) continue;\n const s = await stat(path).catch(() => null);\n out.push({ sessionId, title: title || \"(无标题)\", updatedAt: s ? Math.round(s.mtimeMs) : 0, messageCount: count });\n }\n out.sort((a, b) => b.updatedAt - a.updatedAt);\n return out;\n}\n\n// Read one rollout → simplified events (user_message / assistant_message / notice for tool steps / turn_ended)\ntype CodexEv = {\n type: string; text?: string; message?: string; itemId?: string; level?: string;\n turnId?: string; status?: string;\n};\nexport async function readCodexHistory(sessionId: string, cwd: string): Promise {\n const out: CodexEv[] = [];\n let file: string | null = null;\n for await (const p of walk(CODEX_ROOT)) {\n if (p.endsWith(`${sessionId}.jsonl`)) { file = p; break; }\n }\n if (!file) return out;\n const text = await readFile(file, \"utf8\").catch(() => \"\");\n let matched = false;\n let aid = 0;\n // response_item.message and event_msg.item_completed duplicate each other → dedupe by text\n const seenText = new Set();\n const pushText = (type: \"user_message\" | \"assistant_message\", t: string) => {\n const key = type + \":\" + t;\n if (!t || seenText.has(key)) return;\n seenText.add(key);\n out.push(type === \"user_message\" ? { type, text: t } : { type, itemId: `imp_a${aid++}`, text: t });\n };\n for (const line of text.split(\"\\n\")) {\n if (!line.trim()) continue;\n let r: Rec;\n try {\n r = rec(JSON.parse(line));\n } catch {\n continue;\n }\n if (r.type === \"session_meta\") {\n const c = str(rec(r.payload).cwd);\n if (c && samePath(c, cwd)) matched = true;\n continue;\n }\n if (!matched) continue;\n // response_item message\n if (r.type === \"response_item\") {\n const p = rec(r.payload);\n if (p.type === \"message\") {\n const role = str(p.role);\n const phase = str(p.phase);\n if (role === \"user\") {\n const t = textOf(p.content, \"input_text\");\n if (t && !WRAPPERS.test(t)) pushText(\"user_message\", t);\n } else if (role === \"assistant\" && phase !== \"commentary\") {\n const t = textOf(p.content, \"output_text\");\n if (t) pushText(\"assistant_message\", t);\n }\n } else if (p.type === \"function_call\" || p.type === \"custom_tool_call\") {\n const name = str(p.name) ?? \"tool\";\n out.push({ type: \"notice\", level: \"info\", message: `(历史)调用 ${name}` });\n }\n continue;\n }\n // event_msg.item_completed: AgentMessage (Text content blocks) and task_complete.last_agent_message\n if (r.type === \"event_msg\") {\n const p = rec(r.payload);\n if (p.type === \"item_completed\") {\n const it = rec(p.item);\n if (it.type === \"AgentMessage\" && it.phase !== \"commentary\") {\n const t = textOf(it.content, \"Text\");\n if (t) pushText(\"assistant_message\", t);\n } else if (it.type === \"UserMessage\") {\n const t = textOf(it.content, \"text\");\n if (t && !WRAPPERS.test(t)) pushText(\"user_message\", t);\n }\n } else if (p.type === \"task_complete\") {\n const last = str(p.last_agent_message);\n if (last) pushText(\"assistant_message\", last);\n }\n continue;\n }\n const p2 = rec(r.payload);\n if (p2.type === \"function_call\" || p2.type === \"custom_tool_call\") {\n // Tool steps collapse into a one-line notice (§7.6, Codex part)\n const name = str(p2.name) ?? \"tool\";\n out.push({ type: \"notice\", level: \"info\", message: `(历史)调用 ${name}` });\n }\n }\n out.push({ type: \"turn_ended\", turnId: \"import\", status: \"completed\" });\n return out;\n}\n","// JSON-RPC over stdio client for `codex app-server` (§7.3.2).\n// stdout is parsed line by line: {id,result|error} → response; {id,method} → server request; {method} → notification.\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { log } from \"../../log.js\";\nimport { killTree, trackTree, type Argv } from \"../../util/bin.js\";\n\nexport class CodexRpcError extends Error {\n code?: number;\n constructor(message: string, code?: number) {\n super(message);\n this.code = code;\n }\n}\n\nexport class CodexProcessExitedError extends CodexRpcError {\n constructor() {\n super(\"Codex 进程已退出\");\n }\n}\n\ntype ServerRequestHandler = (method: string, params: Record) => Promise;\ntype NotificationHandler = (method: string, params: Record) => void;\n\nexport class CodexRpc {\n private proc: ChildProcess;\n private nextId = 1;\n private pending = new Map void; reject: (e: Error) => void }>();\n private buf = \"\";\n private exited = false;\n onServerRequest?: ServerRequestHandler;\n onNotification?: NotificationHandler;\n onExit?: (code: number | null) => void;\n\n constructor(\n argv: Argv,\n opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {},\n ) {\n this.proc = spawn(argv.cmd, argv.args, {\n cwd: opts.cwd,\n env: opts.env ?? process.env,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n windowsHide: true,\n ...(argv.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),\n // Non-Windows: its own process group, so after a close() timeout killTree also ends the commands codex started\n ...(process.platform !== \"win32\" ? { detached: true } : {}),\n });\n trackTree(this.proc);\n this.proc.stdout!.setEncoding(\"utf8\");\n this.proc.stdout!.on(\"data\", (chunk: string) => this.onData(chunk));\n this.proc.stderr!.setEncoding(\"utf8\");\n this.proc.stderr!.on(\"data\", (chunk: string) => {\n this.stderrTail = (this.stderrTail + chunk).slice(-4096);\n });\n this.proc.on(\"error\", () => this.failAll());\n this.proc.on(\"close\", (code) => {\n this.exited = true;\n this.failAll();\n this.onExit?.(code);\n });\n }\n\n private stderrTail = \"\";\n\n get stderr(): string {\n return this.stderrTail;\n }\n\n get isExited(): boolean {\n return this.exited;\n }\n\n private onData(chunk: string): void {\n this.buf += chunk;\n let i: number;\n while ((i = this.buf.indexOf(\"\\n\")) >= 0) {\n const line = this.buf.slice(0, i).trim();\n this.buf = this.buf.slice(i + 1);\n if (!line) continue;\n let msg: Record;\n try {\n msg = JSON.parse(line);\n } catch {\n log(\"warn\", \"codex rpc: 非 JSON 行\", { line: line.slice(0, 200) });\n continue;\n }\n void this.dispatch(msg);\n }\n }\n\n private async dispatch(msg: Record): Promise {\n const hasId = msg.id !== undefined && msg.id !== null;\n if (hasId && (\"result\" in msg || \"error\" in msg)) {\n const id = Number(msg.id);\n const p = this.pending.get(id);\n if (p) {\n this.pending.delete(id);\n if (msg.error) {\n const e = msg.error as { code?: number; message?: string };\n p.reject(new CodexRpcError(e.message ?? \"RPC error\", e.code));\n } else {\n p.resolve(msg.result);\n }\n }\n return;\n }\n if (hasId && msg.method) {\n // Server request → needs a response\n const id = Number(msg.id);\n try {\n const result = this.onServerRequest\n ? await this.onServerRequest(String(msg.method), (msg.params as Record) ?? {})\n : undefined;\n this.send({ id, result: result ?? null });\n } catch (e) {\n this.send({ id, error: { code: -32603, message: String((e as Error)?.message ?? e) } });\n }\n return;\n }\n if (!hasId && msg.method) {\n try {\n this.onNotification?.(String(msg.method), (msg.params as Record) ?? {});\n } catch (e) {\n log(\"warn\", \"codex rpc: 通知处理失败\", { method: msg.method, err: String(e) });\n }\n }\n }\n\n private send(obj: Record): void {\n if (this.exited) return;\n try {\n this.proc.stdin!.write(JSON.stringify(obj) + \"\\n\");\n } catch {}\n }\n\n request(method: string, params?: Record, timeoutMs = 60000): Promise {\n if (this.exited) return Promise.reject(new CodexProcessExitedError());\n const id = this.nextId++;\n return new Promise((resolvePromise, reject) => {\n const timer = setTimeout(() => {\n this.pending.delete(id);\n reject(new CodexRpcError(`请求超时:${method}`));\n }, timeoutMs);\n this.pending.set(id, {\n resolve: (v) => {\n clearTimeout(timer);\n resolvePromise(v as T);\n },\n reject: (e) => {\n clearTimeout(timer);\n reject(e);\n },\n });\n this.send({ jsonrpc: \"2.0\", id, method, params: params ?? {} });\n });\n }\n\n notify(method: string, params?: Record): void {\n this.send({ jsonrpc: \"2.0\", method, params: params ?? {} });\n }\n\n private failAll(): void {\n const err = new CodexProcessExitedError();\n for (const [, p] of this.pending) p.reject(err);\n this.pending.clear();\n }\n\n async close(): Promise {\n try {\n this.proc.stdin?.end();\n } catch {}\n await new Promise((resolvePromise) => {\n const t = setTimeout(() => {\n void killTree(this.proc, { graceMs: 1500 });\n resolvePromise();\n }, 3000);\n this.proc.on(\"close\", () => {\n clearTimeout(t);\n resolvePromise();\n });\n if (this.exited) {\n clearTimeout(t);\n resolvePromise();\n }\n });\n }\n}\n","// One user message → Codex app-server UserInput items.\n// Only PNG / JPEG / GIF / WebP go as localImage; every other attachment (text files included) is named by its\n// absolute path in the text, which Codex opens with its own tools. Files that aren't images or text get a warning.\nimport { basename } from \"node:path\";\nimport { resolveImagePath } from \"../../util/attachmentRefs.js\";\nimport { attachmentKind, baseMime, pathNote } from \"../../util/attachmentKinds.js\";\n\nexport async function buildCodexInput(\n text: string,\n attachments: { path: string; mime: string }[],\n): Promise<{ items: Record[]; warnings: string[] }> {\n const images: Record[] = [];\n const notes: string[] = [];\n const warnings: string[] = [];\n for (const a of attachments) {\n let file: string;\n try {\n file = await resolveImagePath(a.path);\n } catch (e) {\n warnings.push(`${a.path}: ${String((e as Error)?.message ?? e)}`);\n continue;\n }\n const kind = attachmentKind(a.mime);\n if (kind === \"image\") {\n images.push({ type: \"localImage\", path: file });\n continue;\n }\n notes.push(pathNote(kind, file));\n if (kind === \"other\") warnings.push(`${basename(file)} (${baseMime(a.mime) || \"unknown type\"}) is not an image or a text file; sent its path`);\n }\n const fullText = [text, ...(notes.length ? [notes.join(\"\\n\")] : [])].filter(Boolean).join(\"\\n\\n\");\n return { items: [{ type: \"text\", text: fullText }, ...images], warnings };\n}\n","// Codex app-server notifications → ProviderEvent (§7.3.4). Method and field names follow the generated schema.\nimport type { ProviderEvent } from \"../../timeline/types.js\";\n\nconst MAX_OUTPUT = 20 * 1024;\nconst trunc = (s: string, n = 80): string => (s.length > n ? s.slice(0, n - 1) + \"…\" : s);\n\ntype Rec = Record;\nconst rec = (v: unknown): Rec => (v && typeof v === \"object\" ? (v as Rec) : {});\nconst str = (v: unknown): string | undefined => (typeof v === \"string\" ? v : undefined);\n\nexport type CodexMapState = {\n currentTurnId: string | null;\n filesChanged: number;\n diffText: string;\n};\n\nexport function newCodexMapState(): CodexMapState {\n return { currentTurnId: null, filesChanged: 0, diffText: \"\" };\n}\n\n// One server notification → 0..n unified events. Server requests (approvals) don't go through here.\nexport function mapNotification(method: string, params: Rec, st: CodexMapState): ProviderEvent[] {\n const out: ProviderEvent[] = [];\n const turnId = str(params.turnId);\n\n switch (method) {\n case \"turn/started\": {\n const turn = rec(params.turn);\n const tid = str(turn.id);\n if (tid) st.currentTurnId = tid;\n st.filesChanged = 0;\n st.diffText = \"\";\n break;\n }\n case \"item/agentMessage/delta\": {\n const itemId = str(params.itemId) ?? \"\";\n const delta = str(params.delta) ?? \"\";\n if (itemId && delta) out.push({ type: \"assistant_delta\", itemId, text: delta });\n break;\n }\n case \"item/plan/delta\": {\n // Incremental plan stream: wait for the plan item in item/completed to send the full plan\n break;\n }\n case \"item/reasoning/summaryTextDelta\":\n case \"item/reasoning/textDelta\": {\n const itemId = str(params.itemId) ?? \"\";\n const delta = str(params.delta) ?? \"\";\n if (itemId && delta) out.push({ type: \"reasoning_delta\", itemId, text: delta });\n break;\n }\n case \"item/commandExecution/outputDelta\":\n case \"item/fileChange/outputDelta\":\n case \"command/exec/outputDelta\": {\n const itemId = str(params.itemId) ?? \"\";\n const delta = str(params.delta) ?? \"\";\n if (itemId && delta) out.push({ type: \"tool_output_delta\", itemId, text: delta });\n break;\n }\n case \"item/started\": {\n const item = rec(params.item);\n const type = str(item.type) ?? \"\";\n const itemId = str(item.id) ?? \"\";\n if (type === \"commandExecution\") {\n const cmd = str(item.command) ?? \"\";\n out.push({\n type: \"tool_call\", itemId, kind: \"bash\",\n title: `运行 ${trunc(cmd)}`, status: \"running\",\n detail: { command: cmd, cwd: str(item.cwd) },\n });\n } else if (type === \"fileChange\") {\n const changes = Array.isArray(item.changes) ? item.changes : [];\n out.push({\n type: \"tool_call\", itemId, kind: \"edit\",\n title: `修改 ${changes.length} 个文件`, status: \"running\",\n detail: { changes },\n });\n } else if (type === \"mcpToolCall\" || type === \"dynamicToolCall\") {\n const tool = str(item.tool) ?? str(item.name) ?? \"\";\n const server = str(item.server) ?? \"\";\n out.push({\n type: \"tool_call\", itemId, kind: \"mcp\",\n title: `${server || \"mcp\"} · ${tool}`, status: \"running\",\n });\n } else if (type === \"webSearch\") {\n out.push({\n type: \"tool_call\", itemId, kind: \"web\",\n title: `搜索网页 \"${trunc(str(item.query) ?? \"\", 40)}\"`, status: \"running\",\n });\n }\n // agentMessage/reasoning/userMessage/plan etc.: wait for a delta or completed\n break;\n }\n case \"item/completed\": {\n const item = rec(params.item);\n const type = str(item.type) ?? \"\";\n const itemId = str(item.id) ?? \"\";\n if (type === \"agentMessage\") {\n const text = str(item.text) ?? \"\";\n if (text) out.push({ type: \"assistant_message\", itemId, text });\n } else if (type === \"reasoning\") {\n const summary = Array.isArray(item.summary) ? item.summary : [];\n const text = summary.filter((s): s is string => typeof s === \"string\").join(\"\\n\");\n if (text) out.push({ type: \"reasoning\", itemId, text });\n } else if (type === \"commandExecution\") {\n const cmd = str(item.command) ?? \"\";\n const exitCode = typeof item.exitCode === \"number\" ? item.exitCode : undefined;\n const output = str(item.aggregatedOutput) ?? \"\";\n out.push({\n type: \"tool_call\", itemId, kind: \"bash\",\n title: `运行 ${trunc(cmd)}`,\n status: exitCode === 0 ? \"ok\" : \"failed\",\n output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output,\n exitCode,\n durationMs: typeof item.durationMs === \"number\" ? item.durationMs : undefined,\n });\n } else if (type === \"fileChange\") {\n const changes = Array.isArray(item.changes) ? item.changes : [];\n const istatus = str(item.status);\n st.filesChanged += changes.length;\n out.push({\n type: \"tool_call\", itemId, kind: \"edit\",\n title: `修改 ${changes.length} 个文件`,\n status: istatus === \"failed\" ? \"failed\" : \"ok\",\n detail: { changes },\n });\n } else if (type === \"mcpToolCall\" || type === \"dynamicToolCall\") {\n const tool = str(item.tool) ?? str(item.name) ?? \"\";\n const server = str(item.server) ?? \"\";\n const istatus = str(item.status);\n const err = str(item.error);\n out.push({\n type: \"tool_call\", itemId, kind: \"mcp\",\n title: `${server || \"mcp\"} · ${tool}`,\n status: istatus === \"failed\" || err ? \"failed\" : \"ok\",\n output: err ?? undefined,\n });\n } else if (type === \"webSearch\") {\n out.push({\n type: \"tool_call\", itemId, kind: \"web\",\n title: `搜索网页 \"${trunc(str(item.query) ?? \"\", 40)}\"`, status: \"ok\",\n });\n } else if (type === \"plan\") {\n const text = str(item.text) ?? \"\";\n if (text) out.push({ type: \"plan\", itemId, markdown: text });\n }\n break;\n }\n case \"turn/plan/updated\": {\n const steps = Array.isArray(params.plan) ? params.plan : [];\n const items = steps.map((s) => {\n const step = rec(s);\n const status = str(step.status) ?? \"pending\";\n return {\n text: str(step.step) ?? \"\",\n status: status === \"completed\" ? \"done\" : status === \"inProgress\" || status === \"in_progress\" ? \"in_progress\" : \"pending\",\n };\n });\n if (items.length) out.push({ type: \"todo\", items });\n break;\n }\n case \"turn/diff/updated\": {\n const diff = str(params.diff) ?? \"\";\n if (diff) st.diffText = diff;\n break;\n }\n case \"thread/tokenUsage/updated\": {\n const usage = rec(params.tokenUsage);\n const last = rec(usage.last) || usage;\n const input = typeof last.inputTokens === \"number\" ? last.inputTokens : 0;\n const output = typeof last.outputTokens === \"number\" ? last.outputTokens : 0;\n const cached = typeof last.cachedInputTokens === \"number\" ? last.cachedInputTokens : 0;\n out.push({\n type: \"usage\",\n inputTokens: input,\n outputTokens: output,\n cacheReadTokens: cached,\n costUsd: 0,\n });\n break;\n }\n case \"turn/completed\": {\n const turn = rec(params.turn);\n const status = str(turn.status) ?? \"completed\";\n const error = rec(turn.error);\n out.push({\n type: \"turn_ended\",\n turnId: str(turn.id) ?? turnId ?? st.currentTurnId ?? \"\",\n status: status === \"completed\" ? \"completed\" : status === \"interrupted\" ? \"interrupted\" : \"failed\",\n durationMs: typeof turn.durationMs === \"number\" ? turn.durationMs : undefined,\n error: str(error.message),\n filesChanged: st.filesChanged || undefined,\n });\n break;\n }\n case \"error\": {\n const msg = str(params.message) ?? \"Codex 错误\";\n out.push({ type: \"notice\", level: \"error\", message: msg });\n break;\n }\n default:\n break;\n }\n return out;\n}\n","import { readdir, readFile, stat } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport type { SlashCommand } from \"../slash.js\";\nimport { parseSlashInput, sortSlashCommands } from \"../slash.js\";\n\nexport type CodexSlashEntry = SlashCommand & { body: string };\n\nfunction parseFrontMatter(raw: string): { meta: Record; body: string } {\n if (!raw.startsWith(\"---\\n\")) return { meta: {}, body: raw };\n const end = raw.indexOf(\"\\n---\\n\", 4);\n if (end === -1) return { meta: {}, body: raw };\n const fm = raw.slice(4, end);\n const meta: Record = {};\n for (const line of fm.split(/\\r?\\n/)) {\n const m = line.match(/^([a-zA-Z0-9_-]+):\\s*(.+)$/);\n if (m) meta[m[1]!] = m[2]!.trim().replace(/^[\"']|[\"']$/g, \"\");\n }\n return { meta, body: raw.slice(end + 5) };\n}\n\nfunction firstLine(body: string): string {\n for (const line of body.split(/\\r?\\n/)) {\n const t = line.trim();\n if (t && !t.startsWith(\"#\")) return t.replace(/^#+\\s*/, \"\").slice(0, 120);\n }\n return \"\";\n}\n\nasync function readPromptFiles(dir: string): Promise {\n const out: CodexSlashEntry[] = [];\n let names: string[] = [];\n try {\n names = await readdir(dir);\n } catch {\n return out;\n }\n for (const f of names) {\n if (!f.endsWith(\".md\")) continue;\n const path = join(dir, f);\n try {\n const st = await stat(path);\n if (!st.isFile()) continue;\n const raw = await readFile(path, \"utf8\");\n const { meta, body } = parseFrontMatter(raw);\n const name = basename(f, \".md\");\n out.push({\n name,\n description: meta.description || firstLine(body) || name,\n argumentHint: meta.argumentHint || meta[\"argument-hint\"],\n kind: \"command\",\n body,\n });\n } catch {\n /* skip */\n }\n }\n return out;\n}\n\nasync function readSkillDirs(root: string): Promise {\n const out: CodexSlashEntry[] = [];\n let dirs: string[] = [];\n try {\n dirs = await readdir(root);\n } catch {\n return out;\n }\n for (const d of dirs) {\n const skillPath = join(root, d, \"SKILL.md\");\n try {\n const st = await stat(skillPath);\n if (!st.isFile()) continue;\n const raw = await readFile(skillPath, \"utf8\");\n const { meta, body } = parseFrontMatter(raw);\n const name = meta.name || d;\n out.push({\n name,\n description: meta.description || firstLine(body) || name,\n argumentHint: meta.argumentHint,\n kind: \"skill\",\n body,\n });\n } catch {\n /* skip */\n }\n }\n return out;\n}\n\nexport async function loadCodexSlashEntries(cwd: string, opts?: { skipHome?: boolean }): Promise {\n const home = homedir();\n const parts = await Promise.all([\n opts?.skipHome ? Promise.resolve([]) : readPromptFiles(join(home, \".codex\", \"prompts\")),\n readPromptFiles(join(cwd, \".codex\", \"prompts\")),\n opts?.skipHome ? Promise.resolve([]) : readSkillDirs(join(home, \".codex\", \"skills\")),\n readSkillDirs(join(cwd, \".codex\", \"skills\")),\n ]);\n const byName = new Map();\n for (const list of parts) {\n for (const e of list) {\n if (!byName.has(e.name)) byName.set(e.name, e);\n }\n }\n return sortSlashCommands([...byName.values()]) as CodexSlashEntry[];\n}\n\nexport function expandCodexSlash(text: string, entries: CodexSlashEntry[]): string {\n const parsed = parseSlashInput(text);\n if (!parsed) return text;\n const hit = entries.find((e) => e.name === parsed.name);\n if (!hit) return text;\n return hit.body.replace(/\\$ARGUMENTS/g, parsed.args);\n}\n\nexport function codexSlashCommandsFromEntries(entries: CodexSlashEntry[]): SlashCommand[] {\n return entries.map(({ body: _b, ...rest }) => rest);\n}\n","// Codex ProviderSession (§7.3.3/7.3.5): codex app-server JSON-RPC session.\nimport type { ProviderSession, SendInput, RespondAnswer, Mode, SlashCommand } from \"../types.js\";\nimport { buildCodexInput } from \"./input.js\";\nimport { tr } from \"../../util/i18n.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { AsyncQueue } from \"../../util/queue.js\";\nimport { CodexRpc } from \"./rpc.js\";\nimport { argvFor } from \"../../util/bin.js\";\nimport { mapNotification, newCodexMapState, type CodexMapState } from \"./map.js\";\nimport {\n codexSlashCommandsFromEntries,\n expandCodexSlash,\n loadCodexSlashEntries,\n type CodexSlashEntry,\n} from \"./commands.js\";\nimport { log } from \"../../log.js\";\n\nconst rid = () => Math.random().toString(36).slice(2, 10);\n\n// §7.3.3 permission presets (per the four modes). Note the two shapes differ:\n// `sandbox` in thread/start|resume is a kebab-case enum (\"read-only\"…);\n// `sandboxPolicy` in turn/start is an object ({type:\"readOnly\"}…).\nconst MODE_PRESETS: Record }> = {\n plan: { approvalPolicy: \"on-request\", sandbox: \"read-only\", sandboxPolicy: { type: \"readOnly\" } },\n default: { approvalPolicy: \"on-request\", sandbox: \"read-only\", sandboxPolicy: { type: \"readOnly\" } },\n acceptEdits: { approvalPolicy: \"on-request\", sandbox: \"workspace-write\", sandboxPolicy: { type: \"workspaceWrite\" } },\n bypassPermissions: { approvalPolicy: \"never\", sandbox: \"danger-full-access\", sandboxPolicy: { type: \"dangerFullAccess\" } },\n ask: { approvalPolicy: \"on-request\", sandbox: \"read-only\", sandboxPolicy: { type: \"readOnly\" } },\n};\n\ntype PendingReq = {\n kind: \"permission\" | \"question\";\n resolve: (a: RespondAnswer) => void;\n};\n\nexport class CodexSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private rpc: CodexRpc | null = null;\n private st: CodexMapState = newCodexMapState();\n private sessionThreadId: string | null = null;\n private pending = new Map();\n private closed = false;\n private started: Promise | null = null;\n private interruptWanted = false;\n private ourTurnId: string | null = null; // The current turn's turnId in the timeline (generated on send)\n private staleCodexTurns = new Set();\n private codexSlash: CodexSlashEntry[] | null = null;\n\n constructor(\n private o: {\n threadId: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n binCmd: string;\n binPrefixArgs: string[];\n binVia?: string;\n binShim?: string;\n version: string;\n },\n ) {\n this.events = this.q;\n }\n\n private emit(ev: ProviderEvent): void {\n if (ev.type === \"turn_ended\") {\n // A late completion for the previous turn (already ended by the interrupt fallback): drop it so it doesn't end the new turn\n if (ev.turnId && this.staleCodexTurns.delete(ev.turnId)) return;\n // turn_started uses our own turnId, so the end must use the same one,\n // otherwise the manager's per-turn dedupe breaks (interrupt fallback + late completion would end the next turn)\n if (this.ourTurnId) ev.turnId = this.ourTurnId;\n this.ourTurnId = null;\n this.st.currentTurnId = null;\n this.interruptWanted = false;\n }\n this.q.push(ev);\n }\n\n private async start(): Promise {\n if (this.started) return this.started;\n const attempt = (async () => {\n const rpc = new CodexRpc(\n argvFor(\n { ok: true, cmd: this.o.binCmd, prefixArgs: this.o.binPrefixArgs, display: this.o.binCmd, via: this.o.binVia ?? \"path\", shim: this.o.binShim },\n [\"app-server\"],\n ),\n { cwd: this.o.cwd },\n );\n this.rpc = rpc;\n rpc.onNotification = (m, p) => {\n for (const ev of mapNotification(m, p, this.st)) this.emit(ev);\n if (this.interruptWanted && this.st.currentTurnId) void this.interrupt();\n };\n rpc.onServerRequest = (m, p) => this.onServerRequest(m, p);\n rpc.onExit = () => {\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n if (!this.closed) this.q.close();\n };\n await rpc.request(\"initialize\", {\n clientInfo: { name: \"agent-console\", title: \"Agent Console\", version: this.o.version },\n capabilities: { experimentalApi: true },\n });\n rpc.notify(\"initialized\", {});\n const preset = MODE_PRESETS[this.o.mode];\n let threadId: string;\n if (this.o.resumeId) {\n const res = (await rpc.request(\"thread/resume\", {\n threadId: this.o.resumeId,\n cwd: this.o.cwd,\n model: this.o.model,\n approvalPolicy: preset.approvalPolicy,\n sandbox: preset.sandbox,\n })) as { thread?: { id?: string } };\n threadId = res.thread?.id ?? this.o.resumeId;\n } else {\n const res = (await rpc.request(\"thread/start\", {\n cwd: this.o.cwd,\n model: this.o.model,\n approvalPolicy: preset.approvalPolicy,\n sandbox: preset.sandbox,\n })) as { thread?: { id?: string } };\n threadId = res.thread?.id ?? \"\";\n }\n if (threadId) {\n this.sessionThreadId = threadId;\n this.emit({ type: \"provider_session_id\", id: threadId });\n }\n })();\n // Don't cache a failed start: clear it so the next send starts app-server again\n this.started = attempt.catch((e) => {\n if (this.started === guarded) {\n this.started = null;\n const rpc = this.rpc;\n this.rpc = null;\n if (rpc) {\n rpc.onExit = () => {};\n void Promise.resolve(rpc.close()).catch(() => {});\n }\n }\n throw e;\n });\n const guarded = this.started;\n return this.started;\n }\n\n async send(input: SendInput): Promise {\n if (this.closed) throw new Error(\"会话已关闭\");\n await this.start();\n const rpc = this.rpc!;\n const preset = MODE_PRESETS[this.o.mode];\n const entries = this.codexSlash ?? (await loadCodexSlashEntries(this.o.cwd));\n this.codexSlash = entries;\n const wireText = expandCodexSlash(input.text, entries);\n const { items, warnings } = await buildCodexInput(wireText, input.images ?? []);\n this.interruptWanted = false;\n const turnId = \"u_\" + Date.now().toString(36) + \"_\" + rid();\n // A new turn started before the previous one completed (interrupt fallback) → remember its Codex turnId\n if (this.ourTurnId && this.st.currentTurnId) this.staleCodexTurns.add(this.st.currentTurnId);\n this.ourTurnId = turnId;\n this.st.currentTurnId = null; // Filled in by this turn's turn/started, so we never interrupt/steer with the previous turn's id\n this.emit({\n type: \"turn_started\",\n turnId,\n model: this.o.model,\n mode: this.o.mode,\n effort: this.o.effort,\n });\n for (const w of warnings) this.emit({ type: \"notice\", level: \"warn\", message: tr(\"notice.attachmentIssue\", { detail: w }) });\n try {\n await rpc.request(\"turn/start\", {\n threadId: this.sessionThreadId ?? undefined,\n input: items,\n model: this.o.model,\n effort: this.o.effort,\n approvalPolicy: preset.approvalPolicy,\n sandboxPolicy: preset.sandboxPolicy,\n });\n } catch (e) {\n this.emit({\n type: \"turn_ended\",\n turnId,\n status: \"failed\",\n error: String((e as Error)?.message ?? e),\n });\n }\n }\n\n async steer(input: SendInput): Promise {\n // No Codex turnId yet (turn/started hasn't arrived) → throw; the manager queues instead so the message isn't lost\n if (!this.rpc || !this.st.currentTurnId) throw new Error(\"当前轮次还没开始,无法插话\");\n const { items, warnings } = await buildCodexInput(input.text, input.images ?? []);\n for (const w of warnings) this.emit({ type: \"notice\", level: \"warn\", message: tr(\"notice.attachmentIssue\", { detail: w }) });\n try {\n await this.rpc.request(\"turn/steer\", {\n threadId: this.threadId(),\n expectedTurnId: this.st.currentTurnId,\n input: items,\n });\n } catch (e) {\n log(\"warn\", \"codex steer 失败\", { err: String(e) });\n }\n }\n\n async interrupt(): Promise {\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n if (!this.rpc) return;\n if (!this.st.currentTurnId) {\n // turn/started hasn't arrived yet: remember it and send the interrupt as soon as the turnId is known\n this.interruptWanted = true;\n return;\n }\n this.interruptWanted = false;\n try {\n await this.rpc.request(\n \"turn/interrupt\",\n { threadId: this.threadId(), turnId: this.st.currentTurnId },\n 5000,\n );\n } catch (e) {\n // \"Already idle\"-type errors count as success\n const msg = String((e as Error)?.message ?? e);\n if (!/idle|not running|no.*turn/i.test(msg)) {\n log(\"warn\", \"codex interrupt 失败\", { err: msg });\n }\n }\n }\n\n private threadId(): string | undefined {\n return this.sessionThreadId ?? undefined;\n }\n\n // ---- server requests (§7.3.5 approvals) ----\n private async onServerRequest(method: string, params: Record): Promise {\n switch (method) {\n case \"item/commandExecution/requestApproval\": {\n const command = String(params.command ?? \"\");\n const requestId = \"perm_\" + rid();\n this.emit({\n type: \"permission_request\",\n requestId,\n title: `运行 ${command.slice(0, 80)}`,\n kind: \"tool\",\n detail: { command, cwd: params.cwd, reason: params.reason, approvalId: params.approvalId, itemId: params.itemId },\n options: [\n { id: \"accept\", label: \"允许\", kind: \"allow_once\" },\n { id: \"acceptForSession\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"decline\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const a = await this.waitFor(requestId);\n if (a.cancelled) return { decision: \"cancel\" };\n const opt = a.optionId === \"acceptForSession\" ? \"acceptForSession\" : a.optionId === \"decline\" ? \"decline\" : \"accept\";\n return { decision: opt };\n }\n case \"item/fileChange/requestApproval\": {\n const changes = Array.isArray(params.changes) ? params.changes : [];\n const requestId = \"perm_\" + rid();\n this.emit({\n type: \"permission_request\",\n requestId,\n title: \"修改文件\",\n kind: \"tool\",\n detail: { changes, approvalId: params.approvalId, itemId: params.itemId, reason: params.reason },\n options: [\n { id: \"accept\", label: \"允许\", kind: \"allow_once\" },\n { id: \"acceptForSession\", label: \"本对话都允许\", kind: \"allow_always\" },\n { id: \"decline\", label: \"拒绝\", kind: \"reject_once\" },\n ],\n });\n const a = await this.waitFor(requestId);\n if (a.cancelled) return { decision: \"cancel\" };\n const opt = a.optionId === \"acceptForSession\" ? \"acceptForSession\" : a.optionId === \"decline\" ? \"decline\" : \"accept\";\n return { decision: opt };\n }\n case \"item/tool/requestUserInput\": {\n const requestId = \"q_\" + rid();\n this.emit({\n type: \"question\",\n requestId,\n questions: (params.questions as unknown[]) ?? [],\n });\n const a = await this.waitFor(requestId);\n if (a.cancelled) return { answers: {} };\n // schema: { answers: { : { answers: [..] } } }\n const raw = a.answers ?? {};\n const shaped: Record = {};\n for (const [k, v] of Object.entries(raw)) shaped[k] = { answers: [v] };\n return { answers: shaped };\n }\n default:\n throw new Error(`未识别的服务端请求 ${method}`);\n }\n }\n\n private waitFor(requestId: string): Promise {\n return new Promise((resolve) => {\n this.pending.set(requestId, { kind: \"permission\", resolve });\n });\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const p = this.pending.get(requestId);\n if (p) {\n this.pending.delete(requestId);\n p.resolve(answer);\n // Writing back resolved events is the manager's job (permission_resolved/question_answered)\n }\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model; // Takes effect on the next turn/start\n }\n async setMode(mode: Mode): Promise {\n this.o.mode = mode; // Takes effect on the next turn/start\n }\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort; // Takes effect on the next turn/start\n }\n\n async listCommands(): Promise {\n try {\n const entries = this.codexSlash ?? (await loadCodexSlashEntries(this.o.cwd));\n this.codexSlash = entries;\n return codexSlashCommandsFromEntries(entries);\n } catch {\n return [];\n }\n }\n\n async close(): Promise {\n this.closed = true;\n for (const [, p] of this.pending) p.resolve({ cancelled: true });\n this.pending.clear();\n if (this.rpc) await this.rpc.close();\n this.q.close();\n }\n}\n","// Codex Provider (§7.3): detect + listModels (temporary app-server → model/list, cached 10 minutes) + createSession.\nimport type { Provider, ProviderSession, DetectResult, ModelInfo, Capabilities, HistoryItem } from \"../types.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { listCodexHistory, readCodexHistory } from \"../../history/codex.js\";\nimport { argvFor, resolveBin, killTree, spawnBin, type ResolveResult } from \"../../util/bin.js\";\nimport { CodexRpc, CodexProcessExitedError } from \"./rpc.js\";\nimport { CodexSession } from \"./session.js\";\nimport { log } from \"../../log.js\";\n\nconst INSTALL_HINT = \"npm i -g @openai/codex\";\n\nexport const codexCapabilities: Capabilities = {\n resume: true,\n interrupt: true,\n steer: true,\n images: true,\n plan: true,\n questions: true,\n cost: false, // tokenUsage yes, cost no\n effort: [], // effort values depend on the model — filled from model/list after detect\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"],\n};\n\nlet modelCache: { at: number; models: ModelInfo[] } | null = null;\nconst MODEL_CACHE_MS = 10 * 60 * 1000;\n\nfunction runVersion(r: ResolveResult & { ok: true }, args: string[], timeoutMs = 5000): Promise {\n return new Promise((resolvePromise, reject) => {\n let out = \"\";\n const p = spawnBin(r, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n const t = setTimeout(() => {\n try {\n void killTree(p, { graceMs: 500 }); // On Windows a .cmd wrapper must be killed together with its child processes\n } catch {}\n reject(new Error(\"timeout\"));\n }, timeoutMs);\n p.stdout?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.stderr?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.on(\"error\", reject);\n p.on(\"close\", (code) => {\n clearTimeout(t);\n if (code === 0) resolvePromise(out);\n else reject(new Error(\"exit \" + code));\n });\n });\n}\n\n/** Normalize the level list to strings: accepts string[] and { reasoningEffort, description }[]; other shapes are dropped */\nexport function normalizeEfforts(raw: unknown[] | undefined): string[] {\n const out: string[] = [];\n for (const e of raw ?? []) {\n const v = typeof e === \"string\" ? e : (e as { reasoningEffort?: unknown } | null)?.reasoningEffort;\n if (typeof v === \"string\" && v && !out.includes(v)) out.push(v);\n }\n return out;\n}\n\nasync function fetchModels(r: ResolveResult & { ok: true }, cwd: string): Promise {\n const rpc = new CodexRpc(argvFor(r, [\"app-server\"]), { cwd });\n try {\n await rpc.request(\"initialize\", {\n clientInfo: { name: \"agent-console\", title: \"Agent Console\", version: \"0\" },\n capabilities: { experimentalApi: true },\n }, 15000);\n rpc.notify(\"initialized\", {});\n const res = (await rpc.request(\"model/list\", {}, 15000)) as {\n data?: {\n id?: string;\n displayName?: string;\n isDefault?: boolean;\n // Newer app-server returns { reasoningEffort, description }[], older ones string[]\n supportedReasoningEfforts?: unknown[];\n defaultReasoningEffort?: string;\n }[];\n };\n const rows = res.data ?? [];\n return rows\n .filter((m) => typeof m.id === \"string\" && m.id)\n .map((m) => {\n const efforts = normalizeEfforts(m.supportedReasoningEfforts);\n const thinkingOptions = efforts.map((e) => ({ id: e, label: e }));\n const defaultThinking = (typeof m.defaultReasoningEffort === \"string\" ? m.defaultReasoningEffort : null) ?? thinkingOptions[0]?.id ?? null;\n return {\n id: m.id!,\n name: m.displayName ?? m.id!,\n isDefault: m.isDefault,\n efforts,\n thinkingOptions,\n defaultThinking,\n };\n });\n } finally {\n await rpc.close();\n }\n}\n\nexport function createCodexProvider(version: string): Provider {\n return {\n id: \"codex\",\n name: \"Codex\",\n capabilities: codexCapabilities,\n async detect(): Promise {\n const bin = await resolveBin(\"codex\");\n if (!bin.ok) {\n return { available: false, reason: bin.reason, installHint: INSTALL_HINT };\n }\n try {\n const v = await runVersion(bin, [\"--version\"]);\n return { available: true, bin: bin.display, version: v.trim().split(\"\\n\")[0] || \"\" };\n } catch (e) {\n return { available: true, bin: bin.display, version: \"\", reason: String(e) };\n }\n },\n async listModels(): Promise {\n if (modelCache && Date.now() - modelCache.at < MODEL_CACHE_MS) return modelCache.models;\n const bin = await resolveBin(\"codex\");\n if (!bin.ok) return [{ id: \"\", name: \"默认\" }];\n try {\n const models = await fetchModels(bin, process.cwd());\n modelCache = { at: Date.now(), models };\n return models;\n } catch (e) {\n // Gateway region errors etc. — the frontend shows a single \"Default\" entry (§7.3.6)\n log(\"warn\", \"codex model/list 失败\", { err: String(e) });\n return [{ id: \"\", name: \"默认\" }];\n }\n },\n async createSession(o): Promise {\n const bin = await resolveBin(\"codex\");\n if (!bin.ok) throw new Error(`找不到 codex:${bin.reason}`);\n return new CodexSession({\n threadId: o.threadId,\n cwd: o.cwd,\n model: o.model,\n mode: o.mode,\n effort: o.effort,\n resumeId: o.resumeId,\n binCmd: bin.cmd,\n binPrefixArgs: bin.prefixArgs,\n binVia: bin.via,\n binShim: bin.shim,\n version,\n });\n },\n // §7.6: ~/.codex/sessions/**/rollout-*.jsonl matched by cwd\n async listHistory(cwd: string): Promise {\n const items = await listCodexHistory(cwd);\n return items.map((i) => ({ provider: \"codex\", ...i, imported: false }));\n },\n async readHistory(sessionId: string, cwd: string): Promise {\n return (await readCodexHistory(sessionId, cwd)) as unknown as ProviderEvent[];\n },\n };\n}\n","/** Provider catalog (fixed, 8 entries).\n * status: verified=accepted on a real machine | experimental=usable but not guaranteed | planned=integration not confirmed\n * All 8 are currently verified; default toggle: verified ones are on by default (see enabled.ts). */\nexport type ProviderStatus = \"verified\" | \"experimental\" | \"planned\";\n\nexport type ProviderCatalogEntry = {\n id: string;\n name: string;\n bins: string[];\n installHint: string;\n status: ProviderStatus;\n};\n\nexport const PROVIDER_CATALOG: ProviderCatalogEntry[] = [\n { id: \"claude\", name: \"Claude Code\", bins: [\"claude\"], installHint: \"npm i -g @anthropic-ai/claude-code\", status: \"verified\" },\n { id: \"codex\", name: \"Codex\", bins: [\"codex\"], installHint: \"见 OpenAI Codex CLI 安装说明\", status: \"verified\" },\n { id: \"cursor\", name: \"Cursor\", bins: [\"cursor-agent\", \"cursor\"], installHint: \"安装 Cursor 并确保 CLI 在 PATH\", status: \"verified\" },\n { id: \"copilot\", name: \"Copilot\", bins: [\"copilot\"], installHint: \"GitHub Copilot CLI 安装说明\", status: \"verified\" },\n { id: \"opencode\", name: \"OpenCode\", bins: [\"opencode\"], installHint: \"npm i -g opencode / 见 OpenCode 文档\", status: \"verified\" },\n { id: \"pi\", name: \"Pi\", bins: [\"pi-acp\", \"pi\", \"pi-coding-agent\"], installHint: \"npm i -g pi-acp(或 Pi coding agent + ACP 适配器)\", status: \"verified\" },\n { id: \"devin\", name: \"Devin CLI\", bins: [\"devin\"], installHint: \"Devin CLI 官方安装\", status: \"verified\" },\n { id: \"dsh\", name: \"dsh\", bins: [\"dsh\"], installHint: \"npm i -g @deepseek-ai/dsh\", status: \"verified\" },\n];\n\nexport function catalogById(id: string): ProviderCatalogEntry | undefined {\n return PROVIDER_CATALOG.find((p) => p.id === id);\n}\n\nexport function providerCreatable(status: ProviderStatus): boolean {\n return status !== \"planned\";\n}\n","import type { ChildProcess } from \"node:child_process\";\nimport { Readable, Writable } from \"node:stream\";\nimport { resolve as resolvePath } from \"node:path\";\nimport * as acp from \"@agentclientprotocol/sdk\";\nimport type { ResolveResult } from \"../../util/bin.js\";\nimport { killTree, spawnBin } from \"../../util/bin.js\";\n\nexport function spawnResolved(r: ResolveResult & { ok: true }, args: string[], o: { cwd?: string } = {}): ChildProcess {\n // group: on non-Windows it gets its own process group so killTree also ends the tool processes it started\n return spawnBin(r, args, {\n cwd: o.cwd,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n env: { ...process.env, NO_COLOR: \"1\", FORCE_COLOR: \"0\" },\n group: true,\n });\n}\n\nexport function acpStreamFromChild(child: ChildProcess): acp.Stream {\n if (!child.stdin || !child.stdout) throw new Error(\"ACP 子进程缺少 stdio 管道\");\n const output = Writable.toWeb(child.stdin) as WritableStream;\n const input = Readable.toWeb(child.stdout) as ReadableStream;\n return acp.ndJsonStream(output, input);\n}\n\nexport function absCwd(cwd: string): string {\n return resolvePath(cwd);\n}\n\nexport async function killAcpChild(child: ChildProcess | null): Promise {\n await killTree(child);\n}\n","import { readFile, writeFile } from \"node:fs/promises\";\nimport type { ClientApp } from \"@agentclientprotocol/sdk\";\nimport { methods } from \"@agentclientprotocol/sdk\";\nimport type { RespondAnswer } from \"../types.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\n\nexport type PermBridge = {\n push(ev: ProviderEvent): void;\n waitFor(requestId: string): Promise;\n /** Cursor Auto-review: no permission card, allow is chosen automatically */\n autoAcceptPermissions?: () => boolean;\n};\n\nexport function registerAcpClientHandlers(app: ClientApp, bridge: PermBridge): void {\n app.onRequest(methods.client.fs.readTextFile, async (ctx) => {\n const text = await readFile(ctx.params.path, \"utf8\");\n return { content: text };\n });\n app.onRequest(methods.client.fs.writeTextFile, async (ctx) => {\n await writeFile(ctx.params.path, ctx.params.content, \"utf8\");\n return {};\n });\n app.onRequest(methods.client.session.requestPermission, async (ctx) => {\n const tc = ctx.params.toolCall;\n const options = ctx.params.options.map((o) => ({\n id: o.optionId,\n label: o.name,\n kind: o.kind,\n }));\n if (bridge.autoAcceptPermissions?.()) {\n const allow =\n options.find((o) => o.kind === \"allow_once\" || o.kind === \"allow_always\") ?? options[0];\n if (allow) return { outcome: { outcome: \"selected\", optionId: allow.id } };\n }\n const requestId = \"p\" + Math.random().toString(36).slice(2, 10);\n bridge.push({\n type: \"permission_request\",\n requestId,\n title: tc.title ?? tc.name ?? \"需要授权\",\n kind: tc.kind ?? \"tool\",\n detail: tc.rawInput ?? undefined,\n options,\n });\n const answer = await bridge.waitFor(requestId);\n if (answer.cancelled) return { outcome: { outcome: \"cancelled\" } };\n const optionId = answer.optionId ?? options[0]?.id;\n if (!optionId) return { outcome: { outcome: \"cancelled\" } };\n return { outcome: { outcome: \"selected\", optionId } };\n });\n}\n","import type * as schema from \"@agentclientprotocol/sdk\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport type { ToolKind } from \"../../timeline/types.js\";\n\nconst rid = () => Math.random().toString(36).slice(2, 8);\n\nfunction mapToolKind(k: schema.ToolKind | null | undefined): ToolKind {\n switch (k) {\n case \"read\":\n case \"edit\":\n case \"search\":\n return k;\n case \"execute\":\n return \"bash\";\n case \"fetch\":\n return \"web\";\n default:\n return \"other\";\n }\n}\n\nfunction mapToolStatus(s: schema.ToolCallStatus | null | undefined): \"running\" | \"ok\" | \"failed\" {\n if (s === \"completed\") return \"ok\";\n if (s === \"failed\") return \"failed\";\n return \"running\";\n}\n\nfunction textFromContent(content: schema.ContentBlock): string {\n if (content.type === \"text\") return content.text;\n return \"\";\n}\n\nexport type MapCtx = { itemPrefix: string; messageIds: Map };\n\nexport function newMapCtx(): MapCtx {\n return { itemPrefix: \"a\" + rid() + \"_\", messageIds: new Map() };\n}\n\nexport function mapSessionUpdate(update: schema.SessionUpdate, ctx: MapCtx): ProviderEvent[] {\n const out: ProviderEvent[] = [];\n const mid = \"messageId\" in update && update.messageId ? String(update.messageId) : null;\n\n switch (update.sessionUpdate) {\n case \"agent_message_chunk\": {\n const text = textFromContent(update.content);\n if (!text) break;\n const itemId = mid ? ctx.messageIds.get(mid) ?? ctx.itemPrefix + mid : ctx.itemPrefix + \"msg\";\n if (mid) ctx.messageIds.set(mid, itemId);\n out.push({ type: \"assistant_delta\", itemId, text });\n break;\n }\n case \"agent_thought_chunk\": {\n const text = textFromContent(update.content);\n if (!text) break;\n const itemId = mid ? ctx.itemPrefix + \"t_\" + mid : ctx.itemPrefix + \"think\";\n out.push({ type: \"reasoning_delta\", itemId, text });\n break;\n }\n case \"tool_call\":\n out.push({\n type: \"tool_call\",\n itemId: ctx.itemPrefix + update.toolCallId,\n kind: mapToolKind(update.kind),\n title: update.title,\n status: mapToolStatus(update.status),\n input: update.rawInput,\n });\n break;\n case \"tool_call_update\":\n out.push({\n type: \"tool_call\",\n itemId: ctx.itemPrefix + update.toolCallId,\n kind: mapToolKind(update.kind),\n title: update.title ?? update.name ?? \"tool\",\n status: mapToolStatus(update.status),\n input: update.rawInput,\n output: typeof update.rawOutput === \"string\" ? update.rawOutput : undefined,\n });\n break;\n case \"plan\":\n out.push({\n type: \"plan\",\n itemId: ctx.itemPrefix + \"plan\",\n markdown: update.entries.map((e) => `- ${e.content}`).join(\"\\n\"),\n entries: update.entries.map((e) => ({ step: e.content, status: e.status ?? \"\" })),\n });\n break;\n case \"notice\":\n out.push({\n type: \"notice\",\n level: update.severity === \"error\" ? \"error\" : update.severity === \"warning\" ? \"warn\" : \"info\",\n message: update.description ? `${update.title}: ${update.description}` : update.title,\n });\n break;\n case \"usage_update\":\n out.push({\n type: \"usage\",\n inputTokens: update.used,\n outputTokens: 0,\n cacheReadTokens: 0,\n costUsd: typeof update.cost?.amount === \"number\" ? update.cost.amount : 0,\n contextWindow: update.size,\n });\n break;\n default:\n break;\n }\n return out;\n}\n\nexport function stopToTurnEnd(stopReason: schema.StopReason, turnId: string, err?: string): ProviderEvent {\n if (stopReason === \"cancelled\") {\n return { type: \"turn_ended\", turnId, status: \"interrupted\" };\n }\n if (stopReason === \"refusal\" || stopReason === \"max_tokens\") {\n return { type: \"turn_ended\", turnId, status: \"failed\", error: err ?? stopReason };\n }\n return { type: \"turn_ended\", turnId, status: \"completed\" };\n}\n","// What each ACP Agent says it accepts in a prompt: initialize → agentCapabilities.promptCapabilities.\n// ACP's baseline is text and resource_link; image / audio / embedded resources are opt-in per Agent.\n// Learned from the model probe (startup warm-up, providers.list) and from every session's initialize,\n// kept in memory per provider id, and read by the provider's capabilities (images) and by AcpSession.send.\nexport type AcpPromptCaps = { image: boolean; audio: boolean; embeddedContext: boolean };\n\nconst learned = new Map();\n\n/** Normalize an initialize response (anything: Agents may leave fields out, which means false) */\nexport function promptCapsFromInitialize(res: unknown): AcpPromptCaps {\n const pc = (res as { agentCapabilities?: { promptCapabilities?: Record | null } | null } | null)\n ?.agentCapabilities?.promptCapabilities;\n return {\n image: pc?.image === true,\n audio: pc?.audio === true,\n embeddedContext: pc?.embeddedContext === true,\n };\n}\n\nexport function rememberPromptCaps(providerId: string | undefined, caps: AcpPromptCaps): void {\n if (providerId) learned.set(providerId, caps);\n}\n\n/** undefined until the Agent has been probed or a session has initialized */\nexport function knownPromptCaps(providerId: string): AcpPromptCaps | undefined {\n return learned.get(providerId);\n}\n\nexport function forgetPromptCaps(providerId?: string): void {\n if (providerId) learned.delete(providerId);\n else learned.clear();\n}\n","// How an ACP Agent reopens an old conversation: initialize → agentCapabilities.\n// - loadSession (top-level): session/load — returns prior messages as session/update\n// - sessionCapabilities.resume: session/resume — reconnects without replaying history\n// Prefer load when both are advertised (OpenCode); resume when only resume (dsh);\n// neither → the caller starts a new session and tells the user prior context wasn't restored.\nexport type AcpSessionOpen = { load: boolean; resume: boolean };\nexport type AcpReconnect = \"load\" | \"resume\" | \"none\";\n\n/** Normalize an initialize response: missing/false/null means the Agent doesn't advertise that method */\nexport function sessionOpenFromInitialize(res: unknown): AcpSessionOpen {\n const caps = (res as { agentCapabilities?: {\n loadSession?: unknown;\n sessionCapabilities?: { resume?: unknown } | null;\n } | null } | null)?.agentCapabilities;\n // ACP: omitted or null = no; {} (any object) = yes\n const resume = caps?.sessionCapabilities?.resume != null && typeof caps.sessionCapabilities.resume === \"object\";\n return { load: caps?.loadSession === true, resume };\n}\n\nexport function pickReconnect(open: AcpSessionOpen): AcpReconnect {\n if (open.load) return \"load\";\n if (open.resume) return \"resume\";\n return \"none\";\n}\n","// Turn one user message (text + attachments) into an ACP prompt (ContentBlock[]) for what this Agent accepts.\n// - PNG/JPEG/GIF/WebP: an image block (base64 + mimeType) when the Agent advertises promptCapabilities.image;\n// otherwise the saved file's absolute path, as a resource_link (ACP baseline) and as a line of text,\n// so an Agent with file tools can still open it; the caller shows a notice that it can't see the image.\n// - audio/* : an audio block when promptCapabilities.audio, else the same path fallback.\n// - text : an embedded resource (its text) when promptCapabilities.embeddedContext and small enough, else a path.\n// Every attachment is either sent or named by path: nothing is dropped silently.\nimport type { ContentBlock } from \"@agentclientprotocol/sdk\";\nimport { readFile, stat } from \"node:fs/promises\";\nimport { basename } from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport { resolveImagePath } from \"../../util/attachmentRefs.js\";\nimport type { AcpPromptCaps } from \"./promptCaps.js\";\nimport { attachmentKind } from \"../../util/attachmentKinds.js\";\n\n/** Largest image / audio sent inline as base64 (above it, the path is sent instead) */\nexport const MAX_INLINE_MEDIA_BYTES = 10 * 1024 * 1024;\n/** All inline media of one prompt together. Base64 adds a third, and an Agent built on the ACP SDK refuses an incoming\n * line over 32 MB (DEFAULT_MAX_MESSAGE_BYTES), so one prompt stays well under that; the rest goes by path. */\nexport const MAX_INLINE_TOTAL_BYTES = 16 * 1024 * 1024;\n/** Largest text file embedded as a resource */\nexport const MAX_EMBED_TEXT_BYTES = 1024 * 1024;\n\nexport type AcpPromptBuild = {\n blocks: ContentBlock[];\n /** Images that went by path because the Agent doesn't take image blocks */\n imagesByPath: string[];\n /** Other problems worth a notice (unreadable file, too large) */\n warnings: string[];\n};\n\nconst isText = (mime: string) => attachmentKind(mime) === \"text\";\n\nexport async function buildAcpPrompt(\n text: string,\n attachments: { path: string; mime: string }[],\n caps: AcpPromptCaps,\n): Promise {\n const media: ContentBlock[] = [];\n const links: ContentBlock[] = [];\n const pathLines: string[] = [];\n const imagesByPath: string[] = [];\n const warnings: string[] = [];\n let inlineBytes = 0;\n\n const byPath = (file: string, mime: string, size: number | null, label: string) => {\n links.push({ type: \"resource_link\", uri: pathToFileURL(file).href, name: basename(file), mimeType: mime, ...(size !== null ? { size } : {}) });\n pathLines.push(`${label}: ${file}`);\n };\n\n for (const a of attachments) {\n const mime = a.mime || \"application/octet-stream\";\n let file: string;\n let size: number | null = null;\n try {\n file = await resolveImagePath(a.path);\n size = (await stat(file)).size;\n } catch (e) {\n warnings.push(`${a.path}: ${String((e as Error)?.message ?? e)}`);\n continue;\n }\n // Only the four image types model APIs take (svg, bmp… go by path like any other file)\n const image = attachmentKind(mime) === \"image\";\n const audio = mime.startsWith(\"audio/\");\n if ((image && caps.image) || (audio && caps.audio)) {\n if (size > MAX_INLINE_MEDIA_BYTES || inlineBytes + size > MAX_INLINE_TOTAL_BYTES) {\n const limit = size > MAX_INLINE_MEDIA_BYTES ? MAX_INLINE_MEDIA_BYTES : MAX_INLINE_TOTAL_BYTES;\n warnings.push(`${basename(file)}: ${(size / 1024 / 1024).toFixed(1)} MB, over the ${limit / 1024 / 1024} MB inline limit; sent as a file path`);\n byPath(file, mime, size, image ? \"Attached image\" : \"Attached audio\");\n if (image) imagesByPath.push(file);\n continue;\n }\n try {\n const data = (await readFile(file)).toString(\"base64\");\n media.push(image ? { type: \"image\", data, mimeType: mime } : { type: \"audio\", data, mimeType: mime });\n inlineBytes += size;\n } catch (e) {\n warnings.push(`${basename(file)}: ${String((e as Error)?.message ?? e)}`);\n byPath(file, mime, size, image ? \"Attached image\" : \"Attached audio\");\n if (image) imagesByPath.push(file);\n }\n continue;\n }\n if (image || audio) {\n byPath(file, mime, size, image ? \"Attached image\" : \"Attached audio\");\n if (image) imagesByPath.push(file);\n continue;\n }\n if (isText(mime) && caps.embeddedContext && size <= MAX_EMBED_TEXT_BYTES && inlineBytes + size <= MAX_INLINE_TOTAL_BYTES) {\n try {\n const body = await readFile(file, \"utf8\");\n media.push({ type: \"resource\", resource: { uri: pathToFileURL(file).href, mimeType: mime, text: body } });\n inlineBytes += size;\n continue;\n } catch {\n /* fall back to the path */\n }\n }\n byPath(file, mime, size, \"Attached file\");\n }\n\n const fullText = [text, ...(pathLines.length ? [pathLines.join(\"\\n\")] : [])].filter(Boolean).join(\"\\n\\n\");\n const blocks: ContentBlock[] = [];\n if (fullText) blocks.push({ type: \"text\", text: fullText });\n blocks.push(...media, ...links);\n return { blocks, imagesByPath, warnings };\n}\n","// Persistent ACP session: cursor-agent acp / devin acp and others.\nimport type { ActiveSession, ClientConnection, ClientContext } from \"@agentclientprotocol/sdk\";\nimport { client, methods, PROTOCOL_VERSION } from \"@agentclientprotocol/sdk\";\nimport type { ChildProcess } from \"node:child_process\";\nimport type { ProviderSession, SendInput, RespondAnswer, Mode, SlashCommand } from \"../types.js\";\nimport { mapAcpAvailableCommands, sortSlashCommands } from \"../slash.js\";\nimport type { ProviderEvent } from \"../../timeline/types.js\";\nimport { AsyncQueue } from \"../../util/queue.js\";\nimport { resolveFirstBin, type ResolveResult } from \"../../util/bin.js\";\nimport { absCwd, acpStreamFromChild, killAcpChild, spawnResolved } from \"./spawn.js\";\nimport { registerAcpClientHandlers } from \"./clientHandlers.js\";\nimport { mapSessionUpdate, newMapCtx, stopToTurnEnd, type MapCtx } from \"./mapUpdate.js\";\nimport { promptCapsFromInitialize, rememberPromptCaps, type AcpPromptCaps } from \"./promptCaps.js\";\nimport { pickReconnect, sessionOpenFromInitialize } from \"./sessionCaps.js\";\nimport { buildAcpPrompt } from \"./prompt.js\";\nimport { tr } from \"../../util/i18n.js\";\n\ntype AcpSelectValue = { value?: string; name?: string; options?: AcpSelectValue[] };\ntype AcpConfigOption = { id: string; category?: string | null; type?: string; currentValue?: unknown; options?: AcpSelectValue[] };\nimport { log } from \"../../log.js\";\n\nconst rid = () => Math.random().toString(36).slice(2, 10);\n\ntype AttachableCtx = {\n attachSession(response: { sessionId: string; modes?: unknown; configOptions?: unknown }): ActiveSession;\n};\n\nexport type AcpSessionOpts = {\n threadId: string;\n /** Provider id and display name: the Agent's prompt capabilities are remembered under the id; notices name the Agent */\n providerId?: string;\n providerName?: string;\n cwd: string;\n model: string | null;\n mode: Mode;\n effort: string | null;\n resumeId: string | null;\n binName: string;\n binCandidates: string[];\n acpArgs: string[];\n /** initialize's image answer holds for the start model only (dsh): after a switch, images go by path */\n imagesFollowStartModel?: boolean;\n appVersion: string;\n flavor: \"cursor\" | \"generic\";\n modeMap?: Partial>;\n cursorFast?: boolean;\n cursorAutoReview?: boolean;\n launch?: { cmd: string; prefixArgs: string[] };\n};\n\nexport class AcpSession implements ProviderSession {\n events: AsyncIterable;\n private q = new AsyncQueue();\n private child: ChildProcess | null = null;\n private conn: ClientConnection | null = null;\n private ctx: ClientContext | null = null;\n private active: ActiveSession | null = null;\n private initErr: Error | null = null;\n private initPromise: Promise;\n private closed = false;\n private turnId: string | null = null;\n private turnNum = 0;\n private running = false;\n private permPending = new Map void>();\n private mapCtx: MapCtx = newMapCtx();\n private modelConfigId: string | null = null;\n private thoughtConfigId: string | null = null;\n private cursorFast = false;\n private cursorAutoReview = false;\n private slashCommands: SlashCommand[] = [];\n private activeTurnId: string | null = null;\n private configOptions: AcpConfigOption[] | null = null;\n // initialize → agentCapabilities.promptCapabilities (ACP baseline until initialize answers: text + resource_link)\n private promptCaps: AcpPromptCaps = { image: false, audio: false, embeddedContext: false };\n private initCaps: AcpPromptCaps = this.promptCaps;\n private startModel: string | null = null;\n private wakePump: (() => void) | null = null;\n // listCommands waits for the first batch of commands (or confirmation there are none), following Paseo's waitForInitialCommands\n private commandsReady!: Promise;\n private settleCommands!: () => void;\n\n constructor(private o: AcpSessionOpts) {\n this.events = this.q;\n this.startModel = o.model; // the model the process is launched for (argsFor), before any setModel\n this.cursorFast = o.cursorFast ?? false;\n // The shield (auto-accept permission prompts) UI was removed: never auto-approve, so conversations that once turned it on don't keep approving with the toggle hidden\n this.cursorAutoReview = false;\n this.commandsReady = new Promise((r) => (this.settleCommands = r));\n this.initPromise = this.boot();\n }\n\n private async boot(): Promise {\n const bin = this.o.launch\n ? ({\n ok: true as const,\n cmd: this.o.launch.cmd,\n prefixArgs: this.o.launch.prefixArgs,\n display: this.o.launch.cmd,\n via: \"custom\",\n binName: \"custom\",\n } satisfies ResolveResult & { ok: true; binName: string })\n : await resolveFirstBin(this.o.binCandidates?.length ? this.o.binCandidates : [this.o.binName]);\n if (!bin.ok) throw new Error(bin.reason);\n try {\n this.child = spawnResolved(bin, this.o.acpArgs, { cwd: this.o.cwd });\n this.child.stderr?.on(\"data\", (d: Buffer) => {\n const t = d.toString().trim();\n if (t) log(\"info\", `[${this.o.binName} acp] ${t.slice(0, 500)}`);\n });\n this.child.on(\"error\", (e) => this.failInit(e));\n this.child.on(\"close\", (code) => {\n if (!this.closed && code !== 0) {\n this.q.push({ type: \"notice\", level: \"error\", message: `${this.o.binName} 进程退出 (${code})` });\n }\n });\n\n const app = client({ name: \"magpie-agent-console\" });\n registerAcpClientHandlers(app, {\n push: (ev) => this.q.push(ev),\n waitFor: (id) => this.waitPerm(id),\n autoAcceptPermissions: () => this.cursorAutoReview,\n });\n const stream = acpStreamFromChild(this.child);\n this.conn = app.connect(stream);\n this.ctx = this.conn.agent;\n const initRes = await this.ctx.request(methods.agent.initialize, {\n protocolVersion: PROTOCOL_VERSION,\n // Cursor: declare support for the parameterized model picker (as Paseo does); only then does it expose levels / Fast as separate config options\n clientCapabilities: this.o.flavor === \"cursor\" ? { _meta: { parameterizedModelPicker: true } } : {},\n clientInfo: { name: \"Agent Console\", version: this.o.appVersion },\n });\n this.promptCaps = promptCapsFromInitialize(initRes);\n this.initCaps = this.promptCaps;\n rememberPromptCaps(this.o.providerId, this.promptCaps);\n\n const cwd = absCwd(this.o.cwd);\n const ctx = this.ctx;\n if (!ctx) throw new Error(\"ACP 未连接\");\n if (this.o.resumeId) {\n // Prefer the method the Agent's initialize advertises: load (replays history) over resume (reconnects only); neither → new session + notice\n const how = pickReconnect(sessionOpenFromInitialize(initRes));\n if (how === \"load\" || how === \"resume\") {\n const method = how === \"load\" ? methods.agent.session.load : methods.agent.session.resume;\n const restored = (await ctx.request(method, {\n sessionId: this.o.resumeId,\n cwd,\n mcpServers: [],\n })) as { modes?: unknown; configOptions?: AcpConfigOption[] };\n this.active = (ctx as unknown as AttachableCtx).attachSession({\n sessionId: this.o.resumeId,\n modes: restored.modes,\n configOptions: restored.configOptions,\n });\n if (Array.isArray(restored.configOptions)) this.configOptions = restored.configOptions;\n } else {\n this.active = await ctx.buildSession(cwd).start();\n const agent = this.o.providerName ?? this.o.binName;\n this.q.push({ type: \"notice\", level: \"warn\", message: tr(\"notice.acpSessionNotRestored\", { agent }) });\n }\n } else {\n this.active = await ctx.buildSession(cwd).start();\n }\n this.syncModelConfigId();\n this.syncThoughtConfigId();\n await this.applySessionMode(this.o.mode);\n await this.applySessionModel(this.o.model); // Also applies the thinking level\n await this.applyCursorAutoReviewConfig(this.cursorAutoReview);\n this.q.push({ type: \"provider_session_id\", id: this.active.sessionId });\n const init = (this.active.newSessionResponse as { availableCommands?: { name: string; description: string; input?: { hint?: string } | null }[] })\n ?.availableCommands;\n if (init?.length) this.applyAvailableCommands(init);\n void this.pumpUpdates();\n } catch (e) {\n this.failInit(e);\n throw e;\n }\n }\n\n private failInit(e: unknown): void {\n this.initErr = e instanceof Error ? e : new Error(String(e));\n }\n\n private applyAvailableCommands(\n raw: { name: string; description: string; input?: { hint?: string } | null }[],\n ): void {\n this.slashCommands = sortSlashCommands(mapAcpAvailableCommands(raw));\n this.q.push({ type: \"slash_commands\", commands: this.slashCommands });\n this.settleCommands();\n }\n\n private async ready(): Promise {\n await this.initPromise.catch(() => {});\n if (this.initErr) throw this.initErr;\n if (!this.active) throw new Error(\"ACP 会话未就绪\");\n }\n\n /** The config options currently in effect: each set_config_option response returns the latest set (levels change after switching models); otherwise use session/new's */\n private currentConfig(): AcpConfigOption[] {\n return (this.configOptions ?? this.active?.newSessionResponse.configOptions ?? []) as AcpConfigOption[];\n }\n\n private syncModelConfigId(): void {\n for (const o of this.currentConfig()) {\n if (o.type === \"select\" && (o.category === \"model\" || o.id === \"model\")) {\n this.modelConfigId = o.id;\n return;\n }\n }\n if (this.o.flavor === \"cursor\") this.modelConfigId = \"model\";\n }\n\n // Level options belong to the current model: recompute on every refresh (cleared when switching to a model without levels)\n private syncThoughtConfigId(): void {\n this.thoughtConfigId = null;\n const opts = this.currentConfig().filter((o) => o.type === \"select\");\n const hit =\n opts.find((o) => o.category === \"thought_level\") ??\n opts.find((o) => /thought|thinking|effort|reasoning/i.test(o.id ?? \"\"));\n if (hit) this.thoughtConfigId = hit.id;\n }\n\n /** set_config_option, then refresh local state from the latest config options it returns */\n private async setOption(configId: string, value: string): Promise {\n if (!this.ctx || !this.active) return false;\n try {\n const res = (await this.ctx.request(methods.agent.session.setConfigOption, {\n sessionId: this.active.sessionId,\n configId,\n value,\n })) as { configOptions?: AcpConfigOption[] } | null;\n if (Array.isArray(res?.configOptions)) {\n this.configOptions = res.configOptions;\n this.syncThoughtConfigId();\n }\n return true;\n } catch (e) {\n log(\"info\", `ACP set_config_option(${configId}=${value}): ${String(e)}`);\n return false;\n }\n }\n\n private async applySessionThought(effort: string | null): Promise {\n if (!effort || !this.thoughtConfigId) return;\n // Don't send a level the current model doesn't support (the level set changes after switching models)\n const opt = this.currentConfig().find((o) => o.id === this.thoughtConfigId);\n const values = (opt?.options ?? []).flatMap((x) => (x.options ? x.options : [x])).map((x) => x.value);\n if (values.length && !values.includes(effort)) return;\n await this.setOption(this.thoughtConfigId, effort);\n }\n\n /** Mode choices the Agent reports: prefer a category=mode select in configOptions, otherwise ACP modes.availableModes */\n private modeChoices(): { id: string; name: string; configId: string | null }[] {\n const opt = this.currentConfig().find(\n (o) => o.type === \"select\" && (o.category === \"mode\" || o.id === \"mode\"),\n ) as { id: string; options?: { value?: string; name?: string }[] } | undefined;\n if (opt?.options?.length) {\n return opt.options\n .filter((x) => typeof x.value === \"string\")\n .map((x) => ({ id: x.value!, name: x.name ?? x.value!, configId: opt.id }));\n }\n return (this.active?.modes?.availableModes ?? []).map((m) => ({ id: m.id, name: m.name, configId: null }));\n }\n\n private resolveAcpModeId(mode: Mode): string | null {\n if (this.o.modeMap) {\n // Find the Agent's real mode via the table; Copilot's mode ids are URLs (…/session-modes#agent), so match on the # suffix\n const want = this.o.modeMap[mode];\n if (!want) return null;\n const hit = this.modeChoices().find(\n (m) => m.id === want || m.id.endsWith(`#${want}`) || m.name.toLowerCase() === want,\n );\n return hit?.id ?? want;\n }\n if (this.o.flavor === \"cursor\") {\n const map: Partial> = { default: \"agent\", plan: \"plan\", ask: \"ask\" };\n const want = map[mode] ?? \"agent\";\n const avail = this.active?.modes?.availableModes ?? [];\n const hit = avail.find((m) => m.id === want || m.name.toLowerCase() === want);\n return hit?.id ?? want;\n }\n return mode === \"plan\" ? \"plan\" : \"default\";\n }\n\n private async applySessionMode(mode: Mode): Promise {\n if (!this.ctx || !this.active) return;\n // Copilot \"Allow All\" (as Paseo does): use the agent mode, then turn the allow_all config on; turn it off for other modes\n const hasAllowAll = this.currentConfig().some((o) => o.id === \"allow_all\");\n if (this.o.modeMap?.[mode] === \"allow-all\") {\n const agentId = this.modeChoices().find((m) => m.id === \"agent\" || m.id.endsWith(\"#agent\"));\n if (agentId?.configId) await this.setOption(agentId.configId, agentId.id);\n if (hasAllowAll) await this.setOption(\"allow_all\", \"on\");\n return;\n }\n if (hasAllowAll && this.o.modeMap) await this.setOption(\"allow_all\", \"off\");\n const modeId = this.resolveAcpModeId(mode);\n if (!modeId) return; // This Agent has no such mode: don't send anything\n try {\n // Modes offered as a config option (Devin / OpenCode / Copilot) use set_config_option, otherwise set_mode\n const viaConfig = this.modeChoices().find((m) => m.id === modeId)?.configId;\n if (viaConfig) {\n await this.setOption(viaConfig, modeId);\n return;\n }\n await this.ctx.request(methods.agent.session.setMode, {\n sessionId: this.active.sessionId,\n modeId,\n });\n } catch (e) {\n log(\"info\", `ACP setMode(${mode}): ${String(e)}`);\n }\n }\n\n /**\n * Switch model → refresh level options from the new config options it returns → then apply the level (as Paseo does).\n * Cursor connects with parameterizedModelPicker: only the base model name is sent (grok-4.6); level / Fast are separate config options.\n */\n private async applySessionModel(model: string | null): Promise {\n if (!this.ctx || !this.active) return;\n const value = model && this.o.flavor === \"cursor\" ? model.replace(/\\[.*\\]$/u, \"\") : model;\n if (value) {\n const modelOption = this.currentConfig().find((o) => o.id === (this.modelConfigId ?? \"model\"));\n const values = (modelOption?.options ?? []).flatMap((x) => x.options ? x.options : [x])\n .map((x) => x.value).filter((x): x is string => typeof x === \"string\");\n // Old sessions may have saved another Agent's model ID. Skip invalid values and let ACP keep its current model.\n if (!values.length || values.includes(value)) {\n await this.setOption(this.modelConfigId ?? \"model\", value);\n } else {\n log(\"info\", `忽略 ${this.o.binName} 的无效模型:${value}`);\n }\n }\n if (this.o.flavor === \"cursor\" && this.cursorFast && this.currentConfig().some((o) => o.id === \"fast\")) {\n await this.setOption(\"fast\", \"true\");\n }\n await this.applySessionThought(this.o.effort);\n }\n\n private async applyCursorAutoReviewConfig(on: boolean): Promise {\n if (this.o.flavor !== \"cursor\" || !this.ctx || !this.active) return;\n for (const configId of [\"auto_review\", \"autoReview\", \"smart_auto\"]) {\n try {\n await this.ctx.request(methods.agent.session.setConfigOption, {\n sessionId: this.active.sessionId,\n configId,\n type: \"boolean\",\n value: on,\n });\n return;\n } catch {\n /* try the next configId */\n }\n }\n }\n\n /** Cursor only: Fast / Auto-review (can be changed while the session is open) */\n async updateCursorOpts(fast: boolean, autoReview: boolean): Promise {\n this.cursorFast = fast;\n void autoReview; // See the constructor: auto-approval is no longer supported\n await this.ready();\n await this.applySessionModel(this.o.model);\n await this.applyCursorAutoReviewConfig(false);\n }\n\n private waitPerm(requestId: string): Promise {\n return new Promise((resolve) => {\n this.permPending.set(requestId, resolve);\n });\n }\n\n respond(requestId: string, answer: RespondAnswer): void {\n const r = this.permPending.get(requestId);\n if (r) {\n this.permPending.delete(requestId);\n r(answer);\n this.q.push({\n type: \"permission_resolved\",\n requestId,\n optionId: answer.optionId,\n cancelled: answer.cancelled,\n });\n }\n }\n\n async send(input: SendInput): Promise {\n await this.ready();\n if (this.running) throw new Error(\"当前轮次尚未结束\");\n this.turnNum++;\n this.turnId = \"u_\" + Date.now().toString(36) + \"_\" + this.turnNum;\n this.mapCtx = newMapCtx();\n this.running = true;\n this.q.push({\n type: \"turn_started\",\n turnId: this.turnId,\n model: this.o.model,\n mode: this.o.mode,\n effort: this.o.effort,\n });\n const text = input.text.trim();\n const attachments = input.images ?? [];\n if (!text && !attachments.length) {\n this.running = false;\n this.q.push({ type: \"turn_ended\", turnId: this.turnId, status: \"completed\" });\n return;\n }\n // An image-only message is still a prompt: images go as image blocks when the Agent takes them, else by file path\n const built = await buildAcpPrompt(text, attachments, this.promptCaps);\n const agent = this.o.providerName ?? this.o.binName;\n if (built.imagesByPath.length && !this.promptCaps.image) {\n const switched = this.o.imagesFollowStartModel && this.initCaps.image && this.o.model !== this.startModel;\n this.q.push({ type: \"notice\", level: \"warn\", message: tr(switched ? \"notice.acpImageByPathSwitched\" : \"notice.acpImageByPath\", { agent, paths: built.imagesByPath.join(\", \") }) });\n }\n for (const w of built.warnings) this.q.push({ type: \"notice\", level: \"warn\", message: tr(\"notice.attachmentIssue\", { detail: w }) });\n if (!built.blocks.length) {\n // Every attachment failed to resolve and there was no text: nothing to send\n this.running = false;\n this.q.push({ type: \"turn_ended\", turnId: this.turnId, status: \"completed\" });\n return;\n }\n void this.runTurn(built.blocks);\n }\n\n // Send this turn's prompt; reading updates is the job of the persistent pumpUpdates() (following Paseo: session/update is handled at session level)\n private async runTurn(prompt: Parameters[0]): Promise {\n this.activeTurnId = this.turnId;\n this.wakePump?.();\n this.wakePump = null;\n this.active!.prompt(prompt).catch(() => {\n /* failures surface through nextUpdate() and pumpUpdates ends the turn */\n });\n }\n\n private endTurn(ev: ProviderEvent): void {\n this.q.push(ev);\n this.activeTurnId = null;\n this.running = false;\n }\n\n /**\n * Read session/update for the whole lifetime of the session.\n * It used to read only during a turn: the available_commands_update an Agent pushes after session/new and before the first message went unread,\n * so the slash menu stayed empty. Now command updates are cached at any time; conversation content becomes timeline events only while a turn is in progress.\n */\n private async pumpUpdates(): Promise {\n const session = this.active;\n if (!session) return;\n while (!this.closed) {\n let msg: Awaited>;\n try {\n msg = await session.nextUpdate();\n } catch (e) {\n if (this.closed) return;\n const turnId = this.activeTurnId;\n if (turnId) {\n this.endTurn({ type: \"turn_ended\", turnId, status: \"failed\", error: String((e as Error)?.message ?? e) });\n }\n // After an error the queue keeps throwing the same error until the next prompt clears it: wait for the next turn before reading again\n await new Promise((r) => (this.wakePump = r));\n continue;\n }\n if (msg.kind === \"session_update\") {\n const u = msg.update;\n if (u.sessionUpdate === \"available_commands_update\") {\n this.applyAvailableCommands(u.availableCommands ?? []);\n continue;\n }\n if (!this.activeTurnId) continue; // Other updates outside a turn (e.g. mode change echoes) don't go to the timeline\n for (const ev of mapSessionUpdate(u, this.mapCtx)) this.q.push(ev);\n } else if (this.activeTurnId) {\n this.endTurn(stopToTurnEnd(msg.stopReason, this.activeTurnId));\n }\n }\n }\n\n async interrupt(): Promise {\n await this.ready();\n if (!this.ctx || !this.active) return;\n try {\n await this.ctx.request(methods.agent.session.cancel, { sessionId: this.active.sessionId });\n } catch (e) {\n log(\"info\", `ACP cancel: ${String(e)}`);\n }\n }\n\n async setModel(model: string | null): Promise {\n this.o.model = model;\n await this.ready();\n if (this.o.imagesFollowStartModel) {\n // dsh answered initialize for the model it started on; another model may not take image blocks (it refuses the prompt)\n this.promptCaps = model === this.startModel ? this.initCaps : { ...this.initCaps, image: false };\n }\n await this.applySessionModel(model);\n }\n\n async setMode(mode: Mode): Promise {\n this.o.mode = mode;\n await this.ready();\n await this.applySessionMode(mode);\n }\n\n async setEffort(effort: string | null): Promise {\n this.o.effort = effort;\n await this.ready();\n await this.applySessionThought(effort);\n }\n\n async listCommands(): Promise {\n try {\n await this.ready();\n } catch {\n return [];\n }\n // Commands are pushed asynchronously after session/new: wait at most 10 s for the first batch (Agents that report none return empty after the timeout)\n if (!this.slashCommands.length) {\n await Promise.race([this.commandsReady, new Promise((r) => setTimeout(r, 10000))]);\n }\n return this.slashCommands;\n }\n\n async close(): Promise {\n this.closed = true;\n this.wakePump?.();\n this.settleCommands();\n for (const r of this.permPending.values()) r({ cancelled: true });\n this.permPending.clear();\n try {\n this.active?.dispose();\n } catch {}\n try {\n this.conn?.close();\n } catch {}\n await killAcpChild(this.child);\n this.child = null;\n this.q.close();\n }\n}\n","import type { Provider, DetectResult, ModelInfo, Capabilities, Mode } from \"../types.js\";\nimport { killTree, spawnBin, type ResolveResult } from \"../../util/bin.js\";\nimport { resolveFirstBin } from \"../../util/bin.js\";\nimport { AcpSession } from \"./session.js\";\nimport { knownPromptCaps } from \"./promptCaps.js\";\n\nexport type AcpProviderConfig = {\n id: string;\n name: string;\n binName: string;\n /** Takes precedence over binName: detect / listModels / session resolve these in order */\n binCandidates?: string[];\n acpArgs: string[];\n /** Arguments for one session's ACP process from its model (dsh starts on the conversation's route); default acpArgs */\n argsFor?: (model: string | null) => Promise;\n /** The Agent decides at initialize whether it takes images, for the model it started on (dsh): a model picked later sends images by path */\n imagesFollowStartModel?: boolean;\n installHint: string;\n appVersion?: string;\n flavor?: \"cursor\" | \"generic\";\n capabilities?: Capabilities;\n fetchModels?: (r: ResolveResult & { ok: true }) => Promise;\n /** Our permission modes → this Agent's mode names over ACP; when given, only modes in the table are shown */\n modeMap?: Partial>;\n /** Mode names shown in the UI (the Agent's own naming) */\n modeLabels?: Partial>;\n};\n\nexport const acpCapabilities: Capabilities = {\n resume: true,\n interrupt: true,\n steer: false,\n images: false,\n plan: true,\n questions: false,\n cost: false,\n effort: [],\n modes: [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"],\n};\n\nexport const cursorAcpCapabilities: Capabilities = {\n ...acpCapabilities,\n questions: true,\n modes: [\"default\", \"plan\", \"ask\"],\n};\n\nfunction runVersion(r: ResolveResult & { ok: true }, args: string[], timeoutMs = 8000): Promise {\n return new Promise((resolvePromise, reject) => {\n let out = \"\";\n const p = spawnBin(r, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"], env: { ...process.env, NO_COLOR: \"1\" } });\n const t = setTimeout(() => {\n try {\n void killTree(p, { graceMs: 500 }); // On Windows a .cmd wrapper must be killed together with its child processes\n } catch {}\n reject(new Error(\"timeout\"));\n }, timeoutMs);\n p.stdout?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.stderr?.on(\"data\", (c: Buffer) => (out += c.toString()));\n p.on(\"error\", reject);\n p.on(\"close\", (code) => {\n clearTimeout(t);\n if (code === 0 || out.trim()) resolvePromise(out.trim());\n else reject(new Error(\"exit \" + code));\n });\n });\n}\n\nasync function resolveProviderBin(cfg: AcpProviderConfig) {\n const names = cfg.binCandidates?.length ? cfg.binCandidates : [cfg.binName];\n return resolveFirstBin(names);\n}\n\nexport function createAcpProvider(cfg: AcpProviderConfig): Provider {\n const base = cfg.capabilities ?? acpCapabilities;\n // With a mode table, expose only the modes the Agent really has (in our fixed order)\n const ORDER: Mode[] = [\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\"];\n const caps: Capabilities = cfg.modeMap\n ? { ...base, modes: ORDER.filter((m) => cfg.modeMap![m] !== undefined), modeLabels: cfg.modeLabels }\n : base;\n return {\n id: cfg.id,\n name: cfg.name,\n // images is what the Agent's own initialize says (probe at warm-up / providers.list, or any session); until then unknown\n get capabilities(): Capabilities {\n const learned = knownPromptCaps(cfg.id);\n return learned ? { ...caps, images: learned.image, imagesKnown: true } : { ...caps, images: false, imagesKnown: false };\n },\n async detect(): Promise {\n const r = await resolveProviderBin(cfg);\n if (!r.ok) {\n return { available: false, reason: r.reason, installHint: cfg.installHint };\n }\n let version: string | undefined;\n try {\n if (cfg.id === \"cursor\") {\n version = (await runVersion(r, [\"--version\"])).split(/\\r?\\n/)[0]?.trim();\n } else if (cfg.id === \"devin\") {\n version = (await runVersion(r, [\"version\"])).split(/\\r?\\n/)[0]?.trim();\n } else if (cfg.id === \"opencode\") {\n version = (await runVersion(r, [\"--version\"])).split(/\\r?\\n/)[0]?.trim();\n }\n } catch {\n /* Having the binary is enough */\n }\n return { available: true, bin: r.display, version };\n },\n async listModels(): Promise {\n const r = await resolveProviderBin(cfg);\n if (!r.ok) return [{ id: \"default\", name: \"默认\", isDefault: true }];\n if (cfg.fetchModels) return cfg.fetchModels(r);\n return [{ id: \"default\", name: \"默认\", isDefault: true }];\n },\n async createSession(o) {\n return new AcpSession({\n threadId: o.threadId,\n providerId: cfg.id,\n providerName: cfg.name,\n cwd: o.cwd,\n model: o.model,\n mode: o.mode,\n effort: o.effort,\n resumeId: o.resumeId,\n binName: cfg.binName,\n binCandidates: cfg.binCandidates ?? [cfg.binName],\n acpArgs: cfg.argsFor ? await cfg.argsFor(o.model) : cfg.acpArgs,\n imagesFollowStartModel: cfg.imagesFollowStartModel,\n appVersion: cfg.appVersion ?? \"0.0.1\",\n flavor: cfg.flavor ?? \"generic\",\n modeMap: cfg.modeMap,\n cursorFast: o.cursorFast,\n cursorAutoReview: o.cursorAutoReview,\n launch: o.launch,\n });\n },\n };\n}\n","// dsh (DeepSeek Harness) over ACP: start it on the model the conversation uses, so it can take images.\n//\n// dsh decides at ACP initialize whether it accepts image blocks, from the route in its `acp` config row\n// (`dsh-acp`'s supportsAcpImagePrompts), not from the session's model. That row ships as\n// deepseek-official / deepseek-v4-flash, which isn't in dsh's own catalog (deepseek-flash is) and isn't a\n// route Magpie writes (Magpie writes `agent-default-model` and its `magpie*` routes, not the `acp` row).\n// So dsh 0.2.1-rc.5 / 0.2.2-alpha.2 answer promptCapabilities.image: false and then refuse every image\n// block (\"inline image prompts were not advertised by this connection\"), even on a model that sees images.\n//\n// The launcher takes `--patch ` overlays, applied after the profile and home patches. We start dsh\n// with one that sets the `acp` row to the conversation's route ([provider, model], the value dsh's ACP model\n// option uses). dsh then advertises image input exactly when that route declares it (a Magpie model it can\n// see images with is written as `input: [text, image]`) and passes the image to the model.\n// A model picked later in the same session goes by path: dsh's read_image tool opens it on a model that sees images.\nimport { createHash } from \"node:crypto\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport const DSH_ACP_ARGS = [\"--profile\", \"acp\"];\n\nexport type DshRoute = { provider: string; model: string };\n\n/** dsh's ACP model value is JSON [provider, model]; anything else isn't a route */\nexport function dshRoute(model: string | null | undefined): DshRoute | null {\n if (!model) return null;\n try {\n const v = JSON.parse(model) as unknown;\n if (Array.isArray(v) && v.length === 2 && typeof v[0] === \"string\" && typeof v[1] === \"string\" && v[0] && v[1]) {\n return { provider: v[0], model: v[1] };\n }\n } catch {\n /* not a dsh route */\n }\n return null;\n}\n\n/** The patch list that starts dsh's ACP on this route (JSON strings are valid YAML double-quoted scalars) */\nexport function dshRoutePatch(route: DshRoute): string {\n return [\n \"# Agent Console: the route dsh's ACP starts on, so initialize advertises image input for it\",\n \"- id: acp\",\n \" config:\",\n ` provider: ${JSON.stringify(route.provider)}`,\n ` model: ${JSON.stringify(route.model)}`,\n \"\",\n ].join(\"\\n\");\n}\n\n/** Arguments for `dsh`: the ACP profile, plus a --patch overlay naming the route when the model is one */\nexport async function dshAcpArgs(model: string | null | undefined, dir = join(tmpdir(), \"agent-console-dsh\")): Promise {\n const route = dshRoute(model);\n if (!route) return [...DSH_ACP_ARGS];\n const body = dshRoutePatch(route);\n const file = join(dir, `acp-route-${createHash(\"sha256\").update(body).digest(\"hex\").slice(0, 16)}.yml`);\n await mkdir(dir, { recursive: true });\n await writeFile(file, body, { mode: 0o600 });\n return [...DSH_ACP_ARGS, \"--patch\", file];\n}\n\n/** The route a new dsh conversation starts on, as the manager picks it: a Magpie route first, else dsh's current one */\nexport function dshDefaultModel(models: { id: string }[], current: string | null): string | null {\n return models.find((m) => /magpie/i.test(m.id))?.id ?? current ?? models[0]?.id ?? null;\n}\n","// ACP probe: start the Agent's ACP process once → initialize → session/new, read the models and thinking levels it reports, then close it.\n// Sends no prompt and costs nothing. This is the Agent's own model list (as Paseo does), more reliable than parsing CLI text.\nimport { tmpdir } from \"node:os\";\nimport type { ModelInfo } from \"../types.js\";\nimport type { ResolveResult } from \"../../util/bin.js\";\nimport { spawnResolved, killAcpChild } from \"./spawn.js\";\nimport { log } from \"../../log.js\";\nimport { promptCapsFromInitialize, rememberPromptCaps, type AcpPromptCaps } from \"./promptCaps.js\";\nimport { DSH_ACP_ARGS, dshAcpArgs, dshDefaultModel, dshRoute } from \"./dsh.js\";\n\ntype SelectOption = { value?: unknown; name?: unknown; options?: SelectOption[] };\ntype ConfigOption = { id?: string; category?: string; type?: string; currentValue?: unknown; options?: SelectOption[] };\n\nexport type AcpProbeResult = {\n models: { id: string; name: string }[];\n currentModel: string | null;\n thinking: { id: string; label: string }[];\n currentThinking: string | null;\n};\n\nfunction flatten(opts: SelectOption[] | undefined): { id: string; name: string }[] {\n const out: { id: string; name: string }[] = [];\n for (const o of opts ?? []) {\n if (o.options) out.push(...flatten(o.options));\n else if (typeof o.value === \"string\" && o.value) {\n out.push({ id: o.value, name: typeof o.name === \"string\" && o.name ? o.name : o.value });\n }\n }\n // Dedupe (Copilot reports auto twice)\n return out.filter((m, i) => out.findIndex((x) => x.id === m.id) === i);\n}\n\n/** Extract models and thinking levels from a session/new response (pure function, easy to test) */\nexport function parseAcpSession(res: { configOptions?: ConfigOption[]; models?: { currentModelId?: string; availableModels?: { modelId?: string; name?: string }[] } }): AcpProbeResult {\n const opts = res.configOptions ?? [];\n const modelOpt = opts.find((o) => o.type === \"select\" && (o.category === \"model\" || o.id === \"model\"));\n const thoughtOpt = opts.find((o) => o.type === \"select\" && (o.category === \"thought_level\" || o.id === \"thought_level\"));\n let models = flatten(modelOpt?.options);\n let currentModel = typeof modelOpt?.currentValue === \"string\" ? modelOpt.currentValue : null;\n if (!models.length && res.models?.availableModels?.length) {\n models = res.models.availableModels\n .filter((m) => typeof m.modelId === \"string\" && m.modelId)\n .map((m) => ({ id: m.modelId!, name: m.name || m.modelId! }));\n models = models.filter((m, i) => models.findIndex((x) => x.id === m.id) === i);\n currentModel = res.models.currentModelId ?? currentModel;\n }\n const thinking = flatten(thoughtOpt?.options).map((t) => ({ id: t.id, label: t.name }));\n const currentThinking = typeof thoughtOpt?.currentValue === \"string\" ? thoughtOpt.currentValue : null;\n return { models, currentModel, thinking, currentThinking };\n}\n\n/** providerId: remember what the Agent's initialize advertised for prompts (images) under this id */\nexport function probeAcp(r: ResolveResult & { ok: true }, acpArgs: string[], timeoutMs = 30000, providerId?: string): Promise {\n return probeAcpRaw(r, acpArgs, timeoutMs).then((x) => {\n rememberPromptCaps(providerId, x.promptCaps);\n return x.parsed;\n });\n}\n\n/** Like probeAcp, but also calls an Agent-specific method after session/new (e.g. Cursor's cursor/list_available_models) */\nexport function probeAcpRaw(\n r: ResolveResult & { ok: true },\n acpArgs: string[],\n timeoutMs = 30000,\n extMethod?: string,\n): Promise<{ parsed: AcpProbeResult; ext: unknown; promptCaps: AcpPromptCaps }> {\n return new Promise((resolvePromise, reject) => {\n const child = spawnResolved(r, acpArgs, { cwd: tmpdir() });\n let buf = \"\";\n let nextId = 0;\n const pending = new Map void; rej: (e: Error) => void }>();\n let done = false;\n const finish = (err: Error | null, v?: { parsed: AcpProbeResult; ext: unknown; promptCaps: AcpPromptCaps }) => {\n if (done) return;\n done = true;\n clearTimeout(timer);\n void killAcpChild(child).catch(() => {});\n if (err) reject(err);\n else resolvePromise(v!);\n };\n const timer = setTimeout(() => finish(new Error(\"ACP 探测超时\")), timeoutMs);\n const send = (method: string, params: unknown) =>\n new Promise((res, rej) => {\n const id = ++nextId;\n pending.set(id, { res, rej });\n child.stdin?.write(JSON.stringify({ jsonrpc: \"2.0\", id, method, params }) + \"\\n\");\n });\n child.on(\"error\", (e) => finish(e));\n child.on(\"close\", () => finish(new Error(\"ACP 进程提前退出\")));\n child.stderr?.on(\"data\", () => {});\n child.stdout?.on(\"data\", (d: Buffer) => {\n buf += d.toString();\n let n: number;\n while ((n = buf.indexOf(\"\\n\")) >= 0) {\n const line = buf.slice(0, n).trim();\n buf = buf.slice(n + 1);\n if (!line) continue;\n let m: { id?: number; method?: string; result?: unknown; error?: unknown };\n try {\n m = JSON.parse(line);\n } catch {\n continue;\n }\n if (m.id !== undefined && !m.method && pending.has(m.id)) {\n const p = pending.get(m.id)!;\n pending.delete(m.id);\n if (m.error) p.rej(new Error(JSON.stringify(m.error)));\n else p.res(m.result);\n } else if (m.id !== undefined && m.method) {\n // Requests from the Agent (shouldn't happen during a probe): always answer empty so it doesn't hang\n child.stdin?.write(JSON.stringify({ jsonrpc: \"2.0\", id: m.id, result: null }) + \"\\n\");\n }\n }\n });\n void (async () => {\n try {\n const init = await send(\"initialize\", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: \"Agent Console\", version: \"probe\" } });\n const res = (await send(\"session/new\", { cwd: tmpdir(), mcpServers: [] })) as Parameters[0];\n let ext: unknown = null;\n if (extMethod) ext = await send(extMethod, {}).catch(() => null);\n finish(null, { parsed: parseAcpSession(res ?? {}), ext, promptCaps: promptCapsFromInitialize(init) });\n } catch (e) {\n finish(e instanceof Error ? e : new Error(String(e)));\n }\n })();\n });\n}\n\n/** Probe result → model list: the current model is the default; thinking levels hang on the current model (other models' levels are only known after switching, so reuse the same set for now) */\nexport function probeToModels(p: AcpProbeResult): ModelInfo[] {\n return p.models.map((m) => ({\n id: m.id,\n name: m.name,\n isDefault: m.id === p.currentModel,\n ...(p.thinking.length\n ? { thinkingOptions: p.thinking, defaultThinking: p.currentThinking ?? undefined }\n : {}),\n }));\n}\n\nconst EFFORT_LABEL: Record = {\n none: \"None\", minimal: \"Minimal\", low: \"Low\", medium: \"Medium\", high: \"High\", xhigh: \"Extra High\", max: \"Max\",\n};\n\ntype CursorCatalog = {\n models?: { value?: string; name?: string; configOptions?: ConfigOption[] }[];\n};\n\n/**\n * Cursor: a model's thinking levels are not in the session-level thought_level but in each model's own parameter (effort / reasoning_effort / reasoning).\n * Following Paseo's resolveCursorCatalogModels: call Cursor's extension method cursor/list_available_models to get each model's parameter definitions.\n * The model id is the base name without parameters (grok-4.6); thinkingParam records the level parameter name, sent as grok-4.6[effort=high].\n */\nexport function cursorCatalogToModels(catalog: CursorCatalog, currentModel: string | null): ModelInfo[] {\n const currentBase = currentModel?.replace(/\\[.*\\]$/u, \"\") ?? null;\n const out: ModelInfo[] = [];\n for (const m of catalog.models ?? []) {\n if (typeof m.value !== \"string\" || !m.value) continue;\n const id = m.value.replace(/\\[.*\\]$/u, \"\");\n const thought = (m.configOptions ?? []).find((o) => o.category === \"thought_level\");\n const levels = flatten(thought?.options);\n out.push({\n id,\n name: typeof m.name === \"string\" && m.name ? m.name : id,\n isDefault: id === currentBase,\n ...(thought && levels.length\n ? {\n thinkingOptions: levels.map((l) => ({ id: l.id, label: EFFORT_LABEL[l.id] ?? l.name })),\n defaultThinking: typeof thought.currentValue === \"string\" ? thought.currentValue : undefined,\n thinkingParam: thought.id,\n }\n : {}),\n });\n }\n return out;\n}\n\nexport function cursorProbeModels(acpArgs: string[], providerId?: string) {\n return async (r: ResolveResult & { ok: true }): Promise => {\n try {\n const { parsed, ext, promptCaps } = await probeAcpRaw(r, acpArgs, 40000, \"cursor/list_available_models\");\n rememberPromptCaps(providerId, promptCaps);\n const fromCatalog = ext ? cursorCatalogToModels(ext as CursorCatalog, parsed.currentModel) : [];\n // If the extension method is unavailable (older Cursor CLI), fall back to the model list reported by the session (no levels)\n return fromCatalog.length ? fromCatalog : probeToModels(parsed);\n } catch (e) {\n log(\"warn\", \"Cursor 模型探测失败\", { err: String(e) });\n return [];\n }\n };\n}\n\n/**\n * dsh: the model list from a plain `dsh --profile acp`, then whether it takes images from an initialize started on the\n * route a new conversation uses (dsh answers for the route it starts on, see dsh.ts).\n */\nexport function dshProbeModels(providerId?: string) {\n return async (r: ResolveResult & { ok: true }): Promise => {\n try {\n const first = await probeAcpRaw(r, DSH_ACP_ARGS, 30000);\n let caps = first.promptCaps;\n const start = dshDefaultModel(first.parsed.models, first.parsed.currentModel);\n if (start && start !== first.parsed.currentModel && dshRoute(start)) {\n caps = await probeAcpRaw(r, await dshAcpArgs(start), 30000).then((x) => x.promptCaps, () => caps);\n }\n rememberPromptCaps(providerId, caps);\n return probeToModels(first.parsed);\n } catch (e) {\n log(\"warn\", \"ACP 探测模型失败\", { err: String(e) });\n return [];\n }\n };\n}\n\nexport function acpProbeModels(acpArgs: string[], providerId?: string) {\n return async (r: ResolveResult & { ok: true }): Promise => {\n try {\n return probeToModels(await probeAcp(r, acpArgs, 30000, providerId));\n } catch (e) {\n log(\"warn\", \"ACP 探测模型失败\", { err: String(e) });\n return [];\n }\n };\n}\n","import type { ProviderCatalogEntry } from \"../catalog.js\";\nimport type { AcpProviderConfig } from \"./provider.js\";\nimport { cursorAcpCapabilities } from \"./provider.js\";\nimport { acpProbeModels, cursorProbeModels, dshProbeModels } from \"./probe.js\";\nimport { DSH_ACP_ARGS, dshAcpArgs } from \"./dsh.js\";\n\nconst ACP_IDS = new Set([\"cursor\", \"devin\", \"opencode\", \"dsh\", \"pi\", \"copilot\"]);\n\nexport function isAcpCatalogId(id: string): boolean {\n return ACP_IDS.has(id);\n}\n\n// Model lists and thinking levels always come from what the Agent reports over ACP (acpProbeModels), no longer from parsing each CLI's text output.\n// modeMap: our permission modes → the Agent's real mode names (from what session/new actually reports); \"\" means keep the entry but send nothing.\nexport function acpConfigForEntry(entry: ProviderCatalogEntry, appVersion: string): AcpProviderConfig | null {\n if (!ACP_IDS.has(entry.id)) return null;\n const base: Omit = {\n id: entry.id,\n name: entry.name,\n binName: entry.bins[0] ?? entry.id,\n binCandidates: entry.bins,\n installHint: entry.installHint,\n appVersion,\n };\n\n switch (entry.id) {\n case \"cursor\":\n // Modes: agent / plan / ask (see cursorAcpCapabilities)\n return {\n ...base,\n acpArgs: [\"acp\"],\n flavor: \"cursor\",\n capabilities: cursorAcpCapabilities,\n // Each model's own thinking levels come from cursor/list_available_models (as Paseo does)\n fetchModels: cursorProbeModels([\"acp\"], entry.id),\n };\n case \"devin\":\n // Modes: Code(accept-edits) / Smart / Ask / Plan / Bypass Permissions\n return {\n ...base,\n acpArgs: [\"acp\"],\n fetchModels: acpProbeModels([\"acp\"], entry.id),\n modeMap: { plan: \"plan\", default: \"ask\", acceptEdits: \"accept-edits\", bypassPermissions: \"bypass\" },\n modeLabels: { plan: \"Plan\", default: \"Ask\", acceptEdits: \"Code\", bypassPermissions: \"Bypass Permissions\" },\n };\n case \"opencode\":\n // Modes: build / plan; thinking level effort (low…ultra, including fast variants)\n return {\n ...base,\n acpArgs: [\"acp\"],\n fetchModels: acpProbeModels([\"acp\"], entry.id),\n modeMap: { plan: \"plan\", default: \"build\" },\n modeLabels: { plan: \"Plan\", default: \"Build\" },\n };\n case \"copilot\":\n // As Paseo: Agent / Plan / Allow All. Allow All = agent mode + allow_all config on (autopilot is not used)\n return {\n ...base,\n acpArgs: [\"--acp\"],\n fetchModels: acpProbeModels([\"--acp\"], entry.id),\n modeMap: { plan: \"plan\", default: \"agent\", bypassPermissions: \"allow-all\" },\n modeLabels: { plan: \"Plan\", default: \"Agent\", bypassPermissions: \"Allow All\" },\n };\n case \"pi\":\n // pi-acp has no mode options; thinking level thought_level (low…max)\n return { ...base, acpArgs: [], fetchModels: acpProbeModels([], entry.id), modeMap: { default: \"\" } };\n case \"dsh\":\n // Started on the conversation's route (--patch), so its initialize advertises image input for that model (see dsh.ts)\n return {\n ...base,\n acpArgs: DSH_ACP_ARGS,\n argsFor: (model) => dshAcpArgs(model),\n imagesFollowStartModel: true,\n fetchModels: dshProbeModels(entry.id),\n };\n default:\n return null;\n }\n}\n","// Provider registry: 8 in the catalog; claude/codex native, the rest over ACP (including copilot --acp).\nimport type { Provider } from \"./types.js\";\nimport { createMockProvider } from \"./mock.js\";\nimport { createClaudeProvider } from \"./claude/provider.js\";\nimport { createCodexProvider } from \"./codex/provider.js\";\nimport { PROVIDER_CATALOG } from \"./catalog.js\";\nimport { createAcpProvider } from \"./acp/provider.js\";\nimport { acpConfigForEntry } from \"./acp/specs.js\";\n\nconst providers = new Map();\n\nexport function registerProvider(p: Provider): void {\n providers.set(p.id, p);\n}\n\nexport function getProvider(id: string): Provider | undefined {\n return providers.get(id);\n}\n\nexport function listProviders(): Provider[] {\n return [...providers.values()];\n}\n\nexport function initProviders(version: string = \"\"): void {\n providers.clear();\n for (const entry of PROVIDER_CATALOG) {\n if (entry.id === \"claude\") registerProvider(createClaudeProvider());\n else if (entry.id === \"codex\") registerProvider(createCodexProvider(version));\n else {\n const acp = acpConfigForEntry(entry, version);\n if (acp) registerProvider(createAcpProvider(acp));\n }\n }\n if (process.env.AGENT_CONSOLE_MOCK === \"1\") {\n registerProvider(createMockProvider());\n }\n}\n","// Whether the user enabled this Provider in settings.\n// Never toggled by hand: catalog status=verified is on by default, others off; entries outside the catalog (mock etc.) are on.\nimport type { Settings } from \"../store/settings.js\";\nimport { catalogById } from \"./catalog.js\";\n\nexport function isProviderEnabled(settings: Settings, id: string): boolean {\n const m = settings.providerEnabled;\n if (m && typeof m === \"object\" && typeof m[id] === \"boolean\") return m[id];\n const entry = catalogById(id);\n if (!entry) return true;\n return entry.status === \"verified\";\n}\n","import { execFile } from \"node:child_process\";\n\n/** Parse PowerShell stdout into a folder path (handles Base64 and CP936 consoles) */\nexport function decodePickFolderStdout(stdout: string | Buffer): string | null {\n const buf = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout, \"utf8\");\n const line = buf.toString(\"utf8\").trim().split(/\\r?\\n/).pop()?.trim() ?? \"\";\n if (!line) return null;\n\n // New format: Base64 of the UTF-8 path, so a GBK-encoded Chinese Windows console isn't garbled when Node decodes it as UTF-8\n if (/^[A-Za-z0-9+/]+=*$/.test(line)) {\n const decoded = Buffer.from(line, \"base64\").toString(\"utf8\");\n if (decoded && !decoded.includes(\"\\uFFFD\")) return decoded;\n }\n\n if (!line.includes(\"\\uFFFD\")) return line;\n\n const gbkLine = new TextDecoder(\"gbk\").decode(buf).trim().split(/\\r?\\n/).pop()?.trim() ?? \"\";\n return gbkLine || null;\n}\n\nexport function pickFolderWindows(initial?: string): Promise<{ available: boolean; path: string | null }> {\n return new Promise((resolvePromise) => {\n if (process.platform !== \"win32\") return resolvePromise({ available: false, path: null });\n const init = (initial ?? \"\").replace(/'/g, \"''\");\n const script =\n \"Add-Type -AssemblyName System.Windows.Forms;\" +\n \"$d = New-Object System.Windows.Forms.FolderBrowserDialog;\" +\n \"$d.Description = '选择项目文件夹';\" +\n \"$d.ShowNewFolderButton = $true;\" +\n (init ? `$d.SelectedPath = '${init}';` : \"\") +\n \"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { \" +\n \"[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($d.SelectedPath)) \" +\n \"}\";\n execFile(\n \"powershell.exe\",\n [\"-NoProfile\", \"-STA\", \"-NonInteractive\", \"-Command\", script],\n { timeout: 120000, windowsHide: false, encoding: \"buffer\" },\n (e, stdout) => {\n if (e) return resolvePromise({ available: true, path: null });\n resolvePromise({ available: true, path: decodePickFolderStdout(stdout ?? Buffer.alloc(0)) });\n },\n );\n });\n}\n","// Native folder picker: Windows uses PowerShell FolderBrowserDialog (pickFolderWin.ts),\n// macOS osascript's choose folder, Linux zenity / kdialog when present; with none, report unavailable and the frontend falls back to typing.\n// Everything starts processes directly via execFile, no shell; the initial directory is passed only as an argument or an escaped AppleScript string.\nimport { execFile } from \"node:child_process\";\nimport { existsSync, statSync } from \"node:fs\";\nimport { findOnPath } from \"../../../lib/pathscan.mjs\";\nimport { pickFolderWindows } from \"./pickFolderWin.js\";\n\nexport type PickResult = { available: boolean; path: string | null };\nconst TIMEOUT = 10 * 60 * 1000; // The user may keep the dialog open for a while\n\n/** AppleScript string literal: only backslashes and double quotes need escaping. */\nexport function appleScriptString(s: string): string {\n return '\"' + s.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"') + '\"';\n}\n\nfunction isDir(p: string | undefined): p is string {\n try {\n return !!p && existsSync(p) && statSync(p).isDirectory();\n } catch {\n return false;\n }\n}\n\n/** Command-line arguments (for testability). Returns null when the platform has no picker. */\nexport function pickFolderCommand(\n platform: NodeJS.Platform,\n initial: string | undefined,\n find: (name: string) => string | null = (n) => findOnPath(n)[0] ?? null,\n env: NodeJS.ProcessEnv = process.env,\n): { cmd: string; args: string[] } | null {\n const init = isDir(initial) ? initial : undefined;\n if (platform === \"darwin\") {\n const prompt = appleScriptString(\"选择项目文件夹 / Choose a project folder\");\n const loc = init ? ` default location (POSIX file ${appleScriptString(init)})` : \"\";\n return { cmd: \"/usr/bin/osascript\", args: [\"-e\", `POSIX path of (choose folder with prompt ${prompt}${loc})`] };\n }\n if (platform === \"linux\" || platform === \"freebsd\" || platform === \"openbsd\") {\n // No graphical session (magpie web on a server): no window can open, so the frontend asks for a typed path\n if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return null;\n const zenity = find(\"zenity\");\n if (zenity) {\n const args = [\"--file-selection\", \"--directory\", \"--title=选择项目文件夹 / Choose a project folder\"];\n if (init) args.push(`--filename=${init.endsWith(\"/\") ? init : init + \"/\"}`);\n return { cmd: zenity, args };\n }\n const kdialog = find(\"kdialog\");\n if (kdialog) return { cmd: kdialog, args: [\"--getexistingdirectory\", init ?? env.HOME ?? \"/\"] };\n }\n return null;\n}\n\nexport function pickFolder(initial?: string): Promise {\n if (process.platform === \"win32\") return pickFolderWindows(initial);\n const c = pickFolderCommand(process.platform, initial);\n if (!c) return Promise.resolve({ available: false, path: null });\n return new Promise((resolvePromise) => {\n execFile(c.cmd, c.args, { timeout: TIMEOUT, encoding: \"utf8\" }, (e, stdout) => {\n // Cancel: osascript / zenity / kdialog all exit non-zero\n if (e) return resolvePromise({ available: true, path: null });\n let p = String(stdout ?? \"\").replace(/\\r?\\n$/, \"\").trim();\n // osascript's POSIX path ends with / (except for the root)\n if (p.length > 1) p = p.replace(/\\/+$/, \"\");\n resolvePromise({ available: true, path: p || null });\n }).on(\"error\", () => resolvePromise({ available: false, path: null }));\n });\n}\n","// magpie gateway: magpie points each Agent's models at the local gateway (applied.json records the model currently applied to each).\n// Here we fill in, for those Agents, the model list, the default model and each model's thinking levels. Read-only; no configuration is changed.\nimport { readFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { ModelInfo } from \"./types.js\";\nimport { log } from \"../log.js\";\n\n/** Agent Console provider id → the key in magpie's applied.json + the model id prefix used inside that Agent */\nconst MAGPIE_TOOLS: Record = {\n claude: { key: \"claude\", prefix: \"\" },\n codex: { key: \"codex\", prefix: \"\" },\n opencode: { key: \"opencode\", prefix: \"magpie/\" },\n pi: { key: \"pi\", prefix: \"magpie/\" },\n dsh: { key: \"dsh\", prefix: \"magpie/\" },\n};\n\nconst EFFORT_LABEL: Record = {\n low: \"Low\", medium: \"Medium\", high: \"High\", xhigh: \"Extra High\", max: \"Max\",\n};\n\ntype Applied = Record;\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readApplied(): Promise {\n return (await readJson(join(homedir(), \".config\", \"magpie\", \"applied.json\"))) ?? {};\n}\n\nasync function readClaudeEnv(): Promise> {\n const s = await readJson<{ env?: Record }>(join(homedir(), \".claude\", \"settings.json\"));\n return s?.env ?? {};\n}\n\n/** Gateway address: ANTHROPIC_BASE_URL from the Claude config (written by magpie), otherwise the default 3425 */\nasync function gatewayBase(): Promise {\n const env = await readClaudeEnv();\n const raw = env.ANTHROPIC_BASE_URL?.trim();\n return (raw && /^https?:\\/\\/(127\\.0\\.0\\.1|localhost)(:\\d+)?/i.test(raw) ? raw : \"http://127.0.0.1:3425\").replace(/\\/+$/, \"\");\n}\n\nasync function fetchGatewayModelIds(): Promise {\n const base = await gatewayBase();\n try {\n const res = await fetch(`${base}/v1/models`, { signal: AbortSignal.timeout(5000) });\n if (!res.ok) return [];\n const j = (await res.json()) as { data?: { id?: unknown }[] };\n return (j.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === \"string\" && !!id);\n } catch (e) {\n log(\"warn\", \"读取 magpie 网关模型列表失败\", { err: String(e) });\n return [];\n }\n}\n\n/** CLAUDE_CODE_MODEL_CAPABILITIES: `id=effort,xhigh_effort,max_effort;id2=effort` → each model's levels */\nexport function parseCapabilities(raw: string | undefined): Map {\n const out = new Map();\n for (const part of (raw ?? \"\").split(\";\")) {\n const i = part.indexOf(\"=\");\n if (i <= 0) continue;\n const flags = new Set(part.slice(i + 1).split(\",\").map((s) => s.trim()));\n if (!flags.has(\"effort\")) continue;\n const levels = [\"low\", \"medium\", \"high\"];\n if (flags.has(\"xhigh_effort\")) levels.push(\"xhigh\");\n if (flags.has(\"max_effort\")) levels.push(\"max\");\n out.set(part.slice(0, i).trim(), levels);\n }\n return out;\n}\n\nfunction isPlaceholder(m: ModelInfo): boolean {\n return !m.id || /^(default|auto)$/i.test(m.id);\n}\n\n/**\n * Fill in model info for Agents managed by magpie; if magpie doesn't manage this Agent (not in applied.json) return it unchanged.\n * - The Agent's own list knows magpie models (e.g. OpenCode, Codex): keep its list with magpie's first (only what it knows, so picks never fail)\n * - The Agent can't report models (Claude, Pi, dsh): use the gateway's /v1/models with the prefix that Agent uses\n * - Default model = the model magpie currently applies to it; thinking levels from magpie's capability table\n */\nexport async function withMagpie(providerId: string, own: ModelInfo[]): Promise {\n const tool = MAGPIE_TOOLS[providerId];\n if (!tool) return own;\n const applied = (await readApplied())[tool.key]?.fields;\n if (!applied?.model) return own;\n\n const caps = parseCapabilities((await readClaudeEnv()).CLAUDE_CODE_MODEL_CAPABILITIES);\n const real = own.filter((m) => !isPlaceholder(m));\n\n // Over ACP, DSH returns its own real model IDs (JSON-array strings).\n // We must not push an old / another Agent's model ID from applied.json into DSH,\n // otherwise DSH treats it as a routed model and sends it to the wrong provider.\n if (providerId === \"dsh\") {\n const appliedId = applied.model.replace(/^magpie\\//, \"\");\n const selected = real.find((m) =>\n m.id === applied.model ||\n m.id === appliedId ||\n (appliedId.includes(\"/\") && m.id.includes(appliedId)),\n );\n const fallback = selected ?? real.find((m) => m.isDefault) ?? real[0];\n return real.map((m) => ({ ...m, isDefault: m.id === fallback?.id }));\n }\n\n const knowsMagpie = tool.prefix ? real.some((m) => m.id.startsWith(tool.prefix)) : real.length > 0;\n\n let list: ModelInfo[];\n if (knowsMagpie) {\n const mine = real.filter((m) => !tool.prefix || m.id.startsWith(tool.prefix));\n const others = tool.prefix ? real.filter((m) => !m.id.startsWith(tool.prefix)) : [];\n list = [...mine, ...others];\n } else {\n list = (await fetchGatewayModelIds()).map((gid) => ({ id: tool.prefix + gid, name: gid }));\n }\n\n // Default model = the one magpie currently applies; if it's not in the list, add it at the top\n const defId = applied.model;\n if (!list.some((m) => m.id === defId)) list.unshift({ id: defId, name: defId.slice(tool.prefix.length) || defId });\n list = list.map((m) => ({ ...m, isDefault: m.id === defId }));\n list.sort((a, b) => Number(!!b.isDefault) - Number(!!a.isDefault));\n\n // Thinking levels: use the Agent's own if it reports them, otherwise magpie's capability table\n return list.map((m) => {\n const name = tool.prefix && m.name.startsWith(tool.prefix) ? m.name.slice(tool.prefix.length) : m.name;\n if (m.thinkingOptions?.length && providerId !== \"claude\") return { ...m, name };\n const levels = caps.get(m.id.slice(tool.prefix.length) || m.id);\n if (!levels) return { ...m, name, thinkingOptions: providerId === \"claude\" ? [] : m.thinkingOptions };\n const def = applied.effort && levels.includes(applied.effort) ? applied.effort : levels.includes(\"high\") ? \"high\" : levels[0];\n return {\n ...m,\n name,\n thinkingOptions: levels.map((id) => ({ id, label: EFFORT_LABEL[id] ?? id })),\n defaultThinking: m.isDefault ? def : m.defaultThinking ?? def,\n };\n });\n}\n","// In-memory cache of Provider detection results and model lists.\n// detect / listModels often have to start a CLI (seconds); without a cache every page open and model pick would wait.\nimport type { Provider, DetectResult, ModelInfo } from \"./types.js\";\nimport { withMagpie } from \"./magpieGateway.js\";\n\nconst DETECT_MS = 60 * 1000;\nconst MODELS_MS = 10 * 60 * 1000;\nconst STEP_TIMEOUT_MS = 15000;\n// Fetching models may start an ACP session (Cursor launches slowly through PowerShell), so it gets a longer timeout\nconst MODELS_TIMEOUT_MS = 35000;\n\ntype Entry = { at: number; value: T } | undefined;\nconst detects = new Map>();\nconst models = new Map>();\n// Concurrent requests for the same Provider share one in-flight detection\nconst inflightDetect = new Map>();\nconst inflightModels = new Map>();\n\nfunction withTimeout(p: Promise, ms: number, fb: T): Promise {\n return Promise.race([p, new Promise((r) => setTimeout(() => r(fb), ms))]);\n}\n\nfunction fresh(e: Entry, ttl: number): e is { at: number; value: T } {\n return !!e && Date.now() - e.at < ttl;\n}\n\nexport async function cachedDetect(p: Provider): Promise {\n const hit = detects.get(p.id);\n if (fresh(hit, DETECT_MS)) return hit.value;\n let job = inflightDetect.get(p.id);\n if (!job) {\n job = withTimeout(\n p.detect().catch((e): DetectResult => ({ available: false, reason: String((e as Error)?.message ?? e) })),\n STEP_TIMEOUT_MS,\n { available: false, reason: \"检测超时\" },\n ).then((v) => {\n // Don't cache a timeout; try again next time\n if (v.reason !== \"检测超时\") detects.set(p.id, { at: Date.now(), value: v });\n return v;\n }).finally(() => inflightDetect.delete(p.id));\n inflightDetect.set(p.id, job);\n }\n return job;\n}\n\nexport async function cachedModels(p: Provider): Promise {\n const hit = models.get(p.id);\n if (fresh(hit, MODELS_MS)) return hit.value;\n let job = inflightModels.get(p.id);\n if (!job) {\n job = withTimeout(p.listModels().catch(() => []), MODELS_TIMEOUT_MS, null)\n .then(async (v) => {\n // Timeout: don't cache, fall back to the old value for now\n if (v === null) return models.get(p.id)?.value ?? [];\n // Agents behind the magpie gateway: fill in gateway models, the default model and thinking levels\n const list = await withMagpie(p.id, dropPlaceholder(v)).catch(() => dropPlaceholder(v));\n models.set(p.id, { at: Date.now(), value: list });\n return list;\n })\n .finally(() => inflightModels.delete(p.id));\n inflightModels.set(p.id, job);\n }\n return job;\n}\n\n/** A list holding only our own \"Default\" placeholder (id=default, name \"默认\") counts as having no real models.\n * An auto / Auto the Agent itself reports (e.g. Copilot) is a real option and is kept. */\nexport function dropPlaceholder(list: ModelInfo[]): ModelInfo[] {\n if (list.length === 1 && list[0].id === \"default\" && list[0].name === \"默认\") return [];\n return list;\n}\n\n/** Return the cached value immediately if there is one (may be stale), else undefined */\nexport function peekModels(id: string): ModelInfo[] | undefined {\n return models.get(id)?.value;\n}\n\nexport function clearProviderCache(id?: string): void {\n if (id) {\n detects.delete(id);\n models.delete(id);\n } else {\n detects.clear();\n models.clear();\n }\n}\n","// Method registry: zod-validated params → call the service → return. P0-5 skeleton; filled in from P1 on.\nimport { z } from \"zod\";\nimport type { Gateway, ConnCtx } from \"./gateway.js\";\nimport type { Config } from \"../config.js\";\nimport type { SettingsStore } from \"../store/settings.js\";\nimport type { ProjectsStore } from \"../store/projects.js\";\nimport type { ThreadsStore } from \"../store/threads.js\";\nimport { listProviders, getProvider } from \"../providers/registry.js\";\nimport { PROVIDER_CATALOG, catalogById, providerCreatable } from \"../providers/catalog.js\";\nimport { isProviderEnabled } from \"../providers/enabled.js\";\nimport type { SlashCommand } from \"../providers/types.js\";\nimport { GitService } from \"../git/service.js\";\nimport { FileService } from \"../files/service.js\";\nimport type { TerminalService } from \"../terminal/service.js\";\nimport { existsSync, statSync } from \"node:fs\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, win32 as win32Path } from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { pickFolder } from \"../util/pickFolder.js\";\nimport { clearBinCache } from \"../util/bin.js\";\nimport { cachedDetect, cachedModels, clearProviderCache } from \"../providers/infoCache.js\";\n\nconst threadModeSchema = z.enum([\"plan\", \"default\", \"acceptEdits\", \"bypassPermissions\", \"ask\"]);\n\nexport type Services = {\n cfg: Config;\n settings: SettingsStore;\n gateway: Gateway;\n projects: ProjectsStore;\n threads: ThreadsStore;\n git: GitService;\n files: FileService;\n terminals: TerminalService;\n // AgentManager provides runtime info once wired in at P1-4; may be empty (no session started)\n runtime?: (threadId: string) => { status: string; queue: unknown[]; pending: unknown[] } | null;\n manager?: {\n send(threadId: string, input: { text: string; attachments?: { path: string; mime: string; name?: string }[] }, behavior?: \"queue\" | \"steer\"): Promise;\n interrupt(threadId: string): Promise;\n respond(threadId: string, requestId: string, answer: { optionId?: string; answers?: Record; cancelled?: boolean }): Promise;\n setModel(threadId: string, model: string | null): Promise;\n setMode(threadId: string, mode: \"plan\" | \"default\" | \"acceptEdits\" | \"bypassPermissions\" | \"ask\"): Promise;\n setEffort(threadId: string, effort: string | null): Promise;\n setCursorOpts(threadId: string, patch: { cursorFast?: boolean; cursorAutoReview?: boolean }): Promise;\n setProvider(threadId: string, providerId: string): Promise;\n close(threadId: string): Promise;\n deleteThread(threadId: string): Promise;\n listCommands(threadId: string, opts?: { load?: boolean }): Promise;\n };\n};\n\nexport class ApiError extends Error {\n code: string;\n constructor(code: string, message: string) {\n super(message);\n this.code = code;\n }\n}\n\nfunction wrap(schema: T, fn: (p: z.infer, ctx: ConnCtx) => unknown) {\n return (params: unknown, ctx: ConnCtx) => {\n const r = schema.safeParse(params ?? {});\n if (!r.success) {\n throw new ApiError(\"bad_params\", \"参数不对:\" + r.error.issues.map((i) => i.message).join(\";\"));\n }\n return fn(r.data, ctx);\n };\n}\n\nexport function registerMethods(gw: Gateway, svc: Services): void {\n const zEmpty = z.object({}).passthrough().optional();\n\n gw.method(\"app.info\", wrap(zEmpty, () => ({\n version: svc.cfg.version,\n dataDir: svc.cfg.dataDir,\n pid: process.pid,\n port: svc.cfg.port,\n })));\n\n gw.method(\"app.shutdown\", wrap(zEmpty, () => {\n setTimeout(() => process.exit(0), 100);\n return { ok: true };\n }));\n\n gw.method(\"settings.get\", wrap(zEmpty, () => svc.settings.get()));\n\n gw.method(\"settings.set\", wrap(z.object({ patch: z.record(z.string(), z.unknown()) }), (p) =>\n svc.settings.set(p.patch)\n ));\n\n gw.method(\"sub.add\", wrap(\n z.object({ ch: z.string().min(1).max(120), afterSeq: z.number().int().nonnegative().optional() }),\n async (p, ctx) => {\n svc.gateway.subscribe(ctx.conn, p.ch);\n // The thread: channel replays persisted events after afterSeq (late subscribers / reconnects lose nothing)\n const m = /^thread:(.+)$/.exec(p.ch);\n if (m && p.afterSeq != null) {\n const events = await svc.threads.readEvents(m[1], p.afterSeq, 5000).catch(() => []);\n for (const ev of events) ctx.conn.send({ t: \"evt\", ch: p.ch, d: { type: \"event\", event: ev } });\n }\n return { ok: true };\n }\n ));\n\n gw.method(\"sub.remove\", wrap(z.object({ ch: z.string().min(1).max(120) }), (p, ctx) => {\n svc.gateway.unsubscribe(ctx.conn, p.ch);\n return { ok: true };\n }));\n\n // ---- providers.list (§6): detection + model list + capabilities ----\n // detect/listModels in parallel; 8s total per provider (listModels may start a temporary process).\n gw.method(\"providers.list\", wrap(\n z.object({ refresh: z.boolean().optional() }).passthrough().optional(),\n async (p) => {\n if (p?.refresh) {\n clearBinCache();\n clearProviderCache();\n }\n const settings = await svc.settings.get();\n const rows = await Promise.all(\n listProviders().map(async (p) => {\n const entry = catalogById(p.id);\n const enabled = isProviderEnabled(settings, p.id);\n // Disabled / not-integrated ones are neither detected nor asked for models (saves seconds of CLI startup)\n const skip = !enabled || entry?.status === \"planned\";\n const det: import(\"../providers/types.js\").DetectResult = skip\n ? { available: false, reason: !enabled ? \"已在设置中禁用\" : \"尚未接入\" }\n : await cachedDetect(p);\n const models = !skip && det.available ? await cachedModels(p) : [];\n return {\n id: p.id,\n name: p.name,\n available: det.available,\n enabled,\n status: entry?.status ?? \"verified\",\n ready: entry?.status === \"verified\",\n bin: det.bin,\n version: det.version,\n reason: !enabled ? \"已在设置中禁用\" : det.reason,\n installHint: det.installHint,\n capabilities: p.capabilities,\n models,\n };\n }),\n );\n const byId = new Map(rows.map((r) => [r.id, r]));\n const ordered = PROVIDER_CATALOG.map((e) => byId.get(e.id)).filter(Boolean) as typeof rows;\n for (const r of rows) {\n if (!PROVIDER_CATALOG.some((e) => e.id === r.id)) ordered.push(r);\n }\n return ordered;\n }\n ));\n\n // ---- projects.* ----\n gw.method(\"projects.list\", wrap(zEmpty, () => svc.projects.list()));\n\n gw.method(\"projects.create\", wrap(\n z.object({ path: z.string().min(1), name: z.string().optional() }),\n async (p) => {\n const proj = await svc.projects.create(p.path, p.name);\n // Record recent directories (deduped, case-insensitive, at most 12)\n const s = await svc.settings.get();\n const dirs = (s.recentDirs ?? []).filter((d) => d.toLowerCase() !== p.path.toLowerCase());\n await svc.settings.set({ recentDirs: [p.path, ...dirs].slice(0, 12) });\n gw.publish(\"sidebar\", { type: \"project.upsert\", project: proj });\n return proj;\n }\n ));\n\n gw.method(\"projects.rename\", wrap(z.object({ id: z.string(), name: z.string() }), async (p) => {\n const proj = await svc.projects.rename(p.id, p.name);\n gw.publish(\"sidebar\", { type: \"project.upsert\", project: proj });\n return proj;\n }));\n\n gw.method(\"projects.remove\", wrap(z.object({ id: z.string() }), async (p) => {\n const proj = await svc.projects.get(p.id);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n // Move all of the project's conversations to the trash\n for (const t of await svc.threads.list()) {\n if (t.projectId === p.id) {\n await svc.threads.delete(t.id);\n gw.publish(\"sidebar\", { type: \"thread.remove\", id: t.id });\n }\n }\n await svc.projects.remove(p.id);\n gw.publish(\"sidebar\", { type: \"project.remove\", id: p.id });\n return { ok: true };\n }));\n\n gw.method(\"projects.open\", wrap(z.object({ id: z.string() }), async (p) => {\n const proj = await svc.projects.get(p.id);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n if (!existsSync(proj.path)) throw new ApiError(\"bad_path\", \"目录不存在或不是文件夹\");\n const cmd = process.platform === \"win32\" ? \"explorer.exe\" : process.platform === \"darwin\" ? \"open\" : \"xdg-open\";\n // explorer.exe doesn't understand forward-slash paths (it opens \"Documents\"); a missing xdg-open must not make the daemon throw either\n const target = process.platform === \"win32\" ? win32Path.normalize(proj.path) : proj.path;\n execFile(cmd, [target], { windowsHide: true }, () => {}).on(\"error\", () => {});\n return { ok: true };\n }));\n\n // ---- fs.* ----\n gw.method(\"fs.check\", wrap(z.object({ path: z.string().min(1) }), (p) => {\n let exists = false;\n let isDir = false;\n try {\n exists = existsSync(p.path);\n isDir = exists && statSync(p.path).isDirectory();\n } catch {}\n return { exists, isDir };\n }));\n\n gw.method(\"fs.recentDirs\", wrap(zEmpty, async () => {\n const s = await svc.settings.get();\n return (s.recentDirs ?? []).slice(0, 12);\n }));\n\n // fs.pickFolder: show the native folder picker (the daemon runs locally): Windows PowerShell, macOS osascript,\n // Linux zenity/kdialog; with none, report unavailable and the frontend falls back to typing.\n gw.method(\"fs.pickFolder\", wrap(z.object({ initial: z.string().optional() }).passthrough().optional(), (p) =>\n pickFolder(p?.initial),\n ));\n\n // ---- threads.* ----\n gw.method(\"threads.list\", wrap(zEmpty, () => svc.threads.list()));\n\n gw.method(\"threads.create\", wrap(\n z.object({\n projectId: z.string().min(1),\n provider: z.string().min(1),\n model: z.string().nullable().optional(),\n mode: threadModeSchema.optional(),\n effort: z.string().nullable().optional(),\n title: z.string().optional(),\n providerSessionId: z.string().nullable().optional(),\n importedFrom: z.object({ provider: z.string(), sessionId: z.string() }).optional(),\n }),\n async (p) => {\n const proj = await svc.projects.get(p.projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n if (!existsSync(proj.path)) throw new ApiError(\"bad_path\", \"项目目录已不存在\");\n // New conversations must use an enabled Agent (except imported sessions, which already ran)\n if (!p.providerSessionId && !isProviderEnabled(await svc.settings.get(), p.provider)) {\n throw new ApiError(\"bad_params\", \"该 Agent 已在设置中禁用\");\n }\n const cat = catalogById(p.provider);\n if (!p.providerSessionId && cat && !providerCreatable(cat.status)) {\n throw new ApiError(\"bad_params\", `${cat.name} 尚未接入,暂不能新建对话`);\n }\n const meta = await svc.threads.create(p);\n gw.publish(\"sidebar\", { type: \"thread.upsert\", thread: meta });\n return meta;\n }\n ));\n\n gw.method(\"threads.get\", wrap(\n z.object({ id: z.string(), afterSeq: z.number().int().nonnegative().optional() }),\n async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n const events = await svc.threads.readEvents(p.id, p.afterSeq ?? 0, 5000);\n const rt = svc.runtime?.(p.id) ?? null;\n return {\n meta,\n events,\n queue: rt?.queue ?? [],\n pending: rt?.pending ?? [],\n runtimeStatus: rt?.status ?? meta.status,\n };\n }\n ));\n\n gw.method(\"threads.update\", wrap(\n z.object({\n id: z.string(),\n title: z.string().optional(),\n pinned: z.boolean().optional(),\n archived: z.boolean().optional(),\n }),\n async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n const patch: Record = {};\n if (p.title !== undefined) {\n patch.title = p.title.trim() || meta.title;\n patch.titleAuto = false; // Once renamed by hand, never auto-overwrite\n }\n if (p.pinned !== undefined) patch.pinned = p.pinned;\n if (p.archived !== undefined) patch.archived = p.archived;\n const next = await svc.threads.patch(p.id, patch);\n gw.publish(\"sidebar\", { type: \"thread.upsert\", thread: next });\n return next;\n }\n ));\n\n gw.method(\"threads.delete\", wrap(z.object({ id: z.string() }), async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n if (svc.manager) await svc.manager.deleteThread(p.id);\n else await svc.threads.delete(p.id);\n gw.publish(\"sidebar\", { type: \"thread.remove\", id: p.id });\n return { ok: true };\n }));\n\n // ---- git.* (§5.8) ----\n const projectPath = async (projectId: string): Promise => {\n const proj = await svc.projects.get(projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n if (!existsSync(proj.path)) throw new ApiError(\"bad_path\", \"项目目录已不存在\");\n return proj.path;\n };\n\n gw.method(\"git.status\", wrap(z.object({ projectId: z.string() }), async (p) =>\n svc.git.status(await projectPath(p.projectId))\n ));\n\n gw.method(\"git.diff\", wrap(z.object({ projectId: z.string(), path: z.string() }), async (p) =>\n svc.git.diff(await projectPath(p.projectId), p.path)\n ));\n\n gw.method(\"git.discard\", wrap(z.object({ projectId: z.string(), path: z.string() }), async (p) =>\n svc.git.discard(await projectPath(p.projectId), p.path, svc.cfg.dataDir)\n ));\n\n // ---- files.* (§5.9) ----\n gw.method(\"files.tree\", wrap(z.object({ projectId: z.string(), dir: z.string().default(\".\") }), async (p) =>\n svc.files.tree(await projectPath(p.projectId), p.dir)\n ));\n\n gw.method(\"files.read\", wrap(z.object({ projectId: z.string(), path: z.string() }), async (p) =>\n svc.files.read(await projectPath(p.projectId), p.path)\n ));\n\n // ---- history.* (§7.6) ----\n gw.method(\"history.list\", wrap(z.object({ projectId: z.string() }), async (p) => {\n const proj = await svc.projects.get(p.projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n const imported = new Set(\n (await svc.threads.list())\n .filter((t) => t.projectId === p.projectId && t.providerSessionId)\n .map((t) => t.providerSessionId),\n );\n const out: unknown[] = [];\n for (const provider of listProviders()) {\n if (!provider.listHistory) continue;\n const items = await provider.listHistory(proj.path).catch(() => []);\n for (const i of items) out.push({ ...i, imported: imported.has(i.sessionId) });\n }\n return out;\n }));\n\n gw.method(\"history.import\", wrap(\n z.object({\n projectId: z.string(),\n items: z.array(z.object({ provider: z.string(), sessionId: z.string(), title: z.string().optional() })).min(1).max(20),\n }),\n async (p) => {\n const proj = await svc.projects.get(p.projectId);\n if (!proj) throw new ApiError(\"not_found\", \"项目不存在\");\n const metas = [];\n for (const item of p.items) {\n const provider = getProvider(item.provider);\n if (!provider?.readHistory) continue;\n // Idempotent: reuse if the same providerSessionId was already imported\n const existing = (await svc.threads.list()).find(\n (t) => t.projectId === p.projectId && t.provider === item.provider && t.providerSessionId === item.sessionId,\n );\n if (existing) { metas.push(existing); continue; }\n const meta = await svc.threads.create({\n projectId: p.projectId,\n provider: item.provider,\n title: item.title,\n providerSessionId: item.sessionId,\n importedFrom: { provider: item.provider, sessionId: item.sessionId },\n });\n const events = await provider.readHistory(item.sessionId, proj.path).catch(() => []);\n for (const ev of events) {\n await svc.threads.appendEvent(meta.id, ev as Parameters[1]);\n }\n await svc.threads.patch(meta.id, { status: \"closed\" });\n gw.publish(\"sidebar\", { type: \"thread.upsert\", thread: await svc.threads.get(meta.id) });\n metas.push(await svc.threads.get(meta.id));\n }\n return metas;\n }\n ));\n\n // ---- terminal.* (§7.5) ----\n gw.method(\"terminal.list\", wrap(z.object({ projectId: z.string().optional() }).passthrough().optional(), (p) =>\n svc.terminals.list(p?.projectId)\n ));\n\n gw.method(\"terminal.create\", wrap(\n z.object({ projectId: z.string(), cols: z.number().int().min(20).max(500).default(80), rows: z.number().int().min(5).max(200).default(24) }),\n async (p) => {\n const path = await projectPath(p.projectId);\n const r = await svc.terminals.create(p.projectId, path, p.cols, p.rows);\n if (\"error\" in r) throw new ApiError(\"bad_params\", r.error);\n return r;\n }\n ));\n\n gw.method(\"terminal.attach\", wrap(z.object({ id: z.string() }), async (p, ctx) => {\n const r = svc.terminals.attach(p.id);\n if (r.error || r.index == null) throw new ApiError(\"not_found\", r.error ?? \"终端不存在\");\n // attach also subscribes to the terminal: channel (input frames are gated on that subscription too)\n svc.gateway.subscribe(ctx.conn, `terminal:${r.index}`);\n return r;\n }));\n\n gw.method(\"terminal.resize\", wrap(z.object({ id: z.string(), cols: z.number().int(), rows: z.number().int() }), (p) =>\n svc.terminals.resize(p.id, p.cols, p.rows)\n ));\n\n gw.method(\"terminal.kill\", wrap(z.object({ id: z.string() }), (p) => svc.terminals.kill(p.id)));\n\n // ---- thread.* (runtime operations, need the manager) ----\n const needManager = () => {\n if (!svc.manager) throw new ApiError(\"unavailable\", \"会话管理器未启动\");\n return svc.manager;\n };\n\n gw.method(\"thread.send\", wrap(\n z.object({\n id: z.string(),\n text: z.string(),\n attachments: z.array(z.object({ path: z.string(), mime: z.string(), name: z.string().optional() })).optional(),\n behavior: z.enum([\"queue\", \"steer\"]).optional(),\n }),\n async (p) => {\n await needManager().send(p.id, { text: p.text, attachments: p.attachments }, p.behavior ?? \"queue\");\n return { ok: true };\n }\n ));\n\n gw.method(\"thread.interrupt\", wrap(z.object({ id: z.string() }), async (p) => {\n await needManager().interrupt(p.id);\n return { ok: true };\n }));\n\n gw.method(\"thread.respond\", wrap(\n z.object({\n id: z.string(),\n requestId: z.string(),\n optionId: z.string().optional(),\n answers: z.record(z.string(), z.string()).optional(),\n cancelled: z.boolean().optional(),\n }),\n async (p) => {\n await needManager().respond(p.id, p.requestId, { optionId: p.optionId, answers: p.answers, cancelled: p.cancelled });\n return { ok: true };\n }\n ));\n\n gw.method(\"thread.setModel\", wrap(z.object({ id: z.string(), model: z.string().nullable() }), async (p) => {\n await needManager().setModel(p.id, p.model);\n return { ok: true };\n }));\n\n gw.method(\"thread.setMode\", wrap(z.object({ id: z.string(), mode: threadModeSchema }), async (p) => {\n await needManager().setMode(p.id, p.mode);\n return { ok: true };\n }));\n\n gw.method(\"thread.setEffort\", wrap(z.object({ id: z.string(), effort: z.string().nullable() }), async (p) => {\n await needManager().setEffort(p.id, p.effort);\n return { ok: true };\n }));\n\n gw.method(\"thread.commands\", wrap(z.object({ id: z.string(), load: z.boolean().optional() }), async (p) => {\n const commands = await needManager().listCommands(p.id, { load: p.load });\n return { commands };\n }));\n\n gw.method(\"thread.setCursorOpts\", wrap(\n z.object({\n id: z.string(),\n cursorFast: z.boolean().optional(),\n cursorAutoReview: z.boolean().optional(),\n }),\n async (p) => {\n await needManager().setCursorOpts(p.id, {\n cursorFast: p.cursorFast,\n cursorAutoReview: p.cursorAutoReview,\n });\n return { ok: true };\n },\n ));\n\n gw.method(\"thread.setProvider\", wrap(z.object({ id: z.string(), provider: z.string().min(1) }), async (p) => {\n await needManager().setProvider(p.id, p.provider);\n return { ok: true };\n }));\n\n gw.method(\"thread.close\", wrap(z.object({ id: z.string() }), async (p) => {\n await needManager().close(p.id);\n return { ok: true };\n }));\n\n // ---- thread.exportMarkdown (P2-7): persisted events → Markdown text ----\n gw.method(\"thread.exportMarkdown\", wrap(z.object({ id: z.string() }), async (p) => {\n const meta = await svc.threads.get(p.id);\n if (!meta) throw new ApiError(\"not_found\", \"对话不存在\");\n const events = await svc.threads.readEvents(p.id, 0, 20000);\n const L: string[] = [];\n L.push(`# ${meta.title}`, \"\");\n L.push(`> ${meta.provider} · ${meta.model ?? \"默认\"} · ${meta.mode} · ${new Date(meta.createdAt).toLocaleString(\"zh-CN\")}`, \"\");\n for (const e of events) {\n switch (e.type) {\n case \"user_message\": L.push(`**我**${e.steered ? \"(插话)\" : \"\"}:\\n\\n${e.text}`, \"\"); break;\n case \"assistant_message\": L.push(e.text, \"\"); break;\n case \"reasoning\": L.push(`
思考\\n\\n${e.text}\\n
`, \"\"); break;\n case \"tool_call\": {\n const icon = e.status === \"ok\" ? \"✓\" : e.status === \"failed\" ? \"✗\" : \"…\";\n L.push(`- \\`${e.kind}\\` ${icon} ${e.title}${e.output ? `\\n \\`\\`\\`\\n ${e.output.slice(0, 2000)}\\n \\`\\`\\`` : \"\"}`);\n break;\n }\n case \"plan\": L.push(\"### 计划\\n\\n\" + e.markdown, \"\"); break;\n case \"todo\": L.push(\"### 待办\\n\\n\" + e.items.map((i) => `- [${i.status === \"done\" || i.status === \"completed\" ? \"x\" : \" \"}] ${i.text}`).join(\"\\n\"), \"\"); break;\n case \"notice\": L.push(`_${e.message}_`, \"\"); break;\n case \"turn_ended\": L.push(`— ${e.status}${e.durationMs ? ` · ${Math.round(e.durationMs / 1000)}s` : \"\"}${e.error ? ` · ${e.error}` : \"\"} —`, \"\"); break;\n default: break;\n }\n }\n const markdown = L.join(\"\\n\");\n const safe = meta.title.replace(/[\\\\/:*?\"<>|\\s]+/g, \"_\").slice(0, 60) || \"thread\";\n const filename = `${safe}.md`;\n const dir = join(homedir(), \"Downloads\");\n await mkdir(dir, { recursive: true });\n let dest = join(dir, filename);\n if (existsSync(dest)) {\n const stamp = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n dest = join(dir, `${safe}-${stamp}.md`);\n }\n await writeFile(dest, markdown, \"utf8\");\n revealInFileManager(dest);\n return { markdown, filename, path: dest };\n }));\n}\n\nfunction revealInFileManager(filePath: string): void {\n if (process.platform === \"win32\") {\n execFile(\"explorer.exe\", [\"/select,\" + win32Path.normalize(filePath)], { windowsHide: true }, () => {}).on(\"error\", () => {});\n return;\n }\n if (process.platform === \"darwin\") {\n execFile(\"open\", [\"-R\", filePath], () => {}).on(\"error\", () => {});\n return;\n }\n execFile(\"xdg-open\", [dirname(filePath)], () => {}).on(\"error\", () => {});\n}\n","// Atomic JSON write: temp file + rename. On fs failure throws an ApiError with a Chinese message (P5-2: disk write failure notice).\nimport { writeFile, rename, mkdir } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { ApiError } from \"../ws/methods.js\";\n\nexport async function writeJsonAtomic(file: string, obj: unknown): Promise {\n try {\n await mkdir(dirname(file), { recursive: true });\n const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;\n await writeFile(tmp, JSON.stringify(obj, null, 2), \"utf8\");\n await rename(tmp, file);\n } catch (e) {\n if (e instanceof ApiError) throw e;\n throw new ApiError(\"io_error\", `写入数据失败(${file}):${(e as Error)?.message ?? e}`);\n }\n}\n","// JSONL read/write: serialized appends, corrupt lines skipped. Write failures throw an ApiError with a Chinese message (P5-2: disk write failure notice).\nimport { appendFile, readFile, mkdir, rename, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { ApiError } from \"../ws/methods.js\";\n\nexport async function readJsonl(file: string): Promise {\n let text = \"\";\n try {\n text = await readFile(file, \"utf8\");\n } catch {\n return [];\n }\n const out: T[] = [];\n for (const line of text.split(\"\\n\")) {\n const t = line.trim();\n if (!t) continue;\n try {\n out.push(JSON.parse(t) as T);\n } catch {\n // Skip corrupt lines\n }\n }\n return out;\n}\n\n// One serialized write queue per file to keep append order.\nconst writers = new Map>();\n\nexport function appendJsonl(file: string, obj: unknown): Promise {\n const prev = writers.get(file) ?? Promise.resolve();\n const next = prev.then(async () => {\n try {\n await mkdir(dirname(file), { recursive: true });\n await appendFile(file, JSON.stringify(obj) + \"\\n\", \"utf8\");\n } catch (e) {\n if (e instanceof ApiError) throw e;\n throw new ApiError(\"io_error\", `写入数据失败(${file}):${(e as Error)?.message ?? e}`);\n }\n });\n writers.set(\n file,\n next.catch(() => {})\n );\n return next;\n}\n\n// Full rewrite (rare path, e.g. cleanup)\nexport async function writeJsonl(file: string, items: unknown[]): Promise {\n try {\n await mkdir(dirname(file), { recursive: true });\n const tmp = file + \".tmp-\" + process.pid;\n await writeFile(tmp, items.map((o) => JSON.stringify(o)).join(\"\\n\") + (items.length ? \"\\n\" : \"\"), \"utf8\");\n await rename(tmp, file);\n } catch (e) {\n if (e instanceof ApiError) throw e;\n throw new ApiError(\"io_error\", `写入数据失败(${file}):${(e as Error)?.message ?? e}`);\n }\n}\n","// Legacy data migration (§10.1): runs once when the daemon starts.\n// Old: sessions//{meta.json,events.jsonl} + projects in settings.json →\n// New: projects.json + threads//{meta.json,timeline.jsonl}. Old files are read-only and copied to trash/legacy-/ as a backup.\nimport { cp, mkdir, readdir, readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { SettingsStore } from \"../store/settings.js\";\nimport type { ProjectsStore } from \"../store/projects.js\";\nimport type { ThreadsStore, ThreadMeta } from \"../store/threads.js\";\nimport type { Mode, TimelineEvent } from \"../timeline/types.js\";\nimport { appendJsonl } from \"../util/jsonl.js\";\nimport { writeJsonAtomic } from \"../util/atomic.js\";\nimport { validId } from \"../util/paths.js\";\nimport { log } from \"../log.js\";\n\ntype OldMeta = {\n id: string; agent: string; cwd: string; model: string; permission: string;\n title: string; titleAuto: boolean; pinned: boolean; cliSessionId: string | null;\n created: number; updated: number; lastStatus?: string; totalCost?: number; totalUsage?: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number } | null;\n};\ntype OldEvt = Record & { type: string; seq?: number; ts?: number };\n\nconst SKIP_TITLE = /^\\/(slow|deny|error|long|codex)/;\nconst MODE_MAP: Record = {\n plan: \"plan\", default: \"default\", acceptEdits: \"acceptEdits\", bypassPermissions: \"bypassPermissions\",\n auto: \"acceptEdits\", full: \"bypassPermissions\",\n};\nconst belongsTo = (cwd: string, parent: string) => {\n const c = cwd.replace(/\\\\/g, \"/\").toLowerCase().replace(/\\/+$/, \"\");\n const p = parent.replace(/\\\\/g, \"/\").toLowerCase().replace(/\\/+$/, \"\");\n return c === p || c.startsWith(p + \"/\");\n};\n\nfunction stamp(): string {\n const d = new Date();\n const p = (n: number) => String(n).padStart(2, \"0\");\n return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;\n}\n\nexport async function migrateLegacy(dataDir: string, stores: {\n settings: SettingsStore; projects: ProjectsStore; threads: ThreadsStore;\n}): Promise<{ migrated: number; skipped: boolean }> {\n const settings = await stores.settings.get();\n if (settings.v1Migrated) return { migrated: 0, skipped: true };\n const oldDir = join(dataDir, \"sessions\");\n const oldSettingsPath = join(dataDir, \"settings.json\");\n if (!existsSync(oldDir) && !existsSync(oldSettingsPath)) {\n await stores.settings.set({ v1Migrated: true });\n return { migrated: 0, skipped: true };\n }\n\n // 1. Backup\n const backup = join(dataDir, \"trash\", `legacy-${stamp()}`);\n await mkdir(backup, { recursive: true });\n if (existsSync(oldDir)) await cp(oldDir, join(backup, \"sessions\"), { recursive: true }).catch(() => {});\n if (existsSync(oldSettingsPath)) await cp(oldSettingsPath, join(backup, \"settings.json\")).catch(() => {});\n\n // Read the old settings (read the file directly; SettingsStore already loaded the same path, the fields coexist without conflict)\n const oldSettings = existsSync(oldSettingsPath)\n ? (JSON.parse(await readFile(oldSettingsPath, \"utf8\").catch(() => \"{}\")) as Record)\n : {};\n\n // 2. Projects: old settings.projects → projects.json; orphan cwds are added per the adoptOrphans rule\n const projectIdByPath = new Map();\n const lc = (p: string) => p.replace(/\\\\/g, \"/\").toLowerCase().replace(/\\/+$/, \"\");\n for (const p of (oldSettings.projects as { name?: string; path?: string }[] | undefined) ?? []) {\n if (!p?.path) continue;\n try {\n const proj = await stores.projects.create(p.path, p.name);\n projectIdByPath.set(lc(proj.path), proj.id);\n } catch (e) {\n log(\"warn\", \"迁移:建项目失败\", { path: p.path, err: String(e) });\n }\n }\n\n // Old session list\n let sessionIds: string[] = [];\n try {\n sessionIds = (await readdir(oldDir)).filter((d) => validId(d) && existsSync(join(oldDir, d, \"meta.json\")));\n } catch {}\n const metas: OldMeta[] = [];\n for (const id of sessionIds) {\n try {\n const m = JSON.parse(await readFile(join(oldDir, id, \"meta.json\"), \"utf8\")) as OldMeta;\n if (m?.cwd) metas.push(m);\n } catch {}\n }\n // Orphan cwd → add a project (subdirectories fold into their parent)\n const extraDirs: string[] = [];\n const projPaths = [...projectIdByPath.keys()];\n for (const m of metas) {\n const covered = projPaths.some((pp) => belongsTo(m.cwd, pp)) || extraDirs.some((d) => belongsTo(m.cwd, d));\n if (covered) continue;\n for (let i = extraDirs.length - 1; i >= 0; i--) if (belongsTo(extraDirs[i], m.cwd)) extraDirs.splice(i, 1);\n extraDirs.push(m.cwd);\n }\n for (const d of extraDirs) {\n if (!existsSync(d)) continue; // Skip directories that no longer exist (same as the old adoptOrphans)\n try {\n const name = d.replace(/\\\\/g, \"/\").split(\"/\").filter(Boolean).pop() ?? d;\n const proj = await stores.projects.create(d, name);\n projectIdByPath.set(lc(proj.path), proj.id);\n projPaths.push(lc(proj.path));\n } catch (e) {\n log(\"warn\", \"迁移:补建孤儿项目失败\", { path: d, err: String(e) });\n }\n }\n\n const projectOf = (cwd: string): string | null => {\n const l = lc(cwd);\n // Take the longest match (a subdirectory joins the most specific project)\n let best: string | null = null;\n for (const [pp, id] of projectIdByPath) {\n if (belongsTo(l, pp) && (best == null || pp.length > best.length)) best = pp;\n }\n return best ? projectIdByPath.get(best)! : null;\n };\n\n // 3. Sessions\n let migrated = 0;\n const threadsDir = join(dataDir, \"threads\");\n for (const m of metas) {\n try {\n const projectId = projectOf(m.cwd);\n if (!projectId) { log(\"warn\", \"迁移:无项目可归,跳过\", { id: m.id, cwd: m.cwd }); continue; }\n const testTitle = SKIP_TITLE.test(m.title ?? \"\") || m.title === \"默认剧本演示\";\n const provider = m.agent === \"claude\" || m.agent === \"codex\" ? m.agent : \"mock\";\n const archived = testTitle || (m.agent !== \"claude\" && m.agent !== \"codex\");\n const now = Date.now();\n const meta: ThreadMeta = {\n id: `t_${m.id.replace(/^s?_?/, \"\")}`.slice(0, 80),\n projectId,\n title: m.title || \"旧对话\",\n titleAuto: false,\n provider,\n model: m.model || null,\n mode: MODE_MAP[m.permission] ?? \"default\",\n effort: null,\n providerSessionId: m.cliSessionId || null,\n status: \"closed\",\n pinned: !!m.pinned,\n archived,\n createdAt: m.created || now,\n updatedAt: m.updated || now,\n lastTurnStatus: m.lastStatus === \"ok\" ? \"completed\" : m.lastStatus === \"error\" ? \"failed\" : null,\n usage: {\n inputTokens: m.totalUsage?.inputTokens ?? 0,\n outputTokens: m.totalUsage?.outputTokens ?? 0,\n cacheReadTokens: m.totalUsage?.cacheReadTokens ?? 0,\n costUsd: m.totalCost ?? 0,\n },\n importedFrom: m.cliSessionId ? { provider, sessionId: m.cliSessionId } : undefined,\n seq: 0,\n };\n const tdir = join(threadsDir, meta.id);\n if (existsSync(tdir)) meta.id = `t_${m.id}_v1`.slice(0, 80);\n await mkdir(join(threadsDir, meta.id), { recursive: true });\n\n // Event mapping\n const oldEvts = (await readFile(join(oldDir, m.id, \"events.jsonl\"), \"utf8\").catch(() => \"\"))\n .split(\"\\n\").filter((l) => l.trim())\n .map((l) => { try { return JSON.parse(l) as OldEvt; } catch { return null; } })\n .filter((x): x is OldEvt => !!x);\n const lines: TimelineEvent[] = [];\n const openTools = new Map();\n let seq = 0;\n const push = (ev: Record & { type: TimelineEvent[\"type\"] }) => {\n seq++;\n lines.push({ ...ev, seq, ts: Date.now(), threadId: meta.id } as TimelineEvent);\n };\n for (const e of oldEvts) {\n switch (e.type) {\n case \"user\": push({ type: \"user_message\", text: String(e.text ?? \"\") }); break;\n case \"text\": push({ type: \"assistant_message\", itemId: `lg_${seq}`, text: String(e.text ?? \"\") }); break;\n case \"thinking\": push({ type: \"reasoning\", itemId: `lg_${seq}`, text: String(e.text ?? \"\") }); break;\n case \"tool.call\": {\n const key = String(e.itemId ?? e.callId ?? `c${seq}`);\n openTools.set(key, { kind: String(e.kind ?? \"other\"), title: String(e.title ?? e.kind ?? \"工具\"), input: e.input, detail: e.detail });\n push({ type: \"tool_call\", itemId: key, kind: \"other\", title: String(e.title ?? \"工具\"), status: \"running\", input: e.input, detail: e.detail });\n break;\n }\n case \"tool.result\": {\n const key = String(e.itemId ?? e.callId ?? `c${seq}`);\n const open = openTools.get(key);\n openTools.delete(key);\n push({\n type: \"tool_call\", itemId: key,\n kind: (open?.kind ?? \"other\") as import(\"../timeline/types.js\").ToolKind,\n title: open?.title ?? \"工具\",\n status: e.ok === false || e.error ? \"failed\" : \"ok\",\n output: typeof e.output === \"string\" ? e.output.slice(0, 20000) : undefined,\n detail: open?.detail,\n } as Record & { type: \"tool_call\" });\n break;\n }\n case \"todo\": {\n const items = (e.items as { text?: string; status?: string }[] | undefined) ?? [];\n push({ type: \"todo\", items: items.map((i) => ({ text: String(i.text ?? \"\"), status: i.status === \"done\" ? \"done\" : i.status === \"in_progress\" ? \"in_progress\" : \"todo\" })) });\n break;\n }\n case \"permission.denied\": push({ type: \"notice\", level: \"warn\", message: `已拒绝:${String(e.title ?? e.kind ?? \"\")}` }); break;\n case \"permission.request\": case \"permission.resolved\": break; // Old permission cards are stale; don't recreate them\n case \"notice\": push({ type: \"notice\", level: (e.level as \"info\" | \"warn\" | \"error\") ?? \"info\", message: String(e.message ?? \"\") }); break;\n case \"run.end\": {\n const u = e.usage as { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number } | undefined;\n if (u) push({ type: \"usage\", turnId: `lg_t${seq}`, inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0, cacheReadTokens: u.cacheReadTokens ?? 0, costUsd: (e.costUsd as number) ?? 0 });\n push({ type: \"turn_ended\", turnId: `lg_t${seq}`, status: e.status === \"error\" ? \"failed\" : e.status === \"interrupted\" ? \"interrupted\" : \"completed\", durationMs: (e.ms as number) ?? undefined, error: e.error as string | undefined });\n break;\n }\n case \"run.start\": break;\n case \"init\": break;\n default: break;\n }\n }\n // Unclosed tool.call → mark failed\n for (const [key, o] of openTools) {\n seq++;\n lines.push({ type: \"tool_call\", itemId: key, kind: \"other\", title: o.title, status: \"failed\", seq, ts: Date.now(), threadId: meta.id } as TimelineEvent);\n }\n for (const ev of lines) await appendJsonl(join(threadsDir, meta.id, \"timeline.jsonl\"), ev);\n meta.seq = seq;\n await writeJsonAtomic(join(threadsDir, meta.id, \"meta.json\"), meta);\n migrated++;\n } catch (e) {\n log(\"warn\", \"迁移:会话失败\", { id: m.id, err: String(e) });\n }\n }\n\n // 4. Merge settings (UI preference fields only)\n const patch: Record = { v1Migrated: true };\n if (typeof oldSettings.theme === \"string\") patch.theme = oldSettings.theme;\n if (typeof oldSettings.sendKey === \"string\") patch.sendKey = oldSettings.sendKey;\n if (typeof oldSettings.showThinking === \"boolean\") patch.showThinking = oldSettings.showThinking;\n if (typeof oldSettings.defaultAgent === \"string\") patch.defaultProvider = oldSettings.defaultAgent === \"claude\" || oldSettings.defaultAgent === \"codex\" ? oldSettings.defaultAgent : \"claude\";\n if (typeof oldSettings.defaultPermission === \"string\") patch.defaultMode = MODE_MAP[oldSettings.defaultPermission] ?? \"default\";\n await stores.settings.set(patch);\n\n log(\"info\", `旧数据迁移完成:${migrated} 个会话、${projectIdByPath.size} 个项目,备份在 ${backup}`);\n return { migrated, skipped: false };\n}\n","// Startup warm-up: for enabled, non-planned Providers, detect then fetch models; results go into infoCache.\nimport type { Settings } from \"../store/settings.js\";\nimport { listProviders } from \"./registry.js\";\nimport { catalogById } from \"./catalog.js\";\nimport { isProviderEnabled } from \"./enabled.js\";\nimport { cachedDetect, cachedModels } from \"./infoCache.js\";\n\nexport async function warmProviderCache(settings: Settings): Promise {\n await Promise.all(\n listProviders().map(async (p) => {\n if (!isProviderEnabled(settings, p.id) || catalogById(p.id)?.status === \"planned\") return;\n const det = await cachedDetect(p);\n if (det.available) await cachedModels(p);\n }),\n );\n}\n","// Daemon entry: parse arguments, initialize services, start HTTP+WS, write daemon.json, handle exit.\nimport { mkdirSync, existsSync, rmSync, chmodSync } from \"node:fs\";\nimport { join, dirname } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { parseConfig } from \"./config.js\";\nimport { initLog, log } from \"./log.js\";\nimport { createHttpServer } from \"./http.js\";\nimport { AttachmentStore } from \"./store/attachments.js\";\nimport { SettingsStore } from \"./store/settings.js\";\nimport { ProjectsStore } from \"./store/projects.js\";\nimport { ThreadsStore } from \"./store/threads.js\";\nimport { Gateway } from \"./ws/gateway.js\";\nimport { registerMethods } from \"./ws/methods.js\";\nimport { writeJsonAtomic } from \"./util/atomic.js\";\nimport { initProviders } from \"./providers/registry.js\";\nimport { AgentManager } from \"./agents/manager.js\";\nimport { GitService } from \"./git/service.js\";\nimport { FileService } from \"./files/service.js\";\nimport { TerminalService } from \"./terminal/service.js\";\nimport { augmentPath, pidAlive } from \"./util/proc.js\";\n\n// The bundle targets node20: an older Node (or being started by Bun by mistake) gets a clear message instead of crashing on some API\nconst nodeMajor = Number(/^v?(\\d+)/.exec(process.versions.node ?? \"\")?.[1] ?? 0);\nif (nodeMajor < 20) {\n console.error(`[agent-console] 需要 Node.js 20 或更高版本,当前 ${process.version}(${process.execPath})`);\n process.exit(3);\n}\n\n// Agent CLI .js entries and the Claude SDK look up \"node\" by name; GUI apps launched from the macOS Dock get a minimal PATH,\n// so append the current node's directory and common install directories to PATH (existing order unchanged)\nprocess.env.PATH = augmentPath(process.env, dirname(process.execPath));\n\nconst cfg = parseConfig(process.argv.slice(2));\n\n// Top-level safety net: log, don't exit\nprocess.on(\"uncaughtException\", (e) => {\n log(\"error\", \"uncaughtException\", { err: String(e?.stack ?? e) });\n});\nprocess.on(\"unhandledRejection\", (e) => {\n log(\"error\", \"unhandledRejection\", { err: String(e) });\n});\n\nasync function main(): Promise {\n initLog(cfg.dataDir);\n for (const d of [\"threads\", \"attachments\", \"trash\", \"logs\"]) {\n mkdirSync(join(cfg.dataDir, d), { recursive: true });\n }\n\n const appDir = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"..\", \"app\");\n const attachments = new AttachmentStore(join(cfg.dataDir, \"attachments\"));\n const { bindAttachmentStore } = await import(\"./util/attachmentRefs.js\");\n bindAttachmentStore(attachments);\n const settings = new SettingsStore(cfg.dataDir);\n const projects = new ProjectsStore(cfg.dataDir);\n const threads = new ThreadsStore(cfg.dataDir);\n const gateway = new Gateway(cfg);\n\n initProviders(cfg.version);\n // Legacy data migration (§10.1): runs once, skipped after v1Migrated; old files are read-only\n const { migrateLegacy } = await import(\"./migrate/legacy.js\");\n try {\n await migrateLegacy(cfg.dataDir, { settings, projects, threads });\n } catch (e) {\n log(\"error\", \"旧数据迁移失败\", { err: String(e) });\n }\n const manager = new AgentManager(gateway, threads, projects, settings, attachments);\n await manager.recoverOnStart();\n const terminals = new TerminalService(gateway);\n registerMethods(gateway, {\n cfg,\n settings,\n gateway,\n projects,\n threads,\n manager,\n git: new GitService(),\n files: new FileService(),\n terminals,\n runtime: (id) => manager.runtimeInfo(id),\n });\n\n const server = createHttpServer(cfg, appDir, attachments, {\n onUpgrade: (req, socket, head) => gateway.handleUpgrade(req, socket, head),\n });\n\n // Retry 5 times if the port is in use\n let attempts = 0;\n await new Promise((resolveListen, rejectListen) => {\n const tryListen = () => {\n attempts++;\n server.once(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"EADDRINUSE\" && attempts < 6) {\n log(\"warn\", `端口 ${cfg.port} 被占用,500ms 后重试(第 ${attempts} 次)`);\n setTimeout(tryListen, 500);\n } else {\n rejectListen(e);\n }\n });\n server.listen(cfg.port, \"127.0.0.1\");\n };\n server.once(\"listening\", () => resolveListen());\n tryListen();\n }).catch((e) => {\n log(\"error\", `端口 ${cfg.port} 无法监听`, { err: String(e) });\n process.exit(2);\n });\n\n // --port 0: after listening on a random port, write the actual port back into cfg.port. Host/Origin checks read cfg.port,\n // and no request can arrive before this (listen has only just completed), so there is no window checking against the old port.\n const bound = server.address();\n if (bound && typeof bound === \"object\") cfg.port = bound.port;\n // Ready line: the plugin host reads this line from stdout to learn the actual port (port and pid only, no token)\n process.stdout.write(JSON.stringify({ agentConsoleReady: true, port: cfg.port, pid: process.pid }) + \"\\n\");\n // Background warm-up: after start, pull detection/models for enabled providers into the cache so the first page open doesn't wait\n void (async () => {\n const { warmProviderCache } = await import(\"./providers/warm.js\");\n await warmProviderCache(await settings.get());\n })().catch((e) => log(\"warn\", \"Provider 预热失败\", { err: String(e) }));\n\n const daemonJsonPath = join(cfg.dataDir, \"daemon.json\");\n await writeJsonAtomic(daemonJsonPath, {\n pid: process.pid,\n port: cfg.port,\n token: cfg.token,\n version: cfg.version,\n buildStamp: cfg.buildStamp,\n startedAt: Date.now(),\n });\n chmodSync(daemonJsonPath, 0o600);\n log(\"info\", `daemon 已启动 端口 ${cfg.port} 数据目录 ${cfg.dataDir} 版本 ${cfg.version} pid ${process.pid}`);\n // Standalone dev mode: print the open URL with the token to the console (console only, never the log file)\n if (!cfg.parentPid) console.log(`[agent-console] 打开 http://127.0.0.1:${cfg.port}/#t=${cfg.token}`);\n\n // Idle exit: check every minute; no WS connection + no running conversation + no open terminal → exit after idleExitMinutes\n let idleSince = Date.now();\n setInterval(() => {\n const busy = gateway.connectionCount > 0 || manager.anyBusy() || terminals.count > 0;\n if (busy) idleSince = Date.now();\n if (Date.now() - idleSince > cfg.idleExitMinutes * 60 * 1000) {\n log(\"info\", \"空闲超时,daemon 退出\");\n shutdown(0);\n }\n }, 60 * 1000).unref();\n\n let shuttingDown = false;\n function shutdown(code: number): void {\n if (shuttingDown) return;\n shuttingDown = true;\n log(\"info\", \"daemon 关闭中\");\n terminals.killAll();\n gateway.closeAll();\n try {\n rmSync(daemonJsonPath, { force: true });\n } catch {}\n // Wait for Agent sessions to close cleanly (at most 2.5 s) before exiting; process trees still alive are cleaned up by util/bin's exit hook\n const sessionsClosed = Promise.race([manager.shutdown(), new Promise((r) => setTimeout(r, 2500))]);\n void sessionsClosed.then(() => server.close(() => process.exit(code)));\n setTimeout(() => process.exit(code), 4000).unref();\n }\n process.on(\"SIGINT\", () => shutdown(0));\n process.on(\"SIGTERM\", () => shutdown(0));\n // Lifeline: when spawned by the plugin host (Magpie's Bun plugin process) we get --parent-pid, and stdin is a pipe the host holds.\n // Host exits (normally or killed) → pipe closes → stdin end/close → exit;\n // also check every 2 seconds whether the parent is still alive, in case stdin was inherited by another process and never closed.\n // Not enabled in standalone dev mode (npm run dev, no --parent-pid); there we rely on idle exit only.\n if (cfg.parentPid) {\n const parentPid = cfg.parentPid;\n const parentWatch = setInterval(() => {\n if (!pidAlive(parentPid)) {\n clearInterval(parentWatch);\n log(\"info\", `父进程 ${parentPid} 已退出,daemon 跟随退出`);\n shutdown(0);\n }\n }, 2000);\n parentWatch.unref();\n process.stdin.on(\"end\", () => shutdown(0));\n process.stdin.on(\"close\", () => shutdown(0));\n process.stdin.on(\"error\", () => shutdown(0));\n process.stdin.resume();\n }\n}\n\nmain().catch((e) => {\n console.error(\"[agent-console] 启动失败\", e);\n process.exit(1);\n});\n","// Arguments and environment: port, dataDir, token, version, idleExitMinutes\nimport { randomBytes } from \"node:crypto\";\nimport { readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join, resolve, dirname } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { daemonBuildStamp } from \"./util/buildStamp.js\";\n\nexport type Config = {\n port: number;\n dataDir: string;\n token: string;\n version: string;\n idleExitMinutes: number;\n buildStamp: string;\n parentPid?: number;\n};\n\nfunction pkgVersion(): string {\n try {\n const here = dirname(fileURLToPath(import.meta.url));\n // dist/main.js → ../../package.json; src/main.ts also ends up in dist via esbuild\n const pkg = JSON.parse(readFileSync(join(here, \"..\", \"..\", \"package.json\"), \"utf8\"));\n return String(pkg.version || \"0.0.0\");\n } catch {\n return \"0.0.0\";\n }\n}\n\n// --data sets the data root (equivalent to setting XDG_CONFIG_HOME and then joining magpie/agent-console?\n// EXEC §5.2: --data (data root, equivalent to setting XDG_CONFIG_HOME; default $XDG_CONFIG_HOME\n// or ~/.config, then joined with magpie/agent-console). i.e. the --data value has XDG_CONFIG_HOME semantics.\nexport function parseConfig(argv: string[]): Config {\n let port: number | null = null;\n let data: string | null = null;\n let token: string | null = null;\n let parentPid: number | undefined;\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i];\n if (a === \"--port\" && argv[i + 1]) port = Number(argv[++i]);\n else if (a.startsWith(\"--port=\")) port = Number(a.slice(7));\n else if (a === \"--data\" && argv[i + 1]) data = argv[++i];\n else if (a.startsWith(\"--data=\")) data = a.slice(7);\n else if (a === \"--token\" && argv[i + 1]) token = argv[++i];\n else if (a.startsWith(\"--token=\")) token = a.slice(8);\n else if (a === \"--parent-pid\" && argv[i + 1]) parentPid = Number(argv[++i]);\n else if (a.startsWith(\"--parent-pid=\")) parentPid = Number(a.slice(13));\n }\n if (port == null || !Number.isInteger(port) || port < 0 || port > 65535) {\n const envPort = Number(process.env.AGENT_CONSOLE_PORT || \"\");\n port = Number.isInteger(envPort) && envPort >= 0 && envPort <= 65535 ? envPort : 3488;\n }\n const xdg = data ?? process.env.XDG_CONFIG_HOME ?? join(homedir(), \".config\");\n const dataDir = resolve(join(xdg, \"magpie\", \"agent-console\"));\n const idleEnv = Number(process.env.AGENT_CONSOLE_IDLE_EXIT_MIN || \"\");\n return {\n port,\n dataDir,\n token: token ?? process.env.AGENT_CONSOLE_TOKEN ?? randomBytes(16).toString(\"hex\"),\n version: pkgVersion(),\n idleExitMinutes: Number.isFinite(idleEnv) && idleEnv > 0 ? idleEnv : 60,\n buildStamp: daemonBuildStamp(),\n parentPid: Number.isInteger(parentPid) ? parentPid : undefined,\n };\n}\n","import { existsSync, statSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nfunction resolveDaemonMainJs(): string | null {\n const self = fileURLToPath(import.meta.url);\n const norm = self.replace(/\\\\/g, \"/\");\n // esbuild single-file bundle: the whole package is dist/main.js\n if (norm.endsWith(\"/daemon/dist/main.js\") || norm.endsWith(\"/dist/main.js\")) return self;\n\n const here = dirname(self);\n const candidates = [\n join(here, \"..\", \"..\", \"dist\", \"main.js\"), // src/util → daemon/dist/main.js (vitest/tsc)\n join(here, \"main.js\"), // Already in the dist directory\n join(here, \"..\", \"main.js\"),\n ];\n for (const p of candidates) {\n if (existsSync(p)) return p;\n }\n return null;\n}\n\n/** mtime of daemon/dist/main.js, used by the plugin to detect code updates and restart the process */\nexport function daemonBuildStamp(): string {\n try {\n const mainJs = resolveDaemonMainJs();\n if (!mainJs) return \"0\";\n return String(statSync(mainJs).mtimeMs);\n } catch {\n return \"0\";\n }\n}\n","// HTTP routes: / → app/index.html (no token), static assets, /api/ping, /api/upload, /attachments/*\nimport { createServer, type IncomingMessage, type ServerResponse, type Server } from \"node:http\";\nimport { readFile, stat } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join, normalize, extname } from \"node:path\";\nimport { checkHost, classifyOrigin, tokenEqual } from \"./security.js\";\nimport type { Config } from \"./config.js\";\nimport type { AttachmentStore } from \"./store/attachments.js\";\nimport { log } from \"./log.js\";\n\nconst MIME: Record = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".webp\": \"image/webp\",\n \".woff2\": \"font/woff2\",\n \".map\": \"application/json\",\n \".json\": \"application/json; charset=utf-8\",\n};\n\nconst MAX_UPLOAD = 50 * 1024 * 1024; // 50MB\nconst MAX_MSG = 200 * 1024; // Ordinary messages: 200KB\n\nexport type HttpHooks = {\n onUpgrade?: (req: IncomingMessage, socket: import(\"node:net\").Socket, head: Buffer) => void;\n};\n\n// A malformed %xx must not become a 500\nfunction safeDecode(s: string): string | null {\n try {\n return decodeURIComponent(s);\n } catch {\n return null;\n }\n}\n\nfunction send(res: ServerResponse, code: number, body: string | Buffer, headers: Record = {}): void {\n res.writeHead(code, headers);\n res.end(body);\n}\n\nfunction sendJson(res: ServerResponse, code: number, obj: unknown, headers: Record = {}): void {\n send(res, code, JSON.stringify(obj), { \"content-type\": \"application/json; charset=utf-8\", \"cache-control\": \"no-store\", ...headers });\n}\n\n// Common headers for static responses: no caching (the page has no token, but don't let old versions linger), no sniffing, no Referer\nconst STATIC_HEADERS: Record = {\n \"cache-control\": \"no-store\",\n \"x-content-type-options\": \"nosniff\",\n \"referrer-policy\": \"no-referrer\",\n};\n\nexport function createHttpServer(cfg: Config, appDir: string, attachments: AttachmentStore, hooks: HttpHooks = {}): Server {\n const server = createServer(async (req, res) => {\n try {\n await route(req, res);\n } catch (e) {\n log(\"error\", \"HTTP 处理异常\", { url: req.url, err: String(e) });\n if (!res.headersSent) sendJson(res, 500, { ok: false, e: { code: \"internal\", message: \"服务内部错误\" } });\n else res.end();\n }\n });\n\n // Origin policy per request (see classifyOrigin in security.ts):\n // foreign (another site) → always 403, even with a token\n // opaque (Origin: null) → may only \"present\" the token on token routes (/api/*, /attachments/*);\n // it confers no trust by itself. Static routes are always refused.\n // none / self → static routes allowed (static files contain no token); token routes still need the token.\n // The CORS header is only given (as null) on responses that carry a valid token + Origin: null; preflight only for /api/upload.\n async function route(req: IncomingMessage, res: ServerResponse): Promise {\n // Host must be loopback + this process's actual port: blocks DNS rebinding (a rebound page's GET carries no Origin)\n const hostCheck = checkHost(req.headers.host, cfg.port);\n if (!hostCheck.ok) {\n sendJson(res, hostCheck.code, { ok: false, e: { code: \"bad_host\", message: hostCheck.message } });\n return;\n }\n const origin = classifyOrigin(req.headers.origin, cfg.port);\n if (origin === \"foreign\") {\n sendJson(res, 403, { ok: false, e: { code: \"bad_origin\", message: \"来源不允许\" } });\n return;\n }\n\n const url = new URL(req.url ?? \"/\", `http://127.0.0.1:${cfg.port}`);\n const pathname = url.pathname;\n const isApi = pathname.startsWith(\"/api/\");\n const isAttachment = pathname.startsWith(\"/attachments/\");\n\n // OPTIONS preflight: browsers don't send custom headers on a preflight, so there is no token here.\n // Only allow /api/upload, the one cross-origin POST the frontend really makes, and only for null / our own origin;\n // the preflight itself returns no data, and the real request still has to pass the token check.\n if (req.method === \"OPTIONS\") {\n if (pathname !== \"/api/upload\" || origin === \"none\") {\n sendJson(res, 403, { ok: false, e: { code: \"bad_preflight\", message: \"预检不允许\" } });\n return;\n }\n send(res, 204, \"\", {\n \"access-control-allow-origin\": origin === \"opaque\" ? \"null\" : `http://127.0.0.1:${cfg.port}`,\n \"access-control-allow-headers\": \"x-agent-console, content-type\",\n \"access-control-allow-methods\": \"GET, POST, OPTIONS\",\n \"access-control-max-age\": \"600\",\n vary: \"origin\",\n });\n return;\n }\n\n // Token routes: the token is the only authorization\n if (isApi || isAttachment) {\n const headerToken = req.headers[\"x-agent-console\"];\n // Attachments accept ?t= ( can't send custom headers); the API only accepts the header\n const queryToken = isAttachment ? url.searchParams.get(\"t\") : null;\n const validToken =\n (typeof headerToken === \"string\" && tokenEqual(headerToken, cfg.token)) ||\n (typeof queryToken === \"string\" && tokenEqual(queryToken, cfg.token));\n if (!validToken) {\n sendJson(res, 403, { ok: false, e: { code: \"bad_token\", message: \"令牌无效\" } });\n return;\n }\n // In a sandbox iframe (no allow-same-origin) the page's origin is null, so even a same-address fetch is cross-origin:\n // the response needs allow-origin: null to be readable. It's only sent after the token is verified,\n // so any Origin: null page without the token can read nothing.\n if (origin === \"opaque\") {\n res.setHeader(\"access-control-allow-origin\", \"null\");\n res.setHeader(\"vary\", \"origin\");\n }\n if (isApi) {\n await routeApi(req, res, pathname, url);\n return;\n }\n const id = safeDecode(pathname.slice(\"/attachments/\".length));\n const a = id === null ? null : await attachments.get(id);\n if (!a) {\n sendJson(res, 404, { ok: false, e: { code: \"not_found\", message: \"附件不存在\" } });\n return;\n }\n const data = await attachments.read(a.id);\n send(res, 200, data ?? Buffer.alloc(0), { ...STATIC_HEADERS, \"content-type\": a.mime });\n return;\n }\n\n // Static assets: refuse Origin: null. Ordinary navigations and + + + + + diff --git a/packages/agent-console/scripts/browser-path.mjs b/packages/agent-console/scripts/browser-path.mjs new file mode 100644 index 00000000..26e82efd --- /dev/null +++ b/packages/agent-console/scripts/browser-path.mjs @@ -0,0 +1,33 @@ +// Chromium-family browser for the e2e / screenshot scripts: MSEDGE_PATH / CHROME_PATH first, +// then the usual Edge, Chrome and Chromium install locations per platform. +import { existsSync } from "node:fs"; + +export function findBrowser(env = process.env, platform = process.platform, exists = existsSync) { + const explicit = env.MSEDGE_PATH || env.CHROME_PATH; + if (explicit) return explicit; + const local = env.LOCALAPPDATA || ""; + const candidates = + platform === "win32" + ? [ + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + local && `${local.replace(/\\/g, "/")}/Google/Chrome/Application/chrome.exe`, + ] + : platform === "darwin" + ? [ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ] + : [ + "/usr/bin/microsoft-edge", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + ]; + return candidates.filter(Boolean).find((p) => exists(p)); +} diff --git a/packages/agent-console/scripts/build-v1.mjs b/packages/agent-console/scripts/build-v1.mjs new file mode 100644 index 00000000..a2cf6e0f --- /dev/null +++ b/packages/agent-console/scripts/build-v1.mjs @@ -0,0 +1,100 @@ +// Deterministic v1 build: daemon ESM bundle plus browser bundle and HTML. +import * as esbuild from "esbuild"; +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +export function options(outRoot = root) { + return { + daemon: { + entryPoints: ["daemon/src/main.ts"], + absWorkingDir: root, + platform: "node", + format: "esm", + target: "node20", + bundle: true, + packages: "external", + outfile: path.join(outRoot, "daemon/dist/main.js"), + sourcemap: "linked", + sourceRoot: "", + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", + }, + logLevel: "silent", + }, + web: { + entryPoints: ["web/src/main.tsx"], + absWorkingDir: root, + platform: "browser", + format: "iife", + target: "es2020", + bundle: true, + minify: true, + jsx: "automatic", + jsxImportSource: "react", + outfile: path.join(outRoot, "app/app.js"), + loader: { ".svg": "text" }, + define: { "process.env.NODE_ENV": '"production"' }, + logLevel: "silent", + }, + }; +} + +async function normalizeSourcemap(outRoot) { + const file = path.join(outRoot, "daemon/dist/main.js.map"); + const map = JSON.parse(await readFile(file, "utf8")); + // Esbuild makes sources relative to the outfile; normalize temporary-output paths + // so a clean build and the committed build have identical bytes. + // Sources live under the repo root (daemon/src, lib/); express each one relative to + // /daemon/dist regardless of where this build was written. + const outDir = path.join(outRoot, "daemon/dist"); + map.sources = map.sources.map((source) => { + // esbuild URL-encodes non-ASCII characters in paths (e.g. "magpie插件") in sources; decode them before computing the relative path, + // otherwise building from a temp directory under a non-ASCII directory doesn't yield ../src/… and verify:build reports a false main.js.map mismatch + let decoded = source; + try { + decoded = decodeURIComponent(source); + } catch {} + const abs = path.resolve(outDir, decoded); + let rel = path.relative(outRoot, abs); + if (rel.startsWith("..") || path.isAbsolute(rel)) rel = path.relative(root, abs); + return path.posix.relative("daemon/dist", rel.split(path.sep).join("/")); + }); + await writeFile(file, JSON.stringify(map)); +} + +export async function buildAll(outRoot = root) { + const buildOptions = options(outRoot); + await mkdir(path.join(outRoot, "daemon/dist"), { recursive: true }); + await mkdir(path.join(outRoot, "app"), { recursive: true }); + await copyFile(path.join(root, "web/index.html"), path.join(outRoot, "app/index.html")); + await Promise.all([esbuild.build(buildOptions.daemon), esbuild.build(buildOptions.web)]); + if (!existsSync(path.join(outRoot, "app/app.css"))) + await writeFile(path.join(outRoot, "app/app.css"), "/* built by build-v1 */\n"); + await normalizeSourcemap(outRoot); +} + +async function main() { + await buildAll(); + const size = await stat(path.join(root, "app/app.js")); + if (size.size > 3 * 1024 * 1024) + console.warn(`[build-v1] warning: app.js ${(size.size / 1024 / 1024).toFixed(2)}MB exceeds 3MB budget`); + else console.log(`[build-v1] app.js ${(size.size / 1024).toFixed(0)}KB`); + console.log("[build-v1] complete"); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv.includes("--watch")) { + const buildOptions = options(); + await copyFile(path.join(root, "web/index.html"), path.join(root, "app/index.html")); + const daemonContext = await esbuild.context(buildOptions.daemon); + const webContext = await esbuild.context(buildOptions.web); + await Promise.all([daemonContext.watch(), webContext.watch()]); + console.log("[build-v1] watch mode started"); + } else { + await main(); + } +} diff --git a/packages/agent-console/scripts/e2e-v1.mjs b/packages/agent-console/scripts/e2e-v1.mjs new file mode 100644 index 00000000..23b53408 --- /dev/null +++ b/packages/agent-console/scripts/e2e-v1.mjs @@ -0,0 +1,541 @@ +// e2e (§11.2): playwright-core + Edge, isolated data directory _tmp/e2e-xdg, port 3497, AGENT_CONSOLE_MOCK=1. +// Scenarios 1–12, 16–19; each prints ✓/✗, and any ✗ exits with code 1. +import { spawn, execFileSync } from "node:child_process"; +import { findBrowser } from "./browser-path.mjs"; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright-core"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TMP = join(root, "_tmp"); +const XDG = join(TMP, "e2e-xdg"); +const PROJ = join(TMP, "e2e-proj"); +const PORT = 3497; +// Edge / Chrome / Chromium (Windows, macOS, Linux); MSEDGE_PATH or CHROME_PATH may be set +const EDGE = findBrowser(); + +const results = []; +const pageErrors = []; +let daemon, page, browser; +let daemonErr = []; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const log = (...a) => console.log(...a); +function record(name, ok, extra = "") { + results.push({ name, ok }); + log(`${ok ? "✓" : "✗"} ${name}${extra ? " " + extra : ""}`); +} + +async function wsCall(method, params) { + return page.evaluate(async ([m, p]) => { + const ws = window.__ws; + return await ws.call(m, p); + }, [method, params]); +} + +async function waitFor(fn, ms = 15000, label = "") { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + try { + const v = await fn(); + if (v) return v; + } catch {} + await sleep(200); + } + throw new Error("等待超时 " + label); +} + +const sel = { + newProject: 'text=新建项目', + dlgInput: '.dlg .tinput', + dlgAdd: '.dlg .btn.primary', + threadItem: '.t-item', + ta: '.cp-ta', + send: '.cp-send', + stop: '.cp-stop', + permCard: '.card.perm', + allow: 'button:has-text("允许")', + queueItem: '.q-item', + pill: '.tb-pill', +}; + +async function setup() { + rmSync(XDG, { recursive: true, force: true }); + rmSync(PROJ, { recursive: true, force: true }); + mkdirSync(PROJ, { recursive: true }); + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\n"); + writeFileSync(join(PROJ, "b.txt"), "gamma\n"); + mkdirSync(join(PROJ, "src"), { recursive: true }); + writeFileSync(join(PROJ, "src", "index.ts"), "const x = 1;\n"); + writeFileSync(join(PROJ, ".gitattributes"), "* -text\n"); + // Initialize e2e-proj as a git repository (scenario 13 needs it) + try { + const g = (a) => execFileSync("git", a, { cwd: PROJ, stdio: "pipe", windowsHide: true }); + g(["init"]); g(["config", "user.email", "e@e"]); g(["config", "user.name", "e"]); + g(["config", "core.autocrlf", "false"]); // Keep CRLF from making a.txt show as modified again after restore + g(["add", "-A"]); g(["commit", "-m", "init", "-q"]); + } catch (e) { log("[e2e] git init 失败(场景13 会失败):", String(e).slice(0, 120)); } + + daemon = spawn("node", [join(root, "daemon/dist/main.js"), "--port", String(PORT), "--data", XDG], { + env: { ...process.env, AGENT_CONSOLE_MOCK: "1" }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + daemonErr = []; + daemon.stderr.on("data", (d) => { daemonErr.push(String(d).trim()); process.stderr.write(`[daemon] ${d}`); }); + daemon.stdout.on("data", (d) => { const s = String(d); if (s.includes("已启动") || s.includes("error")) log("[daemon]", s.trim()); }); + + // Wait for ping + const t0 = Date.now(); + let token = null; + while (Date.now() - t0 < 15000) { + try { + const dj = JSON.parse(readFileSync(join(XDG, "magpie/agent-console/daemon.json"), "utf8")); + token = dj.token; + break; + } catch { await sleep(300); } + } + if (!token) throw new Error("daemon 未启动"); + globalThis.__token = token; + + browser = await chromium.launch({ executablePath: EDGE, headless: true, args: process.platform === "linux" && process.getuid?.() === 0 ? ["--no-sandbox"] : [] }); + page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.on("pageerror", (e) => pageErrors.push(`pageerror: ${e.message}`)); + page.on("dialog", (d) => { pageErrors.push(`dialog: ${d.message()}`); void d.dismiss(); }); + page.on("console", (m) => { if (m.type() === "error") pageErrors.push(`console: ${m.text()}`); if (m.text().startsWith("[tl]") || m.text().startsWith("[ws]")) pageErrors.push(`dbg: ${m.text()}`); }); + await page.goto(`http://127.0.0.1:${PORT}/?lang=zh#t=${encodeURIComponent(token)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + // e2e uses the Mock provider (real Claude would show permission prompts and bill usage) + await wsCall("settings.set", { patch: { defaultProvider: "mock" } }); + // Codex detection in providers.list takes seconds: wait for the data layer before acting, so the empty-conversation composer and "+" don't each create a conversation + await page.waitForFunction(() => window.__stores?.useData?.getState?.().loaded === true, { timeout: 20000 }); +} + +async function newProjectFlow() { + // Scenario 1: no-project onboarding → create → land in a new conversation + await page.click(sel.newProject); + await page.fill(sel.dlgInput, PROJ); + await page.waitForSelector('text=目录可用', { timeout: 5000 }); + await page.click(sel.dlgAdd); + await waitFor(async () => (await page.$$('.pj-group')).length >= 1, 8000, "项目组出现"); + // New conversation (project row +); wait until it's really selected — the empty state also has .cp-ta, and acting early would create another conversation + await page.hover('.pj'); + await page.click('.pj-act[title="新建对话"]'); + await page.waitForSelector('.t-item.on', { timeout: 8000 }); + await page.waitForSelector(sel.ta, { timeout: 5000 }); + record("场景1 新建项目→新对话", true); +} + +async function send(text) { + await page.fill(sel.ta, text); + await page.keyboard.press("Enter"); +} + +// End-of-turn markers: count turnend/err entries in the store, not the DOM (the virtual list renders only the visible area, so DOM counts miss some). +async function endCount() { + return await page.evaluate(() => { + const items = window.__tl?.getState?.().items ?? []; + return items.filter((i) => i.kind === "turnend" || (i.kind === "permission" && i.status !== "pending")).length; + }); +} +async function waitEnd(seq = 0) { + const t0 = Date.now(); + while (Date.now() - t0 < 45000) { + const n = await endCount(); + if (n > seq) return n; + // The daemon is idle but the DOM has no closing marker, which is also wrong → fall back to checking the DOM + const st = await wsCall("threads.get", { id: await curThread() }).then((g) => g.runtimeStatus).catch(() => "?"); + if (st !== "running" && st !== "initializing") { + await sleep(400); // Wait for the last frame to render + if ((await endCount()) > seq) return seq + 1; + } + await sleep(300); + } + const dbg = await wsCall("threads.get", { id: await curThread() }).then((g) => `${g.runtimeStatus} evts:${g.events.length} last:${g.events.map(e=>e.type).slice(-3).join(",")} provider:${g.meta.provider}`).catch((e) => String(e)); + const dom = await page.evaluate(() => document.querySelector(".tl-wrap")?.innerText?.slice(-200)).catch(() => "?"); + throw new Error(`等待超时 turn_ended ${seq}(${dbg})dom尾=${dom}`); +} + +async function scenario2() { + await send("看下这个项目"); + await waitEnd(); + const checks = await page.evaluate(() => ({ + md: !!document.querySelector(".md"), + tg: !!document.querySelector(".tg"), + diff: !!document.querySelector(".diff"), + todo: !!document.querySelector(".rs-item"), + te: !!document.querySelector(".te"), + })); + record("场景2 默认剧本(正文/工具组/diff/待办/结束行)", checks.md && checks.tg && checks.diff && checks.todo && checks.te, JSON.stringify(checks)); +} + +async function scenario3() { + const sid0 = await wsCall("threads.get", { id: await curThread() }).then((g) => g.meta.providerSessionId); + await send("再问一句"); + await waitEnd(1); + const sid1 = await wsCall("threads.get", { id: await curThread() }).then((g) => g.meta.providerSessionId); + record("场景3 多轮同一会话(providerSessionId 不变)", sid0 === sid1 && !!sid0, `${sid0} → ${sid1}`); +} + +let _tid = null; +async function curThread() { + // Use the conversation currently selected on the page (threads.list()[0] is sorted by updatedAt and picks the wrong one with several conversations) + if (!_tid) _tid = await page.evaluate(() => window.__stores?.useUi?.getState?.().currentThreadId ?? null); + return _tid; +} + +async function scenario4() { + await send("/slow"); + await waitFor(async () => (await page.$(sel.pill)) !== null, 8000, "running 胶囊"); + await send("排队消息一"); + await send("排队消息二"); + const n = await waitFor(async () => { + const c = await page.$$eval(sel.queueItem, (e) => e.length); + return c >= 2 ? c : null; + }, 5000, "排队列表"); + await waitEnd(1 + await page.$$eval('.te', (e) => e.length).catch(() => 1)); // Wait for slow to finish, then queue two more turns + // Wait for three turnends: slow + queue 1 + queue 2 — relaxed to waiting until the queue is empty + await waitFor(async () => (await page.$$eval(sel.queueItem, (e) => e.length)) === 0, 45000, "队列清空"); + record("场景4 /slow 排队列表 + 自动发送", n >= 2); +} + +async function scenario5() { + await send("/slow"); + await waitFor(() => page.$(sel.stop), 8000, "运行中"); + await page.keyboard.down("Control"); + await page.fill(sel.ta, "插话内容XYZ"); + await page.keyboard.press("Enter"); + await page.keyboard.up("Control"); + await waitFor(async () => { + const html = await page.content(); + return html.includes("插话:插话内容XYZ"); + }, 40000, "回复含插话"); + record("场景5 Ctrl+Enter 插话", true); +} + +async function scenario6() { + await send("/slow"); + await waitFor(() => page.$(sel.stop), 8000, "stop 按钮"); + const t0 = Date.now(); + await page.click(sel.stop); + await waitFor(async () => { + const g = await wsCall("threads.get", { id: await curThread() }); + return g.runtimeStatus === "idle" || g.runtimeStatus === "closed" ? true : null; + }, 8000, "idle"); + const ms = Date.now() - t0; + await send("停完再来一句"); + await waitEnd(await page.$$eval('.te, .err-card', (e) => e.length)); + record("场景6 停止≤1s→idle→再发成功", ms < 5000, `interrupted ${ms}ms`); +} + +async function scenario7() { + const tid = await curThread(); + await send("/perm"); + await page.waitForSelector(sel.permCard, { timeout: 15000 }); + const waiting = await page.$eval(sel.ta, (el) => el.placeholder.includes("授权")); + await page.click(`${sel.permCard} button:has-text("允许")`); + await waitEnd(await page.$$eval('.te', (e) => e.length - 0)); + // Deny path + await send("/perm"); + await page.waitForSelector(`${sel.permCard} button:has-text("拒绝")`, { timeout: 15000 }); + await page.click(`${sel.permCard} button:has-text("拒绝")`); + await waitFor(async () => { + const html = await page.content(); + return html.includes("已被用户拒绝") || html.includes("已按你的要求跳过"); + }, 15000, "拒绝后回复"); + record("场景7 授权卡片 允许/拒绝", waiting); +} + +async function scenario8() { + await send("/plan"); + await page.waitForSelector('.card.plan', { timeout: 15000 }); + await page.click('.card.perm button:has-text("开始实施")'); + await waitEnd(await page.$$eval('.te', (e) => e.length - 0)); + const mode = await wsCall("threads.get", { id: await curThread() }).then((g) => g.meta.mode); + record("场景8 计划卡片 → 实施(模式变化检查)", mode !== null); +} + +async function scenario9() { + await send("/ask"); + await page.waitForSelector('.card.ask', { timeout: 15000 }); + await page.click('.card.ask button:has-text("生产环境")'); + await page.fill('.ask-free', "记得备份"); + await page.click('.card.ask button:has-text("提交")'); + await waitFor(async () => (await page.content()).includes("生产环境"), 15000, "回复含答案"); + record("场景9 提问卡片 → 回复包含答案", true); +} + +async function scenario10() { + await send("/exit"); + let last = "?"; + try { + await waitFor(async () => { + const g = await wsCall("threads.get", { id: await curThread() }); + last = `${g.runtimeStatus}/${g.meta.status} evts:${g.events.length}`; + return g.runtimeStatus === "closed" || g.meta.status === "closed" ? true : null; + }, 15000, "closed"); + } catch (e) { + const dom = await page.evaluate(() => document.querySelector(".cp-ta")?.placeholder + "|disabled:" + document.querySelector(".cp-ta")?.disabled + "|" + document.querySelector(".tl-wrap")?.innerText?.slice(-150)); + throw new Error(`等待超时 closed last=${last} dom=${dom}`); + } + await send("你还在吗"); + try { + await waitFor(async () => (await page.content()).includes("(已续接)"), 20000, "续接前缀"); + } catch (e) { + const g = await wsCall("threads.get", { id: await curThread() }).catch((x) => String(x)); + const dbg = await page.evaluate(() => { + const tl = window.__tl?.getState?.(); + return { + status: tl?.status, tid: tl?.threadId, n: tl?.items?.length, deduped: tl?.deduped, gaps: tl?.gaps?.slice(0, 8), seqMax: tl?.seqMax, evCount: tl?.evCount, + tail: tl?.items?.slice(-6).map((i) => `${i.kind}:${(i.text ?? i.title ?? "").slice(0, 40)}`), + dom: document.querySelector(".tl-wrap")?.innerText?.slice(-150), + }; + }).catch((x) => String(x)); + throw new Error(`续接前缀超时 rt=${g.runtimeStatus} meta=${g.meta?.status} evts=${g.events?.length} last=${g.events?.slice(-5).map((x) => x.type + ":" + (x.status ?? "")).join(",")} dbg=${JSON.stringify(dbg).slice(0, 200)} logs=${pageErrors.filter((e) => e.startsWith("dbg:")).slice(-6).join("|")}`); + } + record("场景10 /exit 崩溃 → 再发续接", true); +} + +async function scenario11() { + await send("/perm"); + await page.waitForSelector(sel.permCard, { timeout: 15000 }); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + // After a reload the current conversation is still there (lastThreadId) and so is the card (pending or cancelled) + await waitFor(async () => (await page.$('.card.perm')) !== null || (await page.$('.cp-ta')) !== null, 10000, "刷新后恢复"); + const hasPerm = await page.$('.card.perm'); + const cur = await curThread().catch(() => null); + const selTid = await page.evaluate(() => window.__stores?.useUi?.getState?.().currentThreadId).catch(() => "?"); + record("场景11 刷新恢复(对话/卡片)", !!cur && !!hasPerm, `cur=${cur} sel=${selTid} perm=${!!hasPerm}`); + // Cleanup: cancel this permission card (deny) so it doesn't block later scenarios + try { + const g = await wsCall("threads.get", { id: cur }); + const pend = g.pending?.[0]; + if (pend) await wsCall("thread.respond", { id: cur, requestId: pend.requestId, optionId: "decline" }); + } catch {} +} + +async function scenario12() { + // Upload a small png with the attachment button (setInputFiles goes through input[type=file]) + const png = join(TMP, "e2e.png"); + writeFileSync(png, Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c626001000000ffff03000006000557bfabd40000000049454e44ae426082", "hex")); + const input = await page.waitForSelector('input[type=file]', { state: "attached", timeout: 8000 }); + await input.setInputFiles(png); + await page.waitForSelector('.att', { timeout: 8000 }); + // Wait for the upload to finish (send is disabled while uploading) + try { + await waitFor(async () => !(await page.$eval(sel.send, (b) => b.disabled).catch(() => true)), 15000, "上传完成"); + } catch (e) { + const probe = await page.evaluate(async () => { + const tl = window.__tl?.getState?.(); + return { + pending: tl?.pending, status: tl?.status, + atts: [...document.querySelectorAll(".att")].map((a) => a.outerHTML.slice(0, 120)), + sendDisabled: document.querySelector(".cp-send")?.disabled, + }; + }); + throw new Error(`上传完成超时 ${JSON.stringify(probe)} errs=${pageErrors.slice(-6).join("|").slice(0, 300)}`); + } + await page.click(sel.send); + // Verify at the data layer (the user message has an attachment of image type); thumbnail rendering is the UI layer's job + await waitFor(async () => { + const g = await wsCall("threads.get", { id: await curThread() }); + const um = g.events.filter((e) => e.type === "user_message").pop(); + return um?.attachments?.some((a) => a.mime?.startsWith("image/")) ? true : null; + }, 15000, "用户消息带图片附件"); + await waitFor(async () => (await page.$('.um-img')) !== null || (await page.evaluate(() => { + const items = window.__tl?.getState?.().items ?? []; + return items.some((i) => i.kind === "user" && i.attachments?.length); + })), 15000, "缩略图条目"); + record("场景12 附件上传 → 用户消息含缩略图", true); +} + +async function scenario16() { + await page.keyboard.press("Control+k"); + await page.waitForSelector(".cpal", { timeout: 5000 }); + const title = await page.$eval('.t-item.on .t-title, .t-item .t-title', (e) => e.textContent).catch(() => "新对话"); + await page.fill(".cpal-in", "新对话"); + await page.keyboard.press("Enter"); + await sleep(400); + const on = await page.$eval(".tb-title", (e) => e.textContent).catch(() => ""); + record("场景16 命令面板 → 打开对话", !!on); +} + +async function scenario17() { + const t0 = Date.now(); + await send("/long"); + const opened = Date.now() - t0; + await waitFor(async () => (await page.$('.te')) !== null, 60000, "/long 完成"); + // Scroll to the top and back to the bottom without errors (virtual list already tested; check for no new pageerror) + const errBefore = pageErrors.length; + await page.evaluate(() => { const s = document.querySelector(".scroll"); s.scrollTop = 0; }); + await sleep(300); + await page.evaluate(() => { const s = document.querySelector(".scroll"); s.scrollTop = s.scrollHeight; }); + await sleep(300); + record("场景17 /long 打开 ≤1s(记录值)+ 滚动无错", opened < 3000 && pageErrors.length === errBefore, `${opened}ms`); +} + +async function scenario18() { + await page.setViewportSize({ width: 560, height: 900 }); + await sleep(400); + const res = await page.evaluate(() => { + const sb = document.querySelector(".sidebar"); + const st = getComputedStyle(sb); + return { + sbFixed: st.position === "fixed", + noHScroll: document.documentElement.scrollWidth <= 560 + 1, + }; + }); + record("场景18 窄屏 560px:抽屉 + 无横向滚动", res.sbFixed && res.noHScroll, JSON.stringify(res)); + await page.setViewportSize({ width: 1440, height: 900 }); +} + +async function scenario19() { + // Filter benign console errors (e.g. favicon 404) + const bad = pageErrors.filter((e) => !/favicon|404|net::/i.test(e) && !e.startsWith("dbg:")); + record("场景19 无 pageerror / console error", bad.length === 0, bad.slice(0, 3).join(" | ")); +} + +async function scenario13() { + // e2e-proj is already a git repository; make a change → changes panel appears → click diff → discard → it disappears + const pid = await page.evaluate(() => { + const ui = window.__stores.useUi.getState(); + const data = window.__stores.useData.getState(); + const th = data.threads.find((t) => t.id === ui.currentThreadId); + return th?.projectId ?? ui.currentProjectId ?? data.projects[0]?.id; + }); + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\nCHANGEME\n"); + await page.click('.ibtn[title="改动(Ctrl+J)"]'); + await page.waitForSelector(".chg-row", { timeout: 8000 }); + const name = await page.$eval(".chg-row .chg-path", (e) => e.textContent); + await page.click(".chg-row"); + await page.waitForSelector(".chg-diff .diff", { timeout: 8000 }); + const hasAdd = await page.$eval(".chg-diff", (e) => e.textContent.includes("CHANGEME")); + // Discard the change + await page.hover(".chg-row"); + await page.click(".chg-discard"); + await page.waitForSelector(".dlg", { timeout: 5000 }); + await page.click('.dlg .btn.danger'); + try { + await waitFor(async () => { + const st = await wsCall("git.status", { projectId: pid }); + return !(st.files ?? []).some((f) => f.path === "a.txt") ? true : null; + }, 10000, "a.txt 改动消失"); + } catch (e) { + const st = await wsCall("git.status", { projectId: pid }).catch((x) => String(x)); + const onDisk = readFileSync(join(PROJ, "a.txt"), "utf8"); + throw new Error(`${e.message} | status=${JSON.stringify(st).slice(0, 300)} | disk=${JSON.stringify(onDisk).slice(0, 120)} | pid=${pid}`); + } + await waitFor(async () => { + const p = await page.$$eval(".chg-path", (e) => e.map((x) => x.textContent)); + return !p.includes("a.txt") ? true : null; + }, 6000, "a.txt 行从 DOM 消失"); + const rowPaths = await page.$$eval(".chg-path", (e) => e.map((x) => x.textContent)); + const aTxtGone = !rowPaths.includes("a.txt"); + record("场景13 改动面板:列表→diff→放弃→a.txt 消失", name === "a.txt" && hasAdd && aTxtGone, `file=${name} add=${hasAdd} rows=${JSON.stringify(rowPaths)}`); +} + +async function scenario14() { + await page.click('.ibtn[title="文件"]'); + await page.waitForSelector(".ft-row", { timeout: 8000 }); + // Expand src/ and click a.txt to preview it + const rows = await page.$$eval(".ft-row .ft-name", (els) => els.map((e) => e.textContent)); + await page.click('.ft-row:has-text("src")'); + await page.waitForSelector('.ft-row:has-text("index.ts")', { timeout: 5000 }); + await page.click('.ft-row:has-text("a.txt")'); + await page.waitForSelector(".ft-pre", { timeout: 8000 }); + const text = await page.$eval(".ft-pre", (e) => e.textContent); + record("场景14 文件面板:树展开 + 预览", rows.includes("src") && text.includes("alpha"), `rows=${rows.length} text=${text.slice(0, 30)}`); +} + +async function scenario15() { + await page.click('.ibtn[title="终端"]'); + // New terminal + await waitFor(async () => { + const empty = await page.$('.tm-body .pn-empty .btn'); + if (empty) { await empty.click(); return true; } + return (await page.$$(".tm-view")).length > 0 ? true : null; + }, 5000, "新建终端"); + await waitFor(async () => (await page.$$(".tm-tab")).length >= 1, 8000, "终端 tab"); + // Wait for xterm to mount + attach to finish, then type echo hello-e2e + try { + await page.waitForSelector(".tm-view .xterm", { timeout: 10000 }); + } catch (e) { + const dbg = await page.evaluate(() => ({ + tabs: [...document.querySelectorAll(".tm-tab")].map((x) => x.textContent), + views: [...document.querySelectorAll(".tm-view")].map((x) => ({ d: x.style.display, cs: getComputedStyle(x).display, w: x.offsetWidth, h: x.offsetHeight })), + pnBody: document.querySelector(".pn-body")?.className, + panelOpen: window.__stores?.useUi?.getState?.().panelOpen, + })).catch((x) => String(x)); + throw new Error(`xterm 未可见 ${JSON.stringify(dbg)}`); + } + await sleep(1200); // Type only after the attach snapshot has been replayed + await page.click(".tm-view .xterm"); + await page.keyboard.type("echo hello-e2e", { delay: 10 }); + await page.keyboard.press("Enter"); + await waitFor(async () => { + const t = await page.$eval(".tm-view .xterm", (e) => e.textContent).catch(() => ""); + return t.includes("hello-e2e") ? true : null; + }, 15000, "终端输出 echo"); + // Reload the page → the terminal is still there with its history + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + await page.waitForFunction(() => window.__stores?.useData?.getState?.().loaded === true, { timeout: 20000 }); + // Panel state persists in settings: after a reload, don't click if it's already open (clicking again would close it) + const needOpen = await page.evaluate(() => { + const u = window.__stores.useUi.getState(); + return !(u.panelOpen && u.panelTab === "terminal"); + }); + if (needOpen) await page.click('.ibtn[title="终端"]'); + await waitFor(async () => (await page.$$(".tm-tab")).length >= 1, 10000, "刷新后终端仍在"); + try { + await page.waitForSelector(".tm-view .xterm", { timeout: 10000 }); + } catch (e) { + const dbg = await page.evaluate(() => ({ + tabs: [...document.querySelectorAll(".tm-tab")].map((x) => x.textContent), + views: [...document.querySelectorAll(".tm-view")].map((x) => ({ d: x.style.display, cs: getComputedStyle(x).display, w: x.offsetWidth, h: x.offsetHeight })), + terms: [...document.querySelectorAll(".tm-body")].map((x) => x.childElementCount), + })).catch((x) => String(x)); + throw new Error(`刷新后 xterm 未可见 ${JSON.stringify(dbg)}`); + } + await waitFor(async () => { + const t = await page.$eval(".tm-view .xterm", (e) => e.textContent).catch(() => ""); + return t.includes("hello-e2e") ? true : null; + }, 15000, "刷新后历史仍在"); + record("场景15 终端:echo→输出→刷新→历史仍在", true); +} + +async function main() { + await setup(); + try { + await newProjectFlow(); + await scenario2(); + await scenario3(); + await scenario4(); + await scenario5(); + await scenario6(); + await scenario7(); + await scenario8(); + await scenario9(); + await scenario10(); + await scenario11(); + await scenario12(); + await scenario13(); + await scenario14(); + await scenario15(); + await scenario16(); + await scenario17(); + await scenario18(); + await scenario19(); + } catch (e) { + record("中途异常", false, String(e?.message ?? e).slice(0, 600) + " ||daemon:" + daemonErr.slice(-4).join("|").slice(0, 400)); + } + const pass = results.filter((r) => r.ok).length; + log(`\n===== ${pass}/${results.length} 通过 =====`); + await browser?.close().catch(() => {}); + daemon?.kill(); + process.exit(results.every((r) => r.ok) ? 0 : 1); +} + +main().catch((e) => { console.error(e); daemon?.kill(); process.exit(1); }); diff --git a/packages/agent-console/scripts/shot-v1.mjs b/packages/agent-console/scripts/shot-v1.mjs new file mode 100644 index 00000000..41ad992c --- /dev/null +++ b/packages/agent-console/scripts/shot-v1.mjs @@ -0,0 +1,199 @@ +// Screenshot self-check (§11.3): playwright-core + Edge, scenarios × light/dark × 1440/960/560 → _tmp/shots/v1/. +// Scenarios: empty-project onboarding, empty conversation, default script finished, permission card, plan card, command palette (panel scenarios belong to P3-5). +// Each scenario also captures ?audit=1 metrics → _tmp/shots/v1/audit.json. +import { spawn } from "node:child_process"; +import { findBrowser } from "./browser-path.mjs"; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright-core"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TMP = join(root, "_tmp"); +const XDG = join(TMP, "shot-xdg"); +const PROJ = join(TMP, "shot-proj"); +const OUT = join(TMP, "shots", "v1"); +const PORT = 3497; +// Edge / Chrome / Chromium (Windows, macOS, Linux); MSEDGE_PATH or CHROME_PATH may be set +const EDGE = findBrowser(); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const audits = {}; +let daemon, browser; + +async function wsCall(page, m, p) { + return page.evaluate(async ([m, p]) => await window.__ws.call(m, p), [m, p]); +} + +async function setup() { + rmSync(XDG, { recursive: true, force: true }); + rmSync(PROJ, { recursive: true, force: true }); + rmSync(OUT, { recursive: true, force: true }); + mkdirSync(PROJ, { recursive: true }); + mkdirSync(OUT, { recursive: true }); + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\n"); + // The changes panel scenario needs a git repository + try { + const { execFileSync } = await import("node:child_process"); + const g = (a) => execFileSync("git", a, { cwd: PROJ, stdio: "pipe", windowsHide: true }); + g(["init"]); g(["config", "user.email", "s@s"]); g(["config", "user.name", "s"]); + g(["config", "core.autocrlf", "false"]); + g(["add", "-A"]); g(["commit", "-m", "init", "-q"]); + } catch {} + daemon = spawn("node", [join(root, "daemon/dist/main.js"), "--port", String(PORT), "--data", XDG], { + env: { ...process.env, AGENT_CONSOLE_MOCK: "1" }, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + }); + daemon.stderr.on("data", (d) => process.stderr.write(`[daemon] ${d}`)); + const t0 = Date.now(); + while (Date.now() - t0 < 15000) { + try { JSON.parse(readFileSync(join(XDG, "magpie/agent-console/daemon.json"), "utf8")); break; } + catch { await sleep(300); } + } + browser = await chromium.launch({ executablePath: EDGE, headless: true, args: process.platform === "linux" && process.getuid?.() === 0 ? ["--no-sandbox"] : [] }); +} + +// A separate page per scenario: set up the state first, then switch theme/width and shoot +async function newPage(width, theme, audit = false) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + const dj = JSON.parse(readFileSync(join(XDG, "magpie/agent-console/daemon.json"), "utf8")); + const query = `?theme=${encodeURIComponent(theme)}&lang=zh${audit ? "&audit=1" : ""}`; + await page.goto(`http://127.0.0.1:${PORT}/${query}#t=${encodeURIComponent(dj.token)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__ws && window.__ws.getState() === "open", { timeout: 10000 }); + await page.waitForFunction(() => window.__stores?.useData?.getState?.().loaded === true, { timeout: 20000 }); + await wsCall(page, "settings.set", { patch: { defaultProvider: "mock" } }); + return page; +} + +async function shot(page, name, theme, width) { + await page.screenshot({ path: join(OUT, `${name}-${theme}-${width}.png`) }); +} + +async function grabAudit(page, name, theme, width) { + const el = await page.waitForSelector("#audit", { state: "attached", timeout: 8000 }).catch(() => null); + if (!el) { audits[`${name}-${theme}-${width}`] = { error: "no audit" }; return; } + audits[`${name}-${theme}-${width}`] = JSON.parse(await el.textContent()); +} + +// —— scenario setup (each state once, then one shot for each of 3 widths × 2 themes) —— +// For simplicity: evaluate inside the page to change data-theme + viewport and reshoot, instead of rebuilding the state for every combination. + +async function scenario_emptyProject() { + const page = await newPage(1440, "light"); + // No project: empty onboarding + return page; +} + +async function scenario_emptyThread(page) { + // Create a project + conversation but send no message + await page.click("text=新建项目"); + await page.fill(".dlg .tinput", PROJ); + await page.waitForSelector("text=目录可用"); + await page.click(".dlg .btn.primary"); + await sleep(600); + await page.hover(".pj"); + await page.click('.pj-act[title="新建对话"]'); + await page.waitForSelector(".t-item.on", { timeout: 8000 }); +} + +async function scenario_default(page) { + await page.fill(".cp-ta", "看下这个项目"); + await page.keyboard.press("Enter"); + // Wait for turn_ended + const t0 = Date.now(); + while (Date.now() - t0 < 45000) { + const g = await wsCall(page, "threads.get", { id: await page.evaluate(() => window.__stores.useUi.getState().currentThreadId) }); + if (g.runtimeStatus === "idle") break; + await sleep(300); + } + await sleep(1500); // reveal catches up +} + +async function scenario_perm(page) { + await page.fill(".cp-ta", "/perm"); + await page.keyboard.press("Enter"); + await page.waitForSelector(".card.perm", { timeout: 15000 }); +} + +async function scenario_plan(page) { + await page.fill(".cp-ta", "/plan"); + await page.keyboard.press("Enter"); + await page.waitForSelector(".card.plan", { timeout: 15000 }); +} + +async function scenario_cpal(page) { + await page.keyboard.press("Control+k"); + await page.waitForSelector(".cpal", { timeout: 5000 }); + await page.fill(".cpal-in", "对话"); + await sleep(300); +} + +async function scenario_changes(page) { + // Changes panel open: make a change, open the panel and click a file to see its diff + writeFileSync(join(PROJ, "a.txt"), "alpha\nbeta\nchanged-line\n"); + await page.click('.ibtn[title="改动(Ctrl+J)"]'); + await page.waitForSelector(".chg-row", { timeout: 8000 }); + await page.click(".chg-row"); + await page.waitForSelector(".chg-diff .diff", { timeout: 8000 }); +} + +async function scenario_terminal(page) { + await page.click('.ibtn[title="终端"]'); + const empty = await page.$(".tm-body .pn-empty .btn"); + if (empty) await empty.click(); + await page.waitForSelector(".tm-view .xterm", { timeout: 10000 }); + await page.click(".tm-view .xterm"); + await page.keyboard.type("echo hello", { delay: 15 }); + await page.keyboard.press("Enter"); + await sleep(1500); +} + +const SCENARIOS = [ + { name: "empty", prep: scenario_emptyProject }, + { name: "thread", prep: scenario_emptyThread }, + { name: "default", prep: scenario_default }, + { name: "perm", prep: scenario_perm }, + { name: "plan", prep: scenario_plan }, + { name: "cpal", prep: scenario_cpal }, + { name: "changes", prep: scenario_changes }, + { name: "terminal", prep: scenario_terminal }, +]; + +async function main() { + await setup(); + // One long page accumulating state: empty → thread → default → perm (resolved) → plan (resolved) → cpal + // A separate page per scenario is cleaner: a new page per scenario + set up the preceding state as needed + for (const sc of SCENARIOS) { + const page = await newPage(1440, "light", true); + try { + // Preconditions: thread needs a project + conversation; every other scenario starts from a conversation and then does its own setup + if (sc.name === "empty") { + // Empty-project onboarding + } else if (sc.name === "thread") { + await scenario_emptyThread(page); + } else { + await scenario_emptyThread(page); + await sc.prep(page); + } + await grabAudit(page, sc.name, "light", 1440); + for (const theme of ["light", "dark"]) { + await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, theme); + for (const w of [1440, 960, 560]) { + await page.setViewportSize({ width: w, height: 900 }); + await sleep(500); + await shot(page, sc.name, theme, w); + } + } + } catch (e) { + console.log(`✗ ${sc.name}: ${String(e?.message ?? e).slice(0, 120)}`); + } + await page.close(); + } + writeFileSync(join(OUT, "audit.json"), JSON.stringify(audits, null, 2)); + console.log(`截图完成 → ${OUT}(audit.json ${Object.keys(audits).length} 条)`); + await browser?.close().catch(() => {}); + daemon?.kill(); + process.exit(0); +} + +main().catch((e) => { console.error(e); daemon?.kill(); process.exit(1); }); diff --git a/packages/agent-console/scripts/verify-build.mjs b/packages/agent-console/scripts/verify-build.mjs new file mode 100644 index 00000000..17d5fc19 --- /dev/null +++ b/packages/agent-console/scripts/verify-build.mjs @@ -0,0 +1,31 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildAll, root } from "./build-v1.mjs"; + +const files = [ + "app/app.js", + "app/app.css", + "app/index.html", + "daemon/dist/main.js", + "daemon/dist/main.js.map", +]; +const output = await mkdtemp(join(tmpdir(), "agent-console-build-")); +let failed = false; +try { + await buildAll(output); + for (const file of files) { + const expected = await readFile(join(root, file)); + const actual = await readFile(join(output, file)); + const hash = createHash("sha256").update(expected).digest("hex"); + if (expected.equals(actual)) console.log(`OK ${hash} ${file}`); + else { + console.error(`MISMATCH ${hash} ${file}`); + failed = true; + } + } +} finally { + await rm(output, { recursive: true, force: true }); +} +if (failed) process.exitCode = 1; diff --git a/packages/agent-console/scripts/verify-windows.mjs b/packages/agent-console/scripts/verify-windows.mjs new file mode 100644 index 00000000..6af0af20 --- /dev/null +++ b/packages/agent-console/scripts/verify-windows.mjs @@ -0,0 +1,420 @@ +#!/usr/bin/env node +// One-shot Windows self-check (also runs on macOS / Linux, where Windows-only items are marked SKIP). +// +// cd /d +// node scripts\verify-windows.mjs full: npm ci → build → verify:build → npm test → all checks +// node scripts\verify-windows.mjs --quick skip npm ci and npm test (when dependencies are already installed) +// +// Changes no real configuration: the daemon uses a temporary data directory + the Mock Provider; Agent CLIs only run --version. +// The report is also written to _tmp/verify-windows.log. Exit code: 1 if anything FAILs, otherwise 0. +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, appendFileSync } from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const IS_WIN = process.platform === "win32"; +const args = new Set(process.argv.slice(2)); +const QUICK = args.has("--quick"); +const logFile = path.join(root, "_tmp", "verify-windows.log"); +mkdirSync(path.dirname(logFile), { recursive: true }); +writeFileSync(logFile, `verify-windows ${new Date().toISOString()}\n`); + +const results = []; +function out(line) { + console.log(line); + try { + appendFileSync(logFile, line + "\n"); + } catch {} +} +function record(status, name, detail = "") { + results.push({ status, name, detail }); + const mark = { PASS: "✓ PASS", WARN: "! WARN", FAIL: "✗ FAIL", SKIP: "- SKIP" }[status]; + out(`${mark} ${name}${detail ? " — " + detail : ""}`); +} +async function step(name, fn) { + try { + const r = await fn(); + if (r && typeof r === "object" && r.status) record(r.status, name, r.detail ?? ""); + else record("PASS", name, typeof r === "string" ? r : ""); + } catch (e) { + record("FAIL", name, String(e?.message ?? e).slice(0, 800)); + } +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// npm is npm.cmd on Windows: run it through cmd.exe (the command is hard-coded, no user input) +function npm(cmdArgs, { inherit = true } = {}) { + const r = IS_WIN + ? spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `npm ${cmdArgs.join(" ")}`], { + cwd: root, + stdio: inherit ? "inherit" : "pipe", + windowsHide: true, + }) + : spawnSync("npm", cmdArgs, { cwd: root, stdio: inherit ? "inherit" : "pipe" }); + if (r.error) throw r.error; + if (r.status !== 0) throw new Error(`npm ${cmdArgs.join(" ")} 退出码 ${r.status}`); + return r.stdout ? String(r.stdout).trim() : ""; +} + +function decode(buf) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buf); + } catch { + try { + return new TextDecoder("gbk").decode(buf); + } catch { + return Buffer.from(buf).toString("utf8"); + } + } +} + +// Bundle the tools from the daemon sources (PATH scan, shim resolution, process-tree termination, Provider catalog, default shell) into a temporary ESM and load it, +// so the checks run against exactly the code that goes into the daemon. +async function loadDaemonHelpers(tmp) { + const esbuild = await import("esbuild"); + const entry = path.join(tmp, "helpers-entry.ts"); + const rel = (p) => path.relative(tmp, path.join(root, p)).split(path.sep).join("/"); + writeFileSync( + entry, + [ + `export * from ${JSON.stringify(rel("daemon/src/util/bin.ts"))};`, + `export { PROVIDER_CATALOG } from ${JSON.stringify(rel("daemon/src/providers/catalog.ts"))};`, + `export { pickShell } from ${JSON.stringify(rel("daemon/src/terminal/service.ts"))};`, + `export { decodePickFolderStdout } from ${JSON.stringify(rel("daemon/src/util/pickFolderWin.ts"))};`, + `export { normPath } from ${JSON.stringify(rel("daemon/src/util/paths.ts"))};`, + ].join("\n"), + ); + const outfile = path.join(root, "_tmp", `verify-helpers-${process.pid}.mjs`); + await esbuild.build({ + entryPoints: [entry], + bundle: true, + platform: "node", + format: "esm", + target: "node20", + packages: "external", + outfile, + logLevel: "silent", + banner: { js: 'import { createRequire as __vcr } from "node:module"; const require = __vcr(import.meta.url);' }, + }); + try { + return await import(pathToFileURL(outfile).href); + } finally { + // Already loaded into memory; the file can be deleted + setTimeout(() => rmSync(outfile, { force: true }), 100).unref(); + } +} + +function runWithTimeout(child, ms) { + return new Promise((resolve) => { + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + child.stdout?.on("data", (d) => (stdout = Buffer.concat([stdout, d]))); + child.stderr?.on("data", (d) => (stderr = Buffer.concat([stderr, d]))); + const t = setTimeout(() => resolve({ code: null, timedOut: true, stdout, stderr }), ms); + child.on("error", (e) => { + clearTimeout(t); + resolve({ code: null, error: e, stdout, stderr }); + }); + child.on("close", (code) => { + clearTimeout(t); + resolve({ code, stdout, stderr }); + }); + }); +} + +function pidAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e?.code === "EPERM"; + } +} + +function getJson(port, pathname, token) { + return new Promise((resolve, reject) => { + const req = http.get({ host: "127.0.0.1", port, path: pathname, headers: { "x-agent-console": token }, timeout: 3000 }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => { + try { + resolve({ status: res.statusCode, body: JSON.parse(body) }); + } catch { + resolve({ status: res.statusCode, body }); + } + }); + }); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("timeout"))); + }); +} + +async function main() { + out(`系统: ${os.type()} ${os.release()} ${process.arch} Node ${process.version} (${process.execPath})`); + out(`仓库: ${root}`); + out(`用户目录: ${os.homedir()} 数据目录: ${path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "magpie", "agent-console")}`); + if (IS_WIN) { + try { + const cp = execFileSync(process.env.ComSpec || "cmd.exe", ["/d", "/c", "chcp"], { windowsHide: true }); + out(`控制台代码页: ${decode(cp).trim()}`); + } catch {} + } + out(""); + + // 1. Node / npm + await step("Node.js ≥ 20", () => { + const major = Number(process.versions.node.split(".")[0]); + if (major < 20) throw new Error(`当前 ${process.version},需要 20+`); + return process.version; + }); + await step("npm 可用", () => "npm " + npm(["-v"], { inherit: false })); + const nonAscii = /[^\x00-\x7f]/.test(root) || /\s/.test(root); + record(nonAscii ? "PASS" : "SKIP", "仓库路径含中文/空格(用于覆盖编码问题)", nonAscii ? root : "路径全是 ASCII,未覆盖"); + + // 2. Install / build + if (QUICK) record("SKIP", "npm ci", "--quick"); + else await step("npm ci", () => npm(["ci"])); + await step("npm run build", () => npm(["run", "build"])); + await step("npm run verify:build(提交的 bundle 与重新构建一致)", () => npm(["run", "verify:build"])); + await step("npm run check(类型检查)", () => npm(["run", "check"])); + if (QUICK) record("SKIP", "npm test", "--quick"); + else await step("npm test(含 Windows 专属的 CP936 用例)", () => npm(["test"])); + + const tmp = mkdtempSync(path.join(os.tmpdir(), "ac-verify-")); + let helpers = null; + try { + await step("加载 daemon 工具代码(esbuild 临时打包)", async () => { + helpers = await loadDaemonHelpers(tmp); + return "ok"; + }); + + // 3. node-pty + await step("node-pty 加载 + 用默认 shell 起一个 PTY", async () => { + const pty = await import("node-pty"); + const shell = helpers ? helpers.pickShell() : IS_WIN ? "powershell.exe" : "/bin/sh"; + const marker = "AC-PTY-中文-" + randomBytes(3).toString("hex"); + const p = pty.spawn(shell, [], { name: "xterm-256color", cols: 100, rows: 30, cwd: root, env: process.env }); + let text = ""; + p.onData((d) => (text += d)); + await sleep(1500); + p.write(`echo ${marker}\r`); + const t0 = Date.now(); + while (Date.now() - t0 < 10000 && !text.includes(marker + "\r") && !text.includes(marker + "\n")) await sleep(100); + const ok = text.split(marker).length >= 3 || /AC-PTY-中文-[0-9a-f]{6}\r?\n/.test(text); + try { + p.kill(); + } catch {} + if (!text.includes(marker)) throw new Error(`没有收到回显(shell=${shell}):${JSON.stringify(text.slice(-300))}`); + return ok ? `shell=${shell}` : { status: "WARN", detail: `收到回显但格式不同(shell=${shell})` }; + }); + + // 4. Agent CLI resolution + if (helpers) { + const { PROVIDER_CATALOG, resolveFirstBin, resolveBin, spawnBin, killTree } = helpers; + for (const p of PROVIDER_CATALOG) { + await step(`CLI 解析: ${p.name} (${p.bins.join(" / ")})`, async () => { + const r = await resolveFirstBin(p.bins); + if (!r.ok) return { status: "SKIP", detail: "未安装(PATH 和常见目录里都没有)" }; + const child = spawnBin(r, ["--version"], { stdio: ["ignore", "pipe", "pipe"] }); + const res = await runWithTimeout(child, 20000); + if (res.timedOut) await killTree(child, { graceMs: 500 }); + const text = decode(Buffer.concat([res.stdout, res.stderr])).trim().split(/\r?\n/)[0] ?? ""; + const detail = `via=${r.via} ${r.display}${text ? " → " + text.slice(0, 120) : ""}`; + if (res.error) throw new Error(`${detail} 启动失败: ${res.error.message}`); + if (res.timedOut) return { status: "WARN", detail: `${detail} (--version 20 秒没返回)` }; + return { status: res.code === 0 ? "PASS" : "WARN", detail: res.code === 0 ? detail : `${detail} 退出码 ${res.code}` }; + }); + } + await step("CLI 解析: npx", async () => { + const r = await resolveBin("npx"); + if (!r.ok) throw new Error(r.reason); + return `via=${r.via} ${r.display}`; + }); + + // 4b. shim resolution + argument escaping: create .cmd / .ps1 in a temp directory whose name has spaces and Chinese characters + const shimDir = path.join(tmp, "shim 测试 dir"); + mkdirSync(shimDir, { recursive: true }); + const probeArgs = ["a b", "x&y", "中文", 'q"t', "100%"]; + if (IS_WIN) { + writeFileSync(path.join(shimDir, "ac-args.js"), "process.stdout.write(JSON.stringify(process.argv.slice(2)))"); + writeFileSync(path.join(shimDir, "acjs.cmd"), '@ECHO off\r\n"node" "%~dp0\\ac-args.js" %*\r\n'); + writeFileSync(path.join(shimDir, "acbat.cmd"), "@ECHO off\r\nnode -e \"process.stdout.write(JSON.stringify(process.argv.slice(1)))\" -- %*\r\n"); + writeFileSync( + path.join(shimDir, "acps.ps1"), + "[Console]::OutputEncoding=[Text.Encoding]::UTF8\r\n[Console]::Out.Write((ConvertTo-Json -Compress @($args)))\r\n", + ); + } else { + writeFileSync(path.join(shimDir, "acjs"), `#!/usr/bin/env node\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)))\n`, { mode: 0o755 }); + } + const oldPath = process.env.PATH; + const pathKey = Object.keys(process.env).find((k) => k.toUpperCase() === "PATH") ?? "PATH"; + process.env[pathKey] = shimDir + path.delimiter + (process.env[pathKey] ?? ""); + helpers.clearBinCache?.(); + try { + const cases = IS_WIN + ? [ + ["acjs", "shim-js"], + ["acbat", "cmd-shim"], + ["acps", "ps1"], + ] + : [["acjs", "path"]]; + for (const [name, via] of cases) { + await step(`shim + 参数转义: ${name} (${via}),目录含空格和中文`, async () => { + const r = await resolveBin(name); + if (!r.ok) throw new Error(r.reason); + if (r.via !== via) throw new Error(`via=${r.via},期望 ${via}`); + const child = spawnBin(r, probeArgs, { stdio: ["ignore", "pipe", "pipe"] }); + const res = await runWithTimeout(child, 20000); + const text = decode(res.stdout).trim(); + let got = null; + try { + got = JSON.parse(text); + } catch {} + const want = probeArgs; + if (JSON.stringify(got) !== JSON.stringify(want)) { + // Batch %* and PowerShell -File each have quirks with embedded quotes: when the argument count is right and only a few characters differ, record a WARN for comparison + const detail = `得到 ${text || decode(res.stderr).slice(0, 200)},期望 ${JSON.stringify(want)}`; + if (name !== "acjs" && Array.isArray(got) && got.length === want.length) return { status: "WARN", detail }; + throw new Error(detail); + } + return JSON.stringify(got); + }); + } + } finally { + process.env[pathKey] = oldPath; + helpers.clearBinCache?.(); + } + + // 5. Process tree termination + await step("进程树结束(killTree:子进程 + 孙进程)", async () => { + const script = path.join(tmp, "tree-parent.js"); + writeFileSync( + script, + [ + 'const { spawn } = require("node:child_process");', + 'const g = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { stdio: "ignore" });', + "process.stdout.write(String(g.pid) + '\\n');", + "setInterval(()=>{},1000);", + ].join("\n"), + ); + writeFileSync(path.join(tmp, "package.json"), '{"type":"commonjs"}'); + const r = { ok: true, cmd: process.execPath, prefixArgs: [script], display: "node", via: "path" }; + const child = spawnBin(r, [], { stdio: ["ignore", "pipe", "ignore"], group: true }); + const gpid = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("没拿到孙进程 pid")), 10000); + child.stdout.once("data", (d) => { + clearTimeout(t); + resolve(Number(String(d).trim())); + }); + }); + if (!pidAlive(gpid)) throw new Error("孙进程没起来"); + await killTree(child, { graceMs: 1500 }); + for (let i = 0; i < 40 && pidAlive(gpid); i++) await sleep(100); + if (pidAlive(gpid)) { + try { + process.kill(gpid, "SIGKILL"); + } catch {} + throw new Error(`孙进程 ${gpid} 还活着`); + } + return `子 ${child.pid} / 孙 ${gpid} 都已结束`; + }); + + // 6. Encoding + await step("编码:GBK 控制台输出解码(pickFolder 回退)", () => { + const buf = Buffer.concat([Buffer.from("D:\\workwork\\magpie", "ascii"), Buffer.from("b2e5bcfe", "hex")]); + const s = helpers.decodePickFolderStdout(buf); + if (s !== "D:\\workwork\\magpie插件") throw new Error(String(s)); + if (!IS_WIN) return "静态样本 ok(真实 powershell 只在 Windows 上测)"; + const real = execFileSync( + "powershell.exe", + ["-NoProfile", "-Command", "[Console]::OutputEncoding = [Text.Encoding]::GetEncoding(936); [Console]::Out.WriteLine('D:\\workwork\\magpie插件')"], + { windowsHide: true }, + ); + const t = helpers.decodePickFolderStdout(real); + if (t !== "D:\\workwork\\magpie插件") throw new Error(`真实 powershell 得到 ${JSON.stringify(t)}`); + return "静态样本 + 真实 powershell CP936 都 ok"; + }); + await step("路径规范化(大小写 / 分隔符 / 中文)", () => { + const a = helpers.normPath(root); + const b = helpers.normPath(IS_WIN ? root.toUpperCase().replace(/\\/g, "/") : root); + if (IS_WIN && a !== b) throw new Error(`${a} != ${b}`); + return a; + }); + } + + // 7. Daemon start / stop + await step("daemon 启动 → /api/ping → 关 stdin 退出(Mock,临时数据目录)", async () => { + const dataRoot = path.join(tmp, "数据 data"); + const token = randomBytes(32).toString("hex"); + const child = spawn( + process.execPath, + [path.join(root, "daemon", "dist", "main.js"), "--port", "0", "--data", dataRoot, "--parent-pid", String(process.pid)], + { env: { ...process.env, AGENT_CONSOLE_TOKEN: token, AGENT_CONSOLE_MOCK: "1" }, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }, + ); + let stderr = ""; + child.stderr.on("data", (d) => (stderr += d)); + const port = await new Promise((resolve, reject) => { + let buf = ""; + const t = setTimeout(() => reject(new Error("15 秒内没有就绪行 " + stderr.slice(-300))), 15000); + child.stdout.on("data", (d) => { + buf += d; + for (const line of buf.split(/\r?\n/)) { + try { + const j = JSON.parse(line); + if (j.agentConsoleReady) { + clearTimeout(t); + resolve(j.port); + } + } catch {} + } + }); + child.once("exit", (code) => reject(new Error(`daemon 提前退出 ${code} ${stderr.slice(-300)}`))); + }); + const ping = await getJson(port, "/api/ping", token); + if (ping.status !== 200 || !ping.body?.ok) throw new Error("ping 失败 " + JSON.stringify(ping)); + const bad = await getJson(port, "/api/ping", "wrong"); + if (bad.status !== 403) throw new Error("错误令牌没有被拒绝"); + const daemonJson = path.join(dataRoot, "magpie", "agent-console", "daemon.json"); + if (!existsSync(daemonJson)) throw new Error("没有写 daemon.json"); + const t0 = Date.now(); + child.stdin.end(); + const code = await new Promise((resolve) => { + const t = setTimeout(() => resolve("timeout"), 8000); + child.once("exit", (c) => { + clearTimeout(t); + resolve(c); + }); + }); + if (code === "timeout") { + child.kill(); + throw new Error("关 stdin 后 8 秒没退出"); + } + if (existsSync(daemonJson)) throw new Error("退出后 daemon.json 还在"); + return `端口 ${port},${Date.now() - t0}ms 内退出(code ${code}),数据目录含中文和空格`; + }); + + // 8. Plugin entry: the message when node can't be found inside Bun (function check only) + await step("插件宿主找 node(宿主是 Bun 时)", async () => { + const m = await import(pathToFileURL(path.join(root, "lib", "pathscan.mjs")).href); + const node = m.requireNode({ execPath: IS_WIN ? "C:\\Magpie\\bun.exe" : "/opt/magpie/bun" }); + return node; + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + const count = (s) => results.filter((r) => r.status === s).length; + out(""); + out(`===== PASS ${count("PASS")} WARN ${count("WARN")} FAIL ${count("FAIL")} SKIP ${count("SKIP")} =====`); + out(`报告: ${logFile}`); + process.exit(count("FAIL") ? 1 : 0); +} + +main().catch((e) => { + out("自检脚本异常: " + (e?.stack ?? e)); + process.exit(1); +}); diff --git a/packages/agent-console/test/real/acp-resume.real.test.ts b/packages/agent-console/test/real/acp-resume.real.test.ts new file mode 100644 index 00000000..562dd039 --- /dev/null +++ b/packages/agent-console/test/real/acp-resume.real.test.ts @@ -0,0 +1,86 @@ +// Cheap real-CLI check: dsh advertises session/resume (not load), and Agent Console reopens via resume. +// Opt-in: AC_REAL_DSH=. Throwaway HOME; no model call, no sign-in. +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createAcpProvider } from "../../daemon/src/providers/acp/provider.js"; +import { acpConfigForEntry } from "../../daemon/src/providers/acp/specs.js"; +import { sessionOpenFromInitialize } from "../../daemon/src/providers/acp/sessionCaps.js"; +import { catalogById } from "../../daemon/src/providers/catalog.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; + +const tee = resolve(__dirname, "acp-tee.mjs"); +const has = !!process.env.AC_REAL_DSH; + +describe.runIf(has)("real dsh reconnect", () => { + let home: string; + let slog: string; + const saved: Record = {}; + + beforeAll(() => { + home = mkdtempSync(join(tmpdir(), "ac-dsh-resume-")); + slog = join(home, "session.jsonl"); + for (const [k, v] of Object.entries({ + HOME: home, XDG_CONFIG_HOME: join(home, ".config"), XDG_DATA_HOME: join(home, ".local", "share"), + ACP_TEE_SESSION_LOG: slog, + })) { + saved[k] = process.env[k]; + process.env[k] = v; + } + }); + afterAll(() => { + for (const [k, v] of Object.entries(saved)) v === undefined ? delete process.env[k] : (process.env[k] = v); + }); + + it("initialize: resume yes, load no; reopen uses session/resume", async () => { + // First: open a fresh session to get a real session id + const cfg = acpConfigForEntry(catalogById("dsh")!, "real")!; + const launch = { cmd: process.execPath, prefixArgs: [tee, process.env.AC_REAL_DSH!] }; + const first = await createAcpProvider(cfg).createSession({ + threadId: "a", cwd: home, model: null, mode: "default", effort: null, resumeId: null, launch, + }); + const events: ProviderEvent[] = []; + const pump = (async () => { for await (const ev of first.events) events.push(ev); })(); + const end = Date.now() + 30000; + while (Date.now() < end && !events.some((e) => e.type === "provider_session_id")) { + await new Promise((r) => setTimeout(r, 20)); + } + const id = (events.find((e) => e.type === "provider_session_id") as { id: string } | undefined)?.id; + expect(id).toBeTruthy(); + await first.close(); + await pump; + + // The tee logged session/new; clear and reopen with that id + const firstLog = existsSync(slog) ? readFileSync(slog, "utf8") : ""; + expect(firstLog).toContain('"session/new"'); + writeFileSync(slog, ""); + + const second = await createAcpProvider(cfg).createSession({ + threadId: "b", cwd: home, model: null, mode: "default", effort: null, resumeId: id!, launch, + }); + const ev2: ProviderEvent[] = []; + const pump2 = (async () => { for await (const ev of second.events) ev2.push(ev); })(); + const end2 = Date.now() + 30000; + while (Date.now() < end2 && !ev2.some((e) => e.type === "provider_session_id")) { + await new Promise((r) => setTimeout(r, 20)); + } + const reopen = (ev2.find((e) => e.type === "provider_session_id") as { id: string } | undefined)?.id; + const log = existsSync(slog) ? readFileSync(slog, "utf8") : ""; + console.log("[dsh-resume]", JSON.stringify({ id, reopen, log: log.trim() })); + expect(log).toContain('"session/resume"'); + expect(log).not.toContain('"session/load"'); + expect(reopen).toBe(id); + await second.close(); + await pump2; + }); +}); + +// Pure parse check against a recorded dsh initialize shape (no CLI needed) +describe("dsh initialize shape", () => { + it("matches what real dsh 0.2.1-rc.5 advertises", () => { + expect(sessionOpenFromInitialize({ + agentCapabilities: { loadSession: false, sessionCapabilities: { close: {}, list: {}, resume: {} } }, + })).toEqual({ load: false, resume: true }); + }); +}); diff --git a/packages/agent-console/test/real/acp-tee.mjs b/packages/agent-console/test/real/acp-tee.mjs new file mode 100644 index 00000000..4a63a6e8 --- /dev/null +++ b/packages/agent-console/test/real/acp-tee.mjs @@ -0,0 +1,26 @@ +// Sits between Agent Console and a real ACP agent: passes stdio through and appends every session/prompt the +// console sends to $ACP_TEE_LOG (one JSON line each), so a test sees the exact payload the agent got. +import { spawn } from "node:child_process"; +import { appendFileSync } from "node:fs"; +const [bin, ...args] = process.argv.slice(2); +const child = spawn(bin, args, { stdio: ["pipe", "inherit", "inherit"], env: process.env }); +let buf = ""; +process.stdin.on("data", (d) => { + child.stdin.write(d); + buf += d; + let n; + while ((n = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, n); + buf = buf.slice(n + 1); + try { + const m = JSON.parse(line); + if (m.method === "session/prompt" && process.env.ACP_TEE_LOG) appendFileSync(process.env.ACP_TEE_LOG, JSON.stringify(m.params.prompt) + "\n"); + if (/^session\/(new|load|resume)$/.test(m.method) && process.env.ACP_TEE_SESSION_LOG) { + appendFileSync(process.env.ACP_TEE_SESSION_LOG, JSON.stringify({ method: m.method, sessionId: m.params?.sessionId ?? null }) + "\n"); + } + } catch {} + } +}); +process.stdin.on("end", () => child.stdin.end()); +child.on("exit", (c, s) => process.exit(c ?? (s ? 1 : 0))); +for (const sig of ["SIGTERM", "SIGINT"]) process.on(sig, () => child.kill(sig)); diff --git a/packages/agent-console/test/real/agents-images.real.test.ts b/packages/agent-console/test/real/agents-images.real.test.ts new file mode 100644 index 00000000..0ded0461 --- /dev/null +++ b/packages/agent-console/test/real/agents-images.real.test.ts @@ -0,0 +1,238 @@ +// Do the real agent CLIs get the image Agent Console sends, all the way into the model request? +// Opt-in (npm run test:real); each agent runs only when its CLI is named: +// AC_REAL_DSH= AC_REAL_OPENCODE= AC_REAL_PI_ACP= (with pi on PATH) AC_REAL_COPILOT= +// AC_REAL_CODEX= AC_REAL_CLAUDE= AC_REAL_DEVIN= AC_REAL_CURSOR= +// Each runs in a throwaway HOME pointed at test/real/fake-llm.mjs (a local stand-in for the model API, no key, nothing +// leaves the machine), through Agent Console's own provider session, and the test looks for the image in what reached +// the "model". Devin and Cursor need a sign-in to run a model: for them it checks initialize and the exact ACP prompt +// Agent Console sends (captured by acp-tee.mjs), which fails at their login check. +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { createAcpProvider } from "../../daemon/src/providers/acp/provider.js"; +import { acpConfigForEntry } from "../../daemon/src/providers/acp/specs.js"; +import { knownPromptCaps } from "../../daemon/src/providers/acp/promptCaps.js"; +import { catalogById } from "../../daemon/src/providers/catalog.js"; +import { ClaudeSession } from "../../daemon/src/providers/claude/session.js"; +import { resolveBinWith } from "../../daemon/src/util/bin.js"; +import { createCodexProvider } from "../../daemon/src/providers/codex/provider.js"; +import type { ProviderSession } from "../../daemon/src/providers/types.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; +// @ts-ignore plain .mjs helper +import { startFakeLlm } from "./fake-llm.mjs"; + +type Fake = { url: string; requests: { url: string; body: unknown }[]; close(): Promise }; +let fake: Fake; +let png: string; +let pngB64: string; +const tee = resolve(__dirname, "acp-tee.mjs"); + +beforeAll(async () => { + fake = await startFakeLlm(); + const dir = mkdtempSync(join(tmpdir(), "ac-real-")); + png = join(dir, "red.png"); + // 16×16 solid red PNG + pngB64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAF0lEQVR4nGP4z8BAEiJN9aiGUQ1DSgMAkPn/Afnh+ngAAAAASUVORK5CYII="; + writeFileSync(png, Buffer.from(pngB64, "base64")); +}); +afterAll(async () => fake?.close()); + +/** Image parts anywhere in a request body: OpenAI image_url / input_image, Anthropic image, with their data */ +function imageParts(body: unknown): { type: string; data: string }[] { + const out: { type: string; data: string }[] = []; + const walk = (v: unknown) => { + if (Array.isArray(v)) return v.forEach(walk); + if (!v || typeof v !== "object") return; + const o = v as Record; + if (o.type === "image_url") out.push({ type: "image_url", data: String(o.image_url?.url ?? o.image_url ?? "") }); + else if (o.type === "input_image") out.push({ type: "input_image", data: String(o.image_url ?? o.file_id ?? "") }); + else if (o.type === "image" && o.source) out.push({ type: "image", data: String(o.source.data ?? o.source.url ?? "") }); + Object.values(o).forEach(walk); + }; + walk(body); + return out; +} +const sameImage = (data: string) => data.replace(/^data:image\/png;base64,/, "") === pngB64; +const sentImages = () => fake.requests.flatMap((r) => imageParts(r.body)); + +/** Throwaway HOME (and XDG dirs) for one agent run; returns a restore function */ +function isolate(extra: Record = {}): { home: string; restore: () => void } { + const home = mkdtempSync(join(tmpdir(), "ac-real-home-")); + const vars: Record = { + HOME: home, XDG_CONFIG_HOME: join(home, ".config"), XDG_DATA_HOME: join(home, ".local", "share"), + XDG_STATE_HOME: join(home, ".local", "state"), XDG_CACHE_HOME: join(home, ".cache"), ...extra, + }; + const saved: Record = {}; + for (const [k, v] of Object.entries(vars)) { saved[k] = process.env[k]; process.env[k] = v; } + return { home, restore: () => { for (const [k, v] of Object.entries(saved)) v === undefined ? delete process.env[k] : (process.env[k] = v); } }; +} + +async function turn(session: ProviderSession, text: string): Promise { + const events: ProviderEvent[] = []; + const it = session.events[Symbol.asyncIterator](); + await session.send({ text, images: [{ path: png, mime: "image/png" }] }); + const end = Date.now() + 200000; + while (Date.now() < end) { + const r = await Promise.race([it.next(), new Promise((ok) => setTimeout(() => ok(null), end - Date.now()))]); + if (!r || r.done) break; + events.push(r.value); + if (r.value.type === "turn_ended") break; + } + return events; +} + +function acpSession(id: string, bin: string, model: string | null, log: string) { + process.env.ACP_TEE_LOG = log; + return createAcpProvider(acpConfigForEntry(catalogById(id)!, "real")!).createSession({ + threadId: "real", cwd: process.env.HOME!, model, mode: "default", effort: null, resumeId: null, + launch: { cmd: process.execPath, prefixArgs: [tee, bin] }, + }); +} +const teePrompts = (log: string) => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []); +const ended = (ev: ProviderEvent[]) => ev.find((e) => e.type === "turn_ended") as Extract | undefined; + +/** Run one ACP agent end to end: the plugin's prompt carries an image block, and the model request carries the image */ +async function acpEndToEnd(id: string, bin: string, model: string | null, setup: (home: string) => Record | void) { + const env = isolate(); + const extra = setup(env.home) ?? {}; + const saved = Object.fromEntries(Object.keys(extra).map((k) => [k, process.env[k]])); + for (const [k, v] of Object.entries(extra)) process.env[k] = v; + const log = join(env.home, "prompts.jsonl"); + const before = fake.requests.length; + const session = await acpSession(id, bin, model, log); + try { + const ev = await turn(session, "What colour is this image? Answer in one word."); + const prompt = teePrompts(log)[0]; + const reached = fake.requests.slice(before).flatMap((r) => imageParts(r.body)); + console.log(`[${id}] caps`, JSON.stringify(knownPromptCaps(id)), "| ACP prompt", JSON.stringify(prompt?.map((b: any) => b.type)), + "| model request images", JSON.stringify(reached.map((p) => ({ type: p.type, original: sameImage(p.data), len: p.data.length }))), + "| turn", ended(ev)?.status, ended(ev)?.error ?? ""); + expect(prompt).toContainEqual({ type: "image", data: pngB64, mimeType: "image/png" }); + expect(reached.length).toBeGreaterThan(0); + expect(ended(ev)?.status).toBe("completed"); + return reached; + } finally { + await session.close(); + for (const [k, v] of Object.entries(saved)) v === undefined ? delete process.env[k] : (process.env[k] = v); + env.restore(); + } +} + +const has = (k: string) => !!process.env[k]; + +describe("real agents see the image", () => { + it.runIf(has("AC_REAL_DSH"))("dsh (started on the conversation's route)", async () => { + await acpEndToEnd("dsh", process.env.AC_REAL_DSH!, JSON.stringify(["fake", "vis"]), (home) => { + mkdirSync(join(home, ".dsh"), { recursive: true }); + writeFileSync(join(home, ".dsh", "cordis.patch.yml"), [ + "- id: llm-pi-ai", " config:", " providers:", " fake:", " displayName: Fake", " apiKeyEnv: FAKE_KEY", + " api: openai-completions", ` baseURL: ${fake.url}/v1`, " models:", " - id: vis", " input: [text, image]", "", + ].join("\n")); + return { FAKE_KEY: "fake" }; + }); + }); + + it.runIf(has("AC_REAL_OPENCODE"))("OpenCode", async () => { + await acpEndToEnd("opencode", process.env.AC_REAL_OPENCODE!, "fake/vis", (home) => { + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + writeFileSync(join(home, ".config", "opencode", "opencode.json"), JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "fake/vis", + provider: { fake: { npm: "@ai-sdk/openai-compatible", name: "Fake", options: { baseURL: `${fake.url}/v1`, apiKey: "fake" }, + models: { vis: { name: "vis", attachment: true, modalities: { input: ["text", "image"], output: ["text"] } } } } }, + })); + }); + }); + + it.runIf(has("AC_REAL_PI_ACP"))("Pi (pi-acp + pi)", async () => { + await acpEndToEnd("pi", process.env.AC_REAL_PI_ACP!, null, (home) => { + mkdirSync(join(home, ".pi", "agent"), { recursive: true }); + writeFileSync(join(home, ".pi", "agent", "models.json"), JSON.stringify({ providers: { fake: { + baseUrl: `${fake.url}/v1`, api: "openai-completions", apiKey: "fake", + models: [{ id: "vis", name: "vis", input: ["text", "image"], contextWindow: 128000, maxTokens: 4096, reasoning: false }] } } })); + writeFileSync(join(home, ".pi", "agent", "settings.json"), JSON.stringify({ defaultProvider: "fake", defaultModel: "vis" })); + return { PATH: dirname(process.env.AC_REAL_PI_ACP!) + delimiter + process.env.PATH }; + }); + }); + + it.runIf(has("AC_REAL_COPILOT"))("Copilot (BYOK provider, no GitHub sign-in)", async () => { + await acpEndToEnd("copilot", process.env.AC_REAL_COPILOT!, null, () => ({ + COPILOT_PROVIDER_BASE_URL: `${fake.url}/v1`, COPILOT_PROVIDER_API_KEY: "fake", + COPILOT_PROVIDER_MODEL_ID: "gpt-4.1", COPILOT_PROVIDER_WIRE_MODEL: "vis", + })); + }); + + it.runIf(has("AC_REAL_CODEX"))("Codex (app-server, localImage)", async () => { + const env = isolate(); + const path = process.env.PATH; + const codexHome = join(env.home, ".codex"); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, "config.toml"), [ + 'model = "vis"', 'model_provider = "fake"', "", + "[model_providers.fake]", 'name = "fake"', `base_url = "${fake.url}/v1"`, 'wire_api = "responses"', 'env_key = "FAKE_KEY"', "", + ].join("\n")); + process.env.CODEX_HOME = codexHome; + process.env.FAKE_KEY = "fake"; + process.env.PATH = dirname(process.env.AC_REAL_CODEX!) + delimiter + process.env.PATH; + const before = fake.requests.length; + const session = await createCodexProvider("real").createSession({ threadId: "real", cwd: env.home, model: "vis", mode: "default", effort: null, resumeId: null }); + try { + const ev = await turn(session, "What colour is this image? Answer in one word."); + const reached = fake.requests.slice(before).flatMap((r) => imageParts(r.body)); + console.log("[codex] model request images", JSON.stringify(reached.map((p) => ({ type: p.type, original: sameImage(p.data), len: p.data.length }))), "| turn", ended(ev)?.status, ended(ev)?.error ?? ""); + expect(reached.length).toBeGreaterThan(0); + expect(ended(ev)?.status).toBe("completed"); + } finally { + await session.close(); + delete process.env.CODEX_HOME; + delete process.env.FAKE_KEY; + process.env.PATH = path; + env.restore(); + } + }); + + it.runIf(has("AC_REAL_CLAUDE"))("Claude Code (the user's claude over stream-json)", async () => { + const env = isolate({ ANTHROPIC_BASE_URL: fake.url, ANTHROPIC_API_KEY: "fake" }); + const before = fake.requests.length; + const bin = await resolveBinWith("claude", { find: () => [process.env.AC_REAL_CLAUDE!] }); + if (!bin.ok) throw new Error(bin.reason); + const session = new ClaudeSession({ threadId: "real", cwd: env.home, model: "claude-sonnet-4-5", mode: "default", effort: null, resumeId: null, bin }); + try { + const ev = await turn(session, "What colour is this image? Answer in one word."); + const reached = fake.requests.slice(before).flatMap((r) => imageParts(r.body)); + console.log("[claude] model request images", JSON.stringify(reached.map((p) => ({ type: p.type, original: sameImage(p.data), len: p.data.length }))), "| turn", ended(ev)?.status, ended(ev)?.error ?? ""); + expect(reached.some((p) => p.type === "image" && sameImage(p.data))).toBe(true); + expect(ended(ev)?.status).toBe("completed"); + } finally { + await session.close(); + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_API_KEY; + env.restore(); + } + }); + + for (const [id, key] of [["devin", "AC_REAL_DEVIN"], ["cursor", "AC_REAL_CURSOR"]] as const) { + it.runIf(has(key))(`${id}: advertises images and gets an image block (its model needs a sign-in)`, async () => { + const env = isolate(); + const log = join(env.home, "prompts.jsonl"); + try { + const session = await acpSession(id, process.env[key]!, null, log); + try { + const ev = await turn(session, "What colour is this image?"); + console.log(`[${id}] caps`, JSON.stringify(knownPromptCaps(id)), "| ACP prompt", JSON.stringify(teePrompts(log)[0]?.map((b: any) => b.type)), "| turn", ended(ev)?.status, ended(ev)?.error ?? ""); + expect(knownPromptCaps(id)?.image).toBe(true); + expect(teePrompts(log)[0]).toContainEqual({ type: "image", data: pngB64, mimeType: "image/png" }); + } finally { + await session.close(); + } + } catch (e) { + // Cursor can't open a session without a sign-in ("Failed to initialize session services"): initialize is all there is + console.log(`[${id}] caps`, JSON.stringify(knownPromptCaps(id)), "| session:", String((e as Error)?.message ?? e)); + expect(knownPromptCaps(id)?.image).toBe(true); + } finally { + env.restore(); + } + }); + } +}); diff --git a/packages/agent-console/test/real/claude-cli.real.test.ts b/packages/agent-console/test/real/claude-cli.real.test.ts new file mode 100644 index 00000000..a82c4b1f --- /dev/null +++ b/packages/agent-console/test/real/claude-cli.real.test.ts @@ -0,0 +1,133 @@ +// The real `claude` CLI driven over stream-json by Agent Console's Claude provider, against test/real/fake-llm.mjs +// (a local stand-in for the Anthropic Messages API: no sign-in, no key, nothing leaves the machine). +// Opt-in: AC_REAL_CLAUDE= npm run test:real +// Covers what the stand-in CLI in test/v1/claude-cli.test.ts covers, with the real protocol: initialize + slash commands, +// streaming, a Bash permission approved and denied, interrupt, model switch, and reopening by --resume. +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ClaudeSession } from "../../daemon/src/providers/claude/session.js"; +import { resolveBinWith, type ResolveResult } from "../../daemon/src/util/bin.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; +// @ts-ignore plain .mjs helper +import { startFakeLlm } from "./fake-llm.mjs"; + +const real = process.env.AC_REAL_CLAUDE; +type Fake = { url: string; requests: { url: string; body: any }[]; close(): Promise }; +let fake: Fake; +let home: string; +let bin: ResolveResult & { ok: true }; + +beforeAll(async () => { + if (!real) return; + fake = await startFakeLlm(); + home = mkdtempSync(join(tmpdir(), "ac-real-claude-")); + const r = await resolveBinWith("claude", { find: () => [real] }); + if (!r.ok) throw new Error(r.reason); + bin = r; +}); +afterAll(async () => fake?.close()); + +function open(resumeId: string | null = null) { + const env = { ...process.env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"), ANTHROPIC_BASE_URL: fake.url, ANTHROPIC_API_KEY: "fake", CLAUDE_CONFIG_DIR: join(home, ".claude") }; + const s = new ClaudeSession({ threadId: "real", cwd: home, model: "claude-sonnet-4-5", mode: "default", effort: null, resumeId, bin, env }); + const events: ProviderEvent[] = []; + void (async () => { for await (const e of s.events) events.push(e); })(); + const until = async (pred: (e: ProviderEvent) => boolean, from = 0, ms = 90000) => { + const end = Date.now() + ms; + while (Date.now() < end) { + const hit = events.slice(from).find(pred); + if (hit) return hit; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error("timed out; events: " + JSON.stringify(events.map((e) => e.type))); + }; + return { s, events, until }; +} +const ended = (e: ProviderEvent) => e.type === "turn_ended"; + +describe.runIf(real)("real claude over stream-json", () => { + let sessionId = ""; + + it("initialize lists slash commands; a turn streams text and completes", async () => { + const { s, events, until } = open(); + try { + const cmds = await s.listCommands(); + console.log("[claude] slash commands", cmds.length, cmds.slice(0, 5).map((c) => c.name).join(",")); + expect(cmds.length).toBeGreaterThan(0); + await s.send({ text: "say something", images: [] }); + expect(await until(ended)).toMatchObject({ status: "completed" }); + expect(events.some((e) => e.type === "assistant_delta" && e.text === "I see it")).toBe(true); + sessionId = (events.find((e) => e.type === "provider_session_id") as { id: string }).id; + expect(sessionId).toMatch(/^[0-9a-f-]{36}$/); + } finally { + await s.close(); + } + }); + + it("a Bash permission: approve runs it, deny does not", async () => { + const { s, events, until } = open(); + try { + await s.send({ text: "RUN_TOOL now", images: [] }); + const req = (await until((e) => e.type === "permission_request")) as Extract; + console.log("[claude] permission", req.title, JSON.stringify(req.detail).slice(0, 120)); + s.respond(req.requestId, { optionId: "allow" }); + expect(await until(ended)).toMatchObject({ status: "completed" }); + const out = events.find((e) => e.type === "tool_call" && e.status === "ok") as { output: string } | undefined; + expect(out?.output).toContain("real-hi"); + const mark = events.length; + const reqMark = fake.requests.length; + await s.send({ text: "RUN_TOOL again", images: [] }); + const req2 = (await until((e) => e.type === "permission_request", mark)) as Extract; + s.respond(req2.requestId, { optionId: "deny" }); + await until(ended, mark); + const after = events.slice(mark); + expect(after.some((e) => e.type === "tool_call" && e.status === "ok" && String((e as { output?: string }).output).includes("real-hi"))).toBe(false); + // claude reports the refusal as a failed tool call and asks the model again + console.log("[claude] after deny:", JSON.stringify(after.map((e) => e.type + ":" + ((e as any).status ?? ""))), "model requests", fake.requests.length - reqMark); + expect(after.some((e) => e.type === "tool_call" && e.status === "failed")).toBe(true); + expect(after.find(ended)).toMatchObject({ status: "completed" }); + } finally { + await s.close(); + } + }); + + it("interrupt ends a turn that is still answering", async () => { + const { s, events, until } = open(); + try { + await s.send({ text: "SLOW_REPLY please", images: [] }); + await until((e) => e.type === "assistant_delta"); + const t0 = Date.now(); + await s.interrupt(); + const end = await until(ended, 0, 20000); + console.log("[claude] interrupt →", JSON.stringify(end), Date.now() - t0, "ms"); + expect(end).toMatchObject({ status: "interrupted" }); + // the CLI stays usable + const mark = events.length; + await s.send({ text: "still there?", images: [] }); + expect(await until(ended, mark)).toMatchObject({ status: "completed" }); + } finally { + await s.close(); + } + }); + + it("reopening by --resume carries the old conversation; set_model switches the model", async () => { + expect(sessionId).not.toBe(""); + const { s, events, until } = open(sessionId); + try { + await s.setModel("claude-opus-4-1"); + const before = fake.requests.length; + await s.send({ text: "and now?", images: [] }); + expect(await until(ended)).toMatchObject({ status: "completed" }); + expect((events.find((e) => e.type === "provider_session_id") as { id: string }).id).toBe(sessionId); + const req = fake.requests.slice(before).filter((r) => r.url.includes("/messages") && Array.isArray(r.body?.messages)).at(-1)!.body; + const texts = JSON.stringify(req.messages); + console.log("[claude] resumed request: model", req.model, "messages", req.messages.length); + expect(texts).toContain("say something"); + expect(req.model).toMatch(/opus/); // claude maps the id to its current opus + } finally { + await s.close(); + } + }); +}); diff --git a/packages/agent-console/test/real/fake-llm.mjs b/packages/agent-console/test/real/fake-llm.mjs new file mode 100644 index 00000000..4671c9cc --- /dev/null +++ b/packages/agent-console/test/real/fake-llm.mjs @@ -0,0 +1,91 @@ +// A local stand-in for a model API, for test/real: answers OpenAI Chat Completions, OpenAI Responses and Anthropic +// Messages (streaming or not) with "I see it", and keeps every request body so a test can look for the image. +// No key is checked and nothing leaves the machine. +import { createServer } from "node:http"; + +export function startFakeLlm() { + const requests = []; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (d) => (raw += d)); + req.on("end", () => { + let body; + try { body = JSON.parse(raw); } catch { body = raw; } + requests.push({ method: req.method, url: req.url ?? "", body }); + const url = req.url ?? ""; + const json = (o) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(o)); }; + const sse = () => res.writeHead(200, { "content-type": "text/event-stream" }); + const model = body?.model ?? "vis"; + if (url.includes("/models")) return json({ object: "list", data: [{ id: "vis", object: "model" }], models: [] }); + if (url.includes("/chat/completions")) { + if (!body?.stream) return json({ id: "c1", object: "chat.completion", created: 1, model, choices: [{ index: 0, message: { role: "assistant", content: "I see it" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + sse(); + const chunk = (choices, extra = {}) => res.write(`data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model, choices, ...extra })}\n\n`); + chunk([{ index: 0, delta: { role: "assistant", content: "I see it" }, finish_reason: null }]); + chunk([{ index: 0, delta: {}, finish_reason: "stop" }]); + chunk([], { usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + return res.end("data: [DONE]\n\n"); + } + if (url.includes("/messages")) { + if (url.includes("count_tokens")) return json({ input_tokens: 1 }); + // "RUN_TOOL" in the last user message (and no tool result yet): ask to run Bash (a node one-liner that writes a file, which needs a permission) + const last = Array.isArray(body?.messages) ? body.messages[body.messages.length - 1] : null; + const parts = Array.isArray(last?.content) ? last.content : [{ type: "text", text: String(last?.content ?? "") }]; + const wantsTool = parts.some((p) => p?.type === "text" && String(p.text).includes("RUN_TOOL")) && !parts.some((p) => p?.type === "tool_result"); + const toolResult = parts.find((p) => p?.type === "tool_result"); + const reply = toolResult ? `tool said: ${JSON.stringify(toolResult.content).slice(0, 200)}` : "I see it"; + const content = wantsTool + ? [{ type: "tool_use", id: "toolu_real_1", name: "Bash", input: { command: "node -e \"require('fs').writeFileSync('ac-real.txt', 'x'); console.log('real-hi')\"", description: "Write a file" } }] + : [{ type: "text", text: reply }]; + const stop = wantsTool ? "tool_use" : "end_turn"; + const msg = { id: `msg_${requests.length}`, type: "message", role: "assistant", model, content, stop_reason: stop, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } }; + if (!body?.stream) return json(msg); + sse(); + const ev = (type, d) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...d })}\n\n`); + ev("message_start", { message: { ...msg, content: [], stop_reason: null } }); + // "SLOW_REPLY": start answering, then hang until the client goes away (or 60 s), to test interrupting a turn + if (parts.some((p) => p?.type === "text" && String(p.text).includes("SLOW_REPLY"))) { + ev("content_block_start", { index: 0, content_block: { type: "text", text: "" } }); + ev("content_block_delta", { index: 0, delta: { type: "text_delta", text: "working" } }); + const t = setTimeout(() => res.end(), 60000); + res.on("close", () => clearTimeout(t)); + return; + } + if (wantsTool) { + ev("content_block_start", { index: 0, content_block: { ...content[0], input: {} } }); + ev("content_block_delta", { index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(content[0].input) } }); + } else { + ev("content_block_start", { index: 0, content_block: { type: "text", text: "" } }); + ev("content_block_delta", { index: 0, delta: { type: "text_delta", text: reply } }); + } + ev("content_block_stop", { index: 0 }); + ev("message_delta", { delta: { stop_reason: stop, stop_sequence: null }, usage: { output_tokens: 1 } }); + ev("message_stop", {}); + return res.end(); + } + if (url.includes("/responses")) { + sse(); + const item = { id: "m1", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "I see it", annotations: [] }] }; + const r = { id: "r1", object: "response", created_at: 1, status: "in_progress", model, output: [], usage: null }; + let seq = 0; + const ev = (d) => res.write(`event: ${d.type}\ndata: ${JSON.stringify({ ...d, sequence_number: seq++ })}\n\n`); + ev({ type: "response.created", response: r }); + ev({ type: "response.output_item.added", output_index: 0, item: { ...item, status: "in_progress", content: [] } }); + ev({ type: "response.content_part.added", item_id: "m1", output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }); + ev({ type: "response.output_text.delta", item_id: "m1", output_index: 0, content_index: 0, delta: "I see it" }); + ev({ type: "response.output_text.done", item_id: "m1", output_index: 0, content_index: 0, text: "I see it" }); + ev({ type: "response.output_item.done", output_index: 0, item }); + ev({ type: "response.completed", response: { ...r, status: "completed", output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } } } }); + return res.end(); + } + res.writeHead(404, { "content-type": "application/json" }); + res.end("{}"); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ port, url: `http://127.0.0.1:${port}`, requests, close: () => new Promise((r) => server.close(() => r())) }); + }); + }); +} diff --git a/packages/agent-console/test/v1/acp-images.test.ts b/packages/agent-console/test/v1/acp-images.test.ts new file mode 100644 index 00000000..ebdbcc72 --- /dev/null +++ b/packages/agent-console/test/v1/acp-images.test.ts @@ -0,0 +1,215 @@ +// Images to ACP Agents (Cursor, Copilot, OpenCode, Pi, Devin CLI, dsh all share AcpSession). +// A fake ACP agent (fixtures/fake-acp-agent.mjs) advertises promptCapabilities as told and logs every prompt it gets. +// - image support advertised → the prompt carries image blocks (base64 + mimeType) +// - not advertised → the image's absolute path goes as text and as a resource_link, with a notice in the timeline +// - an image-only message still starts (and ends) a turn +// - the provider's capabilities.images follows what initialize advertised (probe or session) +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createAcpProvider } from "../../daemon/src/providers/acp/provider.js"; +import { acpProbeModels } from "../../daemon/src/providers/acp/probe.js"; +import { buildAcpPrompt, MAX_INLINE_MEDIA_BYTES } from "../../daemon/src/providers/acp/prompt.js"; +import { forgetPromptCaps, knownPromptCaps } from "../../daemon/src/providers/acp/promptCaps.js"; +import type { ProviderSession } from "../../daemon/src/providers/types.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; + +const fixture = resolve(__dirname, "fixtures", "fake-acp-agent.mjs"); +const launch = { cmd: process.execPath, prefixArgs: [fixture] }; +// 1×1 transparent PNG +const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +let dir: string; +let png: string; +let notes: string; +const env = { caps: process.env.FAKE_ACP_PROMPT_CAPS, log: process.env.FAKE_ACP_LOG }; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "acp-images-")); + png = join(dir, "shot.png"); + writeFileSync(png, Buffer.from(PNG_B64, "base64")); + notes = join(dir, "notes.txt"); + writeFileSync(notes, "hello from a text attachment\n"); +}); +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); +afterEach(() => { + if (env.caps === undefined) delete process.env.FAKE_ACP_PROMPT_CAPS; + else process.env.FAKE_ACP_PROMPT_CAPS = env.caps; + if (env.log === undefined) delete process.env.FAKE_ACP_LOG; + else process.env.FAKE_ACP_LOG = env.log; + forgetPromptCaps(); +}); + +function provider(id: string) { + return createAcpProvider({ id, name: "Fake ACP", binName: "fake-acp", acpArgs: [], installHint: "" }); +} + +type Run = { session: ProviderSession; events: ProviderEvent[]; log: string; done: Promise }; + +async function open(id: string, caps: Record | null): Promise { + const log = join(dir, `${id}-${Math.random().toString(36).slice(2)}.jsonl`); + if (caps) process.env.FAKE_ACP_PROMPT_CAPS = JSON.stringify(caps); + else delete process.env.FAKE_ACP_PROMPT_CAPS; + process.env.FAKE_ACP_LOG = log; + const session = await provider(id).createSession({ + threadId: "t1", cwd: dir, model: null, mode: "default", effort: null, resumeId: null, launch, + }); + const events: ProviderEvent[] = []; + const done = (async () => { + for await (const ev of session.events) events.push(ev); + })(); + return { session, events, log, done }; +} + +async function until(check: () => boolean, ms = 10000) { + const end = Date.now() + ms; + while (!check()) { + if (Date.now() > end) throw new Error("timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +const ended = (r: Run) => r.events.filter((e) => e.type === "turn_ended"); +const prompts = (r: Run) => + existsSync(r.log) ? readFileSync(r.log, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []; +const notices = (r: Run) => r.events.filter((e): e is Extract => e.type === "notice"); + +async function close(r: Run) { + await r.session.close(); + await r.done; +} + +describe("ACP prompts with images", () => { + it("sends image blocks when the Agent advertises promptCapabilities.image", async () => { + const r = await open("fake-img", { image: true }); + try { + await r.session.send({ text: "what is in this picture?", images: [{ path: png, mime: "image/png" }] }); + await until(() => ended(r).length === 1); + expect(prompts(r)).toEqual([ + [ + { type: "text", text: "what is in this picture?" }, + { type: "image", data: PNG_B64, mimeType: "image/png" }, + ], + ]); + expect(ended(r)[0]).toMatchObject({ status: "completed" }); + expect(notices(r)).toEqual([]); + // the Agent's reply reached the timeline: the turn really ran + expect(r.events).toContainEqual(expect.objectContaining({ type: "assistant_delta", text: "ok: 2 blocks" })); + } finally { + await close(r); + } + }); + + it("sends the image's path (text + resource_link) and a notice when the Agent doesn't take images", async () => { + const r = await open("fake-noimg", null); + try { + await r.session.send({ text: "look at this", images: [{ path: png, mime: "image/png" }] }); + await until(() => ended(r).length === 1); + const [prompt] = prompts(r); + expect(prompt).toEqual([ + { type: "text", text: `look at this\n\nAttached image: ${png}` }, + { type: "resource_link", uri: pathToFileURL(png).href, name: "shot.png", mimeType: "image/png", size: readFileSync(png).length }, + ]); + expect(prompt.some((b: { type: string }) => b.type === "image")).toBe(false); + const n = notices(r); + expect(n).toHaveLength(1); + expect(n[0].level).toBe("warn"); + expect(n[0].message).toContain("Fake ACP"); + expect(n[0].message).toContain(png); + } finally { + await close(r); + } + }); + + it("an image-only message starts a turn (with and without image support)", async () => { + for (const caps of [{ image: true }, null]) { + const r = await open(caps ? "fake-only-img" : "fake-only-noimg", caps); + try { + await r.session.send({ text: " ", images: [{ path: png, mime: "image/png" }] }); + await until(() => ended(r).length === 1); + expect(r.events.filter((e) => e.type === "turn_started")).toHaveLength(1); + expect(ended(r)[0]).toMatchObject({ status: "completed" }); + const [prompt] = prompts(r); + if (caps) expect(prompt).toEqual([{ type: "image", data: PNG_B64, mimeType: "image/png" }]); + else { + expect(prompt[0]).toEqual({ type: "text", text: `Attached image: ${png}` }); + expect(prompt[1]).toMatchObject({ type: "resource_link", uri: pathToFileURL(png).href }); + } + } finally { + await close(r); + } + } + }); + + it("an empty message still ends at once without a prompt", async () => { + const r = await open("fake-empty", { image: true }); + try { + await r.session.send({ text: "", images: [] }); + await until(() => ended(r).length === 1); + expect(prompts(r)).toEqual([]); + } finally { + await close(r); + } + }); +}); + +describe("the provider's images capability follows the Agent", () => { + it("is unknown before the Agent is asked, then what its initialize says (session)", async () => { + const p = provider("fake-cap"); + expect(p.capabilities).toMatchObject({ images: false, imagesKnown: false }); + const r = await open("fake-cap", { image: true, embeddedContext: true }); + try { + await r.session.send({ text: "hi", images: [] }); + await until(() => ended(r).length === 1); + expect(knownPromptCaps("fake-cap")).toEqual({ image: true, audio: false, embeddedContext: true }); + expect(p.capabilities).toMatchObject({ images: true, imagesKnown: true }); + } finally { + await close(r); + } + }); + + it("the model probe records it too", async () => { + process.env.FAKE_ACP_PROMPT_CAPS = JSON.stringify({ image: false }); + await acpProbeModels([], "fake-probe")({ ok: true, cmd: launch.cmd, prefixArgs: launch.prefixArgs, display: "fake", via: "custom" }); + expect(knownPromptCaps("fake-probe")).toEqual({ image: false, audio: false, embeddedContext: false }); + expect(provider("fake-probe").capabilities).toMatchObject({ images: false, imagesKnown: true }); + process.env.FAKE_ACP_PROMPT_CAPS = JSON.stringify({ image: true }); + await acpProbeModels([], "fake-probe")({ ok: true, cmd: launch.cmd, prefixArgs: launch.prefixArgs, display: "fake", via: "custom" }); + expect(provider("fake-probe").capabilities).toMatchObject({ images: true, imagesKnown: true }); + }); +}); + +describe("buildAcpPrompt", () => { + it("embeds a text attachment when embeddedContext is advertised, else links it", async () => { + const withCtx = await buildAcpPrompt("read this", [{ path: notes, mime: "text/plain" }], { image: false, audio: false, embeddedContext: true }); + expect(withCtx.blocks).toEqual([ + { type: "text", text: "read this" }, + { type: "resource", resource: { uri: pathToFileURL(notes).href, mimeType: "text/plain", text: "hello from a text attachment\n" } }, + ]); + const without = await buildAcpPrompt("read this", [{ path: notes, mime: "text/plain" }], { image: false, audio: false, embeddedContext: false }); + expect(without.blocks[0]).toEqual({ type: "text", text: `read this\n\nAttached file: ${notes}` }); + expect(without.blocks[1]).toMatchObject({ type: "resource_link", uri: pathToFileURL(notes).href }); + expect(without.imagesByPath).toEqual([]); + }); + + it("sends an image over the inline limit by path, with a warning", async () => { + const big = join(dir, "big.png"); + writeFileSync(big, ""); + truncateSync(big, MAX_INLINE_MEDIA_BYTES + 1); + const r = await buildAcpPrompt("", [{ path: big, mime: "image/png" }], { image: true, audio: false, embeddedContext: false }); + expect(r.blocks.some((b) => b.type === "image")).toBe(false); + expect(r.blocks[0]).toEqual({ type: "text", text: `Attached image: ${big}` }); + expect(r.imagesByPath).toEqual([big]); + expect(r.warnings[0]).toContain("inline limit"); + }); + + it("names an attachment that can't be found instead of failing the message", async () => { + const r = await buildAcpPrompt("hi", [{ path: join(dir, "gone.png"), mime: "image/png" }], { image: true, audio: false, embeddedContext: false }); + expect(r.blocks).toEqual([{ type: "text", text: "hi" }]); + expect(r.warnings).toHaveLength(1); + }); +}); diff --git a/packages/agent-console/test/v1/acp-probe.test.ts b/packages/agent-console/test/v1/acp-probe.test.ts new file mode 100644 index 00000000..9846eb82 --- /dev/null +++ b/packages/agent-console/test/v1/acp-probe.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { parseAcpSession, probeToModels, cursorCatalogToModels } from "../../daemon/src/providers/acp/probe.js"; + +describe("Cursor model catalog (cursor/list_available_models)", () => { + it("each model has its own level parameter; the current model is the default; models without levels get no dropdown", () => { + const models = cursorCatalogToModels({ + models: [ + { value: "grok-4.6[effort=high,fast=true]", name: "Grok 4.6", configOptions: [ + { id: "effort", category: "thought_level", type: "select", currentValue: "high", options: [ + { value: "low", name: "Low" }, { value: "medium", name: "Medium" }, { value: "high", name: "High" }, { value: "xhigh", name: "Extra High" }, + ] }, + { id: "fast", category: "model_config", type: "select", currentValue: "true", options: [{ value: "false" }, { value: "true" }] }, + ] }, + { value: "gpt-5.6-sol[reasoning=medium]", name: "GPT-5.6 Sol", configOptions: [ + { id: "reasoning", category: "thought_level", type: "select", currentValue: "medium", options: [{ value: "none" }, { value: "max" }] }, + ] }, + { value: "composer-2.5[fast=true]", name: "Composer 2.5", configOptions: [ + { id: "fast", category: "model_config", type: "select", currentValue: "true", options: [{ value: "false" }, { value: "true" }] }, + ] }, + ], + }, "grok-4.6[effort=high,fast=true]"); + expect(models.map((m) => m.id)).toEqual(["grok-4.6", "gpt-5.6-sol", "composer-2.5"]); + expect(models[0]).toMatchObject({ isDefault: true, defaultThinking: "high", thinkingParam: "effort" }); + expect(models[0].thinkingOptions?.map((o) => o.id)).toEqual(["low", "medium", "high", "xhigh"]); + expect(models[1]).toMatchObject({ thinkingParam: "reasoning" }); + expect(models[2].thinkingOptions).toBeUndefined(); + }); +}); + +describe("ACP probe parsing", () => { + it("Devin: model + thought_level config options", () => { + const r = parseAcpSession({ + configOptions: [ + { id: "mode", category: "mode", type: "select", currentValue: "accept-edits", options: [{ value: "accept-edits", name: "Code" }] }, + { id: "model", category: "model", type: "select", currentValue: "swe-2-high", options: [ + { value: "adaptive", name: "Adaptive" }, { value: "swe-2-high", name: "SWE-2" }, + ] }, + { id: "thought_level", category: "thought_level", type: "select", currentValue: "high", options: [ + { value: "medium", name: "Medium" }, { value: "high", name: "High" }, { value: "max", name: "Max" }, + ] }, + ], + }); + expect(r.models.map((m) => m.id)).toEqual(["adaptive", "swe-2-high"]); + expect(r.currentModel).toBe("swe-2-high"); + const models = probeToModels(r); + expect(models.find((m) => m.isDefault)?.id).toBe("swe-2-high"); + expect(models[1].thinkingOptions?.map((t) => t.id)).toEqual(["medium", "high", "max"]); + expect(models[1].defaultThinking).toBe("high"); + }); + + it("Copilot: duplicate auto is deduped", () => { + const r = parseAcpSession({ + configOptions: [{ id: "model", category: "model", type: "select", currentValue: "auto", options: [ + { value: "auto", name: "Auto" }, { value: "auto", name: "Auto" }, + ] }], + }); + expect(r.models).toEqual([{ id: "auto", name: "Auto" }]); + }); + + it("grouped options are flattened; falls back to the models field without configOptions", () => { + const grouped = parseAcpSession({ + configOptions: [{ id: "model", category: "model", type: "select", currentValue: "b", options: [ + { name: "G", options: [{ value: "a", name: "A" }, { value: "b", name: "B" }] }, + ] }], + }); + expect(grouped.models.map((m) => m.id)).toEqual(["a", "b"]); + const legacy = parseAcpSession({ models: { currentModelId: "x", availableModels: [{ modelId: "x", name: "X" }] } }); + expect(legacy).toMatchObject({ currentModel: "x", models: [{ id: "x", name: "X" }] }); + }); +}); diff --git a/packages/agent-console/test/v1/acp-resume.test.ts b/packages/agent-console/test/v1/acp-resume.test.ts new file mode 100644 index 00000000..3e9db1ee --- /dev/null +++ b/packages/agent-console/test/v1/acp-resume.test.ts @@ -0,0 +1,138 @@ +// Reopening an old ACP conversation: use the method the Agent's initialize advertises. +// - loadSession → session/load (Cursor, Copilot, OpenCode, Pi, Devin CLI) +// - sessionCapabilities.resume only → session/resume (dsh) +// - both → prefer load (OpenCode) +// - neither → new session + a notice that prior context wasn't restored +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createAcpProvider } from "../../daemon/src/providers/acp/provider.js"; +import { pickReconnect, sessionOpenFromInitialize } from "../../daemon/src/providers/acp/sessionCaps.js"; +import type { ProviderSession } from "../../daemon/src/providers/types.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; + +const fixture = resolve(__dirname, "fixtures", "fake-acp-agent.mjs"); +const launch = { cmd: process.execPath, prefixArgs: [fixture] }; + +let dir: string; +const env = { session: process.env.FAKE_ACP_SESSION, slog: process.env.FAKE_ACP_SESSION_LOG }; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "acp-resume-")); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); +afterEach(() => { + if (env.session === undefined) delete process.env.FAKE_ACP_SESSION; + else process.env.FAKE_ACP_SESSION = env.session; + if (env.slog === undefined) delete process.env.FAKE_ACP_SESSION_LOG; + else process.env.FAKE_ACP_SESSION_LOG = env.slog; +}); + +function provider() { + return createAcpProvider({ id: "fake-resume", name: "Fake ACP", binName: "fake-acp", acpArgs: [], installHint: "" }); +} + +type Run = { session: ProviderSession; events: ProviderEvent[]; slog: string; done: Promise }; + +async function open(sessionCaps: { loadSession?: boolean; resume?: boolean } | null, resumeId: string | null): Promise { + const slog = join(dir, `s-${Math.random().toString(36).slice(2)}.jsonl`); + if (sessionCaps) process.env.FAKE_ACP_SESSION = JSON.stringify(sessionCaps); + else delete process.env.FAKE_ACP_SESSION; + process.env.FAKE_ACP_SESSION_LOG = slog; + const session = await provider().createSession({ + threadId: "t1", cwd: dir, model: null, mode: "default", effort: null, resumeId, launch, + }); + const events: ProviderEvent[] = []; + const done = (async () => { for await (const ev of session.events) events.push(ev); })(); + return { session, events, slog, done }; +} + +async function until(check: () => boolean, ms = 10000) { + const end = Date.now() + ms; + while (!check()) { + if (Date.now() > end) throw new Error("timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +const sessions = (r: Run) => + existsSync(r.slog) ? readFileSync(r.slog, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []; +const notices = (r: Run) => r.events.filter((e): e is Extract => e.type === "notice"); +const sid = (r: Run) => r.events.find((e) => e.type === "provider_session_id") as Extract | undefined; +async function close(r: Run) { await r.session.close(); await r.done; } + +describe("sessionOpenFromInitialize / pickReconnect", () => { + it("reads loadSession and sessionCapabilities.resume the way ACP defines them", () => { + expect(sessionOpenFromInitialize({ agentCapabilities: { loadSession: true } })).toEqual({ load: true, resume: false }); + expect(sessionOpenFromInitialize({ agentCapabilities: { sessionCapabilities: { resume: {} } } })).toEqual({ load: false, resume: true }); + expect(sessionOpenFromInitialize({ agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {}, list: {} } } })).toEqual({ load: true, resume: true }); + expect(sessionOpenFromInitialize({ agentCapabilities: { sessionCapabilities: { resume: null } } })).toEqual({ load: false, resume: false }); + expect(sessionOpenFromInitialize({})).toEqual({ load: false, resume: false }); + }); + it("prefers load, then resume, then none", () => { + expect(pickReconnect({ load: true, resume: true })).toBe("load"); + expect(pickReconnect({ load: false, resume: true })).toBe("resume"); + expect(pickReconnect({ load: true, resume: false })).toBe("load"); + expect(pickReconnect({ load: false, resume: false })).toBe("none"); + }); +}); + +describe("reopening an ACP conversation", () => { + it("calls session/load when the Agent advertises loadSession (and keeps the stored id)", async () => { + const r = await open({ loadSession: true }, "old-load-id"); + try { + await until(() => !!sid(r)); + expect(sessions(r)).toEqual([{ method: "session/load", sessionId: "old-load-id" }]); + expect(sid(r)?.id).toBe("old-load-id"); + expect(notices(r)).toEqual([]); + } finally { + await close(r); + } + }); + + it("calls session/resume when the Agent only advertises resume (dsh)", async () => { + const r = await open({ resume: true }, "old-resume-id"); + try { + await until(() => !!sid(r)); + expect(sessions(r)).toEqual([{ method: "session/resume", sessionId: "old-resume-id" }]); + expect(sid(r)?.id).toBe("old-resume-id"); + expect(notices(r)).toEqual([]); + } finally { + await close(r); + } + }); + + it("prefers load when both are advertised", async () => { + const r = await open({ loadSession: true, resume: true }, "both-id"); + try { + await until(() => !!sid(r)); + expect(sessions(r)).toEqual([{ method: "session/load", sessionId: "both-id" }]); + } finally { + await close(r); + } + }); + + it("starts a new session and notices when neither load nor resume is advertised", async () => { + const r = await open({}, "orphaned-id"); + try { + await until(() => !!sid(r) && notices(r).length >= 1); + expect(sessions(r)).toEqual([{ method: "session/new", sessionId: "fake-session-1" }]); + expect(sid(r)?.id).toBe("fake-session-1"); + expect(notices(r)[0].message).toMatch(/session\/load|session\/resume|prior context|先前/); + } finally { + await close(r); + } + }); + + it("a fresh conversation still uses session/new", async () => { + const r = await open({ loadSession: true, resume: true }, null); + try { + await until(() => !!sid(r)); + expect(sessions(r)).toEqual([{ method: "session/new", sessionId: "fake-session-1" }]); + expect(sid(r)?.id).toBe("fake-session-1"); + } finally { + await close(r); + } + }); +}); diff --git a/packages/agent-console/test/v1/attachment-content.test.ts b/packages/agent-console/test/v1/attachment-content.test.ts new file mode 100644 index 00000000..64b77785 --- /dev/null +++ b/packages/agent-console/test/v1/attachment-content.test.ts @@ -0,0 +1,110 @@ +// How each Agent gets an attachment: only PNG/JPEG/GIF/WebP are images; text files are text; the rest go by path. +// Claude Code used to send every attachment (a .txt included) as an image block, and Codex as localImage. +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { attachmentKind, baseMime } from "../../daemon/src/util/attachmentKinds.js"; +import { buildClaudeContent, CLAUDE_MAX_IMAGE_BYTES } from "../../daemon/src/providers/claude/content.js"; +import { buildCodexInput } from "../../daemon/src/providers/codex/input.js"; +import { buildAcpPrompt } from "../../daemon/src/providers/acp/prompt.js"; + +const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; +let dir: string, png: string, txt: string, svg: string, bin: string; +// what the attachment store gives text uploads +const TEXT = "text/plain; charset=utf-8"; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "att-content-")); + png = join(dir, "shot.png"); + writeFileSync(png, Buffer.from(PNG_B64, "base64")); + txt = join(dir, "notes.txt"); + writeFileSync(txt, "第一行 notes\n"); + svg = join(dir, "logo.svg"); + writeFileSync(svg, ""); + bin = join(dir, "blob.bin"); + writeFileSync(bin, Buffer.from([0, 1, 2, 3])); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe("attachmentKind", () => { + it("knows the four model image types, text, and the rest", () => { + for (const m of ["image/png", "image/jpeg", "image/gif", "image/webp", "IMAGE/PNG"]) expect(attachmentKind(m)).toBe("image"); + for (const m of [TEXT, "text/markdown", "application/json"]) expect(attachmentKind(m)).toBe("text"); + for (const m of ["image/svg+xml", "image/bmp", "application/pdf", "application/octet-stream", "", undefined]) expect(attachmentKind(m)).toBe("other"); + expect(baseMime(TEXT)).toBe("text/plain"); + }); +}); + +describe("Claude Code content", () => { + it("an image is an image block, a text file a plain-text document block, the text last", async () => { + const r = await buildClaudeContent("look", [{ path: png, mime: "image/png" }, { path: txt, mime: TEXT }]); + expect(r.blocks).toEqual([ + { type: "image", source: { type: "base64", media_type: "image/png", data: PNG_B64 } }, + { type: "document", source: { type: "text", media_type: "text/plain", data: "第一行 notes\n" }, title: "notes.txt", context: txt }, + { type: "text", text: "look" }, + ]); + expect(r.warnings).toEqual([]); + }); + + it("a text file is never an image block (the old bug)", async () => { + const r = await buildClaudeContent("", [{ path: txt, mime: TEXT }]); + expect(r.blocks.some((b) => b.type === "image")).toBe(false); + }); + + it("svg, binaries and images over 5 MB go by path, with a warning", async () => { + const big = join(dir, "big.png"); + writeFileSync(big, ""); + truncateSync(big, CLAUDE_MAX_IMAGE_BYTES + 1); + const r = await buildClaudeContent("hi", [ + { path: svg, mime: "image/svg+xml" }, + { path: bin, mime: "application/octet-stream" }, + { path: big, mime: "image/png" }, + ]); + expect(r.blocks).toEqual([{ type: "text", text: `hi\n\nAttached file: ${svg}\nAttached file: ${bin}\nAttached image: ${big}` }]); + expect(r.warnings).toHaveLength(3); + expect(r.warnings[0]).toContain("image/svg+xml"); + expect(r.warnings[2]).toContain("5 MB"); + }); + + it("a missing attachment is a warning, not a failed message", async () => { + const r = await buildClaudeContent("hi", [{ path: join(dir, "gone.png"), mime: "image/png" }]); + expect(r.blocks).toEqual([{ type: "text", text: "hi" }]); + expect(r.warnings).toHaveLength(1); + }); +}); + +describe("Codex input", () => { + it("only images are localImage; text files and others are paths in the text", async () => { + const r = await buildCodexInput("look", [ + { path: png, mime: "image/jpeg" }, + { path: txt, mime: TEXT }, + { path: bin, mime: "application/octet-stream" }, + ]); + expect(r.items).toEqual([ + { type: "text", text: `look\n\nAttached file: ${txt}\nAttached file: ${bin}` }, + { type: "localImage", path: png }, + ]); + expect(r.warnings).toHaveLength(1); + expect(r.warnings[0]).toContain("blob.bin"); + }); + + it("a text-only attachment never becomes localImage (the old bug)", async () => { + const r = await buildCodexInput("", [{ path: txt, mime: TEXT }]); + expect(r.items).toEqual([{ type: "text", text: `Attached file: ${txt}` }]); + expect(r.warnings).toEqual([]); + }); +}); + +describe("ACP prompt", () => { + it("an svg is a file by path even for an Agent that takes images", async () => { + const r = await buildAcpPrompt("", [{ path: svg, mime: "image/svg+xml" }], { image: true, audio: false, embeddedContext: false }); + expect(r.blocks.some((b) => b.type === "image")).toBe(false); + expect(r.blocks[0]).toEqual({ type: "text", text: `Attached file: ${svg}` }); + }); + + it("a text upload (text/plain; charset=utf-8) is embedded with embeddedContext", async () => { + const r = await buildAcpPrompt("", [{ path: txt, mime: TEXT }], { image: true, audio: false, embeddedContext: true }); + expect(r.blocks).toEqual([{ type: "resource", resource: { uri: expect.stringMatching(/^file:/), mimeType: TEXT, text: "第一行 notes\n" } }]); + }); +}); diff --git a/packages/agent-console/test/v1/attachments-send.test.ts b/packages/agent-console/test/v1/attachments-send.test.ts new file mode 100644 index 00000000..684691cb --- /dev/null +++ b/packages/agent-console/test/v1/attachments-send.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AttachmentStore } from "../../daemon/src/store/attachments.js"; +import { resolveAttachmentRefs, resolveImagePath, bindAttachmentStore } from "../../daemon/src/util/attachmentRefs.js"; + +describe("resolveAttachmentRefs", () => { + it("resolves an upload id to an absolute path under the attachments directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-att-")); + const store = new AttachmentStore(join(dir, "attachments")); + const saved = await store.save("x.png", Buffer.from("png")); + const resolved = await resolveAttachmentRefs(store, [{ path: saved.id, mime: "image/png" }]); + expect(resolved).toEqual([{ path: saved.path, mime: "image/png" }]); + rmSync(dir, { recursive: true, force: true }); + }); + + it("resolveImagePath resolves an id to a path on disk", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-att2-")); + const store = new AttachmentStore(join(dir, "attachments")); + bindAttachmentStore(store); + const saved = await store.save("pic.png", Buffer.from("png")); + const disk = await resolveImagePath(saved.id); + expect(disk).toBe(saved.path); + rmSync(dir, { recursive: true, force: true }); + }); + + it("a stale attachment id throws", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-att3-")); + const store = new AttachmentStore(join(dir, "attachments")); + bindAttachmentStore(store); + await expect(resolveAttachmentRefs(store, [{ path: "a_nope", mime: "image/png" }])).rejects.toThrow(/失效/); + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/agent-console/test/v1/buildStamp.test.ts b/packages/agent-console/test/v1/buildStamp.test.ts new file mode 100644 index 00000000..bd1616e1 --- /dev/null +++ b/packages/agent-console/test/v1/buildStamp.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { daemonBuildStamp } from "../../daemon/src/util/buildStamp.js"; + +describe("daemonBuildStamp", () => { + it("matches daemon/dist/main.js mtime (not stuck at 0)", () => { + const root = join(fileURLToPath(import.meta.url), "..", "..", ".."); + const distMain = join(root, "daemon", "dist", "main.js"); + const stamp = daemonBuildStamp(); + expect(stamp).not.toBe("0"); + expect(stamp).toBe(String(statSync(distMain).mtimeMs)); + }); +}); diff --git a/packages/agent-console/test/v1/claude-cli.test.ts b/packages/agent-console/test/v1/claude-cli.test.ts new file mode 100644 index 00000000..252cae18 --- /dev/null +++ b/packages/agent-console/test/v1/claude-cli.test.ts @@ -0,0 +1,260 @@ +// The Claude provider drives the user's own `claude` over stream-json (no SDK). Against a stand-in CLI +// (fixtures/fake-claude.mjs) that speaks the same protocol: streaming text / thinking / tools, permission approve and +// deny, the CLI withdrawing an ask, interrupt, model / mode switches, resume, images, slash commands, and a claude +// that is missing or dies. +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { ClaudeSession } from "../../daemon/src/providers/claude/session.js"; +import { createClaudeProvider } from "../../daemon/src/providers/claude/provider.js"; +import { clearBinCache } from "../../daemon/src/util/bin.js"; +import { setAppLocale } from "../../daemon/src/util/i18n.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; + +const fixture = resolve(__dirname, "fixtures", "fake-claude.mjs"); +const bin = { ok: true as const, cmd: process.execPath, prefixArgs: [fixture], display: fixture, via: "shim-js" }; +let dir: string; +let n = 0; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "claude-cli-")); + setAppLocale("en"); +}); +afterAll(() => { + setAppLocale("zh"); + rmSync(dir, { recursive: true, force: true }); +}); + +function open(o: Partial[0]> = {}, env: Record = {}) { + const logFile = join(dir, `log-${++n}.jsonl`); + const s = new ClaudeSession({ + threadId: "t", cwd: dir, model: null, mode: "default", effort: null, resumeId: null, bin, + env: { ...process.env, FAKE_CLAUDE_LOG: logFile, ...env }, ...o, + }); + const events: ProviderEvent[] = []; + const it = s.events[Symbol.asyncIterator](); + const w: { fn: (() => void) | null } = { fn: null }; + void (async () => { + for (;;) { + const r = await it.next(); + if (r.done) break; + events.push(r.value); + w.fn?.(); + } + w.fn?.(); + })(); + const until = async (pred: (e: ProviderEvent) => boolean, ms = 8000): Promise => { + const end = Date.now() + ms; + for (;;) { + const hit = events.find(pred); + if (hit) return hit; + if (Date.now() > end) throw new Error("timed out; events: " + JSON.stringify(events.map((e) => e.type))); + await new Promise((r) => { w.fn = r; setTimeout(r, 50); }); + } + }; + const log = () => readFileSync(logFile, "utf8").trim().split("\n").map((l) => JSON.parse(l)); + return { s, events, until, log }; +} +const ended = (e: ProviderEvent) => e.type === "turn_ended"; + +describe("claude over stream-json", () => { + it("launches with stream-json both ways and permissions over stdio, initializes, streams thinking and text, reports usage", async () => { + const { s, events, until, log } = open({ model: "claude-sonnet-4-5", effort: "high" }); + try { + await s.send({ text: "hello", images: [] }); + const end = await until(ended); + expect(end).toMatchObject({ type: "turn_ended", status: "completed" }); + const argv = log()[0].argv as string[]; + expect(argv.slice(0, 1)).toEqual(["-p"]); + expect(argv.join(" ")).toContain("--input-format stream-json --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool stdio"); + expect(argv).toEqual(expect.arrayContaining(["--model", "claude-sonnet-4-5", "--effort", "high", "--permission-mode", "default"])); + expect(argv).not.toContain("--resume"); + const ins = log().filter((l) => l.in).map((l) => l.in); + expect(ins[0]).toMatchObject({ type: "control_request", request: { subtype: "initialize" } }); + expect(ins[1]).toMatchObject({ type: "user", message: { role: "user" }, parent_tool_use_id: null }); + expect(events.find((e) => e.type === "provider_session_id")).toEqual({ type: "provider_session_id", id: "sess-new" }); + expect(events.some((e) => e.type === "reasoning_delta" && e.text === "hmm")).toBe(true); + expect(events.some((e) => e.type === "assistant_delta" && e.text.startsWith("echo hello"))).toBe(true); + expect(events.some((e) => e.type === "assistant_message" && e.text.startsWith("echo hello | model=claude-sonnet-4-5"))).toBe(true); + expect(events.find((e) => e.type === "usage")).toMatchObject({ inputTokens: 3, outputTokens: 2, cacheReadTokens: 1, costUsd: 0.001 }); + } finally { + await s.close(); + } + }); + + it("permission approve: the tool runs with its input, the result comes back as a tool call", async () => { + const { s, events, until } = open(); + try { + await s.send({ text: "TOOL please", images: [] }); + const req = (await until((e) => e.type === "permission_request")) as Extract; + expect(req.kind).toBe("tool"); + expect(req.options.map((o) => o.id)).toEqual(["allow", "allow_always", "deny"]); + s.respond(req.requestId, { optionId: "allow" }); + expect(await until(ended)).toMatchObject({ status: "completed" }); + expect(events.some((e) => e.type === "tool_call" && e.itemId === "tu_1" && e.status === "running")).toBe(true); + expect(events.some((e) => e.type === "tool_call" && e.itemId === "tu_1" && e.status === "ok" && e.output === "hi\n")).toBe(true); + expect(events.some((e) => e.type === "assistant_message" && e.text === "ran it")).toBe(true); + } finally { + await s.close(); + } + }); + + it("permission deny: claude gets a deny with a message and does not run the tool", async () => { + const { s, events, until, log } = open(); + try { + await s.send({ text: "TOOL please", images: [] }); + const req = (await until((e) => e.type === "permission_request")) as Extract; + s.respond(req.requestId, { optionId: "deny" }); + await until(ended); + const answer = log().find((l) => l.in?.type === "control_response")?.in; + expect(answer.response).toMatchObject({ subtype: "success", request_id: "perm-1", response: { behavior: "deny" } }); + expect(events.some((e) => e.type === "tool_call" && e.status === "running")).toBe(false); + expect(events.some((e) => e.type === "assistant_message" && e.text.startsWith("not run:"))).toBe(true); + } finally { + await s.close(); + } + }); + + it("an ask claude withdraws (control_cancel_request) closes the card", async () => { + const { s, until } = open(); + try { + await s.send({ text: "CANCEL", images: [] }); + const req = (await until((e) => e.type === "permission_request")) as Extract; + expect(await until((e) => e.type === "permission_resolved")).toMatchObject({ requestId: req.requestId, cancelled: true }); + await until(ended); + } finally { + await s.close(); + } + }); + + it("interrupt: a control request, and the turn ends interrupted", async () => { + const { s, until, log } = open(); + try { + await s.send({ text: "SLOW", images: [] }); + await until((e) => e.type === "assistant_delta"); + await s.interrupt(); + expect(await until(ended)).toMatchObject({ status: "interrupted" }); + expect(log().some((l) => l.in?.request?.subtype === "interrupt")).toBe(true); + } finally { + await s.close(); + } + }); + + it("model and mode switch in place; bypassPermissions and effort relaunch with --resume", async () => { + const { s, events, until, log } = open(); + try { + await s.send({ text: "one", images: [] }); + await until(ended); + await s.setModel("opus"); + await s.setMode("acceptEdits"); + events.length = 0; + await s.send({ text: "two", images: [] }); + expect(((await until((e) => e.type === "assistant_message")) as { text: string }).text).toContain("model=opus mode=acceptEdits"); + await until(ended); + const ins = log().filter((l) => l.in?.type === "control_request").map((l) => l.in.request); + expect(ins).toEqual(expect.arrayContaining([{ subtype: "set_model", model: "opus" }, { subtype: "set_permission_mode", mode: "acceptEdits" }])); + await s.setMode("bypassPermissions"); + events.length = 0; + await s.send({ text: "three", images: [] }); + await until(ended); + const argvs = log().filter((l) => l.argv).map((l) => l.argv as string[]); + expect(argvs).toHaveLength(2); + expect(argvs[1]).toEqual(expect.arrayContaining(["--resume", "sess-new", "--permission-mode", "bypassPermissions", "--allow-dangerously-skip-permissions", "--model", "opus"])); + expect(argvs[0]).not.toContain("--allow-dangerously-skip-permissions"); + } finally { + await s.close(); + } + }); + + it("resume: an old conversation starts claude with --resume ", async () => { + const { s, events, until, log } = open({ resumeId: "0b9c3c3e-aaaa-bbbb-cccc-000011112222" }); + try { + await s.send({ text: "again", images: [] }); + await until(ended); + expect(log()[0].argv).toEqual(expect.arrayContaining(["--resume", "0b9c3c3e-aaaa-bbbb-cccc-000011112222"])); + const user = log().find((l) => l.in?.type === "user").in; + expect(user.session_id).toBe("0b9c3c3e-aaaa-bbbb-cccc-000011112222"); + expect(events.find((e) => e.type === "provider_session_id")).toMatchObject({ id: "0b9c3c3e-aaaa-bbbb-cccc-000011112222" }); + } finally { + await s.close(); + } + }); + + it("images: PNG / JPEG / GIF / WebP go as base64 image blocks, a text file as a document", async () => { + const png = join(dir, "a.png"); + const b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAF0lEQVR4nGP4z8BAEiJN9aiGUQ1DSgMAkPn/Afnh+ngAAAAASUVORK5CYII="; + writeFileSync(png, Buffer.from(b64, "base64")); + const jpg = join(dir, "b.jpg"); writeFileSync(jpg, Buffer.from([0xff, 0xd8, 0xff, 0xd9])); + const gif = join(dir, "c.gif"); writeFileSync(gif, "GIF89a"); + const webp = join(dir, "d.webp"); writeFileSync(webp, "RIFF0000WEBP"); + const txt = join(dir, "notes.txt"); writeFileSync(txt, "hello notes"); + const { s, until, log } = open(); + try { + await s.send({ + text: "look", + images: [ + { path: png, mime: "image/png" }, { path: jpg, mime: "image/jpeg" }, { path: gif, mime: "image/gif" }, + { path: webp, mime: "image/webp" }, { path: txt, mime: "text/plain; charset=utf-8" }, + ], + }); + await until(ended); + const content = log().find((l) => l.in?.type === "user").in.message.content as any[]; + expect(content.filter((b) => b.type === "image").map((b) => b.source.media_type)).toEqual(["image/png", "image/jpeg", "image/gif", "image/webp"]); + expect(content[0]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: b64 } }); + expect(content.find((b) => b.type === "document")).toMatchObject({ source: { type: "text", media_type: "text/plain", data: "hello notes" }, title: "notes.txt" }); + expect(content[content.length - 1]).toEqual({ type: "text", text: "look" }); + } finally { + await s.close(); + } + }); + + it("slash commands come from the initialize answer", async () => { + const { s } = open(); + try { + const cmds = await s.listCommands(); + expect(cmds.map((c) => c.name).sort()).toEqual(["compact", "review"]); + expect(cmds.find((c) => c.name === "review")).toMatchObject({ description: "Review the diff", argumentHint: "[path]" }); + } finally { + await s.close(); + } + }); + + it("a claude that dies: an English notice with its stderr, the turn fails, the next send starts it again", async () => { + const { s, events, until } = open({}, { FAKE_CLAUDE_EXIT: "3" }); + try { + await s.send({ text: "hi", images: [] }); + const end = await until(ended); + expect(end).toMatchObject({ status: "failed" }); + const notice = events.find((e) => e.type === "notice") as { message: string }; + expect(notice.message).toMatch(/^claude exited unexpectedly \(3\) — stderr: fake claude: boom/); + } finally { + await s.close(); + } + }); + + it("a claude that can't be started (a broken shim): an English notice and a failed turn, no hang", async () => { + const broken = { ok: true as const, cmd: join(dir, "missing", "claude.exe"), prefixArgs: [], display: "claude", via: "shim-exe" }; + const { s, events, until } = open({ bin: broken }); + try { + await s.send({ text: "hi", images: [] }); + expect(await until(ended)).toMatchObject({ status: "failed" }); + expect((events.find((e) => e.type === "notice") as { message: string }).message).toMatch(/^Could not start claude: .*ENOENT/); + } finally { + await s.close(); + } + }); + + it("no claude on PATH: createSession says, in English, that Claude Code is not bundled and how to install it", async () => { + const path = process.env.PATH; + process.env.PATH = join(dir, "empty"); + clearBinCache("claude"); + try { + await expect(createClaudeProvider().createSession({ threadId: "t", cwd: dir, model: null, mode: "default", effort: null, resumeId: null })) + .rejects.toThrow(/^claude was not found .*Claude Code is not bundled with this plugin; install it first: npm i -g @anthropic-ai\/claude-code/); + } finally { + process.env.PATH = path; + clearBinCache("claude"); + } + }); +}); diff --git a/packages/agent-console/test/v1/claude-map.test.ts b/packages/agent-console/test/v1/claude-map.test.ts new file mode 100644 index 00000000..47248843 --- /dev/null +++ b/packages/agent-console/test/v1/claude-map.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { mapSdkMessage, newMapState } from "../../daemon/src/providers/claude/map.js"; +/** A claude stream-json message */ +type Msg = Record; + +const cwd = "D:/proj"; + +const sys = (o: Record) => ({ type: "system", ...o }) as Msg; +const stream = (event: Record) => ({ type: "stream_event", event }) as Msg; +const assistant = (content: unknown[], id = "msg_1") => + ({ type: "assistant", message: { id, content } }) as Msg; +const user = (content: unknown[]) => ({ type: "user", message: { role: "user", content } }) as Msg; +const result = (o: Record) => ({ type: "result", ...o }) as Msg; + +describe("claude map: stream-json messages → unified events", () => { + it("system init → provider_session_id", () => { + const st = newMapState(); + const evs = mapSdkMessage(sys({ subtype: "init", session_id: "abc-123", model: "sonnet" }), st, cwd); + expect(evs).toEqual([{ type: "provider_session_id", id: "abc-123" }]); + }); + + it("stream_event text_delta → assistant_delta with :t", () => { + const st = newMapState(); + mapSdkMessage(stream({ type: "message_start", message: { id: "msg_9" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 0, content_block: { type: "text" } }), st, cwd); + const evs = mapSdkMessage(stream({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "你好" } }), st, cwd); + expect(evs).toEqual([{ type: "assistant_delta", itemId: "msg_9:t0", text: "你好" }]); + }); + + it("stream_event thinking_delta → reasoning_delta (thinking and text numbered separately)", () => { + const st = newMapState(); + mapSdkMessage(stream({ type: "message_start", message: { id: "msg_9" } }), st, cwd); + // thinking block index=0, text block index=1 (interleaved by the SDK) → delta itemIds should be k0/t0 respectively + mapSdkMessage(stream({ type: "content_block_start", index: 0, content_block: { type: "thinking" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 1, content_block: { type: "text" } }), st, cwd); + const evs = mapSdkMessage(stream({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "想想" } }), st, cwd); + expect(evs).toEqual([{ type: "reasoning_delta", itemId: "msg_9:k0", text: "想想" }]); + const evs2 = mapSdkMessage(stream({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "回" } }), st, cwd); + expect(evs2).toEqual([{ type: "assistant_delta", itemId: "msg_9:t0", text: "回" }]); + }); + + it("delta and complete assistant event itemIds align (renumbered indexes don't render twice)", () => { + const st = newMapState(); + mapSdkMessage(stream({ type: "message_start", message: { id: "msg_7" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 0, content_block: { type: "text" } }), st, cwd); + mapSdkMessage(stream({ type: "content_block_start", index: 1, content_block: { type: "text" } }), st, cwd); + const d0 = mapSdkMessage(stream({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "长文" } }), st, cwd); + const d1 = mapSdkMessage(stream({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "收到" } }), st, cwd); + // The complete message content has only one text block (renumbered to index 0) → should it overwrite t1? No — by ordinal within type: + // the complete message's only text → t0; the text delta at index 1 → t1. + // Real case: when the SDK assistant event's content matches the streamed blocks, they align one to one; + // if the content was aggregated differently, the last assistant_message at least overwrites the first block of the same type. + const full = mapSdkMessage(assistant([{ type: "text", text: "收到" }], "msg_7"), st, cwd); + expect((d0[0] as { itemId: string }).itemId).toBe("msg_7:t0"); + expect((d1[0] as { itemId: string }).itemId).toBe("msg_7:t1"); + expect((full[0] as { itemId: string }).itemId).toBe("msg_7:t0"); + }); + + it("assistant text/thinking → assistant_message/reasoning; tool_use → tool_call running + TodoWrite→todo", () => { + const st = newMapState(); + const evs = mapSdkMessage( + assistant([ + { type: "text", text: "我来看看" }, + { type: "thinking", thinking: "先读文件" }, + { type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "src/index.ts" } }, + { type: "tool_use", id: "toolu_2", name: "TodoWrite", input: { todos: [{ content: "改代码", status: "in_progress" }] } }, + ], "msg_2"), + st, + cwd, + ); + const types = evs.map((e) => e.type); + expect(types).toEqual(["assistant_message", "reasoning", "tool_call", "tool_call", "todo"]); + expect((evs[0] as { itemId: string }).itemId).toBe("msg_2:t0"); + expect((evs[2] as { itemId: string; status: string; kind: string }).status).toBe("running"); + expect((evs[2] as { kind: string }).kind).toBe("read"); + expect((evs[4] as { items: { text: string }[] }).items[0].text).toBe("改代码"); + }); + + it("Edit tool_use → detail carries diff hunks", () => { + const st = newMapState(); + const evs = mapSdkMessage( + assistant([ + { type: "tool_use", id: "toolu_3", name: "Edit", input: { file_path: "a.ts", old_string: "x=1", new_string: "x=2" } }, + ]), + st, + cwd, + ); + const tc = evs[0] as { kind: string; detail: { hunks?: unknown[]; added: number; removed: number } }; + expect(tc.kind).toBe("edit"); + expect(tc.detail.hunks?.length).toBeGreaterThan(0); + expect(tc.detail.added).toBe(1); + expect(tc.detail.removed).toBe(1); + }); + + it("user tool_result → tool_call ok/failed (truncated at 20KB)", () => { + const st = newMapState(); + const big = "x".repeat(30 * 1024); + const evs = mapSdkMessage( + user([ + { type: "tool_result", tool_use_id: "toolu_1", content: [{ type: "text", text: "done" }] }, + { type: "tool_result", tool_use_id: "toolu_2", content: big, is_error: true }, + ]), + st, + cwd, + ); + expect(evs.length).toBe(2); + expect((evs[0] as { itemId: string; status: string }).itemId).toBe("toolu_1"); + expect((evs[0] as { status: string }).status).toBe("ok"); + expect((evs[1] as { status: string }).status).toBe("failed"); + expect(((evs[1] as { output?: string }).output ?? "").length).toBe(20 * 1024); + }); + + it("result → usage + turn_ended completed", () => { + const st = newMapState(); + const evs = mapSdkMessage( + result({ subtype: "success", total_cost_usd: 0.05, duration_ms: 4200, is_error: false, usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10 } }), + st, + cwd, + ); + const usage = evs.find((e) => e.type === "usage") as { inputTokens: number; costUsd: number }; + const end = evs.find((e) => e.type === "turn_ended") as { status: string; durationMs: number }; + expect(usage.inputTokens).toBe(100); + expect(usage.costUsd).toBe(0.05); + expect(end.status).toBe("completed"); + expect(end.durationMs).toBe(4200); + }); + + it("result is_error → turn_ended failed", () => { + const st = newMapState(); + const evs = mapSdkMessage(result({ subtype: "error_max_turns", is_error: true, result: "超过轮次" }), st, cwd); + const end = evs.find((e) => e.type === "turn_ended") as { status: string; error?: string }; + expect(end.status).toBe("failed"); + expect(end.error).toBe("超过轮次"); + }); + + it("result after interrupt → interrupted (sawInterrupt or a subtype containing interrupt)", () => { + const st = newMapState(); + st.sawInterrupt = true; + const evs = mapSdkMessage(result({ subtype: "success", is_error: false }), st, cwd); + const end = evs.find((e) => e.type === "turn_ended") as { status: string }; + expect(end.status).toBe("interrupted"); + expect(st.sawInterrupt).toBe(false); + + const st2 = newMapState(); + const evs2 = mapSdkMessage(result({ subtype: "error_interrupted", is_error: true }), st2, cwd); + const end2 = evs2.find((e) => e.type === "turn_ended") as { status: string }; + expect(end2.status).toBe("interrupted"); + }); +}); diff --git a/packages/agent-console/test/v1/claude-thinking.test.ts b/packages/agent-console/test/v1/claude-thinking.test.ts new file mode 100644 index 00000000..0d313c06 --- /dev/null +++ b/packages/agent-console/test/v1/claude-thinking.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { claudeThinkingForModel } from "../../daemon/src/providers/claude/thinking.js"; + +describe("claudeThinkingForModel", () => { + it("base levels with high as default", () => { + const t = claudeThinkingForModel("claude-sonnet-4-20250514"); + expect(t.defaultThinking).toBe("high"); + expect(t.thinkingOptions?.map((o) => o.id)).toEqual(["low", "medium", "high", "max", "xhigh"]); + }); + + it("ordinary models have no xhigh", () => { + const t = claudeThinkingForModel("claude-3-haiku"); + expect(t.thinkingOptions?.map((o) => o.id)).toEqual(["low", "medium", "high", "max"]); + }); +}); diff --git a/packages/agent-console/test/v1/cli-models.test.ts b/packages/agent-console/test/v1/cli-models.test.ts new file mode 100644 index 00000000..38859ee5 --- /dev/null +++ b/packages/agent-console/test/v1/cli-models.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { parseDevinModelsList, parseOpencodeModels } from "../../daemon/src/providers/acp/cliModels.js"; + +describe("CLI model parsers", () => { + it("parseDevinModelsList", () => { + const sample = ` +Available models (2 families) + swe-2-high SWE-2 High [262K context, Free] + adaptive Adaptive [$0.5 / 1M Input] +`; + const m = parseDevinModelsList(sample); + expect(m.some((x) => x.id === "swe-2-high")).toBe(true); + expect(m.find((x) => x.id === "adaptive")?.isDefault).toBe(true); + }); + + it("parseOpencodeModels", () => { + const m = parseOpencodeModels("opencode/beam-free\nopencode/big-pickle\n"); + expect(m.map((x) => x.id)).toEqual(["opencode/beam-free", "opencode/big-pickle"]); + }); +}); diff --git a/packages/agent-console/test/v1/coalescer.test.ts b/packages/agent-console/test/v1/coalescer.test.ts new file mode 100644 index 00000000..221fe3f2 --- /dev/null +++ b/packages/agent-console/test/v1/coalescer.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { Coalescer } from "../../daemon/src/timeline/coalescer.js"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("coalescer", () => { + it("first delta is sent immediately (leading)", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "he"); + expect(out).toEqual([["a", "he"]]); + c.dispose(); + }); + + it("deltas within 60ms are merged and sent once (trailing)", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "he"); // leading: sent immediately + c.push("a", "llo"); + c.push("a", "!"); + expect(out).toEqual([["a", "he"]]); + await sleep(90); + expect(out).toEqual([["a", "he"], ["a", "llo!"]]); + c.dispose(); + }); + + it("leading resumes after the window ends", async () => { + const out: [string, string][] = []; + const c = new Coalescer(50, (id, t) => out.push([id, t])); + c.push("a", "x"); + await sleep(80); + c.push("a", "y"); // Window has passed → sent immediately + expect(out).toEqual([["a", "x"], ["a", "y"]]); + c.dispose(); + }); + + it("pending deltas are flushed before the complete event", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "he"); + c.push("a", "llo"); + c.flushItem("a"); // Complete event arrives + expect(out).toEqual([["a", "he"], ["a", "llo"]]); + await sleep(80); + expect(out.length).toBe(2); // Nothing more is sent after flush + }); + + it("different itemIds don't affect each other", async () => { + const out: [string, string][] = []; + const c = new Coalescer(60, (id, t) => out.push([id, t])); + c.push("a", "1"); + c.push("b", "2"); + c.push("a", "3"); + expect(out).toEqual([["a", "1"], ["b", "2"]]); + c.dispose(); // flush a:3 + expect(out).toEqual([["a", "1"], ["b", "2"], ["a", "3"]]); + }); +}); diff --git a/packages/agent-console/test/v1/codex-rpc.test.ts b/packages/agent-console/test/v1/codex-rpc.test.ts new file mode 100644 index 00000000..b9e9d06a --- /dev/null +++ b/packages/agent-console/test/v1/codex-rpc.test.ts @@ -0,0 +1,144 @@ +import { afterAll, describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import { CodexRpc, CodexProcessExitedError } from "../../daemon/src/providers/codex/rpc.js"; +import { mapNotification, newCodexMapState } from "../../daemon/src/providers/codex/map.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Simulated app-server: echoes each JSON-RPC line as {id,result:{echo:params}}; +// on method:"emit" → sends the notifications/server requests in params.lines; +// the echo server request is answered with {id, result:{ok:true}}. +const fakeServer = ` +let buf = ""; +process.stdin.on("data", (c) => { + buf += c; + let i; + while ((i = buf.indexOf("\\n")) >= 0) { + const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); + if (!line) continue; + const m = JSON.parse(line); + if (m.method === "emit") { + for (const l of m.params.lines) process.stdout.write(JSON.stringify(l) + "\\n"); + process.stdout.write(JSON.stringify({ id: m.id, result: { ok: true } }) + "\\n"); + } else if (m.method === "serverRequest") { + // reply to the client + send a server request; once the client answers {id:999,result}, send a notification + process.stdout.write(JSON.stringify({ id: m.id, result: { ok: true } }) + "\\n"); + process.stdout.write(JSON.stringify({ id: 999, method: "item/commandExecution/requestApproval", params: { command: "npm test", itemId: "i1" } }) + "\\n"); + } else if (m.id === 999 && m.result !== undefined) { + process.stdout.write(JSON.stringify({ method: "approval/replied", params: m.result }) + "\\n"); + } else if (m.id !== undefined && m.method) { + process.stdout.write(JSON.stringify({ id: m.id, result: { echo: m.method } }) + "\\n"); + } + } +}); +process.stdin.on("end", () => process.exit(0)); +`; + +// All cases share one temporary script, deleted afterwards +const dir = mkdtempSync(join(tmpdir(), "rpc-")); +const script = join(dir, "server.mjs"); +writeFileSync(script, fakeServer); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +function makeRpc(): { rpc: CodexRpc; notifications: { m: string; p: Record }[] } { + const rpc = new CodexRpc({ cmd: process.execPath, args: [script] }); + const notifications: { m: string; p: Record }[] = []; + rpc.onNotification = (m, p) => notifications.push({ m, p }); + rpc.onServerRequest = async () => ({ decision: "accept" }); + return { rpc, notifications }; +} + +describe("CodexRpc", () => { + it("request → result", async () => { + const { rpc } = makeRpc(); + const res = await rpc.request<{ echo: string }>("turn/start", { x: 1 }); + expect(res.echo).toBe("turn/start"); + await rpc.close(); + }); + + it("notification → onNotification", async () => { + const { rpc, notifications } = makeRpc(); + await rpc.request("emit", { + lines: [ + { method: "turn/started", params: { turn: { id: "t1" } } }, + { method: "item/agentMessage/delta", params: { itemId: "i1", delta: "hi" } }, + ], + }); + await new Promise((r) => setTimeout(r, 200)); + expect(notifications.map((n) => n.m)).toEqual(["turn/started", "item/agentMessage/delta"]); + await rpc.close(); + }); + + it("server request → onServerRequest answer", async () => { + const { rpc, notifications } = makeRpc(); + await rpc.request("serverRequest", {}); + await new Promise((r) => setTimeout(r, 200)); + expect(notifications.some((n) => n.m === "approval/replied" && (n.p as { decision?: string }).decision === "accept")).toBe(true); + await rpc.close(); + }); + + it("process exit → pending requests reject", async () => { + const { rpc } = makeRpc(); + const p = rpc.request("turn/start", {}); + await rpc.close(); + await expect(rpc.request("x")).rejects.toBeInstanceOf(CodexProcessExitedError); + await p.catch(() => {}); + }); +}); + +describe("codex map", () => { + it("turn/started + deltas + completed", () => { + const st = newCodexMapState(); + let evs = mapNotification("turn/started", { turn: { id: "t1" } }, st); + expect(st.currentTurnId).toBe("t1"); + evs = mapNotification("item/agentMessage/delta", { itemId: "m1", delta: "你好" }, st); + expect(evs).toEqual([{ type: "assistant_delta", itemId: "m1", text: "你好" }]); + evs = mapNotification("item/reasoning/summaryTextDelta", { itemId: "r1", delta: "想" }, st); + expect(evs).toEqual([{ type: "reasoning_delta", itemId: "r1", text: "想" }]); + }); + + it("item/started commandExecution → tool_call running; completed → ok", () => { + const st = newCodexMapState(); + let evs = mapNotification("item/started", { item: { type: "commandExecution", id: "c1", command: "npm test" } }, st); + expect(evs[0]).toMatchObject({ type: "tool_call", itemId: "c1", kind: "bash", status: "running" }); + evs = mapNotification("item/completed", { item: { type: "commandExecution", id: "c1", command: "npm test", exitCode: 0, aggregatedOutput: "ok", durationMs: 500 } }, st); + expect(evs[0]).toMatchObject({ status: "ok", output: "ok", exitCode: 0 }); + }); + + it("fileChange → edit tool_call + filesChanged count", () => { + const st = newCodexMapState(); + mapNotification("item/started", { item: { type: "fileChange", id: "f1", changes: [{ path: "a.ts" }, { path: "b.ts" }] } }, st); + mapNotification("item/completed", { item: { type: "fileChange", id: "f1", changes: [{ path: "a.ts" }, { path: "b.ts" }], status: "completed" } }, st); + expect(st.filesChanged).toBe(2); + const evs = mapNotification("turn/completed", { turn: { id: "t1", status: "completed", durationMs: 100 } }, st); + const end = evs.find((e) => e.type === "turn_ended") as { filesChanged?: number; status: string }; + expect(end.status).toBe("completed"); + expect(end.filesChanged).toBe(2); + }); + + it("turn/plan/updated → todo", () => { + const st = newCodexMapState(); + const evs = mapNotification("turn/plan/updated", { plan: [{ step: "改代码", status: "inProgress" }, { step: "测试", status: "pending" }] }, st); + expect(evs[0]).toMatchObject({ type: "todo" }); + const items = (evs[0] as { items: { text: string; status: string }[] }).items; + expect(items[0]).toEqual({ text: "改代码", status: "in_progress" }); + expect(items[1].status).toBe("pending"); + }); + + it("turn/completed interrupted/failed", () => { + const st = newCodexMapState(); + let evs = mapNotification("turn/completed", { turn: { id: "t1", status: "interrupted" } }, st); + expect((evs[0] as { status: string }).status).toBe("interrupted"); + evs = mapNotification("turn/completed", { turn: { id: "t2", status: "failed", error: { message: "网关地区不支持" } } }, st); + const end = evs[0] as { status: string; error?: string }; + expect(end.status).toBe("failed"); + expect(end.error).toBe("网关地区不支持"); + }); + + it("thread/tokenUsage/updated → usage", () => { + const st = newCodexMapState(); + const evs = mapNotification("thread/tokenUsage/updated", { tokenUsage: { last: { inputTokens: 10, outputTokens: 5, cachedInputTokens: 2 } } }, st); + expect(evs[0]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5, cacheReadTokens: 2 }); + }); +}); diff --git a/packages/agent-console/test/v1/crossplatform.test.ts b/packages/agent-console/test/v1/crossplatform.test.ts new file mode 100644 index 00000000..55875a07 --- /dev/null +++ b/packages/agent-console/test/v1/crossplatform.test.ts @@ -0,0 +1,371 @@ +// Cross-platform logic (independent of the real OS): PATH scanning, .cmd/.ps1 shim resolution, cmd.exe escaping, +// default shell, path normalization, pid liveness, PATH completion, cross-drive moves, the gate's 503 text. +import { describe, it, expect } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extraBinDirs, findNode, findOnPath, isNodeExecutable, requireNode, searchDirs } from "../../lib/pathscan.mjs"; +import { failureHtml } from "../../lib/gate.mjs"; +import { + argvFor, + escapeCmdArgument, + escapeCmdCommand, + killTree, + resolveBinWith, + shimTarget, + spawnBin, +} from "../../daemon/src/util/bin.js"; +import { pickShell, simpleShellArgs } from "../../daemon/src/terminal/service.js"; +import { normPath } from "../../daemon/src/util/paths.js"; +import { augmentPath, pidAlive } from "../../daemon/src/util/proc.js"; +import { moveAcrossDevices } from "../../daemon/src/git/service.js"; +import { claudeArgs } from "../../daemon/src/providers/claude/cli.js"; + +const lower = (s: string) => s.toLowerCase(); + +describe("pathscan (simulated Windows)", () => { + const env = { Path: "C:\\Users\\张三\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs", PATHEXT: ".COM;.EXE;.BAT;.CMD" }; + const files = new Set( + [ + "C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.cmd", + "C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.ps1", + "C:\\Users\\张三\\AppData\\Roaming\\npm\\claude", + "C:\\Users\\张三\\AppData\\Roaming\\npm\\onlyps.ps1", + "C:\\Program Files\\nodejs\\node.exe", + "C:\\Program Files\\nodejs\\claude.exe", + ].map(lower), + ); + const exists = (f: string) => files.has(lower(f)); + const opts = { platform: "win32" as const, env, home: "C:\\Users\\张三", exists }; + + it("env var names are case-insensitive (Path), PATH order + PATHEXT order, extensionless shims are not returned", () => { + const hits = findOnPath("claude", opts); + expect(hits[0]).toBe("C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.cmd"); + expect(hits).toContain("C:\\Users\\张三\\AppData\\Roaming\\npm\\claude.ps1"); + expect(hits).toContain("C:\\Program Files\\nodejs\\claude.exe"); + expect(hits).not.toContain("C:\\Users\\张三\\AppData\\Roaming\\npm\\claude"); + }); + + it("a lone .ps1 is found too; names with an extension are looked up as-is", () => { + expect(findOnPath("onlyps", opts)).toEqual(["C:\\Users\\张三\\AppData\\Roaming\\npm\\onlyps.ps1"]); + expect(findOnPath("node.exe", opts)).toEqual(["C:\\Program Files\\nodejs\\node.exe"]); + }); + + it("findNode only accepts node.exe; requireNode searches PATH when the host is Bun and gives a userMessage when not found", () => { + expect(findNode(opts)).toBe("C:\\Program Files\\nodejs\\node.exe"); + expect(requireNode({ execPath: "C:\\Magpie\\bun.exe", find: () => "C:\\n\\node.exe" })).toBe("C:\\n\\node.exe"); + expect(requireNode({ execPath: "/usr/bin/node", find: () => null })).toBe("/usr/bin/node"); + let err: (Error & { code?: string; userMessage?: string }) | null = null; + try { + requireNode({ execPath: "/opt/magpie/bun", find: () => null }); + } catch (e) { + err = e as Error & { code?: string; userMessage?: string }; + } + expect(err?.code).toBe("ENODE"); + expect(err?.userMessage).toMatch(/Node\.js/); + }); + + it("directory dedupe is case-insensitive and quotes in PATH are stripped", () => { + const dirs = searchDirs({ ...opts, env: { PATH: '"C:\\A b";c:\\a B\\;C:\\X', APPDATA: "C:\\X" }, extra: true }); + expect(dirs.filter((d) => lower(d).replace(/\\$/, "") === "c:\\a b")).toHaveLength(1); + expect(dirs.filter((d) => lower(d) === "c:\\x")).toHaveLength(1); + }); + + it("isNodeExecutable", () => { + expect(isNodeExecutable("C:\\Program Files\\nodejs\\node.exe".replace(/\\/g, "/"))).toBe(true); + expect(isNodeExecutable("/usr/local/bin/node")).toBe(true); + expect(isNodeExecutable("/Applications/Magpie.app/Contents/MacOS/bun")).toBe(false); + }); +}); + +describe("pathscan (simulated macOS / Linux)", () => { + it("adds Homebrew and ~/.local/bin when the macOS GUI PATH is minimal", () => { + const dirs = extraBinDirs({ platform: "darwin", env: {}, home: "/Users/me" }); + expect(dirs).toContain("/opt/homebrew/bin"); + expect(dirs).toContain("/usr/local/bin"); + expect(dirs).toContain("/Users/me/.local/bin"); + const hit = findOnPath("codex", { + platform: "darwin", + env: { PATH: "/usr/bin:/bin" }, + home: "/Users/me", + exists: (f) => f === "/opt/homebrew/bin/codex", + }); + expect(hit).toEqual(["/opt/homebrew/bin/codex"]); + }); + + it("Linux does not add Homebrew; PATH comes first", () => { + expect(extraBinDirs({ platform: "linux", env: {}, home: "/home/me" })).not.toContain("/opt/homebrew/bin"); + const hit = findOnPath("x", { + platform: "linux", + env: { PATH: "/a:/b" }, + home: "/home/me", + exists: (f) => f === "/b/x" || f === "/usr/local/bin/x", + }); + expect(hit).toEqual(["/b/x", "/usr/local/bin/x"]); + }); +}); + +describe("resolveBin (simulated Windows)", () => { + const npmShim = [ + "@ECHO off", + "SETLOCAL", + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ") ELSE (", + ' SET "_prog=node"', + ")", + '"%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*', + ].join("\r\n"); + const base = "C:\\Program Files\\nodejs"; + + it("npm .cmd shim → node + cli.js; node.exe in the same directory is not picked by mistake", async () => { + const files = new Set([`${base}\\node.exe`, `${base}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`].map(lower)); + const r = await resolveBinWith("claude", { + platform: "win32", + find: () => [`${base}\\claude.cmd`], + readText: async () => npmShim, + exists: (f) => files.has(lower(f)), + node: () => "C:\\node\\node.exe", + }); + expect(r).toMatchObject({ ok: true, via: "shim-js", cmd: "C:\\node\\node.exe" }); + if (r.ok) expect(r.prefixArgs[0]).toBe(`${base}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`); + }); + + it("uses the exe when shimTarget only points at an .exe", () => { + const t = shimTarget('"%~dp0\\bin\\tool.exe" %*', "C:\\t", { win: true, exists: (f) => lower(f) === "c:\\t\\bin\\tool.exe" }); + expect(t).toBe("C:\\t\\bin\\tool.exe"); + }); + + it(".exe used directly; .ps1 via powershell -File; other .cmd via cmd-shim", async () => { + const exe = await resolveBinWith("codex", { platform: "win32", find: () => ["C:\\x\\codex.exe"] }); + expect(exe).toMatchObject({ ok: true, via: "exe", cmd: "C:\\x\\codex.exe", prefixArgs: [] }); + const ps = await resolveBinWith("agent", { platform: "win32", find: () => ["C:\\x\\agent.ps1"] }); + expect(ps).toMatchObject({ ok: true, via: "ps1", cmd: "powershell.exe" }); + if (ps.ok) expect(ps.prefixArgs.slice(-2)).toEqual(["-File", "C:\\x\\agent.ps1"]); + const cmd = await resolveBinWith("cursor-agent", { + platform: "win32", + find: () => ["C:\\Users\\A B\\AppData\\Local\\cursor-agent\\cursor-agent.cmd"], + readText: async () => '@powershell -NoProfile -File "%~dp0cursor-agent.ps1" %*', + exists: () => false, + comspec: "C:\\Windows\\System32\\cmd.exe", + }); + expect(cmd).toMatchObject({ ok: true, via: "cmd-shim", cmd: "C:\\Windows\\System32\\cmd.exe" }); + }); + + it("npx special case: node.exe + npx-cli.js", async () => { + const r = await resolveBinWith("npx", { + platform: "win32", + find: () => [`${base}\\npx.cmd`], + exists: () => true, + }); + expect(r).toMatchObject({ ok: true, via: "npx-shim", cmd: `${base}\\node.exe` }); + }); + + it("gives a reason when not found; POSIX uses the PATH hit directly", async () => { + expect(await resolveBinWith("nope", { platform: "win32", find: () => [] })).toMatchObject({ ok: false }); + expect(await resolveBinWith("claude", { platform: "darwin", find: () => ["/opt/homebrew/bin/claude"] })).toMatchObject({ + ok: true, + cmd: "/opt/homebrew/bin/claude", + via: "path", + }); + }); +}); + +describe("cmd.exe escaping (cmd-shim)", () => { + it("spaces and metacharacters in paths are ^-escaped; arguments are quoted and escaped twice", () => { + expect(escapeCmdCommand("C:\\Users\\A B\\x.cmd")).toBe("C:\\Users\\A^ B\\x.cmd"); + expect(escapeCmdArgument("a b", false)).toBe('^"a^ b^"'); + expect(escapeCmdArgument("a&b", true)).toBe('^^^"a^^^&b^^^"'); + expect(escapeCmdArgument('say "hi"\\', false)).toBe('^"say^ \\^"hi\\^"\\\\^"'); + }); + + it("argvFor: cmd-shim uses /d /s /c + windowsVerbatimArguments; others just prepend prefixArgs", () => { + const a = argvFor( + { ok: true, cmd: "cmd.exe", prefixArgs: [], display: "", via: "cmd-shim", shim: "C:\\A B\\agent.cmd" }, + ["--print", "x y"], + ); + expect(a.cmd).toBe("cmd.exe"); + expect(a.windowsVerbatimArguments).toBe(true); + expect(a.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(a.args[3]).toBe('"C:\\A^ B\\agent.cmd ^^^"--print^^^" ^^^"x^^^ y^^^""'); + const b = argvFor({ ok: true, cmd: "node", prefixArgs: ["cli.js"], display: "", via: "shim-js" }, ["-v"]); + expect(b).toEqual({ cmd: "node", args: ["cli.js", "-v"] }); + }); +}); + +describe("claude CLI launch", () => { + const base = { model: null, permissionMode: "default", effort: null, resume: null }; + it("stream-json both ways, permissions over stdio, model / effort / resume as flags", () => { + const a = claudeArgs({ ...base, model: "claude-sonnet-4-5", effort: "high", resume: "0b9c3c3e-1111-2222-3333-444455556666" }); + expect(a.slice(0, 1)).toEqual(["-p"]); + expect(a.join(" ")).toContain("--input-format stream-json --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool stdio"); + expect(a).toEqual(expect.arrayContaining(["--model", "claude-sonnet-4-5", "--effort", "high", "--resume", "0b9c3c3e-1111-2222-3333-444455556666", "--permission-mode", "default"])); + expect(a).not.toContain("--allow-dangerously-skip-permissions"); + expect(claudeArgs({ ...base, permissionMode: "bypassPermissions" })).toContain("--allow-dangerously-skip-permissions"); + }); + it("refuses values that could become another flag or shell syntax", () => { + for (const bad of ["--dangerously-skip-permissions", "a b", "x&calc", 'a"b', "m|n", "%PATH%"]) { + expect(() => claudeArgs({ ...base, model: bad })).toThrow(/unexpected value/); + expect(() => claudeArgs({ ...base, resume: bad })).toThrow(/unexpected value/); + } + }); + it("a Windows .cmd shim runs through cmd.exe with every argument escaped; a .ps1 through PowerShell -File; an npm shim as node + cli.js", () => { + const args = claudeArgs({ ...base, model: "opus[1m]" }); + const cmd = argvFor({ ok: true, cmd: "C:\\Windows\\cmd.exe", prefixArgs: [], display: "", via: "cmd-shim", shim: "C:\\Users\\a b\\claude.cmd" }, args); + expect(cmd.cmd).toBe("C:\\Windows\\cmd.exe"); + expect(cmd.windowsVerbatimArguments).toBe(true); + expect(cmd.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(cmd.args[3]).toContain("C:\\Users\\a^ b\\claude.cmd"); + expect(cmd.args[3]).toContain('^^^"opus^^^[1m^^^]^^^"'); + const ps1 = argvFor({ ok: true, cmd: "powershell.exe", prefixArgs: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "C:\\x\\claude.ps1"], display: "", via: "ps1" }, args); + expect(ps1.args.slice(0, 6)).toEqual(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "C:\\x\\claude.ps1"]); + expect(ps1.args.slice(6)).toEqual(args); + const js = argvFor({ ok: true, cmd: "C:\\n\\node.exe", prefixArgs: ["C:\\x\\cli.js"], display: "", via: "shim-js" }, args); + expect(js).toEqual({ cmd: "C:\\n\\node.exe", args: ["C:\\x\\cli.js", ...args] }); + }); +}); + +describe("default shell", () => { + it("Windows: pwsh → powershell → system powershell → cmd", () => { + expect(pickShell({ platform: "win32", env: {}, find: (n) => (n === "pwsh" ? "C:\\P\\pwsh.exe" : null) })).toBe("C:\\P\\pwsh.exe"); + expect( + pickShell({ platform: "win32", env: { SystemRoot: "C:\\Windows" }, find: () => null, isExec: () => false, }), + ).toBe("cmd.exe"); + expect( + pickShell({ platform: "win32", env: { SystemRoot: "C:\\Windows", ComSpec: "C:\\Windows\\system32\\cmd.exe" }, find: () => null, isExec: () => false }), + ).toBe("C:\\Windows\\system32\\cmd.exe"); + }); + it("macOS defaults to zsh; $SHELL is skipped when it does not exist; Linux bash → sh", () => { + expect(pickShell({ platform: "darwin", env: {}, isExec: (f) => f === "/bin/zsh" || f === "/bin/bash" })).toBe("/bin/zsh"); + expect(pickShell({ platform: "darwin", env: { SHELL: "/opt/gone/fish" }, isExec: (f) => f === "/bin/zsh" })).toBe("/bin/zsh"); + expect(pickShell({ platform: "linux", env: { SHELL: "/usr/bin/fish" }, isExec: () => true })).toBe("/usr/bin/fish"); + expect(pickShell({ platform: "linux", env: {}, isExec: (f) => f === "/bin/sh" })).toBe("/bin/sh"); + }); + it("no-PTY arguments", () => { + expect(simpleShellArgs("C:\\Windows\\system32\\cmd.exe", "win32")).toEqual(["/Q"]); + expect(simpleShellArgs("pwsh.exe", "win32")).toEqual(["-NoLogo"]); + expect(simpleShellArgs("/bin/zsh", "darwin")).toEqual(["-i"]); + }); +}); + +describe("path normalization", () => { + it("case-insensitive on macOS / Windows, case-sensitive on Linux", () => { + expect(normPath("/Users/Me/Proj/", "darwin")).toBe("/users/me/proj"); + expect(normPath("/home/Me/Proj", "linux")).toBe("/home/Me/Proj"); + }); + it("macOS normalizes NFD file names (Chinese / accented) to NFC", () => { + expect(normPath("/tmp/cafe\u0301", "darwin")).toBe(normPath("/tmp/caf\u00e9", "darwin")); + }); +}); + +describe("process utilities", () => { + it("pidAlive: EPERM counts as alive, ESRCH as dead", () => { + const err = (code: string) => () => { + throw Object.assign(new Error(code), { code }); + }; + expect(pidAlive(123, err("EPERM"))).toBe(true); + expect(pidAlive(123, err("ESRCH"))).toBe(false); + expect(pidAlive(123, () => true)).toBe(true); + expect(pidAlive(0, () => true)).toBe(false); + expect(pidAlive(process.pid)).toBe(true); + }); + + it("augmentPath: appends at the end, dedupes (case-insensitive on Windows), keeps the existing order", () => { + const win = augmentPath({ Path: "C:\\B;C:\\Program Files\\NodeJS" }, "C:\\Program Files\\nodejs", "win32", "C:\\Users\\me"); + const parts = win.split(";"); + expect(parts.slice(0, 2)).toEqual(["C:\\B", "C:\\Program Files\\NodeJS"]); + expect(parts.filter((p) => lower(p) === "c:\\program files\\nodejs")).toHaveLength(1); + const mac = augmentPath({ PATH: "/usr/bin:/bin" }, "/opt/homebrew/bin", "darwin", "/Users/me").split(":"); + expect(mac.slice(0, 3)).toEqual(["/usr/bin", "/bin", "/opt/homebrew/bin"]); + expect(mac).toContain("/Users/me/.local/bin"); + }); + + it.skipIf(process.platform === "win32")("killTree (POSIX): detached process group, grandchildren exit too", async () => { + const r = { ok: true as const, cmd: "/bin/sh", prefixArgs: [], display: "sh", via: "path" }; + const child = spawnBin(r, ["-c", "sleep 30 & echo $!; wait"], { stdio: ["ignore", "pipe", "ignore"], group: true }); + const grandchild = await new Promise((res) => child.stdout!.once("data", (d) => res(Number(String(d).trim())))); + expect(pidAlive(grandchild)).toBe(true); + await killTree(child, { graceMs: 1000 }); + for (let i = 0; i < 20 && pidAlive(grandchild); i++) await new Promise((r) => setTimeout(r, 50)); + expect(pidAlive(grandchild)).toBe(false); + }); + + it("killTree returns immediately for a child that already exited", async () => { + const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + await new Promise((r) => child.once("exit", r)); + const t0 = Date.now(); + await killTree(child, { graceMs: 3000 }); + expect(Date.now() - t0).toBeLessThan(1000); + }); + + it("spawnBin really starts a process from the argvFor result", async () => { + const r = { ok: true as const, cmd: process.execPath, prefixArgs: ["-e"], display: "node", via: "path" }; + const out = spawnSync(r.cmd, [...r.prefixArgs, "process.stdout.write('ok')"]).stdout.toString(); + expect(out).toBe("ok"); + const child = spawnBin(r, ["process.stdout.write('中文 ok')"], { stdio: ["ignore", "pipe", "ignore"] }); + let text = ""; + child.stdout!.on("data", (d) => (text += d)); + await new Promise((res) => child.once("close", res)); + expect(text).toBe("中文 ok"); + }); +}); + +describe("cross-drive move (git discarding untracked files)", () => { + it("copy then delete when rename fails with EXDEV; other errors are thrown as usual", async () => { + const dir = mkdtempSync(join(tmpdir(), "ac-move-")); + try { + const from = join(dir, "a"); + mkdirSync(join(from, "sub"), { recursive: true }); + writeFileSync(join(from, "sub", "f.txt"), "内容"); + const to = join(dir, "trash", "a"); + mkdirSync(join(dir, "trash")); + const exdev = async () => { + throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); + }; + await moveAcrossDevices(from, to, { rename: exdev as never }); + expect(existsSync(from)).toBe(false); + expect(readFileSync(join(to, "sub", "f.txt"), "utf8")).toBe("内容"); + const eacces = async () => { + throw Object.assign(new Error("denied"), { code: "EACCES" }); + }; + await expect(moveAcrossDevices(to, join(dir, "b"), { rename: eacces as never })).rejects.toThrow("denied"); + expect(existsSync(to)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("gate 503 page", () => { + it("shows only the userMessage, HTML-escaped; other error details are not exposed", () => { + const html = failureHtml(Object.assign(new Error("secret /home/x"), { userMessage: "装 Node" })); + expect(html).toContain("<b>装 Node</b>"); + expect(html).not.toContain(""); + expect(failureHtml(new Error("secret /home/x"))).not.toContain("secret"); + }); +}); + +describe("folder picker (macOS / Linux)", async () => { + const { appleScriptString, pickFolderCommand } = await import("../../daemon/src/util/pickFolder.js"); + it("AppleScript strings escape backslashes and quotes; no default location when the initial directory does not exist", () => { + expect(appleScriptString('a"b\\c')).toBe('"a\\"b\\\\c"'); + const c = pickFolderCommand("darwin", "/definitely/not/here"); + expect(c?.cmd).toBe("/usr/bin/osascript"); + expect(c?.args[1]).not.toContain("default location"); + const d = pickFolderCommand("darwin", tmpdir()); + expect(d?.args[1]).toContain("default location (POSIX file "); + }); + it("Linux: zenity / kdialog only with a graphical session", () => { + const find = (n: string) => (n === "kdialog" ? "/usr/bin/kdialog" : null); + expect(pickFolderCommand("linux", undefined, find, {})).toBeNull(); + expect(pickFolderCommand("linux", undefined, find, { DISPLAY: ":0", HOME: "/home/me" })).toEqual({ + cmd: "/usr/bin/kdialog", + args: ["--getexistingdirectory", "/home/me"], + }); + const z = pickFolderCommand("linux", tmpdir(), (n) => (n === "zenity" ? "/usr/bin/zenity" : null), { WAYLAND_DISPLAY: "wayland-0" }); + expect(z?.args).toContain("--directory"); + expect(z?.args.at(-1)).toMatch(/^--filename=.*\/$/); + expect(pickFolderCommand("aix" as NodeJS.Platform, undefined, find, { DISPLAY: ":0" })).toBeNull(); + }); +}); diff --git a/packages/agent-console/test/v1/cursor-models.test.ts b/packages/agent-console/test/v1/cursor-models.test.ts new file mode 100644 index 00000000..d75e475b --- /dev/null +++ b/packages/agent-console/test/v1/cursor-models.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { parseCursorListModels, cursorWireModel } from "../../daemon/src/providers/acp/cursorModels.js"; + +describe("cursor list-models parsing", () => { + it("parses id and display name", () => { + const text = `Available models + +auto - Auto (default) +composer-2.5 - Composer 2.5 (current) +grok-4.7-high - Grok 4.7 High +Tip: use --model`; + const m = parseCursorListModels(text); + expect(m.find((x) => x.id === "composer-2.5")?.name).toBe("Composer 2.5"); + expect(m.find((x) => x.id === "auto")?.isDefault).toBe(true); + }); + + it("Fast toggle appends the model parameter", () => { + expect(cursorWireModel("composer-2.5", false)).toBe("composer-2.5"); + expect(cursorWireModel("composer-2.5", true)).toBe("composer-2.5[fast=true]"); + // Parameterized values reported over ACP: sent unchanged without Fast; with Fast only fast changes + const v = "claude-opus-5-5[context=300k,effort=medium,fast=false]"; + expect(cursorWireModel(v, false)).toBe(v); + expect(cursorWireModel(v, true)).toBe("claude-opus-5-5[context=300k,effort=medium,fast=true]"); + // A thinking level is chosen: written as that model's own level parameter + expect(cursorWireModel("grok-4.6", false, { param: "effort", value: "xhigh" })).toBe("grok-4.6[effort=xhigh]"); + expect(cursorWireModel("gpt-5.6-sol", true, { param: "reasoning", value: "max" })).toBe("gpt-5.6-sol[reasoning=max,fast=true]"); + expect(cursorWireModel(v, false, { param: "effort", value: "high" })).toBe("claude-opus-5-5[context=300k,fast=false,effort=high]"); + }); +}); diff --git a/packages/agent-console/test/v1/dsh-images.test.ts b/packages/agent-console/test/v1/dsh-images.test.ts new file mode 100644 index 00000000..7a7027a8 --- /dev/null +++ b/packages/agent-console/test/v1/dsh-images.test.ts @@ -0,0 +1,152 @@ +// dsh takes images only on the route its ACP starts on (it answers promptCapabilities.image at initialize for its `acp` row, +// and refuses image blocks on a connection that said no). Agent Console starts it with a --patch naming the conversation's route. +// fixtures/fake-dsh.mjs behaves like dsh 0.2.1-rc.5 here (checked against the real dsh, see README "Images"). +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createAcpProvider } from "../../daemon/src/providers/acp/provider.js"; +import { acpConfigForEntry } from "../../daemon/src/providers/acp/specs.js"; +import { dshProbeModels } from "../../daemon/src/providers/acp/probe.js"; +import { dshAcpArgs, dshDefaultModel, dshRoute, dshRoutePatch } from "../../daemon/src/providers/acp/dsh.js"; +import { forgetPromptCaps, knownPromptCaps } from "../../daemon/src/providers/acp/promptCaps.js"; +import { catalogById } from "../../daemon/src/providers/catalog.js"; +import type { ProviderSession } from "../../daemon/src/providers/types.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; + +const fixture = resolve(__dirname, "fixtures", "fake-dsh.mjs"); +const launch = { cmd: process.execPath, prefixArgs: [fixture] }; +const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; +const VIS = JSON.stringify(["magpie", "vis"]); +const TXT = JSON.stringify(["magpie", "txt"]); + +let dir: string; +let png: string; +const saved = { vision: process.env.FAKE_DSH_VISION, log: process.env.FAKE_ACP_LOG }; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "dsh-images-")); + png = join(dir, "shot.png"); + writeFileSync(png, Buffer.from(PNG_B64, "base64")); + process.env.FAKE_DSH_VISION = "magpie/vis,deepseek-official/deepseek-flash"; +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); +afterEach(() => forgetPromptCaps()); +afterAll(() => { + for (const [k, v] of [["FAKE_DSH_VISION", saved.vision], ["FAKE_ACP_LOG", saved.log]] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +}); + +const dshCfg = () => acpConfigForEntry(catalogById("dsh")!, "test")!; + +type Run = { session: ProviderSession; events: ProviderEvent[]; log: string; done: Promise }; +async function open(model: string | null): Promise { + const log = join(dir, `p-${Math.random().toString(36).slice(2)}.jsonl`); + process.env.FAKE_ACP_LOG = log; + const session = await createAcpProvider(dshCfg()).createSession({ + threadId: "t1", cwd: dir, model, mode: "default", effort: null, resumeId: null, launch, + }); + const events: ProviderEvent[] = []; + const done = (async () => { for await (const ev of session.events) events.push(ev); })(); + return { session, events, log, done }; +} +async function until(check: () => boolean, ms = 10000) { + const end = Date.now() + ms; + while (!check()) { + if (Date.now() > end) throw new Error("timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} +const ended = (r: Run) => r.events.filter((e) => e.type === "turn_ended"); +const sent = (r: Run) => (existsSync(r.log) ? readFileSync(r.log, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []); +const notices = (r: Run) => r.events.filter((e): e is Extract => e.type === "notice"); +async function close(r: Run) { await r.session.close(); await r.done; } +const withImage = { text: "what is this?", images: [{ path: "", mime: "image/png" }] }; + +describe("dsh route", () => { + it("reads dsh's [provider, model] model values", () => { + expect(dshRoute(VIS)).toEqual({ provider: "magpie", model: "vis" }); + expect(dshRoute("deepseek-flash")).toBeNull(); + expect(dshRoute(null)).toBeNull(); + expect(dshRoute('["a"]')).toBeNull(); + }); + it("writes a patch for the acp row, quoting the route", async () => { + expect(dshRoutePatch({ provider: "magpie", model: 'a "b": c' })).toContain('- id: acp\n config:\n provider: "magpie"\n model: "a \\"b\\": c"\n'); + const args = await dshAcpArgs(VIS, dir); + expect(args.slice(0, 3)).toEqual(["--profile", "acp", "--patch"]); + expect(readFileSync(args[3], "utf8")).toContain('provider: "magpie"\n model: "vis"'); + expect(await dshAcpArgs(null, dir)).toEqual(["--profile", "acp"]); + }); + it("a new conversation starts on a Magpie route first", () => { + expect(dshDefaultModel([{ id: '["deepseek-official","deepseek-flash"]' }, { id: VIS }], '["deepseek-official","deepseek-v4-flash"]')).toBe(VIS); + expect(dshDefaultModel([{ id: "x" }], "cur")).toBe("cur"); + }); +}); + +describe("dsh sessions get images", () => { + it("on a model that sees images: started on that route, the image goes as an image block", async () => { + const r = await open(VIS); + try { + withImage.images[0].path = png; + await r.session.send(withImage); + await until(() => ended(r).length === 1); + const [p] = sent(r); + expect(p.argv.slice(0, 3)).toEqual(["--profile", "acp", "--patch"]); + expect(p.prompt).toEqual([{ type: "text", text: "what is this?" }, { type: "image", data: PNG_B64, mimeType: "image/png" }]); + expect(ended(r)[0]).toMatchObject({ status: "completed" }); + expect(notices(r)).toEqual([]); + expect(knownPromptCaps("dsh")).toMatchObject({ image: true }); + } finally { + await close(r); + } + }); + + it("without the patch dsh would refuse it: on its shipped route the image goes by path, and the turn still completes", async () => { + const r = await open(null); + try { + withImage.images[0].path = png; + await r.session.send(withImage); + await until(() => ended(r).length === 1); + const [p] = sent(r); + expect(p.argv).toEqual(["--profile", "acp"]); + expect(p.prompt.some((b: { type: string }) => b.type === "image")).toBe(false); + expect(p.prompt[0].text).toContain(`Attached image: ${png}`); + expect(ended(r)[0]).toMatchObject({ status: "completed" }); + expect(notices(r).map((n) => n.message).join(" ")).toContain(png); + } finally { + await close(r); + } + }); + + it("after switching to another model in the session images go by path (dsh would refuse them), and back again as images", async () => { + const r = await open(VIS); + try { + withImage.images[0].path = png; + await r.session.setModel(TXT); + await r.session.send(withImage); + await until(() => ended(r).length === 1); + expect(sent(r)[0].prompt.some((b: { type: string }) => b.type === "image")).toBe(false); + expect(ended(r)[0]).toMatchObject({ status: "completed" }); + expect(notices(r)[0].message).toMatch(/read_image/); + await r.session.setModel(VIS); + await r.session.send(withImage); + await until(() => ended(r).length === 2); + expect(sent(r)[1].prompt).toContainEqual({ type: "image", data: PNG_B64, mimeType: "image/png" }); + expect(ended(r)[1]).toMatchObject({ status: "completed" }); + } finally { + await close(r); + } + }); + + it("the model probe answers images for the route a new conversation starts on", async () => { + const models = await dshProbeModels("dsh")({ ok: true, cmd: launch.cmd, prefixArgs: launch.prefixArgs, display: "fake", via: "custom" }); + expect(models.map((m) => m.id)).toContain(VIS); + expect(knownPromptCaps("dsh")).toMatchObject({ image: true }); + process.env.FAKE_DSH_VISION = "deepseek-official/deepseek-flash"; + await dshProbeModels("dsh")({ ok: true, cmd: launch.cmd, prefixArgs: launch.prefixArgs, display: "fake", via: "custom" }); + expect(knownPromptCaps("dsh")).toMatchObject({ image: false }); + process.env.FAKE_DSH_VISION = "magpie/vis,deepseek-official/deepseek-flash"; + }); +}); diff --git a/packages/agent-console/test/v1/files.test.ts b/packages/agent-console/test/v1/files.test.ts new file mode 100644 index 00000000..05815e78 --- /dev/null +++ b/packages/agent-console/test/v1/files.test.ts @@ -0,0 +1,88 @@ +// FileService tests (§5.9 + §11.1): tree ignore rules, directories first, read refuses binary/oversized/out-of-bounds +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FileService } from "../../daemon/src/files/service.js"; + +let dir = ""; +const svc = new FileService(); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "ac-files-")); + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, "node_modules", "junk"), { recursive: true }); + mkdirSync(join(dir, ".git"), { recursive: true }); + mkdirSync(join(dir, "ignored-dir"), { recursive: true }); + writeFileSync(join(dir, "a.ts"), "const a = 1;\n"); + writeFileSync(join(dir, "z.md"), "# hi\n"); + writeFileSync(join(dir, "src", "b.py"), "print(1)\n"); + writeFileSync(join(dir, "skipped.log"), "noise\n"); + writeFileSync(join(dir, "ignored-dir", "x.txt"), "gone\n"); + writeFileSync(join(dir, ".gitignore"), "skipped.log\nignored-dir/\n"); + // Binary (contains \0) and oversized (>1MB) + writeFileSync(join(dir, "bin.dat"), Buffer.from([0x89, 0x50, 0, 0x47, 1, 2])); + writeFileSync(join(dir, "big.txt"), Buffer.alloc(1024 * 1024 + 1, 65)); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("files.tree", () => { + it("one level: .git/node_modules absent, .gitignore rules apply, directories first", async () => { + const r = await svc.tree(dir, "."); + const names = (r.entries ?? []).map((e) => e.name); + expect(names).not.toContain(".git"); + expect(names).not.toContain("node_modules"); + expect(names).not.toContain("skipped.log"); + expect(names).not.toContain("ignored-dir"); + expect(names).toContain("src"); + expect(names).toContain("a.ts"); + // Directories first + const idxDir = names.indexOf("src"); + const idxFile = names.indexOf("a.ts"); + expect(idxDir).toBeGreaterThanOrEqual(0); + expect(idxFile).toBeGreaterThan(idxDir); + }); + + it("subdirectories load lazily: dir=src lists only one level of src", async () => { + const r = await svc.tree(dir, "src"); + const names = (r.entries ?? []).map((e) => e.name); + expect(names).toEqual(["b.py"]); + }); + + it("out-of-bounds path → error", async () => { + const r = await svc.tree(dir, "../../"); + expect(r.error).toBeTruthy(); + }); +}); + +describe("files.read", () => { + it("text → text + language by extension", async () => { + const r = await svc.read(dir, "a.ts"); + expect(r.text).toBe("const a = 1;\n"); + expect(r.language).toBe("typescript"); + }); + + it("binary (\\\\0 in the first 8KB) → binary:true", async () => { + const r = await svc.read(dir, "bin.dat"); + expect(r.binary).toBe(true); + expect(r.text).toBeUndefined(); + }); + + it(">1MB → tooLarge:true", async () => { + const r = await svc.read(dir, "big.txt"); + expect(r.tooLarge).toBe(true); + }); + + it("out-of-bounds path → error", async () => { + const r = await svc.read(dir, "../outside.txt"); + expect(r.error).toBeTruthy(); + }); + + it("directory → error", async () => { + const r = await svc.read(dir, "src"); + expect(r.error).toBeTruthy(); + }); +}); diff --git a/packages/agent-console/test/v1/fixes.test.ts b/packages/agent-console/test/v1/fixes.test.ts new file mode 100644 index 00000000..c0842a16 --- /dev/null +++ b/packages/agent-console/test/v1/fixes.test.ts @@ -0,0 +1,138 @@ +// Regression tests for code-review fixes: delta types, permission event dedupe, restart recovery, upload CORS, WS buffer check +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AddressInfo } from "node:net"; +import { AgentManager } from "../../daemon/src/agents/manager.js"; +import { ThreadsStore } from "../../daemon/src/store/threads.js"; +import { ProjectsStore } from "../../daemon/src/store/projects.js"; +import { SettingsStore } from "../../daemon/src/store/settings.js"; +import { AttachmentStore } from "../../daemon/src/store/attachments.js"; +import { Gateway } from "../../daemon/src/ws/gateway.js"; +import { createHttpServer } from "../../daemon/src/http.js"; +import { initProviders } from "../../daemon/src/providers/registry.js"; +import type { Config } from "../../daemon/src/config.js"; + +process.env.AGENT_CONSOLE_MOCK = "1"; + +const cfg: Config = { port: 0, dataDir: "", token: "t", version: "0.0.0", idleExitMinutes: 60, buildStamp: "0" }; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +let dir: string; +let threads: ThreadsStore; +let projects: ProjectsStore; +let settings: SettingsStore; +let gw: Gateway; +let manager: AgentManager; +let threadId: string; +let published: { ch: string; data: { type?: string; event?: { type: string; itemId?: string; text?: string } } }[]; + +type Internals = { onProviderEvent(id: string, ev: Record): Promise }; +const feed = (ev: Record) => (manager as unknown as Internals).onProviderEvent(threadId, ev); + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "ac-fix-")); + threads = new ThreadsStore(dir); + projects = new ProjectsStore(dir); + settings = new SettingsStore(dir); + gw = new Gateway(cfg); + published = []; + const orig = gw.publish.bind(gw); + gw.publish = (ch: string, data: unknown) => { + published.push({ ch, data: data as (typeof published)[number]["data"] }); + orig(ch, data); + }; + initProviders(); + manager = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + const proj = await projects.create(dir); + threadId = (await threads.create({ projectId: proj.id, provider: "mock" })).id; +}); + +afterEach(() => { + manager.shutdown(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe("review fixes", () => { + it("thinking/tool-output deltas keep their type when pushed instead of becoming assistant_delta", async () => { + await feed({ type: "reasoning_delta", itemId: "r1", text: "想" }); + await feed({ type: "tool_output_delta", itemId: "c1", text: "out" }); + await feed({ type: "assistant_delta", itemId: "a1", text: "答" }); + await sleep(100); + const deltas = published.map((p) => p.data.event).filter((e) => e && e.type.endsWith("_delta")); + expect(deltas).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "reasoning_delta", itemId: "r1", text: "想" }), + expect.objectContaining({ type: "tool_output_delta", itemId: "c1", text: "out" }), + expect.objectContaining({ type: "assistant_delta", itemId: "a1", text: "答" }), + ])); + }); + + it("permission results already persisted by the manager are not written again as permission_resolved from the provider echo", async () => { + await feed({ type: "permission_request", requestId: "p1", title: "x", kind: "tool", options: [{ id: "allow", label: "允许", kind: "allow_once" }] }); + await manager.respond(threadId, "p1", { optionId: "allow" }); + await feed({ type: "permission_resolved", requestId: "p1", optionId: "allow" }); + const evs = await threads.readEvents(threadId, 0, 1000); + expect(evs.filter((e) => e.type === "permission_resolved")).toHaveLength(1); + }); + + it("turn_ended is recorded once per turn (a late end from the provider after the interrupt fallback is dropped)", async () => { + await feed({ type: "turn_started", turnId: "u1", model: null, mode: "default", effort: null }); + await feed({ type: "turn_ended", turnId: "u1", status: "interrupted" }); + await feed({ type: "turn_ended", turnId: "u1", status: "completed" }); + const evs = await threads.readEvents(threadId, 0, 1000); + expect(evs.filter((e) => e.type === "turn_ended")).toHaveLength(1); + }); + + it("restart recovery: previous turn has a status, the running turn never ended → turn_ended is still appended", async () => { + await threads.patch(threadId, { status: "running", lastTurnStatus: "completed" }); + const base = { ts: Date.now(), threadId }; + let seq = (await threads.get(threadId))!.seq; + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "turn_started", turnId: "u1", model: null, mode: "default", effort: null }); + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "turn_ended", turnId: "u1", status: "completed" }); + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "user_message", text: "第二轮" }); + await threads.appendEvent(threadId, { ...base, seq: ++seq, type: "turn_started", turnId: "u2", model: null, mode: "default", effort: null }); + const m2 = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + await m2.recoverOnStart(); + const ends = (await threads.readEvents(threadId, 0, 1000)).filter((e) => e.type === "turn_ended"); + expect(ends).toHaveLength(2); + expect(ends[1]).toMatchObject({ turnId: "u2", status: "interrupted" }); + m2.shutdown(); + }); +}); + +describe("Provider disabled", () => { + it("after disabling: new conversations cannot send; conversations that already ran keep working", async () => { + await settings.set({ providerEnabled: { mock: false } }); + await expect(manager.send(threadId, { text: "你好" })).rejects.toThrow(/禁用/); + + const proj = (await projects.list())[0]; + const old = await threads.create({ projectId: proj.id, provider: "mock", providerSessionId: "mock-old" }); + await manager.send(old.id, { text: "继续" }); + const t0 = Date.now(); + while ((await threads.get(old.id))?.status !== "idle" && Date.now() - t0 < 10000) await sleep(50); + const evs = await threads.readEvents(old.id, 0, 1000); + expect(evs.some((e) => e.type === "turn_ended" && (e as { status?: string }).status === "completed")).toBe(true); + }, 15000); +}); + +describe("upload CORS (sandbox iframe origin is null)", () => { + it("POST responses for Origin: null also carry access-control-allow-origin", async () => { + const appDir = join(dir, "app"); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(appDir, "index.html"), "x"); + const c: Config = { ...cfg, dataDir: dir, token: "tok", buildStamp: "0" }; + const server = createHttpServer(c, appDir, new AttachmentStore(join(dir, "attachments"))); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + c.port = (server.address() as AddressInfo).port; + try { + const res = await fetch(`http://127.0.0.1:${c.port}/api/upload?name=a.txt`, { + method: "POST", headers: { origin: "null", "x-agent-console": "tok" }, body: "hello", + }); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe("null"); + } finally { + server.close(); + } + }); +}); diff --git a/packages/agent-console/test/v1/fixtures/fake-acp-agent.mjs b/packages/agent-console/test/v1/fixtures/fake-acp-agent.mjs new file mode 100644 index 00000000..1082740c --- /dev/null +++ b/packages/agent-console/test/v1/fixtures/fake-acp-agent.mjs @@ -0,0 +1,83 @@ +// A minimal ACP agent over stdio (newline-delimited JSON-RPC), for tests. It signs in to nothing and calls no model. +// FAKE_ACP_PROMPT_CAPS JSON for agentCapabilities.promptCapabilities (default: none, the ACP baseline) +// FAKE_ACP_SESSION JSON { loadSession?: boolean, resume?: boolean } — which reopen methods initialize advertises +// (default: neither; matches the ACP baseline until an Agent opts in) +// FAKE_ACP_LOG file each received session/prompt is appended to, one JSON line per prompt +// FAKE_ACP_SESSION_LOG file each session/new|load|resume is appended to, one JSON line { method, sessionId } +// Every prompt is answered with one agent_message_chunk ("ok: blocks") and stopReason end_turn. +import { appendFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const caps = process.env.FAKE_ACP_PROMPT_CAPS ? JSON.parse(process.env.FAKE_ACP_PROMPT_CAPS) : undefined; +const session = process.env.FAKE_ACP_SESSION ? JSON.parse(process.env.FAKE_ACP_SESSION) : {}; +const logFile = process.env.FAKE_ACP_LOG; +const sessionLog = process.env.FAKE_ACP_SESSION_LOG; +const out = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n"); +const answer = (id, result) => out({ id, result }); +const logSession = (method, sessionId) => { + if (sessionLog) appendFileSync(sessionLog, JSON.stringify({ method, sessionId }) + "\n"); +}; + +const agentCapabilities = { + loadSession: session.loadSession === true, + ...(session.resume === true ? { sessionCapabilities: { resume: {} } } : {}), + ...(caps ? { promptCapabilities: caps } : {}), +}; + +createInterface({ input: process.stdin }).on("line", (line) => { + let m; + try { + m = JSON.parse(line); + } catch { + return; + } + if (!m.method) return; // responses to our own (no) requests + const p = m.params ?? {}; + switch (m.method) { + case "initialize": + return answer(m.id, { + protocolVersion: 1, + agentCapabilities, + authMethods: [], + agentInfo: { name: "fake-acp-agent", version: "0.0.0" }, + }); + case "session/new": { + const sessionId = "fake-session-1"; + logSession("session/new", sessionId); + return answer(m.id, { sessionId }); + } + case "session/load": { + if (!agentCapabilities.loadSession) { + return out({ id: m.id, error: { code: -32601, message: '"Method not found": session/load' } }); + } + logSession("session/load", p.sessionId); + return answer(m.id, { modes: null, configOptions: [] }); + } + case "session/resume": { + if (!agentCapabilities.sessionCapabilities?.resume) { + return out({ id: m.id, error: { code: -32601, message: '"Method not found": session/resume' } }); + } + logSession("session/resume", p.sessionId); + return answer(m.id, { modes: null, configOptions: [] }); + } + case "session/set_mode": + return answer(m.id, {}); + case "session/set_config_option": + return answer(m.id, { configOptions: [] }); + case "session/prompt": { + if (logFile) appendFileSync(logFile, JSON.stringify(p.prompt) + "\n"); + out({ + method: "session/update", + params: { + sessionId: p.sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `ok: ${p.prompt.length} blocks` } }, + }, + }); + return answer(m.id, { stopReason: "end_turn" }); + } + case "session/cancel": + return; + default: + if (m.id !== undefined) out({ id: m.id, error: { code: -32601, message: `method not found: ${m.method}` } }); + } +}); diff --git a/packages/agent-console/test/v1/fixtures/fake-claude.mjs b/packages/agent-console/test/v1/fixtures/fake-claude.mjs new file mode 100644 index 00000000..7cd3bc66 --- /dev/null +++ b/packages/agent-console/test/v1/fixtures/fake-claude.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// A stand-in for `claude -p --input-format stream-json --output-format stream-json`, for the Claude provider tests. +// It speaks the same line-delimited JSON as the real CLI: initialize / interrupt / set_model / set_permission_mode +// control requests, can_use_tool requests back to the host, control_cancel_request, stream_event deltas, assistant, +// user tool_result and result messages. Every argv and stdin line is appended to $FAKE_CLAUDE_LOG. +// The user text picks the scenario: TOOL (ask to run Bash), CANCEL (ask, then withdraw the ask), SLOW (stream and wait +// for an interrupt); anything else is echoed with a description of the image blocks it got. +import { appendFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const args = process.argv.slice(2); +const logFile = process.env.FAKE_CLAUDE_LOG; +const log = (o) => logFile && appendFileSync(logFile, JSON.stringify(o) + "\n"); +log({ argv: args }); +if (process.env.FAKE_CLAUDE_EXIT) { + process.stderr.write("fake claude: boom\n"); + process.exit(Number(process.env.FAKE_CLAUDE_EXIT)); +} +const flag = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; }; +const sid = flag("--resume") ?? "sess-new"; +let model = flag("--model") ?? "default"; +let mode = flag("--permission-mode") ?? "default"; +const out = (o) => process.stdout.write(JSON.stringify(o) + "\n"); +const waiting = new Map(); // our can_use_tool request id → resolve +let reqSeq = 0; +let interruptTurn = null; +let msgSeq = 0; + +function ask(toolName, input, toolUseId) { + const id = `perm-${++reqSeq}`; + out({ type: "control_request", request_id: id, request: { subtype: "can_use_tool", tool_name: toolName, input, tool_use_id: toolUseId } }); + return { id, answer: new Promise((resolve) => waiting.set(id, resolve)) }; +} + +function say(text) { + const id = `msg_${++msgSeq}`; + out({ type: "stream_event", event: { type: "message_start", message: { id } }, session_id: sid }); + out({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, session_id: sid }); + out({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hmm" } }, session_id: sid }); + out({ type: "stream_event", event: { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, session_id: sid }); + out({ type: "stream_event", event: { type: "content_block_delta", index: 1, delta: { type: "text_delta", text } }, session_id: sid }); + out({ type: "assistant", message: { id, role: "assistant", content: [{ type: "thinking", thinking: "hmm" }, { type: "text", text }] }, session_id: sid }); +} +const done = (extra = {}) => + out({ type: "result", subtype: "success", is_error: false, duration_ms: 5, num_turns: 1, result: "", session_id: sid, total_cost_usd: 0.001, usage: { input_tokens: 3, output_tokens: 2, cache_read_input_tokens: 1 }, ...extra }); + +async function turn(content) { + const blocks = Array.isArray(content) ? content : [{ type: "text", text: String(content) }]; + const text = blocks.filter((b) => b.type === "text").map((b) => b.text).join("\n"); + out({ type: "system", subtype: "init", session_id: sid, model, permissionMode: mode, cwd: process.cwd() }); + if (text.includes("TOOL") || text.includes("CANCEL")) { + const input = { command: "echo hi", description: "say hi" }; + const { id, answer } = ask("Bash", input, "tu_1"); + if (text.includes("CANCEL")) { + setTimeout(() => { out({ type: "control_cancel_request", request_id: id }); waiting.get(id)?.({ cancelled: true }); waiting.delete(id); }, 50); + } + const r = await answer; + if (r.cancelled) return done({ subtype: "error_during_execution", is_error: true, result: "cancelled" }); + if (r.behavior === "allow") { + out({ type: "assistant", message: { id: `msg_${++msgSeq}`, content: [{ type: "tool_use", id: "tu_1", name: "Bash", input: r.updatedInput }] }, session_id: sid }); + out({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "tu_1", content: "hi\n", is_error: false }] }, session_id: sid }); + say("ran it"); + } else say(`not run: ${r.message}`); + return done(); + } + if (text.includes("SLOW")) { + say("working"); + await new Promise((resolve) => (interruptTurn = resolve)); + return done({ subtype: "error_during_execution", is_error: true, result: "" }); + } + const images = blocks.filter((b) => b.type === "image").map((b) => `${b.source?.media_type}:${b.source?.data?.length}`); + const docs = blocks.filter((b) => b.type === "document").map((b) => b.title); + say(`echo ${text.split("\n")[0]} | model=${model} mode=${mode} images=[${images.join(",")}] docs=[${docs.join(",")}]`); + done(); +} + +const rl = createInterface({ input: process.stdin }); +rl.on("line", (line) => { + if (!line.trim()) return; + const m = JSON.parse(line); + log({ in: m }); + if (m.type === "control_request") { + const r = m.request; + const ok = (response = {}) => out({ type: "control_response", response: { subtype: "success", request_id: m.request_id, response } }); + if (r.subtype === "initialize") return ok({ commands: [{ name: "review", description: "Review the diff", argumentHint: "[path]" }, { name: "compact", description: "Compact", argumentHint: "" }], models: [{ value: "sonnet" }] }); + if (r.subtype === "set_model") { model = r.model ?? "default"; return ok(); } + if (r.subtype === "set_permission_mode") { mode = r.mode; return ok(); } + if (r.subtype === "interrupt") { ok(); interruptTurn?.(); interruptTurn = null; return; } + return out({ type: "control_response", response: { subtype: "error", request_id: m.request_id, error: "unknown" } }); + } + if (m.type === "control_response") { + const w = waiting.get(m.response.request_id); + waiting.delete(m.response.request_id); + return w?.(m.response.response); + } + if (m.type === "user") void turn(m.message.content); +}); +rl.on("close", () => process.exit(0)); diff --git a/packages/agent-console/test/v1/fixtures/fake-dsh.mjs b/packages/agent-console/test/v1/fixtures/fake-dsh.mjs new file mode 100644 index 00000000..2a804858 --- /dev/null +++ b/packages/agent-console/test/v1/fixtures/fake-dsh.mjs @@ -0,0 +1,66 @@ +// Behaves like `dsh --profile acp` (0.2.1-rc.5) where images are concerned, for tests. Calls no model. +// - The route it starts on is its `acp` row: deepseek-official / deepseek-v4-flash, or the provider/model of a `--patch` file. +// - initialize advertises promptCapabilities.image only when that route sees images (FAKE_DSH_VISION: comma list of provider/model). +// - session/new reports the model option as dsh does: values are JSON [provider, model]. +// - A prompt with an image block on a connection that didn't advertise images is refused with dsh's error. +// FAKE_ACP_LOG file each received {argv, prompt} is appended to, one JSON line per prompt +import { appendFileSync, readFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const argv = process.argv.slice(2); +let route = ["deepseek-official", "deepseek-v4-flash"]; +for (let i = 0; i < argv.length; i++) { + if (argv[i] !== "--patch") continue; + const y = readFileSync(argv[i + 1], "utf8"); + const provider = /^\s+provider: (.+)$/m.exec(y)?.[1]; + const model = /^\s+model: (.+)$/m.exec(y)?.[1]; + if (/^- id: acp$/m.test(y) && provider && model) route = [JSON.parse(provider), JSON.parse(model)]; +} +const vision = new Set((process.env.FAKE_DSH_VISION ?? "").split(",").filter(Boolean)); +const image = vision.has(route.join("/")); +const models = [["deepseek-official", "deepseek-flash"], ["magpie", "vis"], ["magpie", "txt"]]; +let current = route; +const logFile = process.env.FAKE_ACP_LOG; +const out = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n"); +const answer = (id, result) => out({ id, result }); +const configOptions = () => [{ + id: "model", category: "model", type: "select", name: "Model", currentValue: JSON.stringify(current), + options: models.map(([p, m]) => ({ value: JSON.stringify([p, m]), name: m })), +}]; + +createInterface({ input: process.stdin }).on("line", (line) => { + let m; + try { m = JSON.parse(line); } catch { return; } + if (!m.method) return; + const p = m.params ?? {}; + switch (m.method) { + case "initialize": + return answer(m.id, { + protocolVersion: 1, + agentInfo: { name: "deepseek-harness-acp", version: "0.0.1" }, + agentCapabilities: { loadSession: false, promptCapabilities: { image, audio: false, embeddedContext: false }, sessionCapabilities: { close: {}, list: {}, resume: {} } }, + authMethods: [], + }); + case "session/new": + return answer(m.id, { sessionId: "fake-dsh-1", configOptions: configOptions() }); + case "session/resume": + return answer(m.id, { configOptions: configOptions() }); + case "session/load": + return out({ id: m.id, error: { code: -32601, message: '"Method not found": session/load' } }); + case "session/set_config_option": + if (p.configId === "model") current = JSON.parse(p.value); + return answer(m.id, { configOptions: configOptions() }); + case "session/prompt": { + if (logFile) appendFileSync(logFile, JSON.stringify({ argv, prompt: p.prompt }) + "\n"); + if (!image && p.prompt.some((b) => b.type === "image")) { + return out({ id: m.id, error: { code: -32602, message: "Invalid params: inline image prompts were not advertised by this connection" } }); + } + out({ method: "session/update", params: { sessionId: p.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } } } }); + return answer(m.id, { stopReason: "end_turn" }); + } + case "session/cancel": + return; + default: + if (m.id !== undefined) out({ id: m.id, error: { code: -32601, message: `method not found: ${m.method}` } }); + } +}); diff --git a/packages/agent-console/test/v1/gate.test.ts b/packages/agent-console/test/v1/gate.test.ts new file mode 100644 index 00000000..5692fd95 --- /dev/null +++ b/packages/agent-console/test/v1/gate.test.ts @@ -0,0 +1,97 @@ +// Plugin gate (lib/gate.mjs): driven by a fake ensureDaemon, no real daemon is started. +import { describe, expect, it } from "vitest"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { freePort, request } from "./helpers-net.js"; + +const gateUrl = pathToFileURL(join(resolve(__dirname, "..", ".."), "lib", "gate.mjs")).href; + +describe("gate (lib/gate.mjs)", () => { + it("checks Host, refuses any Origin, requires the gate key, and serializes opens", async () => { + const { createGate } = await import(/* @vite-ignore */ gateUrl); + const port = await freePort(); + const daemonPort = await freePort(); + const key = "c".repeat(64); + const token = "a".repeat(64); + let calls = 0; + const gate = createGate({ + port, + key, + token, + ensureDaemon: async () => { + calls++; + await new Promise((r) => setTimeout(r, 50)); + return { port: daemonPort }; + }, + }); + await gate.listen(); + try { + expect((await request(port, `/open?k=${key}`, { host: `evil.example:${port}` })).status).toBe(421); + expect((await request(port, `/open?k=${key}`, { host: `127.0.0.1:${port + 1}` })).status).toBe(421); + expect((await request(port, `/open?k=${key}`, { origin: "null" })).status).toBe(403); + expect((await request(port, `/open?k=${key}`, { origin: "https://evil.example" })).status).toBe(403); + expect((await request(port, "/")).status).toBe(404); + expect((await request(port, "/open")).status).toBe(403); + expect((await request(port, "/open?k=wrong")).status).toBe(403); + // the daemon token is not a gate key, in either parameter + expect((await request(port, `/open?k=${token}`)).status).toBe(403); + expect((await request(port, `/open?t=${token}`)).status).toBe(403); + expect((await request(port, `/open?t=${key}`)).status).toBe(403); + expect(calls).toBe(0); + + const results = await Promise.all([ + request(port, `/open?k=${key}&theme=dark`), + request(port, `/open?k=${key}&theme=dark`), + ]); + expect(calls).toBe(1); + for (const result of results) { + expect(result.status).toBe(302); + expect(result.headers.location).toBe(`http://127.0.0.1:${daemonPort}/?theme=dark#t=${token}`); + expect(result.headers["cache-control"]).toBe("no-store"); + expect(result.headers["x-content-type-options"]).toBe("nosniff"); + expect(result.headers["referrer-policy"]).toBe("no-referrer"); + expect(result.headers["access-control-allow-origin"]).toBeUndefined(); + expect(result.headers.location).not.toContain(key); + } + } finally { + await gate.close(); + } + }); + + it("answers 503 when the daemon cannot start, and recovers on the next open", async () => { + const { createGate } = await import(/* @vite-ignore */ gateUrl); + const port = await freePort(); + const key = "d".repeat(64); + const token = "b".repeat(64); + let fail = true; + const gate = createGate({ + port, + key, + token, + ensureDaemon: async () => { + if (fail) throw new Error("spawn ENOENT"); + return { port: 45678 }; + }, + }); + await gate.listen(); + try { + const first = await request(port, `/open?k=${key}`); + expect(first.status).toBe(503); + expect(first.body).not.toContain(token); + fail = false; + const second = await request(port, `/open?k=${key}`); + expect(second.status).toBe(302); + } finally { + await gate.close(); + } + }); + + it("refuses to start with a missing gate key or one equal to the daemon token", async () => { + const { createGate } = await import(/* @vite-ignore */ gateUrl); + const ensureDaemon = async () => ({ port: 1 }); + const token = "e".repeat(64); + expect(() => createGate({ port: 1, token, ensureDaemon })).toThrow(); + expect(() => createGate({ port: 1, key: token, token, ensureDaemon })).toThrow(); + expect(() => createGate({ port: 1, key: "f".repeat(64), token, ensureDaemon })).not.toThrow(); + }); +}); diff --git a/packages/agent-console/test/v1/git.test.ts b/packages/agent-console/test/v1/git.test.ts new file mode 100644 index 00000000..38896116 --- /dev/null +++ b/packages/agent-console/test/v1/git.test.ts @@ -0,0 +1,98 @@ +// GitService tests (§5.8 + §11.1): git init in a temp directory → status/diff/discard, untracked → trash/discarded +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { GitService } from "../../daemon/src/git/service.js"; + +let dir = ""; +let dataDir = ""; +const git = new GitService(); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "ac-git-")); + dataDir = join(dir, "data"); + const g = (args: string[]) => execFileSync("git", args, { cwd: dir, encoding: "utf8", windowsHide: true }); + g(["init"]); + g(["config", "user.email", "t@t"]); + g(["config", "user.name", "t"]); + writeFileSync(join(dir, "a.txt"), "line1\nline2\n"); + g(["add", "a.txt"]); + g(["commit", "-m", "init"]); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("git.status", () => { + it("not a repository → isRepo:false", async () => { + const d = mkdtempSync(join(tmpdir(), "ac-nogit-")); + try { + const r = await git.status(d); + expect(r.isRepo).toBe(false); + } finally { + rmSync(d, { recursive: true, force: true }); + } + }); + + it("inside a repo: clean → empty files; changes + untracked → classified correctly", async () => { + const clean = await git.status(dir); + expect(clean.isRepo).toBe(true); + expect(clean.branch).toBeTruthy(); + expect(clean.files ?? []).toHaveLength(0); + + writeFileSync(join(dir, "a.txt"), "line1\nline2\nline3\n"); + writeFileSync(join(dir, "new.txt"), "brand new\n"); + const r = await git.status(dir); + expect(r.files?.some((f) => f.path === "a.txt" && f.kind === "modified")).toBe(true); + expect(r.files?.some((f) => f.path === "new.txt" && f.kind === "untracked")).toBe(true); + }); +}); + +describe("git.diff", () => { + it("tracked change → unified diff text with +/-", async () => { + const r = await git.diff(dir, "a.txt"); + expect(r.diff).toBeTruthy(); + expect(r.diff).toContain("+line3"); + }); + + it("untracked file → untracked:true + all-added diff", async () => { + const r = await git.diff(dir, "new.txt"); + expect(r.untracked).toBe(true); + expect(r.diff).toContain("+brand new"); + }); + + it("path outside the project → error", async () => { + const r = await git.diff(dir, "../outside.txt"); + expect(r.error).toBeTruthy(); + expect(r.diff).toBeUndefined(); + }); +}); + +describe("git.discard", () => { + it("tracked → git restore brings back the original content", async () => { + const r = await git.discard(dir, "a.txt", dataDir); + expect(r.ok).toBe(true); + expect(readFileSync(join(dir, "a.txt"), "utf8").replace(/\r\n/g, "\n")).toBe("line1\nline2\n"); + const st = await git.status(dir); + expect(st.files?.some((f) => f.path === "a.txt")).toBe(false); + }); + + it("untracked → moved to trash/discarded// and gone from the project", async () => { + const r = await git.discard(dir, "new.txt", dataDir); + expect(r.ok).toBe(true); + expect(r.trashedTo).toContain("trash"); + expect(r.trashedTo).toContain("discarded"); + expect(existsSync(join(dir, "new.txt"))).toBe(false); + expect(existsSync(r.trashedTo!)).toBe(true); + expect(readFileSync(r.trashedTo!, "utf8")).toBe("brand new\n"); + }); + + it("nonexistent file → error", async () => { + const r = await git.discard(dir, "ghost.txt", dataDir); + expect(r.ok).toBe(false); + expect(r.error).toBeTruthy(); + }); +}); diff --git a/packages/agent-console/test/v1/helpers-net.ts b/packages/agent-console/test/v1/helpers-net.ts new file mode 100644 index 00000000..6cd3b962 --- /dev/null +++ b/packages/agent-console/test/v1/helpers-net.ts @@ -0,0 +1,72 @@ +// Small network helpers for tests: get a free port, send requests with explicit Host/Origin headers. +import http from "node:http"; +import net from "node:net"; +import type { AddressInfo } from "node:net"; + +export async function freePort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const port = (server.address() as AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +// Whether the port can be bound again (used to confirm the gate has closed) +export async function canBind(port: number): Promise { + const server = net.createServer(); + const ok = await new Promise((resolve) => { + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => resolve(true)); + }); + if (ok) await new Promise((resolve) => server.close(() => resolve())); + return ok; +} + +export type Reply = { status: number; headers: http.IncomingHttpHeaders; body: string }; + +export function request( + port: number, + path: string, + headers: Record = {}, + method = "GET", +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port, + path, + method, + agent: false, + headers: { host: `127.0.0.1:${port}`, ...headers }, + }, + (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body })); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export async function waitFor(check: () => boolean, timeoutMs: number, stepMs = 100): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (check()) return true; + await new Promise((resolve) => setTimeout(resolve, stepMs)); + } + return check(); +} diff --git a/packages/agent-console/test/v1/i18n.test.ts b/packages/agent-console/test/v1/i18n.test.ts new file mode 100644 index 00000000..04bdf2bd --- /dev/null +++ b/packages/agent-console/test/v1/i18n.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { initLocale, normalizeLang, t } from "../../web/src/i18n/index.js"; + +describe("i18n", () => { + it("normalizeLang: zh variants → zh, anything else → en", () => { + expect(normalizeLang("zh")).toBe("zh"); + expect(normalizeLang("zh-CN")).toBe("zh"); + expect(normalizeLang("en")).toBe("en"); + expect(normalizeLang("ja")).toBe("en"); + }); + + it("UI text is English with ?lang=en", () => { + initLocale("?lang=en"); + expect(t("sidebar.newProject")).toBe("New project"); + expect(t("app.title")).toBe("Agent Console"); + initLocale("?lang=zh"); + expect(t("sidebar.newProject")).toBe("新建项目"); + expect(t("app.title")).toBe("Agent 控制台"); + }); +}); diff --git a/packages/agent-console/test/v1/layout-bounds.test.ts b/packages/agent-console/test/v1/layout-bounds.test.ts new file mode 100644 index 00000000..2c9341e3 --- /dev/null +++ b/packages/agent-console/test/v1/layout-bounds.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { maxPanelWidth, clampLayoutWidths, MAIN_MIN } from "../../web/src/layout/bounds.js"; + +describe("layout bounds", () => { + const base = { sidebarWidth: 248, panelWidth: 440, panelOpen: true, sbHidden: false }; + + it("the right panel cannot exceed the window minus the sidebar and MAIN_MIN", () => { + const vw = 1200; + expect(maxPanelWidth(base, vw)).toBe(1200 - 248 - 10 - MAIN_MIN); + }); + + it("clamp shrinks the panel when the window is narrow", () => { + const sized = clampLayoutWidths({ ...base, panelWidth: 900 }, 1000); + expect(sized.panelWidth).toBeLessThan(900); + expect(1000 - sized.sidebarWidth - sized.panelWidth - 10).toBeGreaterThanOrEqual(MAIN_MIN - 1); + }); + + it("the panel's upper bound shrinks when the sidebar widens", () => { + const vw = 1100; + const a = maxPanelWidth({ ...base, sidebarWidth: 248 }, vw); + const b = maxPanelWidth({ ...base, sidebarWidth: 360 }, vw); + expect(b).toBeLessThan(a); + }); +}); diff --git a/packages/agent-console/test/v1/lifeline.test.ts b/packages/agent-console/test/v1/lifeline.test.ts new file mode 100644 index 00000000..dcee9b00 --- /dev/null +++ b/packages/agent-console/test/v1/lifeline.test.ts @@ -0,0 +1,103 @@ +// Daemon lifeline: --port 0 ready line, Host checked against the actual port, exits when stdin closes, exits when the parent disappears. +import { afterEach, describe, expect, it } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pidAlive, request, waitFor } from "./helpers-net.js"; + +const repo = resolve(__dirname, "..", ".."); +const mainJs = join(repo, "daemon", "dist", "main.js"); +const hasDist = existsSync(mainJs); +const TOKEN = "lifeline-token"; + +let children: ChildProcess[] = []; +let dirs: string[] = []; + +afterEach(() => { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + children = []; + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +function startDaemon(parentPid: number): ChildProcess { + const data = mkdtempSync(join(tmpdir(), "ac-life-")); + dirs.push(data); + const child = spawn(process.execPath, [mainJs, "--port", "0", "--data", data, "--parent-pid", String(parentPid)], { + env: { ...process.env, AGENT_CONSOLE_TOKEN: TOKEN, AGENT_CONSOLE_MOCK: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + children.push(child); + child.stderr!.resume(); + return child; +} + +function readyLine(child: ChildProcess): Promise<{ agentConsoleReady: boolean; port: number; pid: number }> { + return new Promise((resolvePromise, reject) => { + let buffer = ""; + const onData = (chunk: Buffer) => { + buffer += String(chunk); + for (const line of buffer.split(/\r?\n/)) { + try { + const parsed = JSON.parse(line); + if (parsed.agentConsoleReady) { + child.stdout!.off("data", onData); + child.stdout!.resume(); + resolvePromise(parsed); + return; + } + } catch {} + } + }; + child.stdout!.on("data", onData); + child.once("exit", (code) => reject(new Error(`daemon exited early (${code})`))); + }); +} + +function exited(child: ChildProcess): Promise { + if (child.exitCode !== null) return Promise.resolve(child.exitCode); + return new Promise((resolvePromise) => child.once("exit", (code) => resolvePromise(code))); +} + +describe.skipIf(!hasDist)("daemon lifeline (daemon/dist/main.js)", () => { + it("prints a ready line with the real port, checks Host against it, exits when stdin closes", async () => { + const child = startDaemon(process.pid); + const ready = await readyLine(child); + expect(ready.agentConsoleReady).toBe(true); + expect(ready.port).toBeGreaterThan(0); + expect(ready.pid).toBe(child.pid); + + const good = await request(ready.port, "/", { host: `127.0.0.1:${ready.port}` }); + expect(good.status).toBe(200); + const bad = await request(ready.port, "/", { host: `127.0.0.1:${ready.port + 1}` }); + expect(bad.status).toBe(421); + + const started = Date.now(); + child.stdin!.end(); + const code = await Promise.race([exited(child), new Promise((r) => setTimeout(() => r("timeout"), 5000))]); + expect(code).toBe(0); + expect(Date.now() - started).toBeLessThan(5000); + }, 15000); + + it("exits within 7 s when the parent pid dies (stdin kept open)", async () => { + const dummy = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + children.push(dummy); + expect(await waitFor(() => typeof dummy.pid === "number", 2000)).toBe(true); + + const child = startDaemon(dummy.pid!); + const ready = await readyLine(child); + expect(ready.port).toBeGreaterThan(0); + + dummy.kill("SIGKILL"); + await exited(dummy); + const started = Date.now(); + const gone = await waitFor(() => child.exitCode !== null || !pidAlive(child.pid!), 7000); + expect(gone).toBe(true); + expect(Date.now() - started).toBeLessThan(7000); + // stdin never closed: the exit really comes from parent detection + expect(child.stdin!.writableEnded).toBe(false); + }, 15000); +}); diff --git a/packages/agent-console/test/v1/magpie-gateway.test.ts b/packages/agent-console/test/v1/magpie-gateway.test.ts new file mode 100644 index 00000000..f4111622 --- /dev/null +++ b/packages/agent-console/test/v1/magpie-gateway.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { parseCapabilities } from "../../daemon/src/providers/magpieGateway.js"; + +describe("magpie gateway capability table", () => { + it("effort / xhigh_effort / max_effort become levels", () => { + const m = parseCapabilities("devin/swe-2=effort,xhigh_effort,max_effort;cursor/auto=effort;cursor/grok-4.7@256k=effort,xhigh_effort;x/no=foo"); + expect(m.get("devin/swe-2")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(m.get("cursor/auto")).toEqual(["low", "medium", "high"]); + expect(m.get("cursor/grok-4.7@256k")).toEqual(["low", "medium", "high", "xhigh"]); + expect(m.has("x/no")).toBe(false); + }); + + it("empty or missing returns an empty table", () => { + expect(parseCapabilities(undefined).size).toBe(0); + expect(parseCapabilities("").size).toBe(0); + }); +}); diff --git a/packages/agent-console/test/v1/manager.test.ts b/packages/agent-console/test/v1/manager.test.ts new file mode 100644 index 00000000..e8b41720 --- /dev/null +++ b/packages/agent-console/test/v1/manager.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentManager } from "../../daemon/src/agents/manager.js"; +import { ThreadsStore } from "../../daemon/src/store/threads.js"; +import { ProjectsStore } from "../../daemon/src/store/projects.js"; +import { SettingsStore } from "../../daemon/src/store/settings.js"; +import { AttachmentStore } from "../../daemon/src/store/attachments.js"; +import { Gateway } from "../../daemon/src/ws/gateway.js"; +import { initProviders } from "../../daemon/src/providers/registry.js"; +import { mockProviderMetrics } from "../../daemon/src/providers/mock.js"; +import type { Config } from "../../daemon/src/config.js"; + +process.env.AGENT_CONSOLE_MOCK = "1"; + +const cfg: Config = { port: 0, dataDir: "", token: "t", version: "0.0.0", idleExitMinutes: 60, buildStamp: "0" }; + +let dir: string; +let threads: ThreadsStore; +let projects: ProjectsStore; +let settings: SettingsStore; +let gw: Gateway; +let manager: AgentManager; +let threadId: string; + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "ac-test-")); + mockProviderMetrics.createSessionCalls = 0; + threads = new ThreadsStore(dir); + projects = new ProjectsStore(dir); + settings = new SettingsStore(dir); + gw = new Gateway(cfg); + initProviders(); + manager = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + const proj = await projects.create(dir); + const meta = await threads.create({ projectId: proj.id, provider: "mock" }); + threadId = meta.id; +}); + +afterEach(() => { + manager.shutdown(); + rmSync(dir, { recursive: true, force: true }); +}); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const waitFor = async (fn: () => boolean | Promise, ms = 10000) => { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + if (await fn()) return; + await sleep(50); + } + throw new Error("waitFor 超时"); +}; +const events = () => threads.readEvents(threadId, 0, 50000); + +describe("AgentManager", () => { + it("listCommands creates no session by default", async () => { + await manager.listCommands(threadId); + expect(mockProviderMetrics.createSessionCalls).toBe(0); + }); + + it("concurrent listCommands(load) and send create only one session", async () => { + await Promise.all([ + manager.listCommands(threadId, { load: true }), + manager.send(threadId, { text: "你好" }), + ]); + await waitFor(async () => (await threads.get(threadId))?.status === "idle", 20000); + expect(mockProviderMetrics.createSessionCalls).toBe(1); + }, 25000); + + it("send → turn_ended completed", async () => { + await manager.send(threadId, { text: "你好" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const evs = await events(); + expect(evs.some((e) => e.type === "user_message")).toBe(true); + expect(evs.some((e) => e.type === "turn_started")).toBe(true); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("completed"); + // providerSessionId was written back + const meta = await threads.get(threadId); + expect(meta?.providerSessionId).toMatch(/^mock-/); + // Auto title + expect(meta?.title).toBe("你好"); + }, 15000); + + it("send while running → queued, sent automatically when done", async () => { + await manager.send(threadId, { text: "/slow" }); + await waitFor(async () => (await threads.get(threadId))?.status === "running"); + await manager.send(threadId, { text: "第二条" }); + expect(manager.runtimeInfo(threadId)?.queue.length).toBe(1); + await waitFor(async () => (await threads.get(threadId))?.status === "idle", 20000); + const evs = await events(); + const users = evs.filter((e) => e.type === "user_message").map((e) => (e as { text: string }).text); + expect(users).toEqual(["/slow", "第二条"]); + }, 25000); + + it("steer: steers directly while running", async () => { + await manager.send(threadId, { text: "/slow" }); + await waitFor(async () => (await threads.get(threadId))?.status === "running"); + await manager.send(threadId, { text: "顺便改个名" }, "steer"); + const evs = await events(); + const steered = evs.find((e) => e.type === "user_message" && (e as { steered?: boolean }).steered); + expect(steered).toBeTruthy(); + expect(manager.runtimeInfo(threadId)?.queue.length).toBe(0); + await waitFor(async () => (await threads.get(threadId))?.status === "idle", 20000); + }, 25000); + + it("interrupt: back to idle within 1 second", async () => { + await manager.send(threadId, { text: "/slow" }); + await waitFor(async () => (await threads.get(threadId))?.status === "running"); + const t0 = Date.now(); + await manager.interrupt(threadId); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + expect(Date.now() - t0).toBeLessThan(1000); + const evs = await events(); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("interrupted"); + }, 15000); + + it("pending permission → respond allow", async () => { + await manager.send(threadId, { text: "/perm" }); + await waitFor(async () => (manager.runtimeInfo(threadId)?.pending.length ?? 0) > 0); + const pending = manager.runtimeInfo(threadId)!.pending[0] as { requestId: string }; + await manager.respond(threadId, pending.requestId, { optionId: "accept" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const evs = await events(); + const resolved = evs.find((e) => e.type === "permission_resolved"); + expect(resolved && (resolved as { optionId?: string }).optionId).toBe("accept"); + const tool = evs.find((e) => e.type === "tool_call" && (e as { status?: string }).status === "ok"); + expect(tool).toBeTruthy(); + }, 15000); + + it("invalid optionId → 400 (bad_params)", async () => { + await manager.send(threadId, { text: "/perm" }); + await waitFor(async () => (manager.runtimeInfo(threadId)?.pending.length ?? 0) > 0); + const pending = manager.runtimeInfo(threadId)!.pending[0] as { requestId: string }; + await expect(manager.respond(threadId, pending.requestId, { optionId: "bogus" })).rejects.toThrow(/optionId 非法/); + // Still replies normally + await manager.respond(threadId, pending.requestId, { optionId: "decline" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + }, 15000); + + it("interrupt cancels pending permissions automatically", async () => { + await manager.send(threadId, { text: "/perm" }); + await waitFor(async () => (manager.runtimeInfo(threadId)?.pending.length ?? 0) > 0); + // While /perm is pending the status is still running (waiting for the user) — manager.interrupt needs running, and a waiting mock counts as running + const meta = await threads.get(threadId); + expect(meta?.status).toBe("running"); + await manager.interrupt(threadId); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const evs = await events(); + expect(evs.some((e) => e.type === "permission_resolved" && (e as { cancelled?: boolean }).cancelled)).toBe(true); + expect(manager.runtimeInfo(threadId)?.pending.length).toBe(0); + }, 15000); + + it("/exit crash → closed; the next send resumes", async () => { + await manager.send(threadId, { text: "/exit" }); + await waitFor(async () => (await threads.get(threadId))?.status === "closed"); + const evs = await events(); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("failed"); + expect(end && (end as { error?: string }).error).toContain("意外退出"); + const oldSid = (await threads.get(threadId))?.providerSessionId; + + // Send again → resume + await manager.send(threadId, { text: "再来" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + const meta = await threads.get(threadId); + expect(meta?.providerSessionId).toBe(oldSid); // resumeId is passed to the new session; the mock keeps the same id + const msgs = (await events()).filter((e) => e.type === "assistant_message"); + const last = msgs[msgs.length - 1] as { text?: string } | undefined; + expect(last?.text?.startsWith("(已续接)")).toBe(true); + }, 20000); + + it("restart recovery: running → closed + notice + turn_ended(interrupted) + permissions cancelled", async () => { + // Build a running state + an unresolved permission request + const proj = await projects.create(dir); + const t = await threads.create({ projectId: proj.id, provider: "mock" }); + await threads.patch(t.id, { status: "running" }); + const seq0 = (await threads.get(t.id))!.seq; + await threads.appendEvent(t.id, { + seq: seq0 + 1, ts: Date.now(), threadId: t.id, type: "turn_started", turnId: "u1", model: null, mode: "default", effort: null, + }); + await threads.appendEvent(t.id, { + seq: seq0 + 2, ts: Date.now(), threadId: t.id, type: "permission_request", requestId: "r1", + title: "运行 x", kind: "tool", options: [{ id: "accept", label: "允许", kind: "allow_once" }], + }); + + const m2 = new AgentManager(gw, threads, projects, settings, new AttachmentStore(join(dir, "attachments"))); + await m2.recoverOnStart(); + const meta = await threads.get(t.id); + expect(meta?.status).toBe("closed"); + const evs = await threads.readEvents(t.id, 0, 50000); + expect(evs.some((e) => e.type === "notice" && (e as { message?: string }).message?.includes("已重启"))).toBe(true); + expect(evs.some((e) => e.type === "turn_ended" && (e as { status?: string }).status === "interrupted")).toBe(true); + expect(evs.some((e) => e.type === "permission_resolved" && (e as { cancelled?: boolean }).cancelled)).toBe(true); + m2.shutdown(); + }, 15000); + + it("idle recycling: idle timeout → closed", async () => { + await settings.set({ idleCloseMinutes: 0.0001 }); // ~6ms + await manager.send(threadId, { text: "你好" }); + await waitFor(async () => (await threads.get(threadId))?.status === "idle"); + // lastActivity is in milliseconds; wait more than 6ms, then trigger recycling by hand + await sleep(50); + await (manager as unknown as { recycleIdle(): Promise }).recycleIdle(); + expect((await threads.get(threadId))?.status).toBe("closed"); + }, 15000); +}); diff --git a/packages/agent-console/test/v1/migrate.test.ts b/packages/agent-console/test/v1/migrate.test.ts new file mode 100644 index 00000000..b9cfa3c6 --- /dev/null +++ b/packages/agent-console/test/v1/migrate.test.ts @@ -0,0 +1,141 @@ +// Legacy migration unit test (§10.1): build an old layout → migrateLegacy → check the new threads/projects/settings. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { migrateLegacy } from "../../daemon/src/migrate/legacy.js"; +import { SettingsStore } from "../../daemon/src/store/settings.js"; +import { ProjectsStore } from "../../daemon/src/store/projects.js"; +import { ThreadsStore } from "../../daemon/src/store/threads.js"; + +let dir: string; +let realCwd: string; + +async function seed() { + dir = await mkdtemp(join(tmpdir(), "ac-migrate-")); + realCwd = join(dir, "proj-a"); + await mkdir(realCwd, { recursive: true }); + // Old settings.json (with a projects list and UI preferences) + await writeFile(join(dir, "settings.json"), JSON.stringify({ + defaultAgent: "codex", defaultPermission: "auto", sendKey: "ctrlEnter", showThinking: false, theme: "dark", + projects: [{ name: "A 项目", path: realCwd }], + })); + const sdir = join(dir, "sessions"); + // Session 1: normal (tool.call+result, text, run.end) + await mkdir(join(sdir, "s_aaa"), { recursive: true }); + await writeFile(join(sdir, "s_aaa", "meta.json"), JSON.stringify({ + id: "s_aaa", agent: "claude", cwd: realCwd, model: "sonnet", permission: "default", + title: "正常会话", titleAuto: false, pinned: true, cliSessionId: "cli-123", + created: 1700000000000, updated: 1700000100000, lastStatus: "ok", + totalCost: 0.12, totalUsage: { inputTokens: 100, outputTokens: 50 }, + })); + await writeFile(join(sdir, "s_aaa", "events.jsonl"), [ + JSON.stringify({ type: "user", text: "看下代码" }), + JSON.stringify({ type: "text", text: "好的" }), + JSON.stringify({ type: "thinking", text: "先读文件" }), + JSON.stringify({ type: "tool.call", itemId: "c1", kind: "read", title: "读 a.ts" }), + JSON.stringify({ type: "tool.result", itemId: "c1", ok: true, output: "content" }), + JSON.stringify({ type: "todo", items: [{ text: "改", status: "done" }, { text: "测", status: "pending" }] }), + JSON.stringify({ type: "run.end", status: "ok", ms: 5000, usage: { inputTokens: 10, outputTokens: 5 }, costUsd: 0.01 }), + "坏行{", + ].join("\n")); + // Session 2: test title → archived; orphan cwd (nonexistent directory → no project added → skipped) + await mkdir(join(sdir, "s_bbb"), { recursive: true }); + await writeFile(join(sdir, "s_bbb", "meta.json"), JSON.stringify({ + id: "s_bbb", agent: "claude", cwd: join(dir, "gone-dir"), permission: "default", + title: "/slow 测试", titleAuto: true, pinned: false, cliSessionId: null, + created: 1, updated: 2, + })); + await writeFile(join(sdir, "s_bbb", "events.jsonl"), JSON.stringify({ type: "user", text: "x" })); + // Session 3: orphan cwd = a project subdirectory → folded into the parent project + await mkdir(join(realCwd, "sub"), { recursive: true }); + await mkdir(join(sdir, "s_ccc"), { recursive: true }); + await writeFile(join(sdir, "s_ccc", "meta.json"), JSON.stringify({ + id: "s_ccc", agent: "unknown-agent", cwd: join(realCwd, "sub"), permission: "full", + title: "子目录会话", titleAuto: true, pinned: false, cliSessionId: "zzz", + created: 3, updated: 4, + })); + await writeFile(join(sdir, "s_ccc", "events.jsonl"), JSON.stringify({ type: "text", text: "hi" })); + return dir; +} + +beforeEach(async () => { await seed(); }); +afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + +describe("migrateLegacy", () => { + it("migrates sessions/projects/settings, backs up the old directory, sets v1Migrated", async () => { + const stores = { settings: new SettingsStore(dir), projects: new ProjectsStore(dir), threads: new ThreadsStore(dir) }; + const r = await migrateLegacy(dir, stores); + expect(r.migrated).toBe(2); // s_aaa + s_ccc (s_bbb's cwd doesn't exist and is skipped) + + const projs = await stores.projects.list(); + expect(projs).toHaveLength(1); + expect(projs[0].name).toBe("A 项目"); + + const threads = await stores.threads.list(); + expect(threads).toHaveLength(2); + const t1 = threads.find((t) => t.title === "正常会话")!; + expect(t1.provider).toBe("claude"); + expect(t1.providerSessionId).toBe("cli-123"); + expect(t1.status).toBe("closed"); + expect(t1.pinned).toBe(true); + expect(t1.archived).toBe(false); + expect(t1.mode).toBe("default"); + expect(t1.usage.costUsd).toBeCloseTo(0.12); + expect(t1.importedFrom).toEqual({ provider: "claude", sessionId: "cli-123" }); + + const evs = await stores.threads.readEvents(t1.id); + const types = evs.map((e) => e.type); + expect(types).toEqual(["user_message", "assistant_message", "reasoning", "tool_call", "tool_call", "todo", "usage", "turn_ended"]); + const calls = evs.filter((e) => e.type === "tool_call"); + expect((calls[0] as { status: string }).status).toBe("running"); + expect((calls[1] as { status: string }).status).toBe("ok"); + expect((calls[1] as { output?: string }).output).toBe("content"); + const end = evs.find((e) => e.type === "turn_ended") as { status: string; durationMs?: number }; + expect(end.status).toBe("completed"); + expect(end.durationMs).toBe(5000); + const seqs = evs.map((e) => e.seq); + expect([...seqs].sort((a, b) => a - b)).toEqual(seqs); // Strictly increasing + + const t3 = threads.find((t) => t.title === "子目录会话")!; + expect(t3.provider).toBe("mock"); // Unknown agent + expect(t3.archived).toBe(true); + expect(t3.mode).toBe("bypassPermissions"); // full mapping + expect(t3.projectId).toBe(projs[0].id); // Subdirectory folded into its parent + + const s = await stores.settings.get(); + expect(s.v1Migrated).toBe(true); + expect(s.theme).toBe("dark"); + expect(s.sendKey).toBe("ctrlEnter"); + expect(s.showThinking).toBe(false); + expect(s.defaultProvider).toBe("codex"); + expect(s.defaultMode).toBe("acceptEdits"); // auto mapping + + // Backup exists, old files untouched + const trash = join(dir, "trash"); + expect(existsSync(trash)).toBe(true); + expect(existsSync(join(dir, "sessions", "s_aaa", "meta.json"))).toBe(true); + }); + + it("runs again: skipped after v1Migrated", async () => { + const stores = { settings: new SettingsStore(dir), projects: new ProjectsStore(dir), threads: new ThreadsStore(dir) }; + await migrateLegacy(dir, stores); + const r2 = await migrateLegacy(dir, stores); + expect(r2.skipped).toBe(true); + expect(r2.migrated).toBe(0); + expect(await stores.threads.list()).toHaveLength(2); + }); + + it("empty data directory: sets v1Migrated and returns skipped", async () => { + const empty = await mkdtemp(join(tmpdir(), "ac-mig-empty-")); + try { + const stores = { settings: new SettingsStore(empty), projects: new ProjectsStore(empty), threads: new ThreadsStore(empty) }; + const r = await migrateLegacy(empty, stores); + expect(r).toEqual({ migrated: 0, skipped: true }); + expect((await stores.settings.get()).v1Migrated).toBe(true); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/agent-console/test/v1/mock.test.ts b/packages/agent-console/test/v1/mock.test.ts new file mode 100644 index 00000000..2d7d04cb --- /dev/null +++ b/packages/agent-console/test/v1/mock.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMockProvider } from "../../daemon/src/providers/mock.js"; +import type { MockSession } from "../../daemon/src/providers/mock.js"; +import type { ProviderEvent } from "../../daemon/src/timeline/types.js"; +import { initProviders, getProvider } from "../../daemon/src/providers/registry.js"; + +const opts = { + threadId: "th1", + cwd: "D:/proj", + model: null, + mode: "default" as const, + effort: null, + resumeId: null as string | null, +}; + +async function collect(session: MockSession, until: (ev: ProviderEvent) => boolean, timeoutMs = 15000): Promise { + const out: ProviderEvent[] = []; + const it = session.events[Symbol.asyncIterator](); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const r = await Promise.race([ + it.next(), + new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), Math.max(1, deadline - Date.now()))), + ]); + if (r.done) break; + out.push(r.value); + if (until(r.value)) break; + } + return out; +} + +const isEnd = (ev: ProviderEvent) => ev.type === "turn_ended"; +const types = (evs: ProviderEvent[]) => evs.map((e) => e.type); + +describe("mock provider", () => { + beforeEach(() => { + process.env.AGENT_CONSOLE_MOCK = "1"; + }); + + it("registry registers mock only with MOCK=1", async () => { + delete process.env.AGENT_CONSOLE_MOCK; + initProviders(); + expect(getProvider("mock")).toBeUndefined(); + process.env.AGENT_CONSOLE_MOCK = "1"; + initProviders(); + expect(getProvider("mock")?.id).toBe("mock"); + }); + + it("detect/listModels/createSession", async () => { + const p = createMockProvider(); + expect((await p.detect()).available).toBe(true); + const models = await p.listModels(); + expect(models.length).toBe(2); + expect(models[0].isDefault).toBe(true); + const s = (await p.createSession(opts)) as MockSession; + const it = s.events[Symbol.asyncIterator](); + const first = await it.next(); + expect(first.value.type).toBe("provider_session_id"); + await s.close(); + }); + + it("default script: a complete event sequence for one turn", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const collected: ProviderEvent[] = []; + const pump = (async () => { + for await (const ev of s.events) collected.push(ev); + })(); + await s.send({ text: "帮我改一下", images: [] }); + await new Promise((r) => setTimeout(r, 9000)); + await s.close(); + await pump; + const t = types(collected); + expect(t[0]).toBe("provider_session_id"); + expect(t).toContain("turn_started"); + expect(t).toContain("reasoning"); + expect(t).toContain("tool_call"); + expect(t).toContain("todo"); + expect(t).toContain("assistant_delta"); + expect(t).toContain("assistant_message"); + expect(t).toContain("usage"); + const end = collected.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("completed"); + }, 20000); + + it("/perm allow → tool succeeds; deny → failed", async () => { + const p = createMockProvider(); + // Allow + let s = (await p.createSession(opts)) as MockSession; + let pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "permission_request") { + setTimeout(() => s.respond(ev.requestId, { optionId: "accept" }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/perm", images: [] }); + let evs = await pump; + let tool = evs.find((e) => e.type === "tool_call" && e.status === "ok"); + expect(tool).toBeTruthy(); + await s.close(); + + // Deny + s = (await p.createSession(opts)) as MockSession; + pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "permission_request") { + setTimeout(() => s.respond(ev.requestId, { optionId: "decline" }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/perm", images: [] }); + evs = await pump; + tool = evs.find((e) => e.type === "tool_call" && e.status === "failed"); + expect(tool).toBeTruthy(); + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text).toContain("跳过"); + await s.close(); + }, 20000); + + it("/plan deny → reply", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "permission_request") { + setTimeout(() => s.respond(ev.requestId, { optionId: "reject" }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/plan", images: [] }); + const evs = await pump; + expect(evs.some((e) => e.type === "plan")).toBe(true); + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text).toContain("怎么改"); + await s.close(); + }, 20000); + + it("/ask answers are woven into the reply", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "question") { + setTimeout(() => s.respond(ev.requestId, { answers: { q1: "prod", q2: "周五前" } }), 10); + } + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/ask", images: [] }); + const evs = await pump; + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text).toContain("prod"); + await s.close(); + }, 20000); + + it("interrupt → interrupted within 1 second", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/slow", images: [] }); + await new Promise((r) => setTimeout(r, 600)); + const t0 = Date.now(); + await s.interrupt(); + const evs = await pump; + expect(Date.now() - t0).toBeLessThan(1000); + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("interrupted"); + await s.close(); + }, 20000); + + it("/error fails midway", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "/error", images: [] }); + const evs = await pump; + const end = evs.find((e) => e.type === "turn_ended"); + expect(end && (end as { status?: string }).status).toBe("failed"); + expect(end && (end as { error?: string }).error).toContain("构建失败"); + await s.close(); + }, 20000); + + it("/exit crash: the stream ends with no turn_ended", async () => { + const s = (await createMockProvider().createSession(opts)) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) evs.push(ev); + return evs; + })(); + await s.send({ text: "/exit", images: [] }); + const evs = await pump; + expect(evs.some((e) => e.type === "turn_ended")).toBe(false); + }, 20000); + + it("resumeId → the first reply carries the resumed prefix", async () => { + const s = (await createMockProvider().createSession({ ...opts, resumeId: "abc123" })) as MockSession; + const pump = (async () => { + const evs: ProviderEvent[] = []; + for await (const ev of s.events) { + evs.push(ev); + if (ev.type === "turn_ended") return evs; + } + return evs; + })(); + await s.send({ text: "继续", images: [] }); + const evs = await pump; + const msg = evs.find((e) => e.type === "assistant_message"); + expect(msg && (msg as { text?: string }).text?.startsWith("(已续接)")).toBe(true); + await s.close(); + }, 20000); +}); diff --git a/packages/agent-console/test/v1/pickFolder.test.ts b/packages/agent-console/test/v1/pickFolder.test.ts new file mode 100644 index 00000000..62c0d9aa --- /dev/null +++ b/packages/agent-console/test/v1/pickFolder.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { decodePickFolderStdout } from "../../daemon/src/util/pickFolderWin.js"; + +describe("pickFolder stdout decoding", () => { + it("Base64 UTF-8 path", () => { + const path = "D:\\workwork\\magpie插件"; + const b64 = Buffer.from(path, "utf8").toString("base64"); + expect(decodePickFolderStdout(`${b64}\r\n`)).toBe(path); + }); + + it("plain ASCII path (old Write-Output)", () => { + expect(decodePickFolderStdout("D:\\workwork\\demo\r\n")).toBe("D:\\workwork\\demo"); + }); + + it("CP936 bytes (static sample) fall back to GBK", () => { + // GBK encoding of "D:\\workwork\\magpie插件": 插=b2e5 件=bcfe + const buf = Buffer.concat([Buffer.from("D:\\workwork\\magpie", "ascii"), Buffer.from("b2e5bcfe", "hex"), Buffer.from("\r\n")]); + expect(decodePickFolderStdout(buf)).toBe("D:\\workwork\\magpie插件"); + }); + + it.skipIf(process.platform !== "win32")("CP936 console output falls back to GBK (real powershell.exe)", () => { + const buf = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + "[Console]::OutputEncoding = [Text.Encoding]::GetEncoding(936); " + + "[Console]::Out.WriteLine('D:\\workwork\\magpie插件')", + ], + { encoding: "buffer" }, + ); + expect(decodePickFolderStdout(buf)).toBe("D:\\workwork\\magpie插件"); + }); +}); diff --git a/packages/agent-console/test/v1/plugin-entry.test.ts b/packages/agent-console/test/v1/plugin-entry.test.ts new file mode 100644 index 00000000..8d59e183 --- /dev/null +++ b/packages/agent-console/test/v1/plugin-entry.test.ts @@ -0,0 +1,170 @@ +// Plugin entry (index.mjs): import has no side effects; server() → session.js → /open → daemon → stop() end to end. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { canBind, freePort, pidAlive, request, waitFor } from "./helpers-net.js"; + +const repo = resolve(__dirname, "..", ".."); +const entryUrl = pathToFileURL(join(repo, "index.mjs")).href; +const hasDist = existsSync(join(repo, "daemon", "dist", "main.js")); +const ENV_KEYS = ["AGENT_CONSOLE_PAGE_DIR", "XDG_CONFIG_HOME", "AGENT_CONSOLE_PORT", "AGENT_CONSOLE_MOCK"]; + +let savedEnv: Record = {}; +let tempDirs: string[] = []; +let pageDir = ""; +let leftoverPids: number[] = []; +let stopHost: (() => Promise) | null = null; + +function parseSession(text: string): { v: number; port: number; key: string; token?: string } { + const match = /^globalThis\.AgentConsoleSession = (\{.*\});\n$/.exec(text); + if (!match) throw new Error(`unexpected session.js: ${text}`); + return JSON.parse(match[1]); +} + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + pageDir = mkdtempSync(join(tmpdir(), "ac-entry-page-")); + const xdg = mkdtempSync(join(tmpdir(), "ac-entry-xdg-")); + tempDirs = [pageDir, xdg]; + process.env.AGENT_CONSOLE_PAGE_DIR = pageDir; + process.env.XDG_CONFIG_HOME = xdg; + leftoverPids = []; + stopHost = null; +}); + +afterEach(async () => { + if (stopHost) await stopHost().catch(() => {}); + for (const pid of leftoverPids) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); +}); + +describe("plugin entry (index.mjs)", () => { + it("import has no side effects: no listen, no session.js, no spawn", async () => { + const port = await freePort(); + process.env.AGENT_CONSOLE_PORT = String(port); + vi.resetModules(); + const mod = await import(/* @vite-ignore */ `${entryUrl}?noside=${Date.now()}`); + expect(typeof mod.default.server).toBe("function"); + expect(mod.default.id).toBe("agent-console"); + // Give possible async side effects a moment + await new Promise((r) => setTimeout(r, 300)); + expect(existsSync(join(pageDir, "session.js"))).toBe(false); + expect(await canBind(port)).toBe(true); + expect(existsSync(join(process.env.XDG_CONFIG_HOME!, "magpie", "agent-console"))).toBe(false); + }); + + it.skipIf(!hasDist)( + "end to end: server() → session.js → /open → daemon → stop()", + async () => { + const port = await freePort(); + process.env.AGENT_CONSOLE_MOCK = "1"; + delete process.env.AGENT_CONSOLE_PORT; + vi.resetModules(); + const mod = await import(/* @vite-ignore */ `${entryUrl}?e2e=${Date.now()}`); + const host = await mod.default.server({}, { port }); + stopHost = () => host.stop(); + + const sessionPath = join(pageDir, "session.js"); + expect(await waitFor(() => existsSync(sessionPath), 5000)).toBe(true); + const session = parseSession(readFileSync(sessionPath, "utf8")); + expect(session.v).toBe(2); + expect(session.port).toBe(port); + expect(session.key).toMatch(/^[0-9a-f]{64}$/); + // Any page that knows Magpie's page path can read session.js via + + diff --git a/packages/agent-console/web/src/App.tsx b/packages/agent-console/web/src/App.tsx new file mode 100644 index 00000000..ada2890d --- /dev/null +++ b/packages/agent-console/web/src/App.tsx @@ -0,0 +1,72 @@ +// App shell: connection state → three-column layout. Each area is filled in by P2-2..P2-6. +import { useConnection } from "./stores/connection"; +import { Layout } from "./components/Layout"; +import { Sidebar } from "./components/Sidebar"; +import { Topbar } from "./components/Topbar"; +import { Chat } from "./components/Chat"; +import { useTimeline } from "./stores/timeline"; +import { useData } from "./stores/data"; +import { useEffect, useState } from "react"; +import { CommandPalette } from "./components/CommandPalette"; +import { Panel } from "./components/Panel"; +import { SettingsDialog } from "./components/Settings"; +import { Toasts } from "./components/Toast"; +import { t } from "./i18n/index.js"; + +export function App() { + const { state, version } = useConnection(); + const pending = useTimeline((s) => s.pending); + const providers = useData((s) => s.providers); + const [palette, setPalette] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + // Ctrl+K command palette; open-settings custom event; Esc closes panels + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPalette((p) => !p); + } + }; + const onSettings = () => setSettingsOpen(true); + window.addEventListener("keydown", onKey); + window.addEventListener("open-settings", onSettings); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("open-settings", onSettings); + }; + }, []); + + if (state === "down") { + return ( +
+
+ {t("boot.serviceDown")} + +
+
+ ); + } + if (state === "connecting" && !version) { + return ( +
+
+ {t("boot.connecting")} +
+ ); + } + + return ( + useTimeline.getState().threadId!).filter(Boolean))} />} + topbar={} + panel={} + > + {state === "reconnecting" &&
{t("boot.reconnecting")}
} + + {palette && setPalette(false)} />} + {settingsOpen && setSettingsOpen(false)} providers={providers} />} + +
+ ); +} diff --git a/packages/agent-console/web/src/Debug.tsx b/packages/agent-console/web/src/Debug.tsx new file mode 100644 index 00000000..aba4e5db --- /dev/null +++ b/packages/agent-console/web/src/Debug.tsx @@ -0,0 +1,192 @@ +// P1-8 debug page (?debug=1): project picker, conversation list, composer, raw timeline, permission buttons. +// For integration only; the real UI is rewritten in P2. +import { useEffect, useRef, useState } from "react"; +import { useConnection } from "./stores/connection"; +import { ws } from "./main"; + +type Project = { id: string; name: string; path: string }; +type ThreadMeta = { + id: string; projectId: string; title: string; provider: string; + status: string; archived: boolean; +}; +type Ev = Record & { seq?: number; type?: string }; +type Pending = { requestId: string; kind: string; options?: { id: string; label: string }[] }; + +const call = (m: string, p?: unknown) => ws.call(m, p); + +export function DebugApp() { + const { state, version } = useConnection(); + const [projects, setProjects] = useState([]); + const [threads, setThreads] = useState([]); + const [projectId, setProjectId] = useState(""); + const [threadId, setThreadId] = useState(""); + const [events, setEvents] = useState([]); + const [pending, setPending] = useState([]); + const [queue, setQueue] = useState([]); + const [input, setInput] = useState(""); + const [err, setErr] = useState(""); + const subRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (state !== "open") return; + void call("projects.list").then((r) => setProjects((r as Project[]) ?? [])); + void call("threads.list").then((r) => setThreads((r as ThreadMeta[]) ?? [])); + }, [state]); + + useEffect(() => { + subRef.current?.(); + subRef.current = null; + setEvents([]); + setPending([]); + setQueue([]); + if (!threadId || state !== "open") return; + void call("threads.get", { id: threadId }).then((r) => { + const res = r as { events?: Ev[]; pending?: Pending[]; queue?: unknown[] }; + setEvents(res.events ?? []); + setPending(res.pending ?? []); + setQueue(res.queue ?? []); + }); + subRef.current = ws.subscribe(`thread:${threadId}`, (_ch, data) => { + const d = data as { type?: string; event?: Ev; queue?: unknown[] }; + if (d.type === "event" && d.event) { + setEvents((evs) => [...evs, d.event!]); + if (d.event.type === "permission_resolved" || d.event.type === "question_answered") { + const rid = (d.event as { requestId?: string }).requestId; + setPending((ps) => ps.filter((p) => p.requestId !== rid)); + } + } + if (d.type === "queue" && d.queue) setQueue(d.queue); + }); + return () => subRef.current?.(); + }, [threadId, state]); + + // Pending changes are also filled in from timeline events (when a permission_request event arrives) + useEffect(() => { + const reqs = events.filter((e) => e.type === "permission_request" || e.type === "question"); + const resolved = new Set( + events + .filter((e) => e.type === "permission_resolved" || e.type === "question_answered") + .map((e) => (e as { requestId?: string }).requestId), + ); + const next = reqs + .filter((e) => !resolved.has((e as { requestId?: string }).requestId)) + .map((e) => ({ + requestId: (e as { requestId?: string }).requestId ?? "", + kind: e.type === "question" ? "question" : "permission", + options: (e as { options?: { id: string; label: string }[] }).options, + })); + setPending((cur) => { + const merged = new Map(cur.map((p) => [p.requestId, p])); + for (const p of next) merged.set(p.requestId, p); + return [...merged.values()]; + }); + }, [events]); + + const refresh = async () => { + if (!threadId) return; + const r = (await call("threads.get", { id: threadId }).catch(() => null)) as { + events?: Ev[]; pending?: Pending[]; queue?: unknown[]; + } | null; + if (r) { + setEvents(r.events ?? []); + setPending((cur) => { + const merged = new Map(cur.map((p) => [p.requestId, p])); + for (const p of r.pending ?? []) merged.set(p.requestId, p); + return [...merged.values()]; + }); + setQueue(r.queue ?? []); + } + }; + + const send = async (behavior?: "queue" | "steer") => { + setErr(""); + if (!threadId || !input.trim()) return; + const text = input; + setInput(""); + try { + await call("thread.send", { id: threadId, text, behavior }); + } catch (e) { + setErr(String((e as Error)?.message ?? e)); + } + // sub.add may not have completed yet → fetch the current events once to catch up + await refresh(); + }; + + const respond = async (requestId: string, optionId?: string, answers?: Record) => { + await call("thread.respond", { id: threadId, requestId, optionId, answers }).catch((e) => setErr(String((e as Error)?.message ?? e))); + setTimeout(() => void refresh(), 1500); // Let the approved tool_call ok land in the list + }; + + const createThread = async () => { + if (!projectId) return; + const meta = (await call("threads.create", { projectId, provider: "mock" }).catch((e) => { setErr(String((e as Error)?.message ?? e)); return null; })) as ThreadMeta | null; + if (meta) { + setThreads((ts) => [meta, ...ts]); + setThreadId(meta.id); + } + }; + + return ( +
+
+
{state === "open" ? `已连接 · ${version ?? ""}` : "连接中…"}
+ + +
+ {threads.filter((t) => !projectId || t.projectId === projectId).map((t) => ( +
setThreadId(t.id)} + style={{ + padding: "6px 8px", cursor: "pointer", borderRadius: 4, + background: t.id === threadId ? "var(--bg-active, #333)" : "transparent", + }} + > + {t.title} ({t.status}) +
+ ))} +
+
+
+
+ {events.map((e, i) => ( +
+              {(e.seq ?? "") + " " + (e.type ?? "?") + " " + JSON.stringify(e).slice(0, 400)}
+            
+ ))} + {pending.map((p) => ( +
+
授权/提问 {p.requestId}
+ {p.options?.map((o) => ( + + ))} + {p.kind === "question" && ( + + )} + +
+ ))} +
+ {queue.length > 0 &&
排队中:{queue.length} 条
} + {err &&
{err}
} +
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void send(); }} + placeholder="/perm /plan /slow /exit …" + style={{ flex: 1 }} + /> + + + +
+
+
+ ); +} diff --git a/packages/agent-console/web/src/api/token.ts b/packages/agent-console/web/src/api/token.ts new file mode 100644 index 00000000..19208a5e --- /dev/null +++ b/packages/agent-console/web/src/api/token.ts @@ -0,0 +1,5 @@ +// The daemon token lives in the fragment so it is not sent in the HTTP Referer. +export function getToken(): string { + const fragment = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash; + return new URLSearchParams(fragment).get("t") ?? ""; +} diff --git a/packages/agent-console/web/src/api/ws.ts b/packages/agent-console/web/src/api/ws.ts new file mode 100644 index 00000000..53a11463 --- /dev/null +++ b/packages/agent-console/web/src/api/ws.ts @@ -0,0 +1,174 @@ +// WS client: hello, request/response, subscriptions, auto-reconnect, binary frames. +import { t } from "../i18n/index.js"; +export type ConnState = "connecting" | "open" | "reconnecting" | "down"; +export type EvtHandler = (ch: string, data: unknown) => void; +export type BinHandler = (op: number, index: number, payload: Uint8Array) => void; + +type Pending = { res: (v: unknown) => void; rej: (e: Error) => void; timer: ReturnType }; + +export class WsClient { + private ws: WebSocket | null = null; + private seq = 0; + private pending = new Map(); + private subs = new Map number | undefined }>(); + private binHandlers: BinHandler[] = []; + private stateListeners = new Set<(s: ConnState) => void>(); + private state: ConnState = "connecting"; + private retry = 0; + private closed = false; + private helloOk: Record | null = null; + private helloWaiters: ((v: Record) => void)[] = []; + + constructor(private token: string, private lang = "zh") {} + + onState(fn: (s: ConnState) => void): () => void { + this.stateListeners.add(fn); + fn(this.state); + return () => this.stateListeners.delete(fn); + } + getState(): ConnState { + return this.state; + } + private setState(s: ConnState): void { + this.state = s; + for (const f of this.stateListeners) f(s); + } + + connect(): void { + if (this.closed) return; + this.setState(this.retry === 0 ? "connecting" : "reconnecting"); + const ws = new WebSocket(`ws://${location.host}/ws`); + ws.binaryType = "arraybuffer"; + this.ws = ws; + ws.onopen = () => { + ws.send(JSON.stringify({ t: "hello", token: this.token, v: 1, lang: this.lang })); + }; + ws.onmessage = (ev) => { + if (typeof ev.data !== "string") { + this.onBinary(new Uint8Array(ev.data as ArrayBuffer)); + return; + } + let msg: Record; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + if (msg.t === "hello.ok") { + this.retry = 0; + this.helloOk = msg; + this.setState("open"); + const w = this.helloWaiters.splice(0); + for (const fn of w) fn(msg); + // Restore subscriptions after reconnecting + for (const [ch, s] of this.subs) { + void this.call("sub.add", { ch, afterSeq: s.afterSeq?.() }).catch(() => {}); + } + return; + } + if (msg.t === "res") { + const id = msg.id as number; + const p = this.pending.get(id); + if (p) { + this.pending.delete(id); + clearTimeout(p.timer); + if (msg.ok) p.res(msg.r); + else { + const e = msg.e as { code?: string; message?: string } | undefined; + const err = new Error(e?.message ?? t("api.requestFailed")); + (err as { code?: string }).code = e?.code; + p.rej(err); + } + } + return; + } + if (msg.t === "evt") { + const sub = this.subs.get(msg.ch as string); + sub?.handler(msg.ch as string, msg.d); + return; + } + }; + ws.onclose = () => { + this.ws = null; + // Reject all pending requests + for (const [id, p] of this.pending) { + clearTimeout(p.timer); + p.rej(new Error("连接中断")); + this.pending.delete(id); + } + if (this.closed) return; + this.retry++; + const delay = Math.min(1000 * Math.pow(2, this.retry - 1), 10000); + this.setState(this.retry > 20 ? "down" : "reconnecting"); + setTimeout(() => this.connect(), delay); + }; + ws.onerror = () => {}; + } + + waitHello(): Promise> { + if (this.helloOk) return Promise.resolve(this.helloOk); + return new Promise((res) => this.helloWaiters.push(res)); + } + + call(method: string, params?: unknown, timeoutMs = 60000): Promise { + return new Promise((res, rej) => { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) { + rej(new Error("连接未建立")); + return; + } + const id = ++this.seq; + const timer = setTimeout(() => { + this.pending.delete(id); + rej(new Error("请求超时")); + }, timeoutMs); + this.pending.set(id, { res, rej, timer }); + ws.send(JSON.stringify({ t: "req", id, m: method, p: params ?? {} })); + }); + } + + subscribe(ch: string, handler: EvtHandler, afterSeq?: () => number | undefined): () => void { + this.subs.set(ch, { handler, afterSeq }); + if (this.state === "open") { + void this.call("sub.add", { ch, afterSeq: afterSeq?.() }).catch(() => {}); + } + return () => { + this.subs.delete(ch); + if (this.state === "open") void this.call("sub.remove", { ch }).catch(() => {}); + }; + } + + onBinaryFrame(fn: BinHandler): () => void { + this.binHandlers.push(fn); + return () => { + this.binHandlers = this.binHandlers.filter((f) => f !== fn); + }; + } + private onBinary(data: Uint8Array): void { + if (data.length < 3) return; + const op = data[0]; + const index = (data[1] << 8) | data[2]; + for (const f of this.binHandlers) f(op, index, data.subarray(3)); + } + sendBinary(op: number, index: number, payload: Uint8Array): void { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const frame = new Uint8Array(3 + payload.length); + frame[0] = op; + frame[1] = (index >> 8) & 0xff; + frame[2] = index & 0xff; + frame.set(payload, 3); + ws.send(frame); + } + + ping(): void { + try { + this.ws?.send(JSON.stringify({ t: "ping" })); + } catch {} + } + + close(): void { + this.closed = true; + this.ws?.close(); + } +} diff --git a/packages/agent-console/web/src/components/Chat.tsx b/packages/agent-console/web/src/components/Chat.tsx new file mode 100644 index 00000000..b45278a5 --- /dev/null +++ b/packages/agent-console/web/src/components/Chat.tsx @@ -0,0 +1,225 @@ +// Conversation area (§8.6): virtual list, stick-to-bottom, empty state, queue list, Composer. +import { useCallback, useEffect, useRef, useState } from "react"; +import { isImeComposing } from "../composer/ime"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useUi } from "../stores/ui"; +import { useData, pickDefaultProvider } from "../stores/data"; +import { useTimeline } from "../stores/timeline"; +import { TimelineRow } from "./Timeline"; +import { Composer } from "./Composer"; +import { Icon } from "./Icons"; +import { ws } from "../main"; +import { toast } from "./Toast"; +import { useLiveSettings } from "./Settings"; +import { errMsg, localizeDaemonMessage, t as tr, tf } from "../i18n/index.js"; + +export function Chat() { + const ui = useUi(); + const { projects, threads } = useData(); + const tl = useTimeline(); + const prefs = useLiveSettings(); + const items = prefs.showThinking ? tl.items : tl.items.filter((it) => it.kind !== "reasoning"); + const scrollRef = useRef(null); + const [stick, setStick] = useState(true); + const [hasNew, setHasNew] = useState(false); + + const project = projects.find((p) => p.id === ui.currentProjectId) ?? null; + const threadId = ui.currentThreadId; + const thread = threadId ? threads.find((th) => th.id === threadId) ?? null : null; + const activeThreadId = + project && thread && thread.projectId === project.id ? thread.id : null; + + useEffect(() => { + if (activeThreadId) tl.open(activeThreadId); + else tl.close(); + setStick(true); + setHasNew(false); + }, [activeThreadId]); // eslint-disable-line react-hooks/exhaustive-deps + + const est = useCallback((i: number) => { + const it = items[i]; + if (!it) return 80; + switch (it.kind) { + case "user": return 60; + case "assistant": case "reasoning": return 120; + case "toolgroup": return 30 + it.steps.length * 26; + case "plan": return 200; + case "permission": case "question": return 120; + case "todo": return 30; + case "turnend": case "notice": return 30; + default: return 60; + } + }, [items]); + + const virt = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: est, + getItemKey: (i) => items[i]?.key ?? i, + overscan: 8, + measureElement: (el) => el.getBoundingClientRect().height, + }); + + const count = items.length; + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + if (stick) el.scrollTop = el.scrollHeight; + else setHasNew(true); + }, [count, stick]); + + const onScroll = () => { + const el = scrollRef.current; + if (!el) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60; + setStick(nearBottom); + if (nearBottom) setHasNew(false); + }; + + const jump = () => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); + setStick(true); + setHasNew(false); + }; + + const resend = (text: string) => { + if (!activeThreadId) return; + void ws.call("thread.send", { id: activeThreadId, text }); + }; + const retryLast = () => { + if (tl.lastUserText) resend(tl.lastUserText); + }; + + if (projects.length === 0) { + return ( +
+ +
+ ); + } + + if (!activeThreadId) { + return ( +
+
+ {project ? ( + <> +
{tf("chat.newInProject", { name: project.name })}
+ + + ) : ( + + )} +
+
+ ); + } + + return ( + <> +
+
+
+ {virt.getVirtualItems().map((v) => { + const it = items[v.index]; + if (!it) return null; + return ( +
+ +
+ ); + })} +
+ {tl.items.length === 0 && !tl.error && ( +
{tr("chat.emptyHint")}
+ )} + {tl.error &&
{localizeDaemonMessage(tl.error)}
} +
+
+ {!stick && ( + + )} + + + ); +} + +/** No project / nothing selected: centered onboarding matching the sidebar's empty state, no Composer at the bottom */ +function WorkspaceEmpty({ kind }: { kind: "no-projects" }) { + void kind; + return ( +
+
{tr("sidebar.noProjectsTitle")}
+
{tr("sidebar.noProjectsSub")}
+
+ +
+
+ ); +} + +function EmptyComposer({ projectId }: { projectId: string }) { + const ui = useUi(); + const prefs = useLiveSettings(); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + + const send = async () => { + if (!text.trim() || busy) return; + setBusy(true); + try { + const provider = await pickDefaultProvider(); + const s = (await ws.call("settings.get")) as { defaultMode?: string; defaultEffort?: string | null }; + const created = (await ws.call("threads.create", { + projectId, provider, model: null, + mode: s.defaultMode ?? "default", effort: s.defaultEffort ?? null, + })) as { id: string }; + ui.select(projectId, created.id); + await ws.call("thread.send", { id: created.id, text: text.trim() }); + } catch (e) { + toast(tr("chat.failSend") + errMsg(e), true); + setBusy(false); + } + }; + + return ( +
+