@tranhoangnguyen0310/pi-flow-external 2.1.0-external.3 → 2.2.0-external.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/AGENTS.md CHANGED
@@ -36,7 +36,7 @@ This fork changes the original pi-flow contract: `Agent` is not a generic Pi sub
36
36
  - Settings retention automatically prunes eligible completed records beyond `maxRunRecords`; active, interrupted, incomplete, and damaged records are not eligible.
37
37
  - Structured nested-agent activity may extend the wall-clock deadline once, by one fresh base timeout, capped at twice the original deadline.
38
38
  - Do not automatically retry failed or aborted external runs. Preserve the receipt and retry only when the user asks. Exception: the agy backend retries once on infrastructure-classified failures (auth, eligibility, network); the retry is disclosed in the receipt details (`retries`, `retryOf`) and never applies to agent-level failures, aborts, or timeouts.
39
- - `external_runs` is session/project scoped. `list` pages runs and workflow roots; `inspect` pages summaries/output/diagnostics; `wait` returns outcomes only for the selected one/any/all target set and never cancels pending work; `cancel` targets one stable ID and preserves its reason. No routine run event sends a parent message or notification.
39
+ - `external_runs` is session/project scoped. `list` pages runs and workflow roots, each carrying a shared timing projection (`queuedAt`/`executionStartedAt`/`processStartedAt`/activity timestamps plus derived `queueDelayMs`/`elapsedMs`/`activityAgeMs`/`processDurationMs`); `activityAgeMs` and a running `elapsedMs` are computed only for a target independently confirmed live by the registry, never inferred from a durable record's status field alone (an unowned "running"-looking record may be an orphaned/crashed run, not a live one). `inspect` pages `summary`/`output`/`diagnostics`/`final` for one `runId`; `final` returns only the same canonical terminal result every other view already reads (no separate backend parser, no narration promoted to final) and is empty with `finalAvailable: false` until a verified successful terminal boundary exists — including for workflows, where an intentional JSON `null` result still counts as available. `inspect` also accepts a bounded batch `runIds` (up to 20, deduplicated, order preserved, summary-only, mutually exclusive with `runId`): every requested ID's ownership is validated before any page returns, entries share one consistent nested shape regardless of live/durable/agent/workflow origin (a workflow entry omits its unbounded children array in favor of `outputRef`/`diagnosticsRef`), reuses the existing per-inspect `limitBytes` cap with no second budget, and fails with an actionable error naming the run rather than ever silently dropping a target or serving a page over the caller's own byte budget. `wait` returns outcomes only for the selected one/any/all target set and never cancels pending work; `cancel` targets one stable ID and preserves its reason. An unresolvable `wf_...` ID fails as unknown/unavailable across `inspect`/`cancel`/`wait`/batch `inspect`, never falling through to the agent-only durable reader. No routine run event sends a parent message or notification.
40
40
 
41
41
  ## Workflow contract
42
42
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to pi-flow external are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [2.2.0-external.0] - 2026-09-20
8
+
9
+ ### Added
10
+
11
+ - `external_runs` `inspect` now accepts a bounded `runIds` batch (up to 20, deduplicated, order preserved) returning one consistent normalized summary entry per target — live, durable, agent, or workflow — without waiting for completion.
12
+ - Added a verified `view: "final"` projection that returns only a backend's own canonical terminal answer, separate from combined narration/output; unavailable is a bounded, narration-free empty page (`finalAvailable: false`), never a synthesized sentence.
13
+ - Derived explicit queue/elapsed/activity timing (`queueDelayMs`, `elapsedMs`, `activityAgeMs`, `processDurationMs`) from existing evidence, computed only where the underlying timestamps genuinely exist; a registry-confirmed-live run is required before now-relative durations are computed, so an orphaned/crashed durable record is never misreported as actively running.
14
+ - `/external runs` list rows now show status, queue/elapsed duration, live activity age, and output/final availability, plus a manual `Refresh` choice that re-reads the current page and resets stale cursors.
15
+
16
+ ### Fixed
17
+
18
+ - Recorded an explicit `execution_started` evidence boundary (direct `Agent` calls and workflow `agent()` calls) so durable status classification and timing no longer depend solely on `processStartedAt`, which never exists for in-process Pi children.
19
+ - A workflow child's own registry entry now transitions `queued` → `running` explicitly at limiter-acquisition time, instead of only incidentally once its backend's first progress event fires.
20
+ - Settled `outcome.status` now wins over a possibly-stale ambient `observation.status` once a run has terminated.
21
+ - An unresolvable `wf_...` ID now rejects consistently as unknown/unavailable across `inspect`, `cancel`, `wait`, and batch `inspect`, instead of falling through to the agent-only durable reader.
22
+
7
23
  ## [2.1.0-external.3] - 2026-09-18
8
24
 
9
25
  ### Fixed
