pi-extensible-workflows 2.0.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensible-workflows",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Deterministic multi-agent workflow orchestration for Pi",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
6
  "repository": {
@@ -6,10 +6,11 @@ description: Use when the task is complex enough to require multiple subagents o
6
6
  # pi-extensible-workflows
7
7
  Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
8
8
 
9
- ## Pattern
9
+ ## Example
10
10
  ```js
11
11
  const reportSchema = { type: "object", properties: { summary: { type: "string" }, findings: { type: "array", items: { type: "string" } } }, required: ["summary", "findings"], additionalProperties: false };
12
12
 
13
+
13
14
  const reports = await parallel("research", {
14
15
  first: () => agent("Research the first target.", { role: "scout", outputSchema: reportSchema }),
15
16
  second: () => agent("Research the second target.", { role: "scout", outputSchema: reportSchema }),
@@ -21,33 +22,37 @@ return agent(
21
22
  );
22
23
  ```
23
24
 
24
- Pass structured input from the main agent with `args`:
25
+ Inline launches require a non-empty `name`. Registered function launches must omit `name`; they use `workflow` as the run name:
25
26
  ```json
26
27
  { "workflow": "workflowName", "args": { "issue": 42 } }
27
28
  ```
28
- Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes; unnamed or missing names create new worktrees.
29
- If `workflow_catalog` is available, call it once before creating the first workflow for a task. The name-less result is a compact index with launch-ready input schemas, descriptions, variables, and configured model aliases. Use those inputs to launch registered functions directly with `{ "workflow": "name", "args": { ... } }`; their input and output schemas are enforced. Request full detail with `{ "name": "name" }` only when composing a function programmatically or inspecting its output contract and extension metadata; do not load full definitions unconditionally. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
29
+ Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes but does not replay journal results. For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. Retry versus per-agent `retries` and `workflow_resume` is always explicit; external side effects before failure are not guaranteed exactly once.
30
+ Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
30
31
 
31
32
  Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
32
33
 
33
- Use a bounded verification loop when command output controls the gate:
34
+ Example use of `shell`:
35
+
34
36
  ```js
35
- return withWorktree("fix-tests", async ({ path, branch }) => {
36
- for (let attempt = 1; attempt <= 5; attempt += 1) {
37
- const tests = await shell("yarn test", { env: { CI: "1" } });
38
- if (tests.exitCode === 0) return { path, branch, tests };
39
- await agent(prompt("Fix these failures:\n\n{output}", { output: tests.stderr || tests.stdout }));
40
- }
41
- return { path, branch, tests: await shell("yarn test", { env: { CI: "1" } }) };
42
- });
37
+ // ... other code
38
+ const testRes = await shell("yarn test", { env: { CI: "1" } });
39
+ if (testRes.exitCode === 0) {
40
+ // success path
41
+ return {...}
42
+ }
43
43
  ```
44
- Shell results are journaled only after process exit and RPC validation. A host crash after side effects but before journaling can rerun the command on resume; use `shell()` mainly for verification and bounded gates, not exactly-once mutations.
44
+
45
+ Most of the times using `shell()` to perform mutations is an antipattern. Use it mainly for verifications or idempotent actions.
45
46
 
46
47
  ## `agent()` options
47
48
  ```typescript
48
- interface AgentOptions {
49
+ export interface AgentOptions {
49
50
  label?: string; model?: string; role?: string; tools?: string[];
50
- thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null;
51
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
52
+ outputSchema?: JsonSchema;
53
+ retries?: number;
54
+ timeoutMs?: number | null;
55
+ [key: string]: JsonValue;
51
56
  }
52
57
  ```
53
58
 
@@ -55,20 +60,16 @@ Extensions may add JSON-compatible agent options such as `advisor: true`; core k
55
60
 
56
61
  Agent calls are unnamed. Direct calls receive hidden source call-site identity; aliases are unsupported, and calls from one source site must not race outside `parallel` or `pipeline`, whose structural keys make replay deterministic.
57
62
 
58
- ## Persistent conversations
59
- Use `conversation(name, options)` when several agent turns should share a persisted transcript and build on one another. Unlike independent `agent(prompt, options)` calls, the returned handle continues from the last successful turn:
60
-
63
+ ## Passing agent results
64
+ Use independent `agent(prompt, options)` calls and pass each completed result explicitly to the next prompt. This keeps workflow execution deterministic and makes replay state local to each call:
61
65
  ```js
62
- const handle = conversation("developer", { role: "developer" });
63
- const findings = await handle.run("Inspect the implementation.");
64
- const fix = await handle.run("Now propose the smallest fix.");
66
+ const findings = await agent("Inspect the implementation.");
67
+ const fix = await agent(prompt("Propose the smallest fix from these findings:\n\n{findings}", { findings }));
65
68
  return { findings, fix };
66
69
  ```
67
70
 
68
- Await each `handle.run(prompt, turnOptions)` call before starting the next one; conversation turns must be sequential and cannot overlap. Conversation creation accepts the same execution-policy options as `agent()`. `timeoutMs` and `retries` passed to `run()` are turn-local, so a failed turn does not advance the persisted conversation head.
69
-
70
71
  ## Worktrees
71
- Use `withWorktree(callback)` or `withWorktree(name, callback)` for top-level agents that collaborate in one worktree:
72
+ Use `withWorktree(name, callback)` for top-level agents that collaborate in one explicitly named worktree scope:
72
73
  ```js
73
74
  const result = await withWorktree("issue", async ({ path, branch }) => {
74
75
  const report = await agent("Implement the issue");
@@ -94,8 +95,8 @@ Registered extension functions receive `withWorktree` in context and can compose
94
95
  - Preserve item metadata in workflow code between pipeline stages instead of making agents echo it through `outputSchema`.
95
96
  - Use a JavaScript loop for repeated work; each direct `agent(...)` call gets deterministic call-site and occurrence identity.
96
97
  - Runs default to background; set tool-call `foreground: true` when asked to wait.
97
- - Add `budget` only for aggregate limits. Valid dimensions are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches`; each is `{ soft?: number, hard?: number }` with `soft < hard`.
98
- - `budget_exhausted` runs resume through `workflow_resume`: omitted patch values stay unchanged, `null` removes a limit, and tightening resumes directly. Relaxation stores the exact proposal and returns `{ state: "awaiting_approval", proposalId }`; `workflow_respond` must answer that ID. Rejection leaves the run exhausted; approval applies the budget and cold-resumes it.
98
+ - Add `budget` only for aggregate limits. Do not invent limits, omit if user do not ask explicitly. Valid dimensions are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches`; each is `{ soft?: number, hard?: number }` with `soft < hard`.
99
+ - `budget_exhausted` runs resume through `workflow_resume`: omitted patch values stay unchanged, `null` removes a limit, and tightening resumes directly. Relaxation stores the exact proposal and returns `{ state: "awaiting_approval", proposalId }`; `workflow_respond` must answer that ID. Rejection leaves the run exhausted; approval applies the budget and cold-resumes it. `workflow_retry` is only for persisted `failed` runs and inherits cumulative usage; replay itself consumes no budget.
99
100
  - `parallel()` and `pipeline()` return keyed bare values; await them before use. Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings remain literal.
100
101
  - Use `outputSchema` only when another phase compares, aggregates, or validates a result, never for final prose. Keep only consumer-needed fields and avoid repeated evidence. Agents with it must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an extra retry is justified and work is idempotent.
101
102
  - Do not add persona specifications to agent prompts; define the task directly.
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { realpathSync } from "node:fs";
3
2
  import { join, resolve } from "node:path";
4
3
  import { Type } from "@earendil-works/pi-ai";
@@ -6,10 +5,10 @@ import { Value } from "typebox/value";
6
5
  import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type AgentSessionEvent, type InlineExtension, type SessionStats, type ToolDefinition } from "@earendil-works/pi-coding-agent";
7
6
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
8
7
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
9
- import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./index.js";
10
- import { mergeAgentResourceExclusions, resolveModelReference, WorkflowError } from "./index.js";
11
- import type { ConversationHead, PersistedConversation, RunStore } from "./persistence.js";
12
-
8
+ import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
9
+ import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
10
+ import { WorkflowError } from "./types.js";
11
+ import type { RunStore } from "./persistence.js";
13
12
  export interface AgentBudgetHooks {
14
13
  beforeAttempt(): void;
15
14
  beforeTurn(): void;
@@ -42,7 +41,6 @@ export interface AgentExecutionOptions {
42
41
  budget?: AgentBudgetHooks;
43
42
  agentOptions?: Readonly<Record<string, JsonValue>>;
44
43
  agentIdentity?: AgentIdentity;
45
- conversation?: { id: string; turn: number };
46
44
  }
47
45
  export interface AgentExecutionRoot {
48
46
  cwd: string;
@@ -89,7 +87,7 @@ export interface NativeSession {
89
87
  abort?(): Promise<void>;
90
88
  dispose(): void;
91
89
  }
92
- export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string }; allowModelChange?: boolean }
90
+ export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue> }
93
91
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
94
92
 
95
93
  function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
@@ -97,7 +95,6 @@ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: T
97
95
  const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
98
96
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
99
97
  }
100
- function modelCapability(model: ModelSpec): string { return `${model.provider}/${model.model}`; }
101
98
 
102
99
  function text(messages: readonly AgentMessage[]): string {
103
100
  const message = [...messages].reverse().find((item) => item.role === "assistant");
@@ -139,27 +136,8 @@ function canonicalSourcePath(path: string): string { try { return realpathSync(p
139
136
 
140
137
  export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
141
138
  const agentDir = input.agentDir ?? getAgentDir();
142
- let manager: SessionManager;
143
- if (input.continuation) {
144
- try {
145
- manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
146
- const header = manager.getHeader();
147
- if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId)) throw new Error("Persisted transcript identity does not match the conversation head");
148
- manager.branch(input.continuation.leafId);
149
- const context = manager.buildSessionContext();
150
- if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
151
- if (!input.allowModelChange) throw new Error("Persisted transcript model does not match the conversation execution policy");
152
- manager.appendModelChange(input.model.provider, input.model.model);
153
- }
154
- if (input.model.thinking && context.thinkingLevel !== input.model.thinking) throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
155
- } catch (error) {
156
- if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
157
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
158
- }
159
- } else {
160
- manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
161
- manager.appendSessionInfo(input.sessionLabel);
162
- }
139
+ const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
140
+ manager.appendSessionInfo(input.sessionLabel);
163
141
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
164
142
  const model = modelRuntime.getModel(input.model.provider, input.model.model);
165
143
  if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
@@ -203,8 +181,7 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
203
181
  resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
204
182
  await resourceLoader.reload();
205
183
  }
206
- const { session, modelFallbackMessage } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
207
- if (input.continuation && modelFallbackMessage) throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
184
+ const { session } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
208
185
  return Object.assign(session, {
209
186
  getLeafId: () => manager.getLeafId(),
210
187
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
@@ -212,19 +189,6 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
212
189
  }
213
190
  function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
214
191
  function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
215
- function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
216
- if (value === null || typeof value === "boolean" || typeof value === "string") return true;
217
- if (typeof value === "number") return Number.isFinite(value);
218
- if (typeof value !== "object" || seen.has(value)) return false;
219
- if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
220
- const keys = Reflect.ownKeys(value);
221
- if (keys.some((key) => typeof key !== "string")) return false;
222
- seen.add(value);
223
- const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
224
- seen.delete(value);
225
- return valid;
226
- }
227
- function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
228
192
  interface ChildAgentToolParams {
229
193
  prompt: string;
230
194
  label: string;
@@ -256,49 +220,7 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
256
220
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
257
221
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
258
222
  }
259
- function canonicalJson(value: unknown): unknown {
260
- if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
261
- if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
262
- return value;
263
- }
264
- function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
265
- function promptFingerprint(value: string): string { return createHash("sha256").update(value).digest("hex"); }
266
- function fixedConversationOptions(options: Readonly<Record<string, JsonValue>>): JsonValue {
267
- const fixedOptions = structuredClone(options) as Record<string, JsonValue>;
268
- delete fixedOptions.timeoutMs;
269
- delete fixedOptions.retries;
270
- return fixedOptions;
271
- }
272
- function conversationExecutionPolicy(options: AgentExecutionOptions, setup: AgentSetup): JsonValue {
273
- return structuredClone({
274
- model: setup.sessionInput.model,
275
- tools: [...setup.sessionInput.tools],
276
- cwd: setup.sessionInput.cwd,
277
- role: options.role ?? null,
278
- worktreeOwner: options.worktreeOwner ?? null,
279
- parent: options.parent ?? null,
280
- systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
281
- options: fixedConversationOptions(setup.options),
282
- resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
283
- }) as unknown as JsonValue;
284
- }
285
- function conversationPolicyMatches(expected: JsonValue, current: JsonValue, allowModelChange: boolean): boolean {
286
- if (fingerprint(expected) === fingerprint(current)) return true;
287
- if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current)) return false;
288
- const expectedModel = conversationPolicyModel(expected);
289
- const currentModel = conversationPolicyModel(current);
290
- if (!expectedModel || !currentModel) return false;
291
- return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
292
- }
293
- function conversationPolicyModel(policy: JsonValue): ModelSpec | undefined {
294
- if (!policy || typeof policy !== "object" || Array.isArray(policy)) return undefined;
295
- const model = policy.model;
296
- if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string") return undefined;
297
- const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking as ModelSpec["thinking"] : undefined;
298
- return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
299
- }
300
- function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
301
- async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, continuation?: ConversationHead): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
223
+ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
302
224
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
303
225
  const baselineOptions = structuredClone(options.agentOptions ?? {});
304
226
  const baseResourcePolicy = await root.agentResourcePolicy?.();
@@ -320,7 +242,6 @@ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: Sessio
320
242
  if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
321
243
  if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string")) setup.sessionInput.tools = [...setup.options.tools];
322
244
  if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
323
- if (continuation) setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
324
245
  const model = setup.sessionInput.model;
325
246
  const summary: AgentSetupSummary = { hookNames: [...hookNames], model: { provider: model.provider, model: model.model, ...(model.thinking ? { thinking: model.thinking } : {}) }, tools: [...setup.sessionInput.tools], cwd: setup.sessionInput.cwd, ...(setup.sessionInput.resourcePolicy ? { disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) } : {}) };
326
247
  return { setup, summary };
@@ -339,13 +260,14 @@ export class WorkflowAgentExecutor {
339
260
  const forbidden = requested.find((tool) => !this.root.tools.has(tool));
340
261
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
341
262
  const requestedModel = options.model ?? definition?.model;
342
- const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
343
- if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) { const target = this.root.blockedAliasTargets?.[requestedModel]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
344
- const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
263
+ const alias = requestedModel === undefined ? undefined : modelAliasName(requestedModel, this.root.modelAliases ?? {});
264
+ const blockedAlias = requestedModel?.split(":", 1)[0];
265
+ if (requestedModel !== undefined && blockedAlias && this.root.blockedAliases?.has(blockedAlias) && !alias) { const target = this.root.blockedAliasTargets?.[blockedAlias]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
266
+ const aliasThinking = requestedModel !== undefined && alias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
345
267
  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);
346
268
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
347
269
  if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
348
- return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
270
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
349
271
  }
350
272
 
351
273
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
@@ -373,20 +295,7 @@ export class WorkflowAgentExecutor {
373
295
  if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
374
296
  cwd = this.root.cwd;
375
297
  }
376
- let conversationRecord: PersistedConversation | undefined;
377
- if (options.conversation) {
378
- const store = this.root.runStore;
379
- if (!store) throw conversationFailure("Conversation persistence is unavailable");
380
- try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
381
- if (conversationRecord) {
382
- const model = conversationPolicyModel(conversationRecord.policy);
383
- if (model) resolved = this.resolve({ ...options, modelOverride: model });
384
- }
385
- if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
386
- if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1) throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
387
- }
388
298
  const attempts: AgentAttempt[] = [];
389
- let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
390
299
  let maxAttempts = (options.retries ?? 0) + 1;
391
300
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
392
301
  if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
@@ -398,10 +307,6 @@ export class WorkflowAgentExecutor {
398
307
  let setupFailed = false;
399
308
  let budgetError: WorkflowError | undefined;
400
309
  let turnStarted = false;
401
- let conversationSystemPrompt = "";
402
- let conversationToolDefinitionsSha256 = "";
403
- let conversationMismatch: WorkflowError | undefined;
404
- const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
405
310
  const hasSchemaResult = () => schemaResult !== undefined;
406
311
  const resultTool = options.schema ? {
407
312
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -431,51 +336,19 @@ export class WorkflowAgentExecutor {
431
336
  };
432
337
  try {
433
338
  setupFailed = true;
434
- const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
339
+ const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
435
340
  setup = prepared.setup;
436
341
  setupSummary = prepared.summary;
437
342
  setupFailed = false;
438
343
  if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
439
- if (recoveryModel && conversationRecord) setup.sessionInput.allowModelChange = true;
440
344
  const started = Date.now();
441
345
  session = await setup.createSession(setup.sessionInput);
442
346
  if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
443
- if (options.conversation) {
444
- conversationSystemPrompt = session.systemPrompt ?? "";
445
- conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
446
- const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
447
- if (conversationRecord) {
448
- if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
449
- if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId)) throw conversationFailure("Conversation transcript leaf identity changed");
450
- if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
451
- if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
452
- if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
453
- } else if (conversationBaseline) {
454
- if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
455
- if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
456
- } else {
457
- conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
458
- }
459
- if (!session.subscribe) {
460
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
461
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
462
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) throw conversationFailure("Conversation system prompt changed");
463
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
464
- }
465
- if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model)) throw conversationFailure("Conversation model changed");
466
- }
467
347
  const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
468
348
  await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
469
349
  const activeSession = session;
470
350
  unsubscribe = activeSession.subscribe?.((event) => {
471
351
  if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
472
- if (options.conversation) {
473
- conversationSystemPrompt = session.systemPrompt;
474
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
475
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
476
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) { conversationMismatch = conversationFailure("Conversation system prompt changed"); void session.abort?.(); }
477
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
478
- }
479
352
  if (this.root.runStore) {
480
353
  systemPromptTurn += 1;
481
354
  const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
@@ -497,7 +370,7 @@ export class WorkflowAgentExecutor {
497
370
  }
498
371
  }
499
372
  if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; report(false); }
500
- if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); }
373
+ if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); toolCalls.delete(event.toolCallId); }
501
374
  });
502
375
  report(false);
503
376
  if (setSteer) {
@@ -510,7 +383,6 @@ export class WorkflowAgentExecutor {
510
383
  options.budget?.beforeTurn();
511
384
  turnStarted = true;
512
385
  try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
513
- if (conversationMismatch) throw conversationMismatch;
514
386
  throwIfTerminalAssistantError(session, setup.sessionInput.model);
515
387
  { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
516
388
  if (budgetError) throw budgetError;
@@ -525,8 +397,6 @@ export class WorkflowAgentExecutor {
525
397
  }
526
398
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
527
399
  }
528
- const mismatch = conversationMismatchError();
529
- if (mismatch) throw mismatch;
530
400
  const value = options.schema ? schemaResult as JsonValue : text(session.messages);
531
401
  if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
532
402
  report(true);
@@ -534,19 +404,12 @@ export class WorkflowAgentExecutor {
534
404
  await flushSystemPrompts();
535
405
  unsubscribe?.();
536
406
  const attemptAccounting = accounting(session.getSessionStats());
537
- const leafId = session.getLeafId?.() ?? undefined;
538
- if (options.conversation) {
539
- if (!leafId) throw conversationFailure("Conversation transcript has no persisted leaf");
540
- const store = this.root.runStore;
541
- if (!store) throw conversationFailure("Conversation persistence is unavailable");
542
- await store.saveConversation({ id: options.conversation.id, policy: conversationExecutionPolicy(options, setup), head: { turn: options.conversation.turn, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), leafId, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt), toolDefinitionsSha256: conversationToolDefinitionsSha256 } });
543
- }
544
407
  const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
545
408
  attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
546
409
  session.dispose();
547
410
  return { value, attempts, cwd: setupSummary.cwd };
548
411
  } catch (error) {
549
- const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
412
+ const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
550
413
  if (session) {
551
414
  report(true);
552
415
  await progress;
@@ -598,7 +461,6 @@ export interface ScheduledAgentOptions {
598
461
  timeoutMs?: number | null;
599
462
  agentOptions?: Readonly<Record<string, JsonValue>>;
600
463
  agentIdentity?: AgentIdentity;
601
- conversation?: { id: string; turn: number };
602
464
  }
603
465
 
604
466
  export type ScheduledAgentResult =
package/src/budget.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { WorkflowError, type BudgetDimension, type BudgetEvent, type BudgetLimits, type WorkflowBudget, type WorkflowBudgetPatch, type WorkflowBudgetUsage, type AgentAccounting, type RunState } from "./types.js";
2
+ import { fail, object } from "./utils.js";
3
+
4
+ function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
5
+ function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
6
+ export function validateBudget(value: unknown): WorkflowBudget | undefined {
7
+ if (value === undefined) return undefined;
8
+ if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
9
+ const result: WorkflowBudget = {};
10
+ for (const [dimension, raw] of Object.entries(value)) {
11
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
12
+ if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
13
+ if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
14
+ const isCost = dimension === "costUsd";
15
+ for (const key of ["soft", "hard"] as const) if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key]))) fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
16
+ if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
17
+ const limits: BudgetLimits = {};
18
+ if (raw.soft !== undefined) limits.soft = raw.soft as number;
19
+ if (raw.hard !== undefined) limits.hard = raw.hard as number;
20
+ if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
21
+ }
22
+ return Object.freeze(result);
23
+ }
24
+ export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
25
+ if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
26
+ const result: WorkflowBudgetPatch = {};
27
+ for (const [dimension, raw] of Object.entries(value)) {
28
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
29
+ if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
30
+ if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
31
+ const limits: { soft?: number | null; hard?: number | null } = {};
32
+ for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
33
+ if (raw[key] === null) limits[key] = null;
34
+ else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
35
+ }
36
+ if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
37
+ (result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
38
+ }
39
+ return result;
40
+ }
41
+ export function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
42
+ export class WorkflowBudgetRuntime {
43
+ readonly #now: () => number;
44
+ readonly #onChange: (() => void) | undefined;
45
+ readonly #injected = new Set<string>();
46
+ readonly #seen = new Set<string>();
47
+ #active: boolean;
48
+ #activeSince: number | undefined;
49
+ #usage: WorkflowBudgetUsage;
50
+ #events: BudgetEvent[];
51
+ #turnAccounting?: { input: number; output: number; cost: number };
52
+ constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) { this.#now = options.now ?? (() => Date.now()); this.#onChange = options.onChange; this.#active = options.active ?? true; this.#activeSince = this.#active ? this.#now() : undefined; this.#usage = budgetUsage(usage); this.#events = [...events]; for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type); }
53
+ get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
54
+ get events(): readonly BudgetEvent[] { return this.#events; }
55
+ get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
56
+ checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
57
+ beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
58
+ beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
59
+ afterTurn(accounting: AgentAccounting, final: boolean): void { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
60
+ #applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void { this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output); this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost); this.#evaluate(); if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]); }
61
+ instruction(agentId = "agent"): string | undefined { if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined; this.#injected.add(agentId); return `The workflow budget soft limit has been reached. Finish the requested output now, preserving any required output schema. Current usage: ${JSON.stringify(this.usage)}. Do not start additional model work unless it is required to produce the final requested result.`; }
62
+ forAgent(agentId: string) { let attempt = 0; let previous: { input: number; output: number; cost: number } | undefined; return { beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); }, beforeTurn: () => { this.beforeTurn(); }, afterTurn: (accounting: AgentAccounting, final: boolean) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }, instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`) }; }
63
+ transition(state: RunState): void { const active = state === "running"; if (active === this.#active) return; if (active) { this.#active = true; this.#activeSince = this.#now(); } else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; } this.#onChange?.(); }
64
+ #syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
65
+ #hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
66
+ #checkHard(dimensions: readonly BudgetDimension[]): void { const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; }); if (!exhausted.length) return; this.#record("hard_exhausted", exhausted); const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", "); throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`); }
67
+ #evaluate(): void { const budget = this.budget; if (!budget) return; const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; }); if (soft.length) this.#record("soft_crossed", soft); const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; }); if (overrun.length) this.#record("hard_overrun", overrun); }
68
+ #record(type: BudgetEvent["type"], dimensions: readonly BudgetDimension[]): void { if (this.#seen.has(type)) return; this.#seen.add(type); this.#events.push({ type, budgetVersion: this.version, dimensions: [...dimensions], usage: this.usage, limits: structuredClone(this.budget ?? {}), at: this.#now() }); this.#onChange?.(); }
69
+ recordEvent(event: BudgetEvent): void { this.#events.push(structuredClone(event)); }
70
+ snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
71
+ }
72
+ export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined { const merged: WorkflowBudget = structuredClone(budget ?? {}); for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) { const value = patch[dimension]; if (value === null) { Reflect.deleteProperty(merged, dimension); continue; } const next: BudgetLimits = { ...(merged[dimension] ?? {}) }; for (const key of ["soft", "hard"] as const) if (value && Object.prototype.hasOwnProperty.call(value, key)) { const limit = value[key]; if (limit === null) Reflect.deleteProperty(next, key); else if (limit !== undefined) next[key] = limit; } if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension); } return validateBudget(merged); }
73
+ export function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) { const oldLimit = previous?.[dimension]; const newLimit = next?.[dimension]; for (const key of ["soft", "hard"] as const) if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key])) return true; } return false; }
74
+ export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] { if (!budget) return []; return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; }); }
75
+ export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }
package/src/cli.ts CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, type LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
8
8
  import { Value } from "typebox/value";
