pi-ultracode 0.1.0 → 0.1.2

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.
@@ -3,28 +3,67 @@
3
3
  * for streamed tool updates and the final tool result.
4
4
  */
5
5
 
6
+ import { safeDisplayText } from "./display-text.ts";
6
7
  import type { WorkflowMeta } from "./parser.ts";
7
8
 
8
- export type WorkflowAgentStatus = "running" | "done" | "error" | "skipped" | "cached";
9
+ export type WorkflowAgentStatus = "running" | "done" | "error" | "cancelled" | "skipped" | "cached";
10
+
11
+ export interface WorkflowActiveToolSnapshot {
12
+ id: string;
13
+ name: string;
14
+ args?: string;
15
+ startedAt: number;
16
+ lastUpdateAt: number;
17
+ }
18
+
19
+ export interface WorkflowAgentUsageSnapshot {
20
+ inputTokens: number;
21
+ outputTokens: number;
22
+ cacheReadTokens: number;
23
+ cacheWriteTokens: number;
24
+ totalTokens: number;
25
+ cost: number;
26
+ turns: number;
27
+ toolUses: number;
28
+ retries: number;
29
+ compactions: number;
30
+ }
9
31
 
10
32
  export interface WorkflowAgentSnapshot {
11
33
  id: number;
12
34
  label: string;
13
35
  phase?: string;
36
+ workflowPath?: string[];
14
37
  status: WorkflowAgentStatus;
15
38
  resultPreview?: string;
16
39
  error?: string;
40
+ /** Actual model selected by the child session. */
41
+ modelId?: string;
42
+ /** Actual effort applied by the child session. */
43
+ effort?: string;
44
+ /** Requested values are retained for diagnostics but never rendered as actual. */
45
+ requestedModelId?: string;
46
+ requestedEffort?: string;
47
+ agentType?: string;
48
+ isolation?: string;
49
+ structuredOutput?: boolean;
50
+ usage?: WorkflowAgentUsageSnapshot;
51
+ currentTurn?: number;
52
+ legacyCache?: boolean;
53
+ transcriptPath?: string;
17
54
  /** Wall-clock ms when the agent started (host layer; set by the tool). */
18
55
  startedAt?: number;
19
56
  /** Wall-clock ms when the agent finished. */
20
57
  endedAt?: number;
21
58
  /** Runtime duration in ms (endedAt - startedAt). */
22
59
  durationMs?: number;
23
- /** Wall-clock ms of the last observed activity inside the subagent. */
60
+ /** Wall-clock ms of the last observed session event inside the subagent. */
24
61
  lastActivityAt?: number;
25
- /** Short activity label while running, e.g. "text", "bash", "thinking". */
62
+ /** Last known factual state, e.g. "waiting for model" or "retry 2/3". */
26
63
  activity?: string;
27
- /** Ring buffer of recent text output while running (live, not persisted). */
64
+ /** Tool calls that have started but have not emitted tool_execution_end. */
65
+ activeTools?: WorkflowActiveToolSnapshot[];
66
+ /** @deprecated Raw assistant stream text is never captured or rendered. */
28
67
  streamTail?: string;
29
68
  }
30
69
 
