@tt-a1i/openpi 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +30 -20
  2. package/SETUP.md +10 -4
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/bin/openpi.js +25 -15
  5. package/extensions/ai-providers/LICENSE.upstream +23 -0
  6. package/extensions/ai-providers/README.md +65 -0
  7. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  8. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  9. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  10. package/extensions/ai-providers/antigravity/models.ts +84 -0
  11. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  12. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  13. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  14. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  15. package/extensions/ai-providers/cursor/constants.ts +5 -0
  16. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  17. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  18. package/extensions/ai-providers/cursor/input-images.ts +105 -0
  19. package/extensions/ai-providers/cursor/models.ts +45 -0
  20. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  21. package/extensions/ai-providers/cursor/proto.ts +1271 -0
  22. package/extensions/ai-providers/cursor/protobuf.ts +1181 -0
  23. package/extensions/ai-providers/cursor/provider.ts +1431 -0
  24. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  25. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +8 -1
  31. package/extensions/background-terminals/src/manager.ts +3 -5
  32. package/extensions/background-terminals/src/result-delivery.ts +43 -23
  33. package/extensions/cron/index.ts +68 -27
  34. package/extensions/cron/schedule.ts +5 -1
  35. package/extensions/model-info/cache-diagnostics.ts +220 -0
  36. package/extensions/model-info/index.ts +45 -1
  37. package/extensions/plan-mode/index.ts +75 -4
  38. package/extensions/setup/index.ts +15 -3
  39. package/extensions/shared/child-session.ts +39 -5
  40. package/extensions/shared/completion-inbox.ts +193 -0
  41. package/extensions/shared/setup-config.ts +10 -1
  42. package/extensions/shared/structured-output.ts +154 -0
  43. package/extensions/subagents/index.ts +64 -7
  44. package/extensions/subagents/src/agent-types.ts +5 -17
  45. package/extensions/subagents/src/backends/pi.ts +130 -48
  46. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  47. package/extensions/subagents/src/domain.ts +16 -1
  48. package/extensions/subagents/src/manager.ts +7 -71
  49. package/extensions/subagents/src/prompt.ts +19 -5
  50. package/extensions/subagents/src/result-artifact.ts +32 -0
  51. package/extensions/subagents/src/result-delivery.ts +33 -14
  52. package/extensions/subagents/src/runtime.ts +10 -3
  53. package/extensions/ui-customization/footer.ts +16 -5
  54. package/extensions/user-input-fold/index.ts +42 -6
  55. package/extensions/web/index.ts +25 -2
  56. package/extensions/workflows/acceptance.ts +43 -19
  57. package/extensions/workflows/completion-projection.ts +3 -1
  58. package/extensions/workflows/dashboard.ts +147 -21
  59. package/extensions/workflows/index.ts +75 -20
  60. package/extensions/workflows/model.ts +5 -1
  61. package/extensions/workflows/progress-projection.ts +7 -1
  62. package/extensions/workflows/prompt.ts +4 -10
  63. package/extensions/workflows/result-delivery.ts +96 -22
  64. package/extensions/workflows/retention.ts +6 -0
  65. package/extensions/workflows/runner.ts +11 -233
  66. package/extensions/workflows/sandbox.ts +4 -0
  67. package/package.json +7 -7
  68. package/skills/subagents/REFERENCE.md +9 -9
  69. package/skills/subagents/SKILL.md +2 -1
  70. package/skills/workflows/REFERENCE.md +5 -3
  71. package/skills/workflows/SKILL.md +1 -1
  72. package/web/adapter/pi-adapter.ts +3 -0
  73. package/web/host/pi-coding-agent-entry.ts +162 -0
  74. package/web/host/web-host.ts +330 -50
  75. package/web/protocol/types.ts +5 -0
  76. package/web/runtime/pi-runtime.ts +240 -25
  77. package/web/runtime/types.ts +32 -1
  78. package/web/ui/app.js +343 -41
  79. package/web/ui/index.html +3 -0
  80. package/web/ui/styles.css +119 -37
@@ -1,10 +1,16 @@
1
1
  import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+ import {
3
+ type CompletionOwner,
4
+ createCompletionInbox,
5
+ } from "../../shared/completion-inbox.ts";
2
6
 
