@signalridge/pi-workflows 1.8.0 → 1.9.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";
@@ -25,6 +25,7 @@ import {
25
25
  type WorkflowStrengthTable,
26
26
  } from "./strengths.js";
27
27
  import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
28
+ import { buildWorkflowUiSnapshot, openOwnedConversation, WorkflowUiBridge, type WorkflowUiSurface } from "./ui-bus.js";
28
29
  import {
29
30
  loadProjectWorkflowSettings,
30
31
  loadWorkflowSettings,
@@ -111,6 +112,15 @@ function textResult(text: string, details: unknown = {}) {
111
112
  };
112
113
  }
113
114
 
115
+ /** Bounded display copies; command notifications never render stored control bytes. */
116
+ function notifyWorkflow(
117
+ ctx: Pick<ExtensionContext, "ui">,
118
+ message: string,
119
+ type: "info" | "warning" | "error" = "info",
120
+ ): void {
121
+ ctx.ui.notify(sanitizeDisplayText(message.slice(0, 8_000), { multiline: true }).slice(0, 4_000), type);
122
+ }
123
+
114
124
  /**
115
125
  * Run one command for diff discovery. argv array, never a shell string — a
116
126
  * branch name or path reaches this from user input.
@@ -366,6 +376,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
366
376
  const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
367
377
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
368
378
  let widgetUi: ExtensionContext["ui"] | undefined;
379
+ let workflowUiBridge: WorkflowUiBridge | undefined;
380
+ let workflowUiSurfaceId = randomUUID();
381
+ let refreshQueued = false;
369
382
 
370
383
  const currentEngine = (): WorkflowEngine => {
371
384
  if (!active || !engine || engine.isDisposed())
@@ -391,11 +404,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
391
404
  if (!busy) return undefined;
392
405
  const notice =
393
406
  "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");
407
+ if (ctx.hasUI) notifyWorkflow(ctx, notice, "warning");
395
408
  else console.warn(`[pi-workflows] ${notice}`);
396
409
  return { cancel: true };
397
410
  };
398
411
  const invalidateDelivery = (): void => {
412
+ workflowUiBridge?.retire(workflowUiSurfaceId);
413
+ workflowUiSurfaceId = randomUUID();
399
414
  deliveryGeneration++;
400
415
  deliveryObservers.clear();
401
416
  turnArming.retire();
@@ -467,16 +482,55 @@ export default function piWorkflows(pi: ExtensionAPI): void {
467
482
  .list()
468
483
  .map((summary) => workflowEngine.getRun(String(summary.runId)))
469
484
  .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) {
485
+ const hasActive = runs.some((run) => !["completed", "failed", "stopped"].includes(run.status));
486
+ const surfaceId = workflowUiSurfaceId;
487
+ tui.setWidget(
488
+ "pi-workflows",
489
+ hasActive
490
+ ? (host, theme) => ({
491
+ render(width) {
492
+ if (
493
+ !active ||
494
+ engine !== workflowEngine ||
495
+ workflowEngine.isDisposed() ||
496
+ workflowUiSurfaceId !== surfaceId ||
497
+ !Number.isFinite(width) ||
498
+ width < 1
499
+ )
500
+ return [];
501
+ const height = Number.isFinite(host.terminal.rows) ? Math.max(0, host.terminal.rows) : 24;
502
+ const rowBudget = Math.max(1, Math.min(12, Math.floor(height / 4)));
503
+ return liveWidgetLines(
504
+ runs,
505
+ workflowSettings.progressMode,
506
+ workflowSettings.maxAgentsShown,
507
+ rowBudget,
508
+ ).map((line, index) =>
509
+ truncateToWidth(theme.fg(index === 0 ? "accent" : "muted", line), Math.floor(width)),
510
+ );
511
+ },
512
+ invalidate() {},
513
+ })
514
+ : undefined,
515
+ );
516
+ workflowUiBridge?.publish();
517
+ if (hasActive && !widgetTimer) {
473
518
  widgetTimer = setInterval(refreshWidget, 2_000);
474
- } else if (lines.length === 0 && widgetTimer) {
519
+ } else if (!hasActive && widgetTimer) {
475
520
  clearInterval(widgetTimer);
476
521
  widgetTimer = undefined;
477
522
  }
478
523
  };
479
- const notifyRunChanged = (): void => refreshWidget();
524
+ const notifyRunChanged = (): void => {
525
+ if (refreshQueued) return;
526
+ refreshQueued = true;
527
+ const surfaceId = workflowUiSurfaceId;
528
+ queueMicrotask(() => {
529
+ refreshQueued = false;
530
+ if (surfaceId !== workflowUiSurfaceId) return;
531
+ refreshWidget();
532
+ });
533
+ };
480
534
 
481
535
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
482
536
 
@@ -524,6 +578,71 @@ export default function piWorkflows(pi: ExtensionAPI): void {
524
578
  throw new Error("Provide either `script` or `name`.");
525
579
  };
526
580
 
581
+ const showNavigator = async (ctx: ExtensionContext, initialRunId?: string): Promise<void> => {
582
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
583
+ await showWorkflowNavigator(ctx, currentEngine, { initialRunId });
584
+ return;
585
+ }
586
+ const selectedEngine = currentEngine();
587
+ const surfaceId = workflowUiSurfaceId;
588
+ const manager = ctx.sessionManager;
589
+ const sessionId = manager.getSessionId();
590
+ const resolve = (): WorkflowEngine => {
591
+ if (
592
+ surfaceId !== workflowUiSurfaceId ||
593
+ selectedEngine !== currentEngine() ||
594
+ manager !== deliveryContext?.sessionManager ||
595
+ manager.getSessionId() !== sessionId ||
596
+ !ownsDeliveryBranch()
597
+ )
598
+ throw new Error("Workflow inspector belongs to a retired session or branch.");
599
+ return selectedEngine;
600
+ };
601
+ await showWorkflowNavigator(ctx, resolve, {
602
+ initialRunId,
603
+ getPresentation: (runId) => resolve().getInspection(runId),
604
+ openConversation: async (runId, callIndex) => {
605
+ const current = resolve();
606
+ if (!/^(?:0|[1-9]\d*)$/u.test(callIndex)) throw new Error("This call has no retained root conversation.");
607
+ const state = current.getState(runId);
608
+ const run = state?.run;
609
+ const agentId = run?.agentIds[callIndex];
610
+ const attempt = run?.attempts[callIndex];
611
+ if (!state || !run || !agentId || !Number.isInteger(attempt) || (attempt ?? 0) < 1)
612
+ throw new Error("No retained conversation is available for this call.");
613
+ const executionGeneration = state.executionGeneration;
614
+ await openOwnedConversation(
615
+ pi.events,
616
+ agentId,
617
+ { extension: "pi-workflows", runId, nodeId: `call-${callIndex}`, attemptId: `attempt-${attempt}` },
618
+ () => {
619
+ try {
620
+ return (
621
+ resolve().getState(runId) === state &&
622
+ state.executionGeneration === executionGeneration &&
623
+ run.agentIds[callIndex] === agentId &&
624
+ run.attempts[callIndex] === attempt
625
+ );
626
+ } catch {
627
+ return false;
628
+ }
629
+ },
630
+ );
631
+ },
632
+ });
633
+ };
634
+
635
+ const workflowUiSurface = (): WorkflowUiSurface | undefined => {
636
+ const ctx = deliveryContext;
637
+ if (!active || !engine || engine.isDisposed() || ctx?.mode !== "tui" || !ctx.hasUI || !ownsDeliveryBranch())
638
+ return undefined;
639
+ return {
640
+ snapshot: buildWorkflowUiSnapshot(engine, workflowUiSurfaceId),
641
+ open: (runId) => showNavigator(ctx, runId),
642
+ notifyError: (message) => notifyWorkflow(ctx, message, "error"),
643
+ };
644
+ };
645
+
527
646
  const registerSurface = (): void => {
528
647
  if (active) return;
529
648
  active = true;
@@ -531,6 +650,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
531
650
  workflowEngine.onRunSettled = notifyRunChanged;
532
651
  workflowEngine.onBackgroundExecution = (runId) => void deliverBackgroundResult(workflowEngine, runId);
533
652
  workflowEngine.onRuntimeEvent = notifyRunChanged;
653
+ workflowEngine.onInspectionChanged = notifyRunChanged;
534
654
  if (typeof pi.registerMessageRenderer === "function") {
535
655
  pi.registerMessageRenderer("pi-workflows:delivery", (message, { expanded }, theme) => {
536
656
  const original =
@@ -549,6 +669,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
549
669
  // never frozen onto a run. Without this they would resume with no table and
550
670
  // re-run every finished call untabled.
551
671
  workflowEngine.strengths = () => workflowSettings.strengths;
672
+ workflowEngine.loadSavedWorkflow = resolveNestedWorkflow;
552
673
  // Command/tool registrations persist across session replacement, but the
553
674
  // widget belongs to the newly restored engine and must be refreshed too.
554
675
  refreshWidget();
@@ -712,21 +833,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
712
833
  try {
713
834
  return currentEngine();
714
835
  } catch (error: unknown) {
715
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
836
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
716
837
  return undefined;
717
838
  }
718
839
  };
719
840
  if (!subcommand) {
720
- await showWorkflowNavigator(ctx, currentEngine);
841
+ await showNavigator(ctx);
721
842
  return;
722
843
  }
723
844
  if (subcommand === "run") {
724
845
  if (!restArgs) {
725
- ctx.ui.notify("/workflows run <prompt-or-script>", "warning");
846
+ notifyWorkflow(ctx, "/workflows run <prompt-or-script>", "warning");
726
847
  return;
727
848
  }
728
849
  const runId = await runWorkflowFromPrompt(ctx, restArgs);
729
- if (runId) ctx.ui.notify(`Workflow started in background: ${runId}`, "info");
850
+ if (runId) notifyWorkflow(ctx, `Workflow started in background: ${runId}`, "info");
730
851
  return;
731
852
  }
732
853
  if (subcommand === "status") {
@@ -735,14 +856,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
735
856
  if (restArgs) {
736
857
  const run = workflowEngine.getRun(restArgs);
737
858
  if (!run) {
738
- ctx.ui.notify(`Workflow run not found: ${restArgs}`, "warning");
859
+ notifyWorkflow(ctx, `Workflow run not found: ${restArgs}`, "warning");
739
860
  return;
740
861
  }
741
- ctx.ui.notify(`${run.runId} · ${run.status} · ${String(run.meta?.name ?? run.runId)}`, "info");
862
+ notifyWorkflow(ctx, `${run.runId} · ${run.status} · ${String(run.meta?.name ?? run.runId)}`, "info");
742
863
  return;
743
864
  }
744
865
  const runs = workflowEngine.list();
745
- ctx.ui.notify(
866
+ notifyWorkflow(
867
+ ctx,
746
868
  runs.length === 0
747
869
  ? "No workflow runs."
748
870
  : runs.map((r) => `${r.runId} · ${r.status} · ${String(r.name)}`).join("\n"),
@@ -755,11 +877,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
755
877
  if (!workflowEngine) return;
756
878
  const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
757
879
  if (!runId) {
758
- ctx.ui.notify("No workflow runs to watch.", "warning");
880
+ notifyWorkflow(ctx, "No workflow runs to watch.", "warning");
881
+ return;
882
+ }
883
+ const run = workflowEngine.getRun(runId);
884
+ if (!run) {
885
+ notifyWorkflow(ctx, `Workflow run not found: ${runId}`, "warning");
759
886
  return;
760
887
  }
761
- const run = await workflowEngine.waitFor(runId).catch(() => undefined);
762
- if (run) ctx.ui.notify(`${run.runId} settled as ${run.status}`, "info");
888
+ if (ctx.mode === "tui" && ctx.hasUI) await showNavigator(ctx, runId);
889
+ else notifyWorkflow(ctx, `${run.runId} · ${run.status} — use workflow_control outside TUI.`, "info");
763
890
  return;
764
891
  }
765
892
  if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
@@ -773,7 +900,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
773
900
  );
774
901
  if (!runId) {
775
902
  const destructive = subcommand === "stop" || subcommand === "rm";
776
- ctx.ui.notify(
903
+ notifyWorkflow(
904
+ ctx,
777
905
  destructive
778
906
  ? `/workflows ${subcommand} <runId> — a run id is required; /workflows status lists them`
779
907
  : `/workflows ${subcommand} <runId>`,
@@ -786,16 +914,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
786
914
  if (!workflowEngine) return;
787
915
  await workflowEngine.control(subcommand, runId);
788
916
  if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
789
- ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
917
+ notifyWorkflow(ctx, `Workflow ${subcommand} requested: ${runId}`, "info");
790
918
  } catch (error: unknown) {
791
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
919
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
792
920
  }
793
921
  return;
794
922
  }
795
923
  if (subcommand === "save") {
796
924
  const name = rest[0] ?? "";
797
925
  if (!name) {
798
- ctx.ui.notify("/workflows save <name> [runId]", "warning");
926
+ notifyWorkflow(ctx, "/workflows save <name> [runId]", "warning");
799
927
  return;
800
928
  }
801
929
  const workflowEngine = engineForCommand();
@@ -803,13 +931,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
803
931
  // Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
804
932
  const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
805
933
  if (!runId) {
806
- ctx.ui.notify("No workflow runs to save.", "warning");
934
+ notifyWorkflow(ctx, "No workflow runs to save.", "warning");
807
935
  return;
808
936
  }
809
937
  try {
810
938
  await saveWorkflowFromRun(ctx, name, runId);
811
939
  } catch (error: unknown) {
812
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
940
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
813
941
  }
814
942
  return;
815
943
  }
@@ -881,15 +1009,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
881
1009
  };
882
1010
  const [strength, target] = rest;
883
1011
  if (strength === undefined) {
884
- ctx.ui.notify(describe(), "info");
1012
+ notifyWorkflow(ctx, describe(), "info");
885
1013
  return;
886
1014
  }
887
1015
  if (!isWorkflowStrength(strength)) {
888
- ctx.ui.notify(`"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`, "warning");
1016
+ notifyWorkflow(
1017
+ ctx,
1018
+ `"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`,
1019
+ "warning",
1020
+ );
889
1021
  return;
890
1022
  }
891
1023
  if (target === undefined || rest.length !== 2) {
892
- ctx.ui.notify(`/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
1024
+ notifyWorkflow(ctx, `/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
893
1025
  return;
894
1026
  }
895
1027
  // `off` reads as "unset" unless the host really defines a tier by
@@ -898,7 +1030,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
898
1030
  // to clear one.
899
1031
  const unset = target === "off" && catalogue?.includes("off") !== true;
900
1032
  if (!unset && catalogue && !catalogue.includes(target)) {
901
- ctx.ui.notify(
1033
+ notifyWorkflow(
1034
+ ctx,
902
1035
  `"${target}" is not a tier this host defines. Available: ${catalogue.join(", ") || "(none)"}. ` +
903
1036
  "Define it in /agents → Model tiers first.",
904
1037
  "warning",
@@ -911,7 +1044,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
911
1044
  // table, and this key replaces rather than merges — so the other two
912
1045
  // strengths would go from routed to unmapped as a side effect of
913
1046
  // setting this one. Refusing costs a retry; writing costs a run.
914
- ctx.ui.notify(
1047
+ notifyWorkflow(
1048
+ ctx,
915
1049
  "The tier catalogue is unavailable, so the current table cannot be read. " +
916
1050
  "Retry once the managed peer is reachable.",
917
1051
  "warning",
@@ -931,7 +1065,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
931
1065
  try {
932
1066
  persistWorkflowSetting({ strengths: unset ? others : { ...others, [strength]: target } });
933
1067
  } catch (error: unknown) {
934
- ctx.ui.notify(
1068
+ notifyWorkflow(
1069
+ ctx,
935
1070
  `Could not save workflow strength routing: ${error instanceof Error ? error.message : String(error)}`,
936
1071
  "warning",
937
1072
  );
@@ -958,10 +1093,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
958
1093
  "than as the clear keyword. To leave a strength unmapped here, remove its entry from the " +
959
1094
  "`strengths` object in this project's settings.json."
960
1095
  : "";
961
- ctx.ui.notify(`${describe()}${shadowed}`, "info");
1096
+ notifyWorkflow(ctx, `${describe()}${shadowed}`, "info");
962
1097
  return;
963
1098
  }
964
- ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
1099
+ notifyWorkflow(ctx, `Unknown /workflows subcommand: ${subcommand}`, "warning");
965
1100
  },
966
1101
  });
967
1102
 
@@ -972,7 +1107,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
972
1107
  const tokens = args.trim().split(/\s+/).filter(Boolean);
973
1108
  const action = tokens[0] ?? "status";
974
1109
  if (action === "status") {
975
- ctx.ui.notify(
1110
+ notifyWorkflow(
1111
+ ctx,
976
1112
  `Workflow progress: ${workflowSettings.progressMode}, max agents shown: ${workflowSettings.maxAgentsShown}`,
977
1113
  "info",
978
1114
  );
@@ -984,23 +1120,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
984
1120
  } else if (action === "max") {
985
1121
  const max = Number(tokens[1]);
986
1122
  if (!Number.isInteger(max) || max < 1 || max > 32) {
987
- ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
1123
+ notifyWorkflow(ctx, "Usage: /workflows-progress max <1-32>", "warning");
988
1124
  return;
989
1125
  }
990
1126
  patch = { maxAgentsShown: max };
991
1127
  } else {
992
- ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
1128
+ notifyWorkflow(ctx, "Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
993
1129
  return;
994
1130
  }
995
1131
  try {
996
1132
  persistWorkflowSetting(patch);
997
1133
  refreshWidget();
998
- ctx.ui.notify(
1134
+ notifyWorkflow(
1135
+ ctx,
999
1136
  `Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
1000
1137
  "info",
1001
1138
  );
1002
1139
  } catch (error: unknown) {
1003
- ctx.ui.notify(
1140
+ notifyWorkflow(
1141
+ ctx,
1004
1142
  `Could not save workflow progress settings: ${error instanceof Error ? error.message : String(error)}`,
1005
1143
  "warning",
1006
1144
  );
@@ -1029,24 +1167,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1029
1167
  if (token === "off") {
1030
1168
  keywordTriggerEnabled = false;
1031
1169
  persistTriggerSettings();
1032
- ctx.ui.notify("Workflow keyword trigger disabled.", "info");
1170
+ notifyWorkflow(ctx, "Workflow keyword trigger disabled.", "info");
1033
1171
  return;
1034
1172
  }
1035
1173
  if (token === "on") {
1036
1174
  keywordTriggerEnabled = true;
1037
1175
  persistTriggerSettings();
1038
- ctx.ui.notify(`Workflow keyword trigger armed on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}".`, "info");
1176
+ notifyWorkflow(ctx, `Workflow keyword trigger armed on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}".`, "info");
1039
1177
  return;
1040
1178
  }
1041
1179
  if (token === "reset") {
1042
1180
  triggerKeyword = undefined;
1043
1181
  keywordTriggerEnabled = true;
1044
1182
  persistTriggerSettings();
1045
- ctx.ui.notify(`Workflow keyword trigger reset to default "${DEFAULT_TRIGGER_WORD}".`, "info");
1183
+ notifyWorkflow(ctx, `Workflow keyword trigger reset to default "${DEFAULT_TRIGGER_WORD}".`, "info");
1046
1184
  return;
1047
1185
  }
1048
1186
  if (token === "status") {
1049
- ctx.ui.notify(
1187
+ notifyWorkflow(
1188
+ ctx,
1050
1189
  `Workflow keyword trigger: ${keywordTriggerEnabled ? "armed" : "disabled"} on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}"`,
1051
1190
  "info",
1052
1191
  );
@@ -1054,13 +1193,17 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1054
1193
  }
1055
1194
  const keyword = token === "set" ? (args.trim().split(/\s+/)[1] ?? "") : token;
1056
1195
  if (!TRIGGER_WORD_PATTERN.test(keyword)) {
1057
- ctx.ui.notify("Usage: /workflows-trigger set <one-word-keyword> | off | on | reset | status", "warning");
1196
+ notifyWorkflow(
1197
+ ctx,
1198
+ "Usage: /workflows-trigger set <one-word-keyword> | off | on | reset | status",
1199
+ "warning",
1200
+ );
1058
1201
  return;
1059
1202
  }
1060
1203
  triggerKeyword = keyword.toLowerCase();
1061
1204
  keywordTriggerEnabled = true;
1062
1205
  persistTriggerSettings();
1063
- ctx.ui.notify(`Workflow keyword trigger armed on "${keyword}".`, "info");
1206
+ notifyWorkflow(ctx, `Workflow keyword trigger armed on "${keyword}".`, "info");
1064
1207
  },
1065
1208
  });
1066
1209
 
@@ -1132,7 +1275,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1132
1275
  handler: async (args: string, ctx: ExtensionCommandContext) => {
1133
1276
  const level = args.trim().toLowerCase();
1134
1277
  if (level !== "off" && level !== "high" && level !== "ultra") {
1135
- ctx.ui.notify(`Effort is currently "${effortLevel}". Usage: /effort off|high|ultra`, "info");
1278
+ notifyWorkflow(ctx, `Effort is currently "${effortLevel}". Usage: /effort off|high|ultra`, "info");
1136
1279
  return;
1137
1280
  }
1138
1281
  effortLevel = level;
@@ -1141,7 +1284,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1141
1284
  } catch {
1142
1285
  /* warning is non-fatal */
