@signalridge/pi-workflows 1.4.0 → 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";
@@ -124,6 +129,7 @@ export function buildAdHocWorkflowScript(prompt: string): string {
124
129
  const originalPrompt = ${JSON.stringify(prompt)};
125
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, {
126
131
  label: "planner",
132
+ tier: "medium",
127
133
  schema: {
128
134
  type: "object",
129
135
  properties: {
@@ -152,7 +158,7 @@ const graph = await orchestrate(plan.tasks.map((task) => ({
152
158
  id: task.id,
153
159
  description: task.description,
154
160
  dependsOn: task.dependsOn || [],
155
- run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id }),
161
+ run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id, tier: "low" }),
156
162
  })), { onError: "continue" });
157
163
  const SYNTHESIS_CONTEXT_LIMIT = 78000;
158
164
  function renderContextValue(value) {
@@ -226,21 +232,61 @@ while (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT && (valuePreviewBudget > 1
226
232
  synthesisJson = JSON.stringify(synthesisContext);
227
233
  }
228
234
  if (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT) throw new Error("Unable to fit the synthesis context within its managed prompt budget");
229
- 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" });`;
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" });`;
230
236
  }
231
237
 
232
238
  export default function piWorkflows(pi: ExtensionAPI): void {
233
239
  let engine: WorkflowEngine | undefined;
234
240
  let protocolError: string | undefined;
235
- 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
+ };
236
279
  let active = false;
237
- let unsubscribeTreeSignal: (() => void) | undefined;
280
+ let surfaceRegistered = false;
281
+ const registeredWorkflowCommands = new Set<string>();
238
282
  let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
239
283
  let lifecycleGeneration = 0;
240
284
  let protocolAbortController: AbortController | undefined;
241
285
  let sessionCwd = process.cwd();
242
286
  let workflowSettings = loadWorkflowSettings(sessionCwd);
243
287
  let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
288
+ let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
289
+ let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
244
290
  /** Per-session delivery endpoint for background-run results (A10 fail-closed). */
245
291
  let deliverResult: ((text: string, details?: unknown) => void) | undefined;
246
292
  /** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
@@ -252,10 +298,42 @@ export default function piWorkflows(pi: ExtensionAPI): void {
252
298
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
253
299
 
254
300
  const currentEngine = (): WorkflowEngine => {
255
- 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");
256
303
  return engine;
257
304
  };
258
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
+
259
337
  const branchEntries = (): SessionEntryLike[] => {
260
338
  const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
261
339
  try {
@@ -267,16 +345,38 @@ export default function piWorkflows(pi: ExtensionAPI): void {
267
345
 
268
346
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
269
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
+
270
368
  const resolveScript = (
271
369
  script: string | undefined,
272
370
  name: string | undefined,
273
371
  args: unknown,
274
- ): { script: string; source: string; toolset?: string } => {
372
+ ): { script: string; source: string; toolset?: string; shippedScript?: boolean } => {
275
373
  if (script !== undefined && name !== undefined) {
276
374
  throw new Error("Provide either `script` or `name`, not both.");
277
375
  }
278
376
  if (script !== undefined) return { script, source: "inline" };
279
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.
280
380
  const saved = loadSavedWorkflow(name, sessionCwd);
281
381
  if (saved) return { script: saved, source: `saved:${name}` };
282
382
  const builtin = BUILTIN_WORKFLOWS[name];
@@ -284,7 +384,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
284
384
  // Validate named invocations before starting a potentially expensive fleet.
285
385
  // Slash commands provide the same shape through their descriptor primaryArg.
286
386
  validateBuiltinArgs(name, args);
287
- 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
+ };
288
393
  }
289
394
  throw new Error(`Unknown workflow name "${name}". It is neither a saved workflow nor a built-in.`);
290
395
  }
@@ -295,6 +400,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
295
400
  if (active) return;
296
401
  active = true;
297
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;
298
409
 
299
410
  pi.registerTool(
300
411
  defineTool({
@@ -330,62 +441,67 @@ export default function piWorkflows(pi: ExtensionAPI): void {
330
441
  },
331
442
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
332
443
  try {
333
- 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);
334
502
  } catch (error: unknown) {
335
- return textResult(
336
- protocolError ??
337
- `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
338
- );
339
- }
340
- const resolved =
341
- params.resumeFromRunId && params.script === undefined && params.name === undefined
342
- ? (() => {
343
- const prior = workflowEngine.getRun(params.resumeFromRunId);
344
- if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
345
- return { script: prior.script, source: "resume", toolset: prior.toolset };
346
- })()
347
- : resolveScript(params.script, params.name, params.args);
348
- const { script, source, toolset } = resolved;
349
- const options: ScriptStartOptions = {
350
- args: params.args,
351
- background: params.background ?? true,
352
- maxAgents: params.maxAgents,
353
- concurrency: params.concurrency,
354
- agentRetries: params.agentRetries,
355
- tokenBudget: params.tokenBudget,
356
- agentTimeoutMs: params.agentTimeoutMs,
357
- toolset,
358
- excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
359
- signal,
360
- mainModel: ctx?.model?.id,
361
- // Foreground checkpoint confirmation only when the run is not
362
- // background — background runs are headless by contract.
363
- confirm:
364
- params.background === false && ctx?.hasUI && ctx.mode === "tui"
365
- ? async (promptText, checkpointOptions) => {
366
- if (checkpointOptions.kind === "input")
367
- return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
368
- if (checkpointOptions.kind === "select")
369
- return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
370
- return ctx.ui.confirm(promptText, promptText);
371
- }
372
- : undefined,
373
- loadSavedWorkflow: (name) => loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script,
374
- };
375
- if (params.resumeFromRunId) {
376
- const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
377
- const resumed = await workflowEngine.resume(params.resumeFromRunId, branchEntries(), options, replacement);
378
- if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
379
- if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
380
- return textResult(formatStart(resumed), resumed);
381
- }
382
- const result = await workflowEngine.start(script, options);
383
- if (result.background) {
384
- // The engine resolves the script's meta name for the delivery header.
385
- void source;
386
- void deliverBackgroundResult(workflowEngine, result.runId);
503
+ return textResult(error instanceof Error ? error.message : String(error));
387
504
  }
388
- return textResult(formatStart(result), result);
389
505
  },
390
506
  }),
391
507
  );
@@ -405,20 +521,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
405
521
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
406
522
  },
407
523
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
408
- const rawAction = params.action as string;
409
- const action = (rawAction === "status" ? "get" : rawAction) as
410
- | "list"
411
- | "get"
412
- | "pause"
413
- | "resume"
414
- | "stop"
415
- | "rm";
416
- if (!["list", "get", "pause", "resume", "stop", "rm"].includes(action)) {
417
- 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));
418
542
  }
419
- const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
420
- const result = await workflowEngine.control(action, runId);
421
- return textResult(JSON.stringify(result), result);
422
543
  },
423
544
  }),
424
545
  );
@@ -430,8 +551,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
430
551
  const tokens = args.trim().split(/\s+/).filter(Boolean);
431
552
  const [subcommand, ...rest] = tokens;
432
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
+ };
433
562
  if (!subcommand) {
434
- await showWorkflowNavigator(ctx, workflowEngine);
563
+ await showWorkflowNavigator(ctx, currentEngine);
435
564
  return;
436
565
  }
437
566
  if (subcommand === "run") {
@@ -444,6 +573,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
444
573
  return;
445
574
  }
446
575
  if (subcommand === "status") {
576
+ const workflowEngine = engineForCommand();
577
+ if (!workflowEngine) return;
447
578
  if (restArgs) {
448
579
  const run = workflowEngine.getRun(restArgs);
449
580
  if (!run) {
@@ -463,6 +594,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
463
594
  return;
464
595
  }
465
596
  if (subcommand === "watch") {
597
+ const workflowEngine = engineForCommand();
598
+ if (!workflowEngine) return;
466
599
  const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
467
600
  if (!runId) {
468
601
  ctx.ui.notify("No workflow runs to watch.", "warning");
@@ -473,7 +606,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
473
606
  return;
474
607
  }
475
608
  if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
476
- const firstListed = workflowEngine.list()[0]?.runId;
609
+ const listedEngine = engineForCommand();
610
+ if (!listedEngine) return;
611
+ const firstListed = listedEngine.list()[0]?.runId;
477
612
  const runId = resolveControlTarget(
478
613
  subcommand,
479
614
  restArgs,
@@ -490,6 +625,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
490
625
  return;
491
626
  }
492
627
  try {
628
+ const workflowEngine = engineForCommand();
629
+ if (!workflowEngine) return;
493
630
  await workflowEngine.control(subcommand, runId);
494
631
  if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
495
632
  ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
@@ -504,6 +641,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
504
641
  ctx.ui.notify("/workflows save <name> [runId]", "warning");
505
642
  return;
506
643
  }
644
+ const workflowEngine = engineForCommand();
645
+ if (!workflowEngine) return;
507
646
  // Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
508
647
  const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
509
648
  if (!runId) {
@@ -582,8 +721,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
582
721
  // is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
583
722
  // and it never opens UI. A user asking a question about workflows still gets
584
723
  // an ordinary answer.
585
- let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
586
- let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
587
724
  const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
588
725
  const persistTriggerSettings = (): void => {
589
726
  workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
@@ -708,7 +845,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
708
845
  // unregister or replace a command, so clobbering one is permanent.
709
846
  const claimedCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
710
847
  for (const name of Object.keys(BUILTIN_WORKFLOWS)) {
711
- if (claimedCommands.has(name)) continue;
848
+ if (claimedCommands.has(name) || registeredWorkflowCommands.has(name)) continue;
712
849
  const descriptor = BUILTIN_WORKFLOWS[name];
713
850
  pi.registerCommand(name, {
714
851
  description: `Run the ${name} workflow`,
@@ -736,54 +873,33 @@ export default function piWorkflows(pi: ExtensionAPI): void {
736
873
  args: scriptArgs,
737
874
  background: true,
738
875
  toolset: descriptor.toolset,
876
+ shippedScript: true,
739
877
  excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
740
- loadSavedWorkflow: (savedName) =>
741
- loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script,
878
+ loadSavedWorkflow: resolveNestedWorkflow,
742
879
  };
743
- const result = await workflowEngine.start(descriptor.script, options);
744
- void deliverBackgroundResult(workflowEngine, result.runId);
745
- 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
+ }
746
888
  },
747
889
  });
890
+ registeredWorkflowCommands.add(name);
748
891
  }
749
892
 
750
- // Live progress widget (A10): renders only while runs are active, then
751
- // clears itself. The widget is TUI-only; headless sessions skip it.
752
- const refreshWidget = () => {
753
- const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
754
- if (!tui?.setWidget) return;
755
- const runs = workflowEngine
756
- .list()
757
- .map((summary) => workflowEngine.getRun(String(summary.runId)))
758
- .filter((run): run is NonNullable<typeof run> => run !== undefined);
759
- const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
760
- tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
761
- if (lines.length > 0 && !widgetTimer) {
762
- widgetTimer = setInterval(() => {
763
- if (!tui?.setWidget) return;
764
- const current = workflowEngine
765
- .list()
766
- .map((summary) => workflowEngine.getRun(String(summary.runId)))
767
- .filter((run): run is NonNullable<typeof run> => run !== undefined);
768
- const next = liveWidgetLines(current, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
769
- tui.setWidget("pi-workflows", next.length > 0 ? next : undefined);
770
- if (next.length === 0 && widgetTimer) {
771
- clearInterval(widgetTimer);
772
- widgetTimer = undefined;
773
- }
774
- }, 2_000);
775
- } else if (lines.length === 0 && widgetTimer) {
776
- clearInterval(widgetTimer);
777
- widgetTimer = undefined;
778
- }
779
- };
780
- // Refresh after any control action and on run settlement.
781
- const notifyRunChanged = () => refreshWidget();
782
- workflowEngine.onRunSettled = notifyRunChanged;
783
- void notifyRunChanged;
784
-
893
+ // Live progress widget refreshes through the current engine, so its timer
894
+ // and settlement callback remain safe across session replacement.
785
895
  const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
786
896
  if (!branchQuiesce) {
897
+ let workflowEngine: WorkflowEngine;
898
+ try {
899
+ workflowEngine = currentEngine();
900
+ } catch {
901
+ return Promise.resolve({ settled: true, pending: [] });
902
+ }
787
903
  branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
788
904
  if (!result.settled) {
789
905
  console.warn(
@@ -796,7 +912,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
796
912
  return branchQuiesce;
797
913
  };
798
914
 
799
- unsubscribeTreeSignal = pi.events.on("subagents:session_before_tree", () => {
915
+ pi.events.on("subagents:session_before_tree", () => {
800
916
  void beginBranchQuiesce();
801
917
  });
802
918
 
@@ -810,6 +926,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
810
926
  } catch {
811
927
  return;
812
928
  }
929
+ let workflowEngine: WorkflowEngine;
930
+ try {
931
+ workflowEngine = currentEngine();
932
+ } catch {
933
+ return;
934
+ }
813
935
  workflowEngine.suspendLifecycle();
814
936
  try {
815
937
  workflowEngine.restore(entries);
@@ -894,18 +1016,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
894
1016
  // while the graph validates dependencies before dispatching any workers.
895
1017
  const script = buildAdHocWorkflowScript(prompt);
896
1018
  try {
897
- await protocolCheck;
898
- const result = await currentEngine().start(script, { background: true, mainModel: ctx.model?.id });
899
- 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);
900
1028
  return result.runId;
901
1029
  } catch (error: unknown) {
902
- ctx.ui.notify(protocolError ?? (error instanceof Error ? error.message : String(error)), "error");
1030
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
903
1031
  return undefined;
904
1032
  }
905
1033
  };
906
1034
 
907
1035
  const saveWorkflowFromRun = async (ctx: ExtensionCommandContext, name: string, runId?: string): Promise<void> => {
908
- const state = engine?.getState(runId ?? "");
1036
+ const state = currentEngine().getState(runId ?? "");
909
1037
  if (!state) {
910
1038
  throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
911
1039
  }
@@ -919,6 +1047,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
919
1047
  sessionCwd = ctx.cwd ?? process.cwd();
920
1048
  workflowSettings = loadWorkflowSettings(sessionCwd);
921
1049
  effortLevel = workflowSettings.effort;
1050
+ triggerKeyword = workflowSettings.keywordTriggerWord;
1051
+ keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
922
1052
  let initialEntries: SessionEntryLike[];
923
1053
  try {
924
1054
  initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
@@ -936,6 +1066,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
936
1066
 
937
1067
  protocolAbortController = new AbortController();
938
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
+ });
939
1078
 
940
1079
  let recoveredEngine: WorkflowEngine | undefined;
941
1080
  let recoveryError: unknown;
@@ -955,6 +1094,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
955
1094
  return [];
956
1095
  }
957
1096
  },
1097
+ awaitProtocol,
958
1098
  );
959
1099
  try {
960
1100
  const entries =
@@ -1036,36 +1176,39 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1036
1176
  // name clashes first — never overwrite another extension's command.
1037
1177
  const existingCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
1038
1178
  for (const savedName of listSavedWorkflows(sessionCwd)) {
1039
- if (existingCommands.has(savedName)) continue;
1040
- const script = loadSavedWorkflow(savedName, sessionCwd);
1041
- if (!script) continue;
1179
+ if (existingCommands.has(savedName) || registeredWorkflowCommands.has(savedName)) continue;
1180
+ if (!loadSavedWorkflow(savedName, sessionCwd)) continue;
1042
1181
  pi.registerCommand(savedName, {
1043
1182
  description: `Run the saved workflow ${savedName}`,
1044
1183
  handler: async (args: string, commandCtx: ExtensionCommandContext) => {
1045
- const options: ScriptStartOptions = {
1046
- args: args.trim() ? { prompt: args.trim() } : undefined,
1047
- background: true,
1048
- mainModel: commandCtx?.model?.id,
1049
- loadSavedWorkflow: (name) => loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script,
1050
- };
1051
- const result = await recoveredEngine.start(script, options);
1052
- void deliverBackgroundResult(recoveredEngine, result.runId);
1053
- 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
+ }
1054
1200
  },
1055
1201
  });
1202
+ registeredWorkflowCommands.add(savedName);
1056
1203
  }
1057
-
1058
- protocolError = undefined;
1059
- protocolCheck = client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
1060
- void protocolCheck.catch((error: unknown) => {
1061
- if (generation !== lifecycleGeneration) return;
1062
- const detail = error instanceof Error ? error.message : String(error);
1063
- protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
1064
- });
1065
1204
  });
1066
1205
 
1067
1206
  pi.on("session_shutdown", async () => {
1068
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();
1069
1212
  if (widgetTimer) {
1070
1213
  clearInterval(widgetTimer);
1071
1214
  widgetTimer = undefined;
@@ -1076,13 +1219,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1076
1219
  }
1077
1220
  protocolAbortController?.abort();
1078
1221
  protocolAbortController = undefined;
1079
- unsubscribeTreeSignal?.();
1080
- unsubscribeTreeSignal = undefined;
1081
1222
  closingEngine?.dispose();
1082
1223
  engine = undefined;
1083
- active = false;
1084
1224
  branchQuiesce = undefined;
1085
- protocolCheck = Promise.resolve();
1225
+ protocolCheck = undefined;
1226
+ protocolProbe = undefined;
1227
+ protocolInFlight = undefined;
1086
1228
  protocolError = undefined;
1087
1229
  deliverResult = undefined;
1088
1230
  });