3
7
  export interface SubagentResultDeliveryOptions<T> {
4
8
  /** True only when the parent has no run or queued continuation in flight. */
5
9
  readonly isIdle: () => boolean;
6
10
  /** Deliver one drained batch and wake the parent. */
7
11
  readonly deliver: (results: readonly T[]) => void;
12
+ /** Current Pi Session transcript owner. */
13
+ readonly owner?: () => CompletionOwner | undefined;
8
14
  }
9
15
 
10
16
  /**
@@ -22,50 +28,63 @@ export interface SubagentResultDeliveryOptions<T> {
22
28
  * The parent boundary wakes even if an earlier extension handler has already
23
29
  * started another turn: Pi queues the follow-up into that active run.
24
30
  *
25
- * The Map is the one-shot gate: `subagent_wait` may consume a result before it
26
- * is delivered, and whichever path drains first prevents duplicate delivery.
31
+ * The shared inbox is the one-shot gate: `subagent_wait` may consume a result
32
+ * before it is delivered, and whichever path claims first prevents duplicate
33
+ * delivery.
27
34
  */
28
35
  export function createSubagentResultDelivery<T extends { id: string }>(
29
36
  options: SubagentResultDeliveryOptions<T>,
30
37
  ) {
31
- const pending = new Map<string, T>();
38
+ const inbox = createCompletionInbox<T>();
39
+ const owner = options.owner ?? (() => ({ sessionId: "test", epoch: 0 }));
32
40
 
33
41
  const flush = () => {
34
- if (pending.size === 0) return;
35
- const results = [...pending.values()];
36
- pending.clear();
42
+ const envelopes = inbox.claim(owner());
43
+ if (envelopes.length === 0) return;
44
+ const results = envelopes.map((envelope) => envelope.payload);
37
45
  try {
38
46
  options.deliver(results);
47
+ inbox.acknowledge(envelopes.map((envelope) => envelope.deliveryId));
39
48
  } catch (error) {
40
49
  // A synchronous session teardown may reject append/send. Preserve the
41
50
  // original batch ahead of anything deferred re-entrantly while delivery
42
51
  // ran, so a later boundary can retry without loss or reordering.
43
- const current = [...pending.values()];
44
- pending.clear();
45
- for (const result of results) pending.set(result.id, result);
46
- for (const result of current) pending.set(result.id, result);
52
+ inbox.retry(envelopes, owner());
47
53
  throw error;
48
54
  }
49
55
  };
50
56
 
51
57
  const queue = {
52
58
  defer(result: T) {
53
- pending.set(result.id, result);
59
+ const currentOwner = owner();
60
+ inbox.defer(
61
+ {
62
+ deliveryId: `subagent:${result.id}`,
63
+ owner: currentOwner ?? { sessionId: "unowned", epoch: 0 },
64
+ producer: "subagent",
65
+ producerId: result.id,
66
+ terminalRef: { kind: "subagent-snapshot", id: result.id },
67
+ wake: "follow-up",
68
+ payload: result,
69
+ },
70
+ currentOwner,
71
+ );
54
72
  if (options.isIdle()) flush();
55
73
  },
56
74
  consume(ids: Iterable<string>) {
57
- for (const id of ids) pending.delete(id);
75
+ inbox.consume("subagent", ids);
58
76
  },
59
77
  /** Flush at the authoritative parent boundary. */
60
78
  parentSettled() {
61
79
  flush();
62
80
  },
63
81
  clear() {
64
- pending.clear();
82
+ inbox.clear();
65
83
  },
66
84
  size() {
67
- return pending.size;
85
+ return inbox.size();
68
86
  },
87
+ inspectDeadLetters: inbox.inspectDeadLetters,
69
88
  };
70
89
  return queue satisfies ConsumableResultDeliveryQueue<T>;
71
90
  }
