@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,590 @@
1
+ /**
2
+ * Extension loaded into sub-agents.
3
+ * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O)
4
+ * - Provides an `ask_question` tool for asking the parent orchestrator a question
5
+ *
6
+ * Subagents do NOT self-terminate via a tool. Auto-exit agents shut down
7
+ * automatically when their agent loop ends (see the `agent_end` handler);
8
+ * interactive agents end when the human exits the pane.
9
+ *
10
+ * `ask_question` keeps the session OPEN: it writes a `${sessionFile}.ask`
11
+ * signal the parent's watcher picks up, parks the session in a "waiting" state
12
+ * (auto-exit is suppressed for that turn via `awaitingAnswer`), and the parent
13
+ * replies with subagent_message — which lands as the subagent's next turn.
14
+ */
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { Box, Text } from "@earendil-works/pi-tui";
17
+ import { Type } from "@sinclair/typebox";
18
+ import { renameSync, writeFileSync } from "node:fs";
19
+
20
+ /** sidecar 协议文件(.exit/.ask)原子写:tmp+rename,避免部分写入被父侧读走。 */
21
+ function writeSidecar(path: string, data: string): void {
22
+ const tmp = `${path}.${process.pid}.tmp`;
23
+ writeFileSync(tmp, data);
24
+ renameSync(tmp, path);
25
+ }
26
+ import { createSubagentActivityRecorder } from "./activity.ts";
27
+ import { postTeamMessage, readRoster, rosterPath, writeRoundSignal } from "./team.ts";
28
+ import { normalizeSubagentName } from "./names.ts";
29
+
30
+ export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
31
+ return agentStarted;
32
+ }
33
+
34
+ /**
35
+ * Number of child subagents this session itself still has in flight.
36
+ *
37
+ * When this extension is loaded inside a subagent that can spawn its own
38
+ * children (e.g. a worker delegating to scout/researcher), `index.ts` runs in
39
+ * the same process and publishes a live count through a shared process-global
40
+ * symbol. A subagent that spawns children and then writes a "waiting for
41
+ * results" message would otherwise auto-exit the instant that turn ends —
42
+ * killing the session before its children report back. Reading this count lets
43
+ * `agent_end` keep the session open until every child has finished and its
44
+ * result has been delivered.
45
+ *
46
+ * Returns 0 when the spawning tools aren't loaded (scout/researcher, or a
47
+ * standalone session), so those agents auto-exit exactly as before.
48
+ */
49
+ export function runningChildrenCount(): number {
50
+ const fn = (globalThis as any)[Symbol.for("pi-subagents/running-children-count")];
51
+ if (typeof fn !== "function") return 0;
52
+ try {
53
+ const n = fn();
54
+ return typeof n === "number" && n > 0 ? n : 0;
55
+ } catch {
56
+ return 0;
57
+ }
58
+ }
59
+
60
+ export function shouldAutoExitOnAgentEnd(
61
+ _userTookOver: boolean,
62
+ messages: any[] | undefined,
63
+ ): boolean {
64
+ // Manual input should not strand an auto-exit subagent. If the latest agent
65
+ // turn completed normally, close the session. Escape/abort still leaves it
66
+ // open for inspection or another prompt.
67
+ //
68
+ // stopReason: "error" (e.g. exhausted retries on a provider overload) also
69
+ // returns true — we want to shut down so the parent is woken up — but we
70
+ // pair this with findLatestAssistantError() so the parent learns it was an
71
+ // error, not a clean completion.
72
+ if (messages) {
73
+ for (let i = messages.length - 1; i >= 0; i--) {
74
+ const msg = messages[i];
75
+ if (msg?.role === "assistant") {
76
+ return msg.stopReason !== "aborted";
77
+ }
78
+ }
79
+ }
80
+
81
+ return true;
82
+ }
83
+
84
+ export interface SubagentErrorInfo {
85
+ errorMessage: string;
86
+ stopReason: "error";
87
+ }
88
+
89
+ /**
90
+ * If the last assistant message in the turn ended with `stopReason: "error"`
91
+ * (typically auto-retry exhausted on an overload / rate limit / server error),
92
+ * return its error info so the parent orchestrator can surface a clear
93
+ * failure instead of silently treating the run as completed.
94
+ *
95
+ * Returns `null` when the latest assistant turn completed normally or was
96
+ * aborted by the user (handled separately by shouldAutoExitOnAgentEnd).
97
+ */
98
+ export function findLatestAssistantError(
99
+ messages: any[] | undefined,
100
+ ): SubagentErrorInfo | null {
101
+ if (!messages) return null;
102
+ for (let i = messages.length - 1; i >= 0; i--) {
103
+ const msg = messages[i];
104
+ if (msg?.role !== "assistant") continue;
105
+ if (msg.stopReason !== "error") return null;
106
+ const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : "";
107
+ return {
108
+ errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).",
109
+ stopReason: "error",
110
+ };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ export function parseDeniedTools(rawValue: string | undefined): string[] {
116
+ return (rawValue ?? "")
117
+ .split(",")
118
+ .map((value) => value.trim())
119
+ .filter(Boolean);
120
+ }
121
+
122
+ /** 持久成员的轮次结束信号判定(纯函数):成员模式且本轮真正落定——
123
+ * 无待答问题(ask 等待的 park 不是轮次完成)、无在途孙代理(它们的
124
+ * 结果还没回来,提前宣布轮次完成会丢弃后续交付)。 */
125
+ export function shouldWriteRoundSignal(
126
+ memberMode: boolean,
127
+ awaitingAnswer: boolean,
128
+ pendingChildren: number,
129
+ ): boolean {
130
+ return memberMode && !awaitingAnswer && pendingChildren === 0;
131
+ }
132
+
133
+ export default function (pi: ExtensionAPI) {
134
+ let toolNames: string[] = [];
135
+ let denied: string[] = [];
136
+ let expanded = false;
137
+
138
+ // Read subagent identity from env vars (set by parent orchestrator)
139
+ const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
140
+ const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
141
+ const deniedToolsValue = process.env.PI_DENY_TOOLS;
142
+ const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
143
+ // A blocking parent tool cannot wait forever while an autonomous child parks
144
+ // for input: the parent's turn is still busy, so it cannot call
145
+ // subagent_message to answer. In barrier mode, ask_question therefore acts
146
+ // as a durable checkpoint: notify the parent, request a graceful exit, and
147
+ // let the parent resume this session with the answer. Interactive/demo panes
148
+ // keep the original live waiting behavior.
149
+ const barrierMode = process.env.PI_SUBAGENT_BARRIER === "1";
150
+ // 持久团队成员:常驻进程(无 barrier/无 auto-exit),轮次结束写 .round;
151
+ // 拥有受限的成员间直信工具 team_send(peer-send ACL)。
152
+ const memberMode = process.env.PI_SUBAGENT_MEMBER === "1";
153
+ const teamDir = process.env.PI_SUBAGENT_TEAM_DIR ?? "";
154
+ const peerSendAcl = parseDeniedTools(process.env.PI_SUBAGENT_PEER_SEND);
155
+ let memberRoundSeq = 0;
156
+ const recorder = createSubagentActivityRecorder({
157
+ runningChildId: process.env.PI_SUBAGENT_ID,
158
+ activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
159
+ });
160
+
161
+ function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
162
+ ctx.ui.setWidget(
163
+ "subagent-tools",
164
+ (_tui: any, theme: any) => {
165
+ const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
166
+
167
+ const label = subagentAgent || subagentName;
168
+ const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : "";
169
+
170
+ if (expanded) {
171
+ // Expanded: full tool list + denied
172
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
173
+ const hint = theme.fg("muted", " (Ctrl+Alt+O to collapse)");
174
+
175
+ const toolList = toolNames
176
+ .map((name: string) => theme.fg("dim", name))
177
+ .join(theme.fg("muted", ", "));
178
+
179
+ let deniedLine = "";
180
+ if (denied.length > 0) {
181
+ const deniedList = denied
182
+ .map((name: string) => theme.fg("error", name))
183
+ .join(theme.fg("muted", ", "));
184
+ deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
185
+ }
186
+
187
+ const content = new Text(
188
+ `${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
189
+ 0,
190
+ 0,
191
+ );
192
+ box.addChild(content);
193
+ } else {
194
+ // Collapsed: one-line summary
195
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
196
+ const deniedInfo =
197
+ denied.length > 0
198
+ ? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
199
+ : "";
200
+ const hint = theme.fg("muted", " (Ctrl+Alt+O to expand)");
201
+
202
+ const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
203
+ box.addChild(content);
204
+ }
205
+
206
+ return box;
207
+ },
208
+ { placement: "aboveEditor" },
209
+ );
210
+ }
211
+
212
+ let userTookOver = false;
213
+ let agentStarted = false;
214
+ // Set when ask_question is called; suppresses auto-exit so the session stays
215
+ // open while it waits for the orchestrator's reply. Cleared when the reply
216
+ // lands — on `input` (covers a reply steered into the current run) and on
217
+ // `agent_start` (covers a reply that starts a fresh turn after parking).
218
+ let awaitingAnswer = false;
219
+
220
+ // Show widget + status bar on session start
221
+ pi.on("session_start", (_event, ctx) => {
222
+ recorder.sessionStart();
223
+ const tools = pi.getAllTools();
224
+ toolNames = tools.map((t) => t.name).sort();
225
+ denied = parseDeniedTools(deniedToolsValue);
226
+
227
+ renderWidget(ctx, null);
228
+ });
229
+
230
+ pi.on("input", () => {
231
+ recorder.input();
232
+ // A submitted message is the orchestrator's (or a human's) reply — the
233
+ // pending ask_question has been answered, however it was delivered. Clear
234
+ // here, not only on agent_start, because a reply steered in *mid-run* is
235
+ // absorbed into the current run (pi's `steer` behavior injects it before
236
+ // the next LLM call): no new agent_start fires, so without this the flag
237
+ // would stay set and agent_end would park the session as `waiting` even
238
+ // though the answer already arrived and was consumed. (The `input` event
239
+ // fires for mid-run steers because prompt() emits it before queueing.)
240
+ awaitingAnswer = false;
241
+ // Ignore the initial task message that starts an autonomous subagent.
242
+ // Only inputs after the first agent run has started count as user takeover.
243
+ if (!shouldMarkUserTookOver(agentStarted)) return;
244
+ userTookOver = true;
245
+ });
246
+
247
+ pi.on("before_agent_start", () => {
248
+ recorder.beforeAgentStart();
249
+ });
250
+
251
+ pi.on("agent_start", () => {
252
+ agentStarted = true;
253
+ // A new turn is starting — any pending ask_question has now been answered
254
+ // (or superseded), so let auto-exit resume normally when this turn ends.
255
+ awaitingAnswer = false;
256
+ recorder.agentStart();
257
+ });
258
+
259
+ pi.on("agent_end", (event, ctx) => {
260
+ const messages = (event as any).messages as any[] | undefined;
261
+ // Never shut down while this session still has work in flight:
262
+ // - awaitingAnswer: an ask_question is pending the orchestrator's reply.
263
+ // - runningChildrenCount(): this subagent spawned its own children and is
264
+ // waiting for their results (delivered as steered turns). Exiting now
265
+ // would strand those children and drop their results.
266
+ // In both cases the session parks as `waiting` and resumes when the next
267
+ // turn lands.
268
+ const hasPendingChildren = runningChildrenCount() > 0;
269
+ const shouldExit =
270
+ !awaitingAnswer &&
271
+ !hasPendingChildren &&
272
+ autoExit &&
273
+ shouldAutoExitOnAgentEnd(userTookOver, messages);
274
+
275
+ if (shouldExit) {
276
+ // Surface stopReason: "error" turns (auto-retry exhausted, provider
277
+ // overload, etc.) to the parent via the .exit sidecar so the watcher
278
+ // can report a clear failure with the underlying error message.
279
+ // Without this the parent would only see exit code 0 and a stale
280
+ // assistant message, mistaking the crash for a successful completion.
281
+ const errorInfo = findLatestAssistantError(messages);
282
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
283
+ if (errorInfo && sessionFile) {
284
+ try {
285
+ writeSidecar(
286
+ `${sessionFile}.exit`,
287
+ JSON.stringify({
288
+ type: "error",
289
+ errorMessage: errorInfo.errorMessage,
290
+ stopReason: errorInfo.stopReason,
291
+ }),
292
+ );
293
+ } catch {
294
+ // Best effort — even without the sidecar, watcher's session-file
295
+ // fallback can still recover the errorMessage.
296
+ }
297
+ }
298
+
299
+ recorder.agentEndDone();
300
+ ctx.shutdown();
301
+ return;
302
+ }
303
+
304
+ recorder.agentEndWaiting();
305
+ // 持久成员:本轮真正落定(无待答问题、无在途孙代理)时写轮次结束
306
+ // 信号;父侧 round watcher 认领后恰好回注一次。"进程仍存活"是成员的
307
+ // 常态,轮次终态以 .round 为准。
308
+ if (
309
+ shouldWriteRoundSignal(memberMode, awaitingAnswer, runningChildrenCount()) &&
310
+ process.env.PI_SUBAGENT_SESSION
311
+ ) {
312
+ memberRoundSeq += 1;
313
+ try {
314
+ writeRoundSignal(process.env.PI_SUBAGENT_SESSION, {
315
+ name: subagentName || "member",
316
+ seq: memberRoundSeq,
317
+ at: Date.now(),
318
+ });
319
+ } catch {
320
+ // 信号写失败不阻断 park;父侧会在进程死亡时兑出降级结果。
321
+ }
322
+ }
323
+ if (autoExit) {
324
+ // Reset any recorded manual input marker. Auto-exit is decided by whether
325
+ // the latest agent turn completed normally, not by who initiated it.
326
+ userTookOver = false;
327
+ }
328
+ });
329
+
330
+ pi.on("turn_start", (event) => {
331
+ recorder.turnStart((event as any).turnIndex);
332
+ });
333
+
334
+ pi.on("turn_end", (event) => {
335
+ recorder.turnEnd((event as any).turnIndex);
336
+ });
337
+
338
+ pi.on("before_provider_request", () => {
339
+ recorder.beforeProviderRequest();
340
+ });
341
+
342
+ pi.on("after_provider_response", () => {
343
+ recorder.afterProviderResponse();
344
+ });
345
+
346
+ pi.on("message_update", (event) => {
347
+ recorder.messageUpdate((event as any).assistantMessageEvent?.type);
348
+ });
349
+
350
+ pi.on("tool_execution_start", (event) => {
351
+ recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName);
352
+ });
353
+
354
+ pi.on("tool_call", (event) => {
355
+ recorder.toolCall((event as any).toolCallId, (event as any).toolName);
356
+ });
357
+
358
+ pi.on("tool_execution_update", (event) => {
359
+ recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName);
360
+ });
361
+
362
+ pi.on("tool_result", (event) => {
363
+ recorder.toolResult((event as any).toolCallId, (event as any).toolName);
364
+ });
365
+
366
+ pi.on("tool_execution_end", (event) => {
367
+ recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName);
368
+ });
369
+
370
+ pi.on("session_shutdown", (event) => {
371
+ recorder.sessionShutdown((event as any).reason);
372
+ });
373
+
374
+ // Toggle expand/collapse with Ctrl+Alt+O
375
+ pi.registerShortcut("ctrl+alt+o", {
376
+ description: "Toggle subagent tools widget",
377
+ handler: (ctx) => {
378
+ expanded = !expanded;
379
+ renderWidget(ctx, null);
380
+ },
381
+ });
382
+
383
+ const questionLifecycleHint = barrierMode
384
+ ? "In autonomous barrier mode your session checkpoints and exits after sending the question; the orchestrator resumes it with the answer. "
385
+ : "Your session stays open while you wait — the answer arrives as your next message, then you continue. ";
386
+
387
+ pi.registerTool({
388
+ name: "ask_question",
389
+ label: "ask_question",
390
+ description:
391
+ "Ask the orchestrator (the parent agent that spawned you) a single question. " +
392
+ "Use this when requirements are ambiguous, a decision would materially affect your work, you're blocked, " +
393
+ "or you need information or confirmation only the orchestrator has. Prefer asking over guessing. " +
394
+ questionLifecycleHint +
395
+ "Ask exactly one question per call; make separate calls for unrelated questions.",
396
+ promptSnippet:
397
+ "Use this tool to ask the orchestrator one clarifying, missing-requirement, preference, or decision question before continuing — instead of guessing.",
398
+ promptGuidelines: [
399
+ "Ask exactly one question per tool call.",
400
+ "If you need answers to multiple things, make separate ask_question calls instead of bundling them.",
401
+ "Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.",
402
+ "Use it when multiple valid paths exist and the right one depends on the orchestrator's intent.",
403
+ "Give enough context in the question that the orchestrator can answer without re-reading your whole task.",
404
+ "After asking, stop and wait — the reply will arrive as your next message.",
405
+ ],
406
+ parameters: Type.Object({
407
+ question: Type.String({
408
+ description:
409
+ "The single freeform question to ask the orchestrator. Include enough context to answer it directly.",
410
+ }),
411
+ }),
412
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
413
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
414
+ if (!sessionFile) {
415
+ throw new Error(
416
+ "ask_question is only available in subagent contexts. " +
417
+ "PI_SUBAGENT_SESSION environment variable is not set.",
418
+ );
419
+ }
420
+
421
+ // Interactive/demo panes can remain live while the parent answers. An
422
+ // autonomous child is awaited by a blocking parent tool, so parking it
423
+ // would deadlock that parent; barrier mode turns the question into a
424
+ // checkpoint that is resumed by subagent_message after this process exits.
425
+ const checkpoint = autoExit && barrierMode;
426
+ awaitingAnswer = !checkpoint;
427
+ recorder.askQuestion();
428
+ const askData = {
429
+ name: process.env.PI_SUBAGENT_NAME ?? "subagent",
430
+ agent: process.env.PI_SUBAGENT_AGENT ?? "",
431
+ question: params.question,
432
+ };
433
+ writeSidecar(`${sessionFile}.ask`, JSON.stringify(askData));
434
+
435
+ if (checkpoint) {
436
+ // ctx.shutdown() is graceful: the current tool result and session
437
+ // entries are flushed, then subagent-done's agent_end handler permits
438
+ // the auto-exit. The parent receives the .ask notification and can
439
+ // resume this durable session with the answer once this tool settles.
440
+ //
441
+ // 但绝不能在本会话自身仍有在途孙代理时 shutdown——那会把孙代理一并
442
+ // 终止并丢失它们的结果。此时采用延迟退出:.ask 已写入、父侧收到通知;
443
+ // agent_end 的 runningChildrenCount 抑制自动退出,会话停留 waiting,
444
+ // 孙代理全部交付后的最终 agent_end 自然 checkpoint 退出(此时父侧
445
+ // 无论 steer 还是 resume 都能送达答案)。
446
+ const pendingChildren = runningChildrenCount();
447
+ if (pendingChildren > 0) {
448
+ return {
449
+ content: [{
450
+ type: "text",
451
+ text:
452
+ `Question sent to the orchestrator. This run still has ${pendingChildren} sub-agent(s) in flight; ` +
453
+ `the session stays alive until they finish and will only then checkpoint and exit. ` +
454
+ `The orchestrator has the question and will answer via subagent_message — keep waiting ` +
455
+ `for your sub-agents and do not assume the question is answered yet.`,
456
+ }],
457
+ details: {
458
+ question: params.question,
459
+ checkpoint: true,
460
+ deferredExit: true,
461
+ pendingChildren,
462
+ },
463
+ };
464
+ }
465
+ _ctx.shutdown();
466
+ return {
467
+ content: [{
468
+ type: "text",
469
+ text:
470
+ "Question sent to the orchestrator. This autonomous run is checkpointing and will exit; " +
471
+ "the orchestrator should resume this session with the answer via subagent_message.",
472
+ }],
473
+ details: { question: params.question, checkpoint: true },
474
+ };
475
+ }
476
+
477
+ return {
478
+ content: [
479
+ {
480
+ type: "text",
481
+ text:
482
+ "Question sent to the orchestrator. Stop here and wait — do not continue working or " +
483
+ "assume an answer. Their reply will arrive as your next message.",
484
+ },
485
+ ],
486
+ details: { question: params.question },
487
+ };
488
+ },
489
+
490
+ renderCall(args, theme) {
491
+ const text =
492
+ theme.fg("toolTitle", theme.bold("ask_question ")) +
493
+ theme.fg("muted", String((args as any).question ?? ""));
494
+ return new Text(text, 0, 0);
495
+ },
496
+ });
497
+
498
+ // ── 持久成员间直信(仅 member 模式注册)──────────────────────────
499
+ // fire-and-forget:写入目标成员 mailbox 后立即返回;父侧 round watcher
500
+ // 认领并经 RPC steer 注入。无阻塞 request/response——不存在互等死锁。
501
+ // ACL:仅 profile 的 peer-send 列出的成员名可投递;发送方身份取自父
502
+ // 进程注入的 env(成员无法冒名——它只能用自己的 PI_SUBAGENT_NAME)。
503
+ if (memberMode) {
504
+ pi.registerTool({
505
+ name: "team_send",
506
+ label: "team_send",
507
+ description:
508
+ "Send a FIRE-AND-FORGET note to another persistent team member (one-way, never blocking, no reply channel). " +
509
+ "Allowed targets are limited by this member's profile `peer-send` ACL; offline or unknown targets are refused. " +
510
+ "Delivery is best-effort: success only means the message was queued to the target's mailbox and will be injected " +
511
+ "into its running process — do not wait for a reply and do not assume it was read. If you need a result, ask the " +
512
+ "orchestrator to dispatch a round instead.",
513
+ parameters: Type.Object({
514
+ to: Type.String({ description: "Exact display name of the target team member (must be in this profile's peer-send ACL)." }),
515
+ text: Type.String({ description: "The note text. Keep it short and self-contained; it arrives prefixed with your member name." }),
516
+ }),
517
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
518
+ if (!teamDir) {
519
+ throw new Error("team_send requires PI_SUBAGENT_TEAM_DIR (member spawn must set it).");
520
+ }
521
+ const to = normalizeSubagentName(typeof params?.to === "string" ? params.to : "", "");
522
+ const text = typeof params?.text === "string" ? params.text : "";
523
+ if (!to) {
524
+ return { content: [{ type: "text", text: "Provide the target member's `name`." }], details: { error: "missing-target" } };
525
+ }
526
+ // 空白校验用 trim,但投递保留原文(fire-and-forget 忠实传递)。
527
+ if (!text.trim()) {
528
+ return { content: [{ type: "text", text: "Provide a non-empty `text`." }], details: { error: "missing-text", to } };
529
+ }
530
+ if (peerSendAcl.length === 0 || !peerSendAcl.includes(to)) {
531
+ return {
532
+ content: [{
533
+ type: "text",
534
+ text:
535
+ `Not allowed: "${to}" is not in this profile's peer-send ACL` +
536
+ `${peerSendAcl.length > 0 ? ` (allowed: ${peerSendAcl.join(", ")})` : " (no peer-send declared)"}. ` +
537
+ `Ask the orchestrator to relay the information instead.`,
538
+ }],
539
+ details: { error: "acl-denied", to },
540
+ };
541
+ }
542
+ // 目标状态核验(成员只读 roster):offline/未知目标明确拒绝,不伪称在线。
543
+ const target = readRoster(rosterPath(teamDir)).find(
544
+ (entry) => entry.name === to,
545
+ );
546
+ if (!target) {
547
+ return {
548
+ content: [{ type: "text", text: `No team member named "${to}" is registered in this session's roster.` }],
549
+ details: { error: "unknown-target", to },
550
+ };
551
+ }
552
+ if (target.status === "offline") {
553
+ return {
554
+ content: [{
555
+ type: "text",
556
+ text:
557
+ `Team member "${to}" is OFFLINE (${target.offlineReason ?? "unknown"}). The message was NOT delivered — ` +
558
+ `tell the orchestrator to restart it if this matters.`,
559
+ }],
560
+ details: { error: "target-offline", to },
561
+ };
562
+ }
563
+ postTeamMessage(teamDir, {
564
+ from: subagentName || "member",
565
+ to,
566
+ text,
567
+ sentAt: Date.now(),
568
+ });
569
+ return {
570
+ content: [{
571
+ type: "text",
572
+ text:
573
+ `Note queued to "${to}" (fire-and-forget). Do NOT wait for or assume a reply — it arrives in the ` +
574
+ `target's conversation, not yours. Continue your own work.`,
575
+ }],
576
+ details: { to, queued: true },
577
+ };
578
+ },
579
+
580
+ renderCall(args, theme) {
581
+ const to = String((args as any).to ?? "?");
582
+ return new Text(
583
+ theme.fg("toolTitle", theme.bold("team_send → ")) + theme.fg("muted", to),
584
+ 0,
585
+ 0,
586
+ );
587
+ },
588
+ });
589
+ }
590
+ }