@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,1155 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
findLastAssistantOutcome,
|
|
6
|
+
getNewEntries,
|
|
7
|
+
getSessionId,
|
|
8
|
+
readNameRegistry,
|
|
9
|
+
readSubagentLoadout,
|
|
10
|
+
registerName,
|
|
11
|
+
resolveNameInRegistry,
|
|
12
|
+
} from "./session.ts";
|
|
13
|
+
import { normalizeSubagentName } from "./names.ts";
|
|
14
|
+
import { resolveSurfaceChoice, type SubagentSurfaceChoice } from "./launch-config.ts";
|
|
15
|
+
import { peekExitSidecar } from "./surface.ts";
|
|
16
|
+
import { debugLog } from "./diagnostics.ts";
|
|
17
|
+
import { routeExceptionFromResult } from "./route-error.ts";
|
|
18
|
+
import {
|
|
19
|
+
announceCompletion,
|
|
20
|
+
dependencyExceptionResult,
|
|
21
|
+
exceptionKindForOutcome,
|
|
22
|
+
findDependencyCycle,
|
|
23
|
+
getCompletionRecord,
|
|
24
|
+
normalizeDependencyName,
|
|
25
|
+
settleCompletionFromResult,
|
|
26
|
+
waitForCompletion,
|
|
27
|
+
type CompletionRecord,
|
|
28
|
+
type DependencyException,
|
|
29
|
+
type DependencyOutcome,
|
|
30
|
+
} from "./dependencies.ts";
|
|
31
|
+
import { normalizeCohortId, SubagentParams, validateCohortId } from "./params.ts";
|
|
32
|
+
import { cleanupSubagentArtifacts, resolveRetentionDecision } from "./retention.ts";
|
|
33
|
+
import { markRuntimeWaitReleased } from "./runtime-registry.ts";
|
|
34
|
+
import type { RunningSubagent, SubagentResult } from "./types.ts";
|
|
35
|
+
|
|
36
|
+
/** 等待解除原因:Escape 中止或 timeoutMs 超时——都是“只解除工具等待,
|
|
37
|
+
* 不动子代理”的非终态(超时即程序触发的 detach)。 */
|
|
38
|
+
export type WaitRelease = { kind: "escape" } | { kind: "timeout" };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 硬屏障等待的三向竞速:watcher 终态 / 宿主 signal 中止(Escape)/ 可选
|
|
42
|
+
* timeoutMs 超时。竞速落定即清理定时器与监听器,不留悬挂的定时器;
|
|
43
|
+
* watcher reject 兑底为失败结果(不丢错误)。signal 与 timeout 同时存在
|
|
44
|
+
* 时先到者胜。未传 signal 且未传 timeout 时直接等待(现有无限等待)。
|
|
45
|
+
*/
|
|
46
|
+
export async function waitForSubagentTerminal(params: {
|
|
47
|
+
watchPromise: Promise<SubagentResult>;
|
|
48
|
+
running: RunningSubagent;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}): Promise<SubagentResult | WaitRelease> {
|
|
52
|
+
const { watchPromise, running, signal, timeoutMs } = params;
|
|
53
|
+
const guarded = watchPromise.then(
|
|
54
|
+
(value) => value,
|
|
55
|
+
(error) => syntheticWatcherError(running, error),
|
|
56
|
+
);
|
|
57
|
+
if (!signal && timeoutMs == null) return guarded;
|
|
58
|
+
return new Promise<SubagentResult | WaitRelease>((resolve) => {
|
|
59
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
60
|
+
let onAbort: (() => void) | null = null;
|
|
61
|
+
const settle = (value: SubagentResult | WaitRelease) => {
|
|
62
|
+
if (timer != null) clearTimeout(timer);
|
|
63
|
+
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
64
|
+
resolve(value);
|
|
65
|
+
};
|
|
66
|
+
if (signal) {
|
|
67
|
+
onAbort = () => settle({ kind: "escape" });
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
settle({ kind: "escape" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
73
|
+
}
|
|
74
|
+
if (timeoutMs != null) {
|
|
75
|
+
timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
|
|
76
|
+
}
|
|
77
|
+
guarded.then((value) => settle(value));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** timeoutMs 参数校验(信任边界:模型传参);返回错误文案或 null。 */
|
|
82
|
+
export function validateTimeoutMs(timeoutMs: unknown): string | null {
|
|
83
|
+
if (timeoutMs == null) return null;
|
|
84
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 1000) {
|
|
85
|
+
return `Invalid timeoutMs: must be an integer >= 1000 (milliseconds); got ${String(timeoutMs)}.`;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface SpawnContext {
|
|
91
|
+
sessionManager: {
|
|
92
|
+
getSessionFile(): string | null | undefined;
|
|
93
|
+
getSessionId(): string;
|
|
94
|
+
getSessionDir(): string;
|
|
95
|
+
};
|
|
96
|
+
cwd: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SubagentToolDeps {
|
|
100
|
+
allowlist: Set<string> | null;
|
|
101
|
+
discoverAgents: () => Array<{ name: string }>;
|
|
102
|
+
isMuxAvailable: () => boolean;
|
|
103
|
+
muxUnavailableResult: () => any;
|
|
104
|
+
/** 表面选择(auto/background/pane):pane 需要 mux,headless 不需要。 */
|
|
105
|
+
resolveSurfaceChoice: (params: { surface?: string; agent?: string }) =>
|
|
106
|
+
{ choice: SubagentSurfaceChoice } | { error: string };
|
|
107
|
+
getArtifactDir: (sessionDir: string, sessionId: string) => string;
|
|
108
|
+
runningSubagents: Map<string, RunningSubagent>;
|
|
109
|
+
reservedNames: Set<string>;
|
|
110
|
+
/** 该 spawn 是否交互式(演示 pane);完成注册表用它拒绝交互式依赖提供者。
|
|
111
|
+
* 可选:旧测试夹具缺省时按非交互式处理。 */
|
|
112
|
+
resolveInteractive?: (params: { agent?: string }) => boolean;
|
|
113
|
+
uniqueRunningName: (base: string, registryNames?: Set<string>) => string;
|
|
114
|
+
launchSubagent: (params: typeof SubagentParams.static, ctx: SpawnContext) => Promise<RunningSubagent>;
|
|
115
|
+
startWidgetRefresh: () => void;
|
|
116
|
+
startStatusRefresh: (pi: ExtensionAPI) => void;
|
|
117
|
+
watchSubagent: (running: RunningSubagent, signal: AbortSignal) => Promise<SubagentResult>;
|
|
118
|
+
/** 持久成员的轮次 watcher(fire-and-forget:.round 消费/回注、offline 检测、mailbox 注入)。 */
|
|
119
|
+
watchMemberRound: (running: RunningSubagent, signal: AbortSignal) => void | Promise<void>;
|
|
120
|
+
/** 同名重建判定:member spawn 复用 offline 成员名字(roster 状态为准)。可选:
|
|
121
|
+
* 旧测试夹具缺省时按不可复用处理。 */
|
|
122
|
+
canReuseMemberName?: (name: string, artifactDir: string) => boolean;
|
|
123
|
+
updateWidget: () => void;
|
|
124
|
+
resolveResultPresentation: (
|
|
125
|
+
result: SubagentResult,
|
|
126
|
+
name: string,
|
|
127
|
+
options?: { sessionPreserved?: boolean },
|
|
128
|
+
) => string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** watcher 异常 reject 时的合成失败结果(正常不 reject,兑底保护不丢错误)。 */
|
|
132
|
+
function syntheticWatcherError(running: RunningSubagent, error: unknown): SubagentResult {
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
|
+
return {
|
|
135
|
+
name: running.name,
|
|
136
|
+
task: running.task,
|
|
137
|
+
summary: `Subagent error: ${message}`,
|
|
138
|
+
exitCode: 1,
|
|
139
|
+
elapsed: Math.floor((Date.now() - running.startTime) / 1000),
|
|
140
|
+
errorMessage: message,
|
|
141
|
+
sessionFile: running.sessionFile,
|
|
142
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* retention 决策 + 清理执行:正常硬屏障返回路径与 detached 迟到回注共用,
|
|
148
|
+
* 保证两条路径的保留语义不漂移。删除发生在结果定型之后,失败只 debug。
|
|
149
|
+
*/
|
|
150
|
+
function applyRetentionToRunning(
|
|
151
|
+
running: RunningSubagent,
|
|
152
|
+
policy: "auto" | "preserve" | "discard",
|
|
153
|
+
outcome: { cancelled: boolean; failed: boolean; handedOff: boolean },
|
|
154
|
+
): Record<string, unknown> {
|
|
155
|
+
const decision = resolveRetentionDecision({
|
|
156
|
+
policy,
|
|
157
|
+
interactive: running.interactive,
|
|
158
|
+
kind: running.kind,
|
|
159
|
+
...outcome,
|
|
160
|
+
});
|
|
161
|
+
if (decision.action === "delete") {
|
|
162
|
+
const cleanup = cleanupSubagentArtifacts({
|
|
163
|
+
sessionFile: running.sessionFile,
|
|
164
|
+
...(running.activityFile ? { activityFile: running.activityFile } : {}),
|
|
165
|
+
...(running.contextFiles?.length ? { contextFiles: running.contextFiles } : {}),
|
|
166
|
+
});
|
|
167
|
+
return cleanup.skipped.length > 0
|
|
168
|
+
? { policy, outcome: "preserved", reason: "pending-ask" }
|
|
169
|
+
: {
|
|
170
|
+
policy,
|
|
171
|
+
outcome: "cleaned",
|
|
172
|
+
reason: decision.reason,
|
|
173
|
+
...(cleanup.removed.length ? { removed: cleanup.removed } : {}),
|
|
174
|
+
...(cleanup.errors.length ? { errors: cleanup.errors } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { policy, outcome: "preserved", reason: decision.reason };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* detached(Escape 中止等待)后的迟到回注:watcher 到达真实终态时 settle
|
|
182
|
+
* 依赖记录并经 steer 消息把真实结果回注恰好一次。
|
|
183
|
+
*
|
|
184
|
+
* 幂等保证:watcher promise 单次 resolve → 回调至多触发一次;本注册仅在
|
|
185
|
+
* detached 分支发生(正常路径的结果由工具调用直接返回,不再注册);
|
|
186
|
+
* handedOff(再次 /reload 或宿主会话关闭移交)跳过回注,由恢复路径接管,
|
|
187
|
+
* 不伪造、不重复。依赖 completion 不提前 settle——detached 时上游仍在
|
|
188
|
+
* 运行,dependsOn 消费者继续等待真实终态。
|
|
189
|
+
*/
|
|
190
|
+
function registerDetachedLateDelivery(
|
|
191
|
+
pi: ExtensionAPI,
|
|
192
|
+
deps: SubagentToolDeps,
|
|
193
|
+
running: RunningSubagent,
|
|
194
|
+
watchPromise: Promise<SubagentResult>,
|
|
195
|
+
retentionPolicy: "auto" | "preserve" | "discard",
|
|
196
|
+
): void {
|
|
197
|
+
watchPromise
|
|
198
|
+
.then((late) => {
|
|
199
|
+
deps.updateWidget();
|
|
200
|
+
// 真实终态才 settle:user_closed/显式停止按取消语义,不误报 failed。
|
|
201
|
+
settleCompletionFromResult(running.name, late, {
|
|
202
|
+
...(late.userClosed || late.stopped ? { status: "cancelled" as const } : {}),
|
|
203
|
+
});
|
|
204
|
+
// 移交(再次 /reload 或宿主会话关闭):恢复路径接管,不在此回注。
|
|
205
|
+
if (late.handedOff) return;
|
|
206
|
+
const failed = late.exitCode !== 0 || !!late.errorMessage;
|
|
207
|
+
const retentionDetails = applyRetentionToRunning(running, retentionPolicy, {
|
|
208
|
+
cancelled: !!late.userClosed || !!late.stopped,
|
|
209
|
+
failed,
|
|
210
|
+
handedOff: false,
|
|
211
|
+
});
|
|
212
|
+
const presentation = late.stopped
|
|
213
|
+
? `Sub-agent "${running.name}" was stopped explicitly (subagent_stop) after the wait had been detached — ` +
|
|
214
|
+
`no result was produced. Its session is preserved; resume it with subagent_message if the task still matters.`
|
|
215
|
+
: deps.resolveResultPresentation(late, running.name, {
|
|
216
|
+
sessionPreserved: retentionDetails.outcome !== "cleaned",
|
|
217
|
+
});
|
|
218
|
+
const routeException = late.userClosed || late.stopped
|
|
219
|
+
? undefined
|
|
220
|
+
: routeExceptionFromResult(late, running.model);
|
|
221
|
+
pi.sendMessage(
|
|
222
|
+
{
|
|
223
|
+
customType: "subagent_result",
|
|
224
|
+
content: presentation,
|
|
225
|
+
display: true,
|
|
226
|
+
details: {
|
|
227
|
+
name: running.name,
|
|
228
|
+
task: running.task,
|
|
229
|
+
agent: running.agent,
|
|
230
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
231
|
+
exitCode: late.exitCode,
|
|
232
|
+
elapsed: late.elapsed,
|
|
233
|
+
sessionFile: late.sessionFile,
|
|
234
|
+
status: late.userClosed ? "user_closed" : late.stopped ? "cancelled" : failed ? "failed" : "completed",
|
|
235
|
+
lateDelivery: "detached",
|
|
236
|
+
retention: retentionDetails,
|
|
237
|
+
...(late.userClosed ? { userClosed: true } : {}),
|
|
238
|
+
...(late.sessionId ? { sessionId: late.sessionId } : {}),
|
|
239
|
+
...(late.errorMessage ? { errorMessage: late.errorMessage } : {}),
|
|
240
|
+
...(late.stats ? { stats: late.stats } : {}),
|
|
241
|
+
...(routeException ? { routeException } : {}),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
245
|
+
);
|
|
246
|
+
})
|
|
247
|
+
.catch((error: any) => {
|
|
248
|
+
deps.updateWidget();
|
|
249
|
+
// watcher reject(正常不发生):兑底为失败终态,唤醒所有等待者并回注一次。
|
|
250
|
+
settleCompletionFromResult(running.name, {
|
|
251
|
+
exitCode: 1,
|
|
252
|
+
errorMessage: error?.message ?? String(error),
|
|
253
|
+
sessionFile: running.sessionFile,
|
|
254
|
+
});
|
|
255
|
+
pi.sendMessage(
|
|
256
|
+
{
|
|
257
|
+
customType: "subagent_result",
|
|
258
|
+
content: `Sub-agent "${running.name}" error after detached wait: ${error?.message ?? String(error)}`,
|
|
259
|
+
display: true,
|
|
260
|
+
details: {
|
|
261
|
+
name: running.name,
|
|
262
|
+
task: running.task,
|
|
263
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
264
|
+
error: error?.message,
|
|
265
|
+
lateDelivery: "detached",
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── dependsOn v1:launch 前的依赖解析与等待 ─────────────────────────────
|
|
274
|
+
// 上游结果摘要拼进下游 task 时的单条截断上限:够模型获知结论,不把 task
|
|
275
|
+
// 撑成上下文炸弹。
|
|
276
|
+
const UPSTREAM_SUMMARY_LIMIT = 1600;
|
|
277
|
+
|
|
278
|
+
function buildUpstreamAppendix(outcomes: DependencyOutcome[]): string {
|
|
279
|
+
const blocks = outcomes.map((outcome) => {
|
|
280
|
+
const raw = (outcome.summary ?? "").trim();
|
|
281
|
+
const summary =
|
|
282
|
+
raw.length > UPSTREAM_SUMMARY_LIMIT
|
|
283
|
+
? raw.slice(0, UPSTREAM_SUMMARY_LIMIT) + "…(truncated)"
|
|
284
|
+
: raw || "(no summary text captured)";
|
|
285
|
+
return `Upstream subagent "${outcome.name}" completed:\n${summary}`;
|
|
286
|
+
});
|
|
287
|
+
return `[Upstream dependency results — context only, not your task]\n${blocks.join("\n\n")}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type DependsOnResolution =
|
|
291
|
+
| { ok: true; appendix: string }
|
|
292
|
+
| { ok: false; appendix: ""; exception: DependencyException };
|
|
293
|
+
|
|
294
|
+
function blockedResolution(exception: DependencyException): DependsOnResolution {
|
|
295
|
+
return { ok: false, appendix: "", exception };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 把从会话文件解析出的终态缓存进注册表:同一消息的后续消费者直接命中,
|
|
299
|
+
* 不重读磁盘(.exit sidecar 首读即被消费,缓存也避免二次读取结果漂移)。 */
|
|
300
|
+
function cacheDiskOutcome(dep: string, outcome: DependencyOutcome): void {
|
|
301
|
+
if (!getCompletionRecord(dep)) announceCompletion(dep).settle(outcome);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 在真正 launch 前解析并等待 dependsOn(逐条串行,短路返回第一个异常):
|
|
306
|
+
* 1. pending/settled 完成记录(同会话,含同一 assistant message 的 sibling)
|
|
307
|
+
* → 等待其 completion promise(支持 AbortSignal);等待前登记 waitingOn
|
|
308
|
+
* 边并做环检测——同消息 sibling 互相 dependsOn 会永久互等,必须结构化失败;
|
|
309
|
+
* 2. 本进程运行中但无完成记录(reload 恢复边界)→ 明确 fail-fast,不无限挂;
|
|
310
|
+
* 3. 跨 turn 已完成 → 从会话名字注册表 + 会话文件判定终态(.exit sidecar
|
|
311
|
+
* 只读不消费:失败结果同时缓存进注册表,后续判定不依赖文件存活);
|
|
312
|
+
* 4. 找不到 → unknown_dependency。
|
|
313
|
+
* 自身依赖、空名、交互式提供者一律拒绝,避免死锁。不猜测批次分母。
|
|
314
|
+
*/
|
|
315
|
+
export async function resolveDependsOn(params: {
|
|
316
|
+
ownName: string;
|
|
317
|
+
dependsOn: string[];
|
|
318
|
+
artifactDir: string;
|
|
319
|
+
runningSubagents: Map<string, RunningSubagent>;
|
|
320
|
+
signal?: AbortSignal;
|
|
321
|
+
ownRecord?: CompletionRecord;
|
|
322
|
+
}): Promise<DependsOnResolution> {
|
|
323
|
+
const { ownName, dependsOn, artifactDir, runningSubagents, signal } = params;
|
|
324
|
+
const completed: DependencyOutcome[] = [];
|
|
325
|
+
const seen = new Set<string>();
|
|
326
|
+
|
|
327
|
+
for (const rawEntry of dependsOn) {
|
|
328
|
+
const dep = normalizeDependencyName(rawEntry ?? "");
|
|
329
|
+
if (!dep) {
|
|
330
|
+
return blockedResolution({
|
|
331
|
+
kind: "unknown_dependency",
|
|
332
|
+
dependency: "",
|
|
333
|
+
message: "Invalid dependsOn entry: dependency names must be non-empty.",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (dep === ownName) {
|
|
337
|
+
return blockedResolution({
|
|
338
|
+
kind: "self_dependency",
|
|
339
|
+
dependency: dep,
|
|
340
|
+
message: `Subagent "${ownName}" cannot depend on itself — that would deadlock the launch. Remove it from dependsOn.`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (seen.has(dep)) continue;
|
|
344
|
+
seen.add(dep);
|
|
345
|
+
|
|
346
|
+
const record = getCompletionRecord(dep);
|
|
347
|
+
if (record) {
|
|
348
|
+
// 交互式(演示 pane)子代理与持久团队成员都没有可等待的硬屏障终态:
|
|
349
|
+
// pending 时直接拒绝,不排队等一个语义不成立的结果。
|
|
350
|
+
if (record.member) {
|
|
351
|
+
return blockedResolution({
|
|
352
|
+
kind: "member_dependency",
|
|
353
|
+
dependency: dep,
|
|
354
|
+
message:
|
|
355
|
+
`"${dep}" is a persistent team member (member: true) — members stay alive between rounds and have no process-level ` +
|
|
356
|
+
`terminal state, so they cannot be a dependsOn target. Consume each round's steer-delivered result instead, or drop the dependency.`,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (!record.settled && record.interactive) {
|
|
360
|
+
return blockedResolution({
|
|
361
|
+
kind: "interactive_dependency",
|
|
362
|
+
dependency: dep,
|
|
363
|
+
message:
|
|
364
|
+
`"${dep}" is an interactive (demo) subagent without a waitable terminal state, so it cannot be a dependsOn target. ` +
|
|
365
|
+
`Consume its steer-delivered result instead, or drop the dependency.`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// 环检测:登记本 spawn 的等待边后,从目标沿 pending 记录的 waitingOn
|
|
369
|
+
// 边回溯;能回到自己即同消息 sibling 互等——结构化失败,不进入等待。
|
|
370
|
+
params.ownRecord?.waitingOn.add(dep);
|
|
371
|
+
const cycle = findDependencyCycle(ownName, dep);
|
|
372
|
+
if (cycle) {
|
|
373
|
+
return blockedResolution({
|
|
374
|
+
kind: "dependency_cycle",
|
|
375
|
+
dependency: dep,
|
|
376
|
+
message:
|
|
377
|
+
`Dependency cycle detected: ${cycle.join(" → ")}. Sibling subagent calls in the same message cannot wait on ` +
|
|
378
|
+
`each other — nothing was launched for "${ownName}". Break the cycle by removing one dependsOn edge.`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
let outcome: DependencyOutcome;
|
|
382
|
+
try {
|
|
383
|
+
outcome = await waitForCompletion(dep, signal);
|
|
384
|
+
} catch (error: any) {
|
|
385
|
+
return blockedResolution({
|
|
386
|
+
kind: "aborted",
|
|
387
|
+
dependency: dep,
|
|
388
|
+
message: `${error?.message ?? String(error)}. Nothing was launched for "${ownName}".`,
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
params.ownRecord?.waitingOn.delete(dep);
|
|
392
|
+
}
|
|
393
|
+
if (outcome.status !== "completed") {
|
|
394
|
+
const reason: Record<string, string> = {
|
|
395
|
+
dependency_failed: `Upstream subagent "${dep}" failed, so this call was NOT launched. Decide whether to respawn the upstream subagent or continue without it — do not treat this task as started.`,
|
|
396
|
+
dependency_cancelled: `Upstream subagent "${dep}" was cancelled, so this call was NOT launched. Respawn or resume it first if this task still matters.`,
|
|
397
|
+
dependency_handed_off: `Upstream subagent "${dep}" was handed off to recovery after a host reload and has no waitable result, so this call was NOT launched.`,
|
|
398
|
+
};
|
|
399
|
+
return blockedResolution({
|
|
400
|
+
kind: exceptionKindForOutcome(outcome.status),
|
|
401
|
+
dependency: dep,
|
|
402
|
+
message: reason[exceptionKindForOutcome(outcome.status)],
|
|
403
|
+
...(outcome.exitCode !== undefined || outcome.errorMessage || outcome.sessionFile
|
|
404
|
+
? {
|
|
405
|
+
upstream: {
|
|
406
|
+
...(outcome.exitCode !== undefined ? { exitCode: outcome.exitCode } : {}),
|
|
407
|
+
...(outcome.errorMessage ? { errorMessage: outcome.errorMessage } : {}),
|
|
408
|
+
...(outcome.sessionFile ? { sessionFile: outcome.sessionFile } : {}),
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
: {}),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
completed.push(outcome);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 本进程运行中但无完成记录:典型是 /reload 恢复的子代理(watcher 早已
|
|
419
|
+
// 启动,注册表里没有它的完成 promise)。明确 fail-fast,绝不无限等。
|
|
420
|
+
const running = Array.from(runningSubagents.values()).find((candidate) => candidate.name === dep);
|
|
421
|
+
if (running) {
|
|
422
|
+
return blockedResolution({
|
|
423
|
+
kind: "running_without_waiter",
|
|
424
|
+
dependency: dep,
|
|
425
|
+
message:
|
|
426
|
+
`"${dep}" is running in this session but has no waitable completion record ` +
|
|
427
|
+
`(its watcher started outside this session's dependency registry, e.g. after a host reload). ` +
|
|
428
|
+
`Failing fast instead of waiting forever — wait for its result message or resume it explicitly, then retry this call.`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 跨 turn 已完成:从会话名字注册表 + 会话文件判定终态。
|
|
433
|
+
const entry = resolveNameInRegistry(artifactDir, dep);
|
|
434
|
+
if (!entry) {
|
|
435
|
+
return blockedResolution({
|
|
436
|
+
kind: "unknown_dependency",
|
|
437
|
+
dependency: dep,
|
|
438
|
+
message:
|
|
439
|
+
`No subagent named "${dep}" in this session — it is not running, not announced by a sibling call in this message, ` +
|
|
440
|
+
`and not in the session name registry. Check the spelling, spawn it first, or drop the dependency.`,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
if (!entry.sessionFile || !existsSync(entry.sessionFile)) {
|
|
444
|
+
return blockedResolution({
|
|
445
|
+
kind: "unknown_dependency",
|
|
446
|
+
dependency: dep,
|
|
447
|
+
message: `"${dep}" is registered but its session file is missing (${entry.sessionFile ?? "none"}); its outcome cannot be determined.`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const loadout = readSubagentLoadout(entry.sessionFile);
|
|
451
|
+
if (loadout?.member) {
|
|
452
|
+
return blockedResolution({
|
|
453
|
+
kind: "member_dependency",
|
|
454
|
+
dependency: dep,
|
|
455
|
+
message:
|
|
456
|
+
`"${dep}" is a persistent team member — members stay alive between rounds and have no process-level ` +
|
|
457
|
+
`terminal state, so they cannot be a dependsOn target. Consume each round's steer-delivered result instead.`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (loadout && loadout.autoExit === false) {
|
|
461
|
+
return blockedResolution({
|
|
462
|
+
kind: "interactive_dependency",
|
|
463
|
+
dependency: dep,
|
|
464
|
+
message:
|
|
465
|
+
`"${dep}" is an interactive (demo) subagent (auto-exit disabled) without a waitable terminal result, ` +
|
|
466
|
+
`so it cannot be a dependsOn target.`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const exit = peekExitSidecar(entry.sessionFile);
|
|
470
|
+
if (exit && (exit.exitCode !== 0 || exit.errorMessage)) {
|
|
471
|
+
const outcome: DependencyOutcome = {
|
|
472
|
+
name: dep,
|
|
473
|
+
status: "failed",
|
|
474
|
+
exitCode: exit.exitCode,
|
|
475
|
+
...(exit.errorMessage ? { errorMessage: exit.errorMessage } : {}),
|
|
476
|
+
sessionFile: entry.sessionFile,
|
|
477
|
+
source: "session-file",
|
|
478
|
+
};
|
|
479
|
+
cacheDiskOutcome(dep, outcome);
|
|
480
|
+
return blockedResolution({
|
|
481
|
+
kind: "dependency_failed",
|
|
482
|
+
dependency: dep,
|
|
483
|
+
message: `Upstream subagent "${dep}" failed in an earlier turn, so this call was NOT launched.`,
|
|
484
|
+
upstream: {
|
|
485
|
+
exitCode: exit.exitCode,
|
|
486
|
+
...(exit.errorMessage ? { errorMessage: exit.errorMessage } : {}),
|
|
487
|
+
sessionFile: entry.sessionFile,
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
let assistantOutcome: ReturnType<typeof findLastAssistantOutcome>;
|
|
492
|
+
try {
|
|
493
|
+
assistantOutcome = findLastAssistantOutcome(getNewEntries(entry.sessionFile, 0));
|
|
494
|
+
} catch {
|
|
495
|
+
assistantOutcome = { summary: null, errorMessage: null };
|
|
496
|
+
}
|
|
497
|
+
if (assistantOutcome.errorMessage) {
|
|
498
|
+
const outcome: DependencyOutcome = {
|
|
499
|
+
name: dep,
|
|
500
|
+
status: "failed",
|
|
501
|
+
...(assistantOutcome.summary ? { summary: assistantOutcome.summary } : {}),
|
|
502
|
+
exitCode: 1,
|
|
503
|
+
errorMessage: assistantOutcome.errorMessage,
|
|
504
|
+
sessionFile: entry.sessionFile,
|
|
505
|
+
source: "session-file",
|
|
506
|
+
};
|
|
507
|
+
cacheDiskOutcome(dep, outcome);
|
|
508
|
+
return blockedResolution({
|
|
509
|
+
kind: "dependency_failed",
|
|
510
|
+
dependency: dep,
|
|
511
|
+
message:
|
|
512
|
+
`Upstream subagent "${dep}" failed in an earlier turn (${assistantOutcome.errorMessage}), ` +
|
|
513
|
+
`so this call was NOT launched.`,
|
|
514
|
+
upstream: {
|
|
515
|
+
exitCode: 1,
|
|
516
|
+
errorMessage: assistantOutcome.errorMessage,
|
|
517
|
+
sessionFile: entry.sessionFile,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (assistantOutcome.summary == null) {
|
|
522
|
+
return blockedResolution({
|
|
523
|
+
kind: "unknown_dependency",
|
|
524
|
+
dependency: dep,
|
|
525
|
+
message:
|
|
526
|
+
`"${dep}" is registered but no final result could be read from its session file, so its outcome cannot be determined. ` +
|
|
527
|
+
`It may still be running under another host; do not depend on it.`,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
const outcome: DependencyOutcome = {
|
|
531
|
+
name: dep,
|
|
532
|
+
status: "completed",
|
|
533
|
+
summary: assistantOutcome.summary,
|
|
534
|
+
sessionFile: entry.sessionFile,
|
|
535
|
+
source: "session-file",
|
|
536
|
+
};
|
|
537
|
+
cacheDiskOutcome(dep, outcome);
|
|
538
|
+
completed.push(outcome);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return { ok: true, appendix: completed.length > 0 ? buildUpstreamAppendix(completed) : "" };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function registerSubagentTool(pi: ExtensionAPI, deps: SubagentToolDeps): void {
|
|
545
|
+
pi.registerTool({
|
|
546
|
+
name: "subagent",
|
|
547
|
+
label: "Subagent",
|
|
548
|
+
description:
|
|
549
|
+
"Spawn a sub-agent as an independent process. " +
|
|
550
|
+
"AUTONOMOUS SUBAGENTS (agents defined with auto-exit, the default for workers): by default they run HEADLESS — an independent background pi process with NO pane/tab — and the call BLOCKS until the sub-agent reaches a terminal state; its real result is returned directly as this tool's result, never a placeholder. " +
|
|
551
|
+
"Multiple subagent calls in the SAME assistant message run in PARALLEL, and the turn continues only after all of them have settled, so batch them freely and end your turn after spawning. " +
|
|
552
|
+
"VISIBLE DEMO SUBAGENTS (agents defined without auto-exit / interactive: true): the call returns immediately with only an acknowledgement; the sub-agent runs in a visible pane that belongs to the user, and the result is delivered later as a steer message that starts a new turn. Never block on a demo subagent. " +
|
|
553
|
+
"ESCAPE / ABORT SEMANTICS: aborting this tool call (e.g. the user presses Escape) does NOT cancel the sub-agent — the wait is only DETACHED. The call returns a `detached` non-terminal result; the sub-agent keeps running (its session, runtime registration and dependency record are all preserved), and its real result is delivered later as a steer message, exactly once. Do not respawn a detached sub-agent. The ONLY way to actually terminate a running sub-agent early is the subagent_stop tool. " +
|
|
554
|
+
"TIMEOUT (timeoutMs): optional wait bound in milliseconds (>= 1000) for the autonomous hard barrier. When it fires the call returns a structured non-terminal `timed-out` result — the sub-agent is NOT killed or cancelled, nothing is cleaned up and no summary is fabricated; it behaves exactly like an Escape-detach, and the real result arrives later as a single steer message. DependsOn consumers keep waiting for the real terminal state (the timeout covers the post-launch run wait only). Omit timeoutMs to wait indefinitely (existing behavior); refused for interactive agents. Siblings in the same message each return their own tool result — a timed-out call is not a batch summary. " +
|
|
555
|
+
"PERSISTENT TEAM MEMBERS (member: true): spawns a long-lived headless team member instead of a one-shot sub-agent. The call returns an immediate ack (no hard barrier); the member stays alive between rounds, receives follow-up rounds via team_dispatch, and each round's result is delivered once as a steer message. Members may fire-and-forget message each other via team_send along the profile's peer-send ACL. Refused combinations: surface 'pane', interactive agents, retention 'discard', timeoutMs, dependsOn. Members cannot be dependsOn targets. Terminate a member explicitly with subagent_stop; host shutdown or /reload terminates members and marks them offline (session preserved for explicit resume). " +
|
|
556
|
+
"surface parameter: 'auto' (default — headless for autonomous, visible pane for interactive), 'background' (force headless; refused for interactive agents), 'pane' (force a visible pane). " +
|
|
557
|
+
"RETENTION parameter: 'auto' (default) | 'preserve' | 'discard' — controls what happens to a headless autonomous sub-agent's session artifacts (session JSONL, .loadout.json, context task/system-prompt files, activity/runtime registration) AFTER this call has settled with the real result. 'auto': successful runs are cleaned up; failed/cancelled/handed-off runs are preserved so you can resume them with subagent_message. 'preserve': always keep everything. 'discard': always clean up after a terminal state, including failures — use when you know you will never resume. Visible pane / demo sub-agents are ALWAYS preserved regardless. Cleanup never affects the tool result." +
|
|
558
|
+
"tier parameter: 'fast' | 'balanced' | 'deep' preset resolved from the pi-subagents config (models.fast/balanced/deep; aliases quick/balance/standard/strong accepted). An explicit `model` always wins over `tier`; a tier without a configured model fails with a clear error — models are never silently swapped. " +
|
|
559
|
+
"DEPENDENCIES (dependsOn): pass `dependsOn: [names]` to make this call WAIT for those same-session subagents to reach a terminal state BEFORE launching — including siblings issued in the SAME assistant message (the dependent call waits on the upstream completion promise; independent siblings still run in parallel). " +
|
|
560
|
+
"When all dependencies completed, a short real-results context block is appended to `task`. If any dependency failed or was cancelled, this call does NOT launch and the tool result details carry a structured `dependencyException` (kind, dependency, upstream error) — no pane/process is created. " +
|
|
561
|
+
"Rejected without launching: this call's own name, unknown names (not running, not announced by a sibling, not in the session registry), running-without-waiter (e.g. after a host reload), and interactive demo subagents (no waitable terminal state). Dependencies finished in an earlier turn are resolved from the session registry. " +
|
|
562
|
+
"ROUTE FAILURES: when a sub-agent dies on quota/auth/rate-limit/model/context errors, the tool result details carry a structured `route_exception` (kind, retryable, provider/model, suggestedActions) for YOU to decide on — there is no automatic fallback, sibling subagents keep running, and you choose whether to respawn (same or different model) or report to the user. " +
|
|
563
|
+
"DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. If you need a deliberate one-time diagnostic snapshot, use subagent_inspect; do not call it repeatedly just to wait. " +
|
|
564
|
+
"DO NOT fabricate, assume, or summarize results you do not yet have. " +
|
|
565
|
+
"PANE SAFETY: you may freely create panes and read from or send keys to panes when the task requires it; but NEVER close or kill a pane you did not create yourself — before terminating any pane, verify it is yours (spawned by you in this session) or ask the user. Panes you did not create may belong to the user or other agents and may hold running work. " +
|
|
566
|
+
"After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness handles delivery for you.",
|
|
567
|
+
promptSnippet:
|
|
568
|
+
"Spawn a sub-agent. Autonomous subagents (auto-exit, the default) run headless in the background (no pane) and block this call until terminal state, returning their real result as the tool result; sibling calls in one message run in parallel and all settle before the turn continues. Demo subagents (interactive) run in a visible pane, return immediately, and deliver their result later as a steer message. Do not poll; do not fabricate results.",
|
|
569
|
+
parameters: SubagentParams,
|
|
570
|
+
|
|
571
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
572
|
+
const cohortError = validateCohortId(params.cohortId);
|
|
573
|
+
if (cohortError) {
|
|
574
|
+
return { content: [{ type: "text", text: cohortError }], details: { error: cohortError } };
|
|
575
|
+
}
|
|
576
|
+
const cohortId = normalizeCohortId(params.cohortId);
|
|
577
|
+
if (cohortId) params.cohortId = cohortId;
|
|
578
|
+
else delete params.cohortId;
|
|
579
|
+
|
|
580
|
+
const currentAgent = process.env.PI_SUBAGENT_AGENT;
|
|
581
|
+
if (params.agent && currentAgent && params.agent === currentAgent) {
|
|
582
|
+
return {
|
|
583
|
+
content: [{
|
|
584
|
+
type: "text",
|
|
585
|
+
text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`,
|
|
586
|
+
}],
|
|
587
|
+
details: { error: "self-spawn blocked" },
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const permittedAgents = deps.allowlist
|
|
592
|
+
? [...deps.allowlist]
|
|
593
|
+
: deps.discoverAgents().map((agent) => agent.name);
|
|
594
|
+
const permittedSet = new Set(permittedAgents);
|
|
595
|
+
const permittedList = permittedAgents.join(", ") || "(none)";
|
|
596
|
+
|
|
597
|
+
if (!params.agent) {
|
|
598
|
+
return {
|
|
599
|
+
content: [{
|
|
600
|
+
type: "text",
|
|
601
|
+
text: `You must specify which agent to spawn via the "agent" field. Available agents: ${permittedList}.`,
|
|
602
|
+
}],
|
|
603
|
+
details: { error: "agent required" },
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
if (!permittedSet.has(params.agent)) {
|
|
607
|
+
return {
|
|
608
|
+
content: [{
|
|
609
|
+
type: "text",
|
|
610
|
+
text:
|
|
611
|
+
`You may not spawn the "${params.agent}" agent — it is not ` +
|
|
612
|
+
`${deps.allowlist ? "in your allowlist" : "a known agent"}. ` +
|
|
613
|
+
`Available agents: ${permittedList}.`,
|
|
614
|
+
}],
|
|
615
|
+
details: { error: deps.allowlist ? "agent not in allowlist" : "unknown agent" },
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 表面选择:auto → headless(自动)/ pane(交互);background 强制 headless
|
|
620
|
+
// (交互拒绝);pane 强制可见 pane。仅 pane 需要复用器;headless 在无
|
|
621
|
+
// herdr/tmux 的环境也能运行。
|
|
622
|
+
const surfaceChoice = deps.resolveSurfaceChoice(params);
|
|
623
|
+
if ("error" in surfaceChoice) {
|
|
624
|
+
return {
|
|
625
|
+
content: [{ type: "text", text: surfaceChoice.error }],
|
|
626
|
+
details: { error: surfaceChoice.error },
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
// timeoutMs 校验与适用性检查(在创建任何 pane/进程之前):非法值直接
|
|
630
|
+
// 拒绝;交互式(演示)spawn 立即返回、没有可设上限的等待,显式拒绝
|
|
631
|
+
// 而不是静默忽略——避免调用方误以为有超时保护。
|
|
632
|
+
const timeoutError = validateTimeoutMs(params.timeoutMs);
|
|
633
|
+
if (timeoutError) {
|
|
634
|
+
return {
|
|
635
|
+
content: [{ type: "text", text: timeoutError }],
|
|
636
|
+
details: { error: timeoutError },
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const timeoutMs = params.timeoutMs ?? null;
|
|
640
|
+
if (timeoutMs != null && (deps.resolveInteractive?.(params) ?? false)) {
|
|
641
|
+
const error =
|
|
642
|
+
`timeoutMs does not apply to interactive (demo) sub-agents: their spawn returns immediately and ` +
|
|
643
|
+
`there is no blocking wait to bound. Drop timeoutMs for this spawn.`;
|
|
644
|
+
return { content: [{ type: "text", text: error }], details: { error } };
|
|
645
|
+
}
|
|
646
|
+
// ── 持久团队成员(member)约束(全部在创建任何进程之前拒绝)──
|
|
647
|
+
// member 是独立生命周期,与硬屏障/依赖/保留策略/超时/pane 语义互斥;
|
|
648
|
+
// 显式拒绝而非静默降级,不偷偷改变现有自动任务与演示 pane 的默认行为。
|
|
649
|
+
if (params.member) {
|
|
650
|
+
const interactiveAgent = deps.resolveInteractive?.(params) ?? false;
|
|
651
|
+
const violations: string[] = [];
|
|
652
|
+
if (params.surface === "pane") violations.push('surface "pane" (members are headless-only)');
|
|
653
|
+
if (interactiveAgent) violations.push("interactive (demo) agents cannot be members");
|
|
654
|
+
if (params.retention === "discard") violations.push('retention "discard" (member sessions are always preserved)');
|
|
655
|
+
if (params.timeoutMs != null) violations.push("timeoutMs (members are non-blocking; there is no wait to bound)");
|
|
656
|
+
if (params.dependsOn?.length) violations.push("dependsOn (members have no process-level terminal state to wait for)");
|
|
657
|
+
if (violations.length > 0) {
|
|
658
|
+
const error =
|
|
659
|
+
`member: true cannot be combined with: ${violations.join("; ")}. ` +
|
|
660
|
+
`Drop the conflicting options or spawn a regular one-shot sub-agent instead.`;
|
|
661
|
+
return { content: [{ type: "text", text: error }], details: { error, violations } };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (surfaceChoice.choice === "pane" && !deps.isMuxAvailable()) return deps.muxUnavailableResult();
|
|
665
|
+
if (!ctx.sessionManager.getSessionFile()) {
|
|
666
|
+
return {
|
|
667
|
+
content: [{
|
|
668
|
+
type: "text",
|
|
669
|
+
text: "Error: no session file. Start pi with a persistent session to use subagents.",
|
|
670
|
+
}],
|
|
671
|
+
details: { error: "no session file" },
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const parentArtifactDir = deps.getArtifactDir(
|
|
676
|
+
ctx.sessionManager.getSessionDir(),
|
|
677
|
+
ctx.sessionManager.getSessionId(),
|
|
678
|
+
);
|
|
679
|
+
let reservedName: string | null = null;
|
|
680
|
+
const suppliedName = params.name?.trim();
|
|
681
|
+
if (!suppliedName) {
|
|
682
|
+
const registryNames = new Set(Object.keys(readNameRegistry(parentArtifactDir)));
|
|
683
|
+
params.name = deps.uniqueRunningName(normalizeSubagentName(params.agent), registryNames);
|
|
684
|
+
reservedName = params.name;
|
|
685
|
+
deps.reservedNames.add(reservedName);
|
|
686
|
+
} else {
|
|
687
|
+
params.name = normalizeSubagentName(suppliedName);
|
|
688
|
+
const registeredEntry = resolveNameInRegistry(parentArtifactDir, params.name);
|
|
689
|
+
// offline 成员同名重建:member spawn 允许安全复用 roster 中 offline
|
|
690
|
+
// 成员的名字(覆盖 registry 登记);与 team_dispatch/subagent_stop 的
|
|
691
|
+
// 重启指引一致。其余场景(运行中/非 offline/非 member)照旧拒绝。
|
|
692
|
+
const memberNameReuse =
|
|
693
|
+
params.member === true &&
|
|
694
|
+
!!registeredEntry &&
|
|
695
|
+
(deps.canReuseMemberName?.(params.name, parentArtifactDir) ?? false);
|
|
696
|
+
const clash =
|
|
697
|
+
deps.reservedNames.has(params.name) ||
|
|
698
|
+
Array.from(deps.runningSubagents.values()).some((running) => running.name === params.name) ||
|
|
699
|
+
(Boolean(registeredEntry) && !memberNameReuse);
|
|
700
|
+
if (clash) {
|
|
701
|
+
const reuseHint = registeredEntry && !memberNameReuse
|
|
702
|
+
? params.member === true
|
|
703
|
+
? ` ("${params.name}" is not an offline team member in this session's roster; pick another name.)`
|
|
704
|
+
: ` (to restart the offline team member "${params.name}", spawn it with member: true.)`
|
|
705
|
+
: "";
|
|
706
|
+
const error = `Subagent name "${params.name}" is already in use in this session.${reuseHint} Pick another name.`;
|
|
707
|
+
return { content: [{ type: "text", text: error }], details: { error } };
|
|
708
|
+
}
|
|
709
|
+
reservedName = params.name;
|
|
710
|
+
deps.reservedNames.add(reservedName);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let running: RunningSubagent;
|
|
714
|
+
try {
|
|
715
|
+
// 名字已确定:启动前登记完成记录,让同一消息里后执行的 dependsOn
|
|
716
|
+
// 消费者能找到并等待本次 spawn 的终态。与保留名字同段同步执行,
|
|
717
|
+
// 不给“找不到依赖”留窗口。
|
|
718
|
+
const ownRecord = announceCompletion(params.name, {
|
|
719
|
+
interactive: deps.resolveInteractive?.(params) ?? false,
|
|
720
|
+
...(params.member ? { member: true } : {}),
|
|
721
|
+
});
|
|
722
|
+
// 真正 launch 之前解析并等待 dependsOn:依赖未到终态就不启动;失败/
|
|
723
|
+
// 取消/无法解析直接返回结构化异常,不创建 pane/进程。没有 dependsOn
|
|
724
|
+
// 时此步直通,旧行为不变。
|
|
725
|
+
if (params.dependsOn?.length) {
|
|
726
|
+
const resolution = await resolveDependsOn({
|
|
727
|
+
ownName: params.name,
|
|
728
|
+
dependsOn: params.dependsOn,
|
|
729
|
+
artifactDir: parentArtifactDir,
|
|
730
|
+
runningSubagents: deps.runningSubagents,
|
|
731
|
+
signal,
|
|
732
|
+
ownRecord,
|
|
733
|
+
});
|
|
734
|
+
if (!resolution.ok) {
|
|
735
|
+
// 本次 spawn 永不启动:完成记录必须落定,依赖本 spawn 的下游
|
|
736
|
+
// (如有)才能拿到明确失败而不是永久等待。
|
|
737
|
+
ownRecord.settle({
|
|
738
|
+
name: params.name,
|
|
739
|
+
status: "failed",
|
|
740
|
+
errorMessage: resolution.exception.message,
|
|
741
|
+
source: "dependency-blocked",
|
|
742
|
+
});
|
|
743
|
+
return dependencyExceptionResult(resolution.exception);
|
|
744
|
+
}
|
|
745
|
+
if (resolution.appendix) {
|
|
746
|
+
params.task = `${params.task}\n\n${resolution.appendix}`;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
running = await deps.launchSubagent(params, ctx);
|
|
750
|
+
// 工具层负责规范化入参;这里再写回运行态,兼容测试/宿主注入的
|
|
751
|
+
// launchSubagent 实现,确保后续 registry、inspect 和结果详情同源。
|
|
752
|
+
if (cohortId) running.cohortId = cohortId;
|
|
753
|
+
} catch (error: any) {
|
|
754
|
+
// launch 失败必须释放/拒绝:完成记录兑成失败终态,等待者不挂死;
|
|
755
|
+
// 名字保留照旧在 finally 释放,异常按原语义向宿主上抛。
|
|
756
|
+
settleCompletionFromResult(params.name, {
|
|
757
|
+
exitCode: 1,
|
|
758
|
+
errorMessage: error?.message ?? String(error),
|
|
759
|
+
}, { source: "launch-failure" });
|
|
760
|
+
throw error;
|
|
761
|
+
} finally {
|
|
762
|
+
if (reservedName) deps.reservedNames.delete(reservedName);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
registerName(parentArtifactDir, running.name, {
|
|
766
|
+
sessionFile: running.sessionFile,
|
|
767
|
+
sessionId: getSessionId(running.sessionFile),
|
|
768
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
769
|
+
...(running.anchoredLoadout ? { anchored: true } : {}),
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const watcherAbort = new AbortController();
|
|
773
|
+
running.abortController = watcherAbort;
|
|
774
|
+
deps.startWidgetRefresh();
|
|
775
|
+
deps.startStatusRefresh(pi);
|
|
776
|
+
|
|
777
|
+
// ── 持久团队成员(member):立即 ack,不走硬屏障。──
|
|
778
|
+
// 成员是常驻 headless 进程:首轮任务照常经 stdin 投递,轮次结束时
|
|
779
|
+
// 成员侧写 .round sidecar;父侧 round watcher 消费并恰好回注一次。
|
|
780
|
+
// 后续轮次经 team_dispatch;显式终止只能 subagent_stop。
|
|
781
|
+
if (running.member) {
|
|
782
|
+
running.dispatchedRound = true;
|
|
783
|
+
running.roundEntryBaseline = 0;
|
|
784
|
+
// fire-and-forget watcher:成员常驻,轮询循环永不到终态;异常必须
|
|
785
|
+
// 就地兑底(debug 日志),不能变成 unhandled rejection 杀掉宿主。
|
|
786
|
+
Promise.resolve(deps.watchMemberRound(running, watcherAbort.signal)).catch((error) => {
|
|
787
|
+
debugLog(`Team member round watcher failed for ${running.name}`, error);
|
|
788
|
+
});
|
|
789
|
+
return {
|
|
790
|
+
content: [{
|
|
791
|
+
type: "text",
|
|
792
|
+
text:
|
|
793
|
+
`Team member "${params.name}" launched and is now running its FIRST round in the background. ` +
|
|
794
|
+
`Do NOT generate or assume results — each round's real result is delivered as a steer message exactly once when it ends. ` +
|
|
795
|
+
`Dispatch follow-up rounds with team_dispatch({ name: "${params.name}", task: "…" }); ` +
|
|
796
|
+
`members can message each other via team_send when the profile's peer-send ACL allows it; ` +
|
|
797
|
+
`stop the member explicitly with subagent_stop.`,
|
|
798
|
+
}],
|
|
799
|
+
details: {
|
|
800
|
+
id: running.id,
|
|
801
|
+
name: params.name,
|
|
802
|
+
task: params.task,
|
|
803
|
+
agent: params.agent,
|
|
804
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
805
|
+
sessionFile: running.sessionFile,
|
|
806
|
+
status: "member-started",
|
|
807
|
+
member: true,
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── 可见演示子代理(interactive):保持异步立即返回,pane 归用户操作。──
|
|
813
|
+
// watcher 到达终态后经 steer 消息回注,不阻塞主 turn。
|
|
814
|
+
if (running.interactive) {
|
|
815
|
+
const interactiveWatch = deps.watchSubagent(running, watcherAbort.signal);
|
|
816
|
+
running.watchPromise = interactiveWatch;
|
|
817
|
+
interactiveWatch
|
|
818
|
+
.then((result) => {
|
|
819
|
+
deps.updateWidget();
|
|
820
|
+
// 终态(含 handed-off)落定完成记录:依赖本子代理的下游 spawn 才有
|
|
821
|
+
// 明确的等待结果。/reload 移交(handedOff)不是可用结果,但等待者
|
|
822
|
+
// 仍要被唤醒并拿到 handed-off 语义;用户直接关闭 pane(userClosed)
|
|
823
|
+
// 按取消语义 settle,不误报 failed。
|
|
824
|
+
settleCompletionFromResult(running.name, result, {
|
|
825
|
+
...(result.userClosed ? { status: "cancelled" as const } : {}),
|
|
826
|
+
});
|
|
827
|
+
// /reload 移交(handedOff):不是终态,恢复路径会回注真实结果;
|
|
828
|
+
// 这里回注会伪造/重复结果,直接跳过。
|
|
829
|
+
if (result.handedOff) return;
|
|
830
|
+
const presentation = deps.resolveResultPresentation(result, running.name);
|
|
831
|
+
const routeException = result.userClosed
|
|
832
|
+
? undefined
|
|
833
|
+
: routeExceptionFromResult(result, running.model);
|
|
834
|
+
pi.sendMessage(
|
|
835
|
+
{
|
|
836
|
+
customType: "subagent_result",
|
|
837
|
+
content: presentation,
|
|
838
|
+
display: true,
|
|
839
|
+
details: {
|
|
840
|
+
name: running.name,
|
|
841
|
+
task: running.task,
|
|
842
|
+
agent: running.agent,
|
|
843
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
844
|
+
exitCode: result.exitCode,
|
|
845
|
+
elapsed: result.elapsed,
|
|
846
|
+
sessionFile: result.sessionFile,
|
|
847
|
+
status: result.userClosed ? "user_closed" : result.exitCode !== 0 || result.errorMessage ? "failed" : "completed",
|
|
848
|
+
...(result.userClosed ? { userClosed: true } : {}),
|
|
849
|
+
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
|
850
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
851
|
+
...(result.stats ? { stats: result.stats } : {}),
|
|
852
|
+
...(routeException ? { routeException } : {}),
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
856
|
+
);
|
|
857
|
+
})
|
|
858
|
+
.catch((error) => {
|
|
859
|
+
deps.updateWidget();
|
|
860
|
+
settleCompletionFromResult(running.name, { exitCode: 1, errorMessage: error?.message ?? String(error) });
|
|
861
|
+
// watcher 正常不会 reject(内部已兑底为失败结果);万一 reject,
|
|
862
|
+
// 真实上报错误,不伪造成功结果。
|
|
863
|
+
pi.sendMessage(
|
|
864
|
+
{
|
|
865
|
+
customType: "subagent_result",
|
|
866
|
+
content: `Sub-agent "${running.name}" error: ${error?.message ?? String(error)}`,
|
|
867
|
+
display: true,
|
|
868
|
+
details: {
|
|
869
|
+
name: running.name,
|
|
870
|
+
task: running.task,
|
|
871
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
872
|
+
error: error?.message,
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
876
|
+
);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
return {
|
|
880
|
+
content: [{
|
|
881
|
+
type: "text",
|
|
882
|
+
text:
|
|
883
|
+
`Sub-agent "${params.name}" launched and is now running in the background. ` +
|
|
884
|
+
`Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
|
|
885
|
+
`The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
|
|
886
|
+
`Until then, move on to other work or tell the user you're waiting.`,
|
|
887
|
+
}],
|
|
888
|
+
details: {
|
|
889
|
+
id: running.id,
|
|
890
|
+
name: params.name,
|
|
891
|
+
task: params.task,
|
|
892
|
+
agent: params.agent,
|
|
893
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
894
|
+
sessionFile: running.sessionFile,
|
|
895
|
+
status: "started",
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ── 自动子代理(非 interactive):硬屏障。──
|
|
901
|
+
// 在本工具调用内等待子代理终态,把真实结果作为 tool result 返回。
|
|
902
|
+
// pi 对同一条 assistant 消息中的 sibling tool calls 默认并行执行
|
|
903
|
+
// (agent-core executeToolCallsParallel → Promise.all),且 turn_end 在
|
|
904
|
+
// 全部工具调用完成后才发出;因此本调用不 resolve,turn 就不结束——
|
|
905
|
+
// 同一 turn 内并行 spawn 的全部自动子代理都结束后,编排者才会带着
|
|
906
|
+
// 全部 tool results 继续下一步。无需批次聚合,也不产生额外唤醒。
|
|
907
|
+
//
|
|
908
|
+
// 中止语义(与子代理取消严格分离):宿主中止工具等待(Escape)或
|
|
909
|
+
// timeoutMs 超时都不转发到 watcher——子代理继续运行,进程/pane、
|
|
910
|
+
// runtime record、依赖 completion 全部保留,本调用立即返回非终态
|
|
911
|
+
// (detached/timed-out);真实结果由 watcher 终态时经迟到回注
|
|
912
|
+
// (registerDetachedLateDelivery)送达一次。watcher 自身 signal 的
|
|
913
|
+
// abort 只来自显式停止(subagent_stop),此时 watcher 兑 cancelled
|
|
914
|
+
// 终态并终止进程/关闭 pane,走正常返回路径。
|
|
915
|
+
const watchPromise = deps.watchSubagent(running, watcherAbort.signal);
|
|
916
|
+
running.watchPromise = watchPromise;
|
|
917
|
+
|
|
918
|
+
const raced = await waitForSubagentTerminal({
|
|
919
|
+
watchPromise,
|
|
920
|
+
running,
|
|
921
|
+
signal,
|
|
922
|
+
timeoutMs: timeoutMs ?? undefined,
|
|
923
|
+
});
|
|
924
|
+
if (typeof raced === "object" && "kind" in raced) {
|
|
925
|
+
running.waitReleased = raced.kind;
|
|
926
|
+
markRuntimeWaitReleased(running.runtimeFile, running.id, raced.kind);
|
|
927
|
+
// 主工具等待被解除(Escape 或超时):子代理仍在运行,返回非终态。
|
|
928
|
+
// 不 settle completion(上游仍在跑,dependsOn 等真实终态)、不清理
|
|
929
|
+
// retention、不伪造摘要。迟到回注与 handed-off 移交由同一机制幂等处理。
|
|
930
|
+
registerDetachedLateDelivery(pi, deps, running, watchPromise, params.retention ?? "auto");
|
|
931
|
+
if (raced.kind === "timeout") {
|
|
932
|
+
return {
|
|
933
|
+
content: [{
|
|
934
|
+
type: "text",
|
|
935
|
+
text:
|
|
936
|
+
`Sub-agent "${running.name}" wait timed out after ${Math.round(timeoutMs! / 1000)}s (timeoutMs) — this is NOT a result. ` +
|
|
937
|
+
`The sub-agent itself is STILL RUNNING and was NOT cancelled, and no summary exists yet — do not assume or fabricate one. ` +
|
|
938
|
+
`Its real result will be delivered as a steer message when it finishes, exactly once. ` +
|
|
939
|
+
`Do not respawn it. You can steer it meanwhile (subagent_message), stop it (subagent_stop), or simply wait. ` +
|
|
940
|
+
`Sibling calls in this message each return their own tool result; this is not a batch summary.`,
|
|
941
|
+
}],
|
|
942
|
+
details: {
|
|
943
|
+
id: running.id,
|
|
944
|
+
name: running.name,
|
|
945
|
+
task: running.task,
|
|
946
|
+
agent: running.agent,
|
|
947
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
948
|
+
sessionFile: running.sessionFile,
|
|
949
|
+
elapsed: Math.floor((Date.now() - running.startTime) / 1000),
|
|
950
|
+
timeoutMs,
|
|
951
|
+
status: "timed-out",
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
content: [{
|
|
957
|
+
type: "text",
|
|
958
|
+
text:
|
|
959
|
+
`Sub-agent "${running.name}" was DETACHED: your tool-call wait was aborted (Escape), but the sub-agent ` +
|
|
960
|
+
`itself is STILL RUNNING and was NOT cancelled — no result exists yet, so do not assume or fabricate one. ` +
|
|
961
|
+
`Its real result will be delivered as a steer message when it finishes, exactly once. ` +
|
|
962
|
+
`Do not respawn it. You can still steer it meanwhile (subagent_message) or stop it explicitly (subagent_stop).`,
|
|
963
|
+
}],
|
|
964
|
+
details: {
|
|
965
|
+
id: running.id,
|
|
966
|
+
name: running.name,
|
|
967
|
+
task: running.task,
|
|
968
|
+
agent: running.agent,
|
|
969
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
970
|
+
sessionFile: running.sessionFile,
|
|
971
|
+
elapsed: Math.floor((Date.now() - running.startTime) / 1000),
|
|
972
|
+
status: "detached",
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const result: SubagentResult = raced;
|
|
977
|
+
|
|
978
|
+
// watcherAbort 到此只可能被 sibling subagent_stop 触发(工具 Escape 不
|
|
979
|
+
// 再转发);此时 watcher 已兑出真实取消终态,走本返回路径。stopped
|
|
980
|
+
// 标志覆盖 mock watcher 直接 resolve 取消终态的场景。
|
|
981
|
+
const userClosed = !!result.userClosed;
|
|
982
|
+
const cancelled = watcherAbort.signal.aborted || userClosed || !!result.stopped;
|
|
983
|
+
const failed = result.exitCode !== 0 || !!result.errorMessage;
|
|
984
|
+
const routeException = cancelled ? undefined : routeExceptionFromResult(result, running.model);
|
|
985
|
+
// 硬屏障终态(成功/失败/取消/handed-off)落定完成记录:同一消息或后续
|
|
986
|
+
// turn 的 dependsOn 消费者都以此为等待源头;用户关闭 pane 按取消 settle。
|
|
987
|
+
settleCompletionFromResult(running.name, result, {
|
|
988
|
+
...(cancelled ? { status: "cancelled" as const } : {}),
|
|
989
|
+
});
|
|
990
|
+
// ── 会话保留(retention)──在结果提取(watcher 内)、依赖 settle(上段)、
|
|
991
|
+
// runtime record 清理(watcher 内)全部完成之后才删除工件;删除失败只
|
|
992
|
+
// debug,绝不覆盖已取得的子代理结果。pane/演示与 handed-off 恒保留。
|
|
993
|
+
const retentionPolicy = params.retention ?? "auto";
|
|
994
|
+
const retentionDetails = applyRetentionToRunning(running, retentionPolicy, {
|
|
995
|
+
cancelled,
|
|
996
|
+
failed,
|
|
997
|
+
handedOff: !!result.handedOff,
|
|
998
|
+
});
|
|
999
|
+
if (result.handedOff) {
|
|
1000
|
+
// /reload 移交:显式 handed-off 语义,不报 cancelled/failed;
|
|
1001
|
+
// 真实结果由恢复路径在子代理结束后经 steer 消息回注。
|
|
1002
|
+
return {
|
|
1003
|
+
content: [{
|
|
1004
|
+
type: "text",
|
|
1005
|
+
text:
|
|
1006
|
+
`Sub-agent "${running.name}" was handed off to recovery because the host was reloaded (/reload). ` +
|
|
1007
|
+
`It is still running; its real result will be delivered as a steer message when it finishes. ` +
|
|
1008
|
+
`Do not treat this as a failure and do not spawn a replacement.`,
|
|
1009
|
+
}],
|
|
1010
|
+
details: {
|
|
1011
|
+
id: running.id,
|
|
1012
|
+
name: running.name,
|
|
1013
|
+
task: running.task,
|
|
1014
|
+
agent: running.agent,
|
|
1015
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
1016
|
+
sessionFile: result.sessionFile,
|
|
1017
|
+
elapsed: result.elapsed,
|
|
1018
|
+
status: "handed-off",
|
|
1019
|
+
retention: retentionDetails,
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
return {
|
|
1024
|
+
content: [{
|
|
1025
|
+
type: "text",
|
|
1026
|
+
text: userClosed
|
|
1027
|
+
? `Sub-agent "${running.name}" was closed by the user: its pane is gone and no result was produced. ` +
|
|
1028
|
+
`This is not a provider or agent error. Its session is preserved; resume it with ` +
|
|
1029
|
+
`subagent_message({ name: "${running.name}", message: "…" }) if the task still matters.`
|
|
1030
|
+
: cancelled
|
|
1031
|
+
? retentionDetails.outcome === "cleaned"
|
|
1032
|
+
? `Sub-agent "${running.name}" was stopped before finishing — no result was produced. ` +
|
|
1033
|
+
`Its session artifacts were cleaned up (retention: ${retentionPolicy}).`
|
|
1034
|
+
: `Sub-agent "${running.name}" was stopped before finishing — no result was produced. ` +
|
|
1035
|
+
`Its session is preserved; resume it with subagent_message({ name: "${running.name}", message: "…" }) if needed.`
|
|
1036
|
+
: deps.resolveResultPresentation(result, running.name, {
|
|
1037
|
+
sessionPreserved: retentionDetails.outcome !== "cleaned",
|
|
1038
|
+
}),
|
|
1039
|
+
}],
|
|
1040
|
+
details: {
|
|
1041
|
+
id: running.id,
|
|
1042
|
+
name: running.name,
|
|
1043
|
+
task: running.task,
|
|
1044
|
+
agent: running.agent,
|
|
1045
|
+
...(running.cohortId ? { cohortId: running.cohortId } : {}),
|
|
1046
|
+
sessionFile: result.sessionFile,
|
|
1047
|
+
exitCode: result.exitCode,
|
|
1048
|
+
elapsed: result.elapsed,
|
|
1049
|
+
status: userClosed ? "user_closed" : cancelled ? "cancelled" : failed ? "failed" : "completed",
|
|
1050
|
+
retention: retentionDetails,
|
|
1051
|
+
...(userClosed ? { userClosed: true } : {}),
|
|
1052
|
+
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
|
1053
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
1054
|
+
...(result.stats ? { stats: result.stats } : {}),
|
|
1055
|
+
...(routeException ? { routeException } : {}),
|
|
1056
|
+
},
|
|
1057
|
+
};
|
|
1058
|
+
},
|
|
1059
|
+
|
|
1060
|
+
renderCall(args, theme) {
|
|
1061
|
+
const partialArgs = args as Record<string, unknown>;
|
|
1062
|
+
const agentName = typeof partialArgs.agent === "string" && partialArgs.agent ? partialArgs.agent : "";
|
|
1063
|
+
const name = typeof partialArgs.name === "string" && partialArgs.name ? partialArgs.name : agentName || "(unnamed)";
|
|
1064
|
+
const task = typeof partialArgs.task === "string" ? partialArgs.task : "";
|
|
1065
|
+
const agent = agentName && name !== agentName ? theme.fg("dim", ` (${agentName})`) : "";
|
|
1066
|
+
const cwdHint = typeof partialArgs.cwd === "string" && partialArgs.cwd ? theme.fg("dim", ` in ${partialArgs.cwd}`) : "";
|
|
1067
|
+
let text = "○ " + theme.fg("toolTitle", theme.bold(name)) + agent + cwdHint;
|
|
1068
|
+
if (task) {
|
|
1069
|
+
const firstLine = task.split("\n").find((line: string) => line.trim()) ?? "";
|
|
1070
|
+
const preview = firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine;
|
|
1071
|
+
if (preview) text += "\n" + theme.fg("toolOutput", preview);
|
|
1072
|
+
const totalLines = task.split("\n").length;
|
|
1073
|
+
if (totalLines > 1) text += theme.fg("muted", ` (${totalLines} lines)`);
|
|
1074
|
+
}
|
|
1075
|
+
return new Text(text, 0, 0);
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
renderResult(result, _opts, theme) {
|
|
1079
|
+
const details = result.details as any;
|
|
1080
|
+
const name = details?.name ?? "(unnamed)";
|
|
1081
|
+
if (details?.status === "started") {
|
|
1082
|
+
return new Text(
|
|
1083
|
+
theme.fg("accent", "⟳") + " " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", " — started"),
|
|
1084
|
+
0,
|
|
1085
|
+
0,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
// 持久成员启动:立即 ack,首轮结果稍后经轮次回注送达。
|
|
1089
|
+
if (details?.status === "member-started") {
|
|
1090
|
+
return new Text(
|
|
1091
|
+
theme.fg("accent", "◆") + " " + theme.fg("toolTitle", theme.bold(name)) +
|
|
1092
|
+
theme.fg("accent", " — team member · round 1 running"),
|
|
1093
|
+
0,
|
|
1094
|
+
0,
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
// /reload 移交:非终态,真实结果稍后由恢复路径回注。
|
|
1098
|
+
if (details?.status === "handed-off") {
|
|
1099
|
+
return new Text(
|
|
1100
|
+
theme.fg("accent", "⏳") + " " +
|
|
1101
|
+
theme.fg("toolTitle", theme.bold(name)) +
|
|
1102
|
+
theme.fg("dim", " — handed off after reload"),
|
|
1103
|
+
0,
|
|
1104
|
+
0,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
// Escape 中止等待:非终态,子代理仍在运行,真实结果稍后迟到回注。
|
|
1108
|
+
if (details?.status === "detached") {
|
|
1109
|
+
return new Text(
|
|
1110
|
+
theme.fg("accent", "⇄") + " " +
|
|
1111
|
+
theme.fg("toolTitle", theme.bold(name)) +
|
|
1112
|
+
theme.fg("dim", " — detached (still running; late result to follow)"),
|
|
1113
|
+
0,
|
|
1114
|
+
0,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
// 用户直接关闭 pane:稳定分类,不是 provider/agent 错误。
|
|
1118
|
+
if (details?.status === "user_closed") {
|
|
1119
|
+
return new Text(
|
|
1120
|
+
theme.fg("warning", "✕") + " " +
|
|
1121
|
+
theme.fg("toolTitle", theme.bold(name)) +
|
|
1122
|
+
theme.fg("warning", " — closed by user"),
|
|
1123
|
+
0,
|
|
1124
|
+
0,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
// 等待超时:非终态,子代理仍在运行,真实结果稍后迟到回注。
|
|
1128
|
+
if (details?.status === "timed-out") {
|
|
1129
|
+
const bound = typeof details.timeoutMs === "number" ? ` after ${Math.round(details.timeoutMs / 1000)}s` : "";
|
|
1130
|
+
return new Text(
|
|
1131
|
+
theme.fg("warning", "⏱") + " " +
|
|
1132
|
+
theme.fg("toolTitle", theme.bold(name)) +
|
|
1133
|
+
theme.fg("warning", ` — timed out${bound} (still running; late result to follow)`),
|
|
1134
|
+
0,
|
|
1135
|
+
0,
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
// 硬屏障终态:自动子代理的结果直接作为 tool result 返回,按状态渲染。
|
|
1139
|
+
if (details?.status === "completed" || details?.status === "failed" || details?.status === "cancelled") {
|
|
1140
|
+
const elapsed = typeof details.elapsed === "number" ? ` · ${details.elapsed}s` : "";
|
|
1141
|
+
const reason =
|
|
1142
|
+
details.status === "completed"
|
|
1143
|
+
? theme.fg("dim", `completed${elapsed}`)
|
|
1144
|
+
: details.status === "cancelled"
|
|
1145
|
+
? theme.fg("warning", `cancelled${elapsed}`)
|
|
1146
|
+
: theme.fg("error", `failed${elapsed}`);
|
|
1147
|
+
const icon = details.status === "completed" ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
1148
|
+
return new Text(icon + " " + theme.fg("toolTitle", theme.bold(name)) + " " + reason, 0, 0);
|
|
1149
|
+
}
|
|
1150
|
+
const content = result.content[0];
|
|
1151
|
+
const text = content && content.type === "text" ? content.text : "";
|
|
1152
|
+
return new Text(theme.fg("dim", text), 0, 0);
|
|
1153
|
+
},
|
|
1154
|
+
});
|
|
1155
|
+
}
|