@trim21/personal-pi-extensions 0.1.650 → 0.1.652

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.
Files changed (52) hide show
  1. package/README.md +67 -9
  2. package/package.json +5 -13
  3. package/src/aft/index.ts +40 -23
  4. package/src/aft/tools.ts +10 -9
  5. package/src/claude-code/files.ts +99 -54
  6. package/src/claude-code/glob.ts +8 -5
  7. package/src/claude-code/grep.ts +8 -5
  8. package/src/claude-code/index.ts +56 -10
  9. package/src/claude-code/search.ts +4 -3
  10. package/src/claude-code/session-tools.ts +4 -3
  11. package/src/claude-code/shell.ts +3 -8
  12. package/src/gh/index.ts +48 -25
  13. package/src/gh/tools/download-release-assets.ts +3 -3
  14. package/src/gh/tools/get-workflow-jobs.ts +3 -3
  15. package/src/gh/tools/list-issues.ts +3 -3
  16. package/src/gh/tools/list-prs.ts +3 -3
  17. package/src/gh/tools/list-releases.ts +3 -3
  18. package/src/gh/tools/list-workflow-runs.ts +3 -3
  19. package/src/gh/tools/read-ci-logs.ts +3 -4
  20. package/src/gh/tools/read-issue-comments.ts +3 -3
  21. package/src/gh/tools/read-issue.ts +3 -3
  22. package/src/gh/tools/read-pr-comments.ts +3 -3
  23. package/src/gh/tools/read-pr-diff.ts +3 -3
  24. package/src/gh/tools/read-pr-status.ts +3 -3
  25. package/src/gh/tools/read-pr.ts +3 -3
  26. package/src/gh/tools/read-release.ts +3 -3
  27. package/src/gh/tools/read-repo.ts +3 -3
  28. package/src/gh/tools/wait-commit-checks.ts +3 -3
  29. package/src/gh/tools/wait-pr-checks.ts +3 -3
  30. package/src/gh/tools/watch-run.ts +3 -3
  31. package/src/index.ts +120 -0
  32. package/src/lib/lsp/inspect-tool.ts +5 -5
  33. package/src/lib/lsp/rename-tool.ts +4 -7
  34. package/src/lib/tool-bus.ts +126 -0
  35. package/src/lib/tool-registration.ts +83 -0
  36. package/src/lib/tool-services.ts +65 -0
  37. package/src/lib/tool-units.ts +102 -0
  38. package/src/lib/tools-config.ts +277 -0
  39. package/src/opencode/bash.ts +7 -2
  40. package/src/opencode/files.ts +90 -43
  41. package/src/opencode/glob.ts +7 -1
  42. package/src/opencode/grep.ts +7 -1
  43. package/src/opencode/index.ts +66 -21
  44. package/src/opencode/question.ts +8 -2
  45. package/src/opencode/todo.ts +8 -2
  46. package/src/orphan-worker-cleaner.ts +38 -0
  47. package/src/spawn-agent.ts +160 -175
  48. package/src/talk/index.ts +92 -66
  49. package/src/vision-agent.ts +31 -7
  50. package/src/web/fetch.ts +89 -72
  51. package/src/web/search.ts +14 -2
  52. package/src/gh-readonly.ts +0 -10
@@ -27,6 +27,8 @@ import { type ExtensionAPI, truncateToVisualLines } from "@earendil-works/pi-cod
27
27
  import { type Static, Type } from "typebox";
28
28
 
29
29
  import { type ToolPendant } from "../lib/pendant.js";
30
+ import type { ToolBus } from "../lib/tool-bus.js";
31
+ import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
30
32
 
31
33
  // ── constants ────────────────────────────────────────────────────────────────
32
34
 
@@ -155,8 +157,12 @@ export function buildTodoWidgetLines(todos: readonly TodoInfo[]): string[] | und
155
157
 
156
158
  // ── extension ────────────────────────────────────────────────────────────────
157
159
 