1143
1286
  }
1144
- ctx.ui.notify(
1287
+ notifyWorkflow(
1288
+ ctx,
1145
1289
  level === "off"
1146
1290
  ? "Effort off — messages are no longer auto-armed."
1147
1291
  : `Effort ${level} enabled for substantive messages.`,
@@ -1158,7 +1302,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1158
1302
  } catch {
1159
1303
  /* warning is non-fatal */
1160
1304
  }
1161
- ctx.ui.notify(
1305
+ notifyWorkflow(
1306
+ ctx,
1162
1307
  effortLevel === "off"
1163
1308
  ? "Ultracode off."
1164
1309
  : "Ultracode on — substantive messages use exhaustive workflow guidance.",
@@ -1186,7 +1331,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1186
1331
  if (name === "code-review") {
1187
1332
  const cwd = ctx.cwd;
1188
1333
  const scope = resolveCodeReviewScope(text, (file, argv) => runCommand(file, argv, cwd));
1189
- for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
1334
+ for (const notice of scope.notices) notifyWorkflow(ctx, notice, "warning");
1190
1335
  if (!scope.diff.trim()) return;
1191
1336
  scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
1192
1337
  } else {
@@ -1195,7 +1340,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1195
1340
  try {
1196
1341
  validateBuiltinArgs(name, scriptArgs);
1197
1342
  } catch (error: unknown) {
1198
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
1343
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "warning");
1199
1344
  return;
1200
1345
  }
1201
1346
  const options: ScriptStartOptions = {
@@ -1210,9 +1355,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1210
1355
  const workflowEngine = currentEngine();
1211
1356
  const result = await workflowEngine.start(descriptor.script, options);
1212
1357
  void deliverBackgroundResult(workflowEngine, result.runId);
1213
- ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
1358
+ notifyWorkflow(ctx, `Workflow ${name} started in background: ${result.runId}`, "info");
1214
1359
  } catch (error: unknown) {
1215
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1360
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
1216
1361
  }
1217
1362
  },
1218
1363
  });
