@signalridge/pi-workflows 1.8.0 → 1.10.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
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { defineTool } from "@earendil-works/pi-coding-agent";
5
- import { Text } from "@earendil-works/pi-tui";
5
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
6
6
  import { sanitizeDisplayText } from "@signalridge/pi-ui";
7
7
  import { Type } from "@sinclair/typebox";
8
8
  import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMING_MESSAGE_TYPE, WorkflowTurnArming } from "./arming.js";
@@ -17,6 +17,7 @@ import {
17
17
  queryChildSessionContextImmediate,
18
18
  } from "./rpc-client.js";
19
19
  import { listSavedWorkflows, loadSavedWorkflow, saveWorkflow } from "./saved-workflows.js";
20
+ import { guardStatusMount, StatusActions, type StatusSurface, workflowStatusSummary } from "./status-actions.js";
20
21
  import {
21
22
  defaultStrengthTable,
22
23
  isWorkflowStrength,
@@ -25,6 +26,7 @@ import {
25
26
  type WorkflowStrengthTable,
26
27
  } from "./strengths.js";
27
28
  import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
29
+ import { buildWorkflowUiSnapshot, openOwnedConversation, WorkflowUiBridge, type WorkflowUiSurface } from "./ui-bus.js";
28
30
  import {
29
31
  loadProjectWorkflowSettings,
30
32
  loadWorkflowSettings,
@@ -111,6 +113,15 @@ function textResult(text: string, details: unknown = {}) {
111
113
  };
112
114
  }
113
115
 
116
+ /** Bounded display copies; command notifications never render stored control bytes. */
117
+ function notifyWorkflow(
118
+ ctx: Pick<ExtensionContext, "ui">,
119
+ message: string,
120
+ type: "info" | "warning" | "error" = "info",
121
+ ): void {
122
+ ctx.ui.notify(sanitizeDisplayText(message.slice(0, 8_000), { multiline: true }).slice(0, 4_000), type);
123
+ }
124
+
114
125
  /**
115
126
  * Run one command for diff discovery. argv array, never a shell string — a
116
127
  * branch name or path reaches this from user input.
@@ -366,6 +377,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
366
377
  const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
367
378
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
368
379
  let widgetUi: ExtensionContext["ui"] | undefined;
380
+ let workflowUiBridge: WorkflowUiBridge | undefined;
381
+ let workflowUiSurfaceId = randomUUID();
382
+ let refreshQueued = false;
383
+ let statusActions: StatusActions | undefined;
384
+ let statusSource: (() => StatusSurface | undefined) | undefined;
385
+ const navigatorOpen = new Map<string, number>();
386
+ const retireStatusActions = (): void => {
387
+ const hadStatus = !!statusSource;
388
+ statusActions?.dispose();
389
+ statusActions = undefined;
390
+ statusSource = undefined;
391
+ if (hadStatus && deliveryContext?.mode === "tui") deliveryContext.ui.setStatus("pi-workflows", undefined);
392
+ };
369
393
 
370
394
  const currentEngine = (): WorkflowEngine => {
371
395
  if (!active || !engine || engine.isDisposed())
@@ -391,11 +415,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
391
415
  if (!busy) return undefined;
392
416
  const notice =
393
417
  "Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
394
- if (ctx.hasUI) ctx.ui.notify(notice, "warning");
418
+ if (ctx.hasUI) notifyWorkflow(ctx, notice, "warning");
395
419
  else console.warn(`[pi-workflows] ${notice}`);
396
420
  return { cancel: true };
397
421
  };
398
422
  const invalidateDelivery = (): void => {
423
+ navigatorOpen.delete(workflowUiSurfaceId);
424
+ retireStatusActions();
425
+ workflowUiBridge?.retire(workflowUiSurfaceId);
426
+ workflowUiSurfaceId = randomUUID();
399
427
  deliveryGeneration++;
400
428
  deliveryObservers.clear();
401
429
  turnArming.retire();
@@ -467,16 +495,56 @@ export default function piWorkflows(pi: ExtensionAPI): void {
467
495
  .list()
468
496
  .map((summary) => workflowEngine.getRun(String(summary.runId)))
469
497
  .filter((run): run is NonNullable<typeof run> => run !== undefined);
470
- const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
471
- tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
472
- if (lines.length > 0 && !widgetTimer) {
498
+ if (statusSource?.()) tui.setStatus("pi-workflows", workflowStatusSummary(runs));
499
+ const hasActive = runs.some((run) => !["completed", "failed", "stopped"].includes(run.status));
500
+ const surfaceId = workflowUiSurfaceId;
501
+ tui.setWidget(
502
+ "pi-workflows",
503
+ hasActive
504
+ ? (host, theme) => ({
505
+ render(width) {
506
+ if (
507
+ !active ||
508
+ engine !== workflowEngine ||
509
+ workflowEngine.isDisposed() ||
510
+ workflowUiSurfaceId !== surfaceId ||
511
+ !Number.isFinite(width) ||
512
+ width < 1
513
+ )
514
+ return [];
515
+ const height = Number.isFinite(host.terminal.rows) ? Math.max(0, host.terminal.rows) : 24;
516
+ const rowBudget = Math.max(1, Math.min(12, Math.floor(height / 4)));
517
+ return liveWidgetLines(
518
+ runs,
519
+ workflowSettings.progressMode,
520
+ workflowSettings.maxAgentsShown,
521
+ rowBudget,
522
+ ).map((line, index) =>
523
+ truncateToWidth(theme.fg(index === 0 ? "accent" : "muted", line), Math.floor(width)),
524
+ );
525
+ },
526
+ invalidate() {},
527
+ })
528
+ : undefined,
529
+ );
530
+ workflowUiBridge?.publish();
531
+ if (hasActive && !widgetTimer) {
473
532
  widgetTimer = setInterval(refreshWidget, 2_000);
474
- } else if (lines.length === 0 && widgetTimer) {
533
+ } else if (!hasActive && widgetTimer) {
475
534
  clearInterval(widgetTimer);
476
535
  widgetTimer = undefined;
477
536
  }
478
537
  };
479
- const notifyRunChanged = (): void => refreshWidget();
538
+ const notifyRunChanged = (): void => {
539
+ if (refreshQueued) return;
540
+ refreshQueued = true;
541
+ const surfaceId = workflowUiSurfaceId;
542
+ queueMicrotask(() => {
543
+ refreshQueued = false;
544
+ if (surfaceId !== workflowUiSurfaceId) return;
545
+ refreshWidget();
546
+ });
547
+ };
480
548
 
481
549
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
482
550
 
@@ -524,6 +592,139 @@ export default function piWorkflows(pi: ExtensionAPI): void {
524
592
  throw new Error("Provide either `script` or `name`.");
525
593
  };
526
594
 
595
+ const showNavigator = async (ctx: ExtensionContext, initialRunId?: string, guard?: () => void): Promise<void> => {
596
+ guard?.();
597
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
598
+ await showWorkflowNavigator(ctx, currentEngine, { initialRunId });
599
+ return;
600
+ }
601
+ const selectedEngine = currentEngine();
602
+ const surfaceId = workflowUiSurfaceId;
603
+ const manager = ctx.sessionManager;
604
+ const sessionId = manager.getSessionId();
605
+ const resolve = (): WorkflowEngine => {
606
+ guard?.(); // Keep a mounted status navigator bound to its exact source.
607
+ if (
608
+ surfaceId !== workflowUiSurfaceId ||
609
+ selectedEngine !== currentEngine() ||
610
+ manager !== deliveryContext?.sessionManager ||
611
+ manager.getSessionId() !== sessionId ||
612
+ !ownsDeliveryBranch()
613
+ )
614
+ throw new Error("Workflow inspector belongs to a retired session or branch.");
615
+ return selectedEngine;
616
+ };
617
+ navigatorOpen.set(surfaceId, (navigatorOpen.get(surfaceId) ?? 0) + 1);
618
+ try {
619
+ await showWorkflowNavigator(guard ? guardStatusMount(ctx, guard) : ctx, resolve, {
620
+ initialRunId,
621
+ getPresentation: (runId) => resolve().getInspection(runId),
622
+ openConversation: async (runId, callIndex) => {
623
+ const current = resolve();
624
+ if (!/^(?:0|[1-9]\d*)$/u.test(callIndex)) throw new Error("This call has no retained root conversation.");
625
+ const state = current.getState(runId);
626
+ const run = state?.run;
627
+ const agentId = run?.agentIds[callIndex];
628
+ const attempt = run?.attempts[callIndex];
629
+ if (!state || !run || !agentId || !Number.isInteger(attempt) || (attempt ?? 0) < 1)
630
+ throw new Error("No retained conversation is available for this call.");
631
+ const executionGeneration = state.executionGeneration;
632
+ await openOwnedConversation(
633
+ pi.events,
634
+ agentId,
635
+ { extension: "pi-workflows", runId, nodeId: `call-${callIndex}`, attemptId: `attempt-${attempt}` },
636
+ () => {
637
+ try {
638
+ return (
639
+ resolve().getState(runId) === state &&
640
+ state.executionGeneration === executionGeneration &&
641
+ run.agentIds[callIndex] === agentId &&
642
+ run.attempts[callIndex] === attempt
643
+ );
644
+ } catch {
645
+ return false;
646
+ }
647
+ },
648
+ );
649
+ },
650
+ });
651
+ } finally {
652
+ // An obsolete factory cannot hold or release a replacement surface's slot.
653
+ const remaining = (navigatorOpen.get(surfaceId) ?? 0) - 1;
654
+ if (remaining > 0) navigatorOpen.set(surfaceId, remaining);
655
+ else navigatorOpen.delete(surfaceId);
656
+ }
657
+ };
658
+
659
+ const registerStatusActions = (): void => {
660
+ const ctx = deliveryContext;
661
+ if (!active || !engine || ctx?.mode !== "tui" || !ctx.hasUI) return;
662
+ const selectedEngine = currentEngine();
663
+ const surfaceId = workflowUiSurfaceId;
664
+ const manager = ctx.sessionManager;
665
+ const sessionId = manager.getSessionId();
666
+ let branchIds = manager.getBranch().map((entry) => entry.id);
667
+ let retired = false;
668
+ const source = (): StatusSurface | undefined => {
669
+ if (retired) return;
670
+ let valid = false;
671
+ try {
672
+ const branch = manager.getBranch().map((entry) => entry.id);
673
+ valid =
674
+ active &&
675
+ engine === selectedEngine &&
676
+ !selectedEngine.isDisposed() &&
677
+ workflowUiSurfaceId === surfaceId &&
678
+ deliveryContext?.sessionManager === manager &&
679
+ manager.getSessionId() === sessionId &&
680
+ ctx.mode === "tui" &&
681
+ ctx.hasUI &&
682
+ ownsDeliveryBranch() &&
683
+ branchIds.every((id, index) => branch[index] === id);
684
+ if (valid) branchIds = branch;
685
+ } catch {
686
+ // A disposed context permanently retires this presentation surface.
687
+ }
688
+ if (!valid) {
689
+ retired = true;
690
+ if (statusSource === source) retireStatusActions();
691
+ return;
692
+ }
693
+ return {
694
+ owner: selectedEngine,
695
+ surfaceId,
696
+ sessionId,
697
+ busy: () => (navigatorOpen.get(surfaceId) ?? 0) > 0,
698
+ open: () =>
699
+ showNavigator(ctx, undefined, () => {
700
+ if (!source()) throw new Error("Workflow status surface is retired.");
701
+ }),
702
+ };
703
+ };
704
+ statusSource = source;
705
+ statusActions = new StatusActions(
706
+ pi.events,
707
+ { providerId: "pi-workflows", statusKey: "pi-workflows", actionId: "runs", label: "Workflow runs" },
708
+ source,
709
+ );
710
+ if (source())
711
+ ctx.ui.setStatus(
712
+ "pi-workflows",
713
+ workflowStatusSummary(selectedEngine.list().map((run) => ({ status: String(run.status) }))),
714
+ );
715
+ };
716
+
717
+ const workflowUiSurface = (): WorkflowUiSurface | undefined => {
718
+ const ctx = deliveryContext;
719
+ if (!active || !engine || engine.isDisposed() || ctx?.mode !== "tui" || !ctx.hasUI || !ownsDeliveryBranch())
720
+ return undefined;
721
+ return {
722
+ snapshot: buildWorkflowUiSnapshot(engine, workflowUiSurfaceId),
723
+ open: (runId) => showNavigator(ctx, runId),
724
+ notifyError: (message) => notifyWorkflow(ctx, message, "error"),
725
+ };
726
+ };
727
+
527
728
  const registerSurface = (): void => {
528
729
  if (active) return;
529
730
  active = true;
@@ -531,6 +732,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
531
732
  workflowEngine.onRunSettled = notifyRunChanged;
532
733
  workflowEngine.onBackgroundExecution = (runId) => void deliverBackgroundResult(workflowEngine, runId);
533
734
  workflowEngine.onRuntimeEvent = notifyRunChanged;
735
+ workflowEngine.onInspectionChanged = notifyRunChanged;
534
736
  if (typeof pi.registerMessageRenderer === "function") {
535
737
  pi.registerMessageRenderer("pi-workflows:delivery", (message, { expanded }, theme) => {
536
738
  const original =
@@ -549,6 +751,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
549
751
  // never frozen onto a run. Without this they would resume with no table and
550
752
  // re-run every finished call untabled.
551
753
  workflowEngine.strengths = () => workflowSettings.strengths;
754
+ workflowEngine.loadSavedWorkflow = resolveNestedWorkflow;
552
755
  // Command/tool registrations persist across session replacement, but the
553
756
  // widget belongs to the newly restored engine and must be refreshed too.
554
757
  refreshWidget();
@@ -712,21 +915,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
712
915
  try {
713
916
  return currentEngine();
714
917
  } catch (error: unknown) {
715
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
918
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
716
919
  return undefined;
717
920
  }
718
921
  };
719
922
  if (!subcommand) {
720
- await showWorkflowNavigator(ctx, currentEngine);
923
+ await showNavigator(ctx);
721
924
  return;
722
925
  }
723
926
  if (subcommand === "run") {
724
927
  if (!restArgs) {
725
- ctx.ui.notify("/workflows run <prompt-or-script>", "warning");
928
+ notifyWorkflow(ctx, "/workflows run <prompt-or-script>", "warning");
726
929
  return;
727
930
  }
728
931
  const runId = await runWorkflowFromPrompt(ctx, restArgs);
729
- if (runId) ctx.ui.notify(`Workflow started in background: ${runId}`, "info");
932
+ if (runId) notifyWorkflow(ctx, `Workflow started in background: ${runId}`, "info");
730
933
  return;
731
934
  }
732
935
  if (subcommand === "status") {
@@ -735,14 +938,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
735
938
  if (restArgs) {
736
939
  const run = workflowEngine.getRun(restArgs);
737
940
  if (!run) {
738
- ctx.ui.notify(`Workflow run not found: ${restArgs}`, "warning");
941
+ notifyWorkflow(ctx, `Workflow run not found: ${restArgs}`, "warning");
739
942
  return;
740
943
  }
741
- ctx.ui.notify(`${run.runId} · ${run.status} · ${String(run.meta?.name ?? run.runId)}`, "info");
944
+ notifyWorkflow(ctx, `${run.runId} · ${run.status} · ${String(run.meta?.name ?? run.runId)}`, "info");
742
945
  return;
743
946
  }
744
947
  const runs = workflowEngine.list();
745
- ctx.ui.notify(
948
+ notifyWorkflow(
949
+ ctx,
746
950
  runs.length === 0
747
951
  ? "No workflow runs."
748
952
  : runs.map((r) => `${r.runId} · ${r.status} · ${String(r.name)}`).join("\n"),
@@ -755,11 +959,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
755
959
  if (!workflowEngine) return;
756
960
  const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
757
961
  if (!runId) {
758
- ctx.ui.notify("No workflow runs to watch.", "warning");
962
+ notifyWorkflow(ctx, "No workflow runs to watch.", "warning");
963
+ return;
964
+ }
965
+ const run = workflowEngine.getRun(runId);
966
+ if (!run) {
967
+ notifyWorkflow(ctx, `Workflow run not found: ${runId}`, "warning");
759
968
  return;
760
969
  }
761
- const run = await workflowEngine.waitFor(runId).catch(() => undefined);
762
- if (run) ctx.ui.notify(`${run.runId} settled as ${run.status}`, "info");
970
+ if (ctx.mode === "tui" && ctx.hasUI) await showNavigator(ctx, runId);
971
+ else notifyWorkflow(ctx, `${run.runId} · ${run.status} — use workflow_control outside TUI.`, "info");
763
972
  return;
764
973
  }
765
974
  if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
@@ -773,7 +982,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
773
982
  );
774
983
  if (!runId) {
775
984
  const destructive = subcommand === "stop" || subcommand === "rm";
776
- ctx.ui.notify(
985
+ notifyWorkflow(
986
+ ctx,
777
987
  destructive
778
988
  ? `/workflows ${subcommand} <runId> — a run id is required; /workflows status lists them`
779
989
  : `/workflows ${subcommand} <runId>`,
@@ -786,16 +996,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
786
996
  if (!workflowEngine) return;
787
997
  await workflowEngine.control(subcommand, runId);
788
998
  if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
789
- ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
999
+ notifyWorkflow(ctx, `Workflow ${subcommand} requested: ${runId}`, "info");
790
1000
  } catch (error: unknown) {
791
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1001
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
792
1002
  }
793
1003
  return;
794
1004
  }
795
1005
  if (subcommand === "save") {
796
1006
  const name = rest[0] ?? "";
797
1007
  if (!name) {
798
- ctx.ui.notify("/workflows save <name> [runId]", "warning");
1008
+ notifyWorkflow(ctx, "/workflows save <name> [runId]", "warning");
799
1009
  return;
800
1010
  }
801
1011
  const workflowEngine = engineForCommand();
@@ -803,13 +1013,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
803
1013
  // Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
804
1014
  const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
805
1015
  if (!runId) {
806
- ctx.ui.notify("No workflow runs to save.", "warning");
1016
+ notifyWorkflow(ctx, "No workflow runs to save.", "warning");
807
1017
  return;
808
1018
  }
809
1019
  try {
810
1020
  await saveWorkflowFromRun(ctx, name, runId);
811
1021
  } catch (error: unknown) {
812
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1022
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
813
1023
  }
814
1024
  return;
815
1025
  }
@@ -881,15 +1091,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
881
1091
  };
882
1092
  const [strength, target] = rest;
883
1093
  if (strength === undefined) {
884
- ctx.ui.notify(describe(), "info");
1094
+ notifyWorkflow(ctx, describe(), "info");
885
1095
  return;
886
1096
  }
887
1097
  if (!isWorkflowStrength(strength)) {
888
- ctx.ui.notify(`"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`, "warning");
1098
+ notifyWorkflow(
1099
+ ctx,
1100
+ `"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`,
1101
+ "warning",
1102
+ );
889
1103
  return;
890
1104
  }
891
1105
  if (target === undefined || rest.length !== 2) {
892
- ctx.ui.notify(`/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
1106
+ notifyWorkflow(ctx, `/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
893
1107
  return;
894
1108
  }
895
1109
  // `off` reads as "unset" unless the host really defines a tier by
@@ -898,7 +1112,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
898
1112
  // to clear one.
899
1113
  const unset = target === "off" && catalogue?.includes("off") !== true;
900
1114
  if (!unset && catalogue && !catalogue.includes(target)) {
901
- ctx.ui.notify(
1115
+ notifyWorkflow(
1116
+ ctx,
902
1117
  `"${target}" is not a tier this host defines. Available: ${catalogue.join(", ") || "(none)"}. ` +
903
1118
  "Define it in /agents → Model tiers first.",
904
1119
  "warning",
@@ -911,7 +1126,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
911
1126
  // table, and this key replaces rather than merges — so the other two
912
1127
  // strengths would go from routed to unmapped as a side effect of
913
1128
  // setting this one. Refusing costs a retry; writing costs a run.
914
- ctx.ui.notify(
1129
+ notifyWorkflow(
1130
+ ctx,
915
1131
  "The tier catalogue is unavailable, so the current table cannot be read. " +
916
1132
  "Retry once the managed peer is reachable.",
917
1133
  "warning",
@@ -931,7 +1147,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
931
1147
  try {
932
1148
  persistWorkflowSetting({ strengths: unset ? others : { ...others, [strength]: target } });
933
1149
  } catch (error: unknown) {
934
- ctx.ui.notify(
1150
+ notifyWorkflow(
1151
+ ctx,
935
1152
  `Could not save workflow strength routing: ${error instanceof Error ? error.message : String(error)}`,
936
1153
  "warning",
937
1154
  );
@@ -958,10 +1175,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
958
1175
  "than as the clear keyword. To leave a strength unmapped here, remove its entry from the " +
959
1176
  "`strengths` object in this project's settings.json."
960
1177
  : "";
961
- ctx.ui.notify(`${describe()}${shadowed}`, "info");
1178
+ notifyWorkflow(ctx, `${describe()}${shadowed}`, "info");
962
1179
  return;
963
1180
  }
964
- ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
1181
+ notifyWorkflow(ctx, `Unknown /workflows subcommand: ${subcommand}`, "warning");
965
1182
  },
966
1183
  });
967
1184
 
@@ -972,7 +1189,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
972
1189
  const tokens = args.trim().split(/\s+/).filter(Boolean);
973
1190
  const action = tokens[0] ?? "status";
974
1191
  if (action === "status") {
975
- ctx.ui.notify(
1192
+ notifyWorkflow(
1193
+ ctx,
976
1194
  `Workflow progress: ${workflowSettings.progressMode}, max agents shown: ${workflowSettings.maxAgentsShown}`,
977
1195
  "info",
978
1196
  );
@@ -984,23 +1202,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
984
1202
  } else if (action === "max") {
985
1203
  const max = Number(tokens[1]);
986
1204
  if (!Number.isInteger(max) || max < 1 || max > 32) {
987
- ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
1205
+ notifyWorkflow(ctx, "Usage: /workflows-progress max <1-32>", "warning");
988
1206
  return;
989
1207
  }
990
1208
  patch = { maxAgentsShown: max };
991
1209
  } else {
992
- ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
1210
+ notifyWorkflow(ctx, "Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
993
1211
  return;
994
1212
  }
995
1213
  try {
996
1214
  persistWorkflowSetting(patch);
997
1215
  refreshWidget();
998
- ctx.ui.notify(
1216
+ notifyWorkflow(
1217
+ ctx,
999
1218
  `Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
1000
1219
  "info",
1001
1220
  );
1002
1221
  } catch (error: unknown) {
1003
- ctx.ui.notify(
1222
+ notifyWorkflow(
1223
+ ctx,
1004
1224
  `Could not save workflow progress settings: ${error instanceof Error ? error.message : String(error)}`,
1005
1225
  "warning",
1006
1226
  );
@@ -1029,24 +1249,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1029
1249
  if (token === "off") {
1030
1250
  keywordTriggerEnabled = false;
1031
1251
  persistTriggerSettings();
1032
- ctx.ui.notify("Workflow keyword trigger disabled.", "info");
1252
+ notifyWorkflow(ctx, "Workflow keyword trigger disabled.", "info");
1033
1253
  return;
1034
1254
  }
1035
1255
  if (token === "on") {
1036
1256
  keywordTriggerEnabled = true;
1037
1257
  persistTriggerSettings();
1038
- ctx.ui.notify(`Workflow keyword trigger armed on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}".`, "info");
1258
+ notifyWorkflow(ctx, `Workflow keyword trigger armed on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}".`, "info");
1039
1259
  return;
1040
1260
  }
1041
1261
  if (token === "reset") {
1042
1262
  triggerKeyword = undefined;
1043
1263
  keywordTriggerEnabled = true;
1044
1264
  persistTriggerSettings();
1045
- ctx.ui.notify(`Workflow keyword trigger reset to default "${DEFAULT_TRIGGER_WORD}".`, "info");
1265
+ notifyWorkflow(ctx, `Workflow keyword trigger reset to default "${DEFAULT_TRIGGER_WORD}".`, "info");
1046
1266
  return;
1047
1267
  }
1048
1268
  if (token === "status") {
1049
- ctx.ui.notify(
1269
+ notifyWorkflow(
1270
+ ctx,
1050
1271
  `Workflow keyword trigger: ${keywordTriggerEnabled ? "armed" : "disabled"} on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}"`,
1051
1272
  "info",
1052
1273
  );
@@ -1054,13 +1275,17 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1054
1275
  }
1055
1276
  const keyword = token === "set" ? (args.trim().split(/\s+/)[1] ?? "") : token;
1056
1277
  if (!TRIGGER_WORD_PATTERN.test(keyword)) {
1057
- ctx.ui.notify("Usage: /workflows-trigger set <one-word-keyword> | off | on | reset | status", "warning");
1278
+ notifyWorkflow(
1279
+ ctx,
1280
+ "Usage: /workflows-trigger set <one-word-keyword> | off | on | reset | status",
1281
+ "warning",
1282
+ );
1058
1283
  return;
1059
1284
  }
1060
1285
  triggerKeyword = keyword.toLowerCase();
1061
1286
  keywordTriggerEnabled = true;
1062
1287
  persistTriggerSettings();
1063
- ctx.ui.notify(`Workflow keyword trigger armed on "${keyword}".`, "info");
1288
+ notifyWorkflow(ctx, `Workflow keyword trigger armed on "${keyword}".`, "info");
1064
1289
  },
1065
1290
  });
1066
1291
 
@@ -1132,7 +1357,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1132
1357
  handler: async (args: string, ctx: ExtensionCommandContext) => {
1133
1358
  const level = args.trim().toLowerCase();
1134
1359
  if (level !== "off" && level !== "high" && level !== "ultra") {
1135
- ctx.ui.notify(`Effort is currently "${effortLevel}". Usage: /effort off|high|ultra`, "info");
1360
+ notifyWorkflow(ctx, `Effort is currently "${effortLevel}". Usage: /effort off|high|ultra`, "info");
1136
1361
  return;
1137
1362
  }
1138
1363
  effortLevel = level;
@@ -1141,7 +1366,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1141
1366
  } catch {
1142
1367
  /* warning is non-fatal */
1143
1368
  }