@@ -45,6 +45,9 @@ export function createSubagentRuntime(config: SubagentManagerConfig = {}) {
45
45
 
46
46
  export type SubagentRuntime = ReturnType<typeof createSubagentRuntime>;
47
47
 
48
+ /** Canonical interruption, distinct from a known startup failure. */
49
+ export class SubagentToolInterruptedError extends Error {}
50
+
48
51
  /**
49
52
  * Run an effect from an async tool handler. Typed failures and defects are
50
53
  * converted to thrown Errors (what pi's tool contract expects); interruption
@@ -60,9 +63,13 @@ export async function runTool<A, E>(
60
63
  options.signal ? { signal: options.signal } : undefined,
61
64
  );
62
65
  if (Exit.isSuccess(exit)) return exit.value;
63
- if (Cause.hasInterruptsOnly(exit.cause)) {
64
- throw new Error(options.interruptMessage ?? "Operation was aborted.");
65
- }
66
66
  const [first] = Cause.prettyErrors(exit.cause);
67
+ if (Cause.hasInterrupts(exit.cause)) {
68
+ const interrupted = options.interruptMessage ?? "Operation was aborted.";
69
+ const detail = Cause.hasInterruptsOnly(exit.cause)
70
+ ? ""
71
+ : ` ${first?.message ?? Cause.pretty(exit.cause)}`;
72
+ throw new SubagentToolInterruptedError(`${interrupted}${detail}`);
73
+ }
67
74
  throw new Error(first?.message ?? Cause.pretty(exit.cause));
68
75
  }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { relative } from "node:path";
2
+ import { posix, win32 } from "node:path";
3
3
  import type { Theme } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
5
  getCapabilities,
@@ -117,10 +117,21 @@ export function formatTokens(tokens: number) {
117
117
  return `${(tokens / 1_000_000).toFixed(1)}m`;
118
118
  }
119
119
 
120
- export function formatDirectory(cwd: string) {
121
- const home = homedir();
122
- if (cwd === home) return "~";
123
- const display = cwd.startsWith(`${home}/`) ? `~/${relative(home, cwd)}` : cwd;
120
+ export function formatDirectory(
121
+ cwd: string,
122
+ home = homedir(),
123
+ pathModule = process.platform === "win32" ? win32 : posix,
124
+ ) {
125
+ const relativePath = pathModule.relative(home, cwd);
126
+ const outsideHome =
127
+ relativePath === ".." ||
128
+ relativePath.startsWith(`..${pathModule.sep}`) ||
129
+ pathModule.isAbsolute(relativePath);
130
+ const display = outsideHome
131
+ ? cwd
132
+ : relativePath
133
+ ? `~/${relativePath.replaceAll(pathModule.sep, "/")}`
134
+ : "~";
124
135
  return sanitizeTerminalLabel(display);
125
136
  }
126
137
 
@@ -40,8 +40,35 @@ type Segment =
40
40
  | { kind: "prose"; lines: string[] }
41
41
  | { kind: "code"; open: string; content: string[]; close: string };
42
42
 
43
- const FENCE_OPEN = /^ {0,3}`{3,}/;
44
- const FENCE_CLOSE = /^ {0,3}`{3,}[ \t]*\r?$/;
43
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
44
+
45
+ /**
46
+ * Parse an opening code fence (CommonMark §4.5): which character it uses and
47
+ * how long it is. A backtick fence's info string may not contain backticks.
48
+ */
49
+ function openFence(line: string) {
50
+ const match = FENCE_OPEN.exec(line);
51
+ if (!match) return undefined;
52
+ const fence = match[1];
53
+ if (fence[0] === "`" && line.slice(match[0].length).includes("`")) {
54
+ return undefined;
55
+ }
56
+ return { char: fence[0], length: fence.length };
57
+ }
58
+
59
+ /**
60
+ * A closing fence must use the same character as the opening fence and be at
61
+ * least as long: a ``` line does not close a ```` block, and backticks never
62
+ * close a tilde block.
63
+ */
64
+ function isCloseFence(line: string, open: { char: string; length: number }) {
65
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/.exec(line);
66
+ return (
67
+ match !== null &&
68
+ match[1][0] === open.char &&
69
+ match[1].length >= open.length
70
+ );
71
+ }
45
72
 
46
73
  function countLines(markdown: string) {
47
74
  const parts = markdown.split("\n");
@@ -60,7 +87,8 @@ function parseSegments(lines: string[]): Segment[] {
60
87
  let prose: string[] = [];
61
88
  let i = 0;
62
89
  while (i < lines.length) {
63
- if (!FENCE_OPEN.test(lines[i])) {
90
+ const fence = openFence(lines[i]);
91
+ if (!fence) {
64
92
  prose.push(lines[i]);
65
93
  i += 1;
66
94
  continue;
@@ -74,13 +102,21 @@ function parseSegments(lines: string[]): Segment[] {
74
102
  let close: string | undefined;
75
103
  let j = i + 1;
76
104
  while (j < lines.length && close === undefined) {
77
- if (FENCE_CLOSE.test(lines[j])) close = lines[j];
105
+ if (isCloseFence(lines[j], fence)) close = lines[j];
78
106
  else content.push(lines[j]);
79
107
  j += 1;
80
108
  }
81
109
  if (close === undefined) {
82
- // Unterminated fence: fold the whole message conservatively as text.
83
- return [{ kind: "prose", lines }];
110
+ // Unterminated fence: the block runs to the end of the message. Keep it
111
+ // as a code block with a synthesized closing fence so a folded preview
112
+ // never leaks an unclosed fence into the TUI.
113
+ segments.push({
114
+ kind: "code",
115
+ open,
116
+ content,
117
+ close: fence.char.repeat(fence.length),
118
+ });
119
+ return segments;
84
120
  }
85
121
  segments.push({ kind: "code", open, content, close });
86
122
  i = j;
@@ -5,6 +5,11 @@ import type {
5
5
  ExtensionAPI,
6
6
  ExtensionCommandContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ missingPiCodingAgentDiagnostic,
10
+ PI_CODING_AGENT_ENTRY_ENV,
11
+ resolvePiCodingAgentEntry,
12
+ } from "../../web/host/pi-coding-agent-entry.ts";
8
13
 
9
14
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
10
15
 
@@ -26,12 +31,20 @@ interface SpawnWebOptions {
26
31
  stdio: "inherit";
27
32
  }
28
33
 
29
- function webProcessEnvironment(cwd: string) {
34
+ function webProcessEnvironment(
35
+ cwd: string,
36
+ piCodingAgentEntry: string | undefined,
37
+ ) {
30
38
  const environment: NodeJS.ProcessEnv = { ...process.env, PWD: cwd };
31
39
  delete environment.OLDPWD;
32
40
  delete environment.INIT_CWD;
33
41
  delete environment.PI_SESSION_ID;
34
42
  delete environment.PI_SESSION_FILE;
43
+ if (piCodingAgentEntry) {
44
+ environment[PI_CODING_AGENT_ENTRY_ENV] = piCodingAgentEntry;
45
+ } else {
46
+ delete environment[PI_CODING_AGENT_ENTRY_ENV];
47
+ }
35
48
  return environment;
36
49
  }
37
50
 
@@ -40,6 +53,7 @@ export interface WebCommandDependencies {
40
53
  spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess;
41
54
  clearTerminal(): void;
42
55
  holdParentSigint(): () => void;
56
+ resolvePiCodingAgentEntry(): string | undefined;
43
57
  shutdownTimeoutMs: number;
44
58
  }
45
59
 
@@ -65,6 +79,8 @@ const defaultDependencies: WebCommandDependencies = {
65
79
  process.on("SIGINT", keepPiAlive);
66
80
  return () => process.removeListener("SIGINT", keepPiAlive);
67
81
  },
82
+ resolvePiCodingAgentEntry: () =>
83
+ resolvePiCodingAgentEntry({ source: "host" }),
68
84
  shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS,
69
85
  };
70
86
 
@@ -95,6 +111,7 @@ function runWebInForeground(
95
111
  dependencies: WebCommandDependencies,
96
112
  setActive: (active: ActiveWebProcess | undefined) => void,
97
113
  isShuttingDown: () => boolean,
114
+ piCodingAgentEntry: string,
98
115
  ) {
99
116
  return ctx.ui.custom<WebExit>((tui, _theme, _keybindings, done) => {
100
117
  let finished = false;
@@ -128,7 +145,7 @@ function runWebInForeground(
128
145
  [dependencies.entrypoint, "web", "--no-workspace"],
129
146
  {
130
147
  cwd: childCwd,
131
- env: webProcessEnvironment(childCwd),
148
+ env: webProcessEnvironment(childCwd, piCodingAgentEntry),
132
149
  shell: false,
133
150
  stdio: "inherit",
134
151
  },
@@ -191,6 +208,11 @@ export default function web(
191
208
  ctx.ui.notify("OpenPI Web Workbench is already running.", "warning");
192
209
  return;
193
210
  }
211
+ const piCodingAgentEntry = dependencies.resolvePiCodingAgentEntry();
212
+ if (!piCodingAgentEntry) {
213
+ ctx.ui.notify(missingPiCodingAgentDiagnostic(), "error");
214
+ return;
215
+ }
194
216
 
195
217
  running = true;
196
218
  try {
@@ -201,6 +223,7 @@ export default function web(
201
223
  active = next;
202
224
  },
203
225
  () => shuttingDown,
226
+ piCodingAgentEntry,
204
227
  );
205
228
  if (shuttingDown) return;
206
229
  if (result.kind === "error") {
@@ -22,6 +22,25 @@ export interface AcceptanceLedger {
22
22
  readonly status: "accepted" | "rejected" | "missing" | "malformed";
23
23
  readonly criteria: readonly AcceptanceCriterionResult[];
24
24
  readonly errors: readonly string[];
25
+ /** Child-authored judgment retained only for migration; never a runtime fact. */
26
+ readonly authority?: "model-self-attestation";
27
+ readonly deprecated?: {
28
+ readonly since: "0.5";
29
+ readonly removal: "1.0";
30
+ };
31
+ }
32
+
33
+ export const ACCEPTANCE_DEPRECATION_WARNING =
34
+ "acceptance is deprecated since OpenPI 0.5 and will be removed in 1.0; it is model self-attestation, not runtime-verified evidence, and does not determine ok";
35
+
36
+ function ledger(
37
+ value: Omit<AcceptanceLedger, "authority" | "deprecated">,
38
+ ): AcceptanceLedger {
39
+ return {
40
+ ...value,
41
+ authority: "model-self-attestation",
42
+ deprecated: { since: "0.5", removal: "1.0" },
43
+ };
25
44
  }
26
45
 
27
46
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -169,6 +188,12 @@ export function isAcceptanceLedger(value: unknown): value is AcceptanceLedger {
169
188
  value.status === "rejected" ||
170
189
  value.status === "missing" ||
171
190
  value.status === "malformed") &&
191
+ (value.authority === undefined ||
192
+ value.authority === "model-self-attestation") &&
193
+ (value.deprecated === undefined ||
194
+ (record(value.deprecated) &&
195
+ value.deprecated.since === "0.5" &&
196
+ value.deprecated.removal === "1.0")) &&
172
197
  value.errors.every((error) => typeof error === "string") &&
173
198
  value.criteria.every(
174
199
  (criterion) =>
@@ -187,7 +212,7 @@ export function acceptanceInstruction(contract: AcceptanceContract) {
187
212
  `- ${criterion.id}: ${criterion.description}${criterion.requiredEvidence?.length ? `; required evidence labels: ${criterion.requiredEvidence.join(", ")}` : ""}`,
188
213
  );
189
214
  return [
190
- "Acceptance is explicit evidence, not a self-awarded success claim.",
215
+ "Deprecated compatibility protocol: this acceptance ledger is your own model self-attestation, not runtime-verified evidence, and it does not determine execution success.",
191
216
  "Include an `acceptance.criteria` array in structured_output with exactly these ids. Mark rejected when the criterion is not demonstrated. Evidence entries must be concise labels or concrete references; do not invent evidence.",
192
217
  ...criteria,
193
218
  ].join("\n");
@@ -198,19 +223,19 @@ export function evaluateAcceptance(
198
223
  structured: unknown,
199
224
  ): AcceptanceLedger {
200
225
  if (!record(structured) || !record(structured.acceptance)) {
201
- return {
226
+ return ledger({
202
227
  status: "missing",
203
228
  criteria: [],
204
229
  errors: ["structured result omitted acceptance"],
205
- };
230
+ });
206
231
  }
207
232
  const rawCriteria = structured.acceptance.criteria;
208
233
  if (!Array.isArray(rawCriteria)) {
209
- return {
234
+ return ledger({
210
235
  status: "malformed",
211
236
  criteria: [],
212
237
  errors: ["acceptance.criteria is not an array"],
213
- };
238
+ });
214
239
  }
215
240
  const errors: string[] = [];
216
241
  const byId = new Map<string, AcceptanceCriterionResult>();
@@ -265,14 +290,15 @@ export function evaluateAcceptance(
265
290
  errors.push(`unexpected acceptance criterion "${id}"`);
266
291
  }
267
292
  }
268
- if (errors.length) return { status: "malformed", criteria: results, errors };
269
- return {
293
+ if (errors.length)
294
+ return ledger({ status: "malformed", criteria: results, errors });
295
+ return ledger({
270
296
  status: results.every((result) => result.status === "accepted")
271
297
  ? "accepted"
272
298
  : "rejected",
273
299
  criteria: results,
274
300
  errors: [],
275
- };
301
+ });
276
302
  }
277
303
 
278
304
  export function applyAcceptance(options: {
@@ -284,15 +310,13 @@ export function applyAcceptance(options: {
284
310
  const ledger = options.contract
285
311
  ? evaluateAcceptance(options.contract, options.structured)
286
312
  : undefined;
287
- const acceptanceError =
288
- ledger && ledger.status !== "accepted"
289
- ? `Acceptance ${ledger.status}${ledger.errors.length ? `: ${ledger.errors.join("; ")}` : ": one or more criteria were rejected"}`
290
- : undefined;
291
- const ok = options.agentOk && !acceptanceError;
292
- const error = ok
293
- ? undefined
294
- : options.agentError
295
- ? `${options.agentError}${acceptanceError ? `; ${acceptanceError}` : ""}`
296
- : (acceptanceError ?? "Agent failed");
297
- return { ok, ...(ledger ? { ledger } : {}), ...(error ? { error } : {}) };
313
+ const ok = options.agentOk;
314
+ const error = ok ? undefined : (options.agentError ?? "Agent failed");
315
+ return {
316
+ ok,
317
+ ...(ledger
318
+ ? { ledger, acceptanceWarning: ACCEPTANCE_DEPRECATION_WARNING }
319
+ : {}),
320
+ ...(error ? { error } : {}),
321
+ };
298
322
  }
@@ -230,7 +230,9 @@ function buildOperatorReport(
230
230
  : "running";
231
231
  lines.push(
232
232
  `- [${agent.label}]${agent.phase ? ` (${agent.phase})` : ""} ${state}` +
233
- (agent.acceptance ? ` · acceptance ${agent.acceptance.status}` : "") +
233
+ (agent.acceptance
234
+ ? ` · deprecated model self-attestation ${agent.acceptance.status}`
235
+ : "") +
234
236
  (agent.error ? ` — ${agent.error}` : ""),
235
237
  );
236
238
  }
@@ -143,18 +143,54 @@ export function readPersistedWorkflowDetails(
143
143
  runId: string,
144
144
  options: ReadPersistedRunOptions = {},
145
145
  ): WorkflowDetails | undefined {
146
- let details: WorkflowDetails | undefined;
146
+ const details = normalizeReadRecord(
147
+ runId,
148
+ readPersistedWorkflowRecord(runId),
149
+ );
150
+ if (!details) return undefined;
151
+ if (options.hydrateArtifacts) hydrateRunArtifacts(runId, details);
152
+ return details;
153
+ }
154
+
155
+ function normalizeReadRecord(runId: string, raw: unknown) {
156
+ try {
157
+ return normalizePersistedWorkflowDetails(runId, raw);
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ function readPersistedWorkflowRecord(runId: string) {
147
164
  try {
148
165
  const raw: unknown = JSON.parse(
149
166
  fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
150
167
  );
151
- details = normalizePersistedWorkflowDetails(runId, raw);
168
+ return raw && typeof raw === "object"
169
+ ? (raw as Record<string, unknown>)
170
+ : undefined;
152
171
  } catch {
153
172
  return undefined;
154
173
  }
155
- if (!details) return undefined;
156
- if (options.hydrateArtifacts) hydrateRunArtifacts(runId, details);
157
- return details;
174
+ }
175
+
176
+ function matchesRunScope(
177
+ record: { startedAt?: unknown; finishedAt?: unknown; sessionId?: unknown },
178
+ runId: string,
179
+ sessionId: string,
180
+ referencedRunIds: ReadonlySet<string>,
181
+ startedSince: number,
182
+ fromRetention = false,
183
+ ) {
184
+ const touchedAt = Math.max(
185
+ typeof record.startedAt === "number" ? record.startedAt : 0,
186
+ typeof record.finishedAt === "number" ? record.finishedAt : 0,
187
+ );
188
+ return (
189
+ touchedAt >= startedSince &&
190
+ (fromRetention ||
191
+ record.sessionId === sessionId ||
192
+ referencedRunIds.has(runId))
193
+ );
158
194
  }
159
195
 
160
196
  function isWorktreeCleanup(
@@ -220,6 +256,14 @@ function normalizeDelivery(value: unknown): WorkflowDetails["delivery"] {
220
256
  : 0;
221
257
  return {
222
258
  id: sanitizeLine(record.id, 256),
259
+ ...(typeof record.ownerSessionId === "string" && record.ownerSessionId
260
+ ? { ownerSessionId: sanitizeLine(record.ownerSessionId, 256) }
261
+ : {}),
262
+ ...(typeof record.ownerEpoch === "number" &&
263
+ Number.isSafeInteger(record.ownerEpoch) &&
264
+ record.ownerEpoch >= 0
265
+ ? { ownerEpoch: record.ownerEpoch }
266
+ : {}),
223
267
  state,
224
268
  attempts,
225
269
  updatedAt,
@@ -573,27 +617,41 @@ export function loadRunEntries(
573
617
  retained: ReadonlyMap<string, WorkflowDetails> = new Map(),
574
618
  ): RunEntry[] {
575
619
  const entries: RunEntry[] = [];
576
- const runIds = new Set([...listPersistedRunIds(), ...retained.keys()]);
620
+ const runIds = new Set([
621
+ ...listPersistedRunIds(),
622
+ ...retained.keys(),
623
+ ...active.keys(),
624
+ ]);
577
625
  for (const runId of runIds) {
578
626
  const live = active.get(runId);
579
627
  if (live) {
580
628
  entries.push({ runId, details: live, live: true });
581
629
  continue;
582
630
  }
583
- const persisted = readPersistedWorkflowDetails(runId, {
584
- hydrateArtifacts: true,
585
- });
631
+ // Reject unrelated history before normalizing potentially large inline
632
+ // transcripts. Side artifacts belong to explicit detail navigation.
633
+ const raw = readPersistedWorkflowRecord(runId);
634
+ if (
635
+ raw &&
636
+ !matchesRunScope(raw, runId, sessionId, referencedRunIds, startedSince)
637
+ ) {
638
+ continue;
639
+ }
640
+ const persisted = normalizeReadRecord(runId, raw);
586
641
  const retainedDetails = retained.get(runId);
587
642
  const details = persisted ?? retainedDetails;
588
643
  if (!details) continue;
589
644
  const fromRetention =
590
645
  persisted === undefined && retainedDetails !== undefined;
591
- const touchedAt = Math.max(details.startedAt, details.finishedAt ?? 0);
592
646
  if (
593
- touchedAt < startedSince ||
594
- (!fromRetention &&
595
- details.sessionId !== sessionId &&
596
- !referencedRunIds.has(runId))
647
+ !matchesRunScope(
648
+ details,
649
+ runId,
650
+ sessionId,
651
+ referencedRunIds,
652
+ startedSince,
653
+ fromRetention,
654
+ )
597
655
  ) {
598
656
  continue;
599
657
  }
@@ -710,6 +768,9 @@ type DetailFocus = "phases" | "agents";
710
768
  export class WorkflowDashboard {
711
769
  private view: View = "list";
712
770
  private entries: RunEntry[] = [];
771
+ private historyLoaded = false;
772
+ private seenRetainedRunIds = new Set<string>();
773
+ private hydratedRunIds = new Set<string>();
713
774
  private listIndex = 0;
714
775
  private phaseIndex = 0;
715
776
  private agentIndex = 0;
@@ -818,13 +879,68 @@ export class WorkflowDashboard {
818
879
 
819
880
  private refresh() {
820
881
  const selected = this.entries[this.listIndex]?.runId;
821
- this.entries = loadRunEntries(
822
- this.getActive(),
823
- this.sessionId,
824
- this.referencedRunIds,
825
- this.startedSince,
826
- this.getRetained(),
827
- );
882
+ const active = this.getActive();
883
+ const retained = this.getRetained();
884
+ if (!this.historyLoaded) {
885
+ this.entries = loadRunEntries(
886
+ active,
887
+ this.sessionId,
888
+ this.referencedRunIds,
889
+ this.startedSince,
890
+ retained,
891
+ );
892
+ this.historyLoaded = true;
893
+ } else {
894
+ // Animation ticks reuse historical projections. Only a newly settled run
895
+ // needs one canonical read; stable frames never scan or reread history.
896
+ const entries = new Map(
897
+ this.entries.map((entry) => [entry.runId, entry]),
898
+ );
899
+ const settledIds = new Set([
900
+ ...this.entries
901
+ .filter((entry) => entry.live && !active.has(entry.runId))
902
+ .map((entry) => entry.runId),
903
+ ...[...retained.keys()].filter(
904
+ (runId) =>
905
+ !this.seenRetainedRunIds.has(runId) &&
906
+ !entries.has(runId) &&
907
+ !active.has(runId),
908
+ ),
909
+ ]);
910
+ for (const runId of settledIds) {
911
+ const persisted = readPersistedWorkflowDetails(runId);
912
+ const details =
913
+ persisted ?? retained.get(runId) ?? entries.get(runId)?.details;
914
+ if (!details) continue;
915
+ if (
916
+ !matchesRunScope(
917
+ details,
918
+ runId,
919
+ this.sessionId,
920
+ this.referencedRunIds,
921
+ this.startedSince,
922
+ !persisted,
923
+ )
924
+ ) {
925
+ entries.delete(runId);
926
+ continue;
927
+ }
928
+ // Recovery operates on a projection, never on the former live owner.
929
+ const recovered = recoverStaleWorkflowDetails({
930
+ ...details,
931
+ agents: details.agents.map((agent) => ({ ...agent })),
932
+ });
933
+ entries.set(runId, { runId, details: recovered, live: false });
934
+ this.hydratedRunIds.delete(runId);
935
+ }
936
+ for (const [runId, details] of active) {
937
+ entries.set(runId, { runId, details, live: true });
938
+ }
939
+ this.entries = [...entries.values()].sort(
940
+ (a, b) => b.details.startedAt - a.details.startedAt,
941
+ );
942
+ }
943
+ for (const runId of retained.keys()) this.seenRetainedRunIds.add(runId);
828
944
  if (selected) {
829
945
  const index = this.entries.findIndex((e) => e.runId === selected);
830
946
  if (index >= 0) this.listIndex = index;
@@ -839,6 +955,7 @@ export class WorkflowDashboard {
839
955
  );
840
956
  if (refreshed) this.current = refreshed;
841
957
  }
958
+ if (this.view === "transcript") this.hydrateCurrent();
842
959
  if (this.notice && Date.now() - this.noticeAt > NOTICE_TTL_MS)
843
960
  this.notice = undefined;
844
961
  }
@@ -874,7 +991,15 @@ export class WorkflowDashboard {
874
991
  this.agentIndex = Math.min(this.agentIndex, Math.max(0, agents.length - 1));
875
992
  }
876
993
 
994
+ private hydrateCurrent() {
995
+ const entry = this.current;
996
+ if (!entry || entry.live || this.hydratedRunIds.has(entry.runId)) return;
997
+ hydrateRunArtifacts(entry.runId, entry.details);
998
+ this.hydratedRunIds.add(entry.runId);
999
+ }
1000
+
877
1001
  private saveReport() {
1002
+ this.hydrateCurrent();
878
1003
  const entry = this.current;
879
1004
  if (!entry) return;
880
1005
  const target = path.join(runsDir(), entry.runId, "report.md");
@@ -1015,6 +1140,7 @@ export class WorkflowDashboard {
1015
1140
  }
1016
1141
 
1017
1142
  private openTranscriptPage() {
1143
+ this.hydrateCurrent();
1018
1144
  const transcriptAdapter = new WorkflowTranscriptAdapter();
1019
1145
  this.view = "transcript";
1020
1146
  this.transcriptPage = new AgentSessionPage(