@signalridge/pi-workflows 1.4.0 → 1.6.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,10 +8,27 @@ 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";
18
+ import {
19
+ defaultStrengthTable,
20
+ isWorkflowStrength,
21
+ WORKFLOW_STRENGTH_LIST,
22
+ WORKFLOW_STRENGTHS,
23
+ type WorkflowStrengthTable,
24
+ } from "./strengths.js";
13
25
  import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
14
- import { loadWorkflowSettings, saveWorkflowSettings } from "./workflow-settings.js";
26
+ import {
27
+ loadProjectWorkflowSettings,
28
+ loadWorkflowSettings,
29
+ saveWorkflowSettings,
30
+ type WorkflowSettings,
31
+ } from "./workflow-settings.js";
15
32
 
16
33
  const WorkflowToolSchema = Type.Object(
17
34
  {
@@ -124,6 +141,7 @@ export function buildAdHocWorkflowScript(prompt: string): string {
124
141
  const originalPrompt = ${JSON.stringify(prompt)};
125
142
  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
143
  label: "planner",
144
+ strength: "medium",
127
145
  schema: {
128
146
  type: "object",
129
147
  properties: {
@@ -152,7 +170,7 @@ const graph = await orchestrate(plan.tasks.map((task) => ({
152
170
  id: task.id,
153
171
  description: task.description,
154
172
  dependsOn: task.dependsOn || [],
155
- run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id }),
173
+ run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id, strength: "low" }),
156
174
  })), { onError: "continue" });
157
175
  const SYNTHESIS_CONTEXT_LIMIT = 78000;
158
176
  function renderContextValue(value) {
@@ -226,21 +244,61 @@ while (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT && (valuePreviewBudget > 1
226
244
  synthesisJson = JSON.stringify(synthesisContext);
227
245
  }
228
246
  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" });`;
247
+ 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", strength: "medium" });`;
230
248
  }
231
249
 
232
250
  export default function piWorkflows(pi: ExtensionAPI): void {
233
251
  let engine: WorkflowEngine | undefined;
234
252
  let protocolError: string | undefined;
235
- let protocolCheck: Promise<void> = Promise.resolve();
253
+ let protocolCheck: Promise<ManagedProtocolCheck> | undefined;
254
+ let protocolProbe: (() => Promise<ManagedProtocolCheck>) | undefined;
255
+ let protocolInFlight: Promise<ManagedProtocolCheck> | undefined;
256
+ /**
257
+ * Track one in-flight probe so concurrent starts share it, and release it the
258
+ * moment it settles.
259
+ *
260
+ * Releasing matters: the catalogue is read live on the peer's side, so the
261
+ * next start or resume must issue a *new* ping. Holding a settled promise
262
+ * here would pin every later run to the routing policy as it stood at session
263
+ * start, and a tier defined since then would be rejected pre-dispatch as
264
+ * unknown.
265
+ */
266
+ const trackProbe = (check: Promise<ManagedProtocolCheck>): Promise<ManagedProtocolCheck> => {
267
+ protocolInFlight = check;
268
+ const release = () => {
269
+ if (protocolInFlight === check) protocolInFlight = undefined;
270
+ };
271
+ void check.then(release, release);
272
+ return check;
273
+ };
274
+ const awaitProtocol = async (): Promise<ManagedProtocolCheck> => {
275
+ let check = protocolInFlight;
276
+ if (!check) {
277
+ if (protocolProbe) protocolError = undefined;
278
+ check = protocolProbe?.() ?? protocolCheck;
279
+ if (!check) throw new Error(`${PROTOCOL_DIAGNOSTIC} Diagnostic: no active protocol probe`);
280
+ trackProbe(check);
281
+ }
282
+ try {
283
+ const result = await check;
284
+ protocolError = undefined;
285
+ return result;
286
+ } catch (error: unknown) {
287
+ const detail = error instanceof Error ? error.message : String(error);
288
+ throw new Error(protocolError ?? `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`);
289
+ }
290
+ };
236
291
  let active = false;
237
- let unsubscribeTreeSignal: (() => void) | undefined;
292
+ let surfaceRegistered = false;
293
+ const registeredWorkflowCommands = new Set<string>();
238
294
  let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
239
295
  let lifecycleGeneration = 0;
240
296
  let protocolAbortController: AbortController | undefined;
241
297
  let sessionCwd = process.cwd();
242
298
  let workflowSettings = loadWorkflowSettings(sessionCwd);
243
299
  let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
300
+ let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
301
+ let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
244
302
  /** Per-session delivery endpoint for background-run results (A10 fail-closed). */
245
303
  let deliverResult: ((text: string, details?: unknown) => void) | undefined;
246
304
  /** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
@@ -252,10 +310,42 @@ export default function piWorkflows(pi: ExtensionAPI): void {
252
310
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
253
311
 
254
312
  const currentEngine = (): WorkflowEngine => {
255
- if (!engine) throw new Error("pi-workflows is not active in this session context");
313
+ if (!active || !engine || engine.isDisposed())
314
+ throw new Error("pi-workflows is not active in this session context");
256
315
  return engine;
257
316
  };
258
317
 
318
+ const clearWidget = (): void => {
319
+ const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
320
+ tui?.setWidget?.("pi-workflows", undefined);
321
+ };
322
+ const refreshWidget = (): void => {
323
+ const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
324
+ if (!tui?.setWidget) return;
325
+ const workflowEngine = engine;
326
+ if (!active || !workflowEngine || workflowEngine.isDisposed()) {
327
+ tui.setWidget("pi-workflows", undefined);
328
+ if (widgetTimer) {
329
+ clearInterval(widgetTimer);
330
+ widgetTimer = undefined;
331
+ }
332
+ return;
333
+ }
334
+ const runs = workflowEngine
335
+ .list()
336
+ .map((summary) => workflowEngine.getRun(String(summary.runId)))
337
+ .filter((run): run is NonNullable<typeof run> => run !== undefined);
338
+ const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
339
+ tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
340
+ if (lines.length > 0 && !widgetTimer) {
341
+ widgetTimer = setInterval(refreshWidget, 2_000);
342
+ } else if (lines.length === 0 && widgetTimer) {
343
+ clearInterval(widgetTimer);
344
+ widgetTimer = undefined;
345
+ }
346
+ };
347
+ const notifyRunChanged = (): void => refreshWidget();
348
+
259
349
  const branchEntries = (): SessionEntryLike[] => {
260
350
  const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
261
351
  try {
@@ -267,6 +357,26 @@ export default function piWorkflows(pi: ExtensionAPI): void {
267
357
 
268
358
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
269
359
 
360
+ /**
361
+ * Persist one settings change into this project's own file.
362
+ *
363
+ * `workflowSettings` is the merged view — defaults, then global, then project
364
+ * — while `saveWorkflowSettings` replaces the project file with whatever it
365
+ * is handed. Handing it the merged view would copy every global value and
366
+ * every default into the project on the first edit, and the project would
367
+ * stop tracking the global file from then on. So the patch lands on project
368
+ * scope and the merged view is re-derived from what actually landed, which is
369
+ * also what makes it safe to report the result rather than the intent.
370
+ */
371
+ const persistWorkflowSetting = (patch: WorkflowSettings): void => {
372
+ saveWorkflowSettings({ ...loadProjectWorkflowSettings(sessionCwd), ...patch }, sessionCwd);
373
+ workflowSettings = loadWorkflowSettings(sessionCwd);
374
+ };
375
+
376
+ /** Resolve a nested `workflow(name)` reference; a saved file shadows a built-in. */
377
+ const resolveNestedWorkflow = (name: string): string | undefined =>
378
+ loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script;
379
+
270
380
  const resolveScript = (
271
381
  script: string | undefined,
272
382
  name: string | undefined,
@@ -295,6 +405,17 @@ export default function piWorkflows(pi: ExtensionAPI): void {
295
405
  if (active) return;
296
406
  active = true;
297
407
  const workflowEngine = currentEngine();
408
+ workflowEngine.onRunSettled = notifyRunChanged;
409
+ // `control("resume")` and the provider-limit retry start executions with no
410
+ // caller to pass options for them, and the strength table is deliberately
411
+ // never frozen onto a run. Without this they would resume with no table and
412
+ // re-run every finished call untabled.
413
+ workflowEngine.strengths = () => workflowSettings.strengths;
414
+ // Command/tool registrations persist across session replacement, but the
415
+ // widget belongs to the newly restored engine and must be refreshed too.
416
+ refreshWidget();
417
+ if (surfaceRegistered) return;
418
+ surfaceRegistered = true;
298
419
 
299
420
  pi.registerTool(
300
421
  defineTool({
@@ -330,62 +451,67 @@ export default function piWorkflows(pi: ExtensionAPI): void {
330
451
  },
331
452
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
332
453
  try {
333
- await protocolCheck;
454
+ const workflowEngine = currentEngine();
455
+ const resolved =
456
+ params.resumeFromRunId && params.script === undefined && params.name === undefined
457
+ ? (() => {
458
+ const prior = workflowEngine.getRun(params.resumeFromRunId);
459
+ if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
460
+ // Resume replays the stored script; only the shared shape
461
+ // has to be satisfied here.
462
+ return { script: prior.script, source: "resume", toolset: prior.toolset };
463
+ })()
464
+ : resolveScript(params.script, params.name, params.args);
465
+ const { script, source, toolset } = resolved;
466
+ const options: ScriptStartOptions = {
467
+ args: params.args,
468
+ background: params.background ?? true,
469
+ maxAgents: params.maxAgents,
470
+ concurrency: params.concurrency,
471
+ agentRetries: params.agentRetries,
472
+ tokenBudget: params.tokenBudget,
473
+ agentTimeoutMs: params.agentTimeoutMs,
474
+ toolset,
475
+ excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
476
+ signal,
477
+ mainModel: ctx?.model?.id,
478
+ // Foreground checkpoint confirmation only when the run is not
479
+ // background — background runs are headless by contract.
480
+ confirm:
481
+ params.background === false && ctx?.hasUI && ctx.mode === "tui"
482
+ ? async (promptText, checkpointOptions) => {
483
+ if (checkpointOptions.kind === "input")
484
+ return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
485
+ if (checkpointOptions.kind === "select")
486
+ return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
487
+ return ctx.ui.confirm(promptText, promptText);
488
+ }
489
+ : undefined,
490
+ loadSavedWorkflow: resolveNestedWorkflow,
491
+ strengths: workflowSettings.strengths,
492
+ };
493
+ if (params.resumeFromRunId) {
494
+ const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
495
+ const resumed = await workflowEngine.resume(
496
+ params.resumeFromRunId,
497
+ branchEntries(),
498
+ options,
499
+ replacement,
500
+ );
501
+ if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
502
+ if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
503
+ return textResult(formatStart(resumed), resumed);
504
+ }
505
+ const result = await workflowEngine.start(script, options);
506
+ if (result.background) {
507
+ // The engine resolves the script's meta name for the delivery header.
508
+ void source;
509
+ void deliverBackgroundResult(workflowEngine, result.runId);
510
+ }
511
+ return textResult(formatStart(result), result);
334
512
  } catch (error: unknown) {
335
- return textResult(
336
- protocolError ??
337
- `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
338
- );
513
+ return textResult(error instanceof Error ? error.message : String(error));
339
514
  }
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);
387
- }
388
- return textResult(formatStart(result), result);
389
515
  },
390
516
  }),
391
517
  );
@@ -405,33 +531,46 @@ export default function piWorkflows(pi: ExtensionAPI): void {
405
531
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
406
532
  },
407
533
  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}`);
534
+ try {
535
+ const rawAction = params.action as string;
536
+ const action = (rawAction === "status" ? "get" : rawAction) as
537
+ | "list"
538
+ | "get"
539
+ | "pause"
540
+ | "resume"
541
+ | "stop"
542
+ | "rm";
543
+ if (!["list", "get", "pause", "resume", "stop", "rm"].includes(action)) {
544
+ throw new Error(`unknown workflow_control action: ${params.action}`);
545
+ }
546
+ const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
547
+ const workflowEngine = currentEngine();
548
+ const result = await workflowEngine.control(action, runId);
549
+ return textResult(JSON.stringify(result), result);
550
+ } catch (error: unknown) {
551
+ return textResult(error instanceof Error ? error.message : String(error));
418
552
  }
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
553
  },
423
554
  }),
424
555
  );
425
556
 
426
557
  // /workflows: navigator + run/status/watch/stop/pause/resume/rm/save subcommands
427
558
  pi.registerCommand("workflows", {
428
- description: "Workflow runs: open the navigator, or run/status/watch/stop/pause/resume/rm/save",
559
+ description: "Workflow runs: navigator, or run/status/watch/stop/pause/resume/rm/save/strength",
429
560
  handler: async (args: string, ctx: ExtensionCommandContext) => {
430
561
  const tokens = args.trim().split(/\s+/).filter(Boolean);
431
562
  const [subcommand, ...rest] = tokens;
432
563
  const restArgs = rest.join(" ").trim();
564
+ const engineForCommand = (): WorkflowEngine | undefined => {
565
+ try {
566
+ return currentEngine();
567
+ } catch (error: unknown) {
568
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
569
+ return undefined;
570
+ }
571
+ };
433
572
  if (!subcommand) {
434
- await showWorkflowNavigator(ctx, workflowEngine);
573
+ await showWorkflowNavigator(ctx, currentEngine);
435
574
  return;
436
575
  }
437
576
  if (subcommand === "run") {
@@ -444,6 +583,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
444
583
  return;
445
584
  }
446
585
  if (subcommand === "status") {
586
+ const workflowEngine = engineForCommand();
587
+ if (!workflowEngine) return;
447
588
  if (restArgs) {
448
589
  const run = workflowEngine.getRun(restArgs);
449
590
  if (!run) {
@@ -463,6 +604,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
463
604
  return;
464
605
  }
465
606
  if (subcommand === "watch") {
607
+ const workflowEngine = engineForCommand();
608
+ if (!workflowEngine) return;
466
609
  const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
467
610
  if (!runId) {
468
611
  ctx.ui.notify("No workflow runs to watch.", "warning");
@@ -473,7 +616,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
473
616
  return;
474
617
  }
475
618
  if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
476
- const firstListed = workflowEngine.list()[0]?.runId;
619
+ const listedEngine = engineForCommand();
620
+ if (!listedEngine) return;
621
+ const firstListed = listedEngine.list()[0]?.runId;
477
622
  const runId = resolveControlTarget(
478
623
  subcommand,
479
624
  restArgs,
@@ -490,6 +635,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
490
635
  return;
491
636
  }
492
637
  try {
638
+ const workflowEngine = engineForCommand();
639
+ if (!workflowEngine) return;
493
640
  await workflowEngine.control(subcommand, runId);
494
641
  if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
495
642
  ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
@@ -504,6 +651,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
504
651
  ctx.ui.notify("/workflows save <name> [runId]", "warning");
505
652
  return;
506
653
  }
654
+ const workflowEngine = engineForCommand();
655
+ if (!workflowEngine) return;
507
656
  // Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
508
657
  const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
509
658
  if (!runId) {
@@ -517,23 +666,158 @@ export default function piWorkflows(pi: ExtensionAPI): void {
517
666
  }
518
667
  return;
519
668
  }
669
+ if (subcommand === "strength") {
670
+ // The Agent tier catalogue is owned by pi-subagents and still is (A8:
671
+ // no second tier config file). What this edits is narrower and is
672
+ // genuinely ours: which Agent tier each workflow strength runs on.
673
+ //
674
+ // A subcommand rather than a command of its own, because the reason
675
+ // to surface it at all is the three-part view below — two lines of
676
+ // which come from this package and one from the other — and that is
677
+ // a page to read, not a mode to be in.
678
+ //
679
+ // The catalogue lives on the managed peer, so it can legitimately be
680
+ // unreachable here. Status still has its other two parts, and a set
681
+ // made against a table that already exists is stored unvalidated: the
682
+ // run-start check reports a tier this host does not define and drops
683
+ // that entry, which costs the redirect rather than the run. A set made
684
+ // with no table at all is refused instead — see below, where the seed
685
+ // is read.
686
+ const catalogue = await (async (): Promise<string[] | undefined> => {
687
+ try {
688
+ return Object.keys((await awaitProtocol()).routingPolicy.profiles).sort();
689
+ } catch {
690
+ return undefined;
691
+ }
692
+ })();
693
+ const knownTiers = catalogue ? new Set(catalogue) : undefined;
694
+ // What a run would resolve, by the same rule the runtime uses: a
695
+ // configured table if there is one, else the shipped default. Shown
696
+ // rather than the configured table alone, because an unconfigured
697
+ // machine is not an unrouted one and a row reading "(unset)" there
698
+ // would describe something other than what the next run does.
699
+ const effective = (): { table: WorkflowStrengthTable; shipped: boolean } => {
700
+ const configured = workflowSettings.strengths;
701
+ return configured === undefined
702
+ ? { table: defaultStrengthTable(knownTiers), shipped: true }
703
+ : { table: configured, shipped: false };
704
+ };
705
+ const describe = (): string => {
706
+ const { table, shipped } = effective();
707
+ const rows = WORKFLOW_STRENGTHS.map((name) => {
708
+ // The default is derived from the catalogue, so without one there
709
+ // is nothing honest to print: the run will still route these, and
710
+ // "(unset)" would claim it will not.
711
+ if (shipped && knownTiers === undefined) return ` ${name} → (default; needs the tier catalogue)`;
712
+ if (table[name] === undefined) return ` ${name} → (unset; the agent's own default)`;
713
+ return ` ${name} → ${table[name]}${shipped ? " (default)" : ""}`;
714
+ });
715
+ // The shipped default is identity, so on a stock install workflow
716
+ // work rides the same profiles every ordinary spawn does — the
717
+ // coupling this indirection exists to let you break, but does not
718
+ // break on its own. Saying so here is the only place the two steps
719
+ // out of it are visible together.
720
+ const decouple = shipped
721
+ ? "\nThis is the shipped default: each strength on the tier of the same name, so " +
722
+ "workflow work shares the tiers your ordinary spawns use (Explore among them). " +
723
+ "To price workflow work on its own, define a tier in /agents → Model tiers, then " +
724
+ "point a strength at it here."
725
+ : "";
726
+ return (
727
+ `Workflow strengths and the Agent tier each runs on:\n${rows.join("\n")}\n` +
728
+ `Tiers this host defines: ${catalogue?.join(", ") ?? "(unavailable)"}\n` +
729
+ "Edits apply to this project; for a machine-wide table edit " +
730
+ "<agent dir>/workflows/settings.json. The tiers themselves live in " +
731
+ "/agents → Model tiers; this only says which one a strength uses." +
732
+ decouple
733
+ );
734
+ };
735
+ const [strength, target] = rest;
736
+ if (strength === undefined) {
737
+ ctx.ui.notify(describe(), "info");
738
+ return;
739
+ }
740
+ if (!isWorkflowStrength(strength)) {
741
+ ctx.ui.notify(`"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`, "warning");
742
+ return;
743
+ }
744
+ if (target === undefined || rest.length !== 2) {
745
+ ctx.ui.notify(`/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
746
+ return;
747
+ }
748
+ // `off` reads as "unset" unless the host really defines a tier by
749
+ // that name, in which case the user's own catalogue outranks our
750
+ // keyword — a tier you cannot point at is worse than a clumsier way
751
+ // to clear one.
752
+ const unset = target === "off" && catalogue?.includes("off") !== true;
753
+ if (!unset && catalogue && !catalogue.includes(target)) {
754
+ ctx.ui.notify(
755
+ `"${target}" is not a tier this host defines. Available: ${catalogue.join(", ") || "(none)"}. ` +
756
+ "Define it in /agents → Model tiers first.",
757
+ "warning",
758
+ );
759
+ return;
760
+ }
761
+ const { table: current, shipped } = effective();
762
+ if (shipped && knownTiers === undefined) {
763
+ // Seeding from a default we cannot compute would write a one-entry
764
+ // table, and this key replaces rather than merges — so the other two
765
+ // strengths would go from routed to unmapped as a side effect of
766
+ // setting this one. Refusing costs a retry; writing costs a run.
767
+ ctx.ui.notify(
768
+ "The tier catalogue is unavailable, so the current table cannot be read. " +
769
+ "Retry once the managed peer is reachable.",
770
+ "warning",
771
+ );
772
+ return;
773
+ }
774
+ // Seeded from the table a run would use, not from this project's file.
775
+ // Every level of this key replaces the one below rather than merging
776
+ // into it, so a patch built on project scope would silently drop
777
+ // whatever the level below contributed: adding one entry would discard
778
+ // an inherited global table, and `off` against an entry this project
779
+ // never wrote would write nothing and leave the mapping standing. The
780
+ // seed does freeze the current table into the project, which is what a
781
+ // replace-wholesale key costs — the entries a partial file left out
782
+ // would be unmapped, not inherited.
783
+ const { [strength]: _cleared, ...others } = current;
784
+ try {
785
+ persistWorkflowSetting({ strengths: unset ? others : { ...others, [strength]: target } });
786
+ } catch (error: unknown) {
787
+ ctx.ui.notify(
788
+ `Could not save workflow strength routing: ${error instanceof Error ? error.message : String(error)}`,
789
+ "warning",
790
+ );
791
+ return;
792
+ }
793
+ // Show the resolved result rather than echoing the write. The
794
+ // settings layer can drop what it was handed, and `off` against the
795
+ // last remaining entry stores an explicit empty table — "map nothing",
796
+ // which is not the same as the shipped default an absent key would
797
+ // mean — so a message announcing the write would describe something
798
+ // other than what the next run will do. Runs already in flight keep
799
+ // what they started with; the table is read fresh at each start and
800
+ // resume.
801
+ //
802
+ // The one case the table alone cannot explain: the host defines a tier
803
+ // named `off`, so the keyword lost to it above and this mapped rather
804
+ // than cleared. The catalogue outranking our keyword is the right call
805
+ // — a tier you cannot point at is worse than a clumsier way to clear
806
+ // one — but a user who typed the documented keyword and got a mapping
807
+ // has to be told which reading won, and where the other one lives.
808
+ const shadowed =
809
+ target === "off" && !unset
810
+ ? '\n\nNote: this host defines a tier named "off", so `off` was read as that tier rather ' +
811
+ "than as the clear keyword. To leave a strength unmapped here, remove its entry from the " +
812
+ "`strengths` object in this project's settings.json."
813
+ : "";
814
+ ctx.ui.notify(`${describe()}${shadowed}`, "info");
815
+ return;
816
+ }
520
817
  ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
521
818
  },
522
819
  });
523
820
 
524
- // /workflows-models: tier configuration is owned by pi-subagents; this
525
- // command documents the delegation (A8: no second tier config file).
526
- pi.registerCommand("workflows-models", {
527
- description: "Workflow tier routing: tiers are configured in pi-subagents /agents → Model tiers",
528
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
529
- ctx.ui.notify(
530
- "Workflow tiers (small/medium/large) route through pi-subagents' workflow.tiers settings. " +
531
- "Use /agents → Model tiers to configure them.",
532
- "info",
533
- );
534
- },
535
- });
536
-
537
821
  // /workflows-progress: persistent compact/detailed live-panel settings.
538
822
  pi.registerCommand("workflows-progress", {
539
823
  description: "Workflow progress panel: compact|detailed|status|max <N>",
@@ -547,21 +831,22 @@ export default function piWorkflows(pi: ExtensionAPI): void {
547
831
  );
548
832
  return;
549
833
  }
834
+ let patch: WorkflowSettings;
550
835
  if (action === "compact" || action === "detailed") {
551
- workflowSettings = { ...workflowSettings, progressMode: action };
836
+ patch = { progressMode: action };
552
837
  } else if (action === "max") {
553
838
  const max = Number(tokens[1]);
554
839
  if (!Number.isInteger(max) || max < 1 || max > 32) {
555
840
  ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
556
841
  return;
557
842
  }
558
- workflowSettings = { ...workflowSettings, maxAgentsShown: max };
843
+ patch = { maxAgentsShown: max };
559
844
  } else {
560
845
  ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
561
846
  return;
562
847
  }
563
848
  try {
564
- saveWorkflowSettings(workflowSettings, sessionCwd);
849
+ persistWorkflowSetting(patch);
565
850
  refreshWidget();
566
851
  ctx.ui.notify(
567
852
  `Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
@@ -582,13 +867,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
582
867
  // is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
583
868
  // and it never opens UI. A user asking a question about workflows still gets
584
869
  // an ordinary answer.
585
- let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
586
- let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
587
870
  const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
588
871
  const persistTriggerSettings = (): void => {
589
- workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
590
872
  try {
591
- saveWorkflowSettings(workflowSettings, sessionCwd);
873
+ persistWorkflowSetting({ keywordTriggerWord: triggerKeyword, keywordTriggerEnabled });
592
874
  } catch {
593
875
  /* setting changes remain active in memory */
594
876
  }
@@ -647,7 +929,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
647
929
  if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
648
930
  const effortDirective =
649
931
  effortLevel === "ultra"
650
- ? "Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks; choose the large tier where useful."
932
+ ? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks; give the deepest steps strength: "high".'
651
933
  : effortLevel === "high"
652
934
  ? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
653
935
  : "";
@@ -669,9 +951,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
669
951
  return;
670
952
  }
671
953
  effortLevel = level;
672
- workflowSettings = { ...workflowSettings, effort: effortLevel };
673
954
  try {
674
- saveWorkflowSettings(workflowSettings, sessionCwd);
955
+ persistWorkflowSetting({ effort: effortLevel });
675
956
  } catch {
676
957
  /* warning is non-fatal */
677
958
  }
@@ -687,9 +968,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
687
968
  description: "Ultracode: toggle exhaustive workflow effort",
688
969
  handler: async (args: string, ctx: ExtensionCommandContext) => {
689
970
  effortLevel = args.trim().toLowerCase() === "off" ? "off" : "ultra";
690
- workflowSettings = { ...workflowSettings, effort: effortLevel };
691
971
  try {
692
- saveWorkflowSettings(workflowSettings, sessionCwd);
972
+ persistWorkflowSetting({ effort: effortLevel });
693
973
  } catch {
694
974
  /* warning is non-fatal */
695
975
  }
@@ -708,7 +988,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
708
988
  // unregister or replace a command, so clobbering one is permanent.
709
989
  const claimedCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
710
990
  for (const name of Object.keys(BUILTIN_WORKFLOWS)) {
711
- if (claimedCommands.has(name)) continue;
991
+ if (claimedCommands.has(name) || registeredWorkflowCommands.has(name)) continue;
712
992
  const descriptor = BUILTIN_WORKFLOWS[name];
713
993
  pi.registerCommand(name, {
714
994
  description: `Run the ${name} workflow`,
@@ -737,53 +1017,32 @@ export default function piWorkflows(pi: ExtensionAPI): void {
737
1017
  background: true,
738
1018
  toolset: descriptor.toolset,
739
1019
  excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
740
- loadSavedWorkflow: (savedName) =>
741
- loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script,
1020
+ loadSavedWorkflow: resolveNestedWorkflow,
1021
+ strengths: workflowSettings.strengths,
742
1022
  };
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");
1023
+ try {
1024
+ const workflowEngine = currentEngine();
1025
+ const result = await workflowEngine.start(descriptor.script, options);
1026
+ void deliverBackgroundResult(workflowEngine, result.runId);
1027
+ ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
1028
+ } catch (error: unknown) {
1029
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1030
+ }
746
1031
  },
747
1032
  });
1033
+ registeredWorkflowCommands.add(name);
748
1034
  }
749
1035
 
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
-
1036
+ // Live progress widget refreshes through the current engine, so its timer
1037
+ // and settlement callback remain safe across session replacement.
785
1038
  const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
786
1039
  if (!branchQuiesce) {
1040
+ let workflowEngine: WorkflowEngine;
1041
+ try {
1042
+ workflowEngine = currentEngine();
1043
+ } catch {
1044
+ return Promise.resolve({ settled: true, pending: [] });
1045
+ }
787
1046
  branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
788
1047
  if (!result.settled) {
789
1048
  console.warn(
@@ -796,7 +1055,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
796
1055
  return branchQuiesce;
797
1056
  };
798
1057
 
799
- unsubscribeTreeSignal = pi.events.on("subagents:session_before_tree", () => {
1058
+ pi.events.on("subagents:session_before_tree", () => {
800
1059
  void beginBranchQuiesce();
801
1060
  });
802
1061
 
@@ -810,6 +1069,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
810
1069
  } catch {
811
1070
  return;
812
1071
  }
1072
+ let workflowEngine: WorkflowEngine;
1073
+ try {
1074
+ workflowEngine = currentEngine();
1075
+ } catch {
1076
+ return;
1077
+ }
813
1078
  workflowEngine.suspendLifecycle();
814
1079
  try {
815
1080
  workflowEngine.restore(entries);
@@ -894,18 +1159,22 @@ export default function piWorkflows(pi: ExtensionAPI): void {
894
1159
  // while the graph validates dependencies before dispatching any workers.
895
1160
  const script = buildAdHocWorkflowScript(prompt);
896
1161
  try {
897
- await protocolCheck;
898
- const result = await currentEngine().start(script, { background: true, mainModel: ctx.model?.id });
899
- void deliverBackgroundResult(currentEngine(), result.runId);
1162
+ const workflowEngine = currentEngine();
1163
+ const result = await workflowEngine.start(script, {
1164
+ background: true,
1165
+ strengths: workflowSettings.strengths,
1166
+ mainModel: ctx.model?.id,
1167
+ });
1168
+ void deliverBackgroundResult(workflowEngine, result.runId);
900
1169
  return result.runId;
901
1170
  } catch (error: unknown) {
902
- ctx.ui.notify(protocolError ?? (error instanceof Error ? error.message : String(error)), "error");
1171
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
903
1172
  return undefined;
904
1173
  }
905
1174
  };
906
1175
 
907
1176
  const saveWorkflowFromRun = async (ctx: ExtensionCommandContext, name: string, runId?: string): Promise<void> => {
908
- const state = engine?.getState(runId ?? "");
1177
+ const state = currentEngine().getState(runId ?? "");
909
1178
  if (!state) {
910
1179
  throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
911
1180
  }
@@ -919,6 +1188,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
919
1188
  sessionCwd = ctx.cwd ?? process.cwd();
920
1189
  workflowSettings = loadWorkflowSettings(sessionCwd);
921
1190
  effortLevel = workflowSettings.effort;
1191
+ triggerKeyword = workflowSettings.keywordTriggerWord;
1192
+ keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
922
1193
  let initialEntries: SessionEntryLike[];
923
1194
  try {
924
1195
  initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
@@ -936,6 +1207,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
936
1207
 
937
1208
  protocolAbortController = new AbortController();
938
1209
  const client = createManagedSpawnClient(pi.events, protocolAbortController.signal, protocolAbortController.signal);
1210
+ protocolError = undefined;
1211
+ protocolProbe = () =>
1212
+ client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
1213
+ protocolCheck = trackProbe(protocolProbe());
1214
+ void protocolCheck.catch((error: unknown) => {
1215
+ if (generation !== lifecycleGeneration) return;
1216
+ const detail = error instanceof Error ? error.message : String(error);
1217
+ protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
1218
+ });
939
1219
 
940
1220
  let recoveredEngine: WorkflowEngine | undefined;
941
1221
  let recoveryError: unknown;
@@ -955,6 +1235,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
955
1235
  return [];
956
1236
  }
957
1237
  },
1238
+ awaitProtocol,
958
1239
  );
959
1240
  try {
960
1241
  const entries =
@@ -1036,36 +1317,40 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1036
1317
  // name clashes first — never overwrite another extension's command.
1037
1318
  const existingCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
1038
1319
  for (const savedName of listSavedWorkflows(sessionCwd)) {
1039
- if (existingCommands.has(savedName)) continue;
1040
- const script = loadSavedWorkflow(savedName, sessionCwd);
1041
- if (!script) continue;
1320
+ if (existingCommands.has(savedName) || registeredWorkflowCommands.has(savedName)) continue;
1321
+ if (!loadSavedWorkflow(savedName, sessionCwd)) continue;
1042
1322
  pi.registerCommand(savedName, {
1043
1323
  description: `Run the saved workflow ${savedName}`,
1044
1324
  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");
1325
+ try {
1326
+ const workflowEngine = currentEngine();
1327
+ const script = loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script;
1328
+ if (!script) throw new Error(`Saved workflow "${savedName}" is no longer available.`);
1329
+ const options: ScriptStartOptions = {
1330
+ args: args.trim() ? { prompt: args.trim() } : undefined,
1331
+ background: true,
1332
+ mainModel: commandCtx?.model?.id,
1333
+ loadSavedWorkflow: resolveNestedWorkflow,
1334
+ strengths: workflowSettings.strengths,
1335
+ };
1336
+ const result = await workflowEngine.start(script, options);
1337
+ void deliverBackgroundResult(workflowEngine, result.runId);
1338
+ commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
1339
+ } catch (error: unknown) {
1340
+ commandCtx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1341
+ }
1054
1342
  },
1055
1343
  });
1344
+ registeredWorkflowCommands.add(savedName);
1056
1345
  }
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
1346
  });
1066
1347
 
1067
1348
  pi.on("session_shutdown", async () => {
1068
1349
  lifecycleGeneration += 1;
1350
+ // Invalidate all long-lived command/tool/widget closures before awaiting
1351
+ // engine quiescence; the old session must not accept another run.
1352
+ active = false;
1353
+ clearWidget();
1069
1354
  if (widgetTimer) {
1070
1355
  clearInterval(widgetTimer);
1071
1356
  widgetTimer = undefined;
@@ -1076,13 +1361,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1076
1361
  }
1077
1362
  protocolAbortController?.abort();
1078
1363
  protocolAbortController = undefined;
1079
- unsubscribeTreeSignal?.();
1080
- unsubscribeTreeSignal = undefined;
1081
1364
  closingEngine?.dispose();
1082
1365
  engine = undefined;
1083
- active = false;
1084
1366
  branchQuiesce = undefined;
1085
- protocolCheck = Promise.resolve();
1367
+ protocolCheck = undefined;
1368
+ protocolProbe = undefined;
1369
+ protocolInFlight = undefined;
1086
1370
  protocolError = undefined;
1087
1371
  deliverResult = undefined;
1088
1372
  });