1144
- ctx.ui.notify(
1369
+ notifyWorkflow(
1370
+ ctx,
1145
1371
  level === "off"
1146
1372
  ? "Effort off — messages are no longer auto-armed."
1147
1373
  : `Effort ${level} enabled for substantive messages.`,
@@ -1158,7 +1384,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1158
1384
  } catch {
1159
1385
  /* warning is non-fatal */
1160
1386
  }
1161
- ctx.ui.notify(
1387
+ notifyWorkflow(
1388
+ ctx,
1162
1389
  effortLevel === "off"
1163
1390
  ? "Ultracode off."
1164
1391
  : "Ultracode on — substantive messages use exhaustive workflow guidance.",
@@ -1186,7 +1413,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1186
1413
  if (name === "code-review") {
1187
1414
  const cwd = ctx.cwd;
1188
1415
  const scope = resolveCodeReviewScope(text, (file, argv) => runCommand(file, argv, cwd));
1189
- for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
1416
+ for (const notice of scope.notices) notifyWorkflow(ctx, notice, "warning");
1190
1417
  if (!scope.diff.trim()) return;
1191
1418
  scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
1192
1419
  } else {
@@ -1195,7 +1422,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1195
1422
  try {
1196
1423
  validateBuiltinArgs(name, scriptArgs);
1197
1424
  } catch (error: unknown) {
1198
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
1425
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "warning");
1199
1426
  return;
1200
1427
  }
1201
1428
  const options: ScriptStartOptions = {
@@ -1210,9 +1437,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1210
1437
  const workflowEngine = currentEngine();
1211
1438
  const result = await workflowEngine.start(descriptor.script, options);
1212
1439
  void deliverBackgroundResult(workflowEngine, result.runId);
1213
- ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
1440
+ notifyWorkflow(ctx, `Workflow ${name} started in background: ${result.runId}`, "info");
1214
1441
  } catch (error: unknown) {
1215
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1442
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
1216
1443
  }
1217
1444
  },
1218
1445
  });
