@trim21/personal-pi-extensions 0.0.400 → 0.0.402

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.400",
3
+ "version": "0.0.402",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -6,6 +6,8 @@ The working directory persists between commands, but shell state does not. The s
6
6
 
7
7
  Commands run in a sandbox: no write access outside the workspace and no network access. Use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.
8
8
 
9
+ Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` to limit output unless you have a specific reason.
10
+
9
11
  IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:
10
12
 
11
13
  - File search: Use Glob (NOT find or ls)
@@ -156,16 +156,14 @@ export async function guardWriteAccess(
156
156
  const choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
157
157
  if (choice === undefined) {
158
158
  ctx.abort?.();
159
- throw new Error("Write outside workspace cancelled by user.");
159
+ throw new Error(`user deny ${opts.toolName}: cancelled`);
160
160
  }
161
161
  if (choice === "Approve once") return;
162
- if (choice === "Block") throw new Error("Write outside workspace denied by user.");
162
+ if (choice === "Block") throw new Error(`user deny ${opts.toolName}: blocked`);
163
163
  const feedback = await ctx.ui.input("Why was this write denied?");
164
164
  if (feedback === undefined) continue;
165
165
  throw new Error(
166
- feedback
167
- ? `Write outside workspace denied: ${feedback}`
168
- : "Write outside workspace denied by user.",
166
+ feedback ? `user deny ${opts.toolName}: ${feedback}` : `user deny ${opts.toolName}: blocked`,
169
167
  );
170
168
  }
171
169
  }
@@ -0,0 +1,7 @@
1
+ ### Bash tool
2
+
3
+ Executes a given bash command synchronously and returns its output.
4
+
5
+ Commands run in a sandbox: no write access outside the workspace and no network access. Use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.
6
+
7
+ Long command output is automatically truncated and the full output is saved to a file. Avoid piping commands through `tail` to limit output unless you have a specific reason.
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
1
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
5
  import { Type } from "typebox";
3
6
 
@@ -10,6 +13,9 @@ const MAX_TIMEOUT_MS = 600_000;
10
13
  /** 对齐上游 opencode 的截断提示文案(tools/BashTool/bash.ts)。 */
11
14
  const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory safety limit]";
12
15
 
16
+ /** Bash tool guidance, kept in markdown so it reads like documentation. */
17
+ const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
18
+
13
19
  /**
14
20
  * 对齐上游 opencode(packages/core/src/tool/bash.ts):
15
21
  * 命令失败(非 0 退出码)与超时都不抛错,输出与状态文本一起返回,
@@ -32,6 +38,7 @@ export default function opencodeBash(
32
38
  "Every command runs in the foreground. Background command execution is not supported; shell jobs are waited for before the tool returns.",
33
39
  ].join("\n"),
34
40
  promptSnippet: "execute bash command",
41
+ promptGuidelines: [BASH_PROMPT],
35
42
  parameters: Type.Object(
36
43
  {
37
44
  command: Type.String({ description: "The command to execute" }),
@@ -1,13 +1,14 @@
1
1
  /**
2
- * spawn_agent tool — delegate a task to a subagent running in a separate pi
3
- * process with an isolated context window.
2
+ * spawn_agent tool — delegate a task to a subagent with an isolated context
3
+ * window, running in-process via the pi SDK (createAgentSession) instead of a
4
+ * separate process speaking stdio RPC.
4
5
  *
5
6
  * The subagent definition comes from `~/.pi/agent/agents/*.md` (markdown with
6
7
  * YAML frontmatter, see spawn-agent-agents.ts). The extension discovers the
7
8
  * available subagent types once at startup and injects the list via the tool's
8
9
  * `promptGuidelines`, so the model always knows which `agent` names it can
9
10
  * pass to the tool. Execution
10
- * is blocking: the tool awaits the subagent process until it exits and
11
+ * is blocking: the tool awaits the subagent session until the turn settles and
11
12
  * returns its final output to the parent model. Progress is streamed through
12
13
  * `onUpdate`, the same channel the built-in bash tool uses for live output.
13
14
  * Progress is a rolling log: `tool: <name>` lines for tool calls and
@@ -22,24 +23,26 @@
22
23
  * subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
23
24
  */
