@tt-a1i/openpi 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +13 -11
  2. package/SETUP.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +242 -0
  4. package/extensions/ai-providers/README.md +12 -6
  5. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  6. package/extensions/ai-providers/cursor/input-images.ts +2 -3
  7. package/extensions/ai-providers/cursor/proto.ts +218 -11
  8. package/extensions/ai-providers/cursor/protobuf.ts +12 -2
  9. package/extensions/ai-providers/cursor/provider.ts +280 -37
  10. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -0
  11. package/extensions/ai-providers/index.ts +3 -3
  12. package/extensions/file-mutation-display/index.ts +13 -9
  13. package/extensions/shared/agent-transcript.ts +3 -2
  14. package/extensions/shared/child-session.ts +14 -0
  15. package/extensions/subagents/index.ts +20 -3
  16. package/extensions/subagents/src/agent-types.ts +5 -17
  17. package/extensions/subagents/src/backends/pi.ts +70 -59
  18. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  19. package/extensions/subagents/src/manager.ts +2 -71
  20. package/extensions/subagents/src/prompt.ts +2 -2
  21. package/extensions/subagents/src/runtime.ts +10 -3
  22. package/extensions/web/index.ts +69 -5
  23. package/extensions/workflows/artifacts.ts +362 -23
  24. package/extensions/workflows/dashboard.ts +141 -21
  25. package/extensions/workflows/index.ts +62 -20
  26. package/extensions/workflows/progress-projection.ts +7 -1
  27. package/extensions/workflows/runner.ts +5 -162
  28. package/extensions/workflows/sandbox.ts +4 -0
  29. package/package.json +28 -4
  30. package/skills/subagents/REFERENCE.md +6 -7
  31. package/skills/subagents/SKILL.md +1 -1
  32. package/skills/workflows/REFERENCE.md +3 -1
  33. package/web/dist/app.js +87 -0
  34. package/web/dist/favicon.svg +9 -0
  35. package/web/dist/index.html +15 -0
  36. package/web/dist/styles.css +3 -0
  37. package/web/host/web-host.ts +6 -9
  38. package/web/ui/index.html +3 -131
  39. package/web/ui/public/favicon.svg +9 -0
  40. package/web/ui/src/app/App.tsx +134 -0
  41. package/web/ui/src/app/providers.tsx +38 -0
  42. package/web/ui/src/components/Markdown.tsx +58 -0
  43. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  44. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  45. package/web/ui/src/features/composer/Composer.tsx +237 -0
  46. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  47. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  48. package/web/ui/src/i18n.ts +159 -0
  49. package/web/ui/src/lib/format.ts +57 -0
  50. package/web/ui/src/main.tsx +16 -0
  51. package/web/ui/src/protocol/client.ts +199 -0
  52. package/web/ui/src/protocol/event-stream.ts +88 -0
  53. package/web/ui/src/store/web-store.ts +926 -0
  54. package/web/ui/src/styles.css +420 -0
  55. package/web/ui/tsconfig.json +12 -0
  56. package/web/ui/vite-env.d.ts +1 -0
  57. package/web/vite.config.mjs +21 -1
  58. package/web/host/static-assets.ts +0 -4
  59. package/web/ui/app.js +0 -1700
  60. package/web/ui/styles.css +0 -680
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import {
@@ -22,10 +23,14 @@ import {
22
23
  } from "./serialization.ts";
23
24
 
24
25
  export const JOURNAL_FILE = "journal.json";
26
+ export const WORKFLOW_COMMIT_FILE = ".workflow-commit.json";
25
27
 
26
28
  const ARTIFACT_TRANSCRIPT_MAX_BYTES = 32 * 1024;
27
29
  const ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES = 8 * 1024;
28
30
  const AGENT_RESULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
31
+ const WORKFLOW_MANIFEST_MAX_BYTES = 1024 * 1024;
32
+ const WORKFLOW_TRANSCRIPTS_MAX_BYTES = 2 * 1024 * 1024;
33
+ const WORKFLOW_COMMIT_MAX_BYTES = 3 * 1024 * 1024;
29
34
  export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500;
30
35
  const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]";
31
36
  const TRANSCRIPT_TRUNCATION_MARKER =
@@ -35,10 +40,317 @@ type WorkflowJournalSource =
35
40
  | readonly JournalEntry[]
36
41
  | WorkflowJournalAccumulator;
