@signalridge/pi-workflows 1.2.0 → 1.3.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +142 -17
  3. package/package.json +14 -8
  4. package/skills/workflow-authoring/SKILL.md +30 -0
  5. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  6. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  7. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  8. package/skills/workflow-authoring/examples/defensive-json-parsing.js +63 -0
  9. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  10. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  11. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  12. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  13. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  14. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  15. package/skills/workflow-authoring/examples/tournament.js +81 -0
  16. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  17. package/skills/workflow-authoring/references/capabilities.md +44 -0
  18. package/skills/workflow-authoring/references/capability-details.md +294 -0
  19. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  20. package/skills/workflow-authoring/references/debugging.md +29 -0
  21. package/skills/workflow-authoring/references/focused-recipes.md +12 -0
  22. package/skills/workflow-authoring/references/helpers.md +9 -0
  23. package/skills/workflow-authoring/references/lifecycle.md +37 -0
  24. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  25. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  26. package/skills/workflow-authoring/references/registry-ownership.md +17 -0
  27. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  28. package/skills/workflow-authoring/references/review.md +43 -0
  29. package/skills/workflow-authoring/references/runtime.md +29 -0
  30. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  31. package/skills/workflow-authoring/references/versions.md +13 -0
  32. package/skills/workflow-patterns/SKILL.md +51 -0
  33. package/src/arming.ts +69 -0
  34. package/src/builtins.ts +331 -0
  35. package/src/capabilities.ts +318 -0
  36. package/src/code-review-scope.ts +144 -0
  37. package/src/engine.ts +1158 -2561
  38. package/src/errors.ts +136 -0
  39. package/src/index.ts +750 -103
  40. package/src/journal.ts +594 -1001
  41. package/src/rpc-client.ts +95 -24
  42. package/src/runtime.ts +1708 -0
  43. package/src/saved-workflows.ts +121 -0
  44. package/src/shared-store.ts +132 -0
  45. package/src/ui.ts +77 -40
  46. package/src/workflow-settings.ts +100 -0
  47. package/src/schema.ts +0 -215
  48. package/src/state-machine.ts +0 -79
  49. package/src/synthesis.ts +0 -140
package/src/index.ts CHANGED
@@ -1,67 +1,61 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
3
4
  import { Text } from "@earendil-works/pi-tui";
4
5
  import { Type } from "@sinclair/typebox";
5
- import { WorkflowEngine, type WorkflowQuiesceResult } from "./engine.js";
6
+ import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMED_DIRECTIVE } from "./arming.js";
7
+ import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
8
+ import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
9
+ import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
6
10
  import { JOURNAL_ENTRY_TYPE, type SessionEntryLike } from "./journal.js";
7
11
  import { createManagedSpawnClient, PROTOCOL_DIAGNOSTIC, queryChildSessionContextImmediate } from "./rpc-client.js";
8
- import { validateWorkflow } from "./schema.js";
9
- import { showWorkflowNavigator } from "./ui.js";
12
+ import { listSavedWorkflows, loadSavedWorkflow, saveWorkflow } from "./saved-workflows.js";
13
+ import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
14
+ import { loadWorkflowSettings, saveWorkflowSettings } from "./workflow-settings.js";
10
15
 