@@ -1307,6 +1534,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1307
1534
  workflowEngine.resumeLifecycle();
1308
1535
  deliveryContext = ctx;
1309
1536
  deliveryBranchAnchor = journalBranchAnchor;
1537
+ registerStatusActions();
1310
1538
  hydratePendingDeliveries(entries);
1311
1539
  schedulePendingDeliveries();
1312
1540
  } catch (error: unknown) {
@@ -1528,7 +1756,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1528
1756
  void deliverBackgroundResult(workflowEngine, result.runId);
1529
1757
  return result.runId;
1530
1758
  } catch (error: unknown) {
1531
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1759
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
1532
1760
  return undefined;
1533
1761
  }
1534
1762
  };
@@ -1539,7 +1767,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1539
1767
  throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
1540
1768
  }
1541
1769
  saveWorkflow(name, state.run.script, sessionCwd);
1542
- ctx.ui.notify(`Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
1770
+ notifyWorkflow(ctx, `Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
1543
1771
  };
1544
1772
 
1545
1773
  pi.on("session_start", async (_event, ctx) => {
@@ -1654,6 +1882,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1654
1882
  // ownership, not a captured function value, provide session affinity.
1655
1883
  deliveryContext = ctx;
1656
1884
  deliveryBranchAnchor = journalBranchAnchor;
1885
+ registerStatusActions();
1886
+ workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
1887
+ workflowUiBridge.publish();
1657
1888
  inputPreparing = false;
1658
1889
  compacting = false;
1659
1890
  navigating = false;
@@ -1688,9 +1919,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1688
1919
  };
1689
1920
  const result = await workflowEngine.start(script, options);
1690
1921
  void deliverBackgroundResult(workflowEngine, result.runId);
1691
- commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
1922
+ notifyWorkflow(commandCtx, `Workflow ${savedName} started in background: ${result.runId}`, "info");
1692
1923
  } catch (error: unknown) {
1693
- commandCtx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1924
+ notifyWorkflow(commandCtx, error instanceof Error ? error.message : String(error), "error");
1694
1925
  }
1695
1926
  },
1696
1927
  });
@@ -1701,6 +1932,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1701
1932
  pi.on("session_shutdown", async () => {
1702
1933
  // This synchronous boundary is shared with the bus-first shutdown path.
1703
1934
  const quiescence = beginShutdownQuiesce();
1935
+ workflowUiBridge?.dispose();
1936
+ workflowUiBridge = undefined;
1704
1937
  clearWidget();
1705
1938
  widgetUi = undefined;
1706
1939
  if (widgetTimer) {