37
42
 
43
+ interface WorkflowArtifactWrite {
44
+ name: typeof JOURNAL_FILE | "result.json" | "transcripts.json";
45
+ content: string;
46
+ }
47
+
48
+ interface WorkflowCommitArtifact {
49
+ name: WorkflowArtifactWrite["name"];
50
+ bytes: number;
51
+ sha256: string;
52
+ }
53
+
54
+ interface WorkflowCommitMarker {
55
+ version: 1;
56
+ runId: string;
57
+ manifest: string;
58
+ predecessorSha256: string;
59
+ artifacts: WorkflowCommitArtifact[];
60
+ }
61
+
62
+ export type WorkflowCommitRecovery =
63
+ | "none"
64
+ | "recovered"
65
+ | "already-committed"
66
+ | "incomplete"
67
+ | "invalid"
68
+ | "failed";
69
+
70
+ const artifactLimits = new Map<WorkflowArtifactWrite["name"], number>([
71
+ ["transcripts.json", WORKFLOW_TRANSCRIPTS_MAX_BYTES],
72
+ ["result.json", WORKFLOW_MANIFEST_MAX_BYTES],
73
+ [JOURNAL_FILE, JOURNAL_MAX_BYTES],
74
+ ]);
75
+
38
76
  function textBytes(text: string) {
39
77
  return Buffer.byteLength(text, "utf8");
40
78
  }
41
79
 
