pi-extensible-workflows 1.0.1 → 3.0.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 (47) hide show
  1. package/README.md +9 -2
  2. package/dist/src/agent-execution.d.ts +13 -14
  3. package/dist/src/agent-execution.js +110 -197
  4. package/dist/src/budget.d.ts +38 -0
  5. package/dist/src/budget.js +160 -0
  6. package/dist/src/cli.d.ts +9 -0
  7. package/dist/src/cli.js +536 -6
  8. package/dist/src/doctor.d.ts +4 -4
  9. package/dist/src/doctor.js +9 -29
  10. package/dist/src/execution.d.ts +17 -0
  11. package/dist/src/execution.js +630 -0
  12. package/dist/src/herdr.d.ts +12 -0
  13. package/dist/src/herdr.js +74 -0
  14. package/dist/src/host.d.ts +62 -0
  15. package/dist/src/host.js +2696 -0
  16. package/dist/src/index.d.ts +11 -557
  17. package/dist/src/index.js +8 -3974
  18. package/dist/src/persistence.d.ts +32 -19
  19. package/dist/src/persistence.js +310 -70
  20. package/dist/src/registry.d.ts +31 -0
  21. package/dist/src/registry.js +169 -0
  22. package/dist/src/session-inspector.d.ts +1 -0
  23. package/dist/src/session-inspector.js +4 -1
  24. package/dist/src/types.d.ts +565 -0
  25. package/dist/src/types.js +27 -0
  26. package/dist/src/utils.d.ts +26 -0
  27. package/dist/src/utils.js +128 -0
  28. package/dist/src/validation.d.ts +24 -0
  29. package/dist/src/validation.js +740 -0
  30. package/dist/src/workflow-evals.js +2 -1
  31. package/package.json +4 -3
  32. package/skills/pi-extensible-workflows/SKILL.md +52 -67
  33. package/src/agent-execution.ts +84 -147
  34. package/src/budget.ts +75 -0
  35. package/src/cli.ts +427 -6
  36. package/src/doctor.ts +13 -32
  37. package/src/execution.ts +540 -0
  38. package/src/herdr.ts +73 -0
  39. package/src/host.ts +2265 -0
  40. package/src/index.ts +11 -3453
  41. package/src/persistence.ts +255 -47
  42. package/src/registry.ts +157 -0
  43. package/src/session-inspector.ts +5 -1
  44. package/src/types.ts +113 -0
  45. package/src/utils.ts +108 -0
  46. package/src/validation.ts +616 -0
  47. package/src/workflow-evals.ts +2 -1
@@ -5,20 +5,13 @@ import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFi
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { promisify } from "node:util";
8
- import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./index.js";
8
+ import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./types.js";
9
9
  import type { OwnershipRecord } from "./agent-execution.js";
10
- import { loadLaunchSnapshot, WorkflowError } from "./index.js";
10
+ import { WorkflowError } from "./types.js";
11
+ import { loadLaunchSnapshot } from "./utils.js";
11
12
 
12
13
  export interface NativeSessionReference { sessionId: string; sessionFile: string }
13
14
  export interface EffectiveSystemPrompt { sessionId: string; attempt: number; turn: number; sha256: string; prompt: string }
14
- export interface ConversationHead { turn: number; sessionId: string; sessionFile: string; leafId: string; systemPrompt: string; systemPromptSha256: string; toolDefinitionsSha256: string }
15
- export interface PersistedConversation { id: string; policy: JsonValue; head: ConversationHead }
16
- type ConversationArtifact = { version: 1; conversations: Record<string, PersistedConversation> };
17
- function isConversationArtifact(value: unknown): value is ConversationArtifact {
18
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
19
- const artifact = value as { version?: unknown; conversations?: unknown };
20
- return artifact.version === 1 && Boolean(artifact.conversations) && typeof artifact.conversations === "object" && !Array.isArray(artifact.conversations);
21
- }
22
15
  export interface PersistedRun extends RunRecord { nativeSessions: readonly NativeSessionReference[] }
23
16
  export interface CompletedOperation { path: string; value: JsonValue }
24
17
  export interface AwaitingCheckpoint { path: string; name: string; prompt: string; context: JsonValue }
@@ -26,6 +19,7 @@ export type PendingWorkflowDecision = BudgetApprovalRequest
26
19
  export type PersistedOwnershipNode = OwnershipRecord