24
25
 
25
- import { spawn } from "node:child_process";
26
26
  import { existsSync } from "node:fs";
27
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
28
- import { tmpdir } from "node:os";
29
- import { basename, dirname, join } from "node:path";
27
+ import { join } from "node:path";
30
28
  import { fileURLToPath } from "node:url";
31
29
 
32
30
  import type { AgentMessage, AgentToolResult } from "@earendil-works/pi-agent-core";
33
31
  import {
32
+ type AgentSession,
34
33
  type AgentSessionEvent,
34
+ type AgentSessionEventListener,
35
+ createAgentSession,
36
+ DefaultResourceLoader,
35
37
  type ExtensionAPI,
36
38
  type ExtensionUIContext,
37
39
  getAgentDir,
38
- type RpcExtensionUIRequest,
39
- type RpcExtensionUIResponse,
40
+ ModelRuntime,
41
+ type PromptOptions,
42
+ SessionManager,
43
+ SettingsManager,
40
44
  truncateTail,
41
45
  truncateToVisualLines,
42
- withFileMutationQueue,
43
46
  } from "@earendil-works/pi-coding-agent";
44
47
  import { Type } from "typebox";
45
48
 
@@ -62,8 +65,6 @@ const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
62
65
  const MAX_PROGRESS_LINES = 5;
63
66
  /** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
64
67
  const MAX_PROGRESS_CHARS_PER_LINE = 21;
65
- /** stderr 采集上限:防止子代理崩溃循环输出撑爆内存;保留尾部(错误信息通常在尾部)。 */
66
- const MAX_STDERR_CAPTURE_BYTES = 64 * 1024;
67
68
  /** 错误消息里 stderr 的展示上限。 */
68
69
  const MAX_STDERR_ERROR_BYTES = 4 * 1024;
69
70
  /** 全局默认配置:~/.pi/agent/spawn-agent.json,字段可被 frontmatter 覆盖。 */
@@ -72,8 +73,9 @@ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
72
73
 
73
74
  /**
74
75
  * Tool → extension override map: when a subagent's frontmatter enables a
75
- * built-in tool, the matching opencode extension is loaded via `-e` so the
76
- * subagent uses the enhanced implementation instead of the built-in one.
76
+ * built-in tool, the matching opencode extension is loaded via the SDK's
77
+ * `additionalExtensionPaths` so the subagent uses the enhanced implementation
78
+ * instead of the built-in one.
77
79
  *
78
80
  * The bash override also carries the bwrap sandbox: opencode/bash.ts creates
79
81
  * its own bwrap runtime instance and runs commands through runtime.execute(),
@@ -84,7 +86,7 @@ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
84
86
  * Claude Code style tools (capitalized names) map to their claude-code
85
87
  * files, so a subagent can enable exactly the tools it declares — e.g. `Grep`
86
88
  * without `Glob`. The stateful file tools (`Read`/`Edit`/`Write`) share one
87
- * implementation file (they share a read-snapshot state); the `--tools`
89
+ * implementation file (they share a read-snapshot state); the `tools`
88
90
  * allowlist still exposes only the declared subset. The opencode file tools
89
91
  * (read/edit/write) likewise share opencode/files.ts (they share the LSP
90
92
  * service instance).
@@ -200,28 +202,12 @@ function formatPendantMarkdown(task: string, response: string): string {
200
202
  return `# prompt:\n${task.trim()}\n# response\n${response.trim()}`;
201
203
  }
202
204
 
203
- /**
204
- * Resolve how to spawn the subagent process. Running through the current
205
- * entry script (when available) keeps model/tool/extension config identical
206
- * to the parent; otherwise fall back to the `pi` binary on PATH.
207
- */
208
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
209
- const currentScript = process.argv[1];
210
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
211
- if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
212
- return { command: process.execPath, args: [currentScript, ...args] };
213
- }
214
- const execName = basename(process.execPath).toLowerCase();
215
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
216
- if (!isGenericRuntime) return { command: process.execPath, args };
217
- return { command: "pi", args };
218
- }
219
-
220
205
  /**
221
206
  * Resolve a sibling extension file (relative to this module) to an absolute
222
- * path, so `-e` works both when running from the source tree and from an
223
- * installed pi package (node_modules). A missing extension is fatal: silently
224
- * skipping a guard (e.g. bwrap) would leave the subagent unprotected.
207
+ * path, so `additionalExtensionPaths` works both when running from the source
208
+ * tree and from an installed pi package (node_modules). A missing extension is
209
+ * fatal: silently skipping a guard (e.g. bwrap) would leave the subagent
210
+ * unprotected.
225
211
  */