80
+ function sha256(content: string | Buffer) {
81
+ return createHash("sha256").update(content).digest("hex");
82
+ }
83
+
84
+ function removeWorkflowCommit(runDir: string, strict = false) {
85
+ try {
86
+ fs.unlinkSync(path.join(runDir, WORKFLOW_COMMIT_FILE));
87
+ } catch (error) {
88
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
89
+ if (strict) throw error;
90
+ }
91
+ }
92
+
93
+ function workflowCommitMarker(
94
+ details: WorkflowDetails,
95
+ manifest: string,
96
+ artifacts: WorkflowArtifactWrite[],
97
+ predecessorSha256: string,
98
+ ): WorkflowCommitMarker {
99
+ for (const { name, content } of artifacts) {
100
+ const bytes = textBytes(content);
101
+ const limit = artifactLimits.get(name);
102
+ if (limit === undefined || bytes > limit) {
103
+ throw new Error(`Workflow artifact ${name} exceeded its commit budget`);
104
+ }
105
+ }
106
+ return {
107
+ version: 1,
108
+ runId: details.runId,
109
+ manifest,
110
+ predecessorSha256,
111
+ artifacts: artifacts.map(({ name, content }) => ({
112
+ name,
113
+ bytes: textBytes(content),
114
+ sha256: sha256(content),
115
+ })),
116
+ };
117
+ }
118
+
119
+ function serializeWorkflowCommitMarker(
120
+ details: WorkflowDetails,
121
+ manifest: string,
122
+ artifacts: WorkflowArtifactWrite[],
123
+ predecessorSha256: string,
124
+ ) {
125
+ const content = JSON.stringify(
126
+ workflowCommitMarker(details, manifest, artifacts, predecessorSha256),
127
+ );
128
+ if (textBytes(content) > WORKFLOW_COMMIT_MAX_BYTES) {
129
+ throw new Error(
130
+ "Workflow artifact commit receipt exceeded its byte budget",
131
+ );
132
+ }
133
+ return content;
134
+ }
135
+
136
+ function parseWorkflowCommitMarker(
137
+ runDir: string,
138
+ ): WorkflowCommitMarker | "none" | "invalid" {
139
+ const markerPath = path.join(runDir, WORKFLOW_COMMIT_FILE);
140
+ let stat: fs.Stats;
141
+ try {
142
+ stat = fs.lstatSync(markerPath);
143
+ } catch (error) {
144
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
145
+ ? "none"
146
+ : "invalid";
147
+ }
148
+ if (
149
+ !stat.isFile() ||
150
+ stat.isSymbolicLink() ||
151
+ stat.size <= 0 ||
152
+ stat.size > WORKFLOW_COMMIT_MAX_BYTES
153
+ ) {
154
+ return "invalid";
155
+ }
156
+
157
+ let raw: unknown;
158
+ try {
159
+ raw = JSON.parse(fs.readFileSync(markerPath, "utf8"));
160
+ } catch {
161
+ return "invalid";
162
+ }
163
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "invalid";
164
+ const record = raw as Record<string, unknown>;
165
+ if (
166
+ record.version !== 1 ||
167
+ typeof record.runId !== "string" ||
168
+ record.runId !== path.basename(runDir) ||
169
+ typeof record.predecessorSha256 !== "string" ||
170
+ !/^[0-9a-f]{64}$/u.test(record.predecessorSha256) ||
171
+ typeof record.manifest !== "string" ||
172
+ textBytes(record.manifest) > WORKFLOW_MANIFEST_MAX_BYTES ||
173
+ !Array.isArray(record.artifacts) ||
174
+ record.artifacts.length < 1 ||
175
+ record.artifacts.length > artifactLimits.size
176
+ ) {
177
+ return "invalid";
178
+ }
179
+
180
+ let manifest: Record<string, unknown>;
181
+ try {
182
+ const parsed: unknown = JSON.parse(record.manifest);
183
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
184
+ return "invalid";
185
+ }
186
+ manifest = parsed as Record<string, unknown>;
187
+ } catch {
188
+ return "invalid";
189
+ }
190
+ if (
191
+ manifest.runId !== record.runId ||
192
+ !["completed", "failed", "aborted", "uncertain"].includes(
193
+ String(manifest.status),
194
+ ) ||
195
+ manifest.transcriptArtifact !== "transcripts.json" ||
196
+ (manifest.resultArtifact !== undefined &&
197
+ manifest.resultArtifact !== "result.json")
198
+ ) {
199
+ return "invalid";
200
+ }
201
+
202
+ const artifacts: WorkflowCommitArtifact[] = [];
203
+ const names = new Set<string>();
204
+ for (const value of record.artifacts) {
205
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
206
+ return "invalid";
207
+ }
208
+ const artifact = value as Record<string, unknown>;
209
+ if (
210
+ typeof artifact.name !== "string" ||
211
+ !artifactLimits.has(artifact.name as WorkflowArtifactWrite["name"]) ||
212
+ names.has(artifact.name) ||
213
+ typeof artifact.bytes !== "number" ||
214
+ !Number.isInteger(artifact.bytes) ||
215
+ artifact.bytes < 0 ||
216
+ artifact.bytes >
217
+ (artifactLimits.get(artifact.name as WorkflowArtifactWrite["name"]) ??
218
+ -1) ||
219
+ typeof artifact.sha256 !== "string" ||
220
+ !/^[0-9a-f]{64}$/u.test(artifact.sha256)
221
+ ) {
222
+ return "invalid";
223
+ }
224
+ names.add(artifact.name);
225
+ artifacts.push({
226
+ name: artifact.name as WorkflowArtifactWrite["name"],
227
+ bytes: artifact.bytes,
228
+ sha256: artifact.sha256,
229
+ });
230
+ }
231
+ if (
232
+ !names.has("transcripts.json") ||
233
+ (manifest.resultArtifact === "result.json") !== names.has("result.json")
234
+ ) {
235
+ return "invalid";
236
+ }
237
+ return {
238
+ version: 1,
239
+ runId: record.runId,
240
+ manifest: record.manifest,
241
+ predecessorSha256: record.predecessorSha256,
242
+ artifacts,
243
+ };
244
+ }
245
+
246
+ function hasCommittedManifest(
247
+ manifestPath: string,
248
+ marker: WorkflowCommitMarker,
249
+ ) {
250
+ try {
251
+ const stat = fs.lstatSync(manifestPath);
252
+ if (
253
+ !stat.isFile() ||
254
+ stat.isSymbolicLink() ||
255
+ stat.size > WORKFLOW_MANIFEST_MAX_BYTES
256
+ )
257
+ return false;
258
+ const content = fs.readFileSync(manifestPath);
259
+ if (content.byteLength > WORKFLOW_MANIFEST_MAX_BYTES) return false;
260
+ const parsed: unknown = JSON.parse(content.toString("utf8"));
261
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
262
+ return false;
263
+ }
264
+ const manifest = parsed as Record<string, unknown>;
265
+ const markerManifest = JSON.parse(marker.manifest) as Record<
266
+ string,
267
+ unknown
268
+ >;
269
+ return (
270
+ manifest.runId === marker.runId &&
271
+ manifest.status === markerManifest.status &&
272
+ manifest.transcriptArtifact === markerManifest.transcriptArtifact &&
273
+ manifest.resultArtifact === markerManifest.resultArtifact
274
+ );
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Complete a terminal artifact commit only when every prepared file matches
282
+ * the exact bounded receipt written before the side-artifact sequence began.
283
+ */
284
+ export function recoverPendingWorkflowCommit(
285
+ runDir: string,
286
+ ): WorkflowCommitRecovery {
287
+ const marker = parseWorkflowCommitMarker(runDir);
288
+ if (marker === "none" || marker === "invalid") return marker;
289
+
290
+ for (const artifact of marker.artifacts) {
291
+ const artifactPath = path.join(runDir, artifact.name);
292
+ let stat: fs.Stats;
293
+ let content: Buffer;
294
+ try {
295
+ stat = fs.lstatSync(artifactPath);
296
+ if (
297
+ !stat.isFile() ||
298
+ stat.isSymbolicLink() ||
299
+ stat.size !== artifact.bytes
300
+ ) {
301
+ return "incomplete";
302
+ }
303
+ content = fs.readFileSync(artifactPath);
304
+ } catch {
305
+ return "incomplete";
306
+ }
307
+ if (
308
+ content.byteLength !== artifact.bytes ||
309
+ sha256(content) !== artifact.sha256
310
+ ) {
311
+ return "incomplete";
312
+ }
313
+ }
314
+
315
+ const manifestPath = path.join(runDir, "workflow.json");
316
+ try {
317
+ if (
318
+ fs.existsSync(manifestPath) &&
319
+ hasCommittedManifest(manifestPath, marker)
320
+ ) {
321
+ removeWorkflowCommit(runDir);
322
+ return "already-committed";
323
+ }
324
+ // A later terminal/cleanup/delivery publication supersedes this receipt.
325
+ // Artifact validity alone cannot authorize replacing canonical facts.
326
+ let predecessor: Buffer;
327
+ try {
328
+ const stat = fs.lstatSync(manifestPath);
329
+ if (
330
+ !stat.isFile() ||
331
+ stat.isSymbolicLink() ||
332
+ stat.size > WORKFLOW_MANIFEST_MAX_BYTES
333
+ ) {
334
+ return "invalid";
335
+ }
336
+ predecessor = fs.readFileSync(manifestPath);
337
+ } catch {
338
+ return "invalid";
339
+ }
340
+ if (
341
+ predecessor.byteLength > WORKFLOW_MANIFEST_MAX_BYTES ||
342
+ sha256(predecessor) !== marker.predecessorSha256
343
+ ) {
344
+ return "invalid";
345
+ }
346
+ writeFileAtomic(manifestPath, marker.manifest);
347
+ removeWorkflowCommit(runDir);
348
+ return "recovered";
349
+ } catch {
350
+ return "failed";
351
+ }
352
+ }
353
+
42
354
  function boundEntry(entry: TranscriptEntry, maxBytes: number) {
43
355
  if (textBytes(entry.text) <= maxBytes) return { ...entry };
44
356
  const markerBytes = textBytes(ENTRY_TRUNCATION_MARKER);
@@ -120,11 +432,11 @@ export function persistWorkflowTerminalState(
120
432
  delete terminalManifest.result;
121
433
  delete terminalManifest.resultArtifact;
122
434
  delete terminalManifest.transcriptArtifact;
123
- writeRunFile(
124
- runDir,
125
- "workflow.json",
126
- safeStringify(terminalManifest, { maxBytes: 1024 * 1024 }),
127
- );
435
+ const content = safeStringify(terminalManifest, {
436
+ maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
437
+ });
438
+ writeRunFile(runDir, "workflow.json", content);
439
+ return sha256(content);
128
440
  }
129
441
 
130
442
  /** Persist one successful child result before any handoff/context projection. */
@@ -177,15 +489,24 @@ export function persistWorkflowJson(
177
489
  // later artifact write fails, readers still see an explained terminal run
178
490
  // instead of the previous `running` manifest. The final manifest below adds
179
491
  // the artifact references once every dependent file has committed.
180
- if (details.status !== "running") {
181
- persistWorkflowTerminalState(runDir, details);
492
+ const terminal = details.status !== "running";
493
+ let predecessorSha256: string | undefined;
494
+ if (terminal) {
495
+ // A retry supersedes an older unfinished receipt before it publishes a new
496
+ // terminal fact. Failing to remove it must stop the new commit rather than
497
+ // let a concurrent reader promote stale artifact identities.
498
+ removeWorkflowCommit(runDir, true);
499
+ predecessorSha256 = persistWorkflowTerminalState(runDir, details);
182
500
  }
183
501
 
184
- writeRunFile(
185
- runDir,
186
- "transcripts.json",
187
- safeStringify(transcripts, { maxBytes: 2 * 1024 * 1024 }),
188
- );
502
+ const artifactWrites: WorkflowArtifactWrite[] = [
503
+ {
504
+ name: "transcripts.json",
505
+ content: safeStringify(transcripts, {
506
+ maxBytes: WORKFLOW_TRANSCRIPTS_MAX_BYTES,
507
+ }),
508
+ },
509
+ ];
189
510
  // Written alongside the rest so it inherits atomic write, 500ms coalescing,
190
511
  // and the final flush. Only present once a call has actually succeeded.
191
512
  // Accumulators already enforce the cap incrementally and can assemble the
@@ -201,14 +522,15 @@ export function persistWorkflowJson(
201
522
  "toJson" in journal
202
523
  ? journal.toJson()
203
524
  : JSON.stringify(boundedJournal(journal).journal, null, 2);
204
- writeRunFile(runDir, JOURNAL_FILE, content);
525
+ artifactWrites.push({ name: JOURNAL_FILE, content });
205
526
  }
206
527
  if (details.result !== undefined) {
207
- writeRunFile(
208
- runDir,
209
- "result.json",
210
- safeStringify(details.result, { maxBytes: 1024 * 1024 }),
211
- );
528
+ artifactWrites.push({
529
+ name: "result.json",
530
+ content: safeStringify(details.result, {
531
+ maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
532
+ }),
533
+ });
212
534
  }
213
535
  const compact: WorkflowDetails = {
214
536
  ...details,
@@ -218,11 +540,27 @@ export function persistWorkflowJson(
218
540
  transcriptArtifact: "transcripts.json",
219
541
  agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
220
542
  };
221
- writeRunFile(
222
- runDir,
223
- "workflow.json",
224
- safeStringify(compact, { maxBytes: 1024 * 1024 }),
225
- );
543
+ const manifest = safeStringify(compact, {
544
+ maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
545
+ });
546
+
547
+ if (predecessorSha256 !== undefined) {
548
+ writeRunFile(
549
+ runDir,
550
+ WORKFLOW_COMMIT_FILE,
551
+ serializeWorkflowCommitMarker(
552
+ details,
553
+ manifest,
554
+ artifactWrites,
555
+ predecessorSha256,
556
+ ),
557
+ );
558
+ }
559
+ for (const artifact of artifactWrites) {
560
+ writeRunFile(runDir, artifact.name, artifact.content);
561
+ }
562
+ writeRunFile(runDir, "workflow.json", manifest);
563
+ if (terminal) removeWorkflowCommit(runDir);
226
564
  }
227
565
 
228
566
  /**
@@ -235,6 +573,7 @@ export function persistWorkflowDeliveryState(
235
573
  runDir: string,
236
574
  delivery: WorkflowDelivery,
237
575
  ) {
576
+ recoverPendingWorkflowCommit(runDir);
238
577
  const file = path.join(runDir, "workflow.json");
239
578
  const raw: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
240
579
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
@@ -31,6 +31,7 @@ import {
31
31
  import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts";
32
32
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
33
33
  import { isAcceptanceLedger } from "./acceptance.ts";
34
+ import { recoverPendingWorkflowCommit } from "./artifacts.ts";
34
35
  import { projectWorkflowGraph } from "./graph-projection.ts";
35
36
  import {
36
37
  classifyInterruptedInvocation,
@@ -143,18 +144,55 @@ export function readPersistedWorkflowDetails(
143
144
  runId: string,
144
145
  options: ReadPersistedRunOptions = {},
145
146
  ): WorkflowDetails | undefined {
146
- let details: WorkflowDetails | undefined;
147
+ const details = normalizeReadRecord(
148
+ runId,
149
+ readPersistedWorkflowRecord(runId),
150
+ );
151
+ if (!details) return undefined;
152
+ if (options.hydrateArtifacts) hydrateRunArtifacts(runId, details);
153
+ return details;
154
+ }
155
+
156
+ function normalizeReadRecord(runId: string, raw: unknown) {
157
+ try {
158
+ return normalizePersistedWorkflowDetails(runId, raw);
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ function readPersistedWorkflowRecord(runId: string) {
165
+ recoverPendingWorkflowCommit(path.join(runsDir(), runId));
147
166
  try {
148
167
  const raw: unknown = JSON.parse(
149
168
  fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
150
169
  );
151
- details = normalizePersistedWorkflowDetails(runId, raw);
170
+ return raw && typeof raw === "object"
171
+ ? (raw as Record<string, unknown>)
172
+ : undefined;
152
173
  } catch {
153
174
  return undefined;
154
175
  }
155
- if (!details) return undefined;
156
- if (options.hydrateArtifacts) hydrateRunArtifacts(runId, details);
157
- return details;
176
+ }
177
+
178
+ function matchesRunScope(
179
+ record: { startedAt?: unknown; finishedAt?: unknown; sessionId?: unknown },
180
+ runId: string,
181
+ sessionId: string,
182
+ referencedRunIds: ReadonlySet<string>,
183
+ startedSince: number,
184
+ fromRetention = false,
185
+ ) {
186
+ const touchedAt = Math.max(
187
+ typeof record.startedAt === "number" ? record.startedAt : 0,
188
+ typeof record.finishedAt === "number" ? record.finishedAt : 0,
189
+ );
190
+ return (
191
+ touchedAt >= startedSince &&
192
+ (fromRetention ||
193
+ record.sessionId === sessionId ||
194
+ referencedRunIds.has(runId))
195
+ );
158
196
  }
159
197
 
160
198
  function isWorktreeCleanup(
@@ -581,27 +619,41 @@ export function loadRunEntries(
581
619
  retained: ReadonlyMap<string, WorkflowDetails> = new Map(),
582
620
  ): RunEntry[] {
583
621
  const entries: RunEntry[] = [];
584
- const runIds = new Set([...listPersistedRunIds(), ...retained.keys()]);
622
+ const runIds = new Set([
623
+ ...listPersistedRunIds(),
624
+ ...retained.keys(),
625
+ ...active.keys(),
626
+ ]);
585
627
  for (const runId of runIds) {
586
628
  const live = active.get(runId);
587
629
  if (live) {
588
630
  entries.push({ runId, details: live, live: true });
589
631
  continue;
590
632
  }
591
- const persisted = readPersistedWorkflowDetails(runId, {
592
- hydrateArtifacts: true,
593
- });
633
+ // Reject unrelated history before normalizing potentially large inline
634
+ // transcripts. Side artifacts belong to explicit detail navigation.
635
+ const raw = readPersistedWorkflowRecord(runId);
636
+ if (
637
+ raw &&
638
+ !matchesRunScope(raw, runId, sessionId, referencedRunIds, startedSince)
639
+ ) {
640
+ continue;
641
+ }
642
+ const persisted = normalizeReadRecord(runId, raw);
594
643
  const retainedDetails = retained.get(runId);
595
644
  const details = persisted ?? retainedDetails;
596
645
  if (!details) continue;
597
646
  const fromRetention =
598
647
  persisted === undefined && retainedDetails !== undefined;
599
- const touchedAt = Math.max(details.startedAt, details.finishedAt ?? 0);
600
648
  if (
601
- touchedAt < startedSince ||
602
- (!fromRetention &&
603
- details.sessionId !== sessionId &&
604
- !referencedRunIds.has(runId))
649
+ !matchesRunScope(
650
+ details,
651
+ runId,
652
+ sessionId,
653
+ referencedRunIds,
654
+ startedSince,
655
+ fromRetention,
656
+ )
605
657
  ) {
606
658
  continue;
607
659
  }
@@ -718,6 +770,9 @@ type DetailFocus = "phases" | "agents";
718
770
  export class WorkflowDashboard {
719
771
  private view: View = "list";
720
772
  private entries: RunEntry[] = [];
773
+ private historyLoaded = false;
774
+ private seenRetainedRunIds = new Set<string>();
775
+ private hydratedRunIds = new Set<string>();
721
776
  private listIndex = 0;
722
777
  private phaseIndex = 0;
723
778
  private agentIndex = 0;
@@ -826,13 +881,68 @@ export class WorkflowDashboard {
826
881
 
827
882
  private refresh() {
828
883
  const selected = this.entries[this.listIndex]?.runId;
829
- this.entries = loadRunEntries(
830
- this.getActive(),
831
- this.sessionId,
832
- this.referencedRunIds,
833
- this.startedSince,
834
- this.getRetained(),
835
- );
884
+ const active = this.getActive();
885
+ const retained = this.getRetained();
886
+ if (!this.historyLoaded) {
887
+ this.entries = loadRunEntries(
888
+ active,
889
+ this.sessionId,
890
+ this.referencedRunIds,
891
+ this.startedSince,
892
+ retained,
893
+ );
894
+ this.historyLoaded = true;
895
+ } else {
896
+ // Animation ticks reuse historical projections. Only a newly settled run
897
+ // needs one canonical read; stable frames never scan or reread history.
898
+ const entries = new Map(
899
+ this.entries.map((entry) => [entry.runId, entry]),
900
+ );
901
+ const settledIds = new Set([
902
+ ...this.entries
903
+ .filter((entry) => entry.live && !active.has(entry.runId))
904
+ .map((entry) => entry.runId),
905
+ ...[...retained.keys()].filter(
906
+ (runId) =>
907
+ !this.seenRetainedRunIds.has(runId) &&
908
+ !entries.has(runId) &&
909
+ !active.has(runId),
910
+ ),
911
+ ]);
912
+ for (const runId of settledIds) {
913
+ const persisted = readPersistedWorkflowDetails(runId);
914
+ const details =
915
+ persisted ?? retained.get(runId) ?? entries.get(runId)?.details;
916
+ if (!details) continue;
917
+ if (
918
+ !matchesRunScope(
919
+ details,
920
+ runId,
921
+ this.sessionId,
922
+ this.referencedRunIds,
923
+ this.startedSince,
924
+ !persisted,
925
+ )
926
+ ) {
927
+ entries.delete(runId);
928
+ continue;
929
+ }
930
+ // Recovery operates on a projection, never on the former live owner.
931
+ const recovered = recoverStaleWorkflowDetails({
932
+ ...details,
933
+ agents: details.agents.map((agent) => ({ ...agent })),
934
+ });
935
+ entries.set(runId, { runId, details: recovered, live: false });
936
+ this.hydratedRunIds.delete(runId);
937
+ }
938
+ for (const [runId, details] of active) {
939
+ entries.set(runId, { runId, details, live: true });
940
+ }
941
+ this.entries = [...entries.values()].sort(
942
+ (a, b) => b.details.startedAt - a.details.startedAt,
943
+ );
944
+ }
945
+ for (const runId of retained.keys()) this.seenRetainedRunIds.add(runId);
836
946
  if (selected) {
837
947
  const index = this.entries.findIndex((e) => e.runId === selected);
838
948
  if (index >= 0) this.listIndex = index;
@@ -847,6 +957,7 @@ export class WorkflowDashboard {
847
957
  );
848
958
  if (refreshed) this.current = refreshed;
849
959
  }
960
+ if (this.view === "transcript") this.hydrateCurrent();
850
961
  if (this.notice && Date.now() - this.noticeAt > NOTICE_TTL_MS)
851
962
  this.notice = undefined;
852
963
  }
@@ -882,7 +993,15 @@ export class WorkflowDashboard {
882
993
  this.agentIndex = Math.min(this.agentIndex, Math.max(0, agents.length - 1));
883
994
  }
884
995
 
996
+ private hydrateCurrent() {
997
+ const entry = this.current;
998
+ if (!entry || entry.live || this.hydratedRunIds.has(entry.runId)) return;
999
+ hydrateRunArtifacts(entry.runId, entry.details);
1000
+ this.hydratedRunIds.add(entry.runId);
1001
+ }
1002
+
885
1003
  private saveReport() {
1004
+ this.hydrateCurrent();
886
1005
  const entry = this.current;
887
1006
  if (!entry) return;
888
1007
  const target = path.join(runsDir(), entry.runId, "report.md");
@@ -1023,6 +1142,7 @@ export class WorkflowDashboard {
1023
1142
  }
1024
1143
 
1025
1144
  private openTranscriptPage() {
1145
+ this.hydrateCurrent();
1026
1146
  const transcriptAdapter = new WorkflowTranscriptAdapter();
1027
1147
  this.view = "transcript";
1028
1148
  this.transcriptPage = new AgentSessionPage(