@signalridge/pi-workflows 1.3.2 → 1.5.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/src/index.ts CHANGED
@@ -8,7 +8,12 @@ import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
8
8
  import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
9
9
  import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
10
10
  import { JOURNAL_ENTRY_TYPE, type SessionEntryLike } from "./journal.js";
11
- import { createManagedSpawnClient, PROTOCOL_DIAGNOSTIC, queryChildSessionContextImmediate } from "./rpc-client.js";
11
+ import {
12
+ createManagedSpawnClient,
13
+ type ManagedProtocolCheck,
14
+ PROTOCOL_DIAGNOSTIC,
15
+ queryChildSessionContextImmediate,
16
+ } from "./rpc-client.js";
12
17
  import { listSavedWorkflows, loadSavedWorkflow, saveWorkflow } from "./saved-workflows.js";
13
18
  import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
14
19
  import { loadWorkflowSettings, saveWorkflowSettings } from "./workflow-settings.js";
@@ -118,18 +123,170 @@ function formatStart(result: ScriptStartResult): string {
118
123
  return lines.join("\n");
119
124
  }
120
125
 
126
+ /** Build the bounded planner → graph → synthesis script used by `/workflows run`. */
127
+ export function buildAdHocWorkflowScript(prompt: string): string {
128
+ return `export const meta = { name: "ad-hoc", description: "Plan, execute, and synthesize a bounded multi-agent task graph" };
129
+ const originalPrompt = ${JSON.stringify(prompt)};
130
+ const plan = await agent("Design a bounded execution plan for this user task. Return 1-8 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible. User task:\\n\\n" + originalPrompt, {
131
+ label: "planner",
132
+ tier: "medium",
133
+ schema: {
134
+ type: "object",
135
+ properties: {
136
+ tasks: {
137
+ type: "array",
138
+ minItems: 1,
139
+ maxItems: 8,
140
+ items: {
141
+ type: "object",
142
+ properties: {
143
+ id: { type: "string", minLength: 1, maxLength: 64 },
144
+ description: { type: "string", minLength: 1, maxLength: 500 },
145
+ prompt: { type: "string", minLength: 1, maxLength: 8000 },
146
+ dependsOn: { type: "array", maxItems: 8, items: { type: "string", minLength: 1, maxLength: 64 } },
147
+ },
148
+ required: ["id", "description", "prompt"],
149
+ additionalProperties: false,
150
+ },
151
+ },
152
+ },
153
+ required: ["tasks"],
154
+ additionalProperties: false,
155
+ },
156
+ });
157
+ const graph = await orchestrate(plan.tasks.map((task) => ({
158
+ id: task.id,
159
+ description: task.description,
160
+ dependsOn: task.dependsOn || [],
161
+ run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id, tier: "low" }),
162
+ })), { onError: "continue" });
163
+ const SYNTHESIS_CONTEXT_LIMIT = 78000;
164
+ function renderContextValue(value) {
165
+ const rendered = JSON.stringify(value);
166
+ return rendered === undefined ? String(value) : rendered;
167
+ }
168
+ function boundContextText(value, maxEncodedChars) {
169
+ const text = String(value);
170
+ const encodedLength = (candidate) => JSON.stringify(candidate).length - 2;
171
+ if (encodedLength(text) <= maxEncodedChars) return { text, truncated: false, marker: null };
172
+ let low = 0;
173
+ let high = text.length;
174
+ while (low < high) {
175
+ const middle = Math.ceil((low + high) / 2);
176
+ if (encodedLength(text.slice(0, middle)) <= maxEncodedChars) low = middle;
177
+ else high = middle - 1;
178
+ }
179
+ return {
180
+ text: text.slice(0, low),
181
+ truncated: true,
182
+ marker: "[TRUNCATED " + (text.length - low) + " SOURCE CHARACTERS]",
183
+ };
184
+ }
185
+ const planMetadata = {
186
+ taskCount: plan.tasks.length,
187
+ tasks: plan.tasks.map((task) => {
188
+ const description = boundContextText(task.description, 500);
189
+ return {
190
+ id: task.id,
191
+ description: description.text,
192
+ descriptionTruncated: description.truncated,
193
+ descriptionTruncationMarker: description.marker,
194
+ dependsOn: task.dependsOn || [],
195
+ };
196
+ }),
197
+ };
198
+ function makeSynthesisContext(valuePreviewBudget, originalPromptBudget) {
199
+ const boundedOriginalPrompt = boundContextText(originalPrompt, originalPromptBudget);
200
+ const tasks = Object.keys(graph.tasks).map((id) => {
201
+ const task = graph.tasks[id];
202
+ const error = task.error == null ? null : boundContextText(task.error, 1000);
203
+ const value = boundContextText(renderContextValue(task.value), valuePreviewBudget);
204
+ return {
205
+ id,
206
+ status: task.status,
207
+ attempts: task.attempts,
208
+ error: error == null ? null : error.text,
209
+ errorTruncated: error == null ? false : error.truncated,
210
+ errorTruncationMarker: error == null ? null : error.marker,
211
+ valuePreview: value.text,
212
+ valueTruncated: value.truncated,
213
+ valueTruncationMarker: value.marker,
214
+ };
215
+ });
216
+ return {
217
+ originalPrompt: boundedOriginalPrompt.text,
218
+ originalPromptTruncated: boundedOriginalPrompt.truncated,
219
+ originalPromptTruncationMarker: boundedOriginalPrompt.marker,
220
+ plan: planMetadata,
221
+ tasks,
222
+ };
223
+ }
224
+ let valuePreviewBudget = 6000;
225
+ let originalPromptBudget = 16000;
226
+ let synthesisContext = makeSynthesisContext(valuePreviewBudget, originalPromptBudget);
227
+ let synthesisJson = JSON.stringify(synthesisContext);
228
+ while (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT && (valuePreviewBudget > 128 || originalPromptBudget > 256)) {
229
+ valuePreviewBudget = Math.max(128, Math.floor(valuePreviewBudget * 0.75));
230
+ originalPromptBudget = Math.max(256, Math.floor(originalPromptBudget * 0.75));
231
+ synthesisContext = makeSynthesisContext(valuePreviewBudget, originalPromptBudget);
232
+ synthesisJson = JSON.stringify(synthesisContext);
233
+ }
234
+ if (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT) throw new Error("Unable to fit the synthesis context within its managed prompt budget");
235
+ return await agent("Synthesize the worker results into a direct answer to the original task. Mention incomplete or failed coverage explicitly.\\n\\nOriginal task, execution plan, and graph ledger:\\n" + synthesisJson, { label: "synthesizer", tier: "medium" });`;
236
+ }
237
+
121
238
  export default function piWorkflows(pi: ExtensionAPI): void {
122
239
  let engine: WorkflowEngine | undefined;
123
240
  let protocolError: string | undefined;
124
- let protocolCheck: Promise<void> = Promise.resolve();
241
+ let protocolCheck: Promise<ManagedProtocolCheck> | undefined;
242
+ let protocolProbe: (() => Promise<ManagedProtocolCheck>) | undefined;
243
+ let protocolInFlight: Promise<ManagedProtocolCheck> | undefined;
244
+ /**
245
+ * Track one in-flight probe so concurrent starts share it, and release it the
246
+ * moment it settles.
247
+ *
248
+ * Releasing matters: the catalogue is read live on the peer's side, so the
249
+ * next start or resume must issue a *new* ping. Holding a settled promise
250
+ * here would pin every later run to the routing policy as it stood at session
251
+ * start, and a tier defined since then would be rejected pre-dispatch as
252
+ * unknown.
253
+ */
254
+ const trackProbe = (check: Promise<ManagedProtocolCheck>): Promise<ManagedProtocolCheck> => {
255
+ protocolInFlight = check;
256
+ const release = () => {
257
+ if (protocolInFlight === check) protocolInFlight = undefined;
258
+ };
259
+ void check.then(release, release);
260
+ return check;
261
+ };
262
+ const awaitProtocol = async (): Promise<ManagedProtocolCheck> => {
263
+ let check = protocolInFlight;
264
+ if (!check) {
265
+ if (protocolProbe) protocolError = undefined;
266
+ check = protocolProbe?.() ?? protocolCheck;
267
+ if (!check) throw new Error(`${PROTOCOL_DIAGNOSTIC} Diagnostic: no active protocol probe`);
268
+ trackProbe(check);
269
+ }
270
+ try {
271
+ const result = await check;
272
+ protocolError = undefined;
273
+ return result;
274
+ } catch (error: unknown) {
275
+ const detail = error instanceof Error ? error.message : String(error);
276
+ throw new Error(protocolError ?? `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`);
277
+ }
278
+ };
125
279
  let active = false;
126
- let unsubscribeTreeSignal: (() => void) | undefined;
280
+ let surfaceRegistered = false;
281
+ const registeredWorkflowCommands = new Set<string>();
127
282
  let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
128
283
  let lifecycleGeneration = 0;
129
284
  let protocolAbortController: AbortController | undefined;
130
285
  let sessionCwd = process.cwd();
131
286
  let workflowSettings = loadWorkflowSettings(sessionCwd);
132
287
  let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
288
+ let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
289
+ let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
133
290
  /** Per-session delivery endpoint for background-run results (A10 fail-closed). */
134
291
  let deliverResult: ((text: string, details?: unknown) => void) | undefined;
135
292
  /** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
@@ -141,10 +298,42 @@ export default function piWorkflows(pi: ExtensionAPI): void {
141
298
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
142
299
 
143
300
  const currentEngine = (): WorkflowEngine => {
144
- if (!engine) throw new Error("pi-workflows is not active in this session context");
301
+ if (!active || !engine || engine.isDisposed())
302
+ throw new Error("pi-workflows is not active in this session context");
145
303
  return engine;
146
304
  };
147
305
 
306
+ const clearWidget = (): void => {
307
+ const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
308
+ tui?.setWidget?.("pi-workflows", undefined);
309
+ };
310
+ const refreshWidget = (): void => {
311
+ const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
312
+ if (!tui?.setWidget) return;
313
+ const workflowEngine = engine;
314
+ if (!active || !workflowEngine || workflowEngine.isDisposed()) {
315
+ tui.setWidget("pi-workflows", undefined);
316
+ if (widgetTimer) {
317
+ clearInterval(widgetTimer);
318
+ widgetTimer = undefined;
319
+ }
320
+ return;
321
+ }
322
+ const runs = workflowEngine
323
+ .list()
324
+ .map((summary) => workflowEngine.getRun(String(summary.runId)))
325
+ .filter((run): run is NonNullable<typeof run> => run !== undefined);
326
+ const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
327
+ tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
328
+ if (lines.length > 0 && !widgetTimer) {
329
+ widgetTimer = setInterval(refreshWidget, 2_000);
330
+ } else if (lines.length === 0 && widgetTimer) {
331
+ clearInterval(widgetTimer);
332
+ widgetTimer = undefined;
333
+ }
334
+ };
335
+ const notifyRunChanged = (): void => refreshWidget();
336
+
148
337
  const branchEntries = (): SessionEntryLike[] => {
149
338
  const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
150
339
  try {
@@ -156,16 +345,38 @@ export default function piWorkflows(pi: ExtensionAPI): void {
156
345
 
157
346
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
158
347
 
348
+ /**
349
+ * Resolve a nested `workflow(name)` reference, saying where the script came
350
+ * from.
351
+ *
352
+ * Shipped-ness has to be reported per script rather than inherited from the
353
+ * frame that called it: a user script may call a built-in by name, and a
354
+ * built-in's tier names are preferences against whatever catalogue the host
355
+ * defines, while a user script's are assertions about a catalogue the user
356
+ * owns. Inheriting the caller's flag would apply the wrong rule in both
357
+ * directions.
358
+ */
359
+ const resolveNestedWorkflow = (name: string): { script: string; shippedScript?: boolean } | undefined => {
360
+ // A saved file shadows a built-in of the same name; it is the user's own
361
+ // script and keeps the strict tier check.
362
+ const saved = loadSavedWorkflow(name, sessionCwd);
363
+ if (saved !== undefined) return { script: saved };
364
+ const builtin = BUILTIN_WORKFLOWS[name]?.script;
365
+ return builtin === undefined ? undefined : { script: builtin, shippedScript: true };
366
+ };
367
+
159
368
  const resolveScript = (
160
369
  script: string | undefined,
161
370
  name: string | undefined,
162
371
  args: unknown,
163
- ): { script: string; source: string; toolset?: string } => {
372
+ ): { script: string; source: string; toolset?: string; shippedScript?: boolean } => {
164
373
  if (script !== undefined && name !== undefined) {
165
374
  throw new Error("Provide either `script` or `name`, not both.");
166
375
  }
167
376
  if (script !== undefined) return { script, source: "inline" };
168
377
  if (name !== undefined) {
378
+ // A saved workflow is the user's own file even when it shadows a built-in
379
+ // name, so it keeps the strict tier check.
169
380
  const saved = loadSavedWorkflow(name, sessionCwd);
170
381
  if (saved) return { script: saved, source: `saved:${name}` };
171
382
  const builtin = BUILTIN_WORKFLOWS[name];
@@ -173,7 +384,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
173
384
  // Validate named invocations before starting a potentially expensive fleet.
174
385
  // Slash commands provide the same shape through their descriptor primaryArg.
175
386
  validateBuiltinArgs(name, args);
176
- return { script: builtin.script, source: `builtin:${name}`, toolset: builtin.toolset };
387
+ return {
388
+ script: builtin.script,
389
+ source: `builtin:${name}`,
390
+ toolset: builtin.toolset,
391
+ shippedScript: true,
392
+ };
177
393
  }
178
394
  throw new Error(`Unknown workflow name "${name}". It is neither a saved workflow nor a built-in.`);
179
395
  }
@@ -184,17 +400,23 @@ export default function piWorkflows(pi: ExtensionAPI): void {
184
400
  if (active) return;
185
401
  active = true;
186
402
  const workflowEngine = currentEngine();
403
+ workflowEngine.onRunSettled = notifyRunChanged;
404
+ // Command/tool registrations persist across session replacement, but the
405
+ // widget belongs to the newly restored engine and must be refreshed too.
406
+ refreshWidget();
407
+ if (surfaceRegistered) return;
408
+ surfaceRegistered = true;
187
409
 
188
410
  pi.registerTool(
189
411
  defineTool({
190
412
  name: "workflow",
191
413
  label: "Workflow",
192
414
  description:
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.",
415
+ "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(), orchestrate(), 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
416
  promptSnippet: "Run a JavaScript workflow script",
195
417
  promptGuidelines: [
196
418
  "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.",
419
+ "Use orchestrate([{ id, dependsOn, run }]) for named dependency graphs; use parallel(() => agent(...), ...) for a simple fan-out and pipeline(items, ...stages) for sequential per-item stages.",
198
420
  "Use background: true for long runs; retrieve results with workflow_control.",
199
421
  "Use resumeFromRunId with an edited script to replay the unchanged prefix from cache and re-run the rest live.",
200
422
  "Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings beyond the declared options.",
@@ -219,62 +441,67 @@ export default function piWorkflows(pi: ExtensionAPI): void {
219
441
  },
220
442
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
221
443
  try {
222
- await protocolCheck;
444
+ const workflowEngine = currentEngine();
445
+ const resolved =
446
+ params.resumeFromRunId && params.script === undefined && params.name === undefined
447
+ ? (() => {
448
+ const prior = workflowEngine.getRun(params.resumeFromRunId);
449
+ if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
450
+ // Resume reads the frozen flag off the restored run, so
451
+ // this only has to satisfy the shared shape.
452
+ return { script: prior.script, source: "resume", toolset: prior.toolset, shippedScript: undefined };
453
+ })()
454
+ : resolveScript(params.script, params.name, params.args);
455
+ const { script, source, toolset, shippedScript } = resolved;
456
+ const options: ScriptStartOptions = {
457
+ args: params.args,
458
+ background: params.background ?? true,
459
+ maxAgents: params.maxAgents,
460
+ concurrency: params.concurrency,
461
+ agentRetries: params.agentRetries,
462
+ tokenBudget: params.tokenBudget,
463
+ agentTimeoutMs: params.agentTimeoutMs,
464
+ toolset,
465
+ ...(shippedScript === undefined ? {} : { shippedScript }),
466
+ excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
467
+ signal,
468
+ mainModel: ctx?.model?.id,
469
+ // Foreground checkpoint confirmation only when the run is not
470
+ // background — background runs are headless by contract.
471
+ confirm:
472
+ params.background === false && ctx?.hasUI && ctx.mode === "tui"
473
+ ? async (promptText, checkpointOptions) => {
474
+ if (checkpointOptions.kind === "input")
475
+ return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
476
+ if (checkpointOptions.kind === "select")
477
+ return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
478
+ return ctx.ui.confirm(promptText, promptText);
479
+ }
480
+ : undefined,
481
+ loadSavedWorkflow: resolveNestedWorkflow,
482
+ };
483
+ if (params.resumeFromRunId) {
484
+ const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
485
+ const resumed = await workflowEngine.resume(
486
+ params.resumeFromRunId,
487
+ branchEntries(),
488
+ options,
489
+ replacement,
490
+ );
491
+ if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
492
+ if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
493
+ return textResult(formatStart(resumed), resumed);
494
+ }
495
+ const result = await workflowEngine.start(script, options);
496
+ if (result.background) {
497
+ // The engine resolves the script's meta name for the delivery header.
498
+ void source;
499
+ void deliverBackgroundResult(workflowEngine, result.runId);
500
+ }
501
+ return textResult(formatStart(result), result);
223
502
  } catch (error: unknown) {
224
- return textResult(
225
- protocolError ??
226
- `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
227
- );
228
- }
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);
503
+ return textResult(error instanceof Error ? error.message : String(error));
276
504
  }
277
- return textResult(formatStart(result), result);
278
505
  },
279
506
  }),
280
507
  );
@@ -294,20 +521,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
294
521
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
295
522
  },
296
523
  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)) {
306
- throw new Error(`unknown workflow_control action: ${params.action}`);
524
+ try {
525
+ const rawAction = params.action as string;
526
+ const action = (rawAction === "status" ? "get" : rawAction) as
527
+ | "list"
528
+ | "get"
529
+ | "pause"
530
+ | "resume"
531
+ | "stop"
532
+ | "rm";
533
+ if (!["list", "get", "pause", "resume", "stop", "rm"].includes(action)) {
534
+ throw new Error(`unknown workflow_control action: ${params.action}`);
535
+ }
536
+ const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
537
+ const workflowEngine = currentEngine();
538
+ const result = await workflowEngine.control(action, runId);
539
+ return textResult(JSON.stringify(result), result);
540
+ } catch (error: unknown) {
541
+ return textResult(error instanceof Error ? error.message : String(error));
307
542
  }
308
- const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
309
- const result = await workflowEngine.control(action, runId);
310
- return textResult(JSON.stringify(result), result);
311
543
  },
312
544
  }),
313
545
  );
@@ -319,8 +551,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
319
551
  const tokens = args.trim().split(/\s+/).filter(Boolean);
320
552
  const [subcommand, ...rest] = tokens;
321
553
  const restArgs = rest.join(" ").trim();
554
+ const engineForCommand = (): WorkflowEngine | undefined => {
555
+ try {
556
+ return currentEngine();
557
+ } catch (error: unknown) {
558
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
559
+ return undefined;
560
+ }
561
+ };
322
562
  if (!subcommand) {
323
- await showWorkflowNavigator(ctx, workflowEngine);
563
+ await showWorkflowNavigator(ctx, currentEngine);
324
564
  return;
325
565
  }
326
566
  if (subcommand === "run") {
@@ -333,6 +573,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
333
573
  return;
334
574
  }
335
575
  if (subcommand === "status") {
576
+ const workflowEngine = engineForCommand();
577
+ if (!workflowEngine) return;
336
578
  if (restArgs) {
337
579
  const run = workflowEngine.getRun(restArgs);
338
580
  if (!run) {
@@ -352,6 +594,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
352
594
  return;
353
595
  }
354
596
  if (subcommand === "watch") {
597
+ const workflowEngine = engineForCommand();
598
+ if (!workflowEngine) return;
355
599
  const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
356
600
  if (!runId) {
357
601
  ctx.ui.notify("No workflow runs to watch.", "warning");
@@ -362,7 +606,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
362
606
  return;
363
607
  }
364
608
  if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
365
- const firstListed = workflowEngine.list()[0]?.runId;
609
+ const listedEngine = engineForCommand();
610
+ if (!listedEngine) return;
611
+ const firstListed = listedEngine.list()[0]?.runId;
366
612
  const runId = resolveControlTarget(
367
613
  subcommand,
368
614
  restArgs,
@@ -379,6 +625,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
379
625
  return;
380
626
  }
381
627
  try {
628
+ const workflowEngine = engineForCommand();
629
+ if (!workflowEngine) return;
382
630
  await workflowEngine.control(subcommand, runId);
383
631
  if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
384
632
  ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
@@ -393,6 +641,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
393
641
  ctx.ui.notify("/workflows save <name> [runId]", "warning");
394
642
  return;
395
643
  }
644
+ const workflowEngine = engineForCommand();
645
+ if (!workflowEngine) return;
396
646
  // Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
397
647
  const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
398
648
  if (!runId) {
@@ -471,8 +721,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
471
721
  // is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
472
722
  // and it never opens UI. A user asking a question about workflows still gets
473
723
  // an ordinary answer.
474
- let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
475
- let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
476
724
  const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
477
725
  const persistTriggerSettings = (): void => {
478
726
  workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
@@ -597,7 +845,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
597
845
  // unregister or replace a command, so clobbering one is permanent.
598
846
  const claimedCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
599
847
  for (const name of Object.keys(BUILTIN_WORKFLOWS)) {
600
- if (claimedCommands.has(name)) continue;
848
+ if (claimedCommands.has(name) || registeredWorkflowCommands.has(name)) continue;
601
849
  const descriptor = BUILTIN_WORKFLOWS[name];
602
850
  pi.registerCommand(name, {
603
851
  description: `Run the ${name} workflow`,
@@ -625,54 +873,33 @@ export default function piWorkflows(pi: ExtensionAPI): void {
625
873
  args: scriptArgs,
626
874
  background: true,
627
875
  toolset: descriptor.toolset,
876
+ shippedScript: true,
628
877
  excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
629
- loadSavedWorkflow: (savedName) =>
630
- loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script,
878
+ loadSavedWorkflow: resolveNestedWorkflow,
631
879
  };
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");
880
+ try {
881
+ const workflowEngine = currentEngine();
882
+ const result = await workflowEngine.start(descriptor.script, options);
883
+ void deliverBackgroundResult(workflowEngine, result.runId);
884
+ ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
885
+ } catch (error: unknown) {
886
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
887
+ }
635
888
  },
636
889
  });
890
+ registeredWorkflowCommands.add(name);
637
891
  }
638
892
 
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
-
893
+ // Live progress widget refreshes through the current engine, so its timer
894
+ // and settlement callback remain safe across session replacement.
674
895
  const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
675
896
  if (!branchQuiesce) {
897
+ let workflowEngine: WorkflowEngine;
898
+ try {
899
+ workflowEngine = currentEngine();
900
+ } catch {
901
+ return Promise.resolve({ settled: true, pending: [] });
902
+ }
676
903
  branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
677
904
  if (!result.settled) {
678
905
  console.warn(
@@ -685,7 +912,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
685
912
  return branchQuiesce;
686
913
  };
687
914
 
688
- unsubscribeTreeSignal = pi.events.on("subagents:session_before_tree", () => {
915
+ pi.events.on("subagents:session_before_tree", () => {
689
916
  void beginBranchQuiesce();
690
917
  });
691
918
 
@@ -699,6 +926,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
699
926
  } catch {
700
927
  return;
701
928
  }
929
+ let workflowEngine: WorkflowEngine;
930
+ try {
931
+ workflowEngine = currentEngine();
932
+ } catch {
933
+ return;
934
+ }
702
935
  workflowEngine.suspendLifecycle();
703
936
  try {
704
937
  workflowEngine.restore(entries);
@@ -731,7 +964,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
731
964
  const run = await engine.waitFor(runId).catch(() => undefined);
732
965
  if (!run) return;
733
966
  const header = `Workflow ${run.runId} ${run.status}`;
734
- const body = run.error ?? `completed with ${Object.keys(run.callResults).length} agent call(s).`;
967
+ const state = engine.getState(runId);
968
+ const finalResult = state?.result ?? run.finalResult;
969
+ const body =
970
+ run.error ??
971
+ (finalResult === undefined
972
+ ? `completed with ${Object.keys(run.callResults).length} agent call(s).`
973
+ : `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
735
974
  const text = `${header}: ${body}`;
736
975
  if (deliverResult) {
737
976
  try {
@@ -772,14 +1011,20 @@ export default function piWorkflows(pi: ExtensionAPI): void {
772
1011
 
773
1012
  /** Build a script from a natural-language prompt and run it in background. */
774
1013
  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;`;
1014
+ // A bounded planner → named DAG → synthesis script. Keeping the planner's
1015
+ // output inside the workflow makes /workflows run useful for broad tasks,
1016
+ // while the graph validates dependencies before dispatching any workers.
1017
+ const script = buildAdHocWorkflowScript(prompt);
780
1018
  try {
781
- const result = await currentEngine().start(script, { background: true, mainModel: ctx.model?.id });
782
- void deliverBackgroundResult(currentEngine(), result.runId);
1019
+ const workflowEngine = currentEngine();
1020
+ const result = await workflowEngine.start(script, {
1021
+ background: true,
1022
+ // We wrote this script, tiers included, so its tier names are a
1023
+ // preference against whatever catalogue this host defines.
1024
+ shippedScript: true,
1025
+ mainModel: ctx.model?.id,
1026
+ });
1027
+ void deliverBackgroundResult(workflowEngine, result.runId);
783
1028
  return result.runId;
784
1029
  } catch (error: unknown) {
785
1030
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
@@ -788,7 +1033,7 @@ return answer;`;
788
1033
  };
789
1034
 
790
1035
  const saveWorkflowFromRun = async (ctx: ExtensionCommandContext, name: string, runId?: string): Promise<void> => {
791
- const state = engine?.getState(runId ?? "");
1036
+ const state = currentEngine().getState(runId ?? "");
792
1037
  if (!state) {
793
1038
  throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
794
1039
  }
@@ -802,6 +1047,8 @@ return answer;`;
802
1047
  sessionCwd = ctx.cwd ?? process.cwd();
803
1048
  workflowSettings = loadWorkflowSettings(sessionCwd);
804
1049
  effortLevel = workflowSettings.effort;
1050
+ triggerKeyword = workflowSettings.keywordTriggerWord;
1051
+ keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
805
1052
  let initialEntries: SessionEntryLike[];
806
1053
  try {
807
1054
  initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
@@ -819,6 +1066,15 @@ return answer;`;
819
1066
 
820
1067
  protocolAbortController = new AbortController();
821
1068
  const client = createManagedSpawnClient(pi.events, protocolAbortController.signal, protocolAbortController.signal);
1069
+ protocolError = undefined;
1070
+ protocolProbe = () =>
1071
+ client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
1072
+ protocolCheck = trackProbe(protocolProbe());
1073
+ void protocolCheck.catch((error: unknown) => {
1074
+ if (generation !== lifecycleGeneration) return;
1075
+ const detail = error instanceof Error ? error.message : String(error);
1076
+ protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
1077
+ });
822
1078
 
823
1079
  let recoveredEngine: WorkflowEngine | undefined;
824
1080
  let recoveryError: unknown;
@@ -838,6 +1094,7 @@ return answer;`;
838
1094
  return [];
839
1095
  }
840
1096
  },
1097
+ awaitProtocol,
841
1098
  );
842
1099
  try {
843
1100
  const entries =
@@ -919,36 +1176,39 @@ return answer;`;
919
1176
  // name clashes first — never overwrite another extension's command.
920
1177
  const existingCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
921
1178
  for (const savedName of listSavedWorkflows(sessionCwd)) {
922
- if (existingCommands.has(savedName)) continue;
923
- const script = loadSavedWorkflow(savedName, sessionCwd);
924
- if (!script) continue;
1179
+ if (existingCommands.has(savedName) || registeredWorkflowCommands.has(savedName)) continue;
1180
+ if (!loadSavedWorkflow(savedName, sessionCwd)) continue;
925
1181
  pi.registerCommand(savedName, {
926
1182
  description: `Run the saved workflow ${savedName}`,
927
1183
  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");
1184
+ try {
1185
+ const workflowEngine = currentEngine();
1186
+ const script = loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script;
1187
+ if (!script) throw new Error(`Saved workflow "${savedName}" is no longer available.`);
1188
+ const options: ScriptStartOptions = {
1189
+ args: args.trim() ? { prompt: args.trim() } : undefined,
1190
+ background: true,
1191
+ mainModel: commandCtx?.model?.id,
1192
+ loadSavedWorkflow: resolveNestedWorkflow,
1193
+ };
1194
+ const result = await workflowEngine.start(script, options);
1195
+ void deliverBackgroundResult(workflowEngine, result.runId);
1196
+ commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
1197
+ } catch (error: unknown) {
1198
+ commandCtx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1199
+ }
937
1200
  },
938
1201
  });
1202
+ registeredWorkflowCommands.add(savedName);
939
1203
  }
940
-
941
- protocolError = undefined;
942
- protocolCheck = client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
943
- void protocolCheck.catch((error: unknown) => {
944
- if (generation !== lifecycleGeneration) return;
945
- const detail = error instanceof Error ? error.message : String(error);
946
- protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
947
- });
948
1204
  });
949
1205
 
950
1206
  pi.on("session_shutdown", async () => {
951
1207
  lifecycleGeneration += 1;
1208
+ // Invalidate all long-lived command/tool/widget closures before awaiting
1209
+ // engine quiescence; the old session must not accept another run.
1210
+ active = false;
1211
+ clearWidget();
952
1212
  if (widgetTimer) {
953
1213
  clearInterval(widgetTimer);
954
1214
  widgetTimer = undefined;
@@ -959,13 +1219,12 @@ return answer;`;
959
1219
  }
960
1220
  protocolAbortController?.abort();
961
1221
  protocolAbortController = undefined;
962
- unsubscribeTreeSignal?.();
963
- unsubscribeTreeSignal = undefined;
964
1222
  closingEngine?.dispose();
965
1223
  engine = undefined;
966
- active = false;
967
1224
  branchQuiesce = undefined;
968
- protocolCheck = Promise.resolve();
1225
+ protocolCheck = undefined;
1226
+ protocolProbe = undefined;
1227
+ protocolInFlight = undefined;
969
1228
  protocolError = undefined;
970
1229
  deliverResult = undefined;
971
1230
  });