27
20
  type Journal = { completed: Record<string, CompletedOperation>; awaiting?: Record<string, AwaitingCheckpoint>; decisions?: Record<string, PendingWorkflowDecision> };
28
21
  export interface WorktreeReference { owner: string; path: string; branch: string; cwd: string; base: string }
22
+ export interface BorrowedWorktreeBinding { name: string; sourceRunId: string; owner: string }
29
23
 
30
24
  const execute = promisify(execFile);
31
25
  const gitIdentity = {
@@ -191,12 +185,12 @@ export class RunStore {
191
185
  // ponytail: serializes one RunStore instance; cross-process run sharing remains unsupported.
192
186
  private stateWrite: Promise<void> = Promise.resolve();
193
187
  private worktreeWrite: Promise<void> = Promise.resolve();
188
+ private borrowedWorktreeWrite: Promise<void> = Promise.resolve();
194
189
  private snapshotWrite: Promise<void> = Promise.resolve();
195
190
  private launchSnapshotWrite: Promise<void> = Promise.resolve();
196
191
  // ponytail: the session lease prevents concurrent RunStore writers for one run.
197
192
  private systemPromptWrite: Promise<void> = Promise.resolve();
198
- private conversationWrite: Promise<void> = Promise.resolve();
199
- constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, home = homedir()) {
193
+ constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, readonly home = homedir()) {
200
194
  this.cwd = resolve(cwd);
201
195
  this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
202
196
  }
@@ -212,9 +206,9 @@ export class RunStore {
212
206
  await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {}, decisions: {} });
213
207
  await atomicJson(join(temporary, "ownership.json"), []);
214
208
  await atomicJson(join(temporary, "worktrees.json"), []);
209
+ await atomicJson(join(temporary, "borrowed-worktrees.json"), []);
215
210
  await atomicJson(join(temporary, "state.json"), run);
216
211
  await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
217
- await atomicJson(join(temporary, "conversations.json"), { version: 1, conversations: {} });
218
212
  await rename(temporary, this.directory);
219
213
  } catch (error) {
220
214
  await rm(temporary, { recursive: true, force: true });
@@ -293,27 +287,6 @@ export class RunStore {
293
287
  return (await json<{ version: 1; entries: EffectiveSystemPrompt[] }>(this.systemPromptPath()).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1 as const, entries: [] }; throw error; })).entries;
294
288
  }
295
289
 
