@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- 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
|
+
}
|