pi-ultracode 0.1.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.
@@ -0,0 +1,651 @@
1
+ /**
2
+ * Deterministic workflow runtime.
3
+ *
4
+ * Parses a workflow script and runs its body inside a Node vm sandbox with the
5
+ * orchestration globals: agent(), parallel(), pipeline(), phase(), log(),
6
+ * workflow(), plus `args`, `cwd`, and `budget`. The sandbox omits Date / require
7
+ * / fs / network from the named global surface and neuters Math.random() (it
8
+ * throws) as a guardrail against accidental nondeterminism; this is cooperative
9
+ * enforcement, not a hard isolation boundary (see createDeterministicMath).
10
+ */
11
+
12
+ import vm from "node:vm";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { parseWorkflowScript, type WorkflowMeta } from "./parser.ts";
17
+ // Static import: a dynamic import() of this module misbehaves under Pi's jiti
18
+ // loader ("WorkflowAgentRunner is not a constructor"). Tests inject a runner, so
19
+ // they never construct this class; production builds it via getRunner().
20
+ import { WorkflowAgentRunner } from "./agent-runner.ts";
21
+ import type { AgentActivityInput, AgentRunResult, ModelLike, ModelRegistryLike, ThinkingLevel } from "./agent-runner.ts";
22
+
23
+ /**
24
+ * A frozen copy of `Math` with `random` replaced by a throwing function. Workflow
25
+ * scripts get the useful Math surface (max/min/floor/round/PI/E/...) but the
26
+ * NAMED Math.random() throws, guardrailing against accidental nondeterminism.
27
+ *
28
+ * NOTE: this is COOPERATIVE enforcement, not a hard isolation boundary. Node's
29
+ * `vm` is not a sandbox: a determined script can still reach the real Math.random
30
+ * (and Date.now/performance/crypto) via any host-realm function's `.constructor`
31
+ * (the host Function constructor), e.g. `Object.constructor("return Math.random()")()`.
32
+ * Closing that requires not passing host-realm intrinsics into the context (or
33
+ * using isolated-vm); the parser's AST check + this shim are defense-in-depth
34
+ * against ACCIDENTAL nondeterminism, matching the workflow guidelines' "vary
35
+ * randomness by agent index" framing.
36
+ */
37
+ export function createDeterministicMath(): Record<string, unknown> {
38
+ const m = {} as Record<string | symbol, unknown>;
39
+ for (const key of Object.getOwnPropertyNames(Math)) {
40
+ if (key === "random") continue;
41
+ m[key] = (Math as unknown as Record<string | symbol, unknown>)[key];
42
+ }
43
+ // Preserve symbol-keyed members (e.g. Symbol.toStringTag = 'Math') for parity.
44
+ for (const sym of Object.getOwnPropertySymbols(Math)) {
45
+ m[sym] = (Math as unknown as Record<string | symbol, unknown>)[sym];
46
+ }
47
+ m.random = function random() {
48
+ throw new Error(
49
+ "Math.random() is non-deterministic and forbidden in workflow scripts; vary randomness by agent index instead (see the workflow guidelines).",
50
+ );
51
+ };
52
+ return Object.freeze(m) as unknown as Record<string, unknown>;
53
+ }
54
+
55
+ const DETERMINISTIC_MATH = createDeterministicMath();
56
+ import { discoverAgentTypes, resolveAgentType, type AgentTypeDef } from "./agent-types.ts";
57
+ import { agentCallKey, RunJournal } from "./journal.ts";
58
+ import {
59
+ applyPatch,
60
+ captureWorktreeDiff,
61
+ createWorktree,
62
+ hasChanges,
63
+ isGitRepo,
64
+ removeWorktree,
65
+ writeRescuePatch,
66
+ type Worktree,
67
+ type WorktreeDiff,
68
+ } from "./worktree.ts";
69
+
70
+ const MAX_CONCURRENCY = 16;
71
+ const MAX_AGENTS_PER_RUN = 1000;
72
+ const MAX_ITEMS_PER_CALL = 4096;
73
+
74
+ export interface AgentEventBase {
75
+ id: number;
76
+ label: string;
77
+ phase?: string;
78
+ }
79
+
80
+ /** Live activity observed inside a running subagent (text deltas / tool calls). */
81
+ export interface AgentActivityEvent extends AgentEventBase {
82
+ kind: "text" | "thinking" | "tool";
83
+ detail?: string;
84
+ }
85
+
86
+ export interface WorkflowRunOptions {
87
+ cwd?: string;
88
+ args?: unknown;
89
+ signal?: AbortSignal;
90
+ concurrency?: number;
91
+ tokenBudget?: number | null;
92
+ modelRegistry?: ModelRegistryLike;
93
+ model?: ModelLike;
94
+ thinkingLevel?: ThinkingLevel;
95
+ /** Inject a runner (tests). */
96
+ runner?: { run: WorkflowAgentRunner["run"] };
97
+ journal?: RunJournal;
98
+ /** Loads a saved workflow body by name; defaults to disk discovery. */
99
+ loadSavedWorkflow?: (nameOrRef: string | { scriptPath: string }) => { meta: WorkflowMeta; body: string };
100
+ onLog?: (message: string) => void;
101
+ onPhase?: (title: string) => void;
102
+ onAgentStart?: (event: AgentEventBase & { prompt: string; cached: boolean }) => void;
103
+ onAgentEnd?: (event: AgentEventBase & { result: unknown; status: "done" | "error" }) => void;
104
+ onAgentActivity?: (event: AgentActivityEvent) => void;
105
+ }
106
+
107
+ export interface WorkflowRunResult<T = unknown> {
108
+ meta: WorkflowMeta;
109
+ result: T;
110
+ logs: string[];
111
+ phases: string[];
112
+ agentCount: number;
113
+ cachedCount: number;
114
+ spentTokens: number;
115
+ durationMs: number;
116
+ }
117
+
118
+ interface RuntimeState {
119
+ currentPhase?: string;
120
+ logs: string[];
121
+ phases: string[];
122
+ agentCount: number; // number of agent() invocations (for ids / cap)
123
+ cachedCount: number;
124
+ spent: number; // real output tokens
125
+ }
126
+
127
+ export async function runWorkflow<T = unknown>(
128
+ rawScript: string,
129
+ options: WorkflowRunOptions = {},
130
+ ): Promise<WorkflowRunResult<T>> {
131
+ const started = Date.now();
132
+ const { meta, body } = parseWorkflowScript(rawScript);
133
+ const runtime = new Runtime(options);
134
+ const result = await runtime.runBody(body, options.args, 0, meta.name);
135
+ await runtime.drain();
136
+ // structuredClone both validates serialisability and lifts the value out of the
137
+ // vm realm so callers get plain host-realm objects.
138
+ const cloned = cloneResult(result, "workflow result");
139
+ return {
140
+ meta,
141
+ result: cloned as T,
142
+ logs: runtime.state.logs,
143
+ phases: runtime.state.phases,
144
+ agentCount: runtime.state.agentCount,
145
+ cachedCount: runtime.state.cachedCount,
146
+ spentTokens: runtime.state.spent,
147
+ durationMs: Date.now() - started,
148
+ };
149
+ }
150
+
151
+ class Runtime {
152
+ readonly state: RuntimeState = { logs: [], phases: [], agentCount: 0, cachedCount: 0, spent: 0 };
153
+ private readonly options: WorkflowRunOptions;
154
+ private readonly cwd: string;
155
+ private runnerInstance: { run: WorkflowAgentRunner["run"] } | undefined;
156
+ private readonly agentTypes: Map<string, AgentTypeDef>;
157
+ private readonly limiter: <R>(fn: () => Promise<R>) => Promise<R>;
158
+ private readonly pending = new Set<Promise<unknown>>();
159
+ private readonly tokenBudget: number | null;
160
+ private readonly applyLock = new Mutex();
161
+ private depth = 0;
162
+
163
+ constructor(options: WorkflowRunOptions) {
164
+ this.options = options;
165
+ this.cwd = options.cwd ?? process.cwd();
166
+ this.runnerInstance = options.runner;
167
+ this.agentTypes = discoverAgentTypes(this.cwd);
168
+ this.tokenBudget = options.tokenBudget ?? null;
169
+ const cores = (globalThis as any).navigator?.hardwareConcurrency ?? os.cpus().length ?? 8;
170
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? Math.max(1, cores - 2), MAX_CONCURRENCY));
171
+ this.limiter = createLimiter(concurrency);
172
+ }
173
+
174
+ get budget() {
175
+ return Object.freeze({
176
+ total: this.tokenBudget,
177
+ spent: () => this.state.spent,
178
+ remaining: () =>
179
+ this.tokenBudget == null ? Infinity : Math.max(0, this.tokenBudget - this.state.spent),
180
+ });
181
+ }
182
+
183
+ async drain(): Promise<void> {
184
+ await Promise.allSettled([...this.pending]);
185
+ }
186
+
187
+ /** Lazily construct the default in-memory runner (skipped when a runner is injected). */
188
+ private getRunner(): { run: WorkflowAgentRunner["run"] } {
189
+ if (!this.runnerInstance) {
190
+ this.runnerInstance = new WorkflowAgentRunner({
191
+ cwd: this.cwd,
192
+ modelRegistry: this.options.modelRegistry,
193
+ model: this.options.model,
194
+ thinkingLevel: this.options.thinkingLevel,
195
+ });
196
+ }
197
+ return this.runnerInstance;
198
+ }
199
+
200
+ /** Execute one workflow body (top-level or nested) with shared runtime state. */
201
+ async runBody(body: string, args: unknown, depth: number, name: string): Promise<unknown> {
202
+ const context = vm.createContext(this.buildSandbox(args));
203
+ const wrapped = `(async () => {\n${body}\n})()`;
204
+ return new vm.Script(wrapped, { filename: `${name || "workflow"}.js` }).runInContext(context);
205
+ }
206
+
207
+ private buildSandbox(args: unknown): Record<string, unknown> {
208
+ const log = (message: unknown) => {
209
+ const text = String(message);
210
+ this.state.logs.push(text);
211
+ this.options.onLog?.(text);
212
+ };
213
+ return {
214
+ agent: this.agent.bind(this),
215
+ parallel: this.parallel.bind(this),
216
+ pipeline: this.pipeline.bind(this),
217
+ phase: this.phase.bind(this),
218
+ log,
219
+ workflow: this.workflow.bind(this),
220
+ args,
221
+ cwd: this.cwd,
222
+ process: Object.freeze({ cwd: () => this.cwd }),
223
+ budget: this.budget,
224
+ console: {
225
+ log,
226
+ info: log,
227
+ warn: (m: unknown) => log(`[warn] ${String(m)}`),
228
+ error: (m: unknown) => log(`[error] ${String(m)}`),
229
+ },
230
+ JSON,
231
+ Math: DETERMINISTIC_MATH,
232
+ Array,
233
+ Object,
234
+ String,
235
+ Number,
236
+ Boolean,
237
+ Set,
238
+ Map,
239
+ Promise,
240
+ structuredClone,
241
+ };
242
+ }
243
+
244
+ private throwIfAborted(): void {
245
+ if (this.options.signal?.aborted) throw new Error("workflow aborted");
246
+ }
247
+
248
+ private phase(title: unknown): void {
249
+ const text = requireString(title, "phase title");
250
+ this.state.currentPhase = text;
251
+ if (!this.state.phases.includes(text)) this.state.phases.push(text);
252
+ this.options.onPhase?.(text);
253
+ }
254
+
255
+ private async agent(promptValue: unknown, optionsValue: unknown = {}): Promise<unknown> {
256
+ this.throwIfAborted();
257
+ if (this.tokenBudget != null && this.budget.remaining() <= 0) {
258
+ throw new Error("workflow token budget exhausted");
259
+ }
260
+ const prompt = requireString(promptValue, "agent prompt");
261
+ const opts = normalizeAgentOptions(optionsValue);
262
+ const assignedPhase = opts.phase ?? this.state.currentPhase;
263
+
264
+ const seq = ++this.state.agentCount;
265
+ if (seq > MAX_AGENTS_PER_RUN) {
266
+ throw new Error(`workflow exceeded the ${MAX_AGENTS_PER_RUN}-agent cap (runaway loop?)`);
267
+ }
268
+ const id = seq;
269
+ const label = opts.label?.trim() || defaultLabel(assignedPhase, id);
270
+ const key = agentCallKey(prompt, { ...opts, phase: assignedPhase });
271
+
272
+ // Resume: cached prefix replay.
273
+ const cached = this.options.journal?.lookup(seq, key);
274
+ if (cached) {
275
+ this.state.cachedCount++;
276
+ this.state.spent += cached.outputTokens ?? 0;
277
+ this.options.onAgentStart?.({ id, label, phase: assignedPhase, prompt, cached: true });
278
+ this.options.onAgentEnd?.({ id, label, phase: assignedPhase, result: cached.value, status: "done" });
279
+ return cached.value;
280
+ }
281
+
282
+ const run = this.limiter(async () => {
283
+ this.options.onAgentStart?.({ id, label, phase: assignedPhase, prompt, cached: false });
284
+ let worktree: Worktree | undefined;
285
+ let keepWorktree = false;
286
+ try {
287
+ this.throwIfAborted();
288
+ const agentTypeDef = resolveAgentType(opts.agentType, this.agentTypes);
289
+
290
+ if (opts.isolation === "worktree") {
291
+ worktree = this.tryCreateWorktree(id);
292
+ }
293
+
294
+ const runner = this.getRunner();
295
+ const onActivity: ((e: AgentActivityInput) => void) | undefined = this.options.onAgentActivity
296
+ ? (e: AgentActivityInput) =>
297
+ this.options.onAgentActivity!({ id, label, phase: assignedPhase, ...e })
298
+ : undefined;
299
+ const agentStartedAt = Date.now();
300
+ const result: AgentRunResult = await runner.run({
301
+ prompt,
302
+ label,
303
+ schema: opts.schema,
304
+ signal: this.options.signal,
305
+ instructions: buildInstructions(assignedPhase, opts),
306
+ modelPattern: opts.model,
307
+ agentTypeDef,
308
+ cwd: worktree?.agentCwd,
309
+ onActivity,
310
+ });
311
+ this.throwIfAborted();
312
+
313
+ if (worktree) keepWorktree = await this.integrateWorktree(worktree, id, label);
314
+
315
+ this.state.spent += result.usage.outputTokens;
316
+ this.options.journal?.recordAgent({
317
+ seq,
318
+ key,
319
+ label,
320
+ value: result.value,
321
+ outputTokens: result.usage.outputTokens,
322
+ startedAt: agentStartedAt,
323
+ durationMs: Date.now() - agentStartedAt,
324
+ });
325
+ this.options.onAgentEnd?.({ id, label, phase: assignedPhase, result: result.value, status: "done" });
326
+ return result.value;
327
+ } catch (error) {
328
+ if (this.options.signal?.aborted) throw error;
329
+ const message = error instanceof Error ? error.message : String(error);
330
+ this.logLine(`agent ${label} failed: ${message}`);
331
+ this.options.onAgentEnd?.({ id, label, phase: assignedPhase, result: null, status: "error" });
332
+ return null;
333
+ } finally {
334
+ if (worktree && !keepWorktree) {
335
+ try {
336
+ removeWorktree(worktree);
337
+ } catch {
338
+ // ignore cleanup failures
339
+ }
340
+ }
341
+ }
342
+ });
343
+ this.track(run);
344
+ return run;
345
+ }
346
+
347
+ private async parallel(thunks: unknown): Promise<unknown[]> {
348
+ this.throwIfAborted();
349
+ if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
350
+ if (thunks.length > MAX_ITEMS_PER_CALL) {
351
+ throw new Error(`parallel() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${thunks.length})`);
352
+ }
353
+ if (thunks.some((thunk) => typeof thunk !== "function")) {
354
+ throw new TypeError(
355
+ "parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)",
356
+ );
357
+ }
358
+ return Promise.all(
359
+ (thunks as Array<() => Promise<unknown>>).map(async (thunk, index) => {
360
+ try {
361
+ return await thunk();
362
+ } catch (error) {
363
+ if (this.options.signal?.aborted) throw error;
364
+ this.logLine(`parallel[${index}] failed: ${errorMessage(error)}`);
365
+ return null;
366
+ }
367
+ }),
368
+ );
369
+ }
370
+
371
+ private async pipeline(items: unknown, ...stages: unknown[]): Promise<unknown[]> {
372
+ this.throwIfAborted();
373
+ if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
374
+ if (items.length > MAX_ITEMS_PER_CALL) {
375
+ throw new Error(`pipeline() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${items.length})`);
376
+ }
377
+ if (stages.some((stage) => typeof stage !== "function")) {
378
+ throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
379
+ }
380
+ const fns = stages as Array<(prev: unknown, original: unknown, index: number) => unknown>;
381
+ return Promise.all(
382
+ items.map(async (item, index) => {
383
+ let value: unknown = item;
384
+ for (const stage of fns) {
385
+ try {
386
+ this.throwIfAborted();
387
+ value = await stage(value, item, index);
388
+ this.throwIfAborted();
389
+ } catch (error) {
390
+ if (this.options.signal?.aborted) throw error;
391
+ this.logLine(`pipeline[${index}] failed: ${errorMessage(error)}`);
392
+ return null;
393
+ }
394
+ }
395
+ return value;
396
+ }),
397
+ );
398
+ }
399
+
400
+ private async workflow(nameOrRef: unknown, args: unknown): Promise<unknown> {
401
+ this.throwIfAborted();
402
+ if (this.depth >= 1) {
403
+ throw new Error("workflow() nesting is one level deep only; cannot call workflow() inside a child workflow");
404
+ }
405
+ const ref = normalizeWorkflowRef(nameOrRef);
406
+ const loader = this.options.loadSavedWorkflow ?? ((r) => loadSavedWorkflowFromDisk(r, this.cwd));
407
+ const { meta, body } = loader(ref);
408
+ this.depth++;
409
+ try {
410
+ this.options.onLog?.(`▸ nested workflow: ${meta.name}`);
411
+ const value = await this.runBody(body, args, this.depth, meta.name);
412
+ return value;
413
+ } finally {
414
+ this.depth--;
415
+ }
416
+ }
417
+
418
+ private tryCreateWorktree(index: number): Worktree | undefined {
419
+ if (!isGitRepo(this.cwd)) {
420
+ this.logLine(`agent #${index}: isolation:'worktree' ignored — not a git repository`);
421
+ return undefined;
422
+ }
423
+ try {
424
+ const runId = this.options.journal ? path.basename(this.options.journal.filePath, ".jsonl") : "run";
425
+ return createWorktree(this.cwd, runId, index);
426
+ } catch (error) {
427
+ this.logLine(`agent #${index}: worktree setup failed (${errorMessage(error)}); running in shared cwd`);
428
+ return undefined;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Fold a worktree's changes back into the shared working tree. Returns true
434
+ * when the worktree must be KEPT (its changes are not safely preserved
435
+ * elsewhere — e.g. apply conflicted AND the rescue write failed). Never throws:
436
+ * a writeback failure must not discard the agent's already-completed result
437
+ * or its token spend.
438
+ */
439
+ private async integrateWorktree(worktree: Worktree, id: number, label: string): Promise<boolean> {
440
+ // Outer safety net: a writeback/integration failure (including a host
441
+ // onUpdate callback throwing during a log line) must never discard the
442
+ // agent's completed work. Fail-safe toward KEEPING the worktree.
443
+ try {
444
+ let diff: WorktreeDiff;
445
+ try {
446
+ diff = captureWorktreeDiff(worktree);
447
+ } catch (error) {
448
+ this.logLine(
449
+ `worktree[${label}]: diff capture failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
450
+ );
451
+ return true;
452
+ }
453
+ if (!hasChanges(diff)) {
454
+ this.logLine(`worktree[${label}]: no changes (auto-removed)`);
455
+ return false;
456
+ }
457
+ // Apply patches back to the shared tree sequentially to avoid corruption.
458
+ let keep = false;
459
+ await this.applyLock.run(async () => {
460
+ const applied = applyPatch(this.cwd, diff.patch);
461
+ if (applied) {
462
+ this.logLine(
463
+ `worktree[${label}]: ${diff.filesChanged} file(s), +${diff.insertions}/-${diff.deletions} applied to working tree`,
464
+ );
465
+ return;
466
+ }
467
+ // 3-way conflict: `applyPatch` already reverted the shared tree to its
468
+ // pre-apply state. Persist the patch so the agent's work is recoverable
469
+ // before the worktree is removed.
470
+ const runId = this.options.journal
471
+ ? path.basename(this.options.journal.filePath, ".jsonl")
472
+ : "run";
473
+ const rescueDir = this.rescueDir();
474
+ try {
475
+ const rescue = writeRescuePatch(rescueDir, runId, id, label, diff.patch);
476
+ this.logLine(
477
+ `worktree[${label}]: ${diff.filesChanged} file(s), +${diff.insertions}/-${diff.deletions} could NOT be auto-applied (3-way conflict); patch saved to ${rescue} — review and apply with: git apply --3way ${rescue}`,
478
+ );
479
+ } catch (error) {
480
+ // Rescue write failed (disk full / permission / bad path). Keep the
481
+ // worktree so the user can recover the changes manually.
482
+ keep = true;
483
+ this.logLine(
484
+ `worktree[${label}]: ${diff.filesChanged} file(s) could NOT be auto-applied (3-way conflict) AND rescue write failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
485
+ );
486
+ }
487
+ });
488
+ return keep;
489
+ } catch (error) {
490
+ try {
491
+ this.logLine(
492
+ `worktree[${label}]: integration failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
493
+ );
494
+ } catch {
495
+ // best-effort logging
496
+ }
497
+ return true;
498
+ }
499
+ }
500
+
501
+ /** Where to write rescue patches: the session runs dir (co-located with the
502
+ * journal, never inside the repo working tree), or .pi/ultracode/patches. */
503
+ private rescueDir(): string {
504
+ const journalDir = this.options.journal ? path.dirname(this.options.journal.filePath) : undefined;
505
+ if (journalDir) return path.join(journalDir, "patches");
506
+ return path.join(this.cwd, ".pi", "ultracode", "patches");
507
+ }
508
+
509
+ private track(promise: Promise<unknown>): void {
510
+ this.pending.add(promise);
511
+ promise.then(
512
+ () => this.pending.delete(promise),
513
+ () => this.pending.delete(promise),
514
+ );
515
+ }
516
+
517
+ private logLine(text: string): void {
518
+ this.state.logs.push(text);
519
+ this.options.onLog?.(text);
520
+ }
521
+ }
522
+
523
+ export interface AgentOptions {
524
+ label?: string;
525
+ phase?: string;
526
+ schema?: unknown;
527
+ model?: string;
528
+ isolation?: "worktree";
529
+ agentType?: string;
530
+ }
531
+
532
+ function normalizeAgentOptions(value: unknown): AgentOptions {
533
+ if (value == null) return {};
534
+ if (typeof value !== "object") throw new TypeError("agent options must be an object");
535
+ const options = value as AgentOptions;
536
+ return {
537
+ label: optionalString(options.label, "agent label"),
538
+ phase: optionalString(options.phase, "agent phase"),
539
+ schema: options.schema,
540
+ model: optionalString(options.model, "agent model"),
541
+ isolation: options.isolation === "worktree" ? "worktree" : undefined,
542
+ agentType: optionalString(options.agentType, "agent type"),
543
+ };
544
+ }
545
+
546
+ function normalizeWorkflowRef(value: unknown): string | { scriptPath: string } {
547
+ if (typeof value === "string") return value;
548
+ if (value && typeof value === "object" && typeof (value as any).scriptPath === "string") {
549
+ return { scriptPath: (value as any).scriptPath };
550
+ }
551
+ throw new TypeError("workflow() expects a workflow name string or { scriptPath }");
552
+ }
553
+
554
+ export function loadSavedWorkflowFromDisk(
555
+ ref: string | { scriptPath: string },
556
+ cwd: string,
557
+ ): { meta: WorkflowMeta; body: string } {
558
+ let scriptPath: string | undefined;
559
+ if (typeof ref === "object") {
560
+ scriptPath = path.isAbsolute(ref.scriptPath) ? ref.scriptPath : path.join(cwd, ref.scriptPath);
561
+ } else {
562
+ scriptPath = resolveSavedWorkflowPath(ref, cwd);
563
+ }
564
+ if (!scriptPath || !fs.existsSync(scriptPath)) {
565
+ throw new Error(`workflow() could not find a saved workflow for ${JSON.stringify(ref)}`);
566
+ }
567
+ return parseWorkflowScript(fs.readFileSync(scriptPath, "utf8"));
568
+ }
569
+
570
+ function resolveSavedWorkflowPath(name: string, cwd: string): string | undefined {
571
+ const dirs = [
572
+ path.join(cwd, ".pi", "ultracode", "workflows"),
573
+ path.join(os.homedir(), ".pi", "ultracode", "workflows"),
574
+ ];
575
+ const candidates = [`${name}.workflow.js`, `${name}.js`, name];
576
+ for (const dir of dirs) {
577
+ for (const candidate of candidates) {
578
+ const full = path.join(dir, candidate);
579
+ if (fs.existsSync(full)) return full;
580
+ }
581
+ }
582
+ return undefined;
583
+ }
584
+
585
+ function buildInstructions(phase: string | undefined, opts: AgentOptions): string | undefined {
586
+ const lines: string[] = [];
587
+ if (phase) lines.push(`Workflow phase: ${phase}`);
588
+ if (opts.isolation === "worktree") {
589
+ lines.push("You are running in an isolated git worktree; edit files freely without coordinating with siblings.");
590
+ }
591
+ return lines.length ? lines.join("\n") : undefined;
592
+ }
593
+
594
+ function defaultLabel(phase: string | undefined, index: number): string {
595
+ return phase ? `${phase} agent ${index}` : `agent ${index}`;
596
+ }
597
+
598
+ function createLimiter(limit: number): <T>(fn: () => Promise<T>) => Promise<T> {
599
+ let active = 0;
600
+ const queue: Array<() => void> = [];
601
+ const next = () => {
602
+ active--;
603
+ queue.shift()?.();
604
+ };
605
+ return async <T>(fn: () => Promise<T>): Promise<T> => {
606
+ if (active >= limit) await new Promise<void>((resolve) => queue.push(resolve));
607
+ active++;
608
+ try {
609
+ return await fn();
610
+ } finally {
611
+ next();
612
+ }
613
+ };
614
+ }
615
+
616
+ class Mutex {
617
+ private tail: Promise<unknown> = Promise.resolve();
618
+ run<T>(fn: () => Promise<T>): Promise<T> {
619
+ const result = this.tail.then(fn, fn);
620
+ this.tail = result.then(
621
+ () => undefined,
622
+ () => undefined,
623
+ );
624
+ return result;
625
+ }
626
+ }
627
+
628
+ function requireString(value: unknown, name: string): string {
629
+ if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
630
+ return value;
631
+ }
632
+
633
+ function optionalString(value: unknown, name: string): string | undefined {
634
+ if (value === undefined || value === null) return undefined;
635
+ return requireString(value, name);
636
+ }
637
+
638
+ function errorMessage(error: unknown): string {
639
+ return error instanceof Error ? error.message : String(error);
640
+ }
641
+
642
+ function cloneResult<T>(value: T, name: string): T {
643
+ try {
644
+ return structuredClone(value);
645
+ } catch (error) {
646
+ const detail = error instanceof Error ? ` ${error.message}` : "";
647
+ throw new Error(
648
+ `${name} must be structured-cloneable; did you forget to await agent(), parallel(), or pipeline()?${detail}`,
649
+ );
650
+ }
651
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A terminating structured-output tool. When a subagent is given a schema, this
3
+ * tool is the only way for it to "return" — Pi validates the arguments against the
4
+ * schema before execute() runs, and `terminate: true` lets the subagent finish on
5
+ * this call without paying for an extra assistant turn.
6
+ */
7
+
8
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
9
+ import type { Static, TSchema } from "typebox";
10
+
11
+ export interface StructuredOutputCapture<T = unknown> {
12
+ value: T | undefined;
13
+ called: boolean;
14
+ }
15
+
16
+ export interface StructuredOutputToolOptions<TSchemaDef extends TSchema> {
17
+ schema: TSchemaDef;
18
+ capture: StructuredOutputCapture<Static<TSchemaDef>>;
19
+ name?: string;
20
+ }
21
+
22
+ export function createStructuredOutputTool<TSchemaDef extends TSchema>({
23
+ schema,
24
+ capture,
25
+ name = "structured_output",
26
+ }: StructuredOutputToolOptions<TSchemaDef>): ToolDefinition<TSchemaDef, Static<TSchemaDef>> {
27
+ return defineTool({
28
+ name,
29
+ label: "Structured Output",
30
+ description: "Return the final machine-readable result for this subagent task.",
31
+ promptSnippet: "Return final machine-readable output",
32
+ promptGuidelines: [
33
+ `${name} is the final answer channel for this task; call ${name} exactly once when done.`,
34
+ `Do not write a prose final answer after calling ${name}.`,
35
+ `If you need to inspect files or run commands first, do so, then call ${name} exactly once.`,
36
+ ],
37
+ parameters: schema,
38
+ async execute(_toolCallId, params) {
39
+ capture.value = params;
40
+ capture.called = true;
41
+ return {
42
+ content: [{ type: "text", text: "Structured output received." }],
43
+ details: params,
44
+ terminate: true,
45
+ };
46
+ },
47
+ });
48
+ }