11
- const WorkflowTierSchema = Type.Union([Type.Literal("small"), Type.Literal("medium"), Type.Literal("large")]);
12
-
13
- const PhaseSchema = Type.Object(
14
- {
15
- id: Type.String({ minLength: 1, maxLength: 128 }),
16
- title: Type.String({ minLength: 1, maxLength: 512 }),
17
- },
18
- { additionalProperties: false },
19
- );
20
-
21
- const TaskSchema = Type.Object(
22
- {
23
- id: Type.String({ minLength: 1, maxLength: 128 }),
24
- phase: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
25
- tier: Type.Optional(WorkflowTierSchema),
26
- subagent_type: Type.String({ minLength: 1, maxLength: 128 }),
27
- description: Type.String({ minLength: 1, maxLength: 512 }),
28
- prompt: Type.String({ minLength: 1, maxLength: 100_000 }),
29
- depends_on: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
30
- inputs: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
31
- },
32
- { additionalProperties: false },
33
- );
34
-
35
- const SynthesisSchema = Type.Object(
16
+ const WorkflowToolSchema = Type.Object(
36
17
  {
37
- subagent_type: Type.String({ minLength: 1, maxLength: 128 }),
38
- tier: Type.Optional(WorkflowTierSchema),
39
- prompt: Type.String({ minLength: 1, maxLength: 100_000 }),
40
- },
41
- { additionalProperties: false },
42
- );
43
-
44
- const WorkflowSchema = Type.Object(
45
- {
46
- name: Type.String({ minLength: 1, maxLength: 512 }),
47
- description: Type.Optional(Type.String({ maxLength: 100_000 })),
48
- tier: Type.Optional(WorkflowTierSchema),
49
- phases: Type.Optional(Type.Array(PhaseSchema, { maxItems: 32 })),
50
- tasks: Type.Array(TaskSchema, { minItems: 1, maxItems: 128 }),
51
- synthesis: Type.Optional(SynthesisSchema),
18
+ script: Type.Optional(Type.String({ maxLength: 200_000 })),
19
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
20
+ args: Type.Optional(Type.Any()),
52
21
  background: Type.Optional(Type.Boolean()),
22
+ maxAgents: Type.Optional(Type.Number({ minimum: 1 })),
23
+ concurrency: Type.Optional(Type.Number({ minimum: 1 })),
24
+ agentRetries: Type.Optional(Type.Number({ minimum: 0, maximum: 3 })),
25
+ tokenBudget: Type.Optional(Type.Number({ minimum: 1 })),
26
+ agentTimeoutMs: Type.Optional(Type.Number({ minimum: 1 })),
27
+ resumeFromRunId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
53
28
  },
54
29
  { additionalProperties: false },
55
30
  );
56
31
 
57
32
  const ControlSchema = Type.Object(
58
33
  {
59
- action: Type.String({ minLength: 1, maxLength: 32 }),
34
+ action: Type.Union(
35
+ [
36
+ Type.Literal("list", { description: "List all workflow runs" }),
37
+ Type.Literal("get", { description: "Get details for one run" }),
38
+ Type.Literal("status", { description: "Alias for get — inspect one run" }),
39
+ Type.Literal("pause", { description: "Pause a running workflow" }),
40
+ Type.Literal("resume", { description: "Resume a paused/interrupted workflow" }),
41
+ Type.Literal("stop", { description: "Stop a running workflow" }),
42
+ Type.Literal("rm", { description: "Remove a workflow run and its journal" }),
43
+ ],
44
+ { description: "Control action" },
45
+ ),
60
46
  run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
47
+ // Accept camelCase and legacy workflow_run_id for interop with pi-dynamic-workflows docs
48
+ runId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
49
+ workflow_run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
61
50
  },
62
51
  { additionalProperties: false },
63
52
  );
64
53
 
54
+ function normalizeControlRunId(params: Record<string, unknown>): string | undefined {
55
+ const raw = params.run_id ?? params.runId ?? params.workflow_run_id ?? params.runID ?? params.workflowRunId;
56
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
57
+ }
58
+
65
59
  function textResult(text: string, details: unknown = {}) {
66
60
  return {
67
61
  content: [{ type: "text" as const, text }],
@@ -69,14 +63,50 @@ function textResult(text: string, details: unknown = {}) {
69
63
  };
70
64
  }
71
65
 
72
- function formatStart(result: {
73
- runId: string;
74
- status: string;
75
- background: boolean;
76
- result?: string;
77
- error?: string;
78
- waitAborted?: boolean;
79
- }): string {
66
+ /**
67
+ * Run one command for diff discovery. argv array, never a shell string — a
68
+ * branch name or path reaches this from user input.
69
+ */
70
+ function runCommand(file: string, args: string[]): CommandResult {
71
+ try {
72
+ const stdout = execFileSync(file, args, {
73
+ encoding: "utf8",
74
+ maxBuffer: 32 * 1024 * 1024,
75
+ stdio: ["ignore", "pipe", "ignore"],
76
+ });
77
+ return { ok: true, stdout };
78
+ } catch {
79
+ // Non-zero exit or a missing binary. Either way there is no usable diff;
80
+ // the caller decides whether to degrade or report.
81
+ return { ok: false, stdout: "" };
82
+ }
83
+ }
84
+
85
+ /** Control subcommands of `/workflows` that take a run id. */
86
+ export type WorkflowControlSubcommand = "stop" | "pause" | "resume" | "rm";
87
+
88
+ /**
89
+ * Resolve which run a `/workflows <subcommand>` invocation acts on.
90
+ *
91
+ * `stop` and `rm` are destructive — stopping kills the run's owned agents, and
92
+ * `rm` deletes the run and its journal — so they take no implicit target: a
93
+ * bare `/workflows rm` must never act on whatever `list()` happens to return
94
+ * first. `pause` and `resume` are reversible, so they keep the convenience
95
+ * default for the common single-run case.
96
+ *
97
+ * Returns the empty string when the caller must be asked for an explicit id.
98
+ */
99
+ export function resolveControlTarget(
100
+ subcommand: WorkflowControlSubcommand,
101
+ explicitRunId: string,
102
+ firstListedRunId: string | undefined,
103
+ ): string {
104
+ if (explicitRunId) return explicitRunId;
105
+ if (subcommand === "stop" || subcommand === "rm") return "";
106
+ return firstListedRunId ?? "";
107
+ }
108
+
109
+ function formatStart(result: ScriptStartResult): string {
80
110
  const lines = [
81
111
  `Workflow ${result.background ? "started in background" : result.status}: ${result.runId}`,
82
112
  `status=${result.status}`,
@@ -94,16 +124,62 @@ export default function piWorkflows(pi: ExtensionAPI): void {
94
124
  let protocolCheck: Promise<void> = Promise.resolve();
95
125
  let active = false;
96
126
  let unsubscribeTreeSignal: (() => void) | undefined;
97
- let branchQuiesce: Promise<WorkflowQuiesceResult> | undefined;
127
+ let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
98
128
  let lifecycleGeneration = 0;
99
- let branchGeneration = 0;
100
129
  let protocolAbortController: AbortController | undefined;
130
+ let sessionCwd = process.cwd();
131
+ let workflowSettings = loadWorkflowSettings(sessionCwd);
132
+ let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
133
+ /** Per-session delivery endpoint for background-run results (A10 fail-closed). */
134
+ let deliverResult: ((text: string, details?: unknown) => void) | undefined;
135
+ /** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
136
+ const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
137
+ const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
138
+ const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
139
+ const MAX_PENDING_DELIVERIES = 256;
140
+ const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
141
+ let widgetTimer: ReturnType<typeof setInterval> | undefined;
101
142
 
102
143
  const currentEngine = (): WorkflowEngine => {
103
144
  if (!engine) throw new Error("pi-workflows is not active in this session context");
104
145
  return engine;
105
146
  };
106
147
 
148
+ const branchEntries = (): SessionEntryLike[] => {
149
+ const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
150
+ try {
151
+ return (ctx?.getBranch?.() as SessionEntryLike[] | undefined) ?? [];
152
+ } catch {
153
+ return [];
154
+ }
155
+ };
156
+
157
+ const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
158
+
159
+ const resolveScript = (
160
+ script: string | undefined,
161
+ name: string | undefined,
162
+ args: unknown,
163
+ ): { script: string; source: string; toolset?: string } => {
164
+ if (script !== undefined && name !== undefined) {
165
+ throw new Error("Provide either `script` or `name`, not both.");
166
+ }
167
+ if (script !== undefined) return { script, source: "inline" };
168
+ if (name !== undefined) {
169
+ const saved = loadSavedWorkflow(name, sessionCwd);
170
+ if (saved) return { script: saved, source: `saved:${name}` };
171
+ const builtin = BUILTIN_WORKFLOWS[name];
172
+ if (builtin) {
173
+ // Validate named invocations before starting a potentially expensive fleet.
174
+ // Slash commands provide the same shape through their descriptor primaryArg.
175
+ validateBuiltinArgs(name, args);
176
+ return { script: builtin.script, source: `builtin:${name}`, toolset: builtin.toolset };
177
+ }
178
+ throw new Error(`Unknown workflow name "${name}". It is neither a saved workflow nor a built-in.`);
179
+ }
180
+ throw new Error("Provide either `script` or `name`.");
181
+ };
182
+
107
183
  const registerSurface = (): void => {
108
184
  if (active) return;
109
185
  active = true;
@@ -114,16 +190,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
114
190
  name: "workflow",
115
191
  label: "Workflow",
116
192
  description:
117
- "Run a strict declarative task DAG through pi-subagents. Workflows submit subagent_type, prompt, description, and an optional semantic tier; execution policy belongs to pi-subagents.",
118
- promptSnippet: "Run independent tasks as a dependency-aware workflow",
193
+ "Run a JavaScript workflow script through the pi-subagents managed spawning protocol. The script declares `export const meta = { name, description, phases }` first and uses the runtime globals agent(), parallel(), pipeline(), workflow(), verify(), judgePanel(), loopUntilDry(), completenessCheck(), retry(), gate(), checkpoint(), phase(), log(), args, cwd, process, and budget. Determinism is enforced: Date.now()/Math.random()/new Date() are unavailable.",
194
+ promptSnippet: "Run a JavaScript workflow script",
119
195
  promptGuidelines: [
120
- "Use workflow for explicit multi-task DAG orchestration; an optional tier must be small, medium, or large. Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings.",
121
- "depends_on controls ordering only. To pass a dependency's result into a task, also list it in inputs, which must be a subset of that task's depends_on; those results are appended to the task prompt under a bounded budget.",
122
- "Use workflow_control to inspect, pause, resume, or stop a background workflow.",
196
+ "Provide either a full `script` (raw JavaScript with the meta contract first) or a `name` of a saved/built-in workflow.",
197
+ "Use parallel(() => agent(...), ...) for concurrent agents; pipeline(items, ...stages) for sequential per-item stages.",
198
+ "Use background: true for long runs; retrieve results with workflow_control.",
199
+ "Use resumeFromRunId with an edited script to replay the unchanged prefix from cache and re-run the rest live.",
200
+ "Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings beyond the declared options.",
123
201
  ],
124
- parameters: WorkflowSchema,
202
+ parameters: WorkflowToolSchema,
125
203
  renderCall(args, theme) {
126
- return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${args.name ?? ""}`)), 0, 0);
204
+ const label = args.name ?? (args.script !== undefined ? "script" : "");
205
+ return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${label}`)), 0, 0);
127
206
  },
128
207
  renderResult(result, { expanded, isPartial }, theme) {
129
208
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -138,7 +217,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
138
217
  0,
139
218
  );
140
219
  },
141
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
220
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
142
221
  try {
143
222
  await protocolCheck;
144
223
  } catch (error: unknown) {
@@ -147,8 +226,54 @@ export default function piWorkflows(pi: ExtensionAPI): void {
147
226
  `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
148
227
  );
149
228
  }
150
- const definition = validateWorkflow(params);
151
- const result = await workflowEngine.start(definition, signal);
229
+ const resolved =
230
+ params.resumeFromRunId && params.script === undefined && params.name === undefined
231
+ ? (() => {
232
+ const prior = workflowEngine.getRun(params.resumeFromRunId);
233
+ if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
234
+ return { script: prior.script, source: "resume", toolset: prior.toolset };
235
+ })()
236
+ : resolveScript(params.script, params.name, params.args);
237
+ const { script, source, toolset } = resolved;
238
+ const options: ScriptStartOptions = {
239
+ args: params.args,
240
+ background: params.background ?? true,
241
+ maxAgents: params.maxAgents,
242
+ concurrency: params.concurrency,
243
+ agentRetries: params.agentRetries,
244
+ tokenBudget: params.tokenBudget,
245
+ agentTimeoutMs: params.agentTimeoutMs,
246
+ toolset,
247
+ excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
248
+ signal,
249
+ mainModel: ctx?.model?.id,
250
+ // Foreground checkpoint confirmation only when the run is not
251
+ // background — background runs are headless by contract.
252
+ confirm:
253
+ params.background === false && ctx?.hasUI && ctx.mode === "tui"
254
+ ? async (promptText, checkpointOptions) => {
255
+ if (checkpointOptions.kind === "input")
256
+ return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
257
+ if (checkpointOptions.kind === "select")
258
+ return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
259
+ return ctx.ui.confirm(promptText, promptText);
260
+ }
261
+ : undefined,
262
+ loadSavedWorkflow: (name) => loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script,
263
+ };
264
+ if (params.resumeFromRunId) {
265
+ const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
266
+ const resumed = await workflowEngine.resume(params.resumeFromRunId, branchEntries(), options, replacement);
267
+ if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
268
+ if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
269
+ return textResult(formatStart(resumed), resumed);
270
+ }
271
+ const result = await workflowEngine.start(script, options);
272
+ if (result.background) {
273
+ // The engine resolves the script's meta name for the delivery header.
274
+ void source;
275
+ void deliverBackgroundResult(workflowEngine, result.runId);
276
+ }
152
277
  return textResult(formatStart(result), result);
153
278
  },
154
279
  }),
@@ -158,7 +283,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
158
283
  defineTool({
159
284
  name: "workflow_control",
160
285
  label: "Workflow Control",
161
- description: "List, inspect, pause, resume, or stop workflow runs with bounded machine-readable output.",
286
+ description:
287
+ "List, inspect, pause, resume, stop, or remove workflow runs with bounded machine-readable output.",
162
288
  parameters: ControlSchema,
163
289
  renderCall(args, theme) {
164
290
  return new Text(theme.fg("toolTitle", theme.bold(`Workflow control: ${args.action ?? ""}`)), 0, 0);
@@ -167,25 +293,385 @@ export default function piWorkflows(pi: ExtensionAPI): void {
167
293
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
168
294
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
169
295
  },
170
- async execute(_toolCallId, params) {
171
- const action = params.action as "list" | "get" | "pause" | "resume" | "stop";
172
- if (!["list", "get", "pause", "resume", "stop"].includes(action)) {
296
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
297
+ const rawAction = params.action as string;
298
+ const action = (rawAction === "status" ? "get" : rawAction) as
299
+ | "list"
300
+ | "get"
301
+ | "pause"
302
+ | "resume"
303
+ | "stop"
304
+ | "rm";
305
+ if (!["list", "get", "pause", "resume", "stop", "rm"].includes(action)) {
173
306
  throw new Error(`unknown workflow_control action: ${params.action}`);
174
307
  }
175
- const result = await workflowEngine.control(action, params.run_id);
308
+ const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
309
+ const result = await workflowEngine.control(action, runId);
176
310
  return textResult(JSON.stringify(result), result);
177
311
  },
178
312
  }),
179
313
  );
180
314
 
315
+ // /workflows: navigator + run/status/watch/stop/pause/resume/rm/save subcommands
181
316
  pi.registerCommand("workflows", {
182
- description: "Open the workflow run navigator",
317
+ description: "Workflow runs: open the navigator, or run/status/watch/stop/pause/resume/rm/save",
318
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
319
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
320
+ const [subcommand, ...rest] = tokens;
321
+ const restArgs = rest.join(" ").trim();
322
+ if (!subcommand) {
323
+ await showWorkflowNavigator(ctx, workflowEngine);
324
+ return;
325
+ }
326
+ if (subcommand === "run") {
327
+ if (!restArgs) {
328
+ ctx.ui.notify("/workflows run <prompt-or-script>", "warning");
329
+ return;
330
+ }
331
+ const runId = await runWorkflowFromPrompt(ctx, restArgs);
332
+ if (runId) ctx.ui.notify(`Workflow started in background: ${runId}`, "info");
333
+ return;
334
+ }
335
+ if (subcommand === "status") {
336
+ if (restArgs) {
337
+ const run = workflowEngine.getRun(restArgs);
338
+ if (!run) {
339
+ ctx.ui.notify(`Workflow run not found: ${restArgs}`, "warning");
340
+ return;
341
+ }
342
+ ctx.ui.notify(`${run.runId} · ${run.status} · ${String(run.meta?.name ?? run.runId)}`, "info");
343
+ return;
344
+ }
345
+ const runs = workflowEngine.list();
346
+ ctx.ui.notify(
347
+ runs.length === 0
348
+ ? "No workflow runs."
349
+ : runs.map((r) => `${r.runId} · ${r.status} · ${String(r.name)}`).join("\n"),
350
+ "info",
351
+ );
352
+ return;
353
+ }
354
+ if (subcommand === "watch") {
355
+ const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
356
+ if (!runId) {
357
+ ctx.ui.notify("No workflow runs to watch.", "warning");
358
+ return;
359
+ }
360
+ const run = await workflowEngine.waitFor(runId).catch(() => undefined);
361
+ if (run) ctx.ui.notify(`${run.runId} settled as ${run.status}`, "info");
362
+ return;
363
+ }
364
+ if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
365
+ const firstListed = workflowEngine.list()[0]?.runId;
366
+ const runId = resolveControlTarget(
367
+ subcommand,
368
+ restArgs,
369
+ firstListed === undefined ? undefined : String(firstListed),
370
+ );
371
+ if (!runId) {
372
+ const destructive = subcommand === "stop" || subcommand === "rm";
373
+ ctx.ui.notify(
374
+ destructive
375
+ ? `/workflows ${subcommand} <runId> — a run id is required; /workflows status lists them`
376
+ : `/workflows ${subcommand} <runId>`,
377
+ "warning",
378
+ );
379
+ return;
380
+ }
381
+ try {
382
+ await workflowEngine.control(subcommand, runId);
383
+ if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
384
+ ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
385
+ } catch (error: unknown) {
386
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
387
+ }
388
+ return;
389
+ }
390
+ if (subcommand === "save") {
391
+ const name = rest[0] ?? "";
392
+ if (!name) {
393
+ ctx.ui.notify("/workflows save <name> [runId]", "warning");
394
+ return;
395
+ }
396
+ // Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
397
+ const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
398
+ if (!runId) {
399
+ ctx.ui.notify("No workflow runs to save.", "warning");
400
+ return;
401
+ }
402
+ try {
403
+ await saveWorkflowFromRun(ctx, name, runId);
404
+ } catch (error: unknown) {
405
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
406
+ }
407
+ return;
408
+ }
409
+ ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
410
+ },
411
+ });
412
+
413
+ // /workflows-models: tier configuration is owned by pi-subagents; this
414
+ // command documents the delegation (A8: no second tier config file).
415
+ pi.registerCommand("workflows-models", {
416
+ description: "Workflow tier routing: tiers are configured in pi-subagents /agents → Model tiers",
183
417
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
184
- await showWorkflowNavigator(ctx, workflowEngine);
418
+ ctx.ui.notify(
419
+ "Workflow tiers (small/medium/large) route through pi-subagents' workflow.tiers settings. " +
420
+ "Use /agents → Model tiers to configure them.",
421
+ "info",
422
+ );
423
+ },
424
+ });
425
+
426
+ // /workflows-progress: persistent compact/detailed live-panel settings.
427
+ pi.registerCommand("workflows-progress", {
428
+ description: "Workflow progress panel: compact|detailed|status|max <N>",
429
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
430
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
431
+ const action = tokens[0] ?? "status";
432
+ if (action === "status") {
433
+ ctx.ui.notify(
434
+ `Workflow progress: ${workflowSettings.progressMode}, max agents shown: ${workflowSettings.maxAgentsShown}`,
435
+ "info",
436
+ );
437
+ return;
438
+ }
439
+ if (action === "compact" || action === "detailed") {
440
+ workflowSettings = { ...workflowSettings, progressMode: action };
441
+ } else if (action === "max") {
442
+ const max = Number(tokens[1]);
443
+ if (!Number.isInteger(max) || max < 1 || max > 32) {
444
+ ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
445
+ return;
446
+ }
447
+ workflowSettings = { ...workflowSettings, maxAgentsShown: max };
448
+ } else {
449
+ ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
450
+ return;
451
+ }
452
+ try {
453
+ saveWorkflowSettings(workflowSettings, sessionCwd);
454
+ refreshWidget();
455
+ ctx.ui.notify(
456
+ `Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
457
+ "info",
458
+ );
459
+ } catch (error: unknown) {
460
+ ctx.ui.notify(
461
+ `Could not save workflow progress settings: ${error instanceof Error ? error.message : String(error)}`,
462
+ "warning",
463
+ );
464
+ }
465
+ },
466
+ });
467
+
468
+ // /workflows-trigger: keyword arming (A5). Typing the bounded word
469
+ // `workflow`/`workflows` — or a configured synonym — in an ordinary message
470
+ // counts as an explicit opt-in to multi-agent orchestration, and the message
471
+ // is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
472
+ // and it never opens UI. A user asking a question about workflows still gets
473
+ // an ordinary answer.
474
+ let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
475
+ let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
476
+ const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
477
+ const persistTriggerSettings = (): void => {
478
+ workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
479
+ try {
480
+ saveWorkflowSettings(workflowSettings, sessionCwd);
481
+ } catch {
482
+ /* setting changes remain active in memory */
483
+ }
484
+ };
485
+ pi.registerCommand("workflows-trigger", {
486
+ description: "Arm the workflow tool on a keyword: set <keyword> | off | on | reset | status",
487
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
488
+ const token = args.trim().split(/\s+/)[0] ?? "";
489
+ if (token === "off") {
490
+ keywordTriggerEnabled = false;
491
+ persistTriggerSettings();
492
+ ctx.ui.notify("Workflow keyword trigger disabled.", "info");
493
+ return;
494
+ }
495
+ if (token === "on") {
496
+ keywordTriggerEnabled = true;
497
+ persistTriggerSettings();
498
+ ctx.ui.notify(`Workflow keyword trigger armed on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}".`, "info");
499
+ return;
500
+ }
501
+ if (token === "reset") {
502
+ triggerKeyword = undefined;
503
+ keywordTriggerEnabled = true;
504
+ persistTriggerSettings();
505
+ ctx.ui.notify(`Workflow keyword trigger reset to default "${DEFAULT_TRIGGER_WORD}".`, "info");
506
+ return;
507
+ }
508
+ if (token === "status") {
509
+ ctx.ui.notify(
510
+ `Workflow keyword trigger: ${keywordTriggerEnabled ? "armed" : "disabled"} on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}"`,
511
+ "info",
512
+ );
513
+ return;
514
+ }
515
+ const keyword = token === "set" ? (args.trim().split(/\s+/)[1] ?? "") : token;
516
+ if (!TRIGGER_WORD_PATTERN.test(keyword)) {
517
+ ctx.ui.notify("Usage: /workflows-trigger set <one-word-keyword> | off | on | reset | status", "warning");
518
+ return;
519
+ }
520
+ triggerKeyword = keyword.toLowerCase();
521
+ keywordTriggerEnabled = true;
522
+ persistTriggerSettings();
523
+ ctx.ui.notify(`Workflow keyword trigger armed on "${keyword}".`, "info");
524
+ },
525
+ });
526
+
527
+ // Keyword arming input hook (A5). The message is annotated rather than
528
+ // acted on: the model is told it may use the `workflow` tool and may still
529
+ // decline, which is what keeps "how do workflows work?" an ordinary
530
+ // question. Nothing is swallowed and no UI opens.
531
+ pi.on("input", (event) => {
532
+ if (event.source === "extension") return { action: "continue" as const };
533
+ const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(event.text, triggerKeyword);
534
+ const substantive = effortLevel !== "off" && event.text.trim().length >= 16 && !event.text.trim().startsWith("/");
535
+ if (!explicitTrigger && !substantive) return { action: "continue" as const };
536
+ if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
537
+ const effortDirective =
538
+ effortLevel === "ultra"
539
+ ? "Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks; choose the large tier where useful."
540
+ : effortLevel === "high"
541
+ ? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
542
+ : "";
543
+ return {
544
+ action: "transform" as const,
545
+ text: `${event.text}\n\n${WORKFLOW_ARMED_DIRECTIVE}${effortDirective ? `\n${effortDirective}` : ""}`,
546
+ ...(event.images ? { images: event.images } : {}),
547
+ };
548
+ });
549
+
550
+ // Standing effort mode mirrors the reference: it auto-arms substantive
551
+ // interactive messages but remains guidance, not an inferred spend ceiling.
552
+ pi.registerCommand("effort", {
553
+ description: "Standing workflow effort: off | high | ultra",
554
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
555
+ const level = args.trim().toLowerCase();
556
+ if (level !== "off" && level !== "high" && level !== "ultra") {
557
+ ctx.ui.notify(`Effort is currently "${effortLevel}". Usage: /effort off|high|ultra`, "info");
558
+ return;
559
+ }
560
+ effortLevel = level;
561
+ workflowSettings = { ...workflowSettings, effort: effortLevel };
562
+ try {
563
+ saveWorkflowSettings(workflowSettings, sessionCwd);
564
+ } catch {
565
+ /* warning is non-fatal */
566
+ }
567
+ ctx.ui.notify(
568
+ level === "off"
569
+ ? "Effort off — messages are no longer auto-armed."
570
+ : `Effort ${level} enabled for substantive messages.`,
571
+ "info",
572
+ );
573
+ },
574
+ });
575
+ pi.registerCommand("ultracode", {
576
+ description: "Ultracode: toggle exhaustive workflow effort",
577
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
578
+ effortLevel = args.trim().toLowerCase() === "off" ? "off" : "ultra";
579
+ workflowSettings = { ...workflowSettings, effort: effortLevel };
580
+ try {
581
+ saveWorkflowSettings(workflowSettings, sessionCwd);
582
+ } catch {
583
+ /* warning is non-fatal */
584
+ }
585
+ ctx.ui.notify(
586
+ effortLevel === "off"
587
+ ? "Ultracode off."
588
+ : "Ultracode on — substantive messages use exhaustive workflow guidance.",
589
+ "info",
590
+ );
185
591
  },
186
592
  });
187
593
 
188
- const beginBranchQuiesce = (): Promise<WorkflowQuiesceResult> => {
594
+ // Builtin workflow commands (A6). Saved workflows with the same name take
595
+ // precedence at resolution; the commands themselves still dispatch by name.
596
+ // Names already claimed by another extension are left alone — Pi cannot
597
+ // unregister or replace a command, so clobbering one is permanent.
598
+ const claimedCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
599
+ for (const name of Object.keys(BUILTIN_WORKFLOWS)) {
600
+ if (claimedCommands.has(name)) continue;
601
+ const descriptor = BUILTIN_WORKFLOWS[name];
602
+ pi.registerCommand(name, {
603
+ description: `Run the ${name} workflow`,
604
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
605
+ const text = args.trim();
606
+ // Each script reads its own named input; none reads a generic
607
+ // `prompt`, so the text goes in under `primaryArg`. `code-review`
608
+ // takes a diff rather than something typed, so it resolves one first.
609
+ let scriptArgs: Record<string, unknown> | undefined;
610
+ if (name === "code-review") {
611
+ const scope = resolveCodeReviewScope(text, runCommand);
612
+ for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
613
+ if (!scope.diff.trim()) return;
614
+ scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
615
+ } else {
616
+ scriptArgs = text ? { [descriptor.primaryArg]: text } : undefined;
617
+ }
618
+ try {
619
+ validateBuiltinArgs(name, scriptArgs);
620
+ } catch (error: unknown) {
621
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
622
+ return;
623
+ }
624
+ const options: ScriptStartOptions = {
625
+ args: scriptArgs,
626
+ background: true,
627
+ toolset: descriptor.toolset,
628
+ excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
629
+ loadSavedWorkflow: (savedName) =>
630
+ loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script,
631
+ };
632
+ const result = await workflowEngine.start(descriptor.script, options);
633
+ void deliverBackgroundResult(workflowEngine, result.runId);
634
+ ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
635
+ },
636
+ });
637
+ }
638
+
639
+ // Live progress widget (A10): renders only while runs are active, then
640
+ // clears itself. The widget is TUI-only; headless sessions skip it.
641
+ const refreshWidget = () => {
642
+ const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
643
+ if (!tui?.setWidget) return;
644
+ const runs = workflowEngine
645
+ .list()
646
+ .map((summary) => workflowEngine.getRun(String(summary.runId)))
647
+ .filter((run): run is NonNullable<typeof run> => run !== undefined);
648
+ const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
649
+ tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
650
+ if (lines.length > 0 && !widgetTimer) {
651
+ widgetTimer = setInterval(() => {
652
+ if (!tui?.setWidget) return;
653
+ const current = workflowEngine
654
+ .list()
655
+ .map((summary) => workflowEngine.getRun(String(summary.runId)))
656
+ .filter((run): run is NonNullable<typeof run> => run !== undefined);
657
+ const next = liveWidgetLines(current, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
658
+ tui.setWidget("pi-workflows", next.length > 0 ? next : undefined);
659
+ if (next.length === 0 && widgetTimer) {
660
+ clearInterval(widgetTimer);
661
+ widgetTimer = undefined;
662
+ }
663
+ }, 2_000);
664
+ } else if (lines.length === 0 && widgetTimer) {
665
+ clearInterval(widgetTimer);
666
+ widgetTimer = undefined;
667
+ }
668
+ };
669
+ // Refresh after any control action and on run settlement.
670
+ const notifyRunChanged = () => refreshWidget();
671
+ workflowEngine.onRunSettled = notifyRunChanged;
672
+ void notifyRunChanged;
673
+
674
+ const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
189
675
  if (!branchQuiesce) {
190
676
  branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
191
677
  if (!result.settled) {
@@ -211,18 +697,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
211
697
  try {
212
698
  entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
213
699
  } catch {
214
- // A reload may invalidate the old lifecycle context before the tree
215
- // callback is delivered. Do not let stale-context access escape.
216
700
  return;
217
701
  }
218
702
  workflowEngine.suspendLifecycle();
219
- branchGeneration += 1;
220
703
  try {
221
- workflowEngine.restore(entries, branchGeneration);
704
+ workflowEngine.restore(entries);
222
705
  } catch (error: unknown) {
223
- // Keep the lifecycle surface alive after a failed recovery append. The
224
- // engine rolls its candidate state back transactionally; a later tree
225
- // event can retry the same branch without leaving workflows inert.
226
706
  console.warn(
227
707
  `[pi-workflows] session tree recovery failed: ${error instanceof Error ? error.message : String(error)}`,
228
708
  );
@@ -233,20 +713,102 @@ export default function piWorkflows(pi: ExtensionAPI): void {
233
713
  });
234
714
  };
235
715
 
716
+ const rememberPendingDelivery = (runId: string, text: string, timestamp = Date.now()): void => {
717
+ if (!runId || timestamp + PENDING_DELIVERY_TTL_MS < Date.now()) return;
718
+ pendingDeliveryMarkers.set(runId, { text: text.slice(0, 8_000), timestamp });
719
+ for (const [id, marker] of pendingDeliveryMarkers) {
720
+ if (marker.timestamp + PENDING_DELIVERY_TTL_MS < Date.now()) pendingDeliveryMarkers.delete(id);
721
+ }
722
+ while (pendingDeliveryMarkers.size > MAX_PENDING_DELIVERIES) {
723
+ const oldest = [...pendingDeliveryMarkers.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0]?.[0];
724
+ if (!oldest) break;
725
+ pendingDeliveryMarkers.delete(oldest);
726
+ }
727
+ };
728
+
729
+ /** Background-run result delivery: fail-closed, session-bound (A10). */
730
+ const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
731
+ const run = await engine.waitFor(runId).catch(() => undefined);
732
+ if (!run) return;
733
+ const header = `Workflow ${run.runId} ${run.status}`;
734
+ const body = run.error ?? `completed with ${Object.keys(run.callResults).length} agent call(s).`;
735
+ const text = `${header}: ${body}`;
736
+ if (deliverResult) {
737
+ try {
738
+ deliverResult(text);
739
+ } catch {
740
+ // Endpoint suspended or send failed — leave pending for next bind (fail-closed).
741
+ rememberPendingDelivery(runId, text);
742
+ try {
743
+ pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
744
+ } catch {
745
+ // appendEntry is best-effort; in-memory marker still ensures flush on next bind
746
+ }
747
+ }
748
+ } else {
749
+ // No bound session endpoint — fail-closed: persist a pending marker and
750
+ // flush it when a session binds, never fall back to shared pi.sendMessage.
751
+ rememberPendingDelivery(runId, text);
752
+ try {
753
+ pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
754
+ } catch {
755
+ // best-effort persistence; in-memory marker still ensures retry
756
+ }
757
+ }
758
+ };
759
+
760
+ const flushPendingDeliveries = (): void => {
761
+ if (!deliverResult || pendingDeliveryMarkers.size === 0) return;
762
+ for (const [runId, pending] of [...pendingDeliveryMarkers]) {
763
+ try {
764
+ deliverResult(pending.text);
765
+ pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, { runId, timestamp: Date.now() });
766
+ pendingDeliveryMarkers.delete(runId);
767
+ } catch {
768
+ // Keep pending until both delivery and its durable acknowledgement succeed.
769
+ }
770
+ }
771
+ };
772
+
773
+ /** Build a script from a natural-language prompt and run it in background. */
774
+ const runWorkflowFromPrompt = async (ctx: ExtensionCommandContext, prompt: string): Promise<string | undefined> => {
775
+ // A bounded, deterministic script that dispatches the user's instruction
776
+ // through a general-purpose agent and returns its answer.
777
+ const script = `export const meta = { name: "ad-hoc", description: "Run a user-requested task with a general-purpose agent" };
778
+ const answer = await agent(${JSON.stringify(prompt)});
779
+ return answer;`;
780
+ try {
781
+ const result = await currentEngine().start(script, { background: true, mainModel: ctx.model?.id });
782
+ void deliverBackgroundResult(currentEngine(), result.runId);
783
+ return result.runId;
784
+ } catch (error: unknown) {
785
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
786
+ return undefined;
787
+ }
788
+ };
789
+
790
+ const saveWorkflowFromRun = async (ctx: ExtensionCommandContext, name: string, runId?: string): Promise<void> => {
791
+ const state = engine?.getState(runId ?? "");
792
+ if (!state) {
793
+ throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
794
+ }
795
+ saveWorkflow(name, state.run.script, sessionCwd);
796
+ ctx.ui.notify(`Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
797
+ };
798
+
236
799
  pi.on("session_start", async (_event, ctx) => {
237
800
  if (active || engine) return;
238
801
  const generation = ++lifecycleGeneration;
802
+ sessionCwd = ctx.cwd ?? process.cwd();
803
+ workflowSettings = loadWorkflowSettings(sessionCwd);
804
+ effortLevel = workflowSettings.effort;
239
805
  let initialEntries: SessionEntryLike[];
240
806
  try {
241
807
  initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
242
808
  } catch {
243
- // The lifecycle context may already be invalid during a reload race.
244
809
  return;
245
810
  }
246
811
 
247
- // Dispatch the formal child-context query synchronously. The event bus
248
- // delivers the reply synchronously, which lets us filter child sessions
249
- // before registering any Pi tools while keeping startup nonblocking.
250
812
  let child: boolean | undefined;
251
813
  try {
252
814
  child = queryChildSessionContextImmediate(pi.events);
@@ -256,27 +818,31 @@ export default function piWorkflows(pi: ExtensionAPI): void {
256
818
  if (child === true) return;
257
819
 
258
820
  protocolAbortController = new AbortController();
259
- // Tool-call signals only cancel the foreground wait. Managed RPCs use this
260
- // session-lifetime controller so a host with no ctx.signal still cancels
261
- // outstanding requests during session_shutdown.
262
821
  const client = createManagedSpawnClient(pi.events, protocolAbortController.signal, protocolAbortController.signal);
263
822
 
264
- // Recovery appends can fail transiently (for example while Pi is flushing a
265
- // branch). Build and restore a disposable candidate, retrying against the
266
- // latest branch snapshot before exposing the workflow surface. Never leave
267
- // `engine` assigned while restore is half-complete.
268
823
  let recoveredEngine: WorkflowEngine | undefined;
269
824
  let recoveryError: unknown;
270
825
  for (let attempt = 0; attempt < 3 && !recoveredEngine; attempt += 1) {
271
- const candidate = new WorkflowEngine(pi.events, client, {
272
- append(event) {
273
- pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
826
+ const candidate = new WorkflowEngine(
827
+ pi.events,
828
+ client,
829
+ {
830
+ append(event) {
831
+ pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
832
+ },
274
833
  },
275
- });
834
+ () => {
835
+ try {
836
+ return (ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]) ?? [];
837
+ } catch {
838
+ return [];
839
+ }
840
+ },
841
+ );
276
842
  try {
277
843
  const entries =
278
844
  attempt === 0 ? initialEntries : (ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
279
- candidate.restore(entries, branchGeneration);
845
+ candidate.restore(entries);
280
846
  recoveredEngine = candidate;
281
847
  } catch (error: unknown) {
282
848
  recoveryError = error;
@@ -294,35 +860,116 @@ export default function piWorkflows(pi: ExtensionAPI): void {
294
860
  }
295
861
 
296
862
  engine = recoveredEngine;
297
- branchGeneration = 0;
298
863
  registerSurface();
299
864
 
865
+ // Bind per-session delivery endpoint (A10): capture the session-bound send
866
+ // at this moment so completions deliver to the originating session, not
867
+ // whichever session happens to be current later. Never fall back to the
868
+ // shared pi.sendMessage — a missing or suspended endpoint leaves the
869
+ // result pending and flushes on the next successful bind (fail-closed).
870
+ try {
871
+ const capturedSend = pi.sendMessage;
872
+ if (typeof capturedSend === "function") {
873
+ deliverResult = (text: string) => {
874
+ // Use the captured function value for session affinity; if the
875
+ // captured function is itself a shared accessor, the flush is still
876
+ // fail-closed (delivery only when bound).
877
+ (capturedSend as unknown as (message: unknown, options: unknown) => unknown).call(
878
+ pi,
879
+ {
880
+ content: text,
881
+ display: true,
882
+ customType: "pi-workflows:delivery",
883
+ },
884
+ { deliverAs: "followUp", triggerTurn: true },
885
+ );
886
+ };
887
+ }
888
+ } catch {
889
+ // binding is best-effort; pending stays buffered
890
+ }
891
+ // Hydrate any pending markers persisted before this bind (e.g. runs that
892
+ // settled before the first session_start) — scan the branch for
893
+ // PENDING_DELIVERY_TYPE entries that have no in-memory counterpart.
894
+ try {
895
+ const acknowledged = new Set<string>();
896
+ for (const entry of initialEntries) {
897
+ const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
898
+ if (shaped.type !== "custom" || typeof shaped.customType !== "string") continue;
899
+ const data = shaped.data as Record<string, unknown> | undefined;
900
+ const entryRunId = typeof data?.runId === "string" ? data.runId : undefined;
901
+ if (!entryRunId) continue;
902
+ if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) {
903
+ acknowledged.add(entryRunId);
904
+ } else if (shaped.customType === PENDING_DELIVERY_TYPE) {
905
+ const pendingText = typeof data?.text === "string" ? data.text : undefined;
906
+ const timestamp = typeof data?.timestamp === "number" ? data.timestamp : Date.now();
907
+ if (pendingText && !acknowledged.has(entryRunId)) rememberPendingDelivery(entryRunId, pendingText, timestamp);
908
+ }
909
+ }
910
+ for (const runId of acknowledged) pendingDeliveryMarkers.delete(runId);
911
+ } catch {
912
+ // scan is best-effort
913
+ }
914
+ flushPendingDeliveries();
915
+
916
+ // Saved workflow commands register at session_start (Pi cannot unregister
917
+ // or replace command metadata, so factory-time registration would freeze the
918
+ // source project's description and survive cross-project resumes). Check for
919
+ // name clashes first — never overwrite another extension's command.
920
+ const existingCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
921
+ for (const savedName of listSavedWorkflows(sessionCwd)) {
922
+ if (existingCommands.has(savedName)) continue;
923
+ const script = loadSavedWorkflow(savedName, sessionCwd);
924
+ if (!script) continue;
925
+ pi.registerCommand(savedName, {
926
+ description: `Run the saved workflow ${savedName}`,
927
+ handler: async (args: string, commandCtx: ExtensionCommandContext) => {
928
+ const options: ScriptStartOptions = {
929
+ args: args.trim() ? { prompt: args.trim() } : undefined,
930
+ background: true,
931
+ mainModel: commandCtx?.model?.id,
932
+ loadSavedWorkflow: (name) => loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script,
933
+ };
934
+ const result = await recoveredEngine.start(script, options);
935
+ void deliverBackgroundResult(recoveredEngine, result.runId);
936
+ commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
937
+ },
938
+ });
939
+ }
940
+
300
941
  protocolError = undefined;
301
942
  protocolCheck = client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
302
943
  void protocolCheck.catch((error: unknown) => {
303
944
  if (generation !== lifecycleGeneration) return;
304
945
  const detail = error instanceof Error ? error.message : String(error);
305
- // Missing pi-subagents is a valid independent-package activation state.
306
- // Keep startup nonblocking and defer the bounded diagnostic to the next
307
- // workflow tool call instead of logging a spurious startup error.
308
946
  protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
309
947
  });
310
948
  });
311
949
 
312
- pi.on("session_shutdown", () => {
950
+ pi.on("session_shutdown", async () => {
313
951
  lifecycleGeneration += 1;
952
+ if (widgetTimer) {
953
+ clearInterval(widgetTimer);
954
+ widgetTimer = undefined;
955
+ }
956
+ const closingEngine = engine;
957
+ if (closingEngine) {
958
+ await closingEngine.quiesceForBranchChange().catch(() => undefined);
959
+ }
314
960
  protocolAbortController?.abort();
315
961
  protocolAbortController = undefined;
316
962
  unsubscribeTreeSignal?.();
317
963
  unsubscribeTreeSignal = undefined;
318
- engine?.dispose();
964
+ closingEngine?.dispose();
319
965
  engine = undefined;
320
966
  active = false;
321
967
  branchQuiesce = undefined;
322
- branchGeneration = 0;
323
968
  protocolCheck = Promise.resolve();
324
969
  protocolError = undefined;
970
+ deliverResult = undefined;
325
971
  });
326
972
  }
327
973
 
328
- export { validateWorkflow, WorkflowSchema };
974
+ export type { ScriptStartResult };
975
+ export { WorkflowWaitAbortedError };