@@ -1528,7 +1673,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1528
1673
  void deliverBackgroundResult(workflowEngine, result.runId);
1529
1674
  return result.runId;
1530
1675
  } catch (error: unknown) {
1531
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1676
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
1532
1677
  return undefined;
1533
1678
  }
1534
1679
  };
@@ -1539,7 +1684,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1539
1684
  throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
1540
1685
  }
1541
1686
  saveWorkflow(name, state.run.script, sessionCwd);
1542
- ctx.ui.notify(`Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
1687
+ notifyWorkflow(ctx, `Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
1543
1688
  };
1544
1689
 
1545
1690
  pi.on("session_start", async (_event, ctx) => {
@@ -1654,6 +1799,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1654
1799
  // ownership, not a captured function value, provide session affinity.
1655
1800
  deliveryContext = ctx;
1656
1801
  deliveryBranchAnchor = journalBranchAnchor;
1802
+ workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
1803
+ workflowUiBridge.publish();
1657
1804
  inputPreparing = false;
1658
1805
  compacting = false;
1659
1806
  navigating = false;
@@ -1688,9 +1835,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1688
1835
  };
1689
1836
  const result = await workflowEngine.start(script, options);
1690
1837
  void deliverBackgroundResult(workflowEngine, result.runId);
1691
- commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
1838
+ notifyWorkflow(commandCtx, `Workflow ${savedName} started in background: ${result.runId}`, "info");
1692
1839
  } catch (error: unknown) {
1693
- commandCtx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1840
+ notifyWorkflow(commandCtx, error instanceof Error ? error.message : String(error), "error");
1694
1841
  }
1695
1842
  },
1696
1843
  });
@@ -1701,6 +1848,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1701
1848
  pi.on("session_shutdown", async () => {
1702
1849
  // This synchronous boundary is shared with the bus-first shutdown path.
1703
1850
  const quiescence = beginShutdownQuiesce();
1851
+ workflowUiBridge?.dispose();
1852
+ workflowUiBridge = undefined;
1704
1853
  clearWidget();
1705
1854
  widgetUi = undefined;
1706
1855
  if (widgetTimer) {