pi-ultracode 0.1.2 → 0.3.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.
@@ -4,7 +4,7 @@
4
4
  * structured result to the parent assistant.
5
5
  */
6
6
 
7
- import * as fs from "node:fs";
7
+ import * as crypto from "node:crypto";
8
8
  import * as os from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
@@ -18,6 +18,8 @@ import {
18
18
  import { redactCommand, safeDisplayText } from "./display-text.ts";
19
19
  import { parseWorkflowScript, normalizeScript } from "./parser.ts";
20
20
  import {
21
+ ABSOLUTE_MAX_AGENTS,
22
+ DEFAULT_MAX_AGENTS,
21
23
  MAX_WORKFLOW_LOGS,
22
24
  WORKFLOW_LOG_OMITTED_TEXT,
23
25
  runWorkflow,
@@ -25,6 +27,18 @@ import {
25
27
  import type { ModelRuntimeLike, ThinkingLevel } from "./agent-runner.ts";
26
28
  import { RunJournal, hashString, type JournalAgentRecord } from "./journal.ts";
27
29
  import { getRegistry } from "./registry.ts";
30
+ import { normalizeMaxAgents } from "./admission.ts";
31
+ import { acquireWorkflowLease, type WorkflowLease } from "./leases.ts";
32
+ import {
33
+ artifactPathExists,
34
+ assertRegularArtifactFile,
35
+ ensurePrivateArtifactDirectory,
36
+ readArtifactFile,
37
+ readContainedArtifactFile,
38
+ standaloneWorkflowRunsDir,
39
+ writeArtifactFile,
40
+ } from "./run-artifacts.ts";
41
+ import { assertWorkflowArgsLimit } from "./value-limits.ts";
28
42
  import {
29
43
  WorkflowRunDetails,
30
44
  normalizeTaskUsage,
@@ -42,7 +56,7 @@ const workflowToolSchema = Type.Object({
42
56
  script: Type.Optional(
43
57
  Type.String({
44
58
  description:
45
- "Raw JavaScript workflow script (no Markdown fences). First statement: export const meta = { name: 'snake_case', description: '...' }. Must call agent() at least once. Required unless `name` or `scriptPath` is given.",
59
+ "Inline raw JavaScript workflow script (no Markdown fences). The source must begin with export const meta = { name: 'snake_case', description: '...' }. Should call agent() at least once for useful orchestration. Required unless `name` or `scriptPath` is given.",
46
60
  }),
47
61
  ),
48
62
  scriptPath: Type.Optional(
@@ -54,22 +68,25 @@ const workflowToolSchema = Type.Object({
54
68
  args: Type.Optional(
55
69
  Type.Any({ description: "Optional JSON value exposed to the workflow script as the global `args`." }),
56
70
  ),
57
- budget: Type.Optional(
58
- Type.Number({ description: "Optional output-token ceiling for this run; agent() calls throw once exhausted." }),
59
- ),
60
71
  resumeFromRunId: Type.Optional(
61
72
  Type.String({
62
73
  description:
63
- "Resume a prior run: agent() calls with unchanged (prompt, opts) return cached results; the first changed/new call and everything after run live.",
74
+ "Resume an immutable prior run. Script and args must match exactly; successful agent calls replay by stable call path. maxAgents may only stay the same or increase.",
75
+ }),
76
+ ),
77
+ maxAgents: Type.Optional(
78
+ Type.Integer({
79
+ minimum: 1,
80
+ maximum: ABSOLUTE_MAX_AGENTS,
81
+ description:
82
+ `Lifetime live-agent admission cap for this run across resumes. Defaults to ${DEFAULT_MAX_AGENTS}; absolute range is 1..${ABSOLUTE_MAX_AGENTS}. Cache replay is free. This is not a token budget.`,
64
83
  }),
65
84
  ),
66
- });
85
+ }, { additionalProperties: false });
67
86
 
68
87
  export interface WorkflowToolDeps {
69
88
  /** Canonical runtime supplied by an SDK host; shared by all child sessions. */
70
89
  modelRuntime?: ModelRuntimeLike;
71
- /** Default token budget from ultracode mode, if any. */
72
- getDefaultBudget?: () => number | null;
73
90
  /** The ultracode effort level to forward to every workflow subagent as its
74
91
  * default thinking level (`max` when ultracode is on, so each subagent's own
75
92
  * session clamps it independently; undefined when off). A per-call
@@ -82,6 +99,8 @@ export interface WorkflowToolDeps {
82
99
  /** Test seam: override the workflow runtime (lets tests capture the options,
83
100
  * including the forwarded thinkingLevel, without spinning up real subagents). */
84
101
  runWorkflowFn?: typeof runWorkflow;
102
+ /** Internal test seam for bounded cancellation cleanup. */
103
+ cleanupTimeoutMs?: number;
85
104
  }
86
105
 
87
106
  let runCounter = 0;
@@ -92,7 +111,8 @@ const ACTIVITY_RENDER_INTERVAL_MS = 100;
92
111
 
93
112
  function nextRunId(): string {
94
113
  runCounter += 1;
95
- return `wf_${Date.now().toString(36)}-${runCounter.toString(36)}`;
114
+ const nonce = crypto.randomBytes(6).toString("hex");
115
+ return `wf_${Date.now().toString(36)}-${runCounter.toString(36)}-${nonce}`;
96
116
  }
97
117
 
98
118
  export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<typeof workflowToolSchema, any> {
@@ -107,29 +127,57 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
107
127
  if (deps.isExecutionAllowed?.() === false) {
108
128
  throw new Error("The workflow tool is disabled. Run /ultracode on before using it.");
109
129
  }
130
+ const controller = new AbortController();
131
+ let abortRequested = false;
132
+ const requestAbort = () => {
133
+ abortRequested = true;
134
+ controller.abort();
135
+ };
136
+ const onOuterAbort = () => requestAbort();
137
+ signal?.addEventListener("abort", onOuterAbort, { once: true });
138
+ try {
139
+ if (signal?.aborted || controller.signal.aborted) throw new Error("Workflow was aborted before it started");
110
140
 
111
141
  const cwd = ctx.cwd;
112
- const { script, sourceLabel } = resolveScript(params, cwd);
142
+ assertWorkflowArgsLimit(params.args);
143
+ const requestedMaxAgents = params.maxAgents === undefined ? undefined : normalizeMaxAgents(params.maxAgents);
144
+ const resolvedScript = resolveScript(params, cwd);
145
+ const script = normalizeScript(resolvedScript.script);
146
+ const sourceLabel = resolvedScript.sourceLabel;
113
147
  const parsed = parseWorkflowScript(script);
114
148
  const displayName = safeDisplayText(parsed.meta.name, 60) || "workflow";
115
149
 
116
150
  const runsDir = workflowRunsDir(ctx);
117
151
  const requestedRunId = params.resumeFromRunId?.trim();
118
152
  const runId = requestedRunId ? requireSafeRunId(requestedRunId) : nextRunId();
119
- const budgetTotal = params.budget ?? deps.getDefaultBudget?.() ?? null;
153
+ const resuming = Boolean(requestedRunId);
154
+ if (resuming && !RunJournal.exists(runsDir, runId)) {
155
+ throw new Error(`workflow: resumeFromRunId ${runId} was not found in this session`);
156
+ }
120
157
  // Forward the raw `max` request so each subagent session clamps it against
121
158
  // that subagent's model. Undefined when ultracode is off.
122
159
  const thinkingLevel = deps.getThinkingLevel?.();
160
+ if (controller.signal.aborted) throw new Error("Workflow was aborted before it started");
123
161
  const run = deps.runWorkflowFn ?? runWorkflow;
124
162
  const workflowStartedAt = Date.now();
163
+ let lease: WorkflowLease | undefined;
164
+ let journal: RunJournal | undefined;
165
+ let toolSucceeded = false;
166
+ lease = acquireWorkflowLease(runsDir, runId);
167
+ try {
125
168
 
126
- // Persist the script next to the session for resume / inspection.
169
+ // Persist new scripts next to the session. Resume artifacts are immutable:
170
+ // validate the existing file now and compare its content below.
127
171
  const scriptPath = path.join(runsDir, `${runId}.workflow.js`);
128
- try {
129
- fs.mkdirSync(runsDir, { recursive: true });
130
- fs.writeFileSync(scriptPath, script);
131
- } catch {
132
- // non-fatal
172
+ ensurePrivateArtifactDirectory(runsDir);
173
+ if (resuming) {
174
+ assertRegularArtifactFile(scriptPath, "workflow resume script");
175
+ const persistedScript = readArtifactFile(scriptPath, "workflow resume script");
176
+ if (persistedScript !== script) {
177
+ throw new Error(`workflow ${runId} has an immutable script; start a new run for changed source`);
178
+ }
179
+ } else {
180
+ writeArtifactFile(scriptPath, script, { trustedRoot: runsDir });
133
181
  }
134
182
 
135
183
  // Journal (create new, or resume an existing run id).
@@ -140,34 +188,46 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
140
188
  scriptHash: hashString(script),
141
189
  args: params.args,
142
190
  startedAt: Date.now(),
191
+ maxAgents: requestedMaxAgents,
143
192
  };
144
- const resuming = Boolean(requestedRunId) && RunJournal.exists(runsDir, runId);
145
- let journal: RunJournal | undefined;
146
- try {
147
- journal = resuming
148
- ? RunJournal.resume(runsDir, runId, journalMeta)
149
- : RunJournal.create(runsDir, journalMeta);
150
- } catch {
151
- journal = undefined;
152
- }
193
+ journal = resuming
194
+ ? RunJournal.resume(runsDir, runId, journalMeta)
195
+ : RunJournal.create(runsDir, journalMeta);
196
+ const activeJournal = journal;
197
+ const effectiveMaxAgents = activeJournal.effectiveMaxAgents;
153
198
 
154
199
  // Snapshot + registry + abort plumbing.
155
- let snapshot = createSnapshot(parsed.meta, runId, budgetTotal);
200
+ let snapshot = createSnapshot(parsed.meta, runId);
201
+ snapshot.maxAgents = effectiveMaxAgents;
202
+ snapshot.agentsUsed = activeJournal.agentsUsed;
203
+ const detailsPath = path.join(runsDir, `${runId}.details.json`);
204
+ if (resuming && artifactPathExists(detailsPath)) {
205
+ assertRegularArtifactFile(detailsPath, "workflow details manifest");
206
+ }
156
207
  const restoredDetails = resuming
157
- ? WorkflowRunDetails.restore(path.join(runsDir, `${runId}.details.json`))?.details
208
+ ? WorkflowRunDetails.restore(detailsPath)?.details
158
209
  : undefined;
159
210
  const runDetails = restoredDetails ?? new WorkflowRunDetails({
160
211
  runId,
161
212
  name: parsed.meta.name,
162
213
  runsDir,
163
214
  });
215
+ if (resuming) runDetails.beginGeneration();
164
216
  snapshot.detailsManifestPath = runDetails.manifestPath;
165
- const controller = new AbortController();
166
- const onOuterAbort = () => controller.abort();
167
- signal?.addEventListener("abort", onOuterAbort, { once: true });
217
+ if (controller.signal.aborted) throw new Error("Workflow was aborted before it started");
168
218
  const registry = getRegistry();
169
219
  registry.setScope(runsDir);
170
- const handle = registry.register(runId, snapshot, () => controller.abort(), runDetails);
220
+ const handle = registry.register(runId, snapshot, requestAbort, runDetails);
221
+ let updateObserverEnabled = true;
222
+ const emitUpdate = () => {
223
+ if (!onUpdate || !updateObserverEnabled) return;
224
+ try {
225
+ onUpdate({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
226
+ } catch (error) {
227
+ updateObserverEnabled = false;
228
+ throw error;
229
+ }
230
+ };
171
231
 
172
232
  const update = () => {
173
233
  const totals = representedUsage(snapshot);
@@ -177,7 +237,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
177
237
  snapshot = recompute(snapshot);
178
238
  handle.snapshot = snapshot;
179
239
  registry.notify();
180
- onUpdate?.({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
240
+ emitUpdate();
181
241
  };
182
242
 
183
243
  // Throttle state for activity-driven re-renders. Agent fields below are
@@ -194,21 +254,37 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
194
254
  };
195
255
 
196
256
  // Heartbeat: keep elapsed/no-event markers live in the compact panel even
197
- // when a subagent emits no events.
198
- const heartbeat = setInterval(() => update(), 1000);
257
+ // when a subagent emits no events. Detached timer callbacks must never throw
258
+ // past the tool lifecycle; convert observer failures into a controlled run
259
+ // failure and let the runtime finish abort/drain cleanup before releasing the lease.
260
+ let heartbeatFailed = false;
261
+ let heartbeatError: unknown;
262
+ const heartbeat = setInterval(() => {
263
+ try {
264
+ update();
265
+ } catch (error) {
266
+ if (!heartbeatFailed) {
267
+ heartbeatFailed = true;
268
+ heartbeatError = error;
269
+ }
270
+ clearInterval(heartbeat);
271
+ controller.abort();
272
+ }
273
+ }, 1000);
199
274
 
200
275
  try {
201
276
  const result = await run(script, {
202
277
  cwd,
203
278
  args: params.args,
204
279
  signal: controller.signal,
205
- tokenBudget: budgetTotal,
206
280
  thinkingLevel,
207
281
  modelRegistry: ctx.modelRegistry as any,
208
282
  modelRuntime: deps.modelRuntime,
209
283
  model: ctx.model as any,
210
284
  runner: deps.testRunner,
211
- journal,
285
+ journal: activeJournal,
286
+ maxAgents: effectiveMaxAgents,
287
+ cleanupTimeoutMs: deps.cleanupTimeoutMs,
212
288
  onLog(message) {
213
289
  if (!acceptingEvents || snapshot.logs.length > MAX_WORKFLOW_LOGS) return;
214
290
  const safe = safeDisplayText(message, 512);
@@ -227,10 +303,12 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
227
303
  },
228
304
  onAgentStart(event) {
229
305
  if (!acceptingEvents) return;
306
+ snapshot.agentsUsed = activeJournal.agentsUsed;
230
307
  const phase = recordPhase(event.phase);
231
308
  const startedAt = Date.now();
232
309
  const summary = runDetails.startTask({
233
310
  id: event.id,
311
+ callPath: event.callPath,
234
312
  label: event.label,
235
313
  phase,
236
314
  workflowPath: event.workflowPath,
@@ -245,6 +323,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
245
323
  });
246
324
  const agent: WorkflowSnapshot["agents"][number] = {
247
325
  id: event.id,
326
+ callPath: event.callPath,
248
327
  label: safeDisplayText(event.label, 120) || `agent ${event.id}`,
249
328
  phase,
250
329
  workflowPath: event.workflowPath,
@@ -290,9 +369,6 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
290
369
  if (summary) applyTaskSummary(agent, summary);
291
370
  const now = Date.now();
292
371
  agent.lastActivityAt = now;
293
- if (event.kind !== "text_delta" && event.kind !== "thinking_start") {
294
- runDetails.persist(snapshot);
295
- }
296
372
  if (now - lastActivityRenderMs >= ACTIVITY_RENDER_INTERVAL_MS) {
297
373
  lastActivityRenderMs = now;
298
374
  update();
@@ -338,17 +414,28 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
338
414
  },
339
415
  });
340
416
 
417
+ // The runtime normally rejects after cancellation, but success must not
418
+ // win a race with a detached heartbeat failure or a user/registry abort.
419
+ if (heartbeatFailed) throw heartbeatError;
420
+ if (abortRequested || controller.signal.aborted) throw new Error("Workflow execution ended after cancellation");
421
+
341
422
  acceptingEvents = false;
342
423
  for (const agent of snapshot.agents) clearAgentTransient(agent);
343
424
  snapshot.result = result.result;
344
425
  snapshot.spentTokens = result.spentTokens;
345
- snapshot.newTokens = result.newTokens;
346
- snapshot.replayedTokens = result.replayedTokens;
426
+ snapshot.newTokens = result.newTokens ?? 0;
427
+ snapshot.replayedTokens = result.replayedTokens ?? 0;
428
+ snapshot.maxAgents = result.maxAgents ?? effectiveMaxAgents;
429
+ snapshot.agentsUsed = result.agentsUsed ?? activeJournal.agentsUsed;
347
430
  snapshot.durationMs = result.durationMs;
348
431
  snapshot.status = "completed";
349
432
  snapshot = recompute(snapshot);
350
433
  handle.snapshot = snapshot;
351
- runDetails.close(snapshot);
434
+ try {
435
+ runDetails.close(snapshot);
436
+ } catch {
437
+ // best-effort manifest close
438
+ }
352
439
  registry.notify();
353
440
  journal?.recordResult({
354
441
  ok: true,
@@ -356,29 +443,32 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
356
443
  agentCount: result.agentCount,
357
444
  durationMs: result.durationMs,
358
445
  });
359
- onUpdate?.({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
446
+ emitUpdate();
360
447
 
448
+ const agentsUsed = result.agentsUsed ?? activeJournal.agentsUsed;
361
449
  ctx.ui?.notify(
362
- `Workflow ${displayName} completed: ${result.agentCount} agent(s), ~${result.spentTokens} output tokens.`,
450
+ `Workflow ${displayName} completed: ${agentsUsed}/${result.maxAgents ?? effectiveMaxAgents} lifetime agent slot(s), ~${result.spentTokens} output tokens.`,
363
451
  "info",
364
452
  );
365
453
 
366
454
  const cachedNote = result.cachedCount ? ` (${result.cachedCount} cached from resume)` : "";
455
+ toolSucceeded = true;
367
456
  return {
368
457
  content: [
369
458
  {
370
459
  type: "text",
371
460
  text:
372
- `Workflow ${displayName} completed: ${result.agentCount} agent(s)${cachedNote}, ` +
461
+ `Workflow ${displayName} completed: ${result.agentCount} agent call(s), lifetime slots ${agentsUsed}/${result.maxAgents ?? effectiveMaxAgents}${cachedNote}, ` +
373
462
  `~${result.spentTokens} output tokens, ${Math.round(result.durationMs)}ms.\n` +
374
463
  `runId: ${runId} (script: ${scriptPath})\n\n` +
375
464
  `Result:\n${safeJson(result.result)}`,
376
465
  },
377
466
  ],
378
- details: { ...snapshot, runId, scriptPath, source: sourceLabel },
467
+ details: { ...snapshot, runId, scriptPath, source: sourceLabel, maxAgents: result.maxAgents ?? effectiveMaxAgents },
379
468
  };
380
469
  } catch (error) {
381
- const aborted = controller.signal.aborted || isAbortError(error);
470
+ const failure = heartbeatFailed ? heartbeatError : error;
471
+ const aborted = abortRequested;
382
472
  acceptingEvents = false;
383
473
  if (!controller.signal.aborted) controller.abort();
384
474
  for (const agent of snapshot.agents) {
@@ -401,30 +491,64 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
401
491
  snapshot.spentTokens = totals.outputTokens;
402
492
  snapshot = recompute(snapshot);
403
493
  handle.snapshot = snapshot;
404
- runDetails.close(snapshot);
494
+ try {
495
+ runDetails.close(snapshot);
496
+ } catch {
497
+ // best-effort manifest close
498
+ }
405
499
  registry.notify();
406
- const errorText = statusText(error);
407
- journal?.recordResult({
408
- ok: false,
409
- error: errorText,
410
- agentCount: snapshot.agentCount,
411
- durationMs: snapshot.durationMs ?? 0,
412
- });
413
- onUpdate?.({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
500
+ const errorText = statusText(failure);
501
+ try {
502
+ journal?.recordResult({
503
+ ok: false,
504
+ error: errorText,
505
+ agentCount: snapshot.agentCount,
506
+ durationMs: snapshot.durationMs ?? 0,
507
+ });
508
+ } catch {
509
+ // A poisoned journal must not append after a partial write; preserve
510
+ // the execution failure that brought us into this path.
511
+ }
512
+ emitUpdate();
414
513
  ctx.ui?.notify(
415
514
  `Workflow ${displayName} ${aborted ? "was aborted" : "failed"}${aborted ? "" : `: ${errorText}`}`,
416
515
  aborted ? "warning" : "error",
417
516
  );
418
517
  if (aborted) {
419
- throw new Error(`Workflow ${displayName} was aborted (runId: ${runId})`, { cause: error });
518
+ throw new Error(`Workflow ${displayName} was aborted (runId: ${runId})`, { cause: failure });
420
519
  }
421
- throw new Error(`Workflow ${displayName} failed: ${errorText}`, { cause: error });
520
+ throw new Error(`Workflow ${displayName} failed: ${errorText}`, { cause: failure });
422
521
  } finally {
423
522
  acceptingEvents = false;
424
- clearInterval(heartbeat);
523
+ try {
524
+ clearInterval(heartbeat);
525
+ } catch {
526
+ // best-effort cleanup
527
+ }
528
+ try {
529
+ runDetails.close(snapshot);
530
+ } catch {
531
+ // best-effort cleanup
532
+ }
533
+ }
534
+ } finally {
535
+ let journalCloseError: unknown;
536
+ try {
537
+ journal?.close();
538
+ } catch (error) {
539
+ journalCloseError = error;
540
+ }
541
+ try {
542
+ lease?.release();
543
+ } catch {
544
+ // best-effort cleanup; active-run leases must never mask the original error
545
+ }
546
+ if (toolSucceeded && journalCloseError) {
547
+ throw new Error(`workflow journal close failed: ${statusText(journalCloseError)}`, { cause: journalCloseError });
548
+ }
549
+ }
550
+ } finally {
425
551
  signal?.removeEventListener("abort", onOuterAbort);
426
- runDetails.close(snapshot);
427
- journal?.close();
428
552
  }
429
553
  },
430
554
  renderCall(_args, theme) {
@@ -434,7 +558,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
434
558
  const snapshot = result.details as WorkflowSnapshot | undefined;
435
559
  if (snapshot?.name) {
436
560
  return new Text(renderWorkflowText(snapshot, {
437
- maxAgents: expanded ? Number.MAX_SAFE_INTEGER : undefined,
561
+ maxAgentRows: expanded ? Number.MAX_SAFE_INTEGER : undefined,
438
562
  maxLogs: expanded ? 12 : undefined,
439
563
  showResultPreviews: expanded && !isPartial,
440
564
  }), 0, 0);
@@ -454,17 +578,21 @@ function resolveScript(
454
578
  }
455
579
  if (params.scriptPath) {
456
580
  const full = path.isAbsolute(params.scriptPath) ? params.scriptPath : path.join(cwd, params.scriptPath);
457
- return { script: fs.readFileSync(full, "utf8"), sourceLabel: `scriptPath:${params.scriptPath}` };
581
+ return { script: readArtifactFile(full, "workflow script", 16 * 1024 * 1024), sourceLabel: `scriptPath:${params.scriptPath}` };
458
582
  }
459
583
  if (params.name) {
460
- const dirs = [
461
- path.join(cwd, ".pi", "ultracode", "workflows"),
462
- path.join(os.homedir(), ".pi", "ultracode", "workflows"),
463
- ];
464
- for (const dir of dirs) {
584
+ requireSafeWorkflowName(params.name);
585
+ const roots = [cwd, os.homedir()];
586
+ for (const root of roots) {
587
+ const dir = path.join(root, ".pi", "ultracode", "workflows");
465
588
  for (const candidate of [`${params.name}.workflow.js`, `${params.name}.js`]) {
466
589
  const full = path.join(dir, candidate);
467
- if (fs.existsSync(full)) return { script: fs.readFileSync(full, "utf8"), sourceLabel: `name:${params.name}` };
590
+ if (artifactPathExists(full)) {
591
+ return {
592
+ script: readContainedArtifactFile(root, full, "saved workflow", 16 * 1024 * 1024),
593
+ sourceLabel: `name:${params.name}`,
594
+ };
595
+ }
468
596
  }
469
597
  }
470
598
  throw new Error(`workflow: no saved workflow named "${params.name}" found under .pi/ultracode/workflows/`);
@@ -479,7 +607,7 @@ export function workflowRunsDir(ctx: { sessionManager?: { getSessionDir?: () =>
479
607
  } catch {
480
608
  // fall through
481
609
  }
482
- return path.join(ctx.cwd, ".pi", "ultracode-runs");
610
+ return standaloneWorkflowRunsDir(ctx.cwd);
483
611
  }
484
612
 
485
613
  function safeJson(value: unknown): string {
@@ -490,8 +618,12 @@ function safeJson(value: unknown): string {
490
618
  }
491
619
  }
492
620
 
493
- function isAbortError(error: unknown): boolean {
494
- return error instanceof Error && /\babort(?:ed)?\b/i.test(error.message);
621
+
622
+ function requireSafeWorkflowName(value: string): string {
623
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value)) {
624
+ throw new Error("workflow name must contain only letters, numbers, underscore, or hyphen");
625
+ }
626
+ return value;
495
627
  }
496
628
 
497
629
  function requireSafeRunId(value: string): string {
@@ -522,6 +654,7 @@ function representedUsage(snapshot: WorkflowSnapshot): { newTokens: number; repl
522
654
  function cachedRecordSummary(record: JournalAgentRecord): Partial<WorkflowTaskSummary> {
523
655
  return {
524
656
  id: record.seq,
657
+ callPath: record.callPath,
525
658
  label: record.label,
526
659
  status: "cached",
527
660
  promptPreview: "",