@buyi1net/pi-toolkit 0.0.1

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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/assembler.ts +127 -0
  4. package/config.ts +201 -0
  5. package/i18n.ts +693 -0
  6. package/index.ts +99 -0
  7. package/menu/items.ts +264 -0
  8. package/menu/panels.ts +91 -0
  9. package/menu/settings-list.ts +84 -0
  10. package/menu/theme.ts +31 -0
  11. package/menu/toolkit-menu.ts +139 -0
  12. package/module.ts +156 -0
  13. package/modules/eyes/chain.ts +173 -0
  14. package/modules/eyes/config.ts +319 -0
  15. package/modules/eyes/index.ts +161 -0
  16. package/modules/eyes/menu.ts +828 -0
  17. package/modules/eyes/pi-model-backend.ts +245 -0
  18. package/modules/eyes/resilience.ts +161 -0
  19. package/modules/eyes/vision-bridge.ts +232 -0
  20. package/modules/eyes/vision-cache.ts +86 -0
  21. package/modules/eyes/vision-json.ts +19 -0
  22. package/modules/eyes/vision-preprocess.ts +192 -0
  23. package/modules/eyes/vision-probe.ts +21 -0
  24. package/modules/eyes/vision-prompt.ts +90 -0
  25. package/modules/eyes/vision-tool.ts +59 -0
  26. package/modules/eyes/vision-types.ts +33 -0
  27. package/modules/index.ts +10 -0
  28. package/modules/subagents/agents/researcher.md +51 -0
  29. package/modules/subagents/agents/scout.md +40 -0
  30. package/modules/subagents/agents/worker.md +79 -0
  31. package/modules/subagents/config.json.example +6 -0
  32. package/modules/subagents/config.ts +144 -0
  33. package/modules/subagents/index.ts +91 -0
  34. package/modules/subagents/menu.ts +388 -0
  35. package/modules/subagents/src/activity.ts +511 -0
  36. package/modules/subagents/src/agents.ts +126 -0
  37. package/modules/subagents/src/command.ts +37 -0
  38. package/modules/subagents/src/dependencies.ts +246 -0
  39. package/modules/subagents/src/diagnostics.ts +13 -0
  40. package/modules/subagents/src/display.ts +94 -0
  41. package/modules/subagents/src/headless.ts +342 -0
  42. package/modules/subagents/src/herdr.ts +203 -0
  43. package/modules/subagents/src/index.ts +2798 -0
  44. package/modules/subagents/src/inspect-tool.ts +839 -0
  45. package/modules/subagents/src/launch-config.ts +196 -0
  46. package/modules/subagents/src/layout-budget.ts +26 -0
  47. package/modules/subagents/src/list-tool.ts +292 -0
  48. package/modules/subagents/src/message-tool.ts +808 -0
  49. package/modules/subagents/src/names.ts +17 -0
  50. package/modules/subagents/src/pane-layout.ts +49 -0
  51. package/modules/subagents/src/params.ts +145 -0
  52. package/modules/subagents/src/renderers.ts +181 -0
  53. package/modules/subagents/src/result.ts +43 -0
  54. package/modules/subagents/src/retention.ts +114 -0
  55. package/modules/subagents/src/route-error.ts +212 -0
  56. package/modules/subagents/src/routing.ts +235 -0
  57. package/modules/subagents/src/runtime-registry.ts +159 -0
  58. package/modules/subagents/src/session.ts +801 -0
  59. package/modules/subagents/src/status.ts +513 -0
  60. package/modules/subagents/src/stop-tool.ts +235 -0
  61. package/modules/subagents/src/subagent-done.ts +590 -0
  62. package/modules/subagents/src/subagent-tool.ts +1155 -0
  63. package/modules/subagents/src/surface.ts +355 -0
  64. package/modules/subagents/src/team-dispatch-tool.ts +219 -0
  65. package/modules/subagents/src/team.ts +232 -0
  66. package/modules/subagents/src/tmux.ts +210 -0
  67. package/modules/subagents/src/tools/safe-bash.ts +72 -0
  68. package/modules/subagents/src/types.ts +131 -0
  69. package/modules/tui/adapter/provider-usage.ts +143 -0
  70. package/modules/tui/config.ts +50 -0
  71. package/modules/tui/index.ts +120 -0
  72. package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
  73. package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
  74. package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
  75. package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
  76. package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
  77. package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
  78. package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
  79. package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
  80. package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
  81. package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
  82. package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
  83. package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
  84. package/modules/tui/kernel/usage-core.ts +2 -0
  85. package/modules/tui/kernel/usage-node.ts +2 -0
  86. package/modules/tui/menu.ts +418 -0
  87. package/modules/tui/plugin/editor.ts +396 -0
  88. package/modules/tui/plugin/footer.ts +161 -0
  89. package/modules/tui/plugin/index.ts +30 -0
  90. package/modules/tui/plugin/lifecycle.ts +637 -0
  91. package/modules/tui/plugin/package-order.ts +169 -0
  92. package/modules/tui/plugin/screen-transition.ts +203 -0
  93. package/modules/tui/plugin/settings-config.ts +297 -0
  94. package/modules/tui/plugin/status-sources.ts +49 -0
  95. package/modules/tui/plugin/transition-gate.ts +261 -0
  96. package/modules/tui/renderer/custom-header.ts +157 -0
  97. package/modules/tui/renderer/editor.ts +70 -0
  98. package/modules/tui/renderer/header.ts +72 -0
  99. package/modules/tui/renderer/icons.ts +149 -0
  100. package/modules/tui/renderer/pi-installer-logo.ts +194 -0
  101. package/modules/tui/status/auto-compaction.ts +67 -0
  102. package/modules/tui/status/project-status.ts +655 -0
  103. package/modules/tui/status/provider-status.ts +120 -0
  104. package/modules/tui/status/runtime-status.ts +307 -0
  105. package/modules/tui/status/session-status.ts +325 -0
  106. package/modules/tui/status/status-config.ts +95 -0
  107. package/modules/tui/status/status-segments.ts +263 -0
  108. package/modules/tui/status/turn-telemetry.ts +466 -0
  109. package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
  110. package/modules/tui/themes/UPSTREAM.md +7 -0
  111. package/modules/tui/themes/catppuccin-latte.json +80 -0
  112. package/modules/tui/themes/catppuccin-mocha.json +79 -0
  113. package/modules/tui/themes/crimson-noir.json +85 -0
  114. package/modules/tui/themes/dracula.json +79 -0
  115. package/modules/tui/themes/everforest-dark.json +85 -0
  116. package/modules/tui/themes/gruvbox-dark.json +85 -0
  117. package/modules/tui/themes/gruvbox-light.json +85 -0
  118. package/modules/tui/themes/matrix.json +85 -0
  119. package/modules/tui/themes/nord.json +85 -0
  120. package/modules/tui/themes/one-dark.json +85 -0
  121. package/modules/tui/themes/rose-pine-dawn.json +85 -0
  122. package/modules/tui/themes/rose-pine.json +85 -0
  123. package/modules/tui/themes/solarized-dark.json +85 -0
  124. package/modules/tui/themes/solarized-light.json +85 -0
  125. package/modules/tui/themes/tokyo-night-storm.json +79 -0
  126. package/modules/tui/themes/tokyo-night.json +79 -0
  127. package/package.json +28 -0
  128. package/services.ts +38 -0
  129. package/toolkit.ts +147 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * 表面层统一入口 —— index.ts 只依赖本文件,不直接依赖具体复用器。