296
- conversationPath(): string { return join(this.directory, "conversations.json"); }
297
- async conversation(id: string): Promise<PersistedConversation | undefined> {
298
- await this.conversationWrite;
299
- let artifact: ConversationArtifact;
300
- try { const raw = await json<unknown>(this.conversationPath()); if (!isConversationArtifact(raw)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt"); artifact = raw; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; if (error instanceof WorkflowError) throw error; throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
301
- return artifact.conversations[id];
302
- }
303
- async saveConversation(conversation: PersistedConversation): Promise<void> {
304
- const write = this.conversationWrite.then(async () => {
305
- const path = this.conversationPath();
306
- let artifact: ConversationArtifact;
307
- try { const raw = await json<unknown>(path); if (!isConversationArtifact(raw)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt"); artifact = raw; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") artifact = { version: 1, conversations: {} }; else if (error instanceof WorkflowError) throw error; else throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
308
- const previous = artifact.conversations[conversation.id];
309
- if (previous && previous.head.turn + 1 !== conversation.head.turn) throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation head is not the previous turn: ${conversation.id}`);
310
- if (!previous && conversation.head.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation must start at turn one: ${conversation.id}`);
311
- artifact.conversations[conversation.id] = structuredClone(conversation);
312
- await atomicJson(path, artifact);
313
- });
314
- this.conversationWrite = write.catch(() => undefined);
315
- await write;
316
- }
317
290
  private async updateJournal<T>(update: (journal: Journal) => T | Promise<T>): Promise<T> {
318
291
  let result!: T;
319
292
  const write = this.journalWrite.then(async () => {
@@ -336,11 +309,33 @@ export class RunStore {
336
309
  }
337
310
 
338
311
  async replay(path: string): Promise<CompletedOperation | undefined> {
312
+ const operations = await this.replayableOperations();
313
+ return operations.find((operation) => operation.path === path);
314
+ }
315
+
316
+ async replayableOperations(): Promise<readonly CompletedOperation[]> {
317
+ return this.replayableOperationsFrom(new Set());
318
+ }
319
+
320
+ private async replayableOperationsFrom(seen: Set<string>): Promise<readonly CompletedOperation[]> {
321
+ if (seen.has(this.runId)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
322
+ const nextSeen = new Set(seen);
323
+ nextSeen.add(this.runId);
339
324
  await this.journalWrite;
340
- return (await json<Journal>(join(this.directory, "journal.json"))).completed[path];
325
+ const loaded = await this.load();
326
+ const operations = new Map<string, CompletedOperation>();
327
+ if (loaded.run.retry?.sourceRunId) {
328
+ const source = await this.sourceRun(loaded.run.retry.sourceRunId);
329
+ for (const operation of await source.replayableOperationsFrom(nextSeen)) operations.set(operation.path, operation);
330
+ }
331
+ const journal = await json<Journal>(join(this.directory, "journal.json"));
332
+ for (const operation of Object.values(journal.completed)) operations.set(operation.path, operation);
333
+ return [...operations.values()].map((operation) => structuredClone(operation));
341
334
  }
342
335
 
343
336
  async awaitCheckpoint(checkpoint: AwaitingCheckpoint): Promise<boolean | undefined> {
337
+ const replayed = await this.replay(checkpoint.path);
338
+ if (replayed) return replayed.value as boolean;
344
339
  return this.updateJournal((journal) => {
345
340
  const completed = journal.completed[checkpoint.path];
346
341
  if (completed) return completed.value as boolean;
@@ -392,6 +387,24 @@ export class RunStore {
392
387
  return join(this.directory, `worktree-${key}.creating`);
393
388
  }
394
389
 
390
+ private namedWorktreeOwner(name: string): string {
391
+ if (!name.trim()) throw new WorkflowError("WORKTREE_FAILED", "Named worktree names must be non-empty");
392
+ return structuralPath("worktree", "named", name.trim());
393
+ }
394
+
395
+ private worktreeName(owner: string): string | undefined {
396
+ const prefix = `${structuralPath("worktree", "named")}/`;
397
+ if (!owner.startsWith(prefix)) return undefined;
398
+ const encoded = owner.slice(prefix.length);
399
+ if (!encoded || encoded.includes("/")) return undefined;
400
+ try {
401
+ const name = decodeURIComponent(encoded);
402
+ return name.trim() ? name : undefined;
403
+ } catch {
404
+ return undefined;
405
+ }
406
+ }
407
+
395
408
  private structuralWorktree(owner: string, record: unknown): WorktreeReference {
396
409
  if (!record || typeof record !== "object") throw new Error(`Invalid worktree record for ${owner}`);
397
410
  const candidate = record as Partial<WorktreeReference>;
@@ -402,6 +415,146 @@ export class RunStore {
402
415
  return candidate as WorktreeReference;
403
416
  }
404
417
 
418
+ private async borrowedWorktreeRecords(wait = true): Promise<readonly BorrowedWorktreeBinding[]> {
419
+ if (wait) await this.borrowedWorktreeWrite;
420
+ const records = await json<unknown[]>(join(this.directory, "borrowed-worktrees.json")).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
421
+ if (!Array.isArray(records)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings are invalid");
422
+ const seen = new Set<string>();
423
+ return records.map((record) => {
424
+ if (!record || typeof record !== "object") throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
425
+ const candidate = record as Partial<BorrowedWorktreeBinding>;
426
+ if (typeof candidate.name !== "string" || !candidate.name.trim() || candidate.name !== candidate.name.trim() || typeof candidate.sourceRunId !== "string" || !candidate.sourceRunId || typeof candidate.owner !== "string" || candidate.owner !== this.namedWorktreeOwner(candidate.name)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
427
+ if (seen.has(candidate.name)) throw new WorkflowError("WORKTREE_FAILED", `Duplicate borrowed worktree binding for ${candidate.name}`);
428
+ seen.add(candidate.name);
429
+ return { name: candidate.name, sourceRunId: candidate.sourceRunId, owner: candidate.owner };
430
+ });
431
+ }
432
+
433
+ async borrowedWorktrees(): Promise<readonly BorrowedWorktreeBinding[]> { return this.borrowedWorktreeRecords(); }
434
+
435
+ private async borrowedWorktree(name: string): Promise<BorrowedWorktreeBinding | undefined> {
436
+ return (await this.borrowedWorktreeRecords()).find((binding) => binding.name === name);
437
+ }
438
+
439
+ private async sourceRun(sourceRunId: string): Promise<RunStore> {
440
+ if (!sourceRunId || sourceRunId === this.runId) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree source run is invalid");
441
+ const source = new RunStore(this.cwd, this.sessionId, sourceRunId, this.home);
442
+ try {
443
+ const loaded = await source.load();
444
+ if (!["completed", "failed", "stopped"].includes(loaded.run.state)) throw new Error(`Source run ${sourceRunId} is not terminal`);
445
+ return source;
446
+ } catch (error) {
447
+ if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED") throw error;
448
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
449
+ }
450
+ }
451
+
452
+ async validateParentRun(parentRunId: string): Promise<void> { await this.sourceRun(parentRunId); }
453
+ async validateRetrySource(): Promise<void> {
454
+ const validate = async (current: RunStore, seen: Set<string>): Promise<void> => {
455
+ if (seen.has(current.runId)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
456
+ const nextSeen = new Set(seen);
457
+ nextSeen.add(current.runId);
458
+ const loaded = await current.load();
459
+ const retry = loaded.run.retry;
460
+ if (!retry) return;
461
+ if (typeof retry.sourceRunId !== "string" || !retry.sourceRunId || retry.sourceRunId === current.runId || typeof retry.lineageRootRunId !== "string" || !retry.lineageRootRunId || !Array.isArray(retry.completedPaths) || retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(retry.incompletePaths) || retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(retry.namedWorktrees) || retry.namedWorktrees.some((name) => typeof name !== "string")) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance is incomplete");
462
+ const source = await current.sourceRun(retry.sourceRunId);
463
+ const sourceRun = (await source.load()).run;
464
+ if (loaded.run.parentRunId !== retry.sourceRunId) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry parent run does not match its source run");
465
+ if (sourceRun.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", `Retry source run ${retry.sourceRunId} is not failed`);
466
+ const expectedLineageRoot = sourceRun.retry?.lineageRootRunId ?? sourceRun.id;
467
+ if (retry.lineageRootRunId !== expectedLineageRoot) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry lineage root does not match its source run");
468
+ await validate(source, nextSeen);
469
+ };
470
+ try { await validate(this, new Set()); }
471
+ catch (error) { throw error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE" ? error : new WorkflowError("RESUME_INCOMPATIBLE", error instanceof Error ? error.message : String(error)); }
472
+ }
473
+
474
+ private async ownedWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
475
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
476
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
477
+ if (matches.length !== 1) throw new Error(`Missing or duplicate worktree record for ${owner}`);
478
+ const record = this.structuralWorktree(owner, matches[0]);
479
+ if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
480
+ await access(record.cwd);
481
+ return record;
482
+ }
483
+
484
+ private async resolveBorrowedWorktree(binding: BorrowedWorktreeBinding, seen: Set<string>): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string }> {
485
+ try {
486
+ const source = await this.sourceRun(binding.sourceRunId);
487
+ const resolved = await source.findNamedWorktree(binding.name, seen);
488
+ if (!resolved) throw new Error(`Missing named worktree ${binding.name} in source run ${binding.sourceRunId}`);
489
+ if (resolved.owner !== binding.owner) throw new Error(`Borrowed worktree binding does not match source owner for ${binding.name}`);
490
+ return resolved;
491
+ } catch (error) {
492
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
493
+ }
494
+ }
495
+
496
+ private async findNamedWorktree(name: string, seen: Set<string> = new Set()): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string } | undefined> {
497
+ const owner = this.namedWorktreeOwner(name);
498
+ if (seen.has(this.runId)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings contain a cycle");
499
+ const nextSeen = new Set(seen);
500
+ nextSeen.add(this.runId);
501
+ const binding = await this.borrowedWorktree(name);
502
+ if (binding) {
503
+ const loaded = await this.load();
504
+ if (loaded.run.parentRunId === undefined) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree ${name} has no parent run`);
505
+ const parent = await this.sourceRun(loaded.run.parentRunId);
506
+ const resolved = await parent.findNamedWorktree(name, nextSeen);
507
+ if (!resolved || resolved.sourceRunId !== binding.sourceRunId || resolved.owner !== binding.owner) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${name} is not inherited from its parent run`);
508
+ return resolved;
509
+ }
510
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
511
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
512
+ if (matches.length === 0) {
513
+ const loaded = await this.load();
514
+ if (loaded.run.parentRunId === undefined) return undefined;
515
+ const parent = await this.sourceRun(loaded.run.parentRunId);
516
+ return parent.findNamedWorktree(name, nextSeen);
517
+ }
518
+ try {
519
+ const reference = await this.ownedWorktree(owner);
520
+ return { reference, sourceRunId: this.runId, owner };
521
+ } catch (error) {
522
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
523
+ }
524
+ }
525
+
526
+ async resolveNamedWorktree(name: string, seen: Set<string> = new Set()): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string }> {
527
+ const resolved = await this.findNamedWorktree(name, seen);
528
+ if (!resolved) throw new WorkflowError("WORKTREE_FAILED", `Missing named worktree ${name}`);
529
+ return resolved;
530
+ }
531
+
532
+ async validateBorrowedWorktrees(): Promise<void> {
533
+ try {
534
+ const loaded = await this.load();
535
+ if (loaded.run.parentRunId !== undefined) await this.validateParentRun(loaded.run.parentRunId);
536
+ for (const binding of await this.borrowedWorktreeRecords()) await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
537
+ } catch (error) {
538
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
539
+ }
540
+ }
541
+ async validateNamedWorktrees(): Promise<void> {
542
+ try {
543
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
544
+ for (const record of records) {
545
+ const owner = record && typeof record === "object" && typeof (record as Partial<WorktreeReference>).owner === "string" ? (record as Partial<WorktreeReference>).owner : undefined;
546
+ if (owner && this.worktreeName(owner)) await this.validateWorktree(owner);
547
+ }
548
+ } catch (error) {
549
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
550
+ }
551
+ }
552
+
553
+ async ownsWorktree(owner: string): Promise<boolean> {
554
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
555
+ return records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner).length === 1;
556
+ }
557
+
405
558
  private async cleanupMarker(markerPath: string): Promise<void> {
406
559
  let marker: Partial<{ owner: string; path: string; branch: string; base: string }>;
407
560
  try { marker = await json(markerPath); } catch { return; }
@@ -426,13 +579,14 @@ export class RunStore {
426
579
  async validateWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
427
580
  try {
428
581
  await this.load();
429
- const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
430
- const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
431
- if (matches.length !== 1) throw new Error(`Missing or duplicate worktree record for ${owner}`);
432
- const record = this.structuralWorktree(owner, matches[0]);
433
- if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
434
- await access(record.cwd);
435
- return record;
582
+ const name = this.worktreeName(owner);
583
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
584
+ if (binding) {
585
+ const resolved = await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
586
+ if (cwd !== undefined && resolve(cwd) !== resolve(resolved.reference.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
587
+ return resolved.reference;
588
+ }
589
+ return await this.ownedWorktree(owner, cwd);
436
590
  } catch (error) {
437
591
  throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
438
592
  }
@@ -440,9 +594,20 @@ export class RunStore {
440
594
 
441
595
  async worktree(owner: string): Promise<WorktreeReference> {
442
596
  const write = this.worktreeWrite.then(async () => {
443
- await this.load();
597
+ const loaded = await this.load();
444
598
  const recordsPath = join(this.directory, "worktrees.json");
445
599
  let records = await json<WorktreeReference[]>(recordsPath).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
600
+ const name = this.worktreeName(owner);
601
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
602
+ if (binding) return (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference;
603
+ if (name && loaded.run.parentRunId !== undefined) {
604
+ const resolved = await this.resolveNamedWorktreeFromParent(name, loaded.run.parentRunId);
605
+ if (resolved) {
606
+ await this.bindBorrowedWorktree({ name, sourceRunId: resolved.sourceRunId, owner: resolved.owner });
607
+ return resolved.reference;
608
+ }
609
+ }
610
+ if (name && Array.isArray(loaded.run.retry?.namedWorktrees) && loaded.run.retry.namedWorktrees.includes(name)) throw new WorkflowError("WORKTREE_FAILED", `Missing inherited named worktree ${name}`);
446
611
  const existing = records.find((record) => record.owner === owner);
447
612
  if (existing) return this.validateWorktree(owner);
448
613
  const { path, branch } = this.expectedWorktree(owner);
@@ -488,6 +653,25 @@ export class RunStore {
488
653
  return write;
489
654
  }
490
655
 
656
+ private async resolveNamedWorktreeFromParent(name: string, parentRunId: string): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string } | undefined> {
657
+ const source = await this.sourceRun(parentRunId);
658
+ return source.findNamedWorktree(name, new Set([this.runId]));
659
+ }
660
+
661
+ private async bindBorrowedWorktree(binding: BorrowedWorktreeBinding): Promise<void> {
662
+ const write = this.borrowedWorktreeWrite.then(async () => {
663
+ const records = [...await this.borrowedWorktreeRecords(false)];
664
+ const existing = records.find((candidate) => candidate.name === binding.name);
665
+ if (existing) {
666
+ if (JSON.stringify(existing) !== JSON.stringify(binding)) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${binding.name} changed`);
667
+ return;
668
+ }
669
+ records.push(binding);
670
+ await atomicJson(join(this.directory, "borrowed-worktrees.json"), records);
671
+ });
672
+ this.borrowedWorktreeWrite = write.then(() => undefined, () => undefined);
673
+ await write;
674
+ }
491
675
  async snapshotWorktree(owner: string): Promise<string> {
492
676
  try {
493
677
  const write = this.snapshotWrite.then(async () => {
@@ -510,13 +694,37 @@ export class RunStore {
510
694
  throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
511
695
  }
512
696
  }
513
-
514
697
  async worktrees(): Promise<readonly WorktreeReference[]> {
515
698
  const records = await json<WorktreeReference[]>(join(this.directory, "worktrees.json")).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
516
- const validated = await Promise.all(records.map(async (record) => { try { return await this.validateWorktree(record.owner); } catch { return undefined; } }));
517
- return validated.filter((record): record is WorktreeReference => record !== undefined);
699
+ const bindings = await this.borrowedWorktreeRecords();
700
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
701
+ const owned = await Promise.all(records.filter((record) => !boundOwners.has(record.owner)).map(async (record) => { try { return await this.validateWorktree(record.owner); } catch { return undefined; } }));
702
+ const borrowed = await Promise.all(bindings.map(async (binding) => (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference));
703
+ return [...owned.filter((record): record is WorktreeReference => record !== undefined), ...borrowed];
704
+ }
705
+ async validNamedWorktrees(): Promise<readonly string[]> {
706
+ const load = async (path: string): Promise<unknown> => {
707
+ try { return await json<unknown>(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
708
+ };
709
+ const names = new Set<string>();
710
+ const rawRecords = await load(join(this.directory, "worktrees.json"));
711
+ let bindings: readonly BorrowedWorktreeBinding[];
712
+ try { bindings = await this.borrowedWorktreeRecords(); }
713
+ catch (error) { if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED") return []; throw error; }
714
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
715
+ for (const record of Array.isArray(rawRecords) ? rawRecords : []) {
716
+ if (!record || typeof record !== "object") continue;
717
+ const owner = (record as Partial<WorktreeReference>).owner;
718
+ if (typeof owner !== "string") continue;
719
+ const name = this.worktreeName(owner);
720
+ if (!name || owner !== this.namedWorktreeOwner(name) || boundOwners.has(owner)) continue;
721
+ try { await this.ownedWorktree(owner); names.add(name); } catch { /* Do not advertise stale or invalid records. */ }
722
+ }
723
+ for (const binding of bindings) {
724
+ try { await this.resolveBorrowedWorktree(binding, new Set([this.runId])); names.add(binding.name); } catch { /* Do not advertise stale inherited records. */ }
725
+ }
726
+ return [...names];
518
727
  }
519
-
520
728
  async changedWorktrees(): Promise<readonly WorktreeReference[]> {
521
729
  const changed: WorktreeReference[] = [];
522
730
  for (const valid of await this.worktrees()) {
@@ -0,0 +1,157 @@
1
+ import { Value } from "typebox/value";
2
+ import type { JsonValue, RegisteredAgentSetupHook, WorkflowCatalog, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogVariable, WorkflowExtension, WorkflowFunction, WorkflowFunctionContext, WorkflowJournal, WorkflowVariable } from "./types.js";
3
+ import { deepFreeze, fail, jsonValue, object } from "./utils.js";
4
+ import { loadSettings, validateSchema } from "./validation.js";
5
+
6
+ const RESERVED_GLOBALS = new Set(["agent", "shell", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
7
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
8
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
9
+
10
+ export class WorkflowRegistry {
11
+ readonly #extensions = new Set<Readonly<WorkflowExtension>>();
12
+ readonly #globals = new Map<string, string>();
13
+ readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
14
+ #frozen = false;
15
+
16
+ get frozen(): boolean { return this.#frozen; }
17
+ freeze(): void { this.#frozen = true; }
18
+
19
+ register(extension: WorkflowExtension): void {
20
+ if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
21
+ if (object(extension) && Object.prototype.hasOwnProperty.call(extension, "workflows")) fail("INVALID_METADATA", "Separate registered workflow definitions were removed; register a function with input and output schemas instead");
22
+ if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "functions", "variables", "agentSetupHooks"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim()) fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
23
+ const functions = extension.functions ?? {};
24
+ const variables = extension.variables ?? {};
25
+ const agentSetupHooks = extension.agentSetupHooks ?? {};
26
+ if (!object(functions) || !object(variables) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(agentSetupHooks).length === 0)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, or agent setup hooks");
27
+ const names = [...Object.keys(functions), ...Object.keys(variables)];
28
+ if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
29
+ for (const name of names) {
30
+ if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${name}`);
31
+ if (RESERVED_GLOBALS.has(name)) fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
32
+ if (this.#globals.has(name)) fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
33
+ }
34
+ for (const [name, fn] of Object.entries(functions)) {
35
+ if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function") fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
36
+ validateSchema(fn.input, `${name} input`);
37
+ validateSchema(fn.output, `${name} output`);
38
+ if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${name} input must describe one object`);
39
+ }
40
+ for (const [name, variable] of Object.entries(variables)) {
41
+ if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
42
+ validateSchema(variable.schema, `${name} schema`);
43
+ }
44
+ for (const [name, hook] of Object.entries(agentSetupHooks)) {
45
+ if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
46
+ if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
47
+ }
48
+ const stored = deepFreeze({ ...extension, functions, variables, agentSetupHooks });
49
+ this.#extensions.add(stored);
50
+ for (const name of names) this.#globals.set(name, name);
51
+ for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
52
+ }
53
+
54
+ function(name: string): WorkflowFunction {
55
+ if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered functions require an unqualified name: ${name}`);
56
+ const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
57
+ if (!fn) fail("MISSING_WORKFLOW", `Registered function is unavailable: ${name}; the separate registered-workflow format was removed`);
58
+ return fn;
59
+ }
60
+
61
+ functions(): Readonly<Record<string, WorkflowFunction>> {
62
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.entries(extension.functions ?? {}))));
63
+ }
64
+
65
+ catalog(): WorkflowCatalog {
66
+ const functions: WorkflowCatalogFunction[] = [];
67
+ const variables: WorkflowCatalogVariable[] = [];
68
+ for (const extension of this.#extensions) {
69
+ for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
70
+ for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
71
+ }
72
+ let aliases: Readonly<Record<string, string>> | undefined;
73
+ try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
74
+ const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
75
+ return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
76
+ }
77
+
78
+ catalogIndex(): WorkflowCatalogIndex {
79
+ const catalog = this.catalog();
80
+ return deepFreeze({
81
+ functions: catalog.functions.map(({ name, description, input }) => ({ name, description, input: structuredClone(input) })),
82
+ variables: catalog.variables.map(({ name, description, schema }) => ({ name, description, schema: structuredClone(schema) })),
83
+ ...(catalog.modelAliases ? { modelAliases: structuredClone(catalog.modelAliases) } : {}),
84
+ });
85
+ }
86
+
87
+ catalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError {
88
+ const catalog = this.catalog();
89
+ const entry = catalog.functions.find((candidate) => candidate.name === name) ?? catalog.variables.find((candidate) => candidate.name === name);
90
+ if (entry) return entry;
91
+ return deepFreeze({ error: { code: "NOT_FOUND", name, message: `No registered workflow function or variable is available: ${name}` } });
92
+ }
93
+
94
+ globals(): Readonly<Record<string, { name: string }>> {
95
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
96
+ }
97
+
98
+ async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
99
+ const fn = this.function(name);
100
+ if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
101
+ const replayed = journal.get(path);
102
+ if (replayed !== undefined) {
103
+ if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
104
+ return structuredClone(replayed);
105
+ }
106
+ const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, shell: context.shell, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
107
+ if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
108
+ const stored = structuredClone(result);
109
+ journal.put(path, stored);
110
+ return structuredClone(stored);
111
+ }
112
+
113
+ variables(): readonly { name: string; variable: WorkflowVariable }[] {
114
+ return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
115
+ }
116
+ agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
117
+ return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
118
+ }
119
+ }
120
+ export type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
121
+ interface WorkflowRegistryHost { api: WorkflowRegistryApi }
122
+ const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
123
+ const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
124
+ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistryApi {
125
+ return {
126
+ get frozen() { return registry.frozen; },
127
+ freeze: () => { registry.freeze(); },
128
+ register: (extension) => { registry.register(extension); },
129
+ function: (name) => registry.function(name),
130
+ functions: () => registry.functions(),
131
+ catalog: () => registry.catalog(),
132
+ catalogIndex: () => registry.catalogIndex(),
133
+ catalogDetail: (name) => registry.catalogDetail(name),
134
+ globals: () => registry.globals(),
135
+ invokeFunction: (...args) => registry.invokeFunction(...args),
136
+ variables: () => registry.variables(),
137
+ agentSetupHooks: () => registry.agentSetupHooks(),
138
+ };
139
+ }
140
+ function workflowRegistryHost(): WorkflowRegistryHost {
141
+ return globalRegistry[WORKFLOW_REGISTRY_KEY] ??= { api: createWorkflowRegistryApi(new WorkflowRegistry()) };
142
+ }
143
+ export function resetWorkflowRegistry(): void {
144
+ workflowRegistryHost().api = createWorkflowRegistryApi(new WorkflowRegistry());
145
+ }
146
+ export function beginWorkflowExtensionLoading(): void {
147
+ if (workflowRegistryHost().api.frozen) resetWorkflowRegistry();
148
+ }
149
+ export function loadingRegistry(): WorkflowRegistryApi { return workflowRegistryHost().api; }
150
+ beginWorkflowExtensionLoading();
151
+ export function registerWorkflowExtension(extension: WorkflowExtension): void { loadingRegistry().register(extension); }
152
+ export function workflowCatalog(): WorkflowCatalog { return loadingRegistry().catalog(); }
153
+ export function workflowCatalogIndex(): WorkflowCatalogIndex { return loadingRegistry().catalogIndex(); }
154
+ export function workflowCatalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError { return loadingRegistry().catalogDetail(name); }
155
+ export function registeredWorkflowFunctions(): Readonly<Record<string, WorkflowFunction>> { return loadingRegistry().functions(); }
156
+
157
+ export type { WorkflowCatalog, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogIndexFunction, WorkflowCatalogIndexVariable, WorkflowCatalogVariable } from "./types.js";
@@ -53,6 +53,10 @@ export function transcriptLines(entries: readonly SessionEntry[]): string[] {
53
53
  });
54
54
  }
55
55
 
56
+ export function transcriptFileLines(path: string): string[] {
57
+ return transcriptLines(SessionManager.open(path).buildContextEntries());
58
+ }
59
+
56
60
  function mergedModels(groups: readonly (readonly ModelUsage[])[]): ModelUsage[] {
57
61
  const totals = new Map<string, number>();
58
62
  for (const group of groups) for (const item of group) totals.set(item.model, (totals.get(item.model) ?? 0) + item.cost);
@@ -211,7 +215,7 @@ export async function loadSessionReport(path: string, home = homedir()): Promise
211
215
  const args = call.arguments;
212
216
  const agents = loaded ? await Promise.all(loaded.run.agents.map(agentReport)) : [];
213
217
  const models = mergedModels(agents.flatMap(({ attempts }) => attempts.map(({ models: attemptModels }) => attemptModels)));
214
- const name = typeof args.name === "string" ? args.name : typeof args.workflow === "string" ? args.workflow : loaded?.run.workflowName ?? "workflow";
218
+ const name = loaded?.run.workflowName ?? (typeof args.workflow === "string" ? args.workflow : typeof args.name === "string" ? args.name : "workflow");
215
219
  const description = typeof args.description === "string" ? args.description : loaded?.snapshot.metadata.description;
216
220
  const script = typeof args.script === "string" && args.script.trim() ? args.script : loaded?.snapshot.script;
217
221
  let staticCalls: StaticWorkflowCall[] = [];