@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 +1 -1
- package/src/claude-code/bash.md +2 -0
- package/src/lib/write-guard.ts +3 -5
- package/src/opencode/bash.md +7 -0
- package/src/opencode/bash.ts +7 -0
- package/src/spawn-agent.ts +219 -367
package/package.json
CHANGED
package/src/claude-code/bash.md
CHANGED
|
@@ -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)
|
package/src/lib/write-guard.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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.
|
package/src/opencode/bash.ts
CHANGED
|
@@ -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" }),
|
package/src/spawn-agent.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* spawn_agent tool — delegate a task to a subagent
|
|
3
|
-
* process
|
|
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
|
|
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 {
|
|
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
|
-
|
|
39
|
-
type
|
|
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
|
|
76
|
-
* subagent uses the enhanced implementation
|
|
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
|
|
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
|
|
223
|
-
* installed pi package (node_modules). A missing extension is
|
|
224
|
-
* skipping a guard (e.g. bwrap) would leave the subagent
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 && !
|
|
277
|
-
|
|
278
|
-
|
|
233
|
+
if (ext && !loaded.has(ext)) {
|
|
234
|
+
loaded.add(ext);
|
|
235
|
+
paths.push(extensionPath(ext));
|
|
279
236
|
}
|
|
280
237
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
/**
|
|
298
|
-
export
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
): Promise<
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
347
|
+
let session: SubagentSession;
|
|
386
348
|
try {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
} catch {
|
|
614
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
637
|
-
//
|
|
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");
|