9
9
  import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
10
- import workflowExtension, { formatWorkflowProgress, workflowCatalog, type JsonSchema, type JsonValue } from "./index.js";
10
+ import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
11
11
  import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
12
12
  import type { PersistedRun } from "./persistence.js";
13
13
  import type { WorkflowCatalogFunction } from "./index.js";
@@ -274,16 +274,25 @@ function writeLauncher(destination: string, workflowName: string, force: boolean
274
274
  }
275
275
 
276
276
 
277
+ function terminalProgressStyles(enabled: boolean): WorkflowProgressStyles {
278
+ const style = (code: number) => enabled ? (text: string) => `\x1b[${String(code)}m${text}\x1b[0m` : (text: string) => text;
279
+ return { accent: style(36), success: style(32), error: style(31), warning: style(33), muted: style(90), dim: style(2), bold: style(1) };
280
+ }
277
281
  class CliProgress {
278
282
  #lastStable = "";
279
283
  #lines = 0;
280
284
  #frame = 0;
281
285
  #run: PersistedRun | undefined;
282
286
  #timer: ReturnType<typeof setInterval> | undefined;
283
- constructor(private readonly stderr: (text: string) => void, private readonly tty: boolean) {}
287
+ #interactive: boolean;
288
+ #styles: WorkflowProgressStyles;
289
+ constructor(private readonly stderr: (text: string) => void, tty: boolean) {
290
+ this.#interactive = tty && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
291
+ this.#styles = terminalProgressStyles(this.#interactive);
292
+ }
284
293
  update(run: PersistedRun): void {
285
- const stable = formatWorkflowProgress(run, "◇");
286
- if (!this.tty) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
294
+ const stable = formatWorkflowProgress(run, "◇", this.#styles);
295
+ if (!this.#interactive) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
287
296
  this.#run = run;
288
297
  this.#timer ??= setInterval(() => { this.render(); }, 80);
289
298
  this.#timer.unref();
@@ -293,13 +302,13 @@ class CliProgress {
293
302
  if (!this.#run) return;
294
303
  const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
295
304
  const width = process.stderr.columns || 80;
296
- const text = formatWorkflowProgress(this.#run, spinner).split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`).join("\n");
305
+ const text = truncateWorkflowProgress(formatWorkflowProgress(this.#run, spinner, this.#styles), width).join("\n");
297
306
  this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
298
307
  this.#lines = text.split("\n").length;
299
308
  }
300
309
  finish(): void {
301
310
  if (this.#timer) { clearInterval(this.#timer); this.#timer = undefined; }
302
- if (this.tty && this.#lines) { this.stderr(`\x1b[${String(this.#lines)}A\x1b[0J\x1b[?25h`); this.#lines = 0; }
311
+ if (this.#interactive && this.#lines) { this.stderr(`\x1b[${String(this.#lines)}A\x1b[0J\x1b[?25h`); this.#lines = 0; }
303
312
  this.#run = undefined;
304
313
  }
305
314
  }