pi-subagents 0.50.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 (109) hide show
  1. package/CHANGELOG.md +103 -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/async-retention-discovery-worker.mjs +180 -0
  7. package/docs/agents.md +40 -2
  8. package/docs/configuration.md +30 -12
  9. package/docs/extension-api.md +42 -1
  10. package/docs/models.md +2 -0
  11. package/docs/observability.md +41 -5
  12. package/docs/tool-reference.md +38 -39
  13. package/docs/workflows.md +171 -3
  14. package/package.json +4 -2
  15. package/skills/pi-subagents/SKILL.md +6 -4
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
  17. package/skills/pi-subagents/references/execution-controls.md +29 -15
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  19. package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
  20. package/src/agents/agent-management.ts +124 -351
  21. package/src/agents/agents.ts +157 -31
  22. package/src/agents/skills.ts +1 -1
  23. package/src/api/external-job-provider.ts +185 -0
  24. package/src/api/preflight.ts +36 -8
  25. package/src/api/shared-types.ts +2 -0
  26. package/src/extension/config.ts +3 -3
  27. package/src/extension/doctor.ts +3 -6
  28. package/src/extension/fanout-child.ts +2 -2
  29. package/src/extension/index.ts +166 -88
  30. package/src/extension/public-execution.ts +31 -2
  31. package/src/extension/schemas.ts +12 -35
  32. package/src/extension/tool-description.ts +35 -24
  33. package/src/inspectors/herdr/actions.ts +2 -2
  34. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  35. package/src/inspectors/herdr/project-panes.ts +2 -2
  36. package/src/intercom/native-supervisor-channel.ts +32 -10
  37. package/src/missions/lifecycle.ts +6 -1
  38. package/src/missions/store.ts +4 -9
  39. package/src/missions/workflow-state.ts +2 -2
  40. package/src/profiles/profiles.ts +3 -1
  41. package/src/runs/background/active-run-index.ts +31 -8
  42. package/src/runs/background/async-execution.ts +68 -40
  43. package/src/runs/background/async-job-tracker.ts +20 -4
  44. package/src/runs/background/async-resume.ts +47 -15
  45. package/src/runs/background/async-retention.ts +886 -0
  46. package/src/runs/background/async-status.ts +39 -53
  47. package/src/runs/background/chain-append.ts +3 -33
  48. package/src/runs/background/completion-dedupe.ts +5 -1
  49. package/src/runs/background/completion-replay.ts +22 -12
  50. package/src/runs/background/control-channel.ts +14 -68
  51. package/src/runs/background/fleet-view.ts +68 -19
  52. package/src/runs/background/index-segment.ts +59 -0
  53. package/src/runs/background/inspect-rpc.ts +443 -0
  54. package/src/runs/background/notify.ts +31 -5
  55. package/src/runs/background/result-files.ts +158 -90
  56. package/src/runs/background/result-watcher.ts +116 -20
  57. package/src/runs/background/resume-guidance.ts +8 -5
  58. package/src/runs/background/retained-children.ts +13 -3
  59. package/src/runs/background/run-id-query.ts +7 -0
  60. package/src/runs/background/run-id-resolver.ts +11 -9
  61. package/src/runs/background/run-status.ts +27 -18
  62. package/src/runs/background/scheduled-runs.ts +24 -4
  63. package/src/runs/background/stale-run-reconciler.ts +6 -3
  64. package/src/runs/background/steering.ts +11 -1
  65. package/src/runs/background/subagent-runner.ts +253 -140
  66. package/src/runs/background/subagent-wait.ts +8 -8
  67. package/src/runs/background/terminal-run-index.ts +129 -0
  68. package/src/runs/background/wait-completions.ts +21 -4
  69. package/src/runs/background/wait-subscriptions.ts +81 -2
  70. package/src/runs/foreground/async-steering-action.ts +21 -14
  71. package/src/runs/foreground/execution.ts +3 -1
  72. package/src/runs/foreground/subagent-executor.ts +666 -1579
  73. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  74. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  75. package/src/runs/shared/acceptance.ts +4 -4
  76. package/src/runs/shared/chain-outputs.ts +1 -3
  77. package/src/runs/shared/external-job-bridge.ts +444 -0
  78. package/src/runs/shared/external-job-runner.ts +286 -0
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  80. package/src/runs/shared/model-fallback.ts +98 -38
  81. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  82. package/src/runs/shared/parallel-handoff.ts +46 -4
  83. package/src/runs/shared/parallel-utils.ts +4 -15
  84. package/src/runs/shared/permissions.ts +5 -1
  85. package/src/runs/shared/pi-args.ts +8 -1
  86. package/src/runs/shared/session-lease.ts +0 -6
  87. package/src/runs/shared/subagent-control.ts +26 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  89. package/src/runs/shared/workflow-graph.ts +1 -23
  90. package/src/runs/shared/worktree.ts +14 -2
  91. package/src/shared/atomic-json.ts +22 -2
  92. package/src/shared/capacity-resilient-json.ts +102 -0
  93. package/src/shared/completion-owner.ts +14 -0
  94. package/src/shared/file-system-retry.ts +49 -1
  95. package/src/shared/fork-context.ts +42 -0
  96. package/src/shared/prompt-resources.ts +0 -40
  97. package/src/shared/settings.ts +3 -27
  98. package/src/shared/types.ts +71 -26
  99. package/src/shared/utils.ts +8 -0
  100. package/src/shared/watch-strategy.ts +10 -0
  101. package/src/slash/slash-bridge.ts +2 -1
  102. package/src/slash/slash-commands.ts +29 -3
  103. package/src/tui/fleet.ts +63 -15
  104. package/src/watchdog/change-signature.ts +1 -1
  105. package/src/watchdog/lsp-diagnostics.ts +1 -0
  106. package/src/workflows/chat-progress.ts +2 -2
  107. package/src/workflows/scripted-workflow.ts +220 -67
  108. package/src/runs/foreground/chain-clarify.ts +0 -1354
  109. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -0,0 +1,59 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Keep opaque index keys below common filesystem component limits.
