pi-subagents 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/docs/agents.md +8 -3
  7. package/docs/extension-api.md +1 -1
  8. package/docs/models.md +2 -0
  9. package/docs/observability.md +37 -1
  10. package/docs/workflows.md +2 -0
  11. package/package.json +1 -1
  12. package/skills/pi-subagents/SKILL.md +1 -0
  13. package/skills/pi-subagents/references/constraints-and-recipes.md +6 -2
  14. package/skills/pi-subagents/references/execution-controls.md +12 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +2 -0
  16. package/src/agents/agent-management.ts +24 -6
  17. package/src/agents/agents.ts +37 -11
  18. package/src/agents/skills.ts +1 -1
  19. package/src/api/preflight.ts +5 -2
  20. package/src/extension/index.ts +1 -0
  21. package/src/extension/public-execution.ts +3 -1
  22. package/src/intercom/native-supervisor-channel.ts +2 -1
  23. package/src/missions/workflow-state.ts +2 -2
  24. package/src/runs/background/async-execution.ts +19 -9
  25. package/src/runs/background/async-resume.ts +19 -2
  26. package/src/runs/background/async-retention.ts +2 -4
  27. package/src/runs/background/completion-dedupe.ts +5 -1
  28. package/src/runs/background/completion-replay.ts +22 -12
  29. package/src/runs/background/fleet-view.ts +68 -19
  30. package/src/runs/background/inspect-rpc.ts +443 -0
  31. package/src/runs/background/notify.ts +28 -4
  32. package/src/runs/background/result-watcher.ts +44 -0
  33. package/src/runs/background/resume-guidance.ts +8 -5
  34. package/src/runs/background/scheduled-runs.ts +2 -0
  35. package/src/runs/background/subagent-runner.ts +3 -2
  36. package/src/runs/background/subagent-wait.ts +1 -1
  37. package/src/runs/background/wait-subscriptions.ts +1 -1
  38. package/src/runs/foreground/execution.ts +1 -1
  39. package/src/runs/foreground/subagent-executor.ts +239 -76
  40. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  41. package/src/runs/shared/acceptance.ts +4 -4
  42. package/src/runs/shared/external-job-bridge.ts +0 -6
  43. package/src/runs/shared/model-fallback.ts +77 -34
  44. package/src/runs/shared/session-lease.ts +0 -6
  45. package/src/runs/shared/worktree.ts +2 -1
  46. package/src/shared/types.ts +11 -0
  47. package/src/slash/slash-bridge.ts +2 -1
  48. package/src/slash/slash-commands.ts +18 -0
  49. package/src/watchdog/change-signature.ts +1 -1
  50. package/src/watchdog/lsp-diagnostics.ts +1 -0
  51. package/src/workflows/chat-progress.ts +1 -1
  52. package/src/workflows/scripted-workflow.ts +13 -2
  53. package/agents/gpt-pro.md +0 -17
