pi-extensible-workflows 0.3.2 → 1.0.1

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.
@@ -1,4 +1,4 @@
1
- import type { JsonValue, LaunchSnapshot, RunRecord } from "./index.js";
1
+ import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./index.js";
2
2
  import type { OwnershipRecord } from "./agent-execution.js";
3
3
  export interface NativeSessionReference {
4
4
  sessionId: string;
@@ -11,6 +11,20 @@ export interface EffectiveSystemPrompt {
11
11
  sha256: string;
12
12
  prompt: string;
13
13
  }
14
+ export interface ConversationHead {
15
+ turn: number;
16
+ sessionId: string;
17
+ sessionFile: string;
18
+ leafId: string;
19
+ systemPrompt: string;
20
+ systemPromptSha256: string;
21
+ toolDefinitionsSha256: string;
22
+ }
23
+ export interface PersistedConversation {
24
+ id: string;
25
+ policy: JsonValue;
26
+ head: ConversationHead;
27
+ }
14
28
  export interface PersistedRun extends RunRecord {
15
29
  nativeSessions: readonly NativeSessionReference[];
16
30
  }
@@ -24,6 +38,7 @@ export interface AwaitingCheckpoint {
24
38
  prompt: string;
25
39
  context: JsonValue;
26
40
  }
41
+ export type PendingWorkflowDecision = BudgetApprovalRequest;
27
42
  export type PersistedOwnershipNode = OwnershipRecord;
28
43
  export interface WorktreeReference {
29
44
  owner: string;
@@ -45,6 +60,8 @@ export declare class SessionLease {
45
60
  export declare function acquireSessionLease(cwd: string, sessionId: string, home?: string): Promise<SessionLease>;
46
61
  export declare function listRunIds(cwd: string, sessionId: string, home?: string): Promise<string[]>;
47
62
  export declare function structuralPath(...names: string[]): string;
63
+ export declare function atomicWriteFile(path: string, content: string): Promise<void>;
64
+ export declare function atomicWriteFile(path: string, content: string, sync: true): void;
48
65
  export declare class RunStore {
49
66
  readonly cwd: string;
50
67
  readonly sessionId: string;
@@ -54,7 +71,9 @@ export declare class RunStore {
54
71
  private stateWrite;
55
72
  private worktreeWrite;
56
73
  private snapshotWrite;
74
+ private launchSnapshotWrite;
57
75
  private systemPromptWrite;
76
+ private conversationWrite;
58
77
  constructor(cwd: string, sessionId: string, runId: string, home?: string);
59
78
  create(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>): Promise<void>;
60
79
  isComplete(): Promise<boolean>;
@@ -64,16 +83,24 @@ export declare class RunStore {
64
83
  }>;
65
84
  saveState(run: PersistedRun): Promise<void>;
66
85
  updateState(update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun>;
86
+ saveSnapshot(snapshot: Readonly<LaunchSnapshot>): Promise<void>;
87
+ appendEvent(event: WorkflowRunEvent): Promise<void>;
67
88
  saveOwnership(nodes: readonly PersistedOwnershipNode[]): Promise<void>;
68
89
  loadOwnership(): Promise<readonly PersistedOwnershipNode[]>;
69
90
  systemPromptPath(): string;
70
91
  recordSystemPrompt(entry: Omit<EffectiveSystemPrompt, "sha256">): Promise<void>;
71
92
  systemPrompts(): Promise<readonly EffectiveSystemPrompt[]>;
93
+ conversationPath(): string;
94
+ conversation(id: string): Promise<PersistedConversation | undefined>;
95
+ saveConversation(conversation: PersistedConversation): Promise<void>;
72
96
  private updateJournal;
73
97
  complete(path: string, value: JsonValue): Promise<void>;
74
98
  replay(path: string): Promise<CompletedOperation | undefined>;
75
99
  awaitCheckpoint(checkpoint: AwaitingCheckpoint): Promise<boolean | undefined>;
76
100
  awaitingCheckpoints(): Promise<readonly AwaitingCheckpoint[]>;
101
+ requestWorkflowDecision(request: PendingWorkflowDecision): Promise<void>;
102
+ pendingWorkflowDecisions(): Promise<readonly PendingWorkflowDecision[]>;
103
+ answerWorkflowDecision(proposalId: string, approved: boolean): Promise<PendingWorkflowDecision | undefined>;
77
104
  answerCheckpoint(name: string, approved: boolean): Promise<AwaitingCheckpoint | undefined>;
78
105
  private expectedWorktree;
79
106
  private markerPath;
@@ -1,10 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
+ import { renameSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
6
  import { homedir } from "node:os";
6
7
  import { promisify } from "node:util";
7
8
  import { loadLaunchSnapshot, WorkflowError } from "./index.js";
9
+ function isConversationArtifact(value) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value))
11
+ return false;
12
+ const artifact = value;
13
+ return artifact.version === 1 && Boolean(artifact.conversations) && typeof artifact.conversations === "object" && !Array.isArray(artifact.conversations);
14
+ }
8
15
  const execute = promisify(execFile);
9
16
  const gitIdentity = {
10
17
  GIT_AUTHOR_NAME: "pi-extensible-workflows", GIT_AUTHOR_EMAIL: "pi-extensible-workflows@localhost", GIT_COMMITTER_NAME: "pi-extensible-workflows", GIT_COMMITTER_EMAIL: "pi-extensible-workflows@localhost",
@@ -176,10 +183,32 @@ export function structuralPath(...names) {
176
183
  throw new WorkflowError("INVALID_METADATA", "Structural paths require non-empty explicit names");
177
184
  return names.map((name) => encodeURIComponent(name)).join("/");
178
185
  }
179
- async function atomicJson(path, value) {
186
+ export function atomicWriteFile(path, content, sync = false) {
180
187
  const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
181
- await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 });
182
- await rename(temporary, path);
188
+ if (sync) {
189
+ try {
190
+ writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
191
+ renameSync(temporary, path);
192
+ }
193
+ catch (error) {
194
+ try {
195
+ rmSync(temporary, { force: true });
196
+ }
197
+ catch { /* Preserve the original write error. */ }
198
+ throw error;
199
+ }
200
+ return;
201
+ }
202
+ return writeFile(temporary, content, { encoding: "utf8", mode: 0o600 }).then(() => rename(temporary, path)).catch(async (error) => {
203
+ try {
204
+ await rm(temporary, { force: true });
205
+ }
206
+ catch { /* Preserve the original write error. */ }
207
+ throw error;
208
+ });
209
+ }
210
+ async function atomicJson(path, value) {
211
+ await atomicWriteFile(path, `${JSON.stringify(value)}\n`);
183
212
  }
184
213
  async function json(path) { return JSON.parse(await readFile(path, "utf8")); }
185
214
  export class RunStore {
@@ -192,8 +221,10 @@ export class RunStore {
192
221
  stateWrite = Promise.resolve();
193
222
  worktreeWrite = Promise.resolve();
194
223
  snapshotWrite = Promise.resolve();
224
+ launchSnapshotWrite = Promise.resolve();
195
225
  // ponytail: the session lease prevents concurrent RunStore writers for one run.
196
226
  systemPromptWrite = Promise.resolve();
227
+ conversationWrite = Promise.resolve();
197
228
  constructor(cwd, sessionId, runId, home = homedir()) {
198
229
  this.cwd = cwd;
199
230
  this.sessionId = sessionId;
@@ -208,12 +239,14 @@ export class RunStore {
208
239
  await mkdir(dirname(this.directory), { recursive: true, mode: 0o700 });
209
240
  await mkdir(temporary, { mode: 0o700 });
210
241
  try {
242
+ await writeFile(join(temporary, "workflow.js"), snapshot.script, { encoding: "utf8", mode: 0o600 });
211
243
  await atomicJson(join(temporary, "snapshot.json"), snapshot);
212
- await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {} });
244
+ await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {}, decisions: {} });
213
245
  await atomicJson(join(temporary, "ownership.json"), []);
214
246
  await atomicJson(join(temporary, "worktrees.json"), []);
215
247
  await atomicJson(join(temporary, "state.json"), run);
216
248
  await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
249
+ await atomicJson(join(temporary, "conversations.json"), { version: 1, conversations: {} });
217
250
  await rename(temporary, this.directory);
218
251
  }
219
252
  catch (error) {
@@ -261,6 +294,14 @@ export class RunStore {
261
294
  await write;
262
295
  return result;
263
296
  }
297
+ async saveSnapshot(snapshot) {
298
+ const write = this.launchSnapshotWrite.then(() => atomicJson(join(this.directory, "snapshot.json"), snapshot));
299
+ this.launchSnapshotWrite = write.catch(() => undefined);
300
+ await write;
301
+ }
302
+ async appendEvent(event) {
303
+ await this.updateState((run) => ({ ...run, events: [...(run.events ?? []), ...(run.events?.some((current) => current.type === event.type && current.message === event.message) ? [] : [event])] }));
304
+ }
264
305
  async saveOwnership(nodes) {
265
306
  await atomicJson(join(this.directory, "ownership.json"), nodes);
266
307
  }
@@ -284,6 +325,54 @@ export class RunStore {
284
325
  return (await json(this.systemPromptPath()).catch((error) => { if (error.code === "ENOENT")
285
326
  return { version: 1, entries: [] }; throw error; })).entries;
286
327
  }
328
+ conversationPath() { return join(this.directory, "conversations.json"); }
329
+ async conversation(id) {
330
+ await this.conversationWrite;
331
+ let artifact;
332
+ try {
333
+ const raw = await json(this.conversationPath());
334
+ if (!isConversationArtifact(raw))
335
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt");
336
+ artifact = raw;
337
+ }
338
+ catch (error) {
339
+ if (error.code === "ENOENT")
340
+ return undefined;
341
+ if (error instanceof WorkflowError)
342
+ throw error;
343
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
344
+ }
345
+ return artifact.conversations[id];
346
+ }
347
+ async saveConversation(conversation) {
348
+ const write = this.conversationWrite.then(async () => {
349
+ const path = this.conversationPath();
350
+ let artifact;
351
+ try {
352
+ const raw = await json(path);
353
+ if (!isConversationArtifact(raw))
354
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt");
355
+ artifact = raw;
356
+ }
357
+ catch (error) {
358
+ if (error.code === "ENOENT")
359
+ artifact = { version: 1, conversations: {} };
360
+ else if (error instanceof WorkflowError)
361
+ throw error;
362
+ else
363
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
364
+ }
365
+ const previous = artifact.conversations[conversation.id];
366
+ if (previous && previous.head.turn + 1 !== conversation.head.turn)
367
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation head is not the previous turn: ${conversation.id}`);
368
+ if (!previous && conversation.head.turn !== 1)
369
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation must start at turn one: ${conversation.id}`);
370
+ artifact.conversations[conversation.id] = structuredClone(conversation);
371
+ await atomicJson(path, artifact);
372
+ });
373
+ this.conversationWrite = write.catch(() => undefined);
374
+ await write;
375
+ }
287
376
  async updateJournal(update) {
288
377
  let result;
289
378
  const write = this.journalWrite.then(async () => {
@@ -322,13 +411,31 @@ export class RunStore {
322
411
  const journal = await json(join(this.directory, "journal.json"));
323
412
  return Object.values(journal.awaiting ?? {});
324
413
  }
414
+ async requestWorkflowDecision(request) {
415
+ await this.updateJournal((journal) => { journal.decisions ??= {}; journal.decisions[request.proposalId] = request; });
416
+ }
417
+ async pendingWorkflowDecisions() {
418
+ await this.journalWrite;
419
+ const journal = await json(join(this.directory, "journal.json"));
420
+ return Object.values(journal.decisions ?? {});
421
+ }
422
+ async answerWorkflowDecision(proposalId, approved) {
423
+ return this.updateJournal((journal) => {
424
+ const request = journal.decisions?.[proposalId];
425
+ if (!request)
426
+ return undefined;
427
+ journal.completed[`decision/${proposalId}`] = { path: `decision/${proposalId}`, value: approved };
428
+ delete journal.decisions?.[proposalId];
429
+ return request;
430
+ });
431
+ }
325
432
  async answerCheckpoint(name, approved) {
326
433
  return this.updateJournal((journal) => {
327
- const checkpoint = Object.values(journal.awaiting).find((item) => item.name === name);
434
+ const checkpoint = Object.values(journal.awaiting ?? {}).find((item) => item.name === name);
328
435
  if (!checkpoint || journal.completed[checkpoint.path])
329
436
  return undefined;
330
437
  journal.completed[checkpoint.path] = { path: checkpoint.path, value: approved };
331
- journal.awaiting = Object.fromEntries(Object.entries(journal.awaiting).filter(([path]) => path !== checkpoint.path));
438
+ journal.awaiting = Object.fromEntries(Object.entries(journal.awaiting ?? {}).filter(([path]) => path !== checkpoint.path));
332
439
  return checkpoint;
333
440
  });
334
441
  }
@@ -461,9 +568,19 @@ export class RunStore {
461
568
  try {
462
569
  const write = this.snapshotWrite.then(async () => {
463
570
  const record = await this.worktree(owner);
464
- await git(record.path, ["add", "-A"]);
465
- if ((await git(record.path, ["status", "--porcelain"])).trim())
466
- await git(record.path, ["commit", "-m", "pi-extensible-workflows runtime snapshot"], gitIdentity);
571
+ for (let attempt = 0; attempt < 3; attempt += 1) {
572
+ await git(record.path, ["add", "-A"]);
573
+ if (!(await git(record.path, ["status", "--porcelain"])).trim())
574
+ break;
575
+ try {
576
+ await git(record.path, ["commit", "-m", "pi-extensible-workflows runtime snapshot"], gitIdentity);
577
+ break;
578
+ }
579
+ catch (error) {
580
+ if (attempt === 2)
581
+ throw error;
582
+ }
583
+ }
467
584
  return (await git(record.path, ["rev-parse", "HEAD"])).trim();
468
585
  });
469
586
  this.snapshotWrite = write.then(() => undefined, () => undefined);
@@ -1,5 +1,6 @@
1
1
  import { type SessionEntry, type SessionInfo } from "@earendil-works/pi-coding-agent";
2
- import { type ModelSpec, type StaticWorkflowCall } from "./index.js";
2
+ import { type AgentSetupSummary, type ModelSpec, type StaticWorkflowCall } from "./index.js";
3
+ import { type PersistedRun } from "./persistence.js";
3
4
  export interface ModelUsage {
4
5
  model: string;
5
6
  cost: number;
@@ -12,16 +13,19 @@ export interface AttemptReport {
12
13
  cost: number;
13
14
  models: readonly ModelUsage[];
14
15
  error?: string;
16
+ setup?: AgentSetupSummary;
15
17
  }
16
18
  export interface AgentReport {
17
19
  name: string;
18
20
  label?: string;
19
21
  state: string;
20
22
  role?: string;
23
+ requestedModel?: string;
21
24
  model: string;
22
25
  thinking?: ModelSpec["thinking"];
23
26
  cost: number;
24
27
  attempts: readonly AttemptReport[];
28
+ setup?: AgentSetupSummary;
25
29
  }
26
30
  export interface WorkflowReport {
27
31
  name: string;
@@ -34,6 +38,14 @@ export interface WorkflowReport {
34
38
  cost: number;
35
39
  models: readonly ModelUsage[];
36
40
  agents: readonly AgentReport[];
41
+ budget?: PersistedRun["budget"];
42
+ budgetVersion?: number;
43
+ usage?: PersistedRun["usage"];
44
+ budgetEvents?: PersistedRun["budgetEvents"];
45
+ events?: readonly {
46
+ type: string;
47
+ message: string;
48
+ }[];
37
49
  }
38
50
  export interface SessionReport {
39
51
  id: string;
@@ -4,7 +4,7 @@ import { emitKeypressEvents } from "node:readline";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { stdin, stdout } from "node:process";
6
6
  import { highlightCode, initTheme, SessionManager, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
7
- import { inspectWorkflowScript } from "./index.js";
7
+ import { formatBudgetStatus, inspectWorkflowScript } from "./index.js";
8
8
  import { listRunIds, RunStore } from "./persistence.js";
9
9
  function text(content) {
10
10
  if (typeof content === "string")
@@ -170,6 +170,7 @@ async function agentReport(agent) {
170
170
  cost,
171
171
  models: log.models.length ? log.models : [{ model, cost }],
172
172
  ...(attempt.error ? { error: `${attempt.error.code}: ${attempt.error.message}` } : {}),
173
+ ...(attempt.setup ? { setup: attempt.setup } : {}),
173
174
  });
174
175
  continue;
175
176
  }
@@ -182,6 +183,7 @@ async function agentReport(agent) {
182
183
  cost,
183
184
  models: [{ model: fallbackModel, cost }],
184
185
  ...(attempt.error ? { error: `${attempt.error.code}: ${attempt.error.message}` } : {}),
186
+ ...(attempt.setup ? { setup: attempt.setup } : {}),
185
187
  });
186
188
  }
187
189
  if (!attempts.length) {
@@ -189,7 +191,7 @@ async function agentReport(agent) {
189
191
  attempts.push({ attempt: 1, prompt: "(transcript unavailable)", model: fallbackModel, ...(fallbackThinking !== undefined ? { thinking: fallbackThinking } : {}), cost, models: [{ model: fallbackModel, cost }] });
190
192
  }
191
193
  const latest = attempts[attempts.length - 1];
192
- return { name: agent.name, ...(agent.label ? { label: agent.label } : {}), state: agent.state, ...(agent.role ? { role: agent.role } : {}), model: latest?.model ?? fallbackModel, ...(latest?.thinking !== undefined ? { thinking: latest.thinking } : {}), cost: attempts.reduce((sum, attempt) => sum + attempt.cost, 0), attempts };
194
+ return { name: agent.name, ...(agent.label ? { label: agent.label } : {}), state: agent.state, ...(agent.role ? { role: agent.role } : {}), ...(agent.requestedModel ? { requestedModel: agent.requestedModel } : {}), model: latest?.model ?? fallbackModel, ...(latest?.thinking !== undefined ? { thinking: latest.thinking } : {}), cost: attempts.reduce((sum, attempt) => sum + attempt.cost, 0), attempts, ...(latest?.setup ? { setup: latest.setup } : {}) };
193
195
  }
194
196
  export function matchSession(query, sessions) {
195
197
  const exact = sessions.filter(({ id }) => id === query);
@@ -242,6 +244,11 @@ export async function loadSessionReport(path, home = homedir()) {
242
244
  cost: agents.reduce((sum, agent) => sum + agent.cost, 0),
243
245
  models,
244
246
  agents,
247
+ ...(loaded?.run.budget ? { budget: loaded.run.budget } : {}),
248
+ ...(loaded?.run.budgetVersion !== undefined ? { budgetVersion: loaded.run.budgetVersion } : {}),
249
+ ...(loaded?.run.usage ? { usage: loaded.run.usage } : {}),
250
+ ...(loaded?.run.budgetEvents ? { budgetEvents: loaded.run.budgetEvents } : {}),
251
+ ...(loaded?.run.events?.length ? { events: loaded.run.events } : {})
245
252
  });
246
253
  }
247
254
  const workflowCost = workflows.reduce((sum, workflow) => sum + workflow.cost, 0);
@@ -263,7 +270,9 @@ function detailLines(workflow) {
263
270
  style(ansi.bold + ansi.cyan, workflow.name),
264
271
  `${workflow.status} · ${money(workflow.cost)}${workflow.runId ? ` · ${workflow.runId}` : ""}`,
265
272
  workflow.description ?? "",
273
+ ...(workflow.events?.length ? ["", style(ansi.bold, "Run events"), ...workflow.events.map((event) => `${event.type}: ${event.message}`)] : []),
266
274
  "",
275
+ ...(workflow.budget ? formatBudgetStatus({ budget: workflow.budget, ...(workflow.budgetVersion !== undefined ? { budgetVersion: workflow.budgetVersion } : {}), ...(workflow.usage ? { usage: workflow.usage } : {}), ...(workflow.budgetEvents ? { budgetEvents: workflow.budgetEvents } : {}) }).map((line) => `Budget ${line}`) : []),
267
276
  style(ansi.bold, "Models"),
268
277
  modelSummary(workflow.models),
269
278
  "",
@@ -278,9 +287,18 @@ function detailLines(workflow) {
278
287
  if (!workflow.agents.length)
279
288
  lines.push("(no agent run was persisted)");
280
289
  for (const agent of workflow.agents) {
281
- lines.push("", style(agent.state === "completed" ? ansi.green : agent.state === "failed" ? ansi.red : ansi.yellow, `${agent.label ?? agent.name} [${agent.state}]`), `${agent.role ? `role=${agent.role} · ` : ""}${agent.model}${agent.thinking !== undefined ? `:${agent.thinking}` : ""} · ${money(agent.cost)}`);
290
+ lines.push("", style(agent.state === "completed" ? ansi.green : agent.state === "failed" ? ansi.red : ansi.yellow, `${agent.label ?? agent.name} [${agent.state}]`), `${agent.role ? `role=${agent.role} · ` : ""}${agent.requestedModel ? `requested=${agent.requestedModel} · ` : ""}${agent.model}${agent.thinking !== undefined ? `:${agent.thinking}` : ""} · ${money(agent.cost)}`);
282
291
  for (const attempt of agent.attempts) {
283
- lines.push(`Attempt ${String(attempt.attempt)} · ${attempt.model}${attempt.thinking !== undefined ? `:${attempt.thinking}` : ""} · ${money(attempt.cost)}${attempt.error ? ` · ${attempt.error}` : ""}`, `Prompt: ${attempt.prompt}`);
292
+ lines.push(`Attempt ${String(attempt.attempt)} · ${attempt.model}${attempt.thinking !== undefined ? `:${attempt.thinking}` : ""} · ${money(attempt.cost)}${attempt.error ? ` · ${attempt.error}` : ""}`, `Prompt: ${attempt.prompt}`, ...(attempt.setup ? [
293
+ `Hooks: ${attempt.setup.hookNames.join(", ") || "(none)"}`,
294
+ `Effective: model=${attempt.setup.model.provider}/${attempt.setup.model.model}${attempt.setup.model.thinking ? `:${attempt.setup.model.thinking}` : ""} tools=${attempt.setup.tools.join(",") || "(none)"} cwd=${attempt.setup.cwd}`,
295
+ ...(attempt.setup.disabledAgentResources ? [
296
+ `Disabled skills: ${attempt.setup.disabledAgentResources.skills.join(", ") || "(none)"}`,
297
+ `Disabled extensions: ${attempt.setup.disabledAgentResources.extensions.join(", ") || "(none)"}`,
298
+ `Unmatched skills: ${attempt.setup.disabledAgentResources.unmatchedSkills.join(", ") || "(none)"}`,
299
+ `Unmatched extensions: ${attempt.setup.disabledAgentResources.unmatchedExtensions.join(", ") || "(none)"}`,
300
+ ] : []),
301
+ ] : []));
284
302
  }
285
303
  }
286
304
  return lines.filter((line, index) => line || index !== 2);
@@ -252,6 +252,7 @@ export interface CaptureCaseInput {
252
252
  maxCost: number;
253
253
  }
254
254
  export declare function parseSemanticJudge(raw: string, criteria: readonly SemanticCriterion[]): CriterionResult[];
255
+ export declare function findSessionFile(directory: string, sessionId: string): string | undefined;
255
256
  export declare function captureEvalCase(input: CaptureCaseInput): Promise<EvalCaseResult>;
256
257
  export interface IsolatedProcessOptions {
257
258
  childPath: string;
@@ -8,13 +8,12 @@ import { Value } from "typebox/value";
8
8
  import { getAgentDir, parseFrontmatter, SessionManager } from "@earendil-works/pi-coding-agent";
9
9
  import { CAPTURE_ERROR_PREFIX, CAPTURE_IDENTITY, resolveWorkflowSkillPath } from "./eval-capture-extension.js";
10
10
  export { resolveWorkflowSkillPath } from "./eval-capture-extension.js";
11
- import { ERROR_CODES, inspectWorkflowScript, loadAgentDefinitions, runWorkflow, WorkflowError } from "./index.js";
11
+ import { ERROR_CODES, inspectWorkflowScript, isObject, loadAgentDefinitions, runWorkflow, WORKFLOW_CALL_KINDS, WorkflowError } from "./index.js";
12
12
  const CASE_PROCESS_GRACE_MS = 1_000;
13
13
  export const SAFE_PARENT_EVAL_TOOLS = Object.freeze(["read", "grep", "find", "bash", "workflow"]);
14
14
  const EVAL_MODEL_TOKEN = "$EVAL_MODEL";
15
15
  const semantic = (description) => [{ id: "intent", description }];
16
16
  const JSON_RESULT_TYPES = ["null", "boolean", "number", "integer", "string", "array", "object"];
17
- const WORKFLOW_CALL_KINDS = ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"];
18
17
  const AGENT_OPTION_NAMES = ["role", "model", "thinking", "tools", "retries"];
19
18
  const expectationKeys = ["firstSignificantAction", "firstTool", "firstBatchToolSequence", "parentToolSequence", "workflowCallCount", "requiredOperations", "forbiddenOperations", "requiredRoles", "minimumAgentCalls", "requireOutputSchema", "expectedResults", "agentPolicies", "requiredAgentOrder", "requiredAgentStructures", "requiredDataFlow"];
20
19
  const caseKeys = ["id", "prompt", "timeoutMs", "maxCost", "expectations", "expectedWorkflowCalls", "semanticCriteria"];
@@ -234,7 +233,6 @@ catch (error) {
234
233
  cases.push(candidate);
235
234
  } return Object.freeze(cases); }
236
235
  export const INITIAL_WORKFLOW_EVAL_CASES = loadWorkflowEvalCases();
237
- function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
238
236
  function isJson(value) { if (value === null || typeof value === "string" || typeof value === "boolean")
239
237
  return true; if (typeof value === "number")
240
238
  return Number.isFinite(value); if (Array.isArray(value))
@@ -945,6 +943,27 @@ function seedEvalProject(cwd, home, model) {
945
943
  writeFileSync(path, `${content.slice(0, frontmatterEnd).replace(/^model:.*$/m, `model: ${model}`)}${content.slice(frontmatterEnd)}`);
946
944
  }
947
945
  }
946
+ export function findSessionFile(directory, sessionId) {
947
+ if (!existsSync(directory))
948
+ return undefined;
949
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
950
+ const path = join(directory, entry.name);
951
+ if (entry.isDirectory()) {
952
+ const found = findSessionFile(path, sessionId);
953
+ if (found)
954
+ return found;
955
+ }
956
+ else if (entry.name.endsWith(".jsonl")) {
957
+ try {
958
+ const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
959
+ if (isObject(header) && header.id === sessionId)
960
+ return path;
961
+ }
962
+ catch { /* Ignore incomplete sessions. */ }
963
+ }
964
+ }
965
+ return undefined;
966
+ }
948
967
  async function findParentSession(cwd, sessionDir, sessionId) {
949
968
  try {
950
969
  const sessions = await SessionManager.list(cwd, sessionDir);
@@ -953,28 +972,7 @@ async function findParentSession(cwd, sessionDir, sessionId) {
953
972
  return found.path;
954
973
  }
955
974
  catch { /* Fall through to the JSONL scan. */ }
956
- const visit = (directory) => {
957
- if (!existsSync(directory))
958
- return undefined;
959
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
960
- const path = join(directory, entry.name);
961
- if (entry.isDirectory()) {
962
- const found = visit(path);
963
- if (found)
964
- return found;
965
- }
966
- else if (entry.name.endsWith(".jsonl")) {
967
- try {
968
- const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
969
- if (isObject(header) && header.id === sessionId)
970
- return path;
971
- }
972
- catch { /* Ignore incomplete files. */ }
973
- }
974
- }
975
- return undefined;
976
- };
977
- return visit(sessionDir);
975
+ return findSessionFile(sessionDir, sessionId);
978
976
  }
979
977
  function emptyAccounting(cost = 0) { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost, models: [] }; }
980
978
  function emptyMetrics(requiredWorkflowCallCount = 1) { return { parentUsageThroughCandidate: null, parentOutputTokensThroughCandidate: null, nonWorkflowToolSequenceBeforeCandidate: [], nonWorkflowToolCallCountBeforeCandidate: 0, workflowCallCountBeforeCandidate: 0, invalidWorkflowCallCount: 0, productionValidationErrorCodes: [], candidateCallIndices: [], staticCandidates: [], semanticCriteria: [], anyValidCandidate: false, requiredWorkflowCallCount, surplusWorkflowCallCount: 0 }; }
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "pi-extensible-workflows",
3
- "version": "0.3.2",
3
+ "version": "1.0.1",
4
4
  "description": "Deterministic multi-agent workflow orchestration for Pi",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
- "repository": { "type": "git", "url": "git+https://github.com/vekexasia/pi-extensible-workflows.git" },
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/vekexasia/pi-extensible-workflows.git"
9
+ },
7
10
  "type": "module",
8
11
  "keywords": [
9
12
  "pi-package",
@@ -34,10 +34,11 @@ return agent(
34
34
 
35
35
  To pass structured input from the main agent, include `args`:
36
36
  ```json
37
- { "workflow": "namespace.workflowName", "args": { "issue": 42 } }
37
+ { "workflow": "workflowName", "args": { "issue": 42 } }
38
38
  ```
39
39
  Inside the workflow, read `args.issue`; omitted `args` is `null`.
40
- If `workflow_catalog` is available, call it once before creating the first workflow for a task. Use the returned global functions, variables, and registered workflows as needed for the rest of that task. Do not try to reinvent already exposed functions.
40
+ Use `workflow_stop` with the exact run ID to stop an active background run from the current Pi session.
41
+ If `workflow_catalog` is available, call it once before creating the first workflow for a task. Use the returned global functions, variables, registered workflows, and configured model aliases as needed for the rest of that task. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
41
42
 
42
43
  Pass downstream only needed results. Workflow JavaScript has no imports, filesystem, network, process, or timers; delegate such work to agents with the required tools.
43
44
 
@@ -46,7 +47,7 @@ Pass downstream only needed results. Workflow JavaScript has no imports, filesys
46
47
  ```typescript
47
48
  interface AgentOptions {
48
49
  label?: string; // optional non-empty display name
49
- model?: `${provider}/${model}` | `${provider}/${model}:${thinking}`;
50
+ model?: string; // configured alias or provider/model[:thinking]
50
51
  thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
51
52
  role?: string; // one of the available workflow roles
52
53
  tools?: string[]; // [] = no tools; omitted uses role or launch tools
@@ -56,7 +57,9 @@ interface AgentOptions {
56
57
  }
57
58
  ```
58
59
 
59
- Agent calls are unnamed. Direct `agent(...)` calls receive hidden source call-site identity; aliases are unsupported. Calls from one source call site must not race outside `parallel` or `pipeline`, whose structural keys keep replay deterministic.
60
+ Extensions may define additional JSON-compatible agent option keys such as `advisor: true`. Core-owned keys still use the validation and role constraints above; extension options are passed to setup hooks and native setup but are not inherited by child agents.
61
+
62
+ Agent calls are unnamed. Direct `agent(...)` calls receive hidden source call-site identity; JavaScript aliases for workflow calls are unsupported. Calls from one source call site must not race outside `parallel` or `pipeline`, whose structural keys keep replay deterministic.
60
63
 
61
64
  ## Shared worktree scope
62
65
 
@@ -89,7 +92,11 @@ const results = await parallel("implementation", {
89
92
  });
90
93
  ```
91
94
 
92
- Registered extension functions receive `withWorktree` in their context, so they may create a shared scope internally. Their public inputs and outputs must remain JSON; callbacks cannot cross the extension-function boundary.
95
+ Registered extension functions receive `withWorktree` in their context, so they may create a shared scope internally. They can compose other registered functions without importing their source:
96
+ ```ts
97
+ const report = await context.invoke("reviewRepository", { focus: "security" });
98
+ ```
99
+ Their public inputs and outputs must remain JSON; callbacks cannot cross the extension-function boundary.
93
100
 
94
101
  ## Rules
95
102
 
@@ -101,7 +108,8 @@ Registered extension functions receive `withWorktree` in their context, so they
101
108
  - Preserve item metadata in workflow code between pipeline stages instead of requiring agents to echo it through `outputSchema`.
102
109
  - Repeated work uses a JavaScript loop; each direct `agent(...)` call receives deterministic call-site and occurrence identity.
103
110
  - Runs default to background; set tool-call `foreground: true` when asked to wait.
104
- - Omit `maxAgentLaunches` unless an explicit total launch budget is required.
111
+ - Add `budget` only when the run needs aggregate limits. The only valid dimension names are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches` (never `cost`, `duration`, `launches`, or other shorthand). Each dimension is `{ soft?: number, hard?: number }`; `soft` must be less than `hard`.
112
+ - A `budget_exhausted` run is resumable through `workflow_resume`. Omitted patch values stay unchanged, explicit `null` removes a limit, and any relaxation requires an exact human-approved proposal through `workflow_respond`.
105
113
  - `parallel()` and `pipeline()` return keyed bare values. Await results before use.
106
114
  - Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings stay literal.
107
115
  - Use `outputSchema` only when another phase must compare, aggregate, or validate the result. Never add it to a final agent whose prose is returned directly. Keep only fields the consumer needs, and avoid repeating the same evidence in multiple schemas.