3
+ *
4
+ * 检测规则(按 pi 实际运行的位置,天然互斥):
5
+ * HERDR_ENV=1 → herdr 表面(pi 跑在 herdr pane 内)
6
+ * TMUX 设置 → tmux 表面(pi 跑在 tmux pane 内)
7
+ * 都没有 → 无可用表面(报错提示用户进入其中之一)
8
+ *
9
+ * shell 语法跟表面走:
10
+ * herdr:pane 默认 shell 跟随宿主(Windows=PowerShell,Linux/macOS=POSIX)
11
+ * tmux: 恒为 POSIX shell(tmux 无 Windows 原生支持)
12
+ *
13
+ * pollForExit 与两个表面无关(读屏经 readScreenAsync 抽象,sidecar 文件
14
+ * 机制是纯文件协议),集中在本文件。
15
+ */
16
+ import { existsSync, readFileSync, rmSync } from "node:fs";
17
+ import * as herdrSurface from "./herdr.ts";
18
+ import * as tmuxSurface from "./tmux.ts";
19
+ import { debugLog } from "./diagnostics.ts";
20
+
21
+ // ── 归属护栏 ──
22
+ // 只允许关闭本扩展自己创建的 pane。任何关闭路径(含未来新增功能)都必须
23
+ // 经由 closeSurface → assertOwned 这道闸,防止误关用户或其它 agent 的
24
+ // pane(事故预防:曾发生过把外部 pane 当测试残留误关的事故)。
25
+ const ownedSurfaces = new Set<string>();
26
+
27
+ /**
28
+ * 读取并消费 `.exit` sidecar(原子性由写入方 tmp+rename 保证)。
29
+ * pane 与 headless 两条 watcher 共用:pane 在轮询与读屏失败兜底时读,
30
+ * headless 在进程退出后读。
31
+ */
32
+ export function readExitSidecar(sessionFile: string | undefined): PollResult | null {
33
+ if (!sessionFile) return null;
34
+ try {
35
+ const exitFile = `${sessionFile}.exit`;
36
+ if (existsSync(exitFile)) {
37
+ const data = JSON.parse(readFileSync(exitFile, "utf-8"));
38
+ rmSync(exitFile, { force: true });
39
+ return interpretExitSidecar(data);
40
+ }
41
+ } catch (error) {
42
+ debugLog(`Could not consume exit sidecar for ${sessionFile}`, error);
43
+ }
44
+ return null;
45
+ }
46
+ /**
47
+ * 只读查看 `.exit` sidecar(不消费)。跨 turn dependsOn 判定用:判定方
48
+ * 不是该 sidecar 的生命周期所有者,删除式读取会破坏后续判定(文件被消费
49
+ * 后,同一失败上游在下一个 turn/进程里会被误判为 completed)。消费式
50
+ * 读取仍由 watcher 路径(readExitSidecar)负责——它是唯一有权消费的一方。
51
+ */
52
+ export function peekExitSidecar(sessionFile: string | undefined): PollResult | null {
53
+ if (!sessionFile) return null;
54
+ try {
55
+ const exitFile = `${sessionFile}.exit`;
56
+ if (!existsSync(exitFile)) return null;
57
+ return interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
58
+ } catch (error) {
59
+ debugLog(`Could not peek exit sidecar for ${sessionFile}`, error);
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** 校验 pane 归属;非本扩展创建的 pane 抛错拒绝。 */
65
+ export function assertOwned(surface: string): void {
66
+ if (!ownedSurfaces.has(surface)) {
67
+ throw new Error(
68
+ `Refusing to close pane "${surface}": it was not created by this extension. ` +
69
+ "Only panes spawned via createSurface() may be closed.",
70
+ );
71
+ }
72
+ }
73
+
74
+ export type SurfaceKind = "herdr" | "tmux";
75
+ export type ShellSyntax = "powershell" | "posix";
76
+
77
+ /** 当前应使用的表面;无可用表面时返回 null。 */
78
+ export function detectSurface(): SurfaceKind | null {
79
+ if (process.env.HERDR_ENV === "1") return "herdr";
80
+ if (process.env.TMUX) return "tmux";
81
+ return null;
82
+ }
83
+
84
+ /** 当前表面内 shell 的语法族。无表面时按宿主平台推断(供纯函数测试等场景)。 */
85
+ export function shellSyntax(): ShellSyntax {
86
+ switch (detectSurface()) {
87
+ case "herdr":
88
+ return process.platform === "win32" ? "powershell" : "posix";
89
+ case "tmux":
90
+ return "posix";
91
+ default:
92
+ return process.platform === "win32" ? "powershell" : "posix";
93
+ }
94
+ }
95
+
96
+ /** 表面可用性(环境检测 + CLI 二进制存在)。 */
97
+ export function isMuxAvailable(): boolean {
98
+ switch (detectSurface()) {
99
+ case "herdr":
100
+ return herdrSurface.isAvailable();
101
+ case "tmux":
102
+ return tmuxSurface.isAvailable();
103
+ default:
104
+ return false;
105
+ }
106
+ }
107
+
108
+ export function muxSetupHint(): string {
109
+ switch (detectSurface()) {
110
+ case "herdr":
111
+ return herdrSurface.setupHint();
112
+ case "tmux":
113
+ return tmuxSurface.setupHint();
114
+ default:
115
+ return "Start pi inside tmux (`tmux new -A -s pi 'pi'`) or inside a herdr pane.";
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 单引号字面量转义,按当前表面 shell 语法分派。
121
+ */
122
+ export function shellEscape(s: string): string {
123
+ return shellSyntax() === "posix"
124
+ ? "'" + s.replace(/'/g, "'\\''") + "'"
125
+ : "'" + s.replace(/'/g, "''") + "'";
126
+ }
127
+
128
+ /**
129
+ * 启动命令后缀:pi 退出后 echo 带退出码的 sentinel。
130
+ * PowerShell:$LASTEXITCODE(花括号防止尾部下划线并进变量名);
131
+ * POSIX:$?(文字部分单引号包裹,$? 裸露求值拼接)。
132
+ */
133
+ export function sentinelSuffix(token = "__SUBAGENT_DONE"): string {
134
+ return shellSyntax() === "posix"
135
+ ? `; echo '${token}_'$?'__'`
136
+ : `; echo "${token}_\${LASTEXITCODE}__"`;
137
+ }
138
+
139
+ /** 为子代理创建新 pane。opts.cwd 直接指定工作目录,opts.env 注入环境变量。 */
140
+ export function createSurface(
141
+ name: string,
142
+ opts?: { cwd?: string; env?: Record<string, string> },
143
+ ): string {
144
+ const id = requireSurface().createSurface(name, opts);
145
+ ownedSurfaces.add(id);
146
+ // best-effort 标记:让 pane 在复用器 UI 里一眼可辨归属
147
+ try {
148
+ requireSurface().labelSurface?.(id, `[pi-sub] ${name}`);
149
+ } catch (error) {
150
+ debugLog(`Could not label pane ${id}`, error);
151
+ }
152
+ return id;
153
+ }
154
+
155
+ /** 向 pane 发送一行文本并回车(子代理启动命令 / steer 消息)。 */
156
+ export function sendCommand(surface: string, text: string): void {
157
+ requireSurface().sendCommand(surface, text);
158
+ }
159
+
160
+ /** 读取 pane 近期输出(同步)。 */
161
+ export function readScreen(surface: string, lines = 50): string {
162
+ return requireSurface().readScreen(surface, lines);
163
+ }
164
+
165
+ /** 读取 pane 近期输出(异步)。 */
166
+ export function readScreenAsync(surface: string, lines = 50): Promise<string> {
167
+ return requireSurface().readScreenAsync(surface, lines);
168
+ }
169
+
170
+ /** 关闭 pane(其内进程随之终止)。仅限本扩展创建的 pane,否则拒绝。 */
171
+ export function closeSurface(surface: string): void {
172
+ assertOwned(surface);
173
+ requireSurface().closeSurface(surface);
174
+ ownedSurfaces.delete(surface);
175
+ }
176
+
177
+ /**
178
+ * 重载后恢复本扩展此前持有的 pane,使 watcher 可以继续接管生命周期。
179
+ * 返回 false 表示运行态记录指向的 pane 已经不存在。
180
+ */
181
+ export function adoptSurface(surface: string): boolean {
182
+ if (!surface.trim()) return false;
183
+ const exists = requireSurface().surfaceExists?.(surface);
184
+ if (exists === false) return false;
185
+ requireSurface().trackSurface?.(surface);
186
+ ownedSurfaces.add(surface);
187
+ return true;
188
+ }
189
+
190
+ /**
191
+ * 只读探测 pane 是否仍存在。没有可用复用器或表面不支持探测时返回
192
+ * null,调用方不得把未知状态当成已退出。
193
+ */
194
+ export function probeSurface(surface: string): boolean | null {
195
+ if (!surface.trim() || !detectSurface()) return null;
196
+ try {
197
+ const check = requireSurface().surfaceExists;
198
+ return check ? check(surface) : null;
199
+ } catch (error) {
200
+ debugLog(`Could not probe subagent surface ${surface}`, error);
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function requireSurface(): SurfaceModule {
206
+ const kind = detectSurface();
207
+ if (kind === "herdr") return herdrSurface;
208
+ if (kind === "tmux") return tmuxSurface;
209
+ throw new Error(`No terminal multiplexer detected. ${muxSetupHint()}`);
210
+ }
211
+
212
+ /** 两个表面模块必须实现的统一原语集。 */
213
+ interface SurfaceModule {
214
+ isAvailable(): boolean;
215
+ setupHint(): string;
216
+ createSurface(name: string, opts?: { cwd?: string; env?: Record<string, string> }): string;
217
+ sendCommand(surface: string, text: string): void;
218
+ readScreen(surface: string, lines?: number): string;
219
+ readScreenAsync(surface: string, lines?: number): Promise<string>;
220
+ closeSurface(surface: string): void;
221
+ surfaceExists?(surface: string): boolean;
222
+ /** 恢复后把已有 pane 纳入表面层的布局目标集合。 */
223
+ trackSurface?(surface: string): void;
224
+ /** best-effort,表面可不实现 */
225
+ labelSurface?(surface: string, label: string): void;
226
+ }
227
+
228
+ // ── 退出轮询(与表面无关)──
229
+
230
+ export interface PollResult {
231
+ /**
232
+ * 子代理如何退出。"user_closed":pane 被外部关闭且无 .exit sidecar
233
+ * (典型:用户直接关掉演示 pane)——稳定分类,不进 provider/agent error。
234
+ */
235
+ reason: "done" | "sentinel" | "error" | "user_closed";
236
+ /** shell 退出码(来自 sentinel)。文件路径的退出为 0。 */
237
+ exitCode: number;
238
+ /** reason 为 "error" 时的错误信息(自动重试耗尽、供应商过载等) */
239
+ errorMessage?: string;
240
+ }
241
+
242
+ /**
243
+ * 解析 `.exit` sidecar 内容(subagent-done.ts 的错误路径写入)。
244
+ * 集中在此让 pollForExit 的快慢两条路径与 headless watcher 用同一套解码。
245
+ * 正常完成不写 sidecar,headless 路径靠进程退出 + 本文件兜底判定。
246
+ * 注意:ask_question 不写 .exit —— 会话保持打开,经独立的 .ask 文件
247
+ * 通知父会话(见 index.ts 的 deliverPendingQuestion)。
248
+ */
249
+ export interface ExitSidecar {
250
+ type?: unknown;
251
+ errorMessage?: unknown;
252
+ }
253
+
254
+ export function interpretExitSidecar(data: ExitSidecar): PollResult {
255
+ if (data?.type === "error") {
256
+ const errorMessage =
257
+ typeof data.errorMessage === "string" && data.errorMessage.trim() !== ""
258
+ ? data.errorMessage
259
+ : "Subagent exited with stopReason=error (no errorMessage in sidecar).";
260
+ return { reason: "error", exitCode: 1, errorMessage };
261
+ }
262
+ return { reason: "done", exitCode: 0 };
263
+ }
264
+
265
+ export const __pollForExitTest__ = { interpretExitSidecar, isMissingSurfaceError, paneGoneResult };
266
+
267
+ function isMissingSurfaceError(error: unknown): boolean {
268
+ if (!error || typeof error !== "object") return false;
269
+ const code = "code" in error ? (error as { code?: unknown }).code : undefined;
270
+ return code === "ENOENT";
271
+ }
272
+
273
+ /** pane 消失且无 .exit sidecar 时的稳定分类:用户关闭,不是 provider 错误。 */
274
+ export function paneGoneResult(detail: string): PollResult {
275
+ return {
276
+ reason: "user_closed",
277
+ exitCode: 1,
278
+ errorMessage:
279
+ `Subagent pane is gone (${detail}) and no .exit sidecar was found. ` +
280
+ `The pane was most likely closed directly (by the user or the multiplexer); ` +
281
+ `this is not a provider or agent error.`,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * 轮询直至子代理退出。先查 `.exit` sidecar 文件(错误路径),
287
+ * 再读 pane 屏幕匹配完成 sentinel(正常完成与崩溃检测)。
288
+ */
289
+ export async function pollForExit(
290
+ surface: string,
291
+ signal: AbortSignal,
292
+ options: {
293
+ interval: number;
294
+ sessionFile?: string;
295
+ /** 当前运行专属 token,避免普通输出伪造完成标记。 */
296
+ sentinelToken?: string;
297
+ onTick?: (elapsed: number) => void;
298
+ },
299
+ ): Promise<PollResult> {
300
+ const start = Date.now();
301
+ let readFailures = 0;
302
+ const MAX_READ_FAILURES = 15;
303
+
304
+ const readExitFile = (): PollResult | null => readExitSidecar(options.sessionFile);
305
+
306
+ for (;;) {
307
+ if (signal.aborted) {
308
+ throw new Error("Aborted while waiting for subagent to finish");
309
+ }
310
+
311
+ const sidecar = readExitFile();
312
+ if (sidecar) return sidecar;
313
+
314
+ try {
315
+ const screen = await readScreenAsync(surface, 15);
316
+ readFailures = 0;
317
+ const token = options.sentinelToken ?? "__SUBAGENT_DONE";
318
+ const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
319
+ const match = screen.match(new RegExp(`${escapedToken}_(\\d+)__`));
320
+ if (match) {
321
+ return { reason: "sentinel", exitCode: parseInt(match[1], 10) };
322
+ }
323
+ } catch (error) {
324
+ // pane 可能已被销毁 —— 补查一次 sidecar,避免丢掉刚落盘的退出信息
325
+ const late = readExitFile();
326
+ if (late) return late;
327
+ if (isMissingSurfaceError(error)) {
328
+ return paneGoneResult("surface command unavailable — herdr/tmux may have exited or been removed");
329
+ }
330
+ readFailures += 1;
331
+ // 连续读取失败且无 sidecar:pane 大概率已被外部关闭(herdr/tmux 读屏均
332
+ // 走这条路径),终止轮询并归为稳定的 user_closed 终态——不是 provider
333
+ // 错误,不生成 route_exception;避免 watcher/运行记录/widget 永久卡死。
334
+ if (readFailures >= MAX_READ_FAILURES) {
335
+ return paneGoneResult(`closed externally; gave up after ${MAX_READ_FAILURES} consecutive read failures`);
336
+ }
337
+ }
338
+
339
+ const elapsed = Math.floor((Date.now() - start) / 1000);
340
+ options.onTick?.(elapsed);
341
+
342
+ await new Promise<void>((resolve, reject) => {
343
+ if (signal.aborted) return reject(new Error("Aborted"));
344
+ const timer = setTimeout(() => {
345
+ signal.removeEventListener("abort", onAbort);
346
+ resolve();
347
+ }, options.interval);
348
+ function onAbort() {
349
+ clearTimeout(timer);
350
+ reject(new Error("Aborted"));
351
+ }
352
+ signal.addEventListener("abort", onAbort, { once: true });
353
+ });
354
+ }
355
+ }
@@ -0,0 +1,219 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { randomUUID } from "node:crypto";
5
+ import { normalizeSubagentName } from "./names.ts";
6
+ import { buildTeamRoundPrompt, findRosterMember, rosterPath, upsertRosterMember, type RosterMember } from "./team.ts";
7
+ import type { RunningSubagent } from "./types.ts";
8
+
9
+ export interface TeamDispatchToolDeps {
10
+ /** 本进程运行中的子代理(member 查找与在线判定)。 */
11
+ runningSubagents: Map<string, RunningSubagent>;
12
+ getArtifactDir: (sessionDir: string, sessionId: string) => string;
13
+ rosterPath: (artifactDir: string) => string;
14
+ findRosterMember: (path: string, name: string) => RosterMember | null;
15
+ upsertRosterMember: (
16
+ path: string,
17
+ entry: Parameters<typeof upsertRosterMember>[1],
18
+ ) => RosterMember;
19
+ countSessionEntryLines: (sessionFile: string) => number;
20
+ }
21
+
22
+ interface DispatchContext {
23
+ sessionManager: {
24
+ getSessionFile(): string | null | undefined;
25
+ getSessionId(): string;
26
+ getSessionDir(): string;
27
+ };
28
+ }
29
+
30
+ /**
31
+ * `team_dispatch`:向持久团队成员(member: true)派发下一轮任务。
32
+ *
33
+ * 约束(最小 MVP,诚实边界):
34
+ * - 只允许本会话登记的成员;非 member 名字直接拒绝(不隐式升级)。
35
+ * - 每个成员同一时间至多一个在途轮次;在途时明确报 busy。
36
+ * - 投递即返回 ack(经 RPC steer:运行中排队/空闲开新 run);轮次结果由
37
+ * 父侧 round watcher 在 .round 结束信号后恰好回注一次。
38
+ * - offline 成员不伪称在线:明确提示重启(subagent member: true)或显式
39
+ * resume(subagent_message,一次性任务语义)。
40
+ */
41
+ export function registerTeamDispatchTool(pi: ExtensionAPI, deps: TeamDispatchToolDeps): void {
42
+ pi.registerTool({
43
+ name: "team_dispatch",
44
+ label: "Dispatch to Team Member",
45
+ description:
46
+ "Dispatch the NEXT round of work to a persistent team member (spawned with subagent + member: true) by name. " +
47
+ "Returns an immediate acknowledgement — the round runs asynchronously and its real result is delivered exactly once " +
48
+ "as a steer message when the round ends (.round signal). One in-flight round per member at a time: dispatching while " +
49
+ "a round is running is refused (wait for its result message). Members are session-scoped; an offline member " +
50
+ "(stopped / host shutdown / reload) is reported explicitly with restart/resume guidance — never treated as online. " +
51
+ "This tool never spawns anything: create members with subagent({ member: true }).",
52
+ promptSnippet:
53
+ "Dispatch the next round to a persistent team member by name; returns an ack, the round's result arrives later as a steer message.",
54
+ parameters: Type.Object({
55
+ name: Type.String({
56
+ description:
57
+ "Exact display name of a persistent team member spawned with member: true in this session.",
58
+ }),
59
+ task: Type.String({
60
+ description: "The task for this round. Keep it self-contained: the member keeps its conversation across rounds.",
61
+ }),
62
+ }),
63
+
64
+ renderCall(args, theme) {
65
+ const target = typeof (args as any).name === "string" ? (args as any).name : "(unknown)";
66
+ return new Text(
67
+ "○ " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", " — team dispatch"),
68
+ 0,
69
+ 0,
70
+ );
71
+ },
72
+
73
+ renderResult(result, _opts, theme) {
74
+ const details = result.details as any;
75
+ const name = details?.name ?? "member";
76
+ if (details?.status === "dispatched") {
77
+ return new Text(
78
+ theme.fg("accent", "◆") + " " + theme.fg("toolTitle", theme.bold(name)) +
79
+ theme.fg("accent", " — round dispatched"),
80
+ 0,
81
+ 0,
82
+ );
83
+ }
84
+ const content = result.content[0];
85
+ const text = content && content.type === "text" ? content.text : "";
86
+ return new Text(theme.fg("dim", text), 0, 0);
87
+ },
88
+
89
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: DispatchContext) {
90
+ const rawName = typeof params?.name === "string" ? params.name.trim() : "";
91
+ const task = typeof params?.task === "string" ? params.task.trim() : "";
92
+ if (!rawName) {
93
+ const error = "Provide the team member's `name` (spawned with member: true) and a non-empty `task`.";
94
+ return { content: [{ type: "text", text: error }], details: { error } };
95
+ }
96
+ if (!task) {
97
+ const error = "Provide a non-empty `task` for this round.";
98
+ return { content: [{ type: "text", text: error }], details: { error, name: rawName } };
99
+ }
100
+ const name = normalizeSubagentName(rawName, "");
101
+
102
+ const member = Array.from(deps.runningSubagents.values()).find(
103
+ (running) => running.member === true && running.name === name,
104
+ );
105
+ if (!member) {
106
+ // 不在本进程运行态:查 roster 给出诚实提示(offline/未知)。
107
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
108
+ let rosterEntry: RosterMember | null = null;
109
+ if (sessionFile) {
110
+ const rosterFile = deps.rosterPath(
111
+ deps.getArtifactDir(ctx.sessionManager.getSessionDir(), ctx.sessionManager.getSessionId()),
112
+ );
113
+ rosterEntry = deps.findRosterMember(rosterFile, name);
114
+ }
115
+ if (rosterEntry) {
116
+ return {
117
+ content: [{
118
+ type: "text",
119
+ text:
120
+ `Team member "${name}" is OFFLINE (${rosterEntry.offlineReason ?? "unknown reason"}). ` +
121
+ `It is NOT running — do not dispatch work to it. Restart it with ` +
122
+ `subagent({ agent: ${rosterEntry.agent ? `"${rosterEntry.agent}"` : "…"}, member: true, name: "${name}", … }) ` +
123
+ `(fresh member process; its preserved session file is ${rosterEntry.sessionFile}), or resume its session as a ` +
124
+ `one-shot task with subagent_message({ name: "${name}", message: "…" }).`,
125
+ }],
126
+ details: { error: "member-offline", name, roster: rosterEntry },
127
+ };
128
+ }
129
+ return {
130
+ content: [{
131
+ type: "text",
132
+ text:
133
+ `No team member named "${name}" in this session. Members are created explicitly with ` +
134
+ `subagent({ agent: "…", member: true, name: "${name}" }) and stay registered in the team roster. ` +
135
+ `Regular one-shot sub-agents cannot receive team_dispatch.`,
136
+ }],
137
+ details: { error: "unknown-member", name },
138
+ };
139
+ }
140
+
141
+ if (member.dispatchedRound) {
142
+ return {
143
+ content: [{
144
+ type: "text",
145
+ text:
146
+ `Team member "${name}" already has a round in flight. Wait for its steer-delivered round result ` +
147
+ `before dispatching again — do not queue work blindly.`,
148
+ }],
149
+ details: { error: "member-busy", name },
150
+ };
151
+ }
152
+
153
+ const child = member.headlessChild;
154
+ if (!child || child.exited || member.stdinLost) {
155
+ return {
156
+ content: [{
157
+ type: "text",
158
+ text:
159
+ `Team member "${name}" is no longer reachable (process gone). Respawn it with ` +
160
+ `subagent({ member: true, … }) or resume its preserved session with subagent_message.`,
161
+ }],
162
+ details: { error: "member-unreachable", name },
163
+ };
164
+ }
165
+
166
+ // 轮次关联 nonce:baseline 先于投递记录(派单消息进入会话之后才会出
167
+ // 现),steer 文本带 marker;父侧 round watcher 在 .round 认领时用它区分
168
+ // 本次派单轮与交错的自发轮(见 index.ts watchMemberRound)。
169
+ const roundEntryBaseline = deps.countSessionEntryLines(member.sessionFile);
170
+ const roundId = randomUUID();
171
+ try {
172
+ child.steer(buildTeamRoundPrompt(roundId, task));
173
+ } catch (error: any) {
174
+ return {
175
+ content: [{
176
+ type: "text",
177
+ text: `Could not deliver the round to "${name}": ${error?.message ?? String(error)}. Treat it as offline.`,
178
+ }],
179
+ details: { error: "dispatch-failed", name, detail: error?.message ?? String(error) },
180
+ };
181
+ }
182
+
183
+ member.dispatchedRound = true;
184
+ member.dispatchedRoundId = roundId;
185
+ member.roundEntryBaseline = roundEntryBaseline;
186
+ if (member.rosterFile) {
187
+ try {
188
+ deps.upsertRosterMember(member.rosterFile, {
189
+ name: member.name,
190
+ ...(member.agent ? { agent: member.agent } : {}),
191
+ sessionFile: member.sessionFile,
192
+ ...(member.pid != null ? { pid: member.pid } : {}),
193
+ status: "dispatched",
194
+ dispatchedAt: Date.now(),
195
+ });
196
+ } catch {
197
+ // roster 写失败不影响派单;round watcher 的状态更新尽力而为。
198
+ }
199
+ }
200
+
201
+ return {
202
+ content: [{
203
+ type: "text",
204
+ text:
205
+ `Round dispatched to team member "${name}". Do NOT assume or fabricate its result — the real result ` +
206
+ `of this round will be delivered as a steer message exactly once when it ends. Meanwhile you can work on ` +
207
+ `other tasks or dispatch to OTHER idle members.`,
208
+ }],
209
+ details: {
210
+ name: member.name,
211
+ id: member.id,
212
+ sessionFile: member.sessionFile,
213
+ status: "dispatched",
214
+ roundId,
215
+ },
216
+ };
217
+ },
218
+ });
219
+ }