@@ -0,0 +1,443 @@
1
+ import * as fs from "node:fs";
2
+ import { sanitizeDisplayText, truncateDisplayText } from "../../shared/display-text.ts";
3
+ import { decodeUtf8Tail } from "../../shared/utf8.ts";
4
+ import { DIRS, type AsyncStatus, type NestedRunSummary, type SubagentState } from "../../shared/types.ts";
5
+ import { readStatus } from "../../shared/utils.ts";
6
+ import { readSessionMessagesTail, type SessionTranscriptMessage } from "./fleet-view.ts";
7
+ import { completionReplayPath, readCompletionArchive, readCompletionReplay } from "./completion-replay.ts";
8
+ import { resultPayloadPathForSessionRun } from "./result-files.ts";
9
+ import { resolveSubagentRunId } from "./run-id-resolver.ts";
10
+ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
11
+
12
+ /** On-demand inspection of current-session async children. Re-reads canonical artifacts after the same reconciliation as status; nothing is persisted or broadcast. */
13
+
14
+ export const INSPECT_REPLY_KIND = "pi-subagents.inspect-reply";
15
+ export const INSPECT_REPLY_VERSION = 1;
16
+ export const INSPECT_WIDGET_KEY = "subagent-inspect";
17
+ export const INSPECT_WIDGET_PREFIX = "PI_SUBAGENT_INSPECT_JSON:";
18
+
19
+ export type InspectErrorCode =
20
+ | "invalid_request"
21
+ | "not_found"
22
+ | "foreign_session"
23
+ | "stale"
24
+ | "no_active_session"
25
+ | "internal";
26
+
27
+ export interface InspectRequest {
28
+ requestId: string;
29
+ asyncId: string;
30
+ childId?: string;
31
+ lines?: number;
32
+ }
33
+
34
+ export interface InspectReplyMessage {
35
+ role: string;
36
+ kind: "text" | "toolCall" | "toolResult";
37
+ text: string;
38
+ name?: string;
39
+ isError?: boolean;
40
+ }
41
+
42
+ export interface InspectReply {
43
+ kind: typeof INSPECT_REPLY_KIND;
44
+ version: typeof INSPECT_REPLY_VERSION;
45
+ requestId: string;
46
+ /** Canonical run id of the inspected node. Absent on error replies that
47
+ * could not resolve a run. */
48
+ asyncId?: string;
49
+ childId?: string;
50
+ status?: AsyncStatus["state"];
51
+ label?: string;
52
+ task?: string;
53
+ messages?: InspectReplyMessage[];
54
+ finalOutput?: string;
55
+ truncated?: { task: boolean; messages: number; finalOutput: boolean };
56
+ error?: { code: InspectErrorCode; message: string };
57
+ }
58
+
59
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
60
+ const MAX_ID_LENGTH = 256;
61
+ const MAX_LABEL_LENGTH = 160;
62
+ const MAX_TASK_LENGTH = 2_000;
63
+ const MAX_FINAL_OUTPUT_LENGTH = 8_000;
64
+ const MAX_MESSAGE_TEXT_LENGTH = 1_000;
65
+ const DEFAULT_MESSAGE_LINES = 100;
66
+ const MAX_MESSAGE_LINES = 200;
67
+ const FAILED_OUTPUT_ARTIFACT_PREFIX = "Subagent run failed before producing output.\n\nError:\n";
68
+ export const MAX_SERIALIZED_BYTES = 64 * 1024;
69
+
70
+ export interface InspectDeps {
71
+ state?: SubagentState;
72
+ asyncDirRoot?: string;
73
+ resultsDir?: string;
74
+ kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
75
+ now?: () => number;
76
+ }
77
+
78
+ function isRecord(value: unknown): value is Record<string, unknown> {
79
+ return typeof value === "object" && value !== null && !Array.isArray(value);
80
+ }
81
+
82
+ function publicText(value: unknown, maxLength: number): string | undefined {
83
+ if (typeof value !== "string") return undefined;
84
+ const normalized = sanitizeDisplayText(value.slice(0, maxLength * 4));
85
+ return normalized ? truncateDisplayText(normalized, maxLength) : undefined;
86
+ }
87
+
88
+ /** Bound long-form content without flattening it: task/finalOutput/message text
89
+ * must keep newlines readable. Only the two unicode line separators are
90
+ * normalized, since JSON.stringify does not escape them and hosts split the
91
+ * widget payload on line boundaries. */
92
+ function boundContent(value: string, maxLength: number): string {
93
+ const normalized = value.replace(/[\u2028\u2029]/g, "\n");
94
+ return truncateDisplayText(normalized, maxLength);
95
+ }
96
+
97
+ function errorReply(request: Partial<InspectRequest>, code: InspectErrorCode, message: string): InspectReply {
98
+ return {
99
+ kind: INSPECT_REPLY_KIND,
100
+ version: INSPECT_REPLY_VERSION,
101
+ requestId: typeof request.requestId === "string" && REQUEST_ID_PATTERN.test(request.requestId) ? request.requestId : "invalid",
102
+ ...(typeof request.asyncId === "string" ? { asyncId: truncateDisplayText(request.asyncId, MAX_ID_LENGTH) } : {}),
103
+ ...(typeof request.childId === "string" ? { childId: truncateDisplayText(request.childId, MAX_ID_LENGTH) } : {}),
104
+ error: { code, message },
105
+ };
106
+ }
107
+
108
+ export function parseInspectRequest(args: string): { request?: InspectRequest; error?: string } {
109
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
110
+ const positional: string[] = [];
111
+ let lines: number | undefined;
112
+ for (let index = 0; index < tokens.length; index++) {
113
+ const token = tokens[index]!;
114
+ if (token === "--lines") {
115
+ const value = tokens[++index];
116
+ const parsed = value === undefined ? NaN : Number.parseInt(value, 10);
117
+ if (!Number.isFinite(parsed)) return { error: "--lines requires an integer value." };
118
+ lines = parsed;
119
+ continue;
120
+ }
121
+ if (token.startsWith("--")) return { error: `Unknown flag: ${token}. Supported: --lines N.` };
122
+ positional.push(token);
123
+ }
124
+ const [requestId, asyncId, childId, ...extra] = positional;
125
+ if (!requestId || !REQUEST_ID_PATTERN.test(requestId)) {
126
+ return { error: "requestId must match [A-Za-z0-9_-]{1,64}." };
127
+ }
128
+ if (!asyncId) return { error: "Usage: /subagents-inspect-rpc <requestId> <asyncId> [childId] [--lines N]." };
129
+ if (asyncId.length > MAX_ID_LENGTH || (childId !== undefined && childId.length > MAX_ID_LENGTH)) {
130
+ return { error: "asyncId/childId exceed the maximum length." };
131
+ }
132
+ if (extra.length > 0) return { error: "Too many positional arguments. Usage: /subagents-inspect-rpc <requestId> <asyncId> [childId] [--lines N]." };
133
+ if (lines !== undefined && (lines < 1 || !Number.isSafeInteger(lines))) return { error: "--lines must be a positive integer." };
134
+ return { request: { requestId, asyncId, childId, lines } };
135
+ }
136
+
137
+ interface ResolvedNode {
138
+ asyncDir: string;
139
+ status: AsyncStatus;
140
+ stepIndex?: number;
141
+ label?: string;
142
+ }
143
+
144
+ function findChildNode(status: AsyncStatus, asyncDir: string, childId: string): ResolvedNode | { error: string } {
145
+ const steps = status.steps ?? [];
146
+ for (let index = 0; index < steps.length; index++) {
147
+ const step = steps[index]!;
148
+ const ids = [step.workflowKey, step.runId, `step:${index}`];
149
+ if (ids.includes(childId)) {
150
+ return { asyncDir, status, stepIndex: index, label: step.label ?? step.agent };
151
+ }
152
+ }
153
+ const findNested = (children: readonly NestedRunSummary[] | undefined): NestedRunSummary | undefined => {
154
+ for (const child of children ?? []) {
155
+ if (child.id === childId) return child;
156
+ const deeper = findNested(child.children);
157
+ if (deeper) return deeper;
158
+ }
159
+ return undefined;
160
+ };
161
+ for (const step of steps) {
162
+ const nested = findNested(step.children);
163
+ if (nested) {
164
+ if (!nested.asyncDir) return { error: `Child '${childId}' has no async directory to inspect.` };
165
+ const childStatus = readStatus(nested.asyncDir);
166
+ if (!childStatus) return { error: `Child '${childId}' status is unavailable.` };
167
+ return { asyncDir: nested.asyncDir, status: childStatus, label: nested.agent ?? nested.agents?.join(", ") };
168
+ }
169
+ }
170
+ return { error: `Child '${childId}' was not found under async run '${status.runId}'.` };
171
+ }
172
+
173
+ function resultOutput(data: unknown, stepIndex: number | undefined): { output?: string; errorText?: string } {
174
+ if (!isRecord(data)) throw new Error("Async result payload must be an object.");
175
+ if (data.results !== undefined && !Array.isArray(data.results)) throw new Error("Async result payload results must be an array.");
176
+ if (data.summary !== undefined && typeof data.summary !== "string") throw new Error("Async result payload summary must be a string.");
177
+ const results = data.results;
178
+ if (stepIndex !== undefined && results) {
179
+ const stepResult = results[stepIndex];
180
+ if (stepResult === undefined) return {};
181
+ if (!isRecord(stepResult)) throw new Error("Async result payload step result must be an object.");
182
+ if (stepResult.output !== undefined && typeof stepResult.output !== "string") throw new Error("Async result payload step output must be a string.");
183
+ if (stepResult.error !== undefined && typeof stepResult.error !== "string") throw new Error("Async result payload step error must be a string.");
184
+ return {
185
+ ...(typeof stepResult.output === "string" ? { output: stepResult.output } : {}),
186
+ ...(typeof stepResult.error === "string" ? { errorText: stepResult.error } : {}),
187
+ };
188
+ }
189
+ if (typeof data.summary === "string") return { output: data.summary };
190
+ if (results?.length === 1) {
191
+ const result = results[0];
192
+ if (!isRecord(result)) throw new Error("Async result payload result must be an object.");
193
+ if (result.output !== undefined && typeof result.output !== "string") throw new Error("Async result payload output must be a string.");
194
+ if (typeof result.output === "string") return { output: result.output };
195
+ }
196
+ return {};
197
+ }
198
+
199
+ function readOutputArtifact(outputPath: string): { output?: string; errorText?: string } {
200
+ const file = fs.openSync(outputPath, "r");
201
+ try {
202
+ const prefix = Buffer.allocUnsafe(Buffer.byteLength(FAILED_OUTPUT_ARTIFACT_PREFIX, "utf-8"));
203
+ const prefixBytes = fs.readSync(file, prefix, 0, prefix.length, 0);
204
+ const failedOutput = prefix.subarray(0, prefixBytes).toString("utf-8") === FAILED_OUTPUT_ARTIFACT_PREFIX;
205
+ const size = fs.fstatSync(file).size;
206
+ const length = Math.min(size, MAX_FINAL_OUTPUT_LENGTH * 4);
207
+ const buffer = Buffer.allocUnsafe(length);
208
+ const bytesRead = fs.readSync(file, buffer, 0, length, size - length);
209
+ let text = decodeUtf8Tail(buffer.subarray(0, bytesRead));
210
+ if (!failedOutput) return { output: text };
211
+ const metadata = text.lastIndexOf("\nMetadata: ");
212
+ if (metadata >= 0 && !text.slice(metadata + 1).includes("\n")) text = text.slice(0, metadata);
213
+ const transcript = text.lastIndexOf("\n\nTranscript: ");
214
+ if (transcript >= 0 && !text.slice(transcript + 2).includes("\n")) text = text.slice(0, transcript);
215
+ return { errorText: text.startsWith(FAILED_OUTPUT_ARTIFACT_PREFIX) ? text.slice(FAILED_OUTPUT_ARTIFACT_PREFIX.length) : text };
216
+ } finally {
217
+ fs.closeSync(file);
218
+ }
219
+ }
220
+
221
+ function readSessionBackedOutput(sessionPath: string, trustedRoots: string[]): { output?: string } {
222
+ if (trustedRoots.length === 0) return {};
223
+ // The archive retains the child's session file as its output record. The
224
+ // final answer is the terminal assistant message's text — all text parts
225
+ // of that one session record, not just the last part.
226
+ const tail = readSessionMessagesTail(sessionPath, MAX_MESSAGE_LINES, trustedRoots);
227
+ const last = tail.messages.findLast((message) => message.role === "assistant" && message.kind === "text");
228
+ if (!last) return {};
229
+ const parts = tail.messages.filter((message) => message.recordIndex === last.recordIndex && message.kind === "text");
230
+ return { output: parts.map((part) => part.text).join("\n") };
231
+ }
232
+
233
+ function readResultOutput(resultsDir: string, sessionId: string, runId: string, stepIndex: number | undefined, trustedRoots: string[], stepAgent: string | undefined, now: () => number): { output?: string; errorText?: string } {
234
+ const resultPath = resultPayloadPathForSessionRun(resultsDir, sessionId, runId);
235
+ if (resultPath) return resultOutput(JSON.parse(fs.readFileSync(resultPath, "utf-8")) as unknown, stepIndex);
236
+ // Read the raw record first: readCompletionReplay best-effort deletes
237
+ // invalid or expired records, so reading after it cannot distinguish
238
+ // "never existed" from "failed validation".
239
+ const replayPath = completionReplayPath(resultsDir, runId);
240
+ let rawReplay: unknown;
241
+ try {
242
+ rawReplay = JSON.parse(fs.readFileSync(replayPath, "utf-8")) as unknown;
243
+ } catch (error) {
244
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
245
+ }
246
+ const nowMs = now();
247
+ const replay = readCompletionReplay(resultsDir, runId, { sessionId, now: nowMs });
248
+ if (!replay) {
249
+ // readCompletionReplay also returns undefined for absent, expired, foreign,
250
+ // and unknown-version records. A current-version, unexpired record for this
251
+ // run and session that still failed validation is an inspection failure,
252
+ // not a child without output — surface it instead of replying success with
253
+ // no finalOutput.
254
+ if (isRecord(rawReplay) && rawReplay.version === 1 && rawReplay.runId === runId
255
+ && rawReplay.sessionId === sessionId
256
+ && typeof rawReplay.expiresAt === "number" && rawReplay.expiresAt > nowMs) {
257
+ throw new Error("Completion replay record failed validation.");
258
+ }
259
+ return {};
260
+ }
261
+ const archive = readCompletionArchive(replay.archivePath);
262
+ // Run-level inspection must not attribute a child's output to the run in a
263
+ // multi-child archive. Current child entries carry resultIndex and agent;
264
+ // run-level entries carry neither. Legacy archives (written before
265
+ // resultIndex existed) have child entries with agent but no resultIndex,
266
+ // so child reads fall back to a unique agent name. Duplicate legacy agent
267
+ // names fail closed. A single-child archive is the one exception: the run's
268
+ // output IS that child's output, including a legacy agent-tagged entry.
269
+ const childEntries = archive?.entries.filter((entry) => entry.resultIndex !== undefined || entry.agent !== undefined) ?? [];
270
+ const singleChild = childEntries.length === 1;
271
+ const uniqueLegacyAgent = (agent: string | undefined): boolean =>
272
+ agent !== undefined
273
+ && archive?.entries.filter((entry) => entry.resultIndex === undefined && entry.agent === agent).length === 1;
274
+ const matches = (entry: { resultIndex?: number; agent?: string }): boolean => {
275
+ if (stepIndex !== undefined) {
276
+ if (entry.resultIndex !== undefined) return entry.resultIndex === stepIndex;
277
+ return uniqueLegacyAgent(stepAgent) && entry.agent === stepAgent;
278
+ }
279
+ if (singleChild) return entry.resultIndex !== undefined || entry.agent !== undefined;
280
+ return entry.resultIndex === undefined && entry.agent === undefined;
281
+ };
282
+ const artifact = archive?.entries.find((entry) => entry.source === "output-artifact" && matches(entry));
283
+ const artifactPath = artifact?.source === "output-artifact" ? artifact.path : undefined;
284
+ if (artifactPath) return readOutputArtifact(artifactPath);
285
+ const sessionEntry = archive?.entries.find((entry) => entry.source === "session" && matches(entry));
286
+ const sessionPath = sessionEntry?.source === "session" ? sessionEntry.path : undefined;
287
+ if (sessionPath) return readSessionBackedOutput(sessionPath, trustedRoots);
288
+ const output = archive?.entries.find((entry) => entry.source === "result-tail" && matches(entry))?.text;
289
+ return output ? { output } : {};
290
+ }
291
+
292
+ function toReplyMessage(message: SessionTranscriptMessage): InspectReplyMessage {
293
+ return {
294
+ role: truncateDisplayText(sanitizeDisplayText(message.role), 32),
295
+ kind: message.kind,
296
+ text: boundContent(message.text, MAX_MESSAGE_TEXT_LENGTH),
297
+ ...(message.name ? { name: truncateDisplayText(sanitizeDisplayText(message.name), 96) } : {}),
298
+ ...(message.isError === true ? { isError: true } : {}),
299
+ };
300
+ }
301
+
302
+ /** Fit the reply under MAX_SERIALIZED_BYTES: drop oldest messages first, then
303
+ * shrink finalOutput, then task. The envelope always survives. */
304
+ function enforceByteBudget(reply: InspectReply): InspectReply {
305
+ const truncated = reply.truncated ?? { task: false, messages: 0, finalOutput: false };
306
+ const sized = (): number => Buffer.byteLength(JSON.stringify(reply), "utf-8");
307
+ while (reply.messages && reply.messages.length > 0 && sized() > MAX_SERIALIZED_BYTES) {
308
+ reply.messages.shift();
309
+ truncated.messages += 1;
310
+ }
311
+ if (sized() > MAX_SERIALIZED_BYTES && reply.finalOutput) {
312
+ reply.finalOutput = truncateDisplayText(reply.finalOutput, Math.max(0, reply.finalOutput.length - (sized() - MAX_SERIALIZED_BYTES) - 256));
313
+ truncated.finalOutput = true;
314
+ }
315
+ if (sized() > MAX_SERIALIZED_BYTES && reply.task) {
316
+ reply.task = truncateDisplayText(reply.task, Math.max(0, reply.task.length - (sized() - MAX_SERIALIZED_BYTES) - 256));
317
+ truncated.task = true;
318
+ }
319
+ if (truncated.task || truncated.messages > 0 || truncated.finalOutput) reply.truncated = truncated;
320
+ return reply;
321
+ }
322
+
323
+ export function buildInspectReply(request: InspectRequest, deps: InspectDeps = {}): InspectReply {
324
+ const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
325
+ const resultsDir = deps.resultsDir ?? DIRS.results;
326
+ const currentSessionId = deps.state?.currentSessionId ?? undefined;
327
+ if (!currentSessionId) {
328
+ return errorReply(request, "no_active_session", "Inspection requires an active session; the request could not be attributed to a session.");
329
+ }
330
+
331
+ let resolved: ReturnType<typeof resolveSubagentRunId>;
332
+ try {
333
+ resolved = resolveSubagentRunId(request.asyncId, { asyncDirRoot, resultsDir, state: deps.state });
334
+ } catch {
335
+ return errorReply(request, "invalid_request", "The requested async run could not be resolved.");
336
+ }
337
+ if (!resolved || resolved.kind === "foreground") {
338
+ return errorReply(request, "not_found", `Async run '${request.asyncId}' was not found.`);
339
+ }
340
+
341
+ try {
342
+ let asyncDir: string;
343
+ let status: AsyncStatus | null;
344
+ if (resolved.kind === "nested") {
345
+ reconcileNestedAsyncDescendants(resolved.match.route, { resultsDir, kill: deps.kill, now: deps.now });
346
+ asyncDir = resolved.match.run.asyncDir ?? "";
347
+ status = asyncDir ? readStatus(asyncDir) : null;
348
+ } else {
349
+ if (!resolved.location.asyncDir) return errorReply(request, "stale", `Async run '${request.asyncId}' has no remaining artifacts.`);
350
+ asyncDir = resolved.location.asyncDir;
351
+ status = reconcileAsyncRun(asyncDir, { resultsDir, kill: deps.kill, now: deps.now }).status ?? readStatus(asyncDir);
352
+ }
353
+ if (!status) return errorReply(request, "stale", `Async run '${request.asyncId}' artifacts are no longer available.`);
354
+ if (status.sessionId !== currentSessionId) {
355
+ return errorReply(request, "foreign_session", "Inspection is only available for async runs owned by the current session.");
356
+ }
357
+
358
+ const node: ResolvedNode | { error: string } = request.childId === undefined
359
+ ? { asyncDir, status }
360
+ : findChildNode(status, asyncDir, request.childId);
361
+ if ("error" in node) return errorReply(request, "not_found", node.error);
362
+ if (node.asyncDir !== asyncDir) {
363
+ if (node.status.sessionId !== currentSessionId) {
364
+ return errorReply(request, "foreign_session", "Inspection is only available for async runs owned by the current session.");
365
+ }
366
+ reconcileAsyncRun(node.asyncDir, { resultsDir, kill: deps.kill, now: deps.now });
367
+ node.status = readStatus(node.asyncDir) ?? node.status;
368
+ }
369
+
370
+ const step = node.stepIndex !== undefined ? node.status.steps?.[node.stepIndex] : undefined;
371
+ const lineLimit = Math.max(1, Math.min(MAX_MESSAGE_LINES, Math.trunc(request.lines ?? DEFAULT_MESSAGE_LINES)));
372
+ const sessionFile = step?.sessionFile ?? node.status.sessionFile;
373
+ const trustedRoots = [...new Set([
374
+ ...(deps.state?.trustedSessionRoots ?? []),
375
+ ...(node.status.sessionRoot ? [node.status.sessionRoot] : []),
376
+ ])];
377
+
378
+ let messages: InspectReplyMessage[] | undefined;
379
+ let task: string | undefined;
380
+ if (sessionFile && trustedRoots.length > 0) {
381
+ const tail = readSessionMessagesTail(sessionFile, lineLimit, trustedRoots);
382
+ if (tail.messages.length > 0) {
383
+ messages = tail.messages.map(toReplyMessage);
384
+ // The delegated task is the child session's first user message, but
385
+ // only when it is genuinely attributable: fresh-context children
386
+ // (forked sessions begin with inherited parent history) whose
387
+ // session file is fully inside the read window (a truncated tail
388
+ // means the first visible user message may be a steering message).
389
+ if (!tail.truncated && node.status.context !== "fork" && step?.context !== "fork") {
390
+ const firstUser = tail.messages.find((message) => message.role === "user" && message.kind === "text");
391
+ if (firstUser) task = firstUser.text;
392
+ }
393
+ }
394
+ }
395
+
396
+ const result = readResultOutput(resultsDir, currentSessionId, node.status.runId, node.stepIndex, trustedRoots, step?.agent, deps.now ?? Date.now);
397
+ const failedText = step?.error ?? node.status.error;
398
+ if (result.output === undefined && result.errorText === undefined && failedText !== undefined) {
399
+ return errorReply(request, "internal", "Inspection could not read the async run artifacts.");
400
+ }
401
+ const finalOutputRaw = result.output ?? result.errorText;
402
+
403
+ const truncated = { task: false, messages: 0, finalOutput: false };
404
+ const boundedTask = task !== undefined ? boundContent(task, MAX_TASK_LENGTH) : undefined;
405
+ if (task !== undefined && boundedTask !== task) truncated.task = true;
406
+ const boundedFinal = finalOutputRaw !== undefined ? boundContent(finalOutputRaw, MAX_FINAL_OUTPUT_LENGTH) : undefined;
407
+ if (finalOutputRaw !== undefined && boundedFinal !== finalOutputRaw) truncated.finalOutput = true;
408
+ const label = publicText(node.label ?? step?.label ?? step?.agent ?? node.status.runId, MAX_LABEL_LENGTH);
409
+
410
+ const reply: InspectReply = {
411
+ kind: INSPECT_REPLY_KIND,
412
+ version: INSPECT_REPLY_VERSION,
413
+ requestId: request.requestId,
414
+ asyncId: node.status.runId,
415
+ ...(request.childId !== undefined ? { childId: request.childId } : {}),
416
+ status: node.status.state,
417
+ ...(label ? { label } : {}),
418
+ ...(boundedTask !== undefined ? { task: boundedTask } : {}),
419
+ ...(messages && messages.length > 0 ? { messages } : {}),
420
+ ...(boundedFinal !== undefined ? { finalOutput: boundedFinal } : {}),
421
+ ...(truncated.task || truncated.finalOutput || truncated.messages > 0 ? { truncated } : {}),
422
+ };
423
+ return enforceByteBudget(reply);
424
+ } catch {
425
+ return errorReply(request, "internal", "Inspection could not read the async run artifacts.");
426
+ }
427
+ }
428
+
429
+ export function encodeInspectReply(reply: InspectReply): string[] {
430
+ return [`${INSPECT_WIDGET_PREFIX}${JSON.stringify(reply)}`];
431
+ }
432
+
433
+ /** Parse the slash-command args and always return a correlated inspect reply. */
434
+ export function handleInspectRpcArgs(args: string, deps: InspectDeps = {}): InspectReply {
435
+ const parsed = parseInspectRequest(args);
436
+ if (parsed.error || !parsed.request) {
437
+ // Echo a well-formed requestId so the host can correlate the failure.
438
+ const firstToken = args.trim().split(/\s+/)[0];
439
+ const echo = firstToken !== undefined && REQUEST_ID_PATTERN.test(firstToken) ? { requestId: firstToken } : {};
440
+ return errorReply(echo, "invalid_request", parsed.error ?? "Invalid inspect request.");
441
+ }
442
+ return buildInspectReply(parsed.request, deps);
443
+ }
@@ -14,7 +14,7 @@ import {
14
14
  createCompletionBatcher,
15
15
  resolveCompletionBatchConfig,
16
16
  } from "./completion-batcher.ts";
