@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,808 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
countSessionEntryLines,
|
|
9
|
+
getSessionId,
|
|
10
|
+
readNameRegistry,
|
|
11
|
+
readSubagentLoadout,
|
|
12
|
+
resolveNameInRegistry,
|
|
13
|
+
type AnchoredSubagentLoadout,
|
|
14
|
+
type SubagentLoadout,
|
|
15
|
+
} from "./session.ts";
|
|
16
|
+
import { getSubagentActivityFile } from "./activity.ts";
|
|
17
|
+
import { createStatusState } from "./status.ts";
|
|
18
|
+
import { normalizeSubagentName, sanitizeSubagentFileName } from "./names.ts";
|
|
19
|
+
import { routeExceptionFromResult } from "./route-error.ts";
|
|
20
|
+
import { announceCompletion, settleCompletionFromResult } from "./dependencies.ts";
|
|
21
|
+
import { normalizeCohortId } from "./params.ts";
|
|
22
|
+
import { sentinelSuffix } from "./surface.ts";
|
|
23
|
+
import {
|
|
24
|
+
createSubagentLaunchEnv,
|
|
25
|
+
formatHeadlessSurface,
|
|
26
|
+
spawnHeadlessPi,
|
|
27
|
+
type HeadlessChild,
|
|
28
|
+
} from "./headless.ts";
|
|
29
|
+
import { debugLog } from "./diagnostics.ts";
|
|
30
|
+
import { createRuntimeRecord, markRuntimeWaitReleased } from "./runtime-registry.ts";
|
|
31
|
+
import { validateTimeoutMs, waitForSubagentTerminal, type WaitRelease } from "./subagent-tool.ts";
|
|
32
|
+
import type { RunningSubagent, SubagentResult } from "./types.ts";
|
|
33
|
+
|
|
34
|
+
interface MessageContext {
|
|
35
|
+
sessionManager: {
|
|
36
|
+
getSessionId(): string;
|
|
37
|
+
getSessionDir(): string;
|
|
38
|
+
};
|
|
39
|
+
/** Parent session cwd; needed when a legacy loadout did not persist cwd. */
|
|
40
|
+
cwd?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SubagentMessageToolDeps {
|
|
44
|
+
isMuxAvailable: () => boolean;
|
|
45
|
+
muxUnavailableResult: () => any;
|
|
46
|
+
/** 表面选择:resume 恒为自动任务,auto 默认即 headless;仅 pane 需要 mux。 */
|
|
47
|
+
resolveSurfaceChoice: (params: { surface?: string }) => { choice: "headless" | "pane" } | { error: string };
|
|
48
|
+
spawnHeadlessPi: typeof spawnHeadlessPi;
|
|
49
|
+
runningSubagents: Map<string, RunningSubagent>;
|
|
50
|
+
reservedNames: Set<string>;
|
|
51
|
+
handleSubagentSteer: (params: { name?: string; message?: string }) => unknown;
|
|
52
|
+
getArtifactDir: (sessionDir: string, sessionId: string) => string;
|
|
53
|
+
readNameRegistry: typeof readNameRegistry;
|
|
54
|
+
resolveNameInRegistry: typeof resolveNameInRegistry;
|
|
55
|
+
getSessionId: typeof getSessionId;
|
|
56
|
+
readSubagentLoadout: typeof readSubagentLoadout;
|
|
57
|
+
/** 父侧锚定 loadout 副本读取(resume 授权单一真源;legacy 快照返回 null)。 */
|
|
58
|
+
readAnchoredLoadout: (artifactDir: string, sessionFile: string) => AnchoredSubagentLoadout | null;
|
|
59
|
+
getAgentConfigDir: () => string;
|
|
60
|
+
validateResumeTarget: (
|
|
61
|
+
sessionPath: string,
|
|
62
|
+
loadout: SubagentLoadout,
|
|
63
|
+
agentDir: string,
|
|
64
|
+
opts?: { anchored?: AnchoredSubagentLoadout | null; requireAnchored?: boolean },
|
|
65
|
+
) => string | null;
|
|
66
|
+
countSessionEntryLines: typeof countSessionEntryLines;
|
|
67
|
+
getSubagentActivityFile: typeof getSubagentActivityFile;
|
|
68
|
+
createSurface: (name: string, options?: { cwd?: string; env?: Record<string, string> }) => string;
|
|
69
|
+
shellEscape: (value: string) => string;
|
|
70
|
+
subagentsDir: string;
|
|
71
|
+
applySandboxToParts: (
|
|
72
|
+
parts: string[],
|
|
73
|
+
loadout: SubagentLoadout,
|
|
74
|
+
options: { artifactDir: string; name: string },
|
|
75
|
+
raw?: { escape?: (value: string) => string },
|
|
76
|
+
) => void;
|
|
77
|
+
getShellReadyDelayMs: () => number;
|
|
78
|
+
sendCommand: (surface: string, command: string) => void;
|
|
79
|
+
closeSurface: (surface: string) => void;
|
|
80
|
+
resolveResumeLaunchBehavior: () => { autoExit: boolean; interactive: boolean };
|
|
81
|
+
runtimeRegistryPath: (artifactDir: string) => string;
|
|
82
|
+
upsertRuntimeRecord: (path: string, record: Parameters<typeof import("./runtime-registry.ts").upsertRuntimeRecord>[1]) => void;
|
|
83
|
+
removeRuntimeRecord: (path: string, id: string) => void;
|
|
84
|
+
startWidgetRefresh: () => void;
|
|
85
|
+
startStatusRefresh: (pi: ExtensionAPI) => void;
|
|
86
|
+
watchSubagent: (running: RunningSubagent, signal: AbortSignal) => Promise<SubagentResult>;
|
|
87
|
+
updateWidget: () => void;
|
|
88
|
+
extractSubagentResult: (
|
|
89
|
+
sessionFile: string,
|
|
90
|
+
result: Pick<SubagentResult, "exitCode" | "summary" | "errorMessage">,
|
|
91
|
+
afterLine?: number,
|
|
92
|
+
fallbackPrefix?: string,
|
|
93
|
+
) => Promise<{ summary: string; stats: NonNullable<SubagentResult["stats"]> | null; sessionId: string | null }>;
|
|
94
|
+
resolveResultPresentation: (
|
|
95
|
+
result: SubagentResult,
|
|
96
|
+
name: string,
|
|
97
|
+
options?: { sessionPreserved?: boolean },
|
|
98
|
+
) => string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function registerSubagentMessageTool(
|
|
102
|
+
pi: ExtensionAPI,
|
|
103
|
+
deps: SubagentMessageToolDeps,
|
|
104
|
+
): void {
|
|
105
|
+
pi.registerTool({
|
|
106
|
+
name: "subagent_message",
|
|
107
|
+
label: "Message Subagent",
|
|
108
|
+
description:
|
|
109
|
+
"Send a message to a subagent by name. Names are unique within your session and persist after a subagent finishes, " +
|
|
110
|
+
"so the SAME name works whether the subagent is running or finished: if it is still running, your message steers its live session; " +
|
|
111
|
+
"if it has finished, your message resumes that session and continues it. " +
|
|
112
|
+
"`name` and `message` are both required. " +
|
|
113
|
+
"Steering a running subagent returns immediately with a local acknowledgement and does NOT, by itself, emit a new result. " +
|
|
114
|
+
"For an idle persistent team member, a steer starts an untracked spontaneous run whose round result is not delivered; use team_dispatch for tracked work. " +
|
|
115
|
+
"Resuming BLOCKS this call until the resumed sub-agent reaches a terminal state and returns its real result directly as the tool result; " +
|
|
116
|
+
"aborting this call (Escape) only DETACHES the wait — the resumed sub-agent keeps running and its result arrives later as a steer message (once); it is NOT cancelled. " +
|
|
117
|
+
"TIMEOUT (timeoutMs): optional wait bound in milliseconds (>= 1000) for the blocking resume wait. When it fires, the call returns a structured non-terminal `timed-out` result — the resumed sub-agent keeps running, nothing is killed or cleaned up, no summary is fabricated, and the real result arrives later as a single steer message. Omit it to wait indefinitely. Only applies when the message RESUMES a finished sub-agent; steering a running one returns immediately, so the timeout is not used there. " +
|
|
118
|
+
"Use subagent_stop to actually terminate a running sub-agent early; a message's text is never interpreted as a stop command. " +
|
|
119
|
+
"Sibling calls in the same assistant message run in parallel and all settle before the turn continues. " +
|
|
120
|
+
"DO NOT poll, sleep, tail logs, or read session files to detect completion. If you need a deliberate one-time diagnostic snapshot, use subagent_inspect; do not call it repeatedly just to wait. " +
|
|
121
|
+
"DO NOT fabricate or assume results. After calling, either end your turn or work on other independent tasks.",
|
|
122
|
+
promptSnippet:
|
|
123
|
+
"Message a subagent by name: steers it if running (returns immediately), resumes it if finished (blocks until terminal state and returns the real result). " +
|
|
124
|
+
"`name` and `message` are required. Do not poll or fabricate results.",
|
|
125
|
+
parameters: Type.Object({
|
|
126
|
+
name: Type.String({
|
|
127
|
+
description:
|
|
128
|
+
"Exact display name of the subagent. Steers it if it is still running; resumes its session if it has finished.",
|
|
129
|
+
}),
|
|
130
|
+
message: Type.String({
|
|
131
|
+
description:
|
|
132
|
+
"The message to deliver: a follow-up instruction for a running subagent, or the next task for a resumed session.",
|
|
133
|
+
}),
|
|
134
|
+
timeoutMs: Type.Optional(
|
|
135
|
+
Type.Integer({
|
|
136
|
+
minimum: 1000,
|
|
137
|
+
description:
|
|
138
|
+
"Optional wait timeout in milliseconds (>= 1000) for the BLOCKING resume wait. When it fires, the call returns a " +
|
|
139
|
+
"non-terminal `timed-out` result; the resumed sub-agent keeps running and its real result arrives later as a single " +
|
|
140
|
+
"steer message. Omit to wait indefinitely. Ignored when the message steers a still-running sub-agent (that returns immediately).",
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
}),
|
|
144
|
+
|
|
145
|
+
renderCall(args, theme) {
|
|
146
|
+
const target = args.name ?? "(unknown)";
|
|
147
|
+
return new Text(
|
|
148
|
+
"○ " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", " — message"),
|
|
149
|
+
0,
|
|
150
|
+
0,
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
renderResult(result, _opts, theme) {
|
|
155
|
+
const details = result.details as any;
|
|
156
|
+
if (details?.status === "steered") {
|
|
157
|
+
return new Text(
|
|
158
|
+
theme.fg("success", "✓") + " " +
|
|
159
|
+
theme.fg("toolTitle", theme.bold(details.name ?? "subagent")) +
|
|
160
|
+
theme.fg("dim", " — message delivered"),
|
|
161
|
+
0,
|
|
162
|
+
0,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (details?.status === "started") {
|
|
166
|
+
return new Text(
|
|
167
|
+
theme.fg("accent", "⟳") + " " +
|
|
168
|
+
theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) +
|
|
169
|
+
theme.fg("dim", " — resumed"),
|
|
170
|
+
0,
|
|
171
|
+
0,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (details?.status === "handed-off") {
|
|
175
|
+
return new Text(
|
|
176
|
+
theme.fg("accent", "⏳") + " " +
|
|
177
|
+
theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) +
|
|
178
|
+
theme.fg("dim", " — handed off after reload"),
|
|
179
|
+
0,
|
|
180
|
+
0,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
// Escape 中止等待:非终态,子代理仍在运行,真实结果稍后迟到回注。
|
|
184
|
+
if (details?.status === "detached") {
|
|
185
|
+
return new Text(
|
|
186
|
+
theme.fg("accent", "⇄") + " " +
|
|
187
|
+
theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) +
|
|
188
|
+
theme.fg("dim", " — detached (still running; late result to follow)"),
|
|
189
|
+
0,
|
|
190
|
+
0,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
// 用户直接关闭 pane:稳定分类,不是 provider/agent 错误。
|
|
194
|
+
if (details?.status === "user_closed") {
|
|
195
|
+
return new Text(
|
|
196
|
+
theme.fg("warning", "✕") + " " +
|
|
197
|
+
theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) +
|
|
198
|
+
theme.fg("warning", " — closed by user"),
|
|
199
|
+
0,
|
|
200
|
+
0,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
// 等待超时:非终态,resumed 子代理仍在运行,真实结果稍后迟到回注。
|
|
204
|
+
if (details?.status === "timed-out") {
|
|
205
|
+
const bound = typeof details.timeoutMs === "number" ? ` after ${Math.round(details.timeoutMs / 1000)}s` : "";
|
|
206
|
+
return new Text(
|
|
207
|
+
theme.fg("warning", "⏱") + " " +
|
|
208
|
+
theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) +
|
|
209
|
+
theme.fg("warning", ` — timed out${bound} (still running; late result to follow)`),
|
|
210
|
+
0,
|
|
211
|
+
0,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
// 硬屏障终态:自动 resume 的结果直接作为 tool result 返回,按状态渲染。
|
|
215
|
+
if (details?.status === "completed" || details?.status === "failed" || details?.status === "cancelled") {
|
|
216
|
+
const elapsed = typeof details.elapsed === "number" ? ` · ${details.elapsed}s` : "";
|
|
217
|
+
const reason =
|
|
218
|
+
details.status === "completed"
|
|
219
|
+
? theme.fg("dim", `completed${elapsed}`)
|
|
220
|
+
: details.status === "cancelled"
|
|
221
|
+
? theme.fg("warning", `cancelled${elapsed}`)
|
|
222
|
+
: theme.fg("error", `failed${elapsed}`);
|
|
223
|
+
const icon = details.status === "completed" ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
224
|
+
return new Text(icon + " " + theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) + " " + reason, 0, 0);
|
|
225
|
+
}
|
|
226
|
+
const content = result.content[0];
|
|
227
|
+
const text = content && content.type === "text" ? content.text : "";
|
|
228
|
+
return new Text(theme.fg("dim", text), 0, 0);
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx: MessageContext) {
|
|
232
|
+
// 与 spawn 路径同規则 normalize:不改变已干净的名称,但多余空白/超长
|
|
233
|
+
// 名称在 steer 与 resume 两条路径上的寻址行为一致。
|
|
234
|
+
const requestedName = normalizeSubagentName(params.name?.trim() ?? "", "");
|
|
235
|
+
if (!requestedName) {
|
|
236
|
+
const error = "Provide the subagent's `name` to steer (if running) or resume (if finished).";
|
|
237
|
+
return { content: [{ type: "text" as const, text: error }], details: { error } };
|
|
238
|
+
}
|
|
239
|
+
const message = params.message?.trim();
|
|
240
|
+
if (!message) {
|
|
241
|
+
const error = "Provide a non-empty `message` to steer or resume a subagent.";
|
|
242
|
+
return { content: [{ type: "text" as const, text: error }], details: { error } };
|
|
243
|
+
}
|
|
244
|
+
// timeoutMs 校验(信任边界:模型传参);非法值在拉起任何进程前拒绝。
|
|
245
|
+
const timeoutError = validateTimeoutMs(params.timeoutMs);
|
|
246
|
+
if (timeoutError) {
|
|
247
|
+
return { content: [{ type: "text" as const, text: timeoutError }], details: { error: timeoutError } };
|
|
248
|
+
}
|
|
249
|
+
// 表面选择只算一次:resume 恒为自动任务(auto → headless),无复用器时
|
|
250
|
+
// 仍可后台运行;仅当选中 pane 而 mux 不可用时才拒绝。
|
|
251
|
+
const surfaceChoice = deps.resolveSurfaceChoice({});
|
|
252
|
+
const choiceIsHeadless = "choice" in surfaceChoice && surfaceChoice.choice === "headless";
|
|
253
|
+
if (!deps.isMuxAvailable() && !choiceIsHeadless) {
|
|
254
|
+
return deps.muxUnavailableResult();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const runningMatch = Array.from(deps.runningSubagents.values())
|
|
258
|
+
.find((running) => running.name === requestedName);
|
|
259
|
+
if (runningMatch) {
|
|
260
|
+
const steerResult = deps.handleSubagentSteer({ name: requestedName, message: params.message }) as
|
|
261
|
+
| { content?: Array<{ type: string; text?: string }>; details?: Record<string, unknown> }
|
|
262
|
+
| undefined;
|
|
263
|
+
// 持久成员的 steer 不是一次性任务语义:新轮工作必须走 team_dispatch,
|
|
264
|
+
// 轮次结果由 round watcher 回注——这里补充不误导的指引。
|
|
265
|
+
if (runningMatch.member && !steerResult?.details?.error) {
|
|
266
|
+
const base = Array.isArray(steerResult?.content) && steerResult.content[0]?.type === "text"
|
|
267
|
+
? (steerResult.content[0].text ?? "")
|
|
268
|
+
: "";
|
|
269
|
+
const guidance = runningMatch.dispatchedRound === false
|
|
270
|
+
? `Note: "${requestedName}" is a persistent team member and was idle. This steer starts a spontaneous run; its round signal is ` +
|
|
271
|
+
`consumed internally and its result is NOT delivered as a tracked result. Use team_dispatch({ name: "${requestedName}", task: "…" }) ` +
|
|
272
|
+
`for work whose real result must arrive later as a steer message, exactly once.`
|
|
273
|
+
: `Note: "${requestedName}" is a persistent team member — this message steers its CURRENT run and does not start a separate one-shot task. ` +
|
|
274
|
+
`Dispatch new rounds of work with team_dispatch({ name: "${requestedName}", task: "…" }); each dispatched round's real result ` +
|
|
275
|
+
`arrives later as a steer message, exactly once.`;
|
|
276
|
+
return {
|
|
277
|
+
...(steerResult ?? {}),
|
|
278
|
+
content: [{ type: "text" as const, text: base ? `${base}\n\n${guidance}` : guidance }],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return steerResult;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const name = requestedName;
|
|
285
|
+
if (deps.reservedNames.has(name)) {
|
|
286
|
+
const error = `Subagent "${name}" is already being launched or resumed. Wait for that operation to finish.`;
|
|
287
|
+
return { content: [{ type: "text" as const, text: error }], details: { error } };
|
|
288
|
+
}
|
|
289
|
+
const { autoExit, interactive } = deps.resolveResumeLaunchBehavior();
|
|
290
|
+
const startTime = Date.now();
|
|
291
|
+
const id = Math.random().toString(16).slice(2, 10);
|
|
292
|
+
const sentinelToken = `__PI_SUBAGENT_DONE_${randomUUID()}__`;
|
|
293
|
+
const parentArtifactDir = deps.getArtifactDir(
|
|
294
|
+
ctx.sessionManager.getSessionDir(),
|
|
295
|
+
ctx.sessionManager.getSessionId(),
|
|
296
|
+
);
|
|
297
|
+
const entry = deps.resolveNameInRegistry(parentArtifactDir, requestedName);
|
|
298
|
+
if (!entry) {
|
|
299
|
+
const known = Object.keys(deps.readNameRegistry(parentArtifactDir));
|
|
300
|
+
const error =
|
|
301
|
+
`No subagent named "${requestedName}" in this session. ` +
|
|
302
|
+
(known.length > 0 ? `Known subagents: ${known.join(", ")}.` : "No subagents have been spawned in this session yet.");
|
|
303
|
+
return { content: [{ type: "text" as const, text: error }], details: { error } };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const sessionPath = entry.sessionFile;
|
|
307
|
+
if (!sessionPath || !existsSync(sessionPath)) {
|
|
308
|
+
const error =
|
|
309
|
+
`Subagent "${requestedName}" is registered but its session file is gone ` +
|
|
310
|
+
`(${sessionPath}). It cannot be resumed. Spawn a fresh subagent instead.`;
|
|
311
|
+
return { content: [{ type: "text" as const, text: error }], details: { error } };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const running of deps.runningSubagents.values()) {
|
|
315
|
+
if (resolve(running.sessionFile) === resolve(sessionPath)) {
|
|
316
|
+
return deps.handleSubagentSteer({ name: running.name, message: params.message });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const loadout = deps.readSubagentLoadout(sessionPath);
|
|
321
|
+
if (!loadout) {
|
|
322
|
+
const error =
|
|
323
|
+
`Cannot safely resume "${requestedName}": no sandbox snapshot found for this session ` +
|
|
324
|
+
`(it predates sandboxed resume, or its .loadout.json sidecar was removed). ` +
|
|
325
|
+
`Resuming would lose the original tool policy and could change the subagent's capabilities, so this is refused. ` +
|
|
326
|
+
`Re-run the task as a fresh subagent instead.`;
|
|
327
|
+
return { content: [{ type: "text" as const, text: error }], details: { error } };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 锚定副本(父侧 artifactDir,子代理不可直接寻址):resume 授权的
|
|
331
|
+
// 单一真源。新 spawn 的 registry 标记要求副本必须存在;只有旧
|
|
332
|
+
// registry 条目才允许走 legacy 校验路径。
|
|
333
|
+
const anchored = deps.readAnchoredLoadout(parentArtifactDir, sessionPath);
|
|
334
|
+
const trustError = deps.validateResumeTarget(
|
|
335
|
+
sessionPath,
|
|
336
|
+
loadout,
|
|
337
|
+
deps.getAgentConfigDir(),
|
|
338
|
+
{ anchored, requireAnchored: entry.anchored === true },
|
|
339
|
+
);
|
|
340
|
+
if (trustError) {
|
|
341
|
+
const error = `Cannot safely resume "${requestedName}": ${trustError}`;
|
|
342
|
+
return { content: [{ type: "text" as const, text: error }], details: { error } };
|
|
343
|
+
}
|
|
344
|
+
// 锚定副本是运行配置的单一真源(与 sidecar 已校验一致);只有旧快照
|
|
345
|
+
// 或锚定写入失败的 registry 条目才会退回 sidecar 本身(legacy 授权,
|
|
346
|
+
// containment 已按可信根校验)。Trust decisions live in Pi's global
|
|
347
|
+
// agent directory; getAgentConfigDir() stays the trust root here regardless
|
|
348
|
+
// of the loadout's agentDir.
|
|
349
|
+
const effectiveLoadout: SubagentLoadout = anchored ?? loadout;
|
|
350
|
+
const cohortId = normalizeCohortId(effectiveLoadout.cohortId ?? entry.cohortId);
|
|
351
|
+
const cohortDetails = cohortId ? { cohortId } : {};
|
|
352
|
+
const resumedSessionId = entry.sessionId ?? deps.getSessionId(sessionPath) ?? requestedName;
|
|
353
|
+
const entryCountBefore = deps.countSessionEntryLines(sessionPath);
|
|
354
|
+
const artifactDir = deps.getArtifactDir(
|
|
355
|
+
ctx.sessionManager.getSessionDir(),
|
|
356
|
+
ctx.sessionManager.getSessionId(),
|
|
357
|
+
);
|
|
358
|
+
const activityFile = deps.getSubagentActivityFile(artifactDir, id);
|
|
359
|
+
mkdirSync(dirname(activityFile), { recursive: true });
|
|
360
|
+
|
|
361
|
+
const resumeEnv: Record<string, string> = createSubagentLaunchEnv();
|
|
362
|
+
const resumeAgentDir = effectiveLoadout.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? null;
|
|
363
|
+
if (resumeAgentDir) resumeEnv.PI_CODING_AGENT_DIR = resumeAgentDir;
|
|
364
|
+
if (effectiveLoadout.spawnable && effectiveLoadout.spawnable.length > 0) {
|
|
365
|
+
resumeEnv.PI_SUBAGENT_ALLOWED = effectiveLoadout.spawnable.join(",");
|
|
366
|
+
}
|
|
367
|
+
if (effectiveLoadout.agent) resumeEnv.PI_SUBAGENT_AGENT = effectiveLoadout.agent;
|
|
368
|
+
resumeEnv.PI_SUBAGENT_NAME = name;
|
|
369
|
+
resumeEnv.PI_SUBAGENT_SESSION = sessionPath;
|
|
370
|
+
resumeEnv.PI_SUBAGENT_ID = id;
|
|
371
|
+
resumeEnv.PI_SUBAGENT_ACTIVITY_FILE = activityFile;
|
|
372
|
+
if (autoExit) resumeEnv.PI_SUBAGENT_AUTO_EXIT = "1";
|
|
373
|
+
// Resume is always an autonomous blocking operation; keep ask_question
|
|
374
|
+
// checkpoint semantics aligned with initial launches so a resumed child
|
|
375
|
+
// cannot park forever while this tool is awaiting its terminal result.
|
|
376
|
+
if (!interactive) resumeEnv.PI_SUBAGENT_BARRIER = "1";
|
|
377
|
+
|
|
378
|
+
deps.reservedNames.add(name);
|
|
379
|
+
// resume 同样是依赖提供者:名字确定后、真正拉起进程前登记完成记录,
|
|
380
|
+
// 下游 spawn 的 dependsOn 即可等待本次 resume 的终态。
|
|
381
|
+
const ownRecord = announceCompletion(name, { interactive: false });
|
|
382
|
+
// resume 复用既有 session 文件:清掉上一轮遗留的 .exit/.ask sidecar,
|
|
383
|
+
// 避免旧错误/旧问题污染本次 resume(watcher 会把旧 .exit 当本次失败、
|
|
384
|
+
// 旧 .ask 当新问题投递)。写入方用 tmp+rename,直接删除安全。
|
|
385
|
+
try { rmSync(`${sessionPath}.exit`, { force: true }); } catch {}
|
|
386
|
+
try { rmSync(`${sessionPath}.ask`, { force: true }); } catch {}
|
|
387
|
+
let createdSurface: string | null = null;
|
|
388
|
+
let child: HeadlessChild | null = null;
|
|
389
|
+
try {
|
|
390
|
+
// resume 默认走 headless(自动任务,auto → headless):独立 RPC 进程,
|
|
391
|
+
// 不建 pane。surface=pane 或 headless 不可用时回退 pane 路径。
|
|
392
|
+
const useHeadless = choiceIsHeadless;
|
|
393
|
+
if (useHeadless) {
|
|
394
|
+
// RPC argv:参数数组直传子进程,不经 shell 拼接;resume 消息经 stdin 投递。
|
|
395
|
+
const rpcArgs = [
|
|
396
|
+
"--mode",
|
|
397
|
+
"rpc",
|
|
398
|
+
"--session",
|
|
399
|
+
sessionPath,
|
|
400
|
+
"-e",
|
|
401
|
+
join(deps.subagentsDir, "subagent-done.ts"),
|
|
402
|
+
];
|
|
403
|
+
deps.applySandboxToParts(rpcArgs, effectiveLoadout, { artifactDir, name }, { escape: (value) => value });
|
|
404
|
+
const resumePromptId = `resume-${id}`;
|
|
405
|
+
let resumePromptPending = true;
|
|
406
|
+
child = deps.spawnHeadlessPi(
|
|
407
|
+
{
|
|
408
|
+
args: rpcArgs,
|
|
409
|
+
// New loadouts persist the exact child cwd. Fall back to the
|
|
410
|
+
// parent session cwd for older snapshots instead of silently
|
|
411
|
+
// resuming in the extension host's process.cwd().
|
|
412
|
+
cwd: effectiveLoadout.cwd ?? ctx.cwd ?? process.cwd(),
|
|
413
|
+
env: resumeEnv,
|
|
414
|
+
},
|
|
415
|
+
(event) => {
|
|
416
|
+
if (
|
|
417
|
+
resumePromptPending &&
|
|
418
|
+
event.type === "response" &&
|
|
419
|
+
event.command === "prompt" &&
|
|
420
|
+
event.id === resumePromptId
|
|
421
|
+
) {
|
|
422
|
+
resumePromptPending = false;
|
|
423
|
+
if (event.success === false) {
|
|
424
|
+
child!.promptError = typeof event.error === "string" && event.error
|
|
425
|
+
? event.error
|
|
426
|
+
: "resumed sub-agent rejected its prompt";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
if (child.pid == null) {
|
|
432
|
+
child.kill();
|
|
433
|
+
throw new Error("Failed to spawn headless resume process (check PI_SUBAGENT_PI_ENTRY / pi installation).");
|
|
434
|
+
}
|
|
435
|
+
createdSurface = formatHeadlessSurface(child.pid);
|
|
436
|
+
child.send({ id: resumePromptId, type: "prompt", message });
|
|
437
|
+
} else {
|
|
438
|
+
const parts = ["pi", "--session", deps.shellEscape(sessionPath)];
|
|
439
|
+
parts.push("-e", deps.shellEscape(join(deps.subagentsDir, "subagent-done.ts")));
|
|
440
|
+
deps.applySandboxToParts(parts, effectiveLoadout, { artifactDir, name });
|
|
441
|
+
|
|
442
|
+
if (params.message) {
|
|
443
|
+
const msgTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
444
|
+
const safeName = sanitizeSubagentFileName(name, "resume");
|
|
445
|
+
const resumeMsgFile = join(artifactDir, "subagent-resume", `${safeName}-${msgTimestamp}.md`);
|
|
446
|
+
mkdirSync(dirname(resumeMsgFile), { recursive: true });
|
|
447
|
+
writeFileSync(resumeMsgFile, message, "utf8");
|
|
448
|
+
parts.push(deps.shellEscape(`@${resumeMsgFile}`));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const command = `${parts.join(" ")}${sentinelSuffix(sentinelToken)}`;
|
|
452
|
+
const surface = deps.createSurface(name, { cwd: effectiveLoadout.cwd ?? undefined, env: resumeEnv });
|
|
453
|
+
createdSurface = surface;
|
|
454
|
+
await new Promise<void>((resolveDelay) => setTimeout(resolveDelay, deps.getShellReadyDelayMs()));
|
|
455
|
+
deps.sendCommand(surface, command);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const running: RunningSubagent = {
|
|
459
|
+
id,
|
|
460
|
+
name,
|
|
461
|
+
task: message,
|
|
462
|
+
...(cohortId ? { cohortId } : {}),
|
|
463
|
+
// resume 使用 loadout 里的具体 model(tier 已在首次 launch 时解析),
|
|
464
|
+
// 不重新读配置,不随配置改变漂移。
|
|
465
|
+
model: effectiveLoadout.model ?? null,
|
|
466
|
+
parentId: process.env.PI_SUBAGENT_ID ?? null,
|
|
467
|
+
...(params.timeoutMs != null ? { timeoutMs: params.timeoutMs } : {}),
|
|
468
|
+
waitMode: "hard-barrier",
|
|
469
|
+
surface: createdSurface as string,
|
|
470
|
+
startTime,
|
|
471
|
+
sessionFile: sessionPath,
|
|
472
|
+
activityFile,
|
|
473
|
+
interactive,
|
|
474
|
+
sentinelToken,
|
|
475
|
+
runtimeFile: deps.runtimeRegistryPath(artifactDir),
|
|
476
|
+
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
|
477
|
+
kind: useHeadless ? "headless" : "pane",
|
|
478
|
+
...(child && child.pid != null ? { pid: child.pid, headlessChild: child } : {}),
|
|
479
|
+
};
|
|
480
|
+
deps.runningSubagents.set(id, running);
|
|
481
|
+
try {
|
|
482
|
+
deps.upsertRuntimeRecord(running.runtimeFile, createRuntimeRecord(running));
|
|
483
|
+
} catch (error) {
|
|
484
|
+
debugLog(`Could not persist resumed runtime record for ${name}`, error);
|
|
485
|
+
}
|
|
486
|
+
deps.startWidgetRefresh();
|
|
487
|
+
deps.startStatusRefresh(pi);
|
|
488
|
+
const watcherAbort = new AbortController();
|
|
489
|
+
running.abortController = watcherAbort;
|
|
490
|
+
|
|
491
|
+
// resume 默认是自动子代理(resolveResumeLaunchBehavior 恒为 interactive: false):
|
|
492
|
+
// 硬等待终态,真实结果作为 tool result 返回;与 spawn 的硬屏障同语义,
|
|
493
|
+
// pi 的 sibling 并行执行保证同 turn 内多个 spawn/resume 全部落定后 turn 才继续。
|
|
494
|
+
// 若未来引入交互式 resume(interactive: true),保持异步立即返回 + steer 回注。
|
|
495
|
+
//
|
|
496
|
+
// 中止语义与 spawn 一致(Escape ≠ 取消):宿主中止等待不转发到 watcher,
|
|
497
|
+
// 子代理继续运行,本调用返回 detached 非终态;真实结果由 watcher 终态
|
|
498
|
+
// 时迟到回注一次(resume 无 retention,不清理工件)。
|
|
499
|
+
if (!running.interactive) {
|
|
500
|
+
const watchPromise = deps.watchSubagent(running, watcherAbort.signal);
|
|
501
|
+
running.watchPromise = watchPromise;
|
|
502
|
+
// 三向竞速(watcher 终态 / Escape / 可选 timeoutMs)与 spawn 的硬屏障
|
|
503
|
+
// 同源:等待解除只影响本调用,resumed 子代理继续运行,迟到回注一次。
|
|
504
|
+
const raced = await waitForSubagentTerminal({ watchPromise, running, signal, timeoutMs: params.timeoutMs ?? undefined });
|
|
505
|
+
if (typeof raced === "object" && "kind" in raced) {
|
|
506
|
+
const released: WaitRelease = raced;
|
|
507
|
+
running.waitReleased = released.kind;
|
|
508
|
+
markRuntimeWaitReleased(running.runtimeFile, running.id, released.kind);
|
|
509
|
+
// 主工具等待被中止:resumed 子代理仍在运行,不 settle、不提取伪结果;
|
|
510
|
+
// 迟到回注恰好一次(watcher promise 单次 resolve;handedOff 移交给
|
|
511
|
+
// 恢复路径,不回注)。
|
|
512
|
+
watchPromise
|
|
513
|
+
.then(async (late) => {
|
|
514
|
+
deps.updateWidget();
|
|
515
|
+
// handed-off 的提取/回注归恢复路径;但等待者必须被唤醒。
|
|
516
|
+
if (late.handedOff) {
|
|
517
|
+
settleCompletionFromResult(name, late);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const extracted = await deps.extractSubagentResult(
|
|
521
|
+
sessionPath,
|
|
522
|
+
late,
|
|
523
|
+
entryCountBefore,
|
|
524
|
+
"Resumed session",
|
|
525
|
+
);
|
|
526
|
+
const finalResult: SubagentResult = {
|
|
527
|
+
...late,
|
|
528
|
+
summary: extracted.summary,
|
|
529
|
+
sessionFile: sessionPath,
|
|
530
|
+
sessionId: resumedSessionId,
|
|
531
|
+
...cohortDetails,
|
|
532
|
+
};
|
|
533
|
+
settleCompletionFromResult(name, finalResult, {
|
|
534
|
+
...(finalResult.userClosed || finalResult.stopped ? { status: "cancelled" as const } : {}),
|
|
535
|
+
});
|
|
536
|
+
const presentation = finalResult.stopped
|
|
537
|
+
? `Sub-agent "${name}" was stopped explicitly (subagent_stop) after the wait had been detached — ` +
|
|
538
|
+
`no result was produced. Its session is preserved; resume it again if the task still matters.`
|
|
539
|
+
: deps.resolveResultPresentation(finalResult, name);
|
|
540
|
+
const routeException = finalResult.userClosed || finalResult.stopped
|
|
541
|
+
? undefined
|
|
542
|
+
: routeExceptionFromResult(finalResult, effectiveLoadout.model);
|
|
543
|
+
pi.sendMessage(
|
|
544
|
+
{
|
|
545
|
+
customType: "subagent_result",
|
|
546
|
+
content: presentation,
|
|
547
|
+
display: true,
|
|
548
|
+
details: {
|
|
549
|
+
name,
|
|
550
|
+
task: message,
|
|
551
|
+
...cohortDetails,
|
|
552
|
+
exitCode: late.exitCode,
|
|
553
|
+
elapsed: late.elapsed,
|
|
554
|
+
sessionFile: sessionPath,
|
|
555
|
+
sessionId: resumedSessionId,
|
|
556
|
+
status: late.userClosed ? "user_closed" : late.stopped ? "cancelled" : late.exitCode !== 0 || late.errorMessage ? "failed" : "completed",
|
|
557
|
+
lateDelivery: "detached",
|
|
558
|
+
...(late.userClosed ? { userClosed: true } : {}),
|
|
559
|
+
...(late.errorMessage ? { errorMessage: late.errorMessage } : {}),
|
|
560
|
+
...(late.stats ? { stats: late.stats } : {}),
|
|
561
|
+
...(routeException ? { routeException } : {}),
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
565
|
+
);
|
|
566
|
+
})
|
|
567
|
+
.catch((lateError: any) => {
|
|
568
|
+
deps.updateWidget();
|
|
569
|
+
settleCompletionFromResult(name, {
|
|
570
|
+
exitCode: 1,
|
|
571
|
+
errorMessage: lateError?.message ?? String(lateError),
|
|
572
|
+
sessionFile: sessionPath,
|
|
573
|
+
});
|
|
574
|
+
pi.sendMessage(
|
|
575
|
+
{
|
|
576
|
+
customType: "subagent_result",
|
|
577
|
+
content: `Resume error after detached wait: ${lateError?.message ?? String(lateError)}`,
|
|
578
|
+
display: true,
|
|
579
|
+
details: { name, ...cohortDetails, error: lateError?.message, lateDelivery: "detached" },
|
|
580
|
+
},
|
|
581
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
deps.reservedNames.delete(name);
|
|
585
|
+
if (released.kind === "timeout") {
|
|
586
|
+
return {
|
|
587
|
+
content: [{
|
|
588
|
+
type: "text",
|
|
589
|
+
text:
|
|
590
|
+
`Resume of "${name}" wait timed out after ${Math.round((params.timeoutMs ?? 0) / 1000)}s (timeoutMs) — this is NOT a result. ` +
|
|
591
|
+
`The resumed sub-agent is STILL RUNNING and was NOT cancelled, and no summary exists yet — do not assume one. ` +
|
|
592
|
+
`Its real result will be delivered as a steer message when it finishes, exactly once. ` +
|
|
593
|
+
`Do not resume it again meanwhile; steer it with subagent_message if needed, or stop it with subagent_stop.`,
|
|
594
|
+
}],
|
|
595
|
+
details: {
|
|
596
|
+
id,
|
|
597
|
+
name,
|
|
598
|
+
...cohortDetails,
|
|
599
|
+
sessionId: resumedSessionId,
|
|
600
|
+
sessionFile: sessionPath,
|
|
601
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
602
|
+
timeoutMs: params.timeoutMs ?? undefined,
|
|
603
|
+
status: "timed-out",
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
content: [{
|
|
609
|
+
type: "text",
|
|
610
|
+
text:
|
|
611
|
+
`Resume of "${name}" was DETACHED: your tool-call wait was aborted (Escape), but the resumed ` +
|
|
612
|
+
`sub-agent is STILL RUNNING and was NOT cancelled — no result exists yet. ` +
|
|
613
|
+
`Its real result will be delivered as a steer message when it finishes, exactly once. ` +
|
|
614
|
+
`Do not resume it again; steer it with subagent_message if needed, or stop it with subagent_stop.`,
|
|
615
|
+
}],
|
|
616
|
+
details: {
|
|
617
|
+
id,
|
|
618
|
+
name,
|
|
619
|
+
...cohortDetails,
|
|
620
|
+
sessionId: resumedSessionId,
|
|
621
|
+
sessionFile: sessionPath,
|
|
622
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
623
|
+
status: "detached",
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const watcherResult: SubagentResult = raced;
|
|
629
|
+
|
|
630
|
+
deps.updateWidget();
|
|
631
|
+
// /reload 移交:显式 handed-off 语义,不提取伪结果、不报 failed;
|
|
632
|
+
// 恢复路径会在子代理结束后经 steer 消息回注真实结果。
|
|
633
|
+
if (watcherResult.handedOff) {
|
|
634
|
+
// 等待者(如有)必须被唤醒:handed-off 也是注册表承认的终态。
|
|
635
|
+
settleCompletionFromResult(name, watcherResult);
|
|
636
|
+
deps.reservedNames.delete(name);
|
|
637
|
+
return {
|
|
638
|
+
content: [{
|
|
639
|
+
type: "text",
|
|
640
|
+
text:
|
|
641
|
+
`Resume of "${name}" was handed off to recovery because the host was reloaded (/reload). ` +
|
|
642
|
+
`The resumed sub-agent is still running; its real result will be delivered as a steer message ` +
|
|
643
|
+
`when it finishes. Do not treat this as a failure.`,
|
|
644
|
+
}],
|
|
645
|
+
details: {
|
|
646
|
+
id,
|
|
647
|
+
name,
|
|
648
|
+
...cohortDetails,
|
|
649
|
+
sessionId: resumedSessionId,
|
|
650
|
+
sessionFile: sessionPath,
|
|
651
|
+
elapsed: watcherResult.elapsed,
|
|
652
|
+
status: "handed-off",
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
const extracted = await deps.extractSubagentResult(
|
|
657
|
+
sessionPath,
|
|
658
|
+
watcherResult,
|
|
659
|
+
entryCountBefore,
|
|
660
|
+
"Resumed session",
|
|
661
|
+
);
|
|
662
|
+
const userClosed = !!watcherResult.userClosed;
|
|
663
|
+
const cancelled = watcherAbort.signal.aborted || userClosed || !!watcherResult.stopped;
|
|
664
|
+
const finalResult: SubagentResult = {
|
|
665
|
+
...watcherResult,
|
|
666
|
+
summary: extracted.summary,
|
|
667
|
+
sessionFile: sessionPath,
|
|
668
|
+
sessionId: resumedSessionId,
|
|
669
|
+
...cohortDetails,
|
|
670
|
+
};
|
|
671
|
+
const failed = finalResult.exitCode !== 0 || !!finalResult.errorMessage;
|
|
672
|
+
const routeException = cancelled
|
|
673
|
+
? undefined
|
|
674
|
+
: routeExceptionFromResult(finalResult, effectiveLoadout.model);
|
|
675
|
+
// 终态落定完成记录:取消(含用户关闭 pane)兑 cancelled,失败/成功
|
|
676
|
+
// 按结果映射,摘要用 extract 后的真实回复文本。
|
|
677
|
+
settleCompletionFromResult(name, finalResult, {
|
|
678
|
+
...(cancelled ? { status: "cancelled" as const } : {}),
|
|
679
|
+
});
|
|
680
|
+
deps.reservedNames.delete(name);
|
|
681
|
+
return {
|
|
682
|
+
content: [{
|
|
683
|
+
type: "text",
|
|
684
|
+
text: userClosed
|
|
685
|
+
? `Resume of "${name}" ended because its pane was closed by the user — no result was produced. ` +
|
|
686
|
+
`This is not a provider or agent error. Its session is preserved; resume it again if the task still matters.`
|
|
687
|
+
: cancelled
|
|
688
|
+
? `Resume of "${name}" was stopped before finishing — no result was produced. ` +
|
|
689
|
+
`Its session is preserved; resume again with subagent_message({ name: "${name}", message: "…" }) if needed.`
|
|
690
|
+
: deps.resolveResultPresentation(finalResult, name),
|
|
691
|
+
}],
|
|
692
|
+
details: {
|
|
693
|
+
id,
|
|
694
|
+
name,
|
|
695
|
+
...cohortDetails,
|
|
696
|
+
sessionId: resumedSessionId,
|
|
697
|
+
sessionFile: sessionPath,
|
|
698
|
+
exitCode: finalResult.exitCode,
|
|
699
|
+
elapsed: finalResult.elapsed,
|
|
700
|
+
status: userClosed ? "user_closed" : cancelled ? "cancelled" : failed ? "failed" : "completed",
|
|
701
|
+
...(userClosed ? { userClosed: true } : {}),
|
|
702
|
+
...(finalResult.errorMessage ? { errorMessage: finalResult.errorMessage } : {}),
|
|
703
|
+
...(finalResult.stats ? { stats: finalResult.stats } : {}),
|
|
704
|
+
...(routeException ? { routeException } : {}),
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const interactiveWatch = deps.watchSubagent(running, watcherAbort.signal);
|
|
710
|
+
running.watchPromise = interactiveWatch;
|
|
711
|
+
interactiveWatch
|
|
712
|
+
.then(async (result) => {
|
|
713
|
+
deps.updateWidget();
|
|
714
|
+
settleCompletionFromResult(name, result, {
|
|
715
|
+
...(result.userClosed ? { status: "cancelled" as const } : {}),
|
|
716
|
+
});
|
|
717
|
+
// /reload 移交:恢复路径回注真实结果,这里跳过,避免伪造/重复结果。
|
|
718
|
+
if (result.handedOff) return;
|
|
719
|
+
const extracted = await deps.extractSubagentResult(
|
|
720
|
+
sessionPath,
|
|
721
|
+
result,
|
|
722
|
+
entryCountBefore,
|
|
723
|
+
"Resumed session",
|
|
724
|
+
);
|
|
725
|
+
const finalResult: SubagentResult = {
|
|
726
|
+
...result,
|
|
727
|
+
summary: extracted.summary,
|
|
728
|
+
sessionFile: sessionPath,
|
|
729
|
+
sessionId: resumedSessionId,
|
|
730
|
+
...cohortDetails,
|
|
731
|
+
};
|
|
732
|
+
const presentation = deps.resolveResultPresentation(finalResult, name);
|
|
733
|
+
const routeException = routeExceptionFromResult(finalResult, effectiveLoadout.model);
|
|
734
|
+
pi.sendMessage(
|
|
735
|
+
{
|
|
736
|
+
customType: "subagent_result",
|
|
737
|
+
content: presentation,
|
|
738
|
+
display: true,
|
|
739
|
+
details: {
|
|
740
|
+
name,
|
|
741
|
+
task: message,
|
|
742
|
+
...cohortDetails,
|
|
743
|
+
exitCode: result.exitCode,
|
|
744
|
+
elapsed: result.elapsed,
|
|
745
|
+
sessionFile: sessionPath,
|
|
746
|
+
sessionId: resumedSessionId,
|
|
747
|
+
status: result.userClosed ? "user_closed" : result.exitCode !== 0 || result.errorMessage ? "failed" : "completed",
|
|
748
|
+
...(result.userClosed ? { userClosed: true } : {}),
|
|
749
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
750
|
+
...(result.stats ? { stats: result.stats } : {}),
|
|
751
|
+
...(routeException ? { routeException } : {}),
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
755
|
+
);
|
|
756
|
+
})
|
|
757
|
+
.catch((error) => {
|
|
758
|
+
deps.updateWidget();
|
|
759
|
+
settleCompletionFromResult(name, { exitCode: 1, errorMessage: error?.message ?? String(error) });
|
|
760
|
+
// watcher 正常不会 reject;万一 reject,真实上报错误信息。
|
|
761
|
+
pi.sendMessage(
|
|
762
|
+
{
|
|
763
|
+
customType: "subagent_result",
|
|
764
|
+
content: `Resume error: ${error?.message ?? String(error)}`,
|
|
765
|
+
display: true,
|
|
766
|
+
details: { name, ...cohortDetails, error: error?.message },
|
|
767
|
+
},
|
|
768
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
deps.reservedNames.delete(name);
|
|
773
|
+
return {
|
|
774
|
+
content: [{ type: "text", text: `Session "${name}" resumed.` }],
|
|
775
|
+
details: {
|
|
776
|
+
id,
|
|
777
|
+
name,
|
|
778
|
+
...cohortDetails,
|
|
779
|
+
sessionId: resumedSessionId,
|
|
780
|
+
sessionFile: sessionPath,
|
|
781
|
+
status: "started",
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
} catch (error) {
|
|
785
|
+
// resume 启动失败也必须释放完成记录:等待者拿到明确失败,不永久挂起。
|
|
786
|
+
settleCompletionFromResult(name, {
|
|
787
|
+
exitCode: 1,
|
|
788
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
789
|
+
}, { source: "launch-failure" });
|
|
790
|
+
deps.reservedNames.delete(name);
|
|
791
|
+
if (deps.runningSubagents.get(id)?.surface === createdSurface) {
|
|
792
|
+
deps.runningSubagents.delete(id);
|
|
793
|
+
}
|
|
794
|
+
deps.removeRuntimeRecord(deps.runtimeRegistryPath(artifactDir), id);
|
|
795
|
+
if (child) {
|
|
796
|
+
try { child.kill(); } catch (closeError) {
|
|
797
|
+
debugLog(`Could not kill failed resume child ${createdSurface}`, closeError);
|
|
798
|
+
}
|
|
799
|
+
} else if (createdSurface) {
|
|
800
|
+
try { deps.closeSurface(createdSurface); } catch (closeError) {
|
|
801
|
+
debugLog(`Could not close failed resume pane ${createdSurface}`, closeError);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
}
|