226
212
  function extensionPath(fileName: string): string {
227
213
  const abs = fileURLToPath(new URL(fileName, import.meta.url));
@@ -231,133 +217,108 @@ function extensionPath(fileName: string): string {
231
217
  return abs;
232
218
  }
233
219
 
234
- async function writePromptToTempFile(agentName: string, prompt: string): Promise<string> {
235
- const dir = await mkdtemp(join(tmpdir(), "pi-spawn-agent-"));
236
- const safeName = agentName.replaceAll(/[^\w.-]+/g, "_");
237
- const filePath = join(dir, `prompt-${safeName}.md`);
238
- await withFileMutationQueue(filePath, async () => {
239
- await writeFile(filePath, prompt, { encoding: "utf8", mode: 0o600 });
240
- });
241
- return filePath;
242
- }
243
-
244
- export function buildSubagentArgs(
245
- agent: AgentConfig,
246
- _task: string,
247
- systemPromptPath: string | undefined,
248
- ): string[] {
249
- // RPC mode emits agent and extension UI events as JSON lines and accepts
250
- // dialog responses over stdin. --no-session keeps the child ephemeral.
251
- // --no-extensions disables
252
- // extension discovery; only the extensions explicitly loaded below (the
253
- // per-tool overrides) run inside the subagent.
254
- const args: string[] = ["--mode", "rpc", "--no-session", "--no-extensions"];
255
-
256
- // provider/model/thinkingLevel 已由 applyAgentDefaults 合并进 agent。
257
- // --provider 只在 model 不含 "/" 前缀时传:带前缀的 model(如
258
- // "openai/gpt-4o")由 pi 自己解析 provider,显式传 provider 会冲突。
259
- if (agent.model && !agent.model.includes("/") && agent.provider) {
260
- args.push("--provider", agent.provider);
261
- }
262
- if (agent.model) args.push("--model", agent.model);
263
- // --thinking 独立传参;pi 支持 "off" 显式关闭思考。
264
- if (agent.thinkingLevel) args.push("--thinking", agent.thinkingLevel);
265
- // Read-only default unless the agent explicitly declares a toolset.
266
- const tools = agent.tools ?? DEFAULT_TOOLS;
267
- // Load the opencode override for each built-in tool the agent declares
268
- // (read/edit/write), so the subagent uses the enhanced implementation
269
- // instead of the built-in one. Several tool names can map to the same
270
- // implementation file (e.g. cc Read/Edit/Write → claude-code/files.ts);
271
- // loading a file twice would run its extension factory twice and create
272
- // separate closure states, so each file is loaded at most once.
273
- const loadedOverrideFiles = new Set<string>();
220
+ /**
221
+ * Load the override extension for each declared tool (read/edit/write → opencode
222
+ * files.ts, bash → opencode bash.ts, ...), so the subagent uses the enhanced
223
+ * implementation instead of the built-in one. Several tool names can map to
224
+ * the same implementation file (e.g. cc Read/Edit/Write → claude-code/files.ts);
225
+ * loading a file twice would run its extension factory twice and create
226
+ * separate closure states, so each file is loaded at most once.
227
+ */
228
+ export function overrideExtensionPaths(tools: string[]): string[] {
229
+ const loaded = new Set<string>();
230
+ const paths: string[] = [];
274
231
  for (const tool of tools) {
275
232
  const ext = TOOL_EXTENSION_OVERRIDES[tool];
276
- if (ext && !loadedOverrideFiles.has(ext)) {
277
- loadedOverrideFiles.add(ext);
278
- args.push("-e", extensionPath(ext));
233
+ if (ext && !loaded.has(ext)) {
234
+ loaded.add(ext);
235
+ paths.push(extensionPath(ext));
279
236
  }
280
237
  }
281
- args.push("--tools", tools.join(","));
282
- if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
283
- return args;
238
+ return paths;
239
+ }
240
+
241
+ /**
242
+ * Resolve the frontmatter model to a runtime Model. A "provider/model" string
243
+ * carries its own provider; a bare model id uses the declared provider, then
244
+ * the settings default provider (the CLI's implicit resolution when no
245
+ * --provider was passed). Returns undefined when no model is configured, which
246
+ * lets createAgentSession fall back to the settings default.
247
+ */
248
+ export function resolveModel(
249
+ modelRuntime: ModelRuntime,
250
+ agent: AgentConfig,
251
+ settingsManager: SettingsManager,
252
+ ): ReturnType<ModelRuntime["getModel"]> {
253
+ if (!agent.model) return undefined;
254
+ const slash = agent.model.indexOf("/");
255
+ if (slash > 0) {
256
+ return modelRuntime.getModel(agent.model.slice(0, slash), agent.model.slice(slash + 1));
257
+ }
258
+ if (agent.provider) return modelRuntime.getModel(agent.provider, agent.model);
259
+ const defaultProvider = settingsManager.getDefaultProvider();
260
+ return defaultProvider ? modelRuntime.getModel(defaultProvider, agent.model) : undefined;
284
261
  }
285
262
 
286
263
  // ── subagent runner ──────────────────────────────────────────────────────────
287
264
 
288
265
  type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
289
266
 
290
- function dialogOptions(signal: AbortSignal | undefined, timeout: number | undefined) {
291
- return {
292
- ...(signal && { signal }),
293
- ...(timeout !== undefined && { timeout }),
294
- };
267
+ function toolSegment(name: string, count: number): string {
268
+ return count > 1 ? `${name} x ${count}` : name;
295
269
  }
296
270
 
297
- /** Forward one RPC extension UI request to the parent session. */
298
- export async function forwardSubagentUIRequest(
299
- request: RpcExtensionUIRequest,
300
- ui: ExtensionUIContext,
301
- signal?: AbortSignal,
302
- ): Promise<RpcExtensionUIResponse | undefined> {
303
- switch (request.method) {
304
- case "select": {
305
- const value = await ui.select(
306
- request.title,
307
- request.options,
308
- dialogOptions(signal, request.timeout),
309
- );
310
- return value === undefined
311
- ? { type: "extension_ui_response", id: request.id, cancelled: true }
312
- : { type: "extension_ui_response", id: request.id, value };
313
- }
314
- case "confirm": {
315
- const confirmed = await ui.confirm(
316
- request.title,
317
- request.message,
318
- dialogOptions(signal, request.timeout),
319
- );
320
- return { type: "extension_ui_response", id: request.id, confirmed };
321
- }
322
- case "input": {
323
- const value = await ui.input(
324
- request.title,
325
- request.placeholder,
326
- dialogOptions(signal, request.timeout),
327
- );
328
- return value === undefined
329
- ? { type: "extension_ui_response", id: request.id, cancelled: true }
330
- : { type: "extension_ui_response", id: request.id, value };
331
- }
332
- case "editor": {
333
- const value = await ui.editor(request.title, request.prefill);
334
- return value === undefined
335
- ? { type: "extension_ui_response", id: request.id, cancelled: true }
336
- : { type: "extension_ui_response", id: request.id, value };
337
- }
338
- case "notify": {
339
- ui.notify(request.message, request.notifyType);
340
- return undefined;
341
- }
342
- case "setStatus": {
343
- ui.setStatus(request.statusKey, request.statusText);
344
- return undefined;
345
- }
346
- case "setWidget": {
347
- ui.setWidget(request.widgetKey, request.widgetLines, {
348
- placement: request.widgetPlacement,
349
- });
350
- return undefined;
351
- }
352
- case "setTitle": {
353
- ui.setTitle(request.title);
354
- return undefined;
355
- }
356
- case "set_editor_text": {
357
- ui.setEditorText(request.text);
358
- return undefined;
359
- }
360
- }
271
+ /** The subset of AgentSession runAgent relies on (injectable for tests). */
272
+ export interface SubagentSession {
273
+ agent: { state: { messages: AgentMessage[] } };
274
+ subscribe(listener: AgentSessionEventListener): () => void;
275
+ prompt(text: string, options?: PromptOptions): Promise<void>;
276
+ abort(): Promise<void>;
277
+ dispose(): void;
278
+ }
279
+
280
+ export type SessionFactory = (
281
+ agent: AgentConfig,
282
+ cwd: string,
283
+ parentUI: ExtensionUIContext | undefined,
284
+ ) => Promise<SubagentSession>;
285
+
286
+ /**
287
+ * Create the subagent session via the pi SDK: an in-memory session (no disk
288
+ * session recovery or persistence, same as the old --no-session child), a
289
+ * resource loader that discovers only the per-tool override extensions
290
+ * (equivalent to --no-extensions + -e), and the parent UI bound directly so
291
+ * subagent extensions show their dialogs in the parent without RPC.
292
+ */
293
+ export async function createSubagentSession(
294
+ agent: AgentConfig,
295
+ cwd: string,
296
+ parentUI: ExtensionUIContext | undefined,
297
+ ): Promise<AgentSession> {
298
+ const settingsManager = SettingsManager.create(cwd, getAgentDir());
299
+ const loader = new DefaultResourceLoader({
300
+ cwd,
301
+ agentDir: getAgentDir(),
302
+ settingsManager,
303
+ noExtensions: true,
304
+ additionalExtensionPaths: overrideExtensionPaths(agent.tools ?? DEFAULT_TOOLS),
305
+ appendSystemPrompt: agent.systemPrompt ? [agent.systemPrompt] : undefined,
306
+ });
307
+ await loader.reload();
308
+
309
+ const modelRuntime = await ModelRuntime.create();
310
+ const { session } = await createAgentSession({
311
+ cwd,
312
+ model: resolveModel(modelRuntime, agent, settingsManager),
313
+ thinkingLevel: agent.thinkingLevel,
314
+ tools: agent.tools ?? DEFAULT_TOOLS,
315
+ sessionManager: SessionManager.inMemory(cwd),
316
+ settingsManager,
317
+ resourceLoader: loader,
318
+ modelRuntime,
319
+ });
320
+ await session.bindExtensions({ uiContext: parentUI, mode: "rpc" });
321
+ return session;
361
322
  }
362
323
 
363
324
  export async function runAgent(
@@ -367,6 +328,7 @@ export async function runAgent(
367
328
  signal: AbortSignal | undefined,
368
329
  onUpdate: OnUpdateCallback | undefined,
369
330
  parentUI?: ExtensionUIContext,
331
+ createSession: SessionFactory = createSubagentSession,
370
332
  ): Promise<SubagentDetails> {
371
333
  const result: SubagentDetails = {
372
334
  agent: agent.name,
@@ -382,240 +344,131 @@ export async function runAgent(
382
344
  model: agent.model,
383
345
  };
384
346
 
385
- let tmpPromptPath: string | null = null;
347
+ let session: SubagentSession;
386
348
  try {
387
- if (agent.systemPrompt.trim()) {
388
- tmpPromptPath = await writePromptToTempFile(agent.name, agent.systemPrompt);
389
- }
390
- const args = buildSubagentArgs(agent, task, tmpPromptPath ?? undefined);
391
- const invocation = getPiInvocation(args);
392
- const proc = spawn(invocation.command, invocation.args, {
393
- cwd,
394
- shell: false,
395
- stdio: ["pipe", "pipe", "pipe"],
396
- env: { ...process.env },
397
- });
398
-
399
- let logLines: string[] = [];
400
- // 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
401
- // (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
402
- // 不同名按调用顺序罗列;任何非工具行都会打断合并。
403
- let toolLineSegments: string[] = [];
404
- let toolLine: { name: string; count: number } | undefined;
349
+ session = await createSession(agent, cwd, parentUI);
350
+ } catch (error) {
351
+ result.errorMessage = error instanceof Error ? error.message : String(error);
352
+ result.stopReason = "error";
353
+ result.exitCode = 1;
354
+ return result;
355
+ }
405
356
 
406
- const toolSegment = (name: string, count: number) => (count > 1 ? `${name} x ${count}` : name);
357
+ let logLines: string[] = [];
358
+ // 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
359
+ // (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
360
+ // 不同名按调用顺序罗列;任何非工具行都会打断合并。
361
+ let toolLineSegments: string[] = [];
362
+ let toolLine: { name: string; count: number } | undefined;
363
+
364
+ const pushLogLine = (line: string) => {
365
+ logLines.push(line);
366
+ if (logLines.length > MAX_PROGRESS_LINES) {
367
+ logLines = logLines.slice(-MAX_PROGRESS_LINES);
368
+ }
369
+ // 任何非工具行都会打断工具调用合并,下一批调用另起一行。
370
+ toolLine = undefined;
371
+ };
407
372
 
408
- const pushLogLine = (line: string) => {
373
+ const appendToolLine = (name: string) => {
374
+ const firstInBatch = toolLine === undefined;
375
+ if (toolLine === undefined) {
376
+ toolLineSegments = [];
377
+ toolLine = { name, count: 1 };
378
+ } else if (toolLine.name === name) {
379
+ toolLine.count++;
380
+ } else {
381
+ toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
382
+ toolLine = { name, count: 1 };
383
+ }
384
+ const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
385
+ const line = `tool: ${foldProgressLine(parts)}`;
386
+ if (firstInBatch) {
409
387
  logLines.push(line);
410
388
  if (logLines.length > MAX_PROGRESS_LINES) {
411
389
  logLines = logLines.slice(-MAX_PROGRESS_LINES);
412
390
  }
413
- // 任何非工具行都会打断工具调用合并,下一批调用另起一行。
414
- toolLine = undefined;
415
- };
416
-
417
- const appendToolLine = (name: string) => {
418
- const firstInBatch = toolLine === undefined;
419
- if (toolLine === undefined) {
420
- toolLineSegments = [];
421
- toolLine = { name, count: 1 };
422
- } else if (toolLine.name === name) {
423
- toolLine.count++;
424
- } else {
425
- toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
426
- toolLine = { name, count: 1 };
427
- }
428
- const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
429
- const line = `tool: ${foldProgressLine(parts)}`;
430
- if (firstInBatch) {
431
- logLines.push(line);
432
- if (logLines.length > MAX_PROGRESS_LINES) {
433
- logLines = logLines.slice(-MAX_PROGRESS_LINES);
434
- }
435
- } else {
436
- logLines[logLines.length - 1] = line;
437
- }
438
- emitUpdate();
439
- };
440
-
441
- const emitUpdate = () => {
442
- // Usage line rides on the last row so the TUI always shows live token
443
- // cost; it lives outside the rolling window so it is never trimmed.
444
- const usageLine = formatUsageStats(result.usage, result.model);
445
- const lines = usageLine ? [...logLines, usageLine] : logLines;
446
- onUpdate?.({
447
- content: [{ type: "text", text: lines.join("\n") || "(running...)" }],
448
- details: { ...result },
449
- });
450
- };
451
-
452
- let buffer = "";
453
-
454
- const sendRpc = (message: object) => {
455
- proc.stdin.write(`${JSON.stringify(message)}\n`);
456
- };
457
-
458
- let requestedShutdown = false;
459
- const requestShutdown = () => {
460
- if (requestedShutdown) return;
461
- requestedShutdown = true;
462
- proc.stdin.end();
463
- };
464
-
465
- const processLine = (line: string) => {
466
- if (!line.trim()) return;
467
- const record = parseJsonRecord(line);
468
- if (!record) return;
469
-
470
- if (record.type === "extension_ui_request") {
471
- const request = record as RpcExtensionUIRequest;
472
- if (!parentUI) {
473
- if (
474
- request.method === "select" ||
475
- request.method === "confirm" ||
476
- request.method === "input" ||
477
- request.method === "editor"
478
- ) {
479
- sendRpc({ type: "extension_ui_response", id: request.id, cancelled: true });
480
- }
481
- return;
482
- }
483
- void forwardSubagentUIRequest(request, parentUI, signal)
484
- .then((response) => {
485
- if (response) sendRpc(response);
486
- return;
487
- })
488
- .catch(() => {
489
- sendRpc({ type: "extension_ui_response", id: request.id, cancelled: true });
490
- });
491
- return;
492
- }
493
-
494
- if (record.type === "response") {
495
- if (record.command === "prompt" && record.success === false) {
496
- result.errorMessage =
497
- typeof record.error === "string" ? record.error : "Subagent prompt was rejected";
498
- result.stopReason = "error";
499
- requestShutdown();
500
- }
501
- return;
502
- }
503
-
504
- const event = record as AgentSessionEvent;
391
+ } else {
392
+ logLines[logLines.length - 1] = line;
393
+ }
394
+ emitUpdate();
395
+ };
505
396
 
506
- switch (event.type) {
507
- case "message_update": {
508
- // A completed text block (text_end carries the full content) becomes a
509
- // `text:` log line. Deltas/thinking are intentionally not logged.
510
- const delta = event.assistantMessageEvent;
511
- if (delta.type === "text_end") {
512
- pushLogLine(`text: ${foldProgressLine(delta.content)}`);
513
- emitUpdate();
514
- }
397
+ const emitUpdate = () => {
398
+ // Usage line rides on the last row so the TUI always shows live token
399
+ // cost; it lives outside the rolling window so it is never trimmed.
400
+ const usageLine = formatUsageStats(result.usage, result.model);
401
+ const lines = usageLine ? [...logLines, usageLine] : logLines;
402
+ onUpdate?.({
403
+ content: [{ type: "text", text: lines.join("\n") || "(running...)" }],
404
+ details: { ...result },
405
+ });
406
+ };
515
407
 
516
- break;
517
- }
518
- case "tool_execution_start": {
519
- appendToolLine(event.toolName);
520
- break;
521
- }
522
- case "message_end": {
523
- const msg = event.message;
524
- result.messages.push(msg);
525
- if (msg.role === "assistant") {
526
- result.usage.turns++;
527
- result.usage.cost += msg.usage.cost.total;
528
- result.usage.contextTokens = msg.usage.totalTokens;
529
- if (!result.model) result.model = msg.model;
530
- result.stopReason = msg.stopReason;
531
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
532
- }
408
+ const handleEvent = (event: AgentSessionEvent) => {
409
+ switch (event.type) {
410
+ case "message_update": {
411
+ // A completed text block (text_end carries the full content) becomes a
412
+ // `text:` log line. Deltas/thinking are intentionally not logged.
413
+ const delta = event.assistantMessageEvent;
414
+ if (delta.type === "text_end") {
415
+ pushLogLine(`text: ${foldProgressLine(delta.content)}`);
533
416
  emitUpdate();
534
-
535
- break;
536
417
  }
537
- case "agent_settled": {
538
- requestShutdown();
539
- break;
540
- }
541
- // No default
542
- }
543
- };
544
-
545
- proc.stdout.on("data", (data: Buffer) => {
546
- buffer += data.toString();
547
- const lines = buffer.split("\n");
548
- buffer = lines.pop() ?? "";
549
- for (const line of lines) processLine(line);
550
- });
551
-
552
- proc.stderr.on("data", (data: Buffer) => {
553
- result.stderr = (result.stderr + data.toString()).slice(-MAX_STDERR_CAPTURE_BYTES);
554
- });
555
418
 
556
- proc.stdin.on("error", (error) => {
557
- if (!requestedShutdown) result.stderr += error.message;
558
- });
559
-
560
- sendRpc({ type: "prompt", message: `Task: ${task}` });
561
-
562
- const exitCode = await new Promise<number>((resolve) => {
563
- proc.on("close", (code, childSignal) => {
564
- if (buffer.trim()) processLine(buffer);
565
- // 被信号终止时 code 为 null,不能算 0——否则中断会被误判为成功
566
- resolve(code ?? (childSignal ? 1 : 0));
567
- });
568
- proc.on("error", (error) => {
569
- // spawn 失败(如 pi 命令不存在)时 error 先于 close 触发;
570
- // 记录真实错误,而不是只留一个 exit code。
571
- result.errorMessage = error.message;
572
- result.stopReason ??= "error";
573
- resolve(1);
574
- });
575
-
576
- const kill = () => {
577
- // abort 可能发生在子代理产生任何结果之前;标记 aborted 让上层
578
- // 识别中断(已有 stopReason 则保留,避免误报)。
579
- result.stopReason ??= "aborted";
580
- proc.kill("SIGTERM");
581
- setTimeout(() => {
582
- if (!proc.killed) proc.kill("SIGKILL");
583
- }, 5000);
584
- };
585
- if (signal) {
586
- if (signal.aborted) kill();
587
- else signal.addEventListener("abort", kill, { once: true });
419
+ break;
588
420
  }
589
- });
421
+ case "tool_execution_start": {
422
+ appendToolLine(event.toolName);
423
+ break;
424
+ }
425
+ case "message_end": {
426
+ const msg = event.message;
427
+ result.messages.push(msg);
428
+ if (msg.role === "assistant") {
429
+ result.usage.turns++;
430
+ result.usage.cost += msg.usage.cost.total;
431
+ result.usage.contextTokens = msg.usage.totalTokens;
432
+ if (!result.model) result.model = msg.model;
433
+ result.stopReason = msg.stopReason;
434
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage;
435
+ }
436
+ emitUpdate();
590
437
 
591
- result.exitCode = exitCode;
592
- return result;
593
- } finally {
594
- if (tmpPromptPath) {
595
- try {
596
- await rm(tmpPromptPath, { force: true });
597
- await rm(dirname(tmpPromptPath), { recursive: true, force: true });
598
- } catch {
599
- // best-effort cleanup
438
+ break;
600
439
  }
440
+ // agent_settled 等事件无需处理:prompt() resolve 即本轮结束。
441
+ // No default
601
442
  }
443
+ };
444
+
445
+ const unsubscribe = session.subscribe(handleEvent);
446
+ const onAbort = () => {
447
+ // abort 可能发生在子代理产生任何结果之前;标记 aborted 让上层
448
+ // 识别中断(已有 stopReason 则保留,避免误报)。
449
+ result.stopReason ??= "aborted";
450
+ void session.abort();
451
+ };
452
+ if (signal) {
453
+ if (signal.aborted) onAbort();
454
+ else signal.addEventListener("abort", onAbort, { once: true });
602
455
  }
603
- }
604
456
 
605
- /**
606
- * Parse one line of the subagent's RPC stream into a JSON object. Non-JSON
607
- * lines and records without a type discriminator are rejected.
608
- */
609
- function parseJsonRecord(line: string): Record<string, unknown> | null {
610
- let raw: unknown;
611
457
  try {
612
- raw = JSON.parse(line);
613
- } catch {
614
- return null;
458
+ await session.prompt(`Task: ${task}`, { source: "rpc" });
459
+ } catch (error) {
460
+ if (result.stopReason !== "aborted") {
461
+ result.errorMessage = error instanceof Error ? error.message : String(error);
462
+ result.stopReason = "error";
463
+ }
464
+ } finally {
465
+ unsubscribe();
466
+ session.dispose();
615
467
  }
616
- if (typeof raw !== "object" || raw === null) return null;
617
- if (typeof (raw as Record<string, unknown>).type !== "string") return null;
618
- return raw as Record<string, unknown>;
468
+
469
+ // abort/error 没有退出码可依,由 stopReason 推导(语义同子进程退出码)。
470
+ result.exitCode = result.stopReason === "error" || result.stopReason === "aborted" ? 1 : 0;
471
+ return result;
619
472
  }
620
473
 
621
474
  /** Session entry customType used to mark the injected subagent list. */
@@ -633,9 +486,8 @@ export function formatAgentListSection(agents: AgentConfig[]): string {
633
486
  // ── extension ────────────────────────────────────────────────────────────────
634
487
 
635
488
  export default function spawnAgent(pi: ExtensionAPI) {
636
- // Windows 上禁用:子代理进程的派生(node/bun 运行时下回退到 `pi` 命令,
637
- // 而 npm 安装的 pi 是 .cmd shim,spawn 无法直接启动)与信号管理都是
638
- // POSIX 假设,不做 Windows 适配。
489
+ // Windows 上禁用:子代理的工具集依赖 POSIX 设施(opencode bash 的
490
+ // bwrap 沙箱、信号处理),不做 Windows 适配。
639
491
  if (process.platform === "win32") {
640
492
  pi.on("session_start", (_event, ctx) => {
641
493
  ctx.ui.notify("spawn-agent is disabled on Windows.", "warning");