@@ -40,8 +79,13 @@ export interface WorkflowSnapshot {
40
79
  runningCount: number;
41
80
  doneCount: number;
42
81
  errorCount: number;
82
+ cancelledCount: number;
43
83
  cachedCount: number;
44
84
  spentTokens: number;
85
+ newTokens: number;
86
+ replayedTokens: number;
87
+ /** Sanitized session-scoped workflow detail manifest for headless consumers. */
88
+ detailsManifestPath?: string;
45
89
  budgetTotal: number | null;
46
90
  durationMs?: number;
47
91
  result?: unknown;
@@ -52,26 +96,29 @@ export interface RenderOptions {
52
96
  maxAgents?: number;
53
97
  maxLogs?: number;
54
98
  showResultPreviews?: boolean;
55
- /** Show the live streaming tail under each running agent (inspect view). */
99
+ /** @deprecated No-op retained for source compatibility. */
56
100
  showStream?: boolean;
57
- /** Override `Date.now()` for deterministic elapsed/idle rendering in tests. */
101
+ /** Override `Date.now()` for deterministic elapsed/activity rendering in tests. */
58
102
  now?: number;
59
103
  }
60
104
 
61
105
  export function createSnapshot(meta: WorkflowMeta, runId: string, budgetTotal: number | null): WorkflowSnapshot {
62
106
  return {
63
107
  runId,
64
- name: meta.name,
65
- description: meta.description,
66
- phases: meta.phases?.map((p) => p.title) ?? [],
108
+ name: safeDisplayText(meta.name, 120) || "workflow",
109
+ description: meta.description ? safeDisplayText(meta.description, 240) : undefined,
110
+ phases: meta.phases?.map((p) => safeDisplayText(p.title, 120)).filter(Boolean) ?? [],
67
111
  logs: [],
68
112
  agents: [],
69
113
  agentCount: 0,
70
114
  runningCount: 0,
71
115
  doneCount: 0,
72
116
  errorCount: 0,
117
+ cancelledCount: 0,
73
118
  cachedCount: 0,
74
119
  spentTokens: 0,
120
+ newTokens: 0,
121
+ replayedTokens: 0,
75
122
  budgetTotal,
76
123
  status: "running",
77
124
  };
@@ -81,28 +128,34 @@ export function recompute(snapshot: WorkflowSnapshot): WorkflowSnapshot {
81
128
  const runningCount = snapshot.agents.filter((a) => a.status === "running").length;
82
129
  const doneCount = snapshot.agents.filter((a) => a.status === "done" || a.status === "cached").length;
83
130
  const errorCount = snapshot.agents.filter((a) => a.status === "error").length;
131
+ const cancelledCount = snapshot.agents.filter((a) => a.status === "cancelled" || a.status === "skipped").length;
84
132
  const cachedCount = snapshot.agents.filter((a) => a.status === "cached").length;
85
- return { ...snapshot, agentCount: snapshot.agents.length, runningCount, doneCount, errorCount, cachedCount };
133
+ return { ...snapshot, agentCount: snapshot.agents.length, runningCount, doneCount, errorCount, cancelledCount, cachedCount };
86
134
  }
87
135
 
88
136
  export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderOptions = {}): string[] {
89
137
  const maxAgents = options.maxAgents ?? 6;
90
138
  const maxLogs = options.maxLogs ?? 2;
91
139
  const showResultPreviews = options.showResultPreviews ?? false;
92
- const showStream = options.showStream ?? false;
93
140
  const now = options.now ?? Date.now();
94
141
 
95
- const tokens = snapshot.spentTokens
96
- ? ` · ${formatTokens(snapshot.spentTokens)}${snapshot.budgetTotal ? `/${formatTokens(snapshot.budgetTotal)}` : ""} tok`
142
+ const representedTokens = snapshot.newTokens + snapshot.replayedTokens;
143
+ const tokens = representedTokens
144
+ ? ` · ${formatTokens(representedTokens)} token${snapshot.replayedTokens ? ` (${formatTokens(snapshot.newTokens)} new, ${formatTokens(snapshot.replayedTokens)} replayed)` : ""}`
145
+ : snapshot.spentTokens
146
+ ? ` · ${formatTokens(snapshot.spentTokens)} output token`
147
+ : "";
148
+ const budget = snapshot.budgetTotal
149
+ ? ` · ${formatTokens(snapshot.spentTokens)}/${formatTokens(snapshot.budgetTotal)} out`
97
150
  : "";
98
- const state =
99
- snapshot.errorCount > 0
100
- ? `, ${snapshot.errorCount} errors`
101
- : snapshot.runningCount > 0
102
- ? `, ${snapshot.runningCount} running`
103
- : "";
151
+ const stateParts = [
152
+ snapshot.runningCount > 0 ? `${snapshot.runningCount} running` : undefined,
153
+ snapshot.errorCount > 0 ? `${snapshot.errorCount} errors` : undefined,
154
+ snapshot.cancelledCount > 0 ? `${snapshot.cancelledCount} cancelled` : undefined,
155
+ ].filter(Boolean);
156
+ const state = stateParts.length ? `, ${stateParts.join(", ")}` : "";
104
157
  const cached = snapshot.cachedCount ? ` · ${snapshot.cachedCount} cached` : "";
105
- const header = `◆ ${statusMark(snapshot.status)} ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${tokens}`;
158
+ const header = `◆ ${statusMark(snapshot.status)} ${shorten(snapshot.name, 60)} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${tokens}${budget}`;
106
159
  const lines = [header];
107
160
 
108
161
  const phaseNames = unique([
@@ -119,13 +172,22 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
119
172
  const done = agents.filter((a) => a.status === "done" || a.status === "cached").length;
120
173
  const running = agents.filter((a) => a.status === "running").length;
121
174
  const errors = agents.filter((a) => a.status === "error").length;
122
- const complete = agents.length > 0 && done + errors === agents.length;
123
- const marker = running > 0 || (!complete && snapshot.currentPhase === phase) ? "▶" : complete ? "✓" : " ";
175
+ const cancelled = agents.filter((a) => a.status === "cancelled" || a.status === "skipped").length;
176
+ const complete = agents.length > 0 && running === 0;
177
+ const marker = running > 0 || (!complete && snapshot.currentPhase === phase)
178
+ ? "▶"
179
+ : errors
180
+ ? "✗"
181
+ : cancelled
182
+ ? "■"
183
+ : complete
184
+ ? "✓"
185
+ : " ";
124
186
  lines.push(
125
- ` ${marker} ${phase} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}`,
187
+ ` ${marker} ${shorten(phase, 60)} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${cancelled ? ` · ${cancelled} cancelled` : ""}`,
126
188
  );
127
189
  for (const agent of agents.slice(-maxAgents)) {
128
- lines.push(renderAgentLine(agent, { showResultPreviews, showStream, now }));
190
+ lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
129
191
  }
130
192
  if (agents.length > maxAgents) lines.push(` … ${agents.length - maxAgents} earlier agents`);
131
193
  }
@@ -134,8 +196,9 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
134
196
  if (unphased.length) {
135
197
  lines.push(" (unphased)");
136
198
  for (const agent of unphased.slice(-maxAgents)) {
137
- lines.push(renderAgentLine(agent, { showResultPreviews, showStream, now }));
199
+ lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
138
200
  }
201
+ if (unphased.length > maxAgents) lines.push(` … ${unphased.length - maxAgents} earlier agents`);
139
202
  }
140
203
 
141
204
  for (const log of snapshot.logs.slice(-maxLogs)) lines.push(` log: ${shorten(log, 100)}`);
@@ -147,10 +210,8 @@ export function renderWorkflowText(snapshot: WorkflowSnapshot, options: RenderOp
147
210
  }
148
211
 
149
212
  export function preview(value: unknown, max = 80): string {
150
- const text = typeof value === "string" ? value : safeJson(value);
151
- if (!text) return "";
152
- const oneLine = text.replace(/\s+/g, " ").trim();
153
- return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
213
+ const text = typeof value === "string" ? value : boundedProjection(value, max);
214
+ return text ? safeDisplayText(text, max) : "";
154
215
  }
155
216
 
156
217
  function statusMark(status: WorkflowSnapshot["status"]): string {
@@ -176,6 +237,8 @@ function statusIcon(status: WorkflowAgentStatus): string {
176
237
  return "⟲";
177
238
  case "error":
178
239
  return "✗";
240
+ case "cancelled":
241
+ return "■";
179
242
  case "skipped":
180
243
  return "-";
181
244
  }
@@ -186,38 +249,88 @@ function unique(values: string[]): string[] {
186
249
  }
187
250
 
188
251
  function shorten(value: string, max: number): string {
189
- const text = value.replace(/\s+/g, " ").trim();
190
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
252
+ return safeDisplayText(value, max);
191
253
  }
192
254
 
193
255
  function formatTokens(n: number): string {
194
256
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
195
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
257
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
196
258
  return String(n);
197
259
  }
198
260
 
199
- function safeJson(value: unknown): string {
261
+ const PREVIEW_MAX_STRING = 256;
262
+ const UNINSPECTABLE_PREVIEW = "[Uninspectable]";
263
+
264
+ /**
265
+ * Build a UI-only projection without invoking arbitrary object hooks. Objects
266
+ * and arrays intentionally remain opaque: JavaScript has no bounded own-key
267
+ * enumeration API, and Proxy/accessor traps must never affect agent success.
268
+ */
269
+ function boundedProjection(value: unknown, _max: number): string {
200
270
  try {
201
- return JSON.stringify(value) ?? "";
271
+ if (value === null) return "null";
272
+ if (typeof value === "string") {
273
+ const bounded = value.length > PREVIEW_MAX_STRING
274
+ ? `${value.slice(0, PREVIEW_MAX_STRING)}…`
275
+ : value;
276
+ return JSON.stringify(bounded);
277
+ }
278
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "undefined") {
279
+ return String(value);
280
+ }
281
+ if (typeof value === "bigint") return "[BigInt]";
282
+ if (typeof value === "symbol") return "[Symbol]";
283
+ if (typeof value === "function") return "[Function]";
284
+ return Array.isArray(value) ? "[Array]" : "[Object]";
202
285
  } catch {
203
- return String(value);
286
+ return UNINSPECTABLE_PREVIEW;
204
287
  }
205
288
  }
206
289
 
207
- /** Seconds of silence after which a running agent is flagged as idle. */
290
+ /** Seconds without an observable session event before visibility escalates. */
208
291
  export const IDLE_THRESHOLD_S = 30;
209
292
 
210
- function renderAgentLine(
293
+ function renderAgentLines(
211
294
  agent: WorkflowAgentSnapshot,
212
- opts: { showResultPreviews: boolean; showStream: boolean; now: number },
213
- ): string {
295
+ opts: {
296
+ showResultPreviews: boolean;
297
+ now: number;
298
+ },
299
+ ): string[] {
214
300
  const meta = agentMeta(agent, opts.now);
215
- const result = opts.showResultPreviews && agent.resultPreview ? ` — ${agent.resultPreview}` : "";
216
- const stream =
217
- opts.showStream && agent.status === "running" && agent.streamTail
218
- ? `\n ┊ ${shorten(agent.streamTail, 120)}`
219
- : "";
220
- return ` #${agent.id} ${statusIcon(agent.status)} ${shorten(agent.label, 48)}${meta}${result}${stream}`;
301
+ const result = opts.showResultPreviews && agent.resultPreview
302
+ ? ` — ${safeDisplayText(agent.resultPreview, 80)}`
303
+ : "";
304
+ return [
305
+ ` #${agent.id} ${statusIcon(agent.status)} ${shorten(agent.label, 48)}${meta}${result}`,
306
+ ` ${agentStats(agent)}`,
307
+ ];
308
+ }
309
+
310
+ function agentStats(agent: WorkflowAgentSnapshot): string {
311
+ const running = agent.status === "running";
312
+ const model = agent.modelId
313
+ ?? (running ? "resolving model…" : agent.legacyCache ? "model unavailable" : "model unresolved");
314
+ const effort = agent.effort
315
+ ?? (running ? "resolving effort…" : agent.legacyCache ? "effort unavailable" : "effort unresolved");
316
+ if (agent.legacyCache) {
317
+ return `${shorten(model, 48)} • ${shorten(effort, 24)} · metrics unavailable · cached legacy entry`;
318
+ }
319
+ const usage = agent.usage ?? {
320
+ inputTokens: 0,
321
+ outputTokens: 0,
322
+ cacheReadTokens: 0,
323
+ cacheWriteTokens: 0,
324
+ totalTokens: 0,
325
+ cost: 0,
326
+ turns: 0,
327
+ toolUses: 0,
328
+ retries: 0,
329
+ compactions: 0,
330
+ };
331
+ const partial = agent.status === "cancelled" || agent.status === "error" || (running && agent.currentTurn != null);
332
+ const cache = agent.status === "cached" ? " · cached" : "";
333
+ return `${shorten(model, 48)} • ${shorten(effort, 24)} · ${usage.turns} turn${usage.turns === 1 ? "" : "s"} · ${usage.toolUses} tool use${usage.toolUses === 1 ? "" : "s"} · ${formatTokens(usage.totalTokens)}${partial ? "+" : ""} token${partial ? " · partial" : ""}${cache}`;
221
334
  }
222
335
 
223
336
  /** Compact per-agent timing/activity suffix for the live snapshot. */
@@ -226,20 +339,40 @@ function agentMeta(agent: WorkflowAgentSnapshot, now: number): string {
226
339
  const startedAt = agent.startedAt ?? now;
227
340
  const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
228
341
  const lastAct = agent.lastActivityAt ?? startedAt;
229
- const idle = Math.max(0, Math.floor((now - lastAct) / 1000));
230
- if (idle >= IDLE_THRESHOLD_S) return ` · ${elapsed}s · ⚠ idle ${idle}s`;
231
- const act = agent.activity ? ` · ${shorten(agent.activity, 20)}` : "";
232
- return ` · ${elapsed}s${act}`;
342
+ const silence = Math.max(0, Math.floor((now - lastAct) / 1000));
343
+ const activeTools = agent.activeTools ?? [];
344
+
345
+ if (activeTools.length > 0) {
346
+ const tool = visibleTool(activeTools);
347
+ const toolDuration = formatDuration(Math.max(0, now - tool.startedAt));
348
+ const toolName = shorten(tool.name, 30) || "tool";
349
+ const args = tool.args ? `: ${shorten(tool.args, 60)}` : "";
350
+ const others = activeTools.length > 1 ? ` +${activeTools.length - 1} more` : "";
351
+ const noToolEvents = Math.max(0, Math.floor((now - tool.lastUpdateAt) / 1000));
352
+ const warning = noToolEvents >= IDLE_THRESHOLD_S ? ` · ⚠ no tool events ${noToolEvents}s` : "";
353
+ return ` · ${elapsed}s · running ${toolName}${args} (${toolDuration})${others}${warning}`;
354
+ }
355
+
356
+ if (silence >= IDLE_THRESHOLD_S) {
357
+ const last = agent.activity ? ` · last: ${shorten(agent.activity, 64)}` : "";
358
+ return ` · ${elapsed}s · ⚠ no events ${silence}s${last}`;
359
+ }
360
+ const activity = agent.activity ? ` · ${shorten(agent.activity, 64)}` : "";
361
+ return ` · ${elapsed}s${activity}`;
233
362
  }
234
363
  if (agent.durationMs != null && agent.durationMs > 0) return ` · ${formatDuration(agent.durationMs)}`;
235
364
  return "";
236
365
  }
237
366
 
367
+ function visibleTool(tools: WorkflowActiveToolSnapshot[]): WorkflowActiveToolSnapshot {
368
+ return tools.reduce((stalest, tool) => tool.lastUpdateAt < stalest.lastUpdateAt ? tool : stalest);
369
+ }
370
+
238
371
  function formatDuration(ms: number): string {
239
- const s = ms / 1000;
240
- if (s < 1) return "<1s";
241
- if (s < 60) return `${Math.round(s)}s`;
242
- const m = Math.floor(s / 60);
243
- const rem = Math.round(s % 60);
244
- return `${m}m${rem ? ` ${rem}s` : ""}`;
372
+ const totalSeconds = Math.round(ms / 1000);
373
+ if (totalSeconds < 1) return "<1s";
374
+ if (totalSeconds < 60) return `${totalSeconds}s`;
375
+ const minutes = Math.floor(totalSeconds / 60);
376
+ const seconds = totalSeconds % 60;
377
+ return `${minutes}m${seconds ? ` ${seconds}s` : ""}`;
245
378
  }
Binary file
@@ -4,31 +4,114 @@
4
4
  * by the workflow tool as a run progresses.
5
5
  */
6
6
 
7
- import type { WorkflowSnapshot } from "./display.ts";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { recompute, type WorkflowSnapshot } from "./display.ts";
10
+ import { WorkflowRunDetails } from "./run-details.ts";
8
11
 
9
12
  export interface RunHandle {
10
13
  snapshot: WorkflowSnapshot;
11
14
  abort: () => void;
12
15
  startedAt: number;
16
+ details?: WorkflowRunDetails;
13
17
  }
14
18
 
15
19
  export class WorkflowRegistry {
16
20
  private readonly runs = new Map<string, RunHandle>();
21
+ private readonly listeners = new Set<() => void>();
17
22
  private order: string[] = [];
23
+ private scopeDir?: string;
18
24
 
19
- register(runId: string, snapshot: WorkflowSnapshot, abort: () => void): RunHandle {
20
- const handle: RunHandle = { snapshot, abort, startedAt: Date.now() };
25
+ register(
26
+ runId: string,
27
+ snapshot: WorkflowSnapshot,
28
+ abort: () => void,
29
+ details?: WorkflowRunDetails,
30
+ ): RunHandle {
31
+ const handle: RunHandle = { snapshot, abort, startedAt: Date.now(), details };
21
32
  this.runs.set(runId, handle);
22
33
  this.order = this.order.filter((id) => id !== runId);
23
34
  this.order.push(runId);
24
- // Keep at most the 50 most recent runs in memory.
25
- while (this.order.length > 50) {
26
- const evict = this.order.shift();
27
- if (evict) this.runs.delete(evict);
28
- }
35
+ // Keep at most the 50 most recent runs in memory without evicting active work.
36
+ this.trimRuns();
37
+ this.notify();
29
38
  return handle;
30
39
  }
31
40
 
41
+ subscribe(listener: () => void): () => void {
42
+ this.listeners.add(listener);
43
+ return () => this.listeners.delete(listener);
44
+ }
45
+
46
+ notify(): void {
47
+ for (const listener of this.listeners) {
48
+ try {
49
+ listener();
50
+ } catch {
51
+ // UI observers must not affect workflow execution.
52
+ }
53
+ }
54
+ }
55
+
56
+ /** Restore the newest task-detail manifests for the current Pi session. */
57
+ restoreRuns(runsDir: string): number {
58
+ this.setScope(runsDir);
59
+ let files: string[];
60
+ try {
61
+ files = fs.readdirSync(runsDir)
62
+ .filter((name) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.details\.json$/.test(name))
63
+ .map((name) => path.join(runsDir, name))
64
+ .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs)
65
+ .slice(-50);
66
+ } catch {
67
+ return 0;
68
+ }
69
+ let restored = 0;
70
+ for (const file of files) {
71
+ const loaded = WorkflowRunDetails.restore(file);
72
+ if (!loaded || this.runs.has(loaded.snapshot.runId ?? loaded.details.runId)) continue;
73
+ const runId = loaded.snapshot.runId ?? loaded.details.runId;
74
+ if (loaded.snapshot.status === "running") {
75
+ loaded.snapshot.status = "aborted";
76
+ for (const agent of loaded.snapshot.agents) {
77
+ if (agent.status !== "running") continue;
78
+ agent.status = "cancelled";
79
+ agent.currentTurn = undefined;
80
+ loaded.details.finishTask(agent.id, { status: "cancelled", error: "Session ended before the task completed." });
81
+ }
82
+ loaded.snapshot = recompute(loaded.snapshot);
83
+ loaded.details.persist(loaded.snapshot);
84
+ }
85
+ let startedAt = Date.now();
86
+ try {
87
+ startedAt = fs.statSync(file).mtimeMs;
88
+ } catch {
89
+ // Keep a deterministic in-memory fallback if the manifest disappears.
90
+ }
91
+ const handle: RunHandle = {
92
+ snapshot: loaded.snapshot,
93
+ abort: () => {},
94
+ startedAt,
95
+ details: loaded.details,
96
+ };
97
+ this.runs.set(runId, handle);
98
+ this.order.push(runId);
99
+ restored++;
100
+ }
101
+ this.trimRuns();
102
+ if (restored) this.notify();
103
+ return restored;
104
+ }
105
+
106
+ setScope(runsDir: string): void {
107
+ const resolved = path.resolve(runsDir);
108
+ if (this.scopeDir === resolved) return;
109
+ this.scopeDir = resolved;
110
+ this.runs.clear();
111
+ this.order = [];
112
+ this.notify();
113
+ }
114
+
32
115
  get(runId: string): RunHandle | undefined {
33
116
  return this.runs.get(runId);
34
117
  }
@@ -36,8 +119,8 @@ export class WorkflowRegistry {
36
119
  list(): RunHandle[] {
37
120
  return this.order
38
121
  .map((id) => this.runs.get(id))
39
- .filter((h): h is RunHandle => Boolean(h))
40
- .reverse();
122
+ .filter((handle): handle is RunHandle => Boolean(handle))
123
+ .sort((left, right) => right.startedAt - left.startedAt);
41
124
  }
42
125
 
43
126
  active(): RunHandle[] {
@@ -55,6 +138,19 @@ export class WorkflowRegistry {
55
138
  }
56
139
  }
57
140
  }
141
+
142
+ private trimRuns(): void {
143
+ while (this.runs.size > 50) {
144
+ const candidate = [...this.runs.entries()].sort((left, right) => {
145
+ const leftActive = left[1].snapshot.status === "running" ? 1 : 0;
146
+ const rightActive = right[1].snapshot.status === "running" ? 1 : 0;
147
+ return leftActive - rightActive || left[1].startedAt - right[1].startedAt;
148
+ })[0];
149
+ if (!candidate) break;
150
+ this.runs.delete(candidate[0]);
151
+ this.order = this.order.filter((id) => id !== candidate[0]);
152
+ }
153
+ }
58
154
  }
59
155
 
60
156
  let singleton: WorkflowRegistry | undefined;