package/README.md CHANGED
@@ -266,13 +266,39 @@ Agent({
266
266
 
267
267
  Use `external_runs` with these actions:
268
268
 
269
- - `list`: current session/project runs; use `cursor` for run pages and `workflowCursor` for workflow pages. `workflowRunId` filters children of one workflow.
270
- - `inspect`: `runId`, `view: "summary" | "output" | "diagnostics"`, and optional opaque `cursor`/`limitBytes`. Follow `nextCursor` to avoid truncation.
269
+ - `list`: current session/project runs; use `cursor` for run pages and `workflowCursor` for workflow pages. `workflowRunId` filters children of one workflow. Rows carry a `timing` projection (`queueDelayMs`, `elapsedMs`, `activityAgeMs` when live, `processDurationMs`) plus `outputAvailable`/`finalAvailable`.
270
+ - `inspect`: single `runId` with `view: "summary" | "output" | "diagnostics" | "final"`, and optional opaque `cursor`/`limitBytes` (max 64 KiB, same cap for every view). Follow `nextCursor` to avoid truncation. `summary` includes the same `timing` projection as `list`, plus `output.finalAvailable`. `final` returns only the verified canonical terminal answer — empty with `finalAvailable: false` until a successful terminal boundary exists; it never promotes partial/narration text. `output` stays the combined stream (assistant messages plus canonical result) and is unchanged.
271
+ - `inspect` with `runIds` instead of `runId` (up to 20, deduplicated, order preserved): a single bounded batch of `summary`-only projections — one cheap request to see whether several selected background children are queued, running, or terminal, each with `outputRef`/`diagnosticsRef` for follow-up detail. Ownership of every requested ID is validated before any page is returned. Reuses the same `limitBytes` cap as single-run inspection; pages contain whole target entries and continue through `nextCursor`, without invalidation from ordinary live progress. If one compact entry cannot fit, an actionable error asks you to increase `limitBytes` or inspect that run individually; no target is silently dropped. `runId` and `runIds` are mutually exclusive, and batch `view` must stay `summary`.
271
272
  - `wait`: one `runId` or selected `runIds`, with `mode: "any" | "all"`. It returns terminal outcomes plus still-pending IDs; an unsuccessful workflow returns early even in `all` mode. It never chooses a winner or cancels pending work.
272
273
  - `cancel`: one `runId` and optional reason. Whole-workflow cancellation stops active children; targeted child cancellation remains a catchable workflow outcome. Cancellation does not roll back edits or other side effects.
273
274
 
274
275
  Interrupting a blocking `Agent`/`workflow` call cancels its work. Interrupting `external_runs wait` stops only that wait. Background work survives its launching tool return and ordinary parent turns, but not the owning session: orderly session shutdown requests cancellation and waits for bounded cleanup. This is not a daemon. After a host crash or unconfirmed shutdown, unfinished evidence is `interrupted_or_uncertain`; restart restores evidence access, never live ownership or guaranteed retrospective process termination. No routine activity wakes the parent, and live steering is not supported.
275
276
 
277
+ A parent's own `sleep`/wait duration (or its process lifetime) is not a measurement of, and does not bound, how long a delegated command actually takes to run — a background child keeps running under the session's ownership regardless of what the parent does next. A realistic collection shape:
278
+
279
+ ```ts
280
+ // Launch two independent background runs.
281
+ const one = Agent({ description: "Audit auth module", prompt: "Audit /absolute/repo/auth read-only.", role: "reviewer", background: true }); // -> run_...
282
+ const two = Agent({ description: "Audit billing module", prompt: "Audit /absolute/repo/billing read-only.", role: "reviewer", background: true }); // -> run_...
283
+
284
+ // Optional: give both a head start before doing anything else. This sleep
285
+ // bounds nothing about the children's own runtime — it is just the parent
286
+ // choosing when to next check in, not a deadline or a proxy for command time.
287
+ // sleep(30_000)
288
+
289
+ // Do unrelated parent work here (read files, answer the user, plan next steps)
290
+ // while both children continue running under the session, not the parent turn.
291
+
292
+ // Check on both at once, without waiting for either to finish:
293
+ // external_runs({ action: "inspect", runIds: [one.runId, two.runId] })
294
+
295
+ // Leave them running and come back later in the conversation (or after
296
+ // further parent work) to collect final output once each is actually done:
297
+ // external_runs({ action: "inspect", runId: one.runId, view: "final" })
298
+ ```
299
+
300
+ A task that sounds like a 30-second command can legitimately take substantially longer end-to-end once queueing and backend overhead are included — inspect and wait, do not assume.
301
+
276
302
  ## Workflow usage
277
303
 
278
304
  The `workflow` tool runs trusted JavaScript that calls one or more external roles and returns a JSON-serializable result. Workflows are for explicit fan-out — and for the case this extension was built to make explicit: running several children on the **same** harness at once. Example:
@@ -105,6 +105,17 @@ Local fixture tests own selection, boundary, and failure behavior; this check on
105
105
 
106
106
  Do not run damaged-record and expected-failure provider scenarios manually. Their behavior is deterministic and belongs in the local fixture tests.
107
107
 
108
+ ## Change-triggered background-run observability check
109
+
110
+ Run only when batch inspection, the `final` view, timing projection, or `/external runs` browsing changes. In one fresh Pi session against a read-only fixture:
111
+
112
+ 1. Launch two independent `Agent({ ..., background: true })` calls back to back (do not wait on either), then do one unrelated piece of parent work (e.g. read a file) before checking on them — this exercises the actually-recommended usage shape (separate launches, other work in between), not a tight launch-then-immediately-inspect loop.
113
+ 2. Call `external_runs({ action: "inspect", runIds: [<first>, <second>] })` once and confirm both come back as one bounded batch of `summary` projections (queued/running/terminal, `outputRef`/`diagnosticsRef` per entry) without waiting for either to finish.
114
+ 3. After both children are confirmed terminal (`external_runs wait` or a later `inspect`), call `external_runs({ action: "inspect", runId: <runId>, view: "final" })` for a completed run and confirm it returns only the verified canonical answer; for a run cancelled mid-flight (or inspected before it settles), confirm the tool response reports `finalAvailable: false` with a deliberately empty, bounded projection (`items: []`, `integrity: "incomplete"`) rather than a fabricated success — this empty shape is correct at the tool layer, not a bug. The human-readable explanation ("No verified final answer is available yet for `<runId>`.") is a separate, UI-only notice that `/external runs` shows when Final is selected on an unsettled run; the tool response itself stays narration-free by design.
115
+ 4. Run `/external runs`, open the list, and manually select `Refresh`: confirm it re-reads the first page and resets any list cursor without navigating into a run or waiting/polling. Select a still-running row and confirm its queue/elapsed timing and final-availability marker are present and update only when you refresh again (never on a timer).
116
+
117
+ Local fixture tests already cover batch pagination/cursor/byte-limit edge cases, the `final` view's success/unavailable/legitimate-null shapes, and `Refresh`'s cursor-reset behavior deterministically; this check only proves the real backend/registry timing (queued→running, activity age, terminal settlement) matches what those fixtures assume.
118
+
108
119
  ## Change-triggered project default-harness check
109
120
 
110
121
  Run only when project default-harness resolution changes. In a fresh Pi session against a trusted read-only fixture containing `.pi/pi-flow-external/settings.json` with `"defaultHarness": "claude"`, verify `/external settings` reports `defaultHarness: claude (project: ...)`, delegate one read-only task with `role` only (no `harness`) and verify the receipt names a `claude-*` profile, then confirm an explicit `harness: "codex"` call still routes to codex. Repeat once in the same fixture with trust removed and confirm the override is ignored with a warning.
@@ -119,4 +130,5 @@ Before a runtime release:
119
130
  4. Run interruption checks for adapters whose cancellation/output path changed.
120
131
  5. Run the natural-language routing smoke only if role discovery, tool descriptions, or coordinator guidance changed.
121
132
  6. Run the nested timeout check only if nested detection or timeout behavior changed.
122
- 7. Remove temporary evidence and profiles.
133
+ 7. Run the background-run observability check only if batch inspection, the `final` view, timing projection, or `/external runs` browsing changed.
134
+ 8. Remove temporary evidence and profiles.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "2.1.0-external.3",
3
+ "version": "2.2.0-external.0",
4
4
  "description": "External Claude Code, Codex CLI, and Antigravity delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/src/core/agy.ts CHANGED
@@ -266,6 +266,7 @@ export async function spawnAgySubagent(params: {
266
266
  permission?: PermissionTier;
267
267
  resumeConversationId?: string;
268
268
  timeoutMs?: number;
269
+ executionStartedAt?: number;
269
270
  }): Promise<AgentToolResult> {
270
271
  const subagentType = params.profile.name;
271
272
  const promptParts = [params.profile.systemPrompt, params.prompt, params.appendInstructions].filter(Boolean);
@@ -278,6 +279,7 @@ export async function spawnAgySubagent(params: {
278
279
  backend: params.profile.backend,
279
280
  enabled: params.progressEnabled,
280
281
  onProgress: params.onProgress,
282
+ executionStartedAt: params.executionStartedAt,
281
283
  });
282
284
  const progress = emitter.progress;
283
285
  const stderrBuffer = createBoundedBuffer(MAX_STDERR_CHARS);
@@ -340,6 +340,7 @@ export async function spawnClaudeSubagent(params: {
340
340
  permission?: PermissionTier;
341
341
  maxBudgetUsd?: number;
342
342
  resumeSessionId?: string;
343
+ executionStartedAt?: number;
343
344
  }): Promise<AgentToolResult> {
344
345
  const subagentType = params.profile.name;
345
346
  const taskPrompt = params.appendInstructions ? `${params.prompt}\n\n${params.appendInstructions}` : params.prompt;
@@ -350,6 +351,7 @@ export async function spawnClaudeSubagent(params: {
350
351
  backend: params.profile.backend,
351
352
  enabled: params.progressEnabled,
352
353
  onProgress: params.onProgress,
354
+ executionStartedAt: params.executionStartedAt,
353
355
  });
354
356
  const progress = emitter.progress;
355
357
  let latestRawUsage = emptyTokenUsage();
package/src/core/codex.ts CHANGED
@@ -311,6 +311,7 @@ export async function spawnCodexSubagent(params: {
311
311
  outputSchema?: unknown;
312
312
  permission?: PermissionTier;
313
313
  resumeSessionId?: string;
314
+ executionStartedAt?: number;
314
315
  }): Promise<AgentToolResult> {
315
316
  const subagentType = params.profile.name;
316
317
  const taskPrompt = params.appendInstructions ? `${params.prompt}\n\n${params.appendInstructions}` : params.prompt;
@@ -321,6 +322,7 @@ export async function spawnCodexSubagent(params: {
321
322
  backend: params.profile.backend,
322
323
  enabled: params.progressEnabled,
323
324
  onProgress: params.onProgress,
325
+ executionStartedAt: params.executionStartedAt,
324
326
  });
325
327
  const progress = emitter.progress;
326
328
  let latestUsage = emptyUsage(params.profile.model);
@@ -186,6 +186,8 @@ export interface ProgressEmitterOptions {
186
186
  backend?: SubagentBackend;
187
187
  enabled: boolean;
188
188
  onProgress: ((result: AgentToolResult) => void) | undefined;
189
+ /** Execution-start boundary captured by the caller right after the shared concurrency limiter granted a slot. */
190
+ executionStartedAt?: number;
189
191
  }
190
192
 
191
193
  export interface ProgressEmitter {
@@ -214,6 +216,9 @@ export interface ProgressEmitter {
214
216
  export function createProgressEmitter(options: ProgressEmitterOptions): ProgressEmitter {
215
217
  const { toolCallId, description, subagentType, backend, enabled, onProgress } = options;
216
218
  const progress = createProgressNode(toolCallId, description, subagentType, "running", backend);
219
+ if (options.executionStartedAt !== undefined) {
220
+ progress.executionStartedAt = options.executionStartedAt;
221
+ }
217
222
  const live = Boolean(enabled && onProgress);
218
223
 
219
224
  let lastProgressEmit = 0;
@@ -14,7 +14,7 @@ const MAX_CURSOR_CHARS = 4096;
14
14
  const MAX_LIST_SCAN = 200;
15
15
  const RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{1,128}$/;
16
16
 
17
- export type RunInspectionView = "output" | "diagnostics" | "summary";
17
+ export type RunInspectionView = "output" | "diagnostics" | "summary" | "final";
18
18
  export type RunRecordIntegrity = "complete" | "incomplete" | "damaged";
19
19
  export type RunOutputStatus = "preliminary" | "final" | "interrupted";
20
20
 
@@ -25,11 +25,41 @@ export interface RunInspectionPage {
25
25
  view: RunInspectionView;
26
26
  items: RunInspectionItem[];
27
27
  outputStatus: RunOutputStatus;
28
+ /** Whether a separate verified final answer (view: "final") is available for this run. */
29
+ finalAvailable: boolean;
28
30
  integrity: RunRecordIntegrity;
29
31
  truncatedTail: boolean;
30
32
  nextCursor?: string;
31
33
  }
32
34
 
35
+ /**
36
+ * Raw timestamp inputs for {@link deriveRunTiming}. Accepts either an ISO
37
+ * string (durable evidence) or an epoch-ms number (live `SubagentProgressNode`
38
+ * fields) so both `run-inspection.ts` and `external-runs.ts` can share one
39
+ * derivation without a normalization step at every call site.
40
+ */
41
+ export interface RunTimingInputs {
42
+ queuedAt?: string | number;
43
+ executionStartedAt?: string | number;
44
+ processStartedAt?: string | number;
45
+ firstActivityAt?: string | number;
46
+ lastActivityAt?: string | number;
47
+ finishedAt?: string | number;
48
+ }
49
+
50
+ export interface RunTiming {
51
+ queuedAt?: string;
52
+ executionStartedAt?: string;
53
+ processStartedAt?: string;
54
+ firstActivityAt?: string;
55
+ lastActivityAt?: string;
56
+ finishedAt?: string;
57
+ queueDelayMs?: number;
58
+ elapsedMs?: number;
59
+ activityAgeMs?: number;
60
+ processDurationMs?: number;
61
+ }
62
+
33
63
  export interface RunRecordListItem {
34
64
  runId: string;
35
65
  queuedAt?: string;
@@ -43,6 +73,8 @@ export interface RunRecordListItem {
43
73
  parentSessionId?: string;
44
74
  workflowRunId?: string;
45
75
  outputAvailable: boolean;
76
+ finalAvailable: boolean;
77
+ timing: RunTiming;
46
78
  integrity: RunRecordIntegrity;
47
79
  }
48
80
 
@@ -61,6 +93,7 @@ interface ScanResult { missing: boolean; stopped: boolean; truncatedTail: boolea
61
93
  interface RunObservation {
62
94
  metadata?: Record<string, unknown>;
63
95
  queuedAt?: string;
96
+ executionStartedAt?: string;
64
97
  processStartedAt?: string;
65
98
  firstActivityAt?: string;
66
99
  lastActivityAt?: string;
@@ -94,6 +127,7 @@ export async function inspectRun({
94
127
  const summary = await readSummary(join(directory, "summary.json"), runId);
95
128
  const terminal = asRecord(summary.document?.summary);
96
129
  const terminalStatus = asString(terminal?.status);
130
+ const finalAvailable = isFinalAvailable(terminal);
97
131
 
98
132
  if (view === "summary") {
99
133
  if (decoded?.kind === "inspect" && decoded.source !== "summary") throw new Error("Cursor source does not match summary view");
@@ -109,6 +143,7 @@ export async function inspectRun({
109
143
  view,
110
144
  items: portion.text ? [{ text: portion.text }] : [],
111
145
  outputStatus: outputStatus(terminalStatus),
146
+ finalAvailable,
112
147
  integrity: mergedIntegrity(summary, observation),
113
148
  truncatedTail: observation.truncatedTail,
114
149
  ...(portion.nextOffset < text.length
@@ -118,6 +153,34 @@ export async function inspectRun({
118
153
  }
119
154
 
120
155
  if (decoded?.kind === "inspect" && decoded.source === "summary") throw new Error("Cursor source does not match inspection view");
156
+
157
+ if (view === "final") {
158
+ // Reuses the same canonical terminal result every other view already reads
159
+ // (canonicalResult/isFinalAvailable) — no separate backend parser or
160
+ // narration heuristic. Always a single small terminal-document item, so it
161
+ // reuses the same "terminal" cursor source as view: "output".
162
+ const observation = await readObservation(eventsPath, runId);
163
+ const finalText = finalAvailable ? canonicalResult(terminal) : undefined;
164
+ const items: RunInspectionItem[] = finalText ? [{ text: finalText }] : [];
165
+ const revision = contentRevision(finalText ?? "");
166
+ const finalCursor = decoded?.kind === "inspect"
167
+ ? decoded
168
+ : { v: CURSOR_VERSION, kind: "inspect", runId, view, source: "terminal", itemIndex: 0, textOffset: 0, revision } as const;
169
+ if (finalCursor.source !== "terminal") throw new Error("Cursor source does not match final view");
170
+ if (finalCursor.revision !== revision) throw staleCursorError();
171
+ return terminalPage(
172
+ runId,
173
+ view,
174
+ items,
175
+ finalCursor,
176
+ limit,
177
+ mergedIntegrity(summary, observation),
178
+ observation.truncatedTail,
179
+ terminalStatus,
180
+ finalAvailable,
181
+ );
182
+ }
183
+
121
184
  if (view === "output" && summary.document && (terminalStatus === "done" || assistantItems(terminal).length > 0)) {
122
185
  if (decoded?.kind === "inspect" && decoded.source === "events") throw staleCursorError();
123
186
  const observation = await readObservation(eventsPath, runId);
@@ -137,6 +200,7 @@ export async function inspectRun({
137
200
  mergedIntegrity(summary, observation),
138
201
  observation.truncatedTail,
139
202
  terminalStatus,
203
+ finalAvailable,
140
204
  );
141
205
  }
142
206
  if (decoded?.kind === "inspect" && decoded.source === "terminal") throw new Error("Cursor source does not match diagnostics view");
@@ -187,6 +251,7 @@ export async function inspectRun({
187
251
  view,
188
252
  items,
189
253
  outputStatus: outputStatus(terminalStatus),
254
+ finalAvailable,
190
255
  integrity,
191
256
  truncatedTail: scan.truncatedTail,
192
257
  ...(nextCursor ? { nextCursor: encodeCursor(nextCursor) } : {}),
@@ -258,8 +323,9 @@ async function readListItem(runsDirectory: string, runId: string): Promise<RunRe
258
323
  const metadata = asRecord(summary.document?.metadata) ?? observation?.metadata;
259
324
  if (!metadata && summary.integrity === "damaged") return undefined;
260
325
  const terminal = asRecord(summary.document?.summary);
261
- const processStarted = Boolean(timeValue(terminal?.processStartedAt) ?? observation?.processStartedAt ?? observation?.outputAvailable);
262
- const status = asString(terminal?.status) ?? (processStarted ? "running" : "queued");
326
+ const executionStartedAt = timeValue(terminal?.executionStartedAt) ?? observation?.executionStartedAt;
327
+ const processStartedAtValue = timeValue(terminal?.processStartedAt) ?? observation?.processStartedAt;
328
+ const status = asString(terminal?.status) ?? classifyQueuedOrRunning(executionStartedAt, processStartedAtValue, observation?.outputAvailable);
263
329
  const outcome = terminal
264
330
  ? status === "done" ? "succeeded" : terminal.timedOut === true ? "timed_out" : status === "aborted" ? "cancelled" : "failed"
265
331
  : undefined;
@@ -278,10 +344,36 @@ async function readListItem(runsDirectory: string, runId: string): Promise<RunRe
278
344
  ...(asString(metadata?.parentSessionId) ? { parentSessionId: asString(metadata?.parentSessionId) } : {}),
279
345
  ...(asString(metadata?.workflowRunId) ? { workflowRunId: asString(metadata?.workflowRunId) } : {}),
280
346
  outputAvailable: Boolean(canonicalResult(terminal) || assistantItems(terminal).length || observation?.outputAvailable),
347
+ finalAvailable: isFinalAvailable(terminal),
348
+ // Never live: a historical list row has no RunRegistry access either, so
349
+ // this timing is always the durable-evidence-only projection (live=false).
350
+ timing: deriveRunTiming({
351
+ queuedAt,
352
+ executionStartedAt,
353
+ processStartedAt: processStartedAtValue,
354
+ firstActivityAt: timeValue(terminal?.firstActivityAt) ?? observation?.firstActivityAt,
355
+ lastActivityAt: timeValue(terminal?.lastActivityAt) ?? observation?.lastActivityAt,
356
+ finishedAt: summary.document?.finishedAt,
357
+ }, false),
281
358
  integrity: observation ? observation.integrity : summary.integrity,
282
359
  };
283
360
  }
284
361
 
362
+ /**
363
+ * Shared queued/running classification for a raw (pre-integrity-override)
364
+ * status, used by both `readListItem` and `summaryProjection`.
365
+ * `executionStartedAt` is backend-agnostic (present for pi too, unlike
366
+ * `processStartedAt`, which only CLI backends with a child OS process ever
367
+ * emit) and is the earliest reliable "execution actually began" signal, so it
368
+ * must gate this classification alongside `processStartedAt`/output — or a pi
369
+ * run that has started but produced no output yet would misreport as still
370
+ * "queued". Does not affect the separate integrity/interrupted_or_uncertain
371
+ * distinction, applied independently downstream (e.g. by `historicalAgent`).
372
+ */
373
+ function classifyQueuedOrRunning(executionStartedAt: unknown, processStartedAt: unknown, outputAvailable: unknown): "queued" | "running" {
374
+ return Boolean(executionStartedAt) || Boolean(processStartedAt) || Boolean(outputAvailable) ? "running" : "queued";
375
+ }
376
+
285
377
  function documentTime(value: unknown): number | undefined {
286
378
  if (typeof value !== "string") return undefined;
287
379
  const time = Date.parse(value);
@@ -296,6 +388,8 @@ async function readObservation(eventsPath: string, runId: string): Promise<RunOb
296
388
  if (event.type === "run_started") {
297
389
  observation.metadata = asRecord(event.data);
298
390
  observation.queuedAt = asString(observation.metadata?.queuedAt) ?? event.timestamp;
391
+ } else if (event.type === "execution_started") {
392
+ observation.executionStartedAt ??= event.timestamp;
299
393
  } else if (event.type === "process_started") {
300
394
  observation.processStartedAt ??= event.timestamp;
301
395
  } else if (event.type === "backend_event") {
@@ -317,10 +411,12 @@ function summaryProjection(runId: string, summary: SummaryState, observation: Ru
317
411
  const document = summary.document;
318
412
  const metadata = asRecord(document?.metadata) ?? observation.metadata;
319
413
  const terminal = asRecord(document?.summary);
414
+ const executionStartedAt = timeValue(terminal?.executionStartedAt) ?? observation.executionStartedAt;
320
415
  const processStartedAt = timeValue(terminal?.processStartedAt) ?? observation.processStartedAt;
321
416
  const firstActivityAt = timeValue(terminal?.firstActivityAt) ?? observation.firstActivityAt;
322
417
  const lastActivityAt = timeValue(terminal?.lastActivityAt) ?? observation.lastActivityAt;
323
- const status = asString(terminal?.status) ?? (processStartedAt || observation.outputAvailable ? "running" : "queued");
418
+ const status = asString(terminal?.status) ?? classifyQueuedOrRunning(executionStartedAt, processStartedAt, observation.outputAvailable);
419
+ const queuedAt = asString(document?.queuedAt) ?? observation.queuedAt;
324
420
  return {
325
421
  runId,
326
422
  task: compactObject({
@@ -333,7 +429,7 @@ function summaryProjection(runId: string, summary: SummaryState, observation: Ru
333
429
  }),
334
430
  state: compactObject({
335
431
  status,
336
- queuedAt: asString(document?.queuedAt) ?? observation.queuedAt,
432
+ queuedAt,
337
433
  processStartedAt,
338
434
  firstActivityAt,
339
435
  lastActivityAt,
@@ -341,9 +437,22 @@ function summaryProjection(runId: string, summary: SummaryState, observation: Ru
341
437
  error: boundedString(terminal?.error),
342
438
  integrity: mergedIntegrity(summary, observation),
343
439
  }),
440
+ // Never live here: this projection is durable-evidence-only (inspectRun has
441
+ // no RunRegistry access), so now-relative fields (elapsedMs while running,
442
+ // activityAgeMs) are never computed — only src/external-runs.ts may do that,
443
+ // and only for a target it resolves through registry.get(runId).
444
+ timing: deriveRunTiming({
445
+ queuedAt,
446
+ executionStartedAt,
447
+ processStartedAt,
448
+ firstActivityAt,
449
+ lastActivityAt,
450
+ finishedAt: document?.finishedAt,
451
+ }, false),
344
452
  output: {
345
453
  available: Boolean(canonicalResult(terminal) || assistantItems(terminal).length || observation.outputAvailable),
346
454
  status: outputStatus(asString(terminal?.status)),
455
+ finalAvailable: isFinalAvailable(terminal),
347
456
  },
348
457
  };
349
458
  }
@@ -457,7 +566,7 @@ async function validateEventCursor(path: string, runId: string, view: RunInspect
457
566
  }
458
567
  }
459
568
 
460
- function terminalPage(runId: string, view: "output", items: RunInspectionItem[], cursor: Extract<InspectionCursor, { source: "terminal" }>, limit: number, integrity: RunRecordIntegrity, truncatedTail: boolean, terminalStatus: string | undefined): RunInspectionPage {
569
+ function terminalPage(runId: string, view: "output" | "final", items: RunInspectionItem[], cursor: Extract<InspectionCursor, { source: "terminal" }>, limit: number, integrity: RunRecordIntegrity, truncatedTail: boolean, terminalStatus: string | undefined, finalAvailable: boolean): RunInspectionPage {
461
570
  if (items.length || cursor.itemIndex !== 0 || cursor.textOffset !== 0) validateItemCursor(items, cursor.itemIndex, cursor.textOffset);
462
571
  const served = serveItems(items, cursor.itemIndex, cursor.textOffset, limit);
463
572
  return {
@@ -465,6 +574,7 @@ function terminalPage(runId: string, view: "output", items: RunInspectionItem[],
465
574
  view,
466
575
  items: served.items,
467
576
  outputStatus: outputStatus(terminalStatus),
577
+ finalAvailable,
468
578
  integrity,
469
579
  truncatedTail,
470
580
  ...(served.next ? { nextCursor: encodeCursor({ v: CURSOR_VERSION, kind: "inspect", runId, view, source: "terminal", ...served.next, revision: cursor.revision }) } : {}),
@@ -538,6 +648,75 @@ function canonicalResult(summary: Record<string, unknown> | undefined): string |
538
648
  return summary?.status === "done" ? textValue(summary.structuredOutput ?? summary.result) : undefined;
539
649
  }
540
650
 
651
+ /**
652
+ * Whether a separate verified final answer (view: "final") is available.
653
+ * Deliberately identical to `Boolean(canonicalResult(summary))`: the terminal
654
+ * `status: "done"` gate lives inside `canonicalResult` itself, so there is
655
+ * exactly one place that decides "this run has a verified successful
656
+ * terminal boundary" — no second heuristic, no narration promotion.
657
+ */
658
+ function isFinalAvailable(summary: Record<string, unknown> | undefined): boolean {
659
+ return Boolean(canonicalResult(summary));
660
+ }
661
+
662
+ function msValue(value: string | number | undefined): number | undefined {
663
+ if (value === undefined) return undefined;
664
+ const parsed = typeof value === "number" ? value : Date.parse(value);
665
+ return Number.isFinite(parsed) ? parsed : undefined;
666
+ }
667
+
668
+ function nonNegativeDelta(end: number | undefined, start: number | undefined): number | undefined {
669
+ if (end === undefined || start === undefined) return undefined;
670
+ const delta = end - start;
671
+ return delta >= 0 ? delta : undefined;
672
+ }
673
+
674
+ function isoValue(value: string | number | undefined): string | undefined {
675
+ if (value === undefined) return undefined;
676
+ return typeof value === "string" ? value : timeValue(value);
677
+ }
678
+
679
+ /**
680
+ * One shared, pure timing projection used by durable summaries, list entries,
681
+ * batch inspection, and (via `live: true`) live registry-backed summaries.
682
+ *
683
+ * `live` must be true ONLY for a target independently confirmed still running
684
+ * by `RunRegistry` (i.e. resolved via `registry.get(runId)` in
685
+ * `src/external-runs.ts`). Durable-evidence-only callers in this module
686
+ * (`inspectRun`, `listRunRecords`, `getRunRecord`) always pass `false`: a
687
+ * record with no `finishedAt` and no owning registry entry could be a
688
+ * genuinely still-running child, or an orphaned/crashed one whose parent
689
+ * session never got to write `finishedAt` — durable evidence alone cannot
690
+ * tell the two apart, so a `now`-relative field is never fabricated for it.
691
+ */
692
+ export function deriveRunTiming(inputs: RunTimingInputs, live: boolean, now: number = Date.now()): RunTiming {
693
+ const queuedAtMs = msValue(inputs.queuedAt);
694
+ const executionStartedAtMs = msValue(inputs.executionStartedAt);
695
+ const processStartedAtMs = msValue(inputs.processStartedAt);
696
+ const lastActivityAtMs = msValue(inputs.lastActivityAt);
697
+ const finishedAtMs = msValue(inputs.finishedAt);
698
+
699
+ const queueDelayMs = nonNegativeDelta(executionStartedAtMs, queuedAtMs);
700
+ const elapsedMs = finishedAtMs !== undefined
701
+ ? nonNegativeDelta(finishedAtMs, executionStartedAtMs)
702
+ : live ? nonNegativeDelta(now, executionStartedAtMs) : undefined;
703
+ const activityAgeMs = live && finishedAtMs === undefined ? nonNegativeDelta(now, lastActivityAtMs) : undefined;
704
+ const processDurationMs = nonNegativeDelta(finishedAtMs, processStartedAtMs);
705
+
706
+ return {
707
+ ...(isoValue(inputs.queuedAt) ? { queuedAt: isoValue(inputs.queuedAt) } : {}),
708
+ ...(isoValue(inputs.executionStartedAt) ? { executionStartedAt: isoValue(inputs.executionStartedAt) } : {}),
709
+ ...(isoValue(inputs.processStartedAt) ? { processStartedAt: isoValue(inputs.processStartedAt) } : {}),
710
+ ...(isoValue(inputs.firstActivityAt) ? { firstActivityAt: isoValue(inputs.firstActivityAt) } : {}),
711
+ ...(isoValue(inputs.lastActivityAt) ? { lastActivityAt: isoValue(inputs.lastActivityAt) } : {}),
712
+ ...(isoValue(inputs.finishedAt) ? { finishedAt: isoValue(inputs.finishedAt) } : {}),
713
+ ...(queueDelayMs !== undefined ? { queueDelayMs } : {}),
714
+ ...(elapsedMs !== undefined ? { elapsedMs } : {}),
715
+ ...(activityAgeMs !== undefined ? { activityAgeMs } : {}),
716
+ ...(processDurationMs !== undefined ? { processDurationMs } : {}),
717
+ };
718
+ }
719
+
541
720
  function textValue(value: unknown): string | undefined {
542
721
  if (typeof value === "string") return value || undefined;
543
722
  if (value === undefined || value === null) return undefined;
@@ -623,7 +802,7 @@ function hasKeys(value: Record<string, unknown>, keys: string[]): boolean {
623
802
  }
624
803
 
625
804
  function isView(value: unknown): value is RunInspectionView {
626
- return value === "output" || value === "diagnostics" || value === "summary";
805
+ return value === "output" || value === "diagnostics" || value === "summary" || value === "final";
627
806
  }
628
807
 
629
808
  function isNonNegativeInteger(value: unknown): value is number {
package/src/core/spawn.ts CHANGED
@@ -90,6 +90,14 @@ export interface SpawnSubagentParams {
90
90
  resumeRunId?: string;
91
91
  /** Evidence identity allocated by the caller before waiting for a concurrency slot. */
92
92
  runRecord?: RunRecord;
93
+ /**
94
+ * Execution-start boundary (epoch ms), captured by the caller immediately after
95
+ * the shared concurrency limiter granted this run a slot. Callers always acquire
96
+ * the limiter themselves before calling spawnSubagent (see the class doc comment
97
+ * above); this field only carries that already-captured timestamp through so it
98
+ * reaches the terminal summary and the live progress snapshot.
99
+ */
100
+ executionStartedAt?: number;
93
101
  }
94
102
 
95
103
  interface SpawnSubagentRuntimeParams extends SpawnSubagentParams {
@@ -372,6 +380,7 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
372
380
  timedOut: details.timedOut === true,
373
381
  result: details.result,
374
382
  assistantOutput: details.assistantOutput,
383
+ executionStartedAt: details.progress?.executionStartedAt,
375
384
  processStartedAt: details.progress?.processStartedAt,
376
385
  firstActivityAt: details.progress?.firstActivityAt,
377
386
  lastActivityAt: details.progress?.lastActivityAt,
@@ -425,6 +434,7 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
425
434
  description: params.description,
426
435
  status: "error",
427
436
  error: error instanceof Error ? error.message : String(error),
437
+ executionStartedAt: params.executionStartedAt,
428
438
  durationMs: Date.now() - startedAt,
429
439
  backendEventCount,
430
440
  nestedActivitySeen,
@@ -456,6 +466,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
456
466
  outputSchema: params.outputSchema,
457
467
  permission: params.permission,
458
468
  resumeSessionId: params.resumeSessionId,
469
+ executionStartedAt: params.executionStartedAt,
459
470
  });
460
471
  }
461
472
  if (params.profile.backend === "agy") {
@@ -477,6 +488,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
477
488
  outputSchema: params.outputSchema,
478
489
  permission: params.permission,
479
490
  resumeConversationId: params.resumeSessionId,
491
+ executionStartedAt: params.executionStartedAt,
480
492
  // ponytail: 2x base because extendOnce() may legitimately double the
481
493
  // outer deadline; the outer AbortSignal remains the real authority.
482
494
  timeoutMs: params.timeoutMs * 2,
@@ -519,6 +531,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
519
531
  permission: params.permission,
520
532
  maxBudgetUsd: params.maxBudgetUsd,
521
533
  resumeSessionId: params.resumeSessionId,
534
+ executionStartedAt: params.executionStartedAt,
522
535
  });
523
536
  }
524
537
  if (!params.model) {
@@ -616,6 +629,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
616
629
  backend: profile.backend,
617
630
  enabled: progressEnabled,
618
631
  onProgress,
632
+ executionStartedAt: params.executionStartedAt,
619
633
  });
620
634
  const progress = emitter.progress;
621
635