5
+ *
6
+ * Portable short values retain the historical URI-encoded representation.
7
+ * Oversized or non-portable values use a deterministic digest so callers can
8
+ * resolve the same index without persisting a separate lookup table.
9
+ *
10
+ * Encoded backslashes and trailing file extensions are not portable. Pi session
11
+ * ids are often the full session `.jsonl` path. On Windows, `readdir` of a
12
+ * directory whose name ends in `.jsonl` or contains `%5C` can fail with EPERM.
13
+ */
14
+ export const MAX_INDEX_SEGMENT_BYTES = 255;
15
+ const HASHED_INDEX_SEGMENT_PREFIX = "~sha256-";
16
+ const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
17
+
18
+ function hashedSegment(value: string): string {
19
+ return `${HASHED_INDEX_SEGMENT_PREFIX}${createHash("sha256").update(value).digest("hex")}`;
20
+ }
21
+
22
+ function isPortableSegment(value: string): boolean {
23
+ return value.length > 0
24
+ && value !== "."
25
+ && value !== ".."
26
+ && !value.endsWith(".")
27
+ && !WINDOWS_RESERVED_NAME.test(value)
28
+ && !/%5C/i.test(value)
29
+ && !/\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(value);
30
+ }
31
+
32
+ export function encodeIndexSegment(value: string, maxBytes = MAX_INDEX_SEGMENT_BYTES): string {
33
+ let encoded: string;
34
+ try {
35
+ encoded = encodeURIComponent(value);
36
+ } catch {
37
+ return hashedSegment(value);
38
+ }
39
+ if (Buffer.byteLength(encoded, "utf-8") <= maxBytes && isPortableSegment(encoded)) return encoded;
40
+ return hashedSegment(value);
41
+ }
42
+
43
+ function historicallyReadableSegment(value: string, maxBytes: number): string | undefined {
44
+ try {
45
+ const encoded = encodeURIComponent(value);
46
+ if (encoded.length === 0 || encoded === "." || encoded === ".." || encoded.endsWith(".") || WINDOWS_RESERVED_NAME.test(encoded)) return undefined;
47
+ if (Buffer.byteLength(encoded, "utf-8") > maxBytes) return undefined;
48
+ return encoded;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /** Current write key first, then the pre-hash URI-encoded key when it still fits. */
55
+ export function indexSegmentAliases(value: string, maxBytes = MAX_INDEX_SEGMENT_BYTES): string[] {
56
+ const current = encodeIndexSegment(value, maxBytes);
57
+ const historical = historicallyReadableSegment(value, maxBytes);
58
+ return historical && historical !== current ? [current, historical] : [current];
59
+ }
@@ -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 {
@@ -63,10 +65,12 @@ export interface CompletionNotification {
63
65
  taskIndex?: number;
64
66
  totalTasks?: number;
65
67
  sessionId?: string | null;
68
+ completionOwnerId?: string | null;
66
69
  triggerTurn?: boolean;
67
70
  /** True when an acknowledged grouped intercom relay already delivered this run. */
68
71
  intercomDelivered?: boolean;
69
72
  parallelHandoff?: ParallelHandoffReference;
73
+ scheduleOrigin?: ScheduleOrigin;
70
74
  }
71
75
 
72
76
  interface NotifyTimerApi {
@@ -93,9 +97,14 @@ function formatSessionLine(details: SubagentNotifyDetails): string | undefined {
93
97
  export function formatSingleCompletion(details: SubagentNotifyDetails): string {
94
98
  const sessionLine = formatSessionLine(details);
95
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;
96
103
  return [
97
104
  `${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
98
105
  "",
106
+ scheduleLine,
107
+ scheduleLine ? "" : undefined,
99
108
  details.resultPreview.trim() ? details.resultPreview : "(no output)",
100
109
  details.handoffPath ? "" : undefined,
101
110
  details.handoffPath ? `Parallel handoff: ${details.handoffPath}` : undefined,
@@ -110,7 +119,17 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
110
119
  const lines = content.split("\n");
111
120
  const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
112
121
  if (!match) return undefined;
113
- 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
+ }
114
133
  let sessionIndex = -1;
115
134
  for (let i = body.length - 1; i >= 1; i--) {
116
135
  if (body[i - 1]?.trim() === "" && /^(Session|Session file|Session share error):\s+/.test(body[i]!)) {
@@ -137,6 +156,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
137
156
  status: match[2] as SubagentNotifyDetails["status"],
138
157
  ...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
139
158
  ...(match[4] ? { taskInfo: match[4] } : {}),
159
+ ...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
140
160
  resultPreview,
141
161
  ...(handoffPath ? { handoffPath } : {}),
142
162
  ...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
@@ -150,7 +170,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
150
170
  const detail = details[index];
151
171
  if (!detail) continue;
152
172
  const sessionLine = formatSessionLine(detail);
153
- 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})` : ""}`);
154
174
  blocks.push(detail.resultPreview.trim() ? detail.resultPreview : "(no output)");
155
175
  if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
156
176
  if (sessionLine) blocks.push(sessionLine);
@@ -170,7 +190,7 @@ function sendCompletion(pi: Pick<ExtensionAPI, "sendMessage">, items: PendingCom
170
190
  if (items.length === 0) return true;
171
191
  const details = items.map((item) => item.details);
172
192
  const content = details.length === 1 ? formatSingleCompletion(details[0]!) : formatGroupedCompletion(details);
173
- 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);
174
194
  try {
175
195
  pi.sendMessage(
176
196
  {
@@ -225,9 +245,14 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
225
245
  : result.sessionFile
226
246
  ? { label: "Session file", value: result.sessionFile }
227
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;
228
252
  return {
229
253
  agent,
230
254
  status,
255
+ ...(scheduleOrigin ? { scheduleOrigin } : {}),
231
256
  ...(result.source ? { source: result.source } : {}),
232
257
  ...(taskInfo ? { taskInfo } : {}),
233
258
  resultPreview: summary,
@@ -239,7 +264,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
239
264
 
240
265
  export default function registerSubagentNotify(
241
266
  pi: ExtensionAPI,
242
- state: Pick<SubagentState, "currentSessionId">,
267
+ state: Pick<SubagentState, "currentSessionId" | "completionOwnerId">,
243
268
  options: RegisterSubagentNotifyOptions = {},
244
269
  ): CompletionNotifier {
245
270
  const seen = new Map<string, number>();
@@ -275,6 +300,7 @@ export default function registerSubagentNotify(
275
300
 
276
301
  const deliver = (result: CompletionNotification): Promise<boolean> => {
277
302
  if (disposed || typeof result.sessionId !== "string" || result.sessionId !== state.currentSessionId) return Promise.resolve(false);
303
+ if (result.source !== "foreground" && (!state.completionOwnerId || result.completionOwnerId !== state.completionOwnerId)) return Promise.resolve(false);
278
304
  if (result.intercomDelivered === true) return Promise.resolve(true);
279
305
  const key = buildCompletionKey(result, "notify");
280
306
  const seenAt = seen.get(key);