pi-extensible-workflows 1.0.0 → 2.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.
package/README.md CHANGED
@@ -20,7 +20,7 @@ For source installs and local development, see the [installation guide](https://
20
20
 
21
21
  ## Capabilities
22
22
 
23
- The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts, or register complete workflows that can be invoked by name.
23
+ The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts; every registered function is also directly runnable as a top-level workflow.
24
24
 
25
25
  A workflow can fan out across specialized agents, combine their results, and resume without rerunning completed work.
26
26
 
@@ -61,9 +61,14 @@ Global workflow settings live at `~/.pi/agent/pi-extensible-workflows/settings.j
61
61
  ```sh
62
62
  npx pi-extensible-workflows doctor
63
63
  npx pi-extensible-workflows inspect [session-id]
64
+ npx pi-extensible-workflows transcript <session-file>
65
+ npx pi-extensible-workflows run <workflow-name> [workflow arguments]
66
+ npx pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]
64
67
  ```
65
68
 
66
- `doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs.
69
+ `doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs. `transcript` renders a session transcript to stdout. `run` derives flat CLI arguments and help from a registered function's input schema. Use `--input '<json>'` for nested or otherwise complex inputs. It executes in the current working directory, writes the final JSON result to stdout, and writes progress and errors to stderr. `export` creates an executable POSIX launcher in `~/.local/bin` by default.
70
+ `run` and `export` accept the trust overrides `--approve` and `--no-approve`; the generated launcher forwards its arguments to `run`. `--` ends launcher option parsing, and later tokens are passed to workflow input instead of being interpreted as launcher options.
71
+ Launch snapshots use identity version 4. A cold resume intentionally rejects persisted v3 runs with `RESUME_INCOMPATIBLE`; relaunch the workflow instead.
67
72
 
68
73
  ## Development
69
74
 
@@ -31,6 +31,15 @@ export interface AgentDefinition {
31
31
  tools?: readonly string[];
32
32
  disabledAgentResources?: AgentResourceExclusions;
33
33
  }
34
+ export interface AgentProviderFailure {
35
+ label: string;
36
+ provider: string;
37
+ model: string;
38
+ error: string;
39
+ }
40
+ export type AgentProviderRecovery = "retry" | "abort" | {
41
+ model: string;
42
+ };
34
43
  export interface AgentExecutionOptions {
35
44
  label: string;
36
45
  workflowName: string;
@@ -40,6 +49,8 @@ export interface AgentExecutionOptions {
40
49
  thinking?: ThinkingLevel;
41
50
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
42
51
  onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
52
+ providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
53
+ modelOverride?: ModelSpec;
43
54
  tools?: readonly string[];
44
55
  effectiveTools?: readonly string[];
45
56
  role?: string;
@@ -179,6 +190,7 @@ export interface SessionInput {
179
190
  sessionFile: string;
180
191
  leafId: string;
181
192
  };
193
+ allowModelChange?: boolean;
182
194
  }
183
195
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
184
196
  export declare function createNativeAgentSession(input: SessionInput): Promise<NativeSession>;
@@ -278,6 +290,7 @@ export declare class FairAgentScheduler {
278
290
  retry(id: string): void;
279
291
  attemptStarted(id: string): void;
280
292
  cancelRun(runId: string): Promise<void>;
293
+ removeRun(runId: string): void;
281
294
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[];
282
295
  snapshot(): readonly OwnershipRecord[];
283
296
  restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void;
@@ -25,10 +25,23 @@ function latestAssistantHasToolCall(messages) {
25
25
  const message = [...messages].reverse().find((item) => item.role === "assistant");
26
26
  return hasToolCall(message);
27
27
  }
28
- function throwIfTerminalAssistantError(session) {
28
+ function throwIfTerminalAssistantError(session, fallbackModel) {
29
29
  const message = [...session.messages].reverse().find((item) => item.role === "assistant");
30
- if (message?.stopReason === "error")
31
- throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
30
+ if (message?.stopReason !== "error")
31
+ return;
32
+ const provider = session.model?.provider ?? fallbackModel.provider;
33
+ const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
34
+ const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
35
+ const failure = new WorkflowError("AGENT_FAILED", error);
36
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
37
+ throw failure;
38
+ }
39
+ function terminalProviderError(error) {
40
+ const value = error.terminalProviderError;
41
+ if (!value || typeof value !== "object")
42
+ return undefined;
43
+ const candidate = value;
44
+ return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
32
45
  }
33
46
  function accounting(stats) {
34
47
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
@@ -50,8 +63,11 @@ export async function createNativeAgentSession(input) {
50
63
  throw new Error("Persisted transcript identity does not match the conversation head");
51
64
  manager.branch(input.continuation.leafId);
52
65
  const context = manager.buildSessionContext();
53
- if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model))
54
- throw new Error("Persisted transcript model does not match the conversation execution policy");
66
+ if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
67
+ if (!input.allowModelChange)
68
+ throw new Error("Persisted transcript model does not match the conversation execution policy");
69
+ manager.appendModelChange(input.model.provider, input.model.model);
70
+ }
55
71
  if (input.model.thinking && context.thinkingLevel !== input.model.thinking)
56
72
  throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
57
73
  }
@@ -120,6 +136,43 @@ export async function createNativeAgentSession(input) {
120
136
  }
121
137
  function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
122
138
  function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
139
+ function jsonValue(value, seen = new Set()) {
140
+ if (value === null || typeof value === "boolean" || typeof value === "string")
141
+ return true;
142
+ if (typeof value === "number")
143
+ return Number.isFinite(value);
144
+ if (typeof value !== "object" || seen.has(value))
145
+ return false;
146
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
147
+ return false;
148
+ const keys = Reflect.ownKeys(value);
149
+ if (keys.some((key) => typeof key !== "string"))
150
+ return false;
151
+ seen.add(value);
152
+ const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key])).every((item) => jsonValue(item, seen));
153
+ seen.delete(value);
154
+ return valid;
155
+ }
156
+ function jsonObject(value) { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
157
+ function isChildAgentToolParams(value) {
158
+ if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string")
159
+ return false;
160
+ if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string")))
161
+ return false;
162
+ if (value.model !== undefined && typeof value.model !== "string")
163
+ return false;
164
+ if (value.thinking !== undefined && !validThinking(value.thinking))
165
+ return false;
166
+ if (value.role !== undefined && typeof value.role !== "string")
167
+ return false;
168
+ if (value.outputSchema !== undefined && !jsonObject(value.outputSchema))
169
+ return false;
170
+ if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0))
171
+ return false;
172
+ if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1)))
173
+ return false;
174
+ return true;
175
+ }
123
176
  function fallbackSetupContext(root, options, signal) {
124
177
  const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
125
178
  const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
@@ -156,6 +209,26 @@ function conversationExecutionPolicy(options, setup) {
156
209
  resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
157
210
  });
158
211
  }
212
+ function conversationPolicyMatches(expected, current, allowModelChange) {
213
+ if (fingerprint(expected) === fingerprint(current))
214
+ return true;
215
+ if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current))
216
+ return false;
217
+ const expectedModel = conversationPolicyModel(expected);
218
+ const currentModel = conversationPolicyModel(current);
219
+ if (!expectedModel || !currentModel)
220
+ return false;
221
+ return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
222
+ }
223
+ function conversationPolicyModel(policy) {
224
+ if (!policy || typeof policy !== "object" || Array.isArray(policy))
225
+ return undefined;
226
+ const model = policy.model;
227
+ if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string")
228
+ return undefined;
229
+ const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking : undefined;
230
+ return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
231
+ }
159
232
  function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
160
233
  async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool, continuation) {
161
234
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
@@ -224,7 +297,7 @@ export class WorkflowAgentExecutor {
224
297
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
225
298
  }
226
299
  const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
227
- const model = parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
300
+ const model = options.modelOverride ?? parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
228
301
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
229
302
  if (!availableModels.has(modelCapability(model)))
230
303
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
@@ -236,7 +309,8 @@ export class WorkflowAgentExecutor {
236
309
  throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
237
310
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0))
238
311
  throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
239
- const resolved = this.resolve(options);
312
+ let resolved = this.resolve(options);
313
+ let recoveryModel;
240
314
  let cwd;
241
315
  if (options.parent) {
242
316
  if (!options.cwd)
@@ -276,6 +350,11 @@ export class WorkflowAgentExecutor {
276
350
  catch (error) {
277
351
  throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
278
352
  }
353
+ if (conversationRecord) {
354
+ const model = conversationPolicyModel(conversationRecord.policy);
355
+ if (model)
356
+ resolved = this.resolve({ ...options, modelOverride: model });
357
+ }
279
358
  if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
280
359
  throw conversationFailure("Conversation turn must be a positive integer");
281
360
  if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
@@ -283,10 +362,11 @@ export class WorkflowAgentExecutor {
283
362
  }
284
363
  const attempts = [];
285
364
  let conversationBaseline;
286
- const maxAttempts = (options.retries ?? 0) + 1;
365
+ let maxAttempts = (options.retries ?? 0) + 1;
287
366
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
367
+ if (recoveryModel)
368
+ resolved = this.resolve({ ...options, modelOverride: recoveryModel });
288
369
  options.budget?.beforeAttempt();
289
- let accepted = false;
290
370
  let schemaResult;
291
371
  let session;
292
372
  let setup;
@@ -302,10 +382,10 @@ export class WorkflowAgentExecutor {
302
382
  const resultTool = options.schema ? {
303
383
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
304
384
  async execute(_id, value) {
305
- if (!accepted)
306
- return { content: [{ type: "text", text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
307
385
  if (!Value.Check(options.schema, value))
308
386
  return { content: [{ type: "text", text: "Result does not match the required schema." }], details: {}, isError: true };
387
+ if (schemaResult !== undefined)
388
+ return { content: [{ type: "text", text: "Result has already been accepted." }], details: {}, isError: true };
309
389
  schemaResult = structuredClone(value);
310
390
  void session?.abort?.();
311
391
  return { content: [{ type: "text", text: "Result accepted." }], details: {} };
@@ -337,6 +417,8 @@ export class WorkflowAgentExecutor {
337
417
  setupFailed = false;
338
418
  if (executionSignal?.aborted)
339
419
  throw new WorkflowError("CANCELLED", "Agent cancelled");
420
+ if (recoveryModel && conversationRecord)
421
+ setup.sessionInput.allowModelChange = true;
340
422
  const started = Date.now();
341
423
  session = await setup.createSession(setup.sessionInput);
342
424
  if (setup.sessionInput.resourcePolicy)
@@ -348,9 +430,9 @@ export class WorkflowAgentExecutor {
348
430
  if (conversationRecord) {
349
431
  if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
350
432
  throw conversationFailure("Conversation transcript identity changed");
351
- if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId)
433
+ if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId))
352
434
  throw conversationFailure("Conversation transcript leaf identity changed");
353
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy))
435
+ if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel)))
354
436
  throw conversationFailure("Conversation execution policy changed");
355
437
  if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
356
438
  throw conversationFailure("Conversation system prompt changed");
@@ -358,7 +440,7 @@ export class WorkflowAgentExecutor {
358
440
  throw conversationFailure("Conversation tool definitions changed");
359
441
  }
360
442
  else if (conversationBaseline) {
361
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy))
443
+ if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel)))
362
444
  throw conversationFailure("Conversation execution policy changed");
363
445
  if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
364
446
  throw conversationFailure("Conversation tool definitions changed");
@@ -417,7 +499,7 @@ export class WorkflowAgentExecutor {
417
499
  activity = undefined;
418
500
  if (event.message.role === "assistant") {
419
501
  const needsMoreWork = hasToolCall(event.message);
420
- const final = !needsMoreWork || (options.schema !== undefined && accepted);
502
+ const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
421
503
  if (!budgetError) {
422
504
  try {
423
505
  options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final);
@@ -459,34 +541,41 @@ export class WorkflowAgentExecutor {
459
541
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
460
542
  options.budget?.beforeTurn();
461
543
  turnStarted = true;
462
- await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
544
+ try {
545
+ await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
546
+ }
547
+ catch (error) {
548
+ if (!hasSchemaResult())
549
+ throw error;
550
+ }
463
551
  if (conversationMismatch)
464
552
  throw conversationMismatch;
465
- throwIfTerminalAssistantError(session);
553
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
466
554
  {
467
555
  const completedAccounting = accounting(session.getSessionStats());
468
- options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages));
556
+ options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages));
469
557
  turnStarted = false;
470
558
  }
471
559
  if (budgetError)
472
560
  throw budgetError;
473
561
  if (options.schema) {
474
- accepted = true;
475
- try {
476
- options.budget?.beforeTurn();
477
- turnStarted = true;
478
- await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
479
- {
480
- const completedAccounting = accounting(session.getSessionStats());
481
- options.budget?.afterTurn(completedAccounting, true);
482
- turnStarted = false;
562
+ if (!hasSchemaResult()) {
563
+ try {
564
+ options.budget?.beforeTurn();
565
+ turnStarted = true;
566
+ await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
567
+ {
568
+ const completedAccounting = accounting(session.getSessionStats());
569
+ options.budget?.afterTurn(completedAccounting, true);
570
+ turnStarted = false;
571
+ }
572
+ }
573
+ catch (error) {
574
+ if (!hasSchemaResult())
575
+ throw error;
483
576
  }
484
577
  }
485
- catch (error) {
486
- if (!hasSchemaResult())
487
- throw error;
488
- }
489
- throwIfTerminalAssistantError(session);
578
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
490
579
  if (!hasSchemaResult()) {
491
580
  try {
492
581
  options.budget?.beforeTurn();
@@ -502,7 +591,7 @@ export class WorkflowAgentExecutor {
502
591
  if (!hasSchemaResult())
503
592
  throw error;
504
593
  }
505
- throwIfTerminalAssistantError(session);
594
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
506
595
  }
507
596
  if (schemaResult === undefined)
508
597
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
@@ -557,6 +646,30 @@ export class WorkflowAgentExecutor {
557
646
  }
558
647
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
559
648
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
649
+ const terminal = terminalProviderError(typed);
650
+ if (terminal && options.providerErrorRecovery) {
651
+ let recovery;
652
+ try {
653
+ recovery = await options.providerErrorRecovery({ label: options.label, ...terminal });
654
+ }
655
+ catch {
656
+ throw Object.assign(typed, { attempts });
657
+ }
658
+ if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
659
+ if (typeof recovery === "object") {
660
+ try {
661
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
662
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
663
+ }
664
+ catch {
665
+ throw Object.assign(typed, { attempts });
666
+ }
667
+ }
668
+ maxAttempts += 1;
669
+ beforeRetry?.();
670
+ continue;
671
+ }
672
+ }
560
673
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE")
561
674
  throw Object.assign(typed, { attempts });
562
675
  beforeRetry?.();
@@ -686,7 +799,26 @@ export class FairAgentScheduler {
686
799
  if (nodes.every(({ restored }) => restored))
687
800
  run.logical = 0;
688
801
  }
689
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
802
+ removeRun(runId) {
803
+ const run = this.#runs.get(runId);
804
+ if (!run)
805
+ return;
806
+ const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
807
+ if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state)))
808
+ throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
809
+ for (const { id } of nodes)
810
+ this.#nodes.delete(id);
811
+ this.#runs.delete(runId);
812
+ const index = this.#runOrder.indexOf(runId);
813
+ if (index >= 0) {
814
+ this.#runOrder.splice(index, 1);
815
+ if (index < this.#cursor)
816
+ this.#cursor -= 1;
817
+ if (this.#cursor >= this.#runOrder.length)
818
+ this.#cursor = 0;
819
+ }
820
+ this.#dispatch();
821
+ }
690
822
  toolsFor(parentId, resolveTools) {
691
823
  const parent = this.#node(parentId);
692
824
  if (!parent.options.tools.includes("agent"))
@@ -694,11 +826,15 @@ export class FairAgentScheduler {
694
826
  const agentTool = {
695
827
  name: "agent", label: "Child Agent", description: "Start a direct child agent",
696
828
  parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
697
- execute: async (_id, params) => {
829
+ execute: async (_id, rawParams) => {
830
+ if (!isChildAgentToolParams(rawParams))
831
+ throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
832
+ const params = rawParams;
698
833
  if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined))
699
834
  throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
700
835
  const tools = (params.tools !== undefined || params.role !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.thinking) : undefined) ?? params.tools ?? parent.options.tools;
701
- const agentOptions = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "prompt"));
836
+ const agentOptions = { ...params };
837
+ Reflect.deleteProperty(agentOptions, "prompt");
702
838
  const options = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
703
839
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
704
840
  return { content: [{ type: "text", text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
@@ -745,17 +881,17 @@ export class FairAgentScheduler {
745
881
  #inherit(parent, options) {
746
882
  if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools))
747
883
  throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
884
+ const inheritedTools = options.tools;
748
885
  if (!parent)
749
- return Object.freeze({ ...options, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
886
+ return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
750
887
  if (options.cwd !== parent.options.cwd)
751
888
  throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
752
- const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
889
+ const forbidden = inheritedTools.find((tool) => !parent.options.tools.includes(tool));
753
890
  if (forbidden)
754
891
  throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
755
892
  const identity = options.agentIdentity ?? parent.options.agentIdentity;
756
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
893
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
757
894
  }
758
- /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
759
895
  #enqueue(runId, node, start) { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
760
896
  #dispatch() {
761
897
  while (this.#active < this.sessionLimit && this.#runOrder.length) {
@@ -802,6 +938,7 @@ export class FairAgentScheduler {
802
938
  return;
803
939
  const heldPermit = node.state === "running" || node.state === "retrying";
804
940
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
941
+ Reflect.deleteProperty(node, "steer");
805
942
  this.#persist(node.runId);
806
943
  if (heldPermit)
807
944
  this.#release(node.runId);
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { CAPTURE_IDENTITY } from "./eval-capture-extension.js";
9
- import { extractCapturedWorkflows, extractParentOracleFile } from "./workflow-evals.js";
9
+ import { isObject } from "./index.js";
10
+ import { extractCapturedWorkflows, extractParentOracleFile, findSessionFile } from "./workflow-evals.js";
10
11
  export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
11
12
  export const AMBIENT_CAPTURE_NOTE = "Ambient Tier D uses the explicit capture extension. Pi 0.80.6 orders CLI extensions before discovered extensions and the first tool registration wins, so ambient tools and skills remain available while workflow execution stays capture-only.";
12
13
  export const AMBIENT_INVOCATION_MODE = "ambient-capture-only";
@@ -234,34 +235,12 @@ function emptyAccounting() {
234
235
  function ambientAgentDir(environment) {
235
236
  return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
236
237
  }
237
- function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
238
- function findSessionFile(directory, sessionId) {
239
- if (!existsSync(directory))
240
- return undefined;
241
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
242
- const path = join(directory, entry.name);
243
- if (entry.isDirectory()) {
244
- const found = findSessionFile(path, sessionId);
245
- if (found)
246
- return found;
247
- }
248
- else if (entry.name.endsWith(".jsonl")) {
249
- try {
250
- const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
251
- if (isObject(header) && header.id === sessionId)
252
- return path;
253
- }
254
- catch { /* Ignore incomplete sessions. */ }
255
- }
256
- }
257
- return undefined;
258
- }
259
238
  function emptyResult(candidate, repository, worktree, environment, error) {
260
239
  const accounting = emptyAccounting();
261
240
  return {
262
241
  id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
263
242
  manifest: {
264
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
243
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
265
244
  cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
266
245
  },
267
246
  };
@@ -271,12 +250,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
271
250
  const sessionId = randomUUID();
272
251
  const sessionDir = join(repository.root, "sessions", candidate.id);
273
252
  let result;
274
- let gitStatusAfter = worktree.gitStatusBefore;
253
+ let failure;
254
+ let gitStatusAfter;
275
255
  try {
276
256
  const pi = await runAmbientPiProcess({ worktree: worktree.path, sessionDir, sessionId, prompt: candidate.prompt, provider, model, ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), timeoutMs: candidate.timeoutMs, maxCost: candidate.maxCost, environment });
277
257
  const sessionFile = findSessionFile(sessionDir, sessionId);
278
258
  if (!sessionFile)
279
- result = emptyResult(candidate, repository, worktree, environment, "Ambient parent session was not written.");
259
+ failure = "Ambient parent session was not written.";
280
260
  else {
281
261
  const oracle = extractParentOracleFile(sessionFile);
282
262
  const workflows = extractCapturedWorkflows(oracle);
@@ -286,14 +266,14 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
286
266
  result = {
287
267
  id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
288
268
  manifest: {
289
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
269
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
290
270
  cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
291
271
  },
292
272
  };
293
273
  }
294
274
  }
295
275
  catch (error) {
296
- result = emptyResult(candidate, repository, worktree, environment, error instanceof Error ? error.message : String(error));
276
+ failure = error instanceof Error ? error.message : String(error);
297
277
  }
298
278
  finally {
299
279
  try {
@@ -304,12 +284,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
304
284
  }
305
285
  const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
306
286
  if (!result)
307
- result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
287
+ result = emptyResult(candidate, repository, worktree, environment, failure ?? "Ambient case produced no result.");
308
288
  const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
309
- result = { ...result, manifest: { ...result.manifest, gitStatusAfter, cleanup } };
310
- writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
289
+ result = { ...result, manifest: { ...result.manifest, cleanup } };
311
290
  }
312
- return result;
291
+ const finalized = { ...result, manifest: { ...result.manifest, gitStatusAfter } };
292
+ writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(finalized, null, 2)}\n`, { mode: 0o600 });
293
+ return finalized;
313
294
  }
314
295
  export function assertAmbientOptIn(environment = process.env) {
315
296
  if (environment[AMBIENT_OPT_IN] !== "1")
package/dist/src/cli.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { type DoctorOptions } from "./doctor.js";
3
+ import { type JsonSchema, type JsonValue } from "./index.js";
4
+ import type { WorkflowCatalogFunction } from "./index.js";
3
5
  export interface CliOptions extends DoctorOptions {
4
6
  inspect?: (sessionId?: string) => Promise<void>;
7
+ transcript?: (sessionFile: string) => Promise<void>;
8
+ stderr?: (text: string) => void;
9
+ signal?: AbortSignal;
10
+ trustOverride?: boolean;
11
+ isTTY?: boolean;
5
12
  }
13
+ export declare function formatWorkflowCliHelp(fn: WorkflowCatalogFunction, command?: string): string;
14
+ export declare function parseWorkflowCliArgs(schema: JsonSchema, rawArgs: readonly string[]): Record<string, JsonValue>;
6
15
  export declare function runCli(args: readonly string[], options?: CliOptions, write?: (text: string) => void): Promise<number>;