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.
- package/CHANGELOG.md +103 -0
- package/agents/oracle.md +3 -1
- package/agents/reviewer.md +1 -0
- package/agents/scout.md +2 -2
- package/agents/worker.md +2 -1
- package/async-retention-discovery-worker.mjs +180 -0
- package/docs/agents.md +40 -2
- package/docs/configuration.md +30 -12
- package/docs/extension-api.md +42 -1
- package/docs/models.md +2 -0
- package/docs/observability.md +41 -5
- package/docs/tool-reference.md +38 -39
- package/docs/workflows.md +171 -3
- package/package.json +4 -2
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
- package/skills/pi-subagents/references/execution-controls.md +29 -15
- package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
- package/src/agents/agent-management.ts +124 -351
- package/src/agents/agents.ts +157 -31
- package/src/agents/skills.ts +1 -1
- package/src/api/external-job-provider.ts +185 -0
- package/src/api/preflight.ts +36 -8
- package/src/api/shared-types.ts +2 -0
- package/src/extension/config.ts +3 -3
- package/src/extension/doctor.ts +3 -6
- package/src/extension/fanout-child.ts +2 -2
- package/src/extension/index.ts +166 -88
- package/src/extension/public-execution.ts +31 -2
- package/src/extension/schemas.ts +12 -35
- package/src/extension/tool-description.ts +35 -24
- package/src/inspectors/herdr/actions.ts +2 -2
- package/src/inspectors/herdr/inspector-runner.ts +2 -1
- package/src/inspectors/herdr/project-panes.ts +2 -2
- package/src/intercom/native-supervisor-channel.ts +32 -10
- package/src/missions/lifecycle.ts +6 -1
- package/src/missions/store.ts +4 -9
- package/src/missions/workflow-state.ts +2 -2
- package/src/profiles/profiles.ts +3 -1
- package/src/runs/background/active-run-index.ts +31 -8
- package/src/runs/background/async-execution.ts +68 -40
- package/src/runs/background/async-job-tracker.ts +20 -4
- package/src/runs/background/async-resume.ts +47 -15
- package/src/runs/background/async-retention.ts +886 -0
- package/src/runs/background/async-status.ts +39 -53
- package/src/runs/background/chain-append.ts +3 -33
- package/src/runs/background/completion-dedupe.ts +5 -1
- package/src/runs/background/completion-replay.ts +22 -12
- package/src/runs/background/control-channel.ts +14 -68
- package/src/runs/background/fleet-view.ts +68 -19
- package/src/runs/background/index-segment.ts +59 -0
- package/src/runs/background/inspect-rpc.ts +443 -0
- package/src/runs/background/notify.ts +31 -5
- package/src/runs/background/result-files.ts +158 -90
- package/src/runs/background/result-watcher.ts +116 -20
- package/src/runs/background/resume-guidance.ts +8 -5
- package/src/runs/background/retained-children.ts +13 -3
- package/src/runs/background/run-id-query.ts +7 -0
- package/src/runs/background/run-id-resolver.ts +11 -9
- package/src/runs/background/run-status.ts +27 -18
- package/src/runs/background/scheduled-runs.ts +24 -4
- package/src/runs/background/stale-run-reconciler.ts +6 -3
- package/src/runs/background/steering.ts +11 -1
- package/src/runs/background/subagent-runner.ts +253 -140
- package/src/runs/background/subagent-wait.ts +8 -8
- package/src/runs/background/terminal-run-index.ts +129 -0
- package/src/runs/background/wait-completions.ts +21 -4
- package/src/runs/background/wait-subscriptions.ts +81 -2
- package/src/runs/foreground/async-steering-action.ts +21 -14
- package/src/runs/foreground/execution.ts +3 -1
- package/src/runs/foreground/subagent-executor.ts +666 -1579
- package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
- package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
- package/src/runs/shared/acceptance.ts +4 -4
- package/src/runs/shared/chain-outputs.ts +1 -3
- package/src/runs/shared/external-job-bridge.ts +444 -0
- package/src/runs/shared/external-job-runner.ts +286 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
- package/src/runs/shared/model-fallback.ts +98 -38
- package/src/runs/shared/orca-progress-tabs.ts +84 -22
- package/src/runs/shared/parallel-handoff.ts +46 -4
- package/src/runs/shared/parallel-utils.ts +4 -15
- package/src/runs/shared/permissions.ts +5 -1
- package/src/runs/shared/pi-args.ts +8 -1
- package/src/runs/shared/session-lease.ts +0 -6
- package/src/runs/shared/subagent-control.ts +26 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
- package/src/runs/shared/workflow-graph.ts +1 -23
- package/src/runs/shared/worktree.ts +14 -2
- package/src/shared/atomic-json.ts +22 -2
- package/src/shared/capacity-resilient-json.ts +102 -0
- package/src/shared/completion-owner.ts +14 -0
- package/src/shared/file-system-retry.ts +49 -1
- package/src/shared/fork-context.ts +42 -0
- package/src/shared/prompt-resources.ts +0 -40
- package/src/shared/settings.ts +3 -27
- package/src/shared/types.ts +71 -26
- package/src/shared/utils.ts +8 -0
- package/src/shared/watch-strategy.ts +10 -0
- package/src/slash/slash-bridge.ts +2 -1
- package/src/slash/slash-commands.ts +29 -3
- package/src/tui/fleet.ts +63 -15
- package/src/watchdog/change-signature.ts +1 -1
- package/src/watchdog/lsp-diagnostics.ts +1 -0
- package/src/workflows/chat-progress.ts +2 -2
- package/src/workflows/scripted-workflow.ts +220 -67
- package/src/runs/foreground/chain-clarify.ts +0 -1354
- 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
|
-
|
|
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);
|