158
- export default function todowrite(pi: ExtensionAPI) {
159
- pi.registerTool<typeof todowriteSchema, TodoDetails>({
160
+ export default function todowrite(pi: ExtensionAPI): void {
161
+ registerToolsOnSessionStart(pi, registerTodoTool);
162
+ }
163
+
164
+ export function registerTodoTool(bus: ToolBus): void {
165
+ bus.register<typeof todowriteSchema, TodoDetails>({
160
166
  name: TOOL_NAME,
161
167
  label: "Todo Write",
162
168
  description: TODOWRITE_DESCRIPTION,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * orphan-worker-cleaner —— 宿主消失时让 pi SDK worker 自行退出
3
+ *
4
+ * VS Code 的 pi 扩展(Pendant)用 fork 启动 dist/pi-sdk-worker.cjs 承载 pi SDK。
5
+ * 宿主进程消失时 worker 不会退出:worker 侧只把 IPC 断连当成可重试的传输错误
6
+ * 上报,既不清理也不自杀,于是留下 ppid=1 的孤儿,连同它拉起的 language server
7
+ * (实测单个 worker 身上挂 4 GiB 上下)一起常驻。
8
+ *
9
+ * 这里只做一件事:定时看一眼自己的父进程还在不在(父进程退出后会被 init 收养,
10
+ * ppid 变成 1),不在就退出。worker 一退,它拉起的 language server 会因为 stdin
11
+ * 关闭自行退出,language server 退出时(process.on("exit") → shutdown())又会
12
+ * kill 掉自己 fork 的 tsserver,整棵树随之消失 —— 所以不需要遍历或清理别的进程。
13
+ *
14
+ * 只对 fork 出来的 SDK worker 生效(process.channel 存在):CLI 直跑、以及 pi
15
+ * 内部 spawn 出来的进程都没有 IPC channel,不在此列,不会被误伤。
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ /** 检查父进程是否还在的间隔 */
21
+ export const DEFAULT_CHECK_INTERVAL_MS = 5_000;
22
+
23
+ export default function orphanWorkerCleaner(pi: ExtensionAPI): void {
24
+ if (process.channel === undefined) {
25
+ return;
26
+ }
27
+ const timer = setInterval(() => {
28
+ if (process.ppid === 1) {
29
+ // eslint-disable-next-line unicorn/no-process-exit -- 宿主已经没了,worker 必须整体退出
30
+ process.exit(0);
31
+ }
32
+ }, DEFAULT_CHECK_INTERVAL_MS);
33
+ timer.unref();
34
+
35
+ pi.on("session_shutdown", () => {
36
+ clearInterval(timer);
37
+ });
38
+ }
@@ -47,9 +47,7 @@
47
47
  * inherit the user's sandbox.json, whose modes may be relaxed for interactive use.
48
48
  */
49
49
 
50
- import { existsSync } from "node:fs";
51
50
  import { join } from "node:path";
52
- import { fileURLToPath } from "node:url";
53
51
 
54
52
  import type { AgentMessage, AgentToolResult } from "@earendil-works/pi-agent-core";
55
53
  import {
@@ -59,9 +57,9 @@ import {
59
57
  createAgentSession,
60
58
  DefaultResourceLoader,
61
59
  type ExtensionAPI,
60
+ type ExtensionFactory,
62
61
  type ExtensionUIContext,
63
62
  getAgentDir,
64
- type InlineExtension,
65
63
  ModelRuntime,
66
64
  type PromptOptions,
67
65
  SessionManager,
@@ -72,11 +70,13 @@ import { throttle } from "lodash-es";
72
70
  import { Type } from "typebox";
73
71
 
74
72
  import { type BwrapConfig, completeBwrapConfig } from "./bwrap/core.js";
75
- import { type BwrapRuntime, createBwrapRuntime } from "./bwrap/runtime.js";
76
- import { registerShellTools } from "./claude-code/shell.js";
73
+ import { createClaudeCodeFileTools } from "./claude-code/files.js";
77
74
  import { type ToolPendant } from "./lib/pendant.js";
78
- import { createRequestPolicy } from "./lib/request-policy.js";
79
- import opencodeBash from "./opencode/bash.js";
75
+ import type { ToolBus } from "./lib/tool-bus.js";
76
+ import { createToolRegistration, registerToolsOnSessionStart } from "./lib/tool-registration.js";
77
+ import { createToolServices } from "./lib/tool-services.js";
78
+ import { toolsetsForToolNames, unitsForToolNames } from "./lib/tool-units.js";
79
+ import { createOpencodeFileTools } from "./opencode/files.js";
80
80
  import {
81
81
  type AgentConfig,
82
82
  applyAgentDefaults,
@@ -114,45 +114,16 @@ const SPAWN_AGENT_CONFIG_PATH = join(getAgentDir(), "spawn-agent.json");
114
114
  const SETTINGS_PATH = join(getAgentDir(), "settings.json");
115
115
 
116
116
  /**
117
- * Tool → extension override map: when a subagent's frontmatter enables a
118
- * built-in tool, the matching opencode extension is loaded via the SDK's
119
- * `additionalExtensionPaths` so the subagent uses the enhanced implementation
120
- * instead of the built-in one.
117
+ * 子代理的工具由 `subagentToolsExtension` 的 inline 工厂注册:它按 frontmatter
118
+ * 声明的工具名从 `TOOL_UNITS` 里挑出覆盖到的单元(见 src/lib/tool-units.ts),
119
+ * 与主入口共用同一张表,因此实现、审批与参数完全一致。
121
120
  *
122
- * `bash` (opencode) and `Bash` (claude-code) are deliberately not in this map:
123
- * they are injected via an inline extension factory (subagentShellExtension)
124
- * instead, because their bwrap runtime carries the agent's declared sandbox
125
- * config — a path-based override has no channel to pass per-agent config
126
- * (see createSubagentSession).
127
- * (Workspace write protection is embedded in the opencode write/edit tools.)
121
+ * 不用 `-e` 路径加载的原因:路径加载是「一个模块一份模块图」,子代理里没有
122
+ * 任何东西能看到完整工具集(工具之间互相看不见),而且 per-agent 的沙箱配置
123
+ * 没有注入通道——inline 工厂的闭包是 SDK 提供的唯一注入点。
128
124
  *
129
- * Claude Code style tools (capitalized names) map to their claude-code
130
- * files, so a subagent can enable exactly the tools it declares — e.g. `Grep`
131
- * without `Glob`. The stateful file tools (`Read`/`Edit`/`Write`) share one
132
- * implementation file (they share a read-snapshot state); the `tools`
133
- * allowlist still exposes only the declared subset. The opencode file tools
134
- * (read/edit/write) likewise share opencode/files.ts (they share the LSP
135
- * service instance); that file also registers the shared `lsp-rename` and
136
- * LSP inspect tools, which stay hidden unless a subagent declares them.
137
- *
138
- * The lowercase search tools map to the opencode implementations: `grep`
139
- * overrides pi's built-in grep, and `glob` adds a tool pi has no built-in for
140
- * (its `find` stays available). Each is a self-contained file registering one
141
- * tool, so they load independently — `grep` without `glob`.
125
+ * 未声明 `tools` 时子 agent 用 DEFAULT_TOOLS(只读)。
142
126
  */
143
- const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
144
- read: "opencode/files.ts",
145
- edit: "opencode/files.ts",
146
- write: "opencode/files.ts",
147
- grep: "opencode/grep.ts",
148
- glob: "opencode/glob.ts",
149
- Grep: "claude-code/grep.ts",
150
- Glob: "claude-code/glob.ts",
151
- Read: "claude-code/files.ts",
152
- Edit: "claude-code/files.ts",
153
- Write: "claude-code/files.ts",
154
- };
155
-
156
127
  // ── schema ───────────────────────────────────────────────────────────────────
157
128
 
158
129
  const spawnAgentSchema = Type.Object({
@@ -236,59 +207,58 @@ function formatPendantMarkdown(task: string, response: string): string {
236
207
  }
237
208
 
238
209
  /**
239
- * Resolve a sibling extension file (relative to this module) to an absolute
240
- * path, so `additionalExtensionPaths` works both when running from the source
241
- * tree and from an installed pi package (node_modules). A missing extension is
242
- * fatal: silently skipping a guard (e.g. bwrap) would leave the subagent
243
- * unprotected.
244
- */
245
- function extensionPath(fileName: string): string {
246
- const abs = fileURLToPath(new URL(fileName, import.meta.url));
247
- if (!existsSync(abs)) {
248
- throw new Error(`Extension file not found: ${abs}`);
249
- }
250
- return abs;
251
- }
252
-
253
- /**
254
- * Load the override extension for each declared tool (read/edit/write → opencode
255
- * files.ts, bash → opencode bash.ts, ...), so the subagent uses the enhanced
256
- * implementation instead of the built-in one. Several tool names can map to
257
- * the same implementation file (e.g. cc Read/Edit/Write → claude-code/files.ts);
258
- * loading a file twice would run its extension factory twice and create
259
- * separate closure states, so each file is loaded at most once.
210
+ * 子代理的工具扩展:一个 inline 工厂里建一份注册上下文与共享服务,只注册被
211
+ * 声明工具覆盖到的单元。
212
+ *
213
+ * 为什么不用 `-e` 路径加载:路径加载是「一个模块一份模块图」,子代理里没有
214
+ * 任何东西能看到完整工具集(工具之间、脚本工具与工具之间互相看不见),而且
215
+ * per-agent 的沙箱配置没有注入通道。inline 工厂是 pi SDK 提供的唯一注入点。
216
+ *
217
+ * 沙箱配置随闭包携带该 agent 的完整配置(frontmatter `sandbox`,已在
218
+ * discoverAgents 补全成 BwrapConfig),未声明时用 SUBAGENT_DEFAULT_SANDBOX。
260
219
  */
261
- export function overrideExtensionPaths(tools: string[]): string[] {
262
- const loaded = new Set<string>();
263
- const paths: string[] = [];
264
- for (const tool of tools) {
265
- const ext = TOOL_EXTENSION_OVERRIDES[tool];
266
- if (!ext || loaded.has(ext)) {
267
- continue;
220
+ export function subagentToolsExtension(
221
+ agent: AgentConfig,
222
+ tools: readonly string[],
223
+ ): ExtensionFactory {
224
+ return (pi) => {
225
+ const registration = createToolRegistration(pi);
226
+ const services = createToolServices(pi, { sandbox: agent.sandbox ?? SUBAGENT_DEFAULT_SANDBOX });
227
+ const kind = toolsetsForToolNames(tools);
228
+ if (!kind) {
229
+ return;
268
230
  }
269
231
 
270
- loaded.add(ext);
271
- paths.push(extensionPath(ext));
272
- }
273
- return paths;
274
- }
275
-
276
- /**
277
- * bash 类工具(opencode `bash` / cc `Bash`)的内联扩展工厂:bwrap runtime 随闭包
278
- * 携带该 agent 的完整沙箱配置(frontmatter `sandbox`,已在 discoverAgents 补全成
279
- * BwrapConfig),未声明时用 SUBAGENT_DEFAULT_SANDBOX。路径式 override
280
- * (additionalExtensionPaths)没有 per-agent 配置通道,extensionFactories 的工厂
281
- * 闭包是 SDK 提供的唯一注入点。
282
- */
283
- export function subagentShellExtension(
284
- agent: AgentConfig,
285
- register: (pi: ExtensionAPI, runtime: BwrapRuntime) => void,
286
- ): InlineExtension {
287
- return (pi) =>
288
- register(
289
- pi,
290
- createBwrapRuntime(createRequestPolicy(pi.events), agent.sandbox ?? SUBAGENT_DEFAULT_SANDBOX),
291
- );
232
+ const fileToolset =
233
+ kind === "claude-code"
234
+ ? createClaudeCodeFileTools(pi, {
235
+ policy: services.policy,
236
+ bus: registration.bus,
237
+ manager: services.manager,
238
+ })
239
+ : createOpencodeFileTools(pi, {
240
+ policy: services.policy,
241
+ bus: registration.bus,
242
+ manager: services.manager,
243
+ });
244
+ services.setLspEnabledHandler((service) => fileToolset.onLspEnabled(registration.bus, service));
245
+
246
+ const units = unitsForToolNames(kind, tools);
247
+ // 工具在 session_start 里注册:禁用规则可以带 models,而子代理的模型在扩展
248
+ // 加载期还读不到。
249
+ registration.onSessionStart((bus, ctx) => {
250
+ fileToolset.restoreReads(ctx);
251
+ for (const unit of units) {
252
+ unit.register({
253
+ pi,
254
+ bus,
255
+ policy: services.policy,
256
+ runtime: services.runtime,
257
+ fileToolset,
258
+ });
259
+ }
260
+ });
261
+ };
292
262
  }
293
263
 
294
264
  /**
@@ -344,8 +314,8 @@ export type SessionFactory = (
344
314
  /**
345
315
  * Create the subagent session via the pi SDK: an in-memory session (no disk
346
316
  * session recovery or persistence, same as the old --no-session child), a
347
- * resource loader that discovers only the per-tool override extensions
348
- * (equivalent to --no-extensions + -e), and the parent UI bound directly so
317
+ * resource loader with no extensions except our own inline tool factory
318
+ * (equivalent to --no-extensions), and the parent UI bound directly so
349
319
  * subagent extensions show their dialogs in the parent without RPC.
350
320
  */
351
321
  export async function createSubagentSession(
@@ -360,11 +330,7 @@ export async function createSubagentSession(
360
330
  agentDir: getAgentDir(),
361
331
  settingsManager,
362
332
  noExtensions: true,
363
- additionalExtensionPaths: overrideExtensionPaths(tools),
364
- extensionFactories: [
365
- ...(tools.includes("bash") ? [subagentShellExtension(agent, opencodeBash)] : []),
366
- ...(tools.includes("Bash") ? [subagentShellExtension(agent, registerShellTools)] : []),
367
- ],
333
+ extensionFactories: [subagentToolsExtension(agent, tools)],
368
334
  appendSystemPrompt: agent.systemPrompt ? [agent.systemPrompt] : undefined,
369
335
  });
370
336
  await loader.reload();
@@ -543,16 +509,8 @@ export function formatAgentListSection(agents: AgentConfig[]): string {
543
509
 
544
510
  // ── extension ────────────────────────────────────────────────────────────────
545
511
 
546
- export default function spawnAgent(pi: ExtensionAPI) {
547
- // Windows 上禁用:子代理的工具集依赖 POSIX 设施(opencode bash 的
548
- // bwrap 沙箱、信号处理),不做 Windows 适配。
549
- if (process.platform === "win32") {
550
- pi.on("session_start", (_event, ctx) => {
551
- ctx.ui.notify("spawn-agent is disabled on Windows.", "warning");
552
- });
553
- return;
554
- }
555
-
512
+ /** spawn-agent 工具集:入口从它取注册函数。 */
513
+ export function createSpawnAgentTool(): { register(bus: ToolBus): void } {
556
514
  // Discover the available subagent types once at extension startup. The
557
515
  // extension owns this discovery: the model never has to guess agent names
558
516
  // or read the agent directory itself. Editing ~/.pi/agent/agents/*.md or
@@ -563,74 +521,101 @@ export default function spawnAgent(pi: ExtensionAPI) {
563
521
  );
564
522
  const agentListSection = agents.length > 0 ? formatAgentListSection(agents) : null;
565
523
 
566
- pi.registerTool<typeof spawnAgentSchema, SubagentDetails>({
567
- name: "spawn-agent",
568
- label: "spawn-agent",
569
- description: [
570
- "Delegate a task to a subagent that runs in an isolated session with its own context window, inside this pi process rather than a separate one.",
571
- "The call blocks until the subagent finishes its turn; its final output comes back as the tool result.",
572
- "The `agent` parameter must be one of the available subagent types listed in the system prompt.",
573
- `Subagents run read-only (${DEFAULT_TOOLS.join(", ")}) unless the agent declares an explicit toolset.`,
574
- ].join(" "),
575
- promptGuidelines: agentListSection ? [agentListSection] : undefined,
576
- parameters: spawnAgentSchema,
577
-
578
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
579
- const agent = agents.find((a) => a.name === params.agent);
580
- if (!agent) {
581
- return {
582
- content: [
583
- {
584
- type: "text",
585
- text: `Unknown agent "${params.agent}". Available agents: ${formatAgentList(agents)}`,
586
- },
587
- ],
588
- details: {},
589
- isError: true,
590
- };
591
- }
524
+ return {
525
+ register(bus) {
526
+ bus.register<typeof spawnAgentSchema, SubagentDetails>({
527
+ name: "spawn-agent",
528
+ label: "spawn-agent",
529
+ description: [
530
+ "Delegate a task to a subagent that runs in an isolated session with its own context window, inside this pi process rather than a separate one.",
531
+ "The call blocks until the subagent finishes its turn; its final output comes back as the tool result.",
532
+ "The `agent` parameter must be one of the available subagent types listed in the system prompt.",
533
+ `Subagents run read-only (${DEFAULT_TOOLS.join(", ")}) unless the agent declares an explicit toolset.`,
534
+ ].join(" "),
535
+ promptGuidelines: agentListSection ? [agentListSection] : undefined,
536
+ parameters: spawnAgentSchema,
537
+
538
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
539
+ const agent = agents.find((a) => a.name === params.agent);
540
+ if (!agent) {
541
+ return {
542
+ content: [
543
+ {
544
+ type: "text",
545
+ text: `Unknown agent "${params.agent}". Available agents: ${formatAgentList(agents)}`,
546
+ },
547
+ ],
548
+ details: {},
549
+ isError: true,
550
+ };
551
+ }
592
552
 
593
- const result = await runAgent(
594
- agent,
595
- params.task,
596
- ctx.cwd,
597
- signal,
598
- onUpdate,
599
- ctx.hasUI ? ctx.ui : undefined,
600
- );
601
-
602
- const isError =
603
- result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
604
- if (isError) {
605
- const { reason, message } = formatSubagentError(result);
606
- return {
607
- content: [
608
- { type: "text", text: `Subagent "${result.agent}" failed (${reason}):\n${message}` },
609
- ],
610
- details: {
611
- pendant: {
612
- subtitle: result.agent,
613
- markdown: formatPendantMarkdown(params.task, message),
614
- } satisfies ToolPendant,
615
- },
616
- isError: true,
617
- };
618
- }
553
+ const result = await runAgent(
554
+ agent,
555
+ params.task,
556
+ ctx.cwd,
557
+ signal,
558
+ onUpdate,
559
+ ctx.hasUI ? ctx.ui : undefined,
560
+ );
561
+
562
+ const isError =
563
+ result.exitCode !== 0 ||
564
+ result.stopReason === "error" ||
565
+ result.stopReason === "aborted";
566
+ if (isError) {
567
+ const { reason, message } = formatSubagentError(result);
568
+ return {
569
+ content: [
570
+ {
571
+ type: "text",
572
+ text: `Subagent "${result.agent}" failed (${reason}):\n${message}`,
573
+ },
574
+ ],
575
+ details: {
576
+ pendant: {
577
+ subtitle: result.agent,
578
+ markdown: formatPendantMarkdown(params.task, message),
579
+ } satisfies ToolPendant,
580
+ },
581
+ isError: true,
582
+ };
583
+ }
619
584
 
620
- const output = getFinalOutput(result.messages) || "(no output)";
621
- const truncation = truncateTail(output, { maxBytes: MAX_OUTPUT_BYTES });
622
- const text = truncation.truncated
623
- ? `${truncation.content}\n\n[Output truncated to ${formatTokens(truncation.content.length)} bytes.]`
624
- : output;
625
- return {
626
- content: [{ type: "text", text }],
627
- details: {
628
- pendant: {
629
- subtitle: result.agent,
630
- markdown: formatPendantMarkdown(params.task, text),
631
- } satisfies ToolPendant,
585
+ const output = getFinalOutput(result.messages) || "(no output)";
586
+ const truncation = truncateTail(output, { maxBytes: MAX_OUTPUT_BYTES });
587
+ const text = truncation.truncated
588
+ ? `${truncation.content}\n\n[Output truncated to ${formatTokens(truncation.content.length)} bytes.]`
589
+ : output;
590
+ return {
591
+ content: [{ type: "text", text }],
592
+ details: {
593
+ pendant: {
594
+ subtitle: result.agent,
595
+ markdown: formatPendantMarkdown(params.task, text),
596
+ } satisfies ToolPendant,
597
+ },
598
+ };
632
599
  },
633
- };
600
+ });
634
601
  },
602
+ };
603
+ }
604
+
605
+ export default function spawnAgent(pi: ExtensionAPI): void {
606
+ // Windows 上禁用:子代理的工具集依赖 POSIX 设施(opencode bash 的
607
+ // bwrap 沙箱、信号处理),不做 Windows 适配。
608
+ if (process.platform === "win32") {
609
+ pi.on("session_start", (_event, ctx) => {
610
+ ctx.ui.notify("spawn-agent is disabled on Windows.", "warning");
611
+ });
612
+ return;
613
+ }
614
+
615
+ const tools = createSpawnAgentTool();
616
+ // 工具在 session_start 里注册:禁用规则可以带 models,只有那时才知道本会话
617
+ // 的模型。
618
+ registerToolsOnSessionStart(pi, (bus) => {
619
+ tools.register(bus);
635
620
  });
636
621
  }
package/src/talk/index.ts CHANGED
@@ -24,6 +24,8 @@ import { type TObject, Type } from "typebox";
24
24
 
25
25
  import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
26
26
  import { resolveHomePath } from "../lib/path.js";
27
+ import type { ToolBus } from "../lib/tool-bus.js";
28
+ import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
27
29
  import { restoreTalkAgentId, TALK_JOIN_ENTRY_TYPE, TalkCore } from "./core.js";
28
30
  import { age, formatDelivery } from "./format.js";
29
31
  import type { Letter } from "./mailbox.js";
@@ -97,7 +99,83 @@ function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
97
99
  }
98
100
  }
99
101
 
100
- export default function talk(pi: ExtensionAPI) {
102
+ /** talk 的三个工具:由扩展入口在 session_start 里注册。 */
103
+ function registerTalkTools(
104
+ bus: ToolBus,
105
+ core: TalkCore,
106
+ requireInit: () => string | undefined,
107
+ ): void {
108
+ bus.register({
109
+ name: "talk-list-agents",
110
+ label: "List Talk Agents",
111
+ description: "List visible pi agents (id, status, work_dir, name).",
112
+ promptSnippet: "List other pi agents on this machine",
113
+ parameters: Type.Object({
114
+ cwd: Type.Optional(Type.String({ description: "Only list agents in this directory" })),
115
+ }),
116
+ async execute(_toolCallId, params) {
117
+ const initError = requireInit();
118
+ if (initError) {
119
+ return toolResult(initError);
120
+ }
121
+ return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
122
+ },
123
+ });
124
+
125
+ bus.register({
126
+ name: "talk-ask",
127
+ label: "Ask Talk",
128
+ description:
129
+ "Ask another pi agent a question and block until it responds (or times out). Before asking, it checks whether that agent already sent you something; if so, you are told to read and respond first instead of asking.",
130
+ promptSnippet: "Ask another pi agent a question and wait for a response",
131
+ parameters: Type.Object({
132
+ to: Type.String({ description: "Target agent id (from talk-list-agents)" }),
133
+ message: Type.String({ description: "The question" }),
134
+ timeoutMs: Type.Optional(
135
+ Type.Number({ description: `Wait cap in ms; default ${ASK_TIMEOUT_MS}` }),
136
+ ),
137
+ }),
138
+ async execute(_toolCallId, params, signal) {
139
+ const initError = requireInit();
140
+ if (initError) {
141
+ return toolResult(initError);
142
+ }
143
+ return toolResult(
144
+ await core.ask(
145
+ params.to,
146
+ params.message,
147
+ params.timeoutMs ?? ASK_TIMEOUT_MS,
148
+ signal ?? undefined,
149
+ ),
150
+ );
151
+ },
152
+ });
153
+
154
+ bus.register({
155
+ name: "talk-send",
156
+ label: "Send Talk Message",
157
+ description:
158
+ "Send a plain-text message to a single pi agent. Plain text only, ≤32KB — send a summary and a path, never file contents.",
159
+ promptSnippet: "Send a message to another pi agent",
160
+ parameters: Type.Object({
161
+ to: Type.String({ description: "Target agent id (from talk-list-agents)" }),
162
+ message: Type.String({ description: "Message body" }),
163
+ }),
164
+ async execute(_toolCallId, params) {
165
+ const initError = requireInit();
166
+ if (initError) {
167
+ return toolResult(initError);
168
+ }
169
+ return toolResult(await core.send(params.to, params.message));
170
+ },
171
+ });
172
+ }
173
+
174
+ /**
175
+ * talk 工具集:创建核心、注册会话接线与 /talk 命令,返回工具注册函数。
176
+ * 入口与独立默认导出都用它,因此两种加载方式行为一致。
177
+ */
178
+ export function createTalkTools(pi: ExtensionAPI): { register(bus: ToolBus): void } {
101
179
  const settings = readTalkSettings();
102
180
  const configured = process.env.PI_TALK_DB ?? settings.dbPath;
103
181
  const dbPath = configured
@@ -220,71 +298,8 @@ export default function talk(pi: ExtensionAPI) {
220
298
  });
221
299
 
222
300
  // ── Tools ──────────────────────────────────────────────────────────────
223
-
224
- pi.registerTool({
225
- name: "talk-list-agents",
226
- label: "List Talk Agents",
227
- description: "List visible pi agents (id, status, work_dir, name).",
228
- promptSnippet: "List other pi agents on this machine",
229
- parameters: Type.Object({
230
- cwd: Type.Optional(Type.String({ description: "Only list agents in this directory" })),
231
- }),
232
- async execute(_toolCallId, params) {
233
- const initError = requireInit();
234
- if (initError) {
235
- return toolResult(initError);
236
- }
237
- return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
238
- },
239
- });
240
-
241
- pi.registerTool({
242
- name: "talk-ask",
243
- label: "Ask Talk",
244
- description:
245
- "Ask another pi agent a question and block until it responds (or times out). Before asking, it checks whether that agent already sent you something; if so, you are told to read and respond first instead of asking.",
246
- promptSnippet: "Ask another pi agent a question and wait for a response",
247
- parameters: Type.Object({
248
- to: Type.String({ description: "Target agent id (from talk-list-agents)" }),
249
- message: Type.String({ description: "The question" }),
250
- timeoutMs: Type.Optional(
251
- Type.Number({ description: `Wait cap in ms; default ${ASK_TIMEOUT_MS}` }),
252
- ),
253
- }),
254
- async execute(_toolCallId, params, signal) {
255
- const initError = requireInit();
256
- if (initError) {
257
- return toolResult(initError);
258
- }
259
- return toolResult(
260
- await core.ask(
261
- params.to,
262
- params.message,
263
- params.timeoutMs ?? ASK_TIMEOUT_MS,
264
- signal ?? undefined,
265
- ),
266
- );
267
- },
268
- });
269
-
270
- pi.registerTool({
271
- name: "talk-send",
272
- label: "Send Talk Message",
273
- description:
274
- "Send a plain-text message to a single pi agent. Plain text only, ≤32KB — send a summary and a path, never file contents.",
275
- promptSnippet: "Send a message to another pi agent",
276
- parameters: Type.Object({
277
- to: Type.String({ description: "Target agent id (from talk-list-agents)" }),
278
- message: Type.String({ description: "Message body" }),
279
- }),
280
- async execute(_toolCallId, params) {
281
- const initError = requireInit();
282
- if (initError) {
283
- return toolResult(initError);
284
- }
285
- return toolResult(await core.send(params.to, params.message));
286
- },
287
- });
301
+ // 工具在 session_start 里注册:禁用规则可以带 models,只有那时才知道本会话
302
+ // 的模型。
288
303
 
289
304
  // ── /talk commands ────────────────────────────────────────────────────
290
305
 
@@ -526,4 +541,15 @@ export default function talk(pi: ExtensionAPI) {
526
541
  };
527
542
  });
528
543
  }
544
+
545
+ return {
546
+ register(bus) {
547
+ registerTalkTools(bus, core, requireInit);
548
+ },
549
+ };
550
+ }
551
+
552
+ export default function talk(pi: ExtensionAPI): void {
553
+ const tools = createTalkTools(pi);
554
+ registerToolsOnSessionStart(pi, (bus) => tools.register(bus));
529
555
  }