17
- import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type ParallelHandoffReference, type SubagentState } from "../../shared/types.ts";
17
+ import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type ParallelHandoffReference, type ScheduleOrigin, type SubagentState } from "../../shared/types.ts";
18
18
  import { isUnexplainedProcessSignal } from "../shared/process-signal.ts";
19
19
 
20
20
  export interface SubagentNotifyDetails {
@@ -27,6 +27,8 @@ export interface SubagentNotifyDetails {
27
27
  sessionLabel?: string;
28
28
  sessionValue?: string;
29
29
  handoffPath?: string;
30
+ /** Present when a durable schedule launched the run. */
31
+ scheduleOrigin?: ScheduleOrigin;
30
32
  }
31
33
 
32
34
  export interface CompletionNotification {
@@ -68,6 +70,7 @@ export interface CompletionNotification {
68
70
  /** True when an acknowledged grouped intercom relay already delivered this run. */
69
71
  intercomDelivered?: boolean;
70
72
  parallelHandoff?: ParallelHandoffReference;
73
+ scheduleOrigin?: ScheduleOrigin;
71
74
  }
72
75
 
73
76
  interface NotifyTimerApi {
@@ -94,9 +97,14 @@ function formatSessionLine(details: SubagentNotifyDetails): string | undefined {
94
97
  export function formatSingleCompletion(details: SubagentNotifyDetails): string {
95
98
  const sessionLine = formatSessionLine(details);
96
99
  const taskKind = details.source === "foreground" ? "Detached foreground task" : "Background task";
100
+ const scheduleLine = details.scheduleOrigin
101
+ ? `Scheduled run from **${details.scheduleOrigin.name ?? details.scheduleOrigin.id}** (schedule ${details.scheduleOrigin.id}).`
102
+ : undefined;
97
103
  return [
98
104
  `${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
99
105
  "",
106
+ scheduleLine,
107
+ scheduleLine ? "" : undefined,
100
108
  details.resultPreview.trim() ? details.resultPreview : "(no output)",
101
109
  details.handoffPath ? "" : undefined,
102
110
  details.handoffPath ? `Parallel handoff: ${details.handoffPath}` : undefined,
@@ -111,7 +119,17 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
111
119
  const lines = content.split("\n");
112
120
  const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
113
121
  if (!match) return undefined;
114
- const body = lines.slice(2);
122
+ let body = lines.slice(2);
123
+ // Restore the schedule origin so a re-rendered notice keeps its attribution and
124
+ // does not fold the line into the result preview.
125
+ const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
126
+ let parsedScheduleOrigin: ScheduleOrigin | undefined;
127
+ if (scheduleMatch) {
128
+ const label = scheduleMatch[1]!;
129
+ const id = scheduleMatch[2]!;
130
+ parsedScheduleOrigin = { id, ...(label === id ? {} : { name: label }) };
131
+ body = body.slice(body[1]?.trim() === "" ? 2 : 1);
132
+ }
115
133
  let sessionIndex = -1;
116
134
  for (let i = body.length - 1; i >= 1; i--) {
117
135
  if (body[i - 1]?.trim() === "" && /^(Session|Session file|Session share error):\s+/.test(body[i]!)) {
@@ -138,6 +156,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
138
156
  status: match[2] as SubagentNotifyDetails["status"],
139
157
  ...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
140
158
  ...(match[4] ? { taskInfo: match[4] } : {}),
159
+ ...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
141
160
  resultPreview,
142
161
  ...(handoffPath ? { handoffPath } : {}),
143
162
  ...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
@@ -151,7 +170,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
151
170
  const detail = details[index];
152
171
  if (!detail) continue;
153
172
  const sessionLine = formatSessionLine(detail);
154
- blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}`);
173
+ blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
155
174
  blocks.push(detail.resultPreview.trim() ? detail.resultPreview : "(no output)");
156
175
  if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
157
176
  if (sessionLine) blocks.push(sessionLine);
@@ -171,7 +190,7 @@ function sendCompletion(pi: Pick<ExtensionAPI, "sendMessage">, items: PendingCom
171
190
  if (items.length === 0) return true;
172
191
  const details = items.map((item) => item.details);
173
192
  const content = details.length === 1 ? formatSingleCompletion(details[0]!) : formatGroupedCompletion(details);
174
- const display = details.some((detail) => detail.source === "foreground" || detail.status !== "completed");
193
+ const display = details.some((detail) => detail.source === "foreground" || detail.status !== "completed" || detail.scheduleOrigin !== undefined);
175
194
  try {
176
195
  pi.sendMessage(
177
196
  {
@@ -226,9 +245,14 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
226
245
  : result.sessionFile
227
246
  ? { label: "Session file", value: result.sessionFile }
228
247
  : undefined;
248
+ const rawOrigin = result.scheduleOrigin;
249
+ const scheduleOrigin = rawOrigin && typeof rawOrigin.id === "string"
250
+ ? { id: rawOrigin.id, ...(typeof rawOrigin.name === "string" ? { name: rawOrigin.name } : {}) }
251
+ : undefined;
229
252
  return {
230
253
  agent,
231
254
  status,
255
+ ...(scheduleOrigin ? { scheduleOrigin } : {}),
232
256
  ...(result.source ? { source: result.source } : {}),
233
257
  ...(taskInfo ? { taskInfo } : {}),
234
258
  resultPreview: summary,
@@ -160,6 +160,21 @@ function hasDeliveredNotification(data: ResultFileData): boolean {
160
160
  return typeof data.notificationDeliveredAt === "number" && Number.isFinite(data.notificationDeliveredAt);
161
161
  }
162
162
 
163
+ type PublicResultIdentity = {
164
+ state?: string;
165
+ timestamp?: number;
166
+ };
167
+
168
+ function resultPayloadWasReplaced(delivered: ResultFileData, disk: PublicResultIdentity | undefined): boolean {
169
+ if (!disk) return false;
170
+ const deliveredState = typeof delivered.state === "string" ? delivered.state : undefined;
171
+ if (disk.state && deliveredState && disk.state !== deliveredState) return true;
172
+ const deliveredTimestamp = typeof delivered.timestamp === "number" && Number.isFinite(delivered.timestamp)
173
+ ? delivered.timestamp
174
+ : undefined;
175
+ return disk.timestamp !== undefined && deliveredTimestamp !== undefined && disk.timestamp !== deliveredTimestamp;
176
+ }
177
+
163
178
  function markDeliveredNotification(resultPath: string, data: ResultFileData, runId: string, now: number): ResultFileData {
164
179
  const marked = { ...data, runId, notificationDeliveredAt: now };
165
180
  writeAsyncResultFile(resultPath, marked);
@@ -323,7 +338,20 @@ export function createResultWatcher(
323
338
  return;
324
339
  }
325
340
  processing.add(file);
341
+ let rereadReplacedPayload = false;
326
342
  let resultPath = publicResultPath(file);
343
+ const readPublicResultIdentity = (): PublicResultIdentity | undefined => {
344
+ if (!publicResultFileExists(file)) return undefined;
345
+ const parsed: unknown = JSON.parse(fsApi.readFileSync(publicResultPath(file), "utf-8"));
346
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
347
+ const record = parsed as Record<string, unknown>;
348
+ const state = typeof record.state === "string" && record.state ? record.state : undefined;
349
+ const timestamp = typeof record.timestamp === "number" && Number.isFinite(record.timestamp)
350
+ ? record.timestamp
351
+ : undefined;
352
+ if (!state && timestamp === undefined) return undefined;
353
+ return { state, timestamp };
354
+ };
327
355
  try {
328
356
  const payloadPath = resultPayloadPath(file, observed);
329
357
  if (!payloadPath) return;
@@ -349,6 +377,18 @@ export function createResultWatcher(
349
377
  }
350
378
  }
351
379
  let data = parseResult(raw);
380
+ const markReplacedPayload = (): boolean => {
381
+ try {
382
+ if (!resultPayloadWasReplaced(data, readPublicResultIdentity())) return false;
383
+ } catch (error) {
384
+ if (isAccessDenied(error)) throw error;
385
+ if (isNotFound(error)) return false;
386
+ console.error(`Failed to re-read subagent result file '${publicResultPath(file)}':`, error);
387
+ }
388
+ identityCache.delete(file);
389
+ rereadReplacedPayload = true;
390
+ return true;
391
+ };
352
392
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
353
393
  const sessionId = data.sessionId;
354
394
  const completionOwnerId = data.completionOwnerId;
@@ -399,6 +439,7 @@ export function createResultWatcher(
399
439
  return;
400
440
  }
401
441
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
442
+ if (markReplacedPayload()) return;
402
443
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
403
444
  return;
404
445
  }
@@ -454,6 +495,7 @@ export function createResultWatcher(
454
495
  return;
455
496
  }
456
497
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
498
+ if (markReplacedPayload()) return;
457
499
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
458
500
  return;
459
501
  }
@@ -503,6 +545,7 @@ export function createResultWatcher(
503
545
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
504
546
  return;
505
547
  }
548
+ if (markReplacedPayload()) return;
506
549
  try {
507
550
  data = markDeliveredNotification(publicResultPath(file), data, runId, Date.now());
508
551
  identityCache.delete(file);
@@ -548,6 +591,7 @@ export function createResultWatcher(
548
591
  } else if (!isNotFound(error)) console.error(`Failed to process subagent result file '${resultPath}':`, error);
549
592
  } finally {
550
593
  processing.delete(file);
594
+ if (rereadReplacedPayload) scheduleResult(file, triggerTurn);
551
595
  }
552
596
  };
553
597
 
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AsyncRunSummary } from "./async-status.ts";
3
+ import { readAsyncRecoveryDescriptor } from "./async-resume.ts";
3
4
 
4
5
  const INTERCOM_DETACH_ERROR = "detached for intercom coordination";
5
6
 
@@ -15,11 +16,13 @@ function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined
15
16
  }
16
17
 
17
18
  export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
18
- const step = run.steps.find((candidate) => candidate.status === "failed" && candidate.sessionFile && fs.existsSync(candidate.sessionFile));
19
- if (!step) {
20
- if (run.steps.length === 1 && run.sessionFile && fs.existsSync(run.sessionFile)) {
21
- return `subagent({ action: "resume", id: "${run.id}", message: "Continue from the persisted child session and report the result." })`;
22
- }
19
+ const step = run.steps.find((candidate) => candidate.status === "failed");
20
+ const sessionFile = step?.sessionFile ?? (run.steps.length === 1 ? run.sessionFile : undefined);
21
+ if (!step || !sessionFile || !fs.existsSync(sessionFile)) return undefined;
22
+ try {
23
+ const descriptor = readAsyncRecoveryDescriptor(run.asyncDir);
24
+ if (!descriptor || descriptor.sourceRunId !== run.id || descriptor.agent !== step.agent) return undefined;
25
+ } catch {
23
26
  return undefined;
24
27
  }
25
28
  const index = run.steps.length === 1 ? "" : `, index: ${step.index}`;
@@ -346,6 +346,8 @@ function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
346
346
  context: "fresh",
347
347
  cwd: schedule.cwd,
348
348
  mission: false,
349
+ // Scheduled fires have no operator watching, so completions must name the origin.
350
+ scheduleOrigin: { id: schedule.id, ...(schedule.name ? { name: schedule.name } : {}) },
349
351
  ...(schedule.timeoutMs === undefined ? {} : { timeoutMs: schedule.timeoutMs }),
350
352
  };
351
353
  }