@signalridge/pi-workflows 1.7.5 → 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
@@ -1,14 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
- import { Text } from "@earendil-works/pi-tui";
5
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { sanitizeDisplayText } from "@signalridge/pi-ui";
5
7
  import { Type } from "@sinclair/typebox";
6
- import {
7
- DEFAULT_TRIGGER_WORD,
8
- deduplicateWorkflowDirective,
9
- hasTriggerWord,
10
- WORKFLOW_ARMED_DIRECTIVE,
11
- } from "./arming.js";
8
+ import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMING_MESSAGE_TYPE, WorkflowTurnArming } from "./arming.js";
12
9
  import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
13
10
  import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
14
11
  import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
@@ -28,6 +25,7 @@ import {
28
25
  type WorkflowStrengthTable,
29
26
  } from "./strengths.js";
30
27
  import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
28
+ import { buildWorkflowUiSnapshot, openOwnedConversation, WorkflowUiBridge, type WorkflowUiSurface } from "./ui-bus.js";
31
29
  import {
32
30
  loadProjectWorkflowSettings,
33
31
  loadWorkflowSettings,
@@ -114,6 +112,15 @@ function textResult(text: string, details: unknown = {}) {
114
112
  };
115
113
  }
116
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
+
117
124
  /**
118
125
  * Run one command for diff discovery. argv array, never a shell string — a
119
126
  * branch name or path reaches this from user input.
@@ -335,6 +342,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
335
342
  let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
336
343
  let lifecycleGeneration = 0;
337
344
  let deliveryGeneration = 0;
345
+ const deliveryObservers = new Map<string, string>();
338
346
  let handledTreeEvent: unknown;
339
347
  let journalBranchAnchor: string | null | undefined;
340
348
  let protocolAbortController: AbortController | undefined;
@@ -343,9 +351,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
343
351
  let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
344
352
  let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
345
353
  let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
346
- /** Per-session delivery endpoint for background-run results (A10 fail-closed). */
347
- let deliverResult: ((text: string, details?: unknown) => void) | undefined;
348
- /** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
354
+ /** Public sends are void: acceptance is checked against the owning branch. */
355
+ let deliveryContext: ExtensionContext | undefined;
356
+ let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
357
+ let inputPreparing = false;
358
+ const turnArming = new WorkflowTurnArming();
359
+ let compacting = false;
360
+ let navigating = false;
361
+ let deliveryBranchAnchor: string | null | undefined;
362
+ const DELIVERY_WAKE_PREFIX = "[pi-workflows:wake:";
363
+ const DELIVERY_WAKE_TEXT = "Workflow results are ready in the preceding context.";
364
+ const MAX_WAKE_ADMISSIONS = 256;
365
+ // Private, one-use admissions survive an earlier awaited input handler, not
366
+ // a lifecycle retirement. Missing/evicted IDs fail closed without tombstones.
367
+ const wakeAdmissions = new Map<
368
+ string,
369
+ { generation: number; context: ExtensionContext; engine: WorkflowEngine; anchor: string | null | undefined }
370
+ >();
371
+ /** Pending deliveries for completed runs, including durable recovery (A10). */
349
372
  const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
350
373
  const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
351
374
  const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
@@ -353,6 +376,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
353
376
  const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
354
377
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
355
378
  let widgetUi: ExtensionContext["ui"] | undefined;
379
+ let workflowUiBridge: WorkflowUiBridge | undefined;
380
+ let workflowUiSurfaceId = randomUUID();
381
+ let refreshQueued = false;
356
382
 
357
383
  const currentEngine = (): WorkflowEngine => {
358
384
  if (!active || !engine || engine.isDisposed())
@@ -378,25 +404,52 @@ export default function piWorkflows(pi: ExtensionAPI): void {
378
404
  if (!busy) return undefined;
379
405
  const notice =
380
406
  "Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
381
- if (ctx.hasUI) ctx.ui.notify(notice, "warning");
407
+ if (ctx.hasUI) notifyWorkflow(ctx, notice, "warning");
382
408
  else console.warn(`[pi-workflows] ${notice}`);
383
409
  return { cancel: true };
384
410
  };
411
+ const invalidateDelivery = (): void => {
412
+ workflowUiBridge?.retire(workflowUiSurfaceId);
413
+ workflowUiSurfaceId = randomUUID();
414
+ deliveryGeneration++;
415
+ deliveryObservers.clear();
416
+ turnArming.retire();
417
+ wakeAdmissions.clear();
418
+ if (deliveryTimer) clearTimeout(deliveryTimer);
419
+ deliveryTimer = undefined;
420
+ };
385
421
  const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
386
422
  if (shutdownQuiesce) return shutdownQuiesce;
423
+ // Retire admission and delivery BEFORE aborting children, including when
424
+ // subagents initiates shutdown first. Journal cleanup retains its separate
425
+ // lifecycle generation until the native shutdown hook finishes quiescence.
426
+ active = false;
427
+ invalidateDelivery();
428
+ deliveryContext = undefined;
387
429
  const closingEngine = engine;
388
- if (!closingEngine || closingEngine.isDisposed()) return Promise.resolve({ settled: true, pending: [] });
389
- shutdownQuiesce = closingEngine.quiesceForBranchChange().catch((error: unknown) => ({
390
- settled: false,
391
- pending: [],
392
- diagnostic: error instanceof Error ? error.message : String(error),
393
- }));
430
+ if (!closingEngine || closingEngine.isDisposed()) {
431
+ shutdownQuiesce = Promise.resolve({ settled: true, pending: [] });
432
+ return shutdownQuiesce;
433
+ }
434
+ // Publish the handshake promise before abort can synchronously re-enter
435
+ // the bus through an owned-child cleanup callback.
436
+ let settle: (result: { settled: boolean; pending: string[]; diagnostic?: string }) => void = () => {};
437
+ shutdownQuiesce = new Promise((resolve) => {
438
+ settle = resolve;
439
+ });
440
+ void closingEngine.quiesceForBranchChange().then(settle, (error: unknown) => {
441
+ settle({
442
+ settled: false,
443
+ pending: [],
444
+ diagnostic: error instanceof Error ? error.message : String(error),
445
+ });
446
+ });
394
447
  return shutdownQuiesce;
395
448
  };
396
449
  // Subagents starts this handshake before retiring the owned-agent RPC. When
397
450
  // workflows shuts down first, its native hook performs the same operation.
398
451
  pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
399
- if (!active || !engine) return;
452
+ if (!engine) return;
400
453
  const respond = (raw as { respond?: unknown } | null)?.respond;
401
454
  if (typeof respond === "function") respond(beginShutdownQuiesce());
402
455
  });
@@ -405,6 +458,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
405
458
  const event = (raw as { event?: unknown } | null)?.event;
406
459
  if (!event || event === handledTreeEvent) return;
407
460
  handledTreeEvent = event;
461
+ invalidateDelivery();
462
+ pendingDeliveryMarkers.clear();
408
463
  engine.fenceCommittedBranch();
409
464
  });
410
465
 
@@ -427,16 +482,55 @@ export default function piWorkflows(pi: ExtensionAPI): void {
427
482
  .list()
428
483
  .map((summary) => workflowEngine.getRun(String(summary.runId)))
429
484
  .filter((run): run is NonNullable<typeof run> => run !== undefined);
430
- const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
431
- tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
432
- 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) {
433
518
  widgetTimer = setInterval(refreshWidget, 2_000);
434
- } else if (lines.length === 0 && widgetTimer) {
519
+ } else if (!hasActive && widgetTimer) {
435
520
  clearInterval(widgetTimer);
436
521
  widgetTimer = undefined;
437
522
  }
438
523
  };
439
- 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
+ };
440
534
 
441
535
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
442
536
 
@@ -484,17 +578,98 @@ export default function piWorkflows(pi: ExtensionAPI): void {
484
578
  throw new Error("Provide either `script` or `name`.");
485
579
  };
486
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
+
487
646
  const registerSurface = (): void => {
488
647
  if (active) return;
489
648
  active = true;
490
649
  const workflowEngine = currentEngine();
491
650
  workflowEngine.onRunSettled = notifyRunChanged;
651
+ workflowEngine.onBackgroundExecution = (runId) => void deliverBackgroundResult(workflowEngine, runId);
492
652
  workflowEngine.onRuntimeEvent = notifyRunChanged;
653
+ workflowEngine.onInspectionChanged = notifyRunChanged;
654
+ if (typeof pi.registerMessageRenderer === "function") {
655
+ pi.registerMessageRenderer("pi-workflows:delivery", (message, { expanded }, theme) => {
656
+ const original =
657
+ typeof message.content === "string"
658
+ ? message.content
659
+ : message.content
660
+ .filter((part) => part.type === "text")
661
+ .map((part) => part.text)
662
+ .join("\n");
663
+ const display = sanitizeDisplayText(original, { multiline: true });
664
+ return new Text(theme.fg("toolOutput", display.slice(0, expanded ? 8000 : 2000)), 0, 0);
665
+ });
666
+ }
493
667
  // `control("resume")` and the provider-limit retry start executions with no
494
668
  // caller to pass options for them, and the strength table is deliberately
495
669
  // never frozen onto a run. Without this they would resume with no table and
496
670
  // re-run every finished call untabled.
497
671
  workflowEngine.strengths = () => workflowSettings.strengths;
672
+ workflowEngine.loadSavedWorkflow = resolveNestedWorkflow;
498
673
  // Command/tool registrations persist across session replacement, but the
499
674
  // widget belongs to the newly restored engine and must be refreshed too.
500
675
  refreshWidget();
@@ -519,11 +694,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
519
694
  ],
520
695
  parameters: WorkflowToolSchema,
521
696
  renderCall(args, theme) {
522
- const label = args.name ?? (args.script !== undefined ? "script" : "");
697
+ const label = sanitizeDisplayText(args.name ?? (args.script !== undefined ? "script" : ""));
523
698
  return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${label}`)), 0, 0);
524
699
  },
525
700
  renderResult(result, { expanded, isPartial }, theme) {
526
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
701
+ const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
702
+ multiline: true,
703
+ });
527
704
  if (isPartial) return new Text(theme.fg("warning", "Workflow running…"), 0, 0);
528
705
  const preview = expanded ? text.slice(0, 8_000) : text.split("\n").slice(0, 4).join("\n");
529
706
  return new Text(
@@ -585,7 +762,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
585
762
  replacement,
586
763
  );
587
764
  if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
588
- if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
589
765
  return textResult(formatStart(resumed), resumed);
590
766
  }
591
767
  const result = await workflowEngine.start(script, options);
@@ -610,10 +786,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
610
786
  "List, inspect, pause, resume, stop, or remove workflow runs with bounded machine-readable output.",
611
787
  parameters: ControlSchema,
612
788
  renderCall(args, theme) {
613
- return new Text(theme.fg("toolTitle", theme.bold(`Workflow control: ${args.action ?? ""}`)), 0, 0);
789
+ return new Text(
790
+ theme.fg("toolTitle", theme.bold(`Workflow control: ${sanitizeDisplayText(args.action ?? "")}`)),
791
+ 0,
792
+ 0,
793
+ );
614
794
  },
615
795
  renderResult(result, { expanded }, theme) {
616
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
796
+ const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
797
+ multiline: true,
798
+ });
617
799
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
618
800
  },
619
801
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -651,21 +833,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
651
833
  try {
652
834
  return currentEngine();
653
835
  } catch (error: unknown) {
654
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
836
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
655
837
  return undefined;
656
838
  }
657
839
  };
658
840
  if (!subcommand) {
659
- await showWorkflowNavigator(ctx, currentEngine);
841
+ await showNavigator(ctx);
660
842
  return;
661
843
  }
662
844
  if (subcommand === "run") {
663
845
  if (!restArgs) {
664
- ctx.ui.notify("/workflows run <prompt-or-script>", "warning");
846
+ notifyWorkflow(ctx, "/workflows run <prompt-or-script>", "warning");
665
847
  return;
666
848
  }
667
849
  const runId = await runWorkflowFromPrompt(ctx, restArgs);
668
- if (runId) ctx.ui.notify(`Workflow started in background: ${runId}`, "info");
850
+ if (runId) notifyWorkflow(ctx, `Workflow started in background: ${runId}`, "info");
669
851
  return;
670
852
  }
671
853
  if (subcommand === "status") {
@@ -674,14 +856,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
674
856
  if (restArgs) {
675
857
  const run = workflowEngine.getRun(restArgs);
676
858
  if (!run) {
677
- ctx.ui.notify(`Workflow run not found: ${restArgs}`, "warning");
859
+ notifyWorkflow(ctx, `Workflow run not found: ${restArgs}`, "warning");
678
860
  return;
679
861
  }
680
- 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");
681
863
  return;
682
864
  }
683
865
  const runs = workflowEngine.list();
684
- ctx.ui.notify(
866
+ notifyWorkflow(
867
+ ctx,
685
868
  runs.length === 0
686
869
  ? "No workflow runs."
687
870
  : runs.map((r) => `${r.runId} · ${r.status} · ${String(r.name)}`).join("\n"),
@@ -694,11 +877,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
694
877
  if (!workflowEngine) return;
695
878
  const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
696
879
  if (!runId) {
697
- 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");
698
886
  return;
699
887
  }
700
- const run = await workflowEngine.waitFor(runId).catch(() => undefined);
701
- 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");
702
890
  return;
703
891
  }
704
892
  if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
@@ -712,7 +900,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
712
900
  );
713
901
  if (!runId) {
714
902
  const destructive = subcommand === "stop" || subcommand === "rm";
715
- ctx.ui.notify(
903
+ notifyWorkflow(
904
+ ctx,
716
905
  destructive
717
906
  ? `/workflows ${subcommand} <runId> — a run id is required; /workflows status lists them`
718
907
  : `/workflows ${subcommand} <runId>`,
@@ -725,16 +914,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
725
914
  if (!workflowEngine) return;
726
915
  await workflowEngine.control(subcommand, runId);
727
916
  if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
728
- ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
917
+ notifyWorkflow(ctx, `Workflow ${subcommand} requested: ${runId}`, "info");
729
918
  } catch (error: unknown) {
730
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
919
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
731
920
  }
732
921
  return;
733
922
  }
734
923
  if (subcommand === "save") {
735
924
  const name = rest[0] ?? "";
736
925
  if (!name) {
737
- ctx.ui.notify("/workflows save <name> [runId]", "warning");
926
+ notifyWorkflow(ctx, "/workflows save <name> [runId]", "warning");
738
927
  return;
739
928
  }
740
929
  const workflowEngine = engineForCommand();
@@ -742,13 +931,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
742
931
  // Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
743
932
  const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
744
933
  if (!runId) {
745
- ctx.ui.notify("No workflow runs to save.", "warning");
934
+ notifyWorkflow(ctx, "No workflow runs to save.", "warning");
746
935
  return;
747
936
  }
748
937
  try {
749
938
  await saveWorkflowFromRun(ctx, name, runId);
750
939
  } catch (error: unknown) {
751
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
940
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
752
941
  }
753
942
  return;
754
943
  }
@@ -820,15 +1009,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
820
1009
  };
821
1010
  const [strength, target] = rest;
822
1011
  if (strength === undefined) {
823
- ctx.ui.notify(describe(), "info");
1012
+ notifyWorkflow(ctx, describe(), "info");
824
1013
  return;
825
1014
  }
826
1015
  if (!isWorkflowStrength(strength)) {
827
- 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
+ );
828
1021
  return;
829
1022
  }
830
1023
  if (target === undefined || rest.length !== 2) {
831
- ctx.ui.notify(`/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
1024
+ notifyWorkflow(ctx, `/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
832
1025
  return;
833
1026
  }
834
1027
  // `off` reads as "unset" unless the host really defines a tier by
@@ -837,7 +1030,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
837
1030
  // to clear one.
838
1031
  const unset = target === "off" && catalogue?.includes("off") !== true;
839
1032
  if (!unset && catalogue && !catalogue.includes(target)) {
840
- ctx.ui.notify(
1033
+ notifyWorkflow(
1034
+ ctx,
841
1035
  `"${target}" is not a tier this host defines. Available: ${catalogue.join(", ") || "(none)"}. ` +
842
1036
  "Define it in /agents → Model tiers first.",
843
1037
  "warning",
@@ -850,7 +1044,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
850
1044
  // table, and this key replaces rather than merges — so the other two
851
1045
  // strengths would go from routed to unmapped as a side effect of
852
1046
  // setting this one. Refusing costs a retry; writing costs a run.
853
- ctx.ui.notify(
1047
+ notifyWorkflow(
1048
+ ctx,
854
1049
  "The tier catalogue is unavailable, so the current table cannot be read. " +
855
1050
  "Retry once the managed peer is reachable.",
856
1051
  "warning",
@@ -870,7 +1065,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
870
1065
  try {
871
1066
  persistWorkflowSetting({ strengths: unset ? others : { ...others, [strength]: target } });
872
1067
  } catch (error: unknown) {
873
- ctx.ui.notify(
1068
+ notifyWorkflow(
1069
+ ctx,
874
1070
  `Could not save workflow strength routing: ${error instanceof Error ? error.message : String(error)}`,
875
1071
  "warning",
876
1072
  );
@@ -897,10 +1093,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
897
1093
  "than as the clear keyword. To leave a strength unmapped here, remove its entry from the " +
898
1094
  "`strengths` object in this project's settings.json."
899
1095
  : "";
900
- ctx.ui.notify(`${describe()}${shadowed}`, "info");
1096
+ notifyWorkflow(ctx, `${describe()}${shadowed}`, "info");
901
1097
  return;
902
1098
  }
903
- ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
1099
+ notifyWorkflow(ctx, `Unknown /workflows subcommand: ${subcommand}`, "warning");
904
1100
  },
905
1101
  });
906
1102
 
@@ -911,7 +1107,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
911
1107
  const tokens = args.trim().split(/\s+/).filter(Boolean);
912
1108
  const action = tokens[0] ?? "status";
913
1109
  if (action === "status") {
914
- ctx.ui.notify(
1110
+ notifyWorkflow(
1111
+ ctx,
915
1112
  `Workflow progress: ${workflowSettings.progressMode}, max agents shown: ${workflowSettings.maxAgentsShown}`,
916
1113
  "info",
917
1114
  );
@@ -923,23 +1120,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
923
1120
  } else if (action === "max") {
924
1121
  const max = Number(tokens[1]);
925
1122
  if (!Number.isInteger(max) || max < 1 || max > 32) {
926
- ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
1123
+ notifyWorkflow(ctx, "Usage: /workflows-progress max <1-32>", "warning");
927
1124
  return;
928
1125
  }
929
1126
  patch = { maxAgentsShown: max };
930
1127
  } else {
931
- ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
1128
+ notifyWorkflow(ctx, "Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
932
1129
  return;
933
1130
  }
934
1131
  try {
935
1132
  persistWorkflowSetting(patch);
936
1133
  refreshWidget();
937
- ctx.ui.notify(
1134
+ notifyWorkflow(
1135
+ ctx,
938
1136
  `Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
939
1137
  "info",
940
1138
  );
941
1139
  } catch (error: unknown) {
942
- ctx.ui.notify(
1140
+ notifyWorkflow(
1141
+ ctx,
943
1142
  `Could not save workflow progress settings: ${error instanceof Error ? error.message : String(error)}`,
944
1143
  "warning",
945
1144
  );
@@ -950,7 +1149,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
950
1149
  // /workflows-trigger: keyword arming (A5). Typing the bounded word
951
1150
  // `workflow`/`workflows` — or a configured synonym — in an ordinary message
952
1151
  // counts as an explicit opt-in to multi-agent orchestration, and the message
953
- // is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
1152
+ // receives hidden model guidance. Arming AUTHORIZES the tool; it never forces a run,
954
1153
  // and it never opens UI. A user asking a question about workflows still gets
955
1154
  // an ordinary answer.
956
1155
  const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
@@ -968,24 +1167,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
968
1167
  if (token === "off") {
969
1168
  keywordTriggerEnabled = false;
970
1169
  persistTriggerSettings();
971
- ctx.ui.notify("Workflow keyword trigger disabled.", "info");
1170
+ notifyWorkflow(ctx, "Workflow keyword trigger disabled.", "info");
972
1171
  return;
973
1172
  }
974
1173
  if (token === "on") {
975
1174
  keywordTriggerEnabled = true;
976
1175
  persistTriggerSettings();
977
- 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");
978
1177
  return;
979
1178
  }
980
1179
  if (token === "reset") {
981
1180
  triggerKeyword = undefined;
982
1181
  keywordTriggerEnabled = true;
983
1182
  persistTriggerSettings();
984
- 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");
985
1184
  return;
986
1185
  }
987
1186
  if (token === "status") {
988
- ctx.ui.notify(
1187
+ notifyWorkflow(
1188
+ ctx,
989
1189
  `Workflow keyword trigger: ${keywordTriggerEnabled ? "armed" : "disabled"} on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}"`,
990
1190
  "info",
991
1191
  );
@@ -993,87 +1193,80 @@ export default function piWorkflows(pi: ExtensionAPI): void {
993
1193
  }
994
1194
  const keyword = token === "set" ? (args.trim().split(/\s+/)[1] ?? "") : token;
995
1195
  if (!TRIGGER_WORD_PATTERN.test(keyword)) {
996
- 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
+ );
997
1201
  return;
998
1202
  }
999
1203
  triggerKeyword = keyword.toLowerCase();
1000
1204
  keywordTriggerEnabled = true;
1001
1205
  persistTriggerSettings();
1002
- ctx.ui.notify(`Workflow keyword trigger armed on "${keyword}".`, "info");
1206
+ notifyWorkflow(ctx, `Workflow keyword trigger armed on "${keyword}".`, "info");
1003
1207
  },
1004
1208
  });
1005
1209
 
1006
- // Keyword arming input hook (A5). The message is annotated rather than
1007
- // acted on: the model is told it may use the `workflow` tool and may still
1210
+ // Keyword arming input hook (A5). Record provenance, never append model
1211
+ // guidance to native user text: the model may use `workflow` and may still
1008
1212
  // decline, which is what keeps "how do workflows work?" an ordinary
1009
1213
  // question. Nothing is swallowed and no UI opens.
1010
- pi.on("input", (event) => {
1011
- if (event.source === "extension") return { action: "continue" as const };
1012
- const deduplicatedText = deduplicateWorkflowDirective(event.text);
1013
- if (deduplicatedText !== event.text) {
1214
+ pi.on("input", (event, ctx) => {
1215
+ const ownWake = event.source === "extension" && event.text.startsWith(DELIVERY_WAKE_PREFIX);
1216
+ if (ownWake) {
1217
+ const admission = wakeAdmissions.get(event.text);
1218
+ wakeAdmissions.delete(event.text); // exact, one-use ownership, even on rejection
1219
+ if (
1220
+ !admission ||
1221
+ admission.generation !== deliveryGeneration ||
1222
+ admission.context !== deliveryContext ||
1223
+ admission.engine !== engine ||
1224
+ !canDeliver()
1225
+ ) {
1226
+ turnArming.retire();
1227
+ return { action: "handled" as const };
1228
+ }
1229
+ try {
1230
+ if (
1231
+ admission.anchor &&
1232
+ !admission.context.sessionManager.getBranch().some((entry) => entry.id === admission.anchor)
1233
+ ) {
1234
+ turnArming.retire();
1235
+ return { action: "handled" as const };
1236
+ }
1237
+ } catch {
1238
+ turnArming.retire();
1239
+ return { action: "handled" as const };
1240
+ }
1241
+ }
1242
+ inputPreparing = true;
1243
+ navigating = false; // a fresh prompt supersedes an uncommitted navigation
1244
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1245
+ deliveryTimer = undefined;
1246
+ const text = event.text;
1247
+ const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(text, triggerKeyword);
1248
+ const substantive = effortLevel !== "off" && text.trim().length >= 16 && !text.trim().startsWith("/");
1249
+ turnArming.observe(
1250
+ { ...event, text },
1251
+ ctx,
1252
+ active && (explicitTrigger || substantive),
1253
+ effortLevel,
1254
+ explicitTrigger,
1255
+ );
1256
+ if (ownWake)
1014
1257
  return {
1015
1258
  action: "transform" as const,
1016
- text: deduplicatedText,
1259
+ text: DELIVERY_WAKE_TEXT,
1017
1260
  ...(event.images ? { images: event.images } : {}),
1018
1261
  };
1019
- }
1020
- const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(event.text, triggerKeyword);
1021
- const substantive = effortLevel !== "off" && event.text.trim().length >= 16 && !event.text.trim().startsWith("/");
1022
- if (!explicitTrigger && !substantive) return { action: "continue" as const };
1023
- if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
1024
- const effortDirective =
1025
- effortLevel === "ultra"
1026
- ? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks. Width is where the exhaustiveness goes: keep steps at strength: "medium" and reserve "high" for a step whose error is expensive to reverse, if the run has one at all.'
1027
- : effortLevel === "high"
1028
- ? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
1029
- : "";
1030
- return {
1031
- action: "transform" as const,
1032
- text: `${event.text}\n\n${WORKFLOW_ARMED_DIRECTIVE}${effortDirective ? `\n${effortDirective}` : ""}`,
1033
- ...(event.images ? { images: event.images } : {}),
1034
- };
1262
+ return { action: "continue" as const };
1035
1263
  });
1036
1264
 
1037
- // Input transforms can be chained with handlers loaded after this package.
1038
- // Normalize only our own repeated marker again at the final context boundary;
1039
- // unrelated plugin content and hooks remain untouched.
1040
- pi.on("context", (event) => {
1041
- type UserMessage = Extract<(typeof event.messages)[number], { role: "user" }>;
1042
- let lastUserIndex = -1;
1043
- let lastUserMessage: UserMessage | undefined;
1044
- for (let index = 0; index < event.messages.length; index += 1) {
1045
- const message = event.messages[index];
1046
- if (message && "role" in message && message.role === "user") {
1047
- lastUserIndex = index;
1048
- lastUserMessage = message;
1049
- }
1050
- }
1051
- const message = lastUserMessage;
1052
- if (lastUserIndex < 0 || !message || !("content" in message)) return;
1053
- let seen = false;
1054
- const deduplicate = (text: string): string =>
1055
- text.replaceAll(WORKFLOW_ARMED_DIRECTIVE, (directive) => {
1056
- if (seen) return "";
1057
- seen = true;
1058
- return directive;
1059
- });
1060
- const content =
1061
- typeof message.content === "string"
1062
- ? deduplicate(message.content)
1063
- : message.content.map((part) => {
1064
- if (part.type !== "text") return part;
1065
- const text = deduplicate(part.text);
1066
- return text === part.text ? part : { ...part, text };
1067
- });
1068
- if (
1069
- content === message.content ||
1070
- (Array.isArray(content) && content.every((part, index) => part === message.content[index]))
1071
- )
1072
- return;
1073
- const messages = event.messages.slice();
1074
- messages[lastUserIndex] = { ...message, content };
1075
- return { messages };
1076
- });
1265
+ // Hidden guidance is turn-local, including on reload and after later hooks
1266
+ // navigate while before_agent_start is awaiting. Never edit native history.
1267
+ // Request-local overlays never enter native history or summarization.
1268
+ pi.on("message_start", (event, ctx) => turnArming.messageStarted(event, ctx));
1269
+ pi.on("context", (event, ctx) => turnArming.filterContext(event, ctx));
1077
1270
 
1078
1271
  // Standing effort mode mirrors the reference: it auto-arms substantive
1079
1272
  // interactive messages but remains guidance, not an inferred spend ceiling.
@@ -1082,7 +1275,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1082
1275
  handler: async (args: string, ctx: ExtensionCommandContext) => {
1083
1276
  const level = args.trim().toLowerCase();
1084
1277
  if (level !== "off" && level !== "high" && level !== "ultra") {
1085
- 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");
1086
1279
  return;
1087
1280
  }
1088
1281
  effortLevel = level;
@@ -1091,7 +1284,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1091
1284
  } catch {
1092
1285
  /* warning is non-fatal */
1093
1286
  }
1094
- ctx.ui.notify(
1287
+ notifyWorkflow(
1288
+ ctx,
1095
1289
  level === "off"
1096
1290
  ? "Effort off — messages are no longer auto-armed."
1097
1291
  : `Effort ${level} enabled for substantive messages.`,
@@ -1108,7 +1302,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1108
1302
  } catch {
1109
1303
  /* warning is non-fatal */
1110
1304
  }
1111
- ctx.ui.notify(
1305
+ notifyWorkflow(
1306
+ ctx,
1112
1307
  effortLevel === "off"
1113
1308
  ? "Ultracode off."
1114
1309
  : "Ultracode on — substantive messages use exhaustive workflow guidance.",
@@ -1136,7 +1331,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1136
1331
  if (name === "code-review") {
1137
1332
  const cwd = ctx.cwd;
1138
1333
  const scope = resolveCodeReviewScope(text, (file, argv) => runCommand(file, argv, cwd));
1139
- for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
1334
+ for (const notice of scope.notices) notifyWorkflow(ctx, notice, "warning");
1140
1335
  if (!scope.diff.trim()) return;
1141
1336
  scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
1142
1337
  } else {
@@ -1145,7 +1340,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1145
1340
  try {
1146
1341
  validateBuiltinArgs(name, scriptArgs);
1147
1342
  } catch (error: unknown) {
1148
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
1343
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "warning");
1149
1344
  return;
1150
1345
  }
1151
1346
  const options: ScriptStartOptions = {
@@ -1160,9 +1355,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1160
1355
  const workflowEngine = currentEngine();
1161
1356
  const result = await workflowEngine.start(descriptor.script, options);
1162
1357
  void deliverBackgroundResult(workflowEngine, result.runId);
1163
- ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
1358
+ notifyWorkflow(ctx, `Workflow ${name} started in background: ${result.runId}`, "info");
1164
1359
  } catch (error: unknown) {
1165
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1360
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
1166
1361
  }
1167
1362
  },
1168
1363
  });
@@ -1171,8 +1366,64 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1171
1366
 
1172
1367
  // Neither before-event is a commit point. Pi can veto in a later handler,
1173
1368
  // or summarization may abort after every handler has run.
1174
- pi.on("session_before_switch", (_event, ctx) => navigationPreflight(ctx));
1175
- pi.on("session_before_tree", (_event, ctx) => navigationPreflight(ctx));
1369
+ const beforeNavigation = (ctx: ExtensionContext): { cancel: true } | undefined => {
1370
+ turnArming.retire();
1371
+ const veto = navigationPreflight(ctx);
1372
+ if (!veto) {
1373
+ navigating = true;
1374
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1375
+ deliveryTimer = undefined;
1376
+ }
1377
+ return veto;
1378
+ };
1379
+ pi.on("session_before_switch", (_event, ctx) => beforeNavigation(ctx));
1380
+ pi.on("session_before_tree", (event, ctx) => {
1381
+ // The public preparation array is mutable; remove only legacy owned
1382
+ // custom guidance, never quoted user content or stored history.
1383
+ const entries = event.preparation?.entriesToSummarize ?? [];
1384
+ for (let index = entries.length - 1; index >= 0; index--) {
1385
+ const entry = entries[index];
1386
+ if (entry?.type === "custom_message" && entry.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
1387
+ entries.splice(index, 1);
1388
+ }
1389
+ return beforeNavigation(ctx);
1390
+ });
1391
+ pi.on("session_before_fork", (_event, ctx) => beforeNavigation(ctx));
1392
+ pi.on("before_agent_start", (event, ctx) => {
1393
+ inputPreparing = true;
1394
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1395
+ deliveryTimer = undefined;
1396
+ return turnArming.consume(event, ctx);
1397
+ });
1398
+ pi.on("agent_start", () => {
1399
+ inputPreparing = false;
1400
+ });
1401
+ pi.on("session_before_compact", (event) => {
1402
+ for (const messages of [
1403
+ event.preparation?.messagesToSummarize ?? [],
1404
+ event.preparation?.turnPrefixMessages ?? [],
1405
+ ]) {
1406
+ for (let index = messages.length - 1; index >= 0; index--) {
1407
+ const message = messages[index];
1408
+ if (message?.role === "custom" && message.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
1409
+ messages.splice(index, 1);
1410
+ }
1411
+ }
1412
+ compacting = true;
1413
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1414
+ deliveryTimer = undefined;
1415
+ });
1416
+ const afterCompact = (): void => {
1417
+ compacting = false;
1418
+ schedulePendingDeliveries();
1419
+ };
1420
+ pi.on("session_compact", afterCompact);
1421
+ pi.on("session_compact_failed", afterCompact);
1422
+ pi.on("agent_settled", () => {
1423
+ turnArming.retire();
1424
+ inputPreparing = false;
1425
+ schedulePendingDeliveries();
1426
+ });
1176
1427
  pi.on("session_tree", (event, ctx) => {
1177
1428
  let workflowEngine: WorkflowEngine;
1178
1429
  try {
@@ -1180,6 +1431,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1180
1431
  } catch {
1181
1432
  return;
1182
1433
  }
1434
+ invalidateDelivery();
1435
+ pendingDeliveryMarkers.clear();
1436
+ inputPreparing = false;
1437
+ compacting = false;
1438
+ navigating = false;
1183
1439
  if (handledTreeEvent !== event) {
1184
1440
  // A controller abort can synchronously call stop-owned RPC. Detach the
1185
1441
  // old subagent records first, regardless of extension load order, so its
@@ -1188,13 +1444,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1188
1444
  workflowEngine.fenceCommittedBranch();
1189
1445
  }
1190
1446
  handledTreeEvent = event;
1191
- deliveryGeneration++;
1192
1447
  try {
1193
1448
  const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
1194
1449
  journalBranchAnchor =
1195
1450
  ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
1196
1451
  workflowEngine.restore(entries);
1197
1452
  workflowEngine.resumeLifecycle();
1453
+ deliveryContext = ctx;
1454
+ deliveryBranchAnchor = journalBranchAnchor;
1455
+ hydratePendingDeliveries(entries);
1456
+ schedulePendingDeliveries();
1198
1457
  } catch (error: unknown) {
1199
1458
  active = false; // never resume an old branch after a failed restore
1200
1459
  console.warn(
@@ -1220,12 +1479,28 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1220
1479
  };
1221
1480
 
1222
1481
  /** Background-run result delivery: fail-closed, session-bound (A10). */
1223
- const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
1482
+ const deliverBackgroundResult = async (workflowEngine: WorkflowEngine, runId: string): Promise<void> => {
1224
1483
  const generation = deliveryGeneration;
1225
- const run = await engine.waitFor(runId).catch(() => undefined);
1226
- if (!run || generation !== deliveryGeneration) return;
1484
+ const executionGeneration = workflowEngine.getState(runId)?.executionGeneration;
1485
+ const observerKey = `${generation}:${executionGeneration}`;
1486
+ if (deliveryObservers.get(runId) === observerKey) return;
1487
+ deliveryObservers.set(runId, observerKey);
1488
+ while (deliveryObservers.size > 256) {
1489
+ const oldest = deliveryObservers.keys().next().value;
1490
+ if (oldest === undefined) break;
1491
+ deliveryObservers.delete(oldest);
1492
+ }
1493
+ const run = await workflowEngine.waitFor(runId).catch(() => undefined);
1494
+ if (
1495
+ !run ||
1496
+ generation !== deliveryGeneration ||
1497
+ workflowEngine !== engine ||
1498
+ executionGeneration !== workflowEngine.getState(runId)?.executionGeneration ||
1499
+ !["completed", "failed", "stopped"].includes(run.status)
1500
+ )
1501
+ return;
1227
1502
  const header = `Workflow ${run.runId} ${run.status}`;
1228
- const state = engine.getState(runId);
1503
+ const state = workflowEngine.getState(runId);
1229
1504
  const finalResult = state?.result ?? run.finalResult;
1230
1505
  const body =
1231
1506
  run.error ??
@@ -1233,42 +1508,154 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1233
1508
  ? `completed with ${Object.keys(run.callResults).length} agent call(s).`
1234
1509
  : `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
1235
1510
  const text = `${header}: ${body}`;
1236
- if (deliverResult) {
1237
- try {
1238
- deliverResult(text);
1239
- } catch {
1240
- // Endpoint suspended or send failed — leave pending for next bind (fail-closed).
1241
- rememberPendingDelivery(runId, text);
1242
- try {
1243
- pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
1244
- } catch {
1245
- // appendEntry is best-effort; in-memory marker still ensures flush on next bind
1246
- }
1247
- }
1248
- } else {
1249
- // No bound session endpoint — fail-closed: persist a pending marker and
1250
- // flush it when a session binds, never fall back to shared pi.sendMessage.
1251
- rememberPendingDelivery(runId, text);
1252
- try {
1253
- pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
1254
- } catch {
1255
- // best-effort persistence; in-memory marker still ensures retry
1256
- }
1511
+ if (!ownsDeliveryBranch()) return;
1512
+ rememberPendingDelivery(runId, text);
1513
+ try {
1514
+ pi.appendEntry(PENDING_DELIVERY_TYPE, {
1515
+ runId,
1516
+ text: text.slice(0, 8_000),
1517
+ status: run.status,
1518
+ timestamp: Date.now(),
1519
+ });
1520
+ advanceDeliveryAnchor();
1521
+ } catch {
1522
+ // Best-effort persistence; the in-memory marker remains retryable.
1257
1523
  }
1524
+ schedulePendingDeliveries();
1258
1525
  };
1259
1526
 
1527
+ const ownsDeliveryBranch = (): boolean => {
1528
+ if (!active || !deliveryContext || !engine || engine.isDisposed()) return false;
1529
+ try {
1530
+ const branch = deliveryContext.sessionManager.getBranch();
1531
+ return [deliveryBranchAnchor, journalBranchAnchor].every(
1532
+ (anchor) => !anchor || branch.some((entry) => entry.id === anchor),
1533
+ );
1534
+ } catch {
1535
+ return false;
1536
+ }
1537
+ };
1538
+ const advanceDeliveryAnchor = (): void => {
1539
+ if (!deliveryContext) return;
1540
+ deliveryBranchAnchor =
1541
+ deliveryContext.sessionManager.getLeafId?.() ?? deliveryContext.sessionManager.getBranch().at(-1)?.id ?? null;
1542
+ };
1543
+ const canDeliver = (): boolean => {
1544
+ if (!ownsDeliveryBranch() || inputPreparing || compacting || navigating) return false;
1545
+ try {
1546
+ return deliveryContext?.isIdle() === true && deliveryContext.hasPendingMessages() === false;
1547
+ } catch {
1548
+ return false;
1549
+ }
1550
+ };
1551
+ const hydratePendingDeliveries = (entries: SessionEntryLike[]): void => {
1552
+ pendingDeliveryMarkers.clear();
1553
+ for (const entry of entries) {
1554
+ const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
1555
+ if (shaped.type !== "custom") continue;
1556
+ const data = shaped.data as Record<string, unknown> | undefined;
1557
+ if (typeof data?.runId !== "string") continue;
1558
+ if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) pendingDeliveryMarkers.delete(data.runId);
1559
+ else if (shaped.customType === PENDING_DELIVERY_TYPE && typeof data.text === "string") {
1560
+ rememberPendingDelivery(
1561
+ data.runId,
1562
+ data.text,
1563
+ typeof data.timestamp === "number" ? data.timestamp : Date.now(),
1564
+ );
1565
+ }
1566
+ }
1567
+ };
1260
1568
  const flushPendingDeliveries = (): void => {
1261
- if (!deliverResult || pendingDeliveryMarkers.size === 0) return;
1262
- for (const [runId, pending] of [...pendingDeliveryMarkers]) {
1263
- try {
1264
- deliverResult(pending.text);
1569
+ if (!canDeliver() || !deliveryContext || pendingDeliveryMarkers.size === 0) return;
1570
+ if (typeof pi.sendMessage !== "function" || typeof pi.sendUserMessage !== "function") return;
1571
+ const ctx = deliveryContext;
1572
+ const generation = deliveryGeneration;
1573
+ // Bounded batches coalesce simultaneous completions without model polling.
1574
+ const batch = [...pendingDeliveryMarkers].slice(0, 8);
1575
+ const isCurrent = (): boolean => generation === deliveryGeneration && ctx === deliveryContext && canDeliver();
1576
+ const persisted = (runId: string): boolean =>
1577
+ ctx.sessionManager.getBranch().some((entry) => {
1578
+ const shaped = entry as {
1579
+ type?: string;
1580
+ customType?: string;
1581
+ content?: unknown;
1582
+ details?: { runIds?: unknown };
1583
+ };
1584
+ const ids = shaped.details?.runIds;
1585
+ return (
1586
+ shaped.type === "custom_message" &&
1587
+ shaped.customType === "pi-workflows:delivery" &&
1588
+ typeof shaped.content === "string" &&
1589
+ Array.isArray(ids) &&
1590
+ ids.length > 0 &&
1591
+ ids.length <= 8 &&
1592
+ ids.every((id) => typeof id === "string" && id.length > 0 && id.length <= 128) &&
1593
+ new Set(ids).size === ids.length &&
1594
+ ids.includes(runId)
1595
+ );
1596
+ });
1597
+ try {
1598
+ if (!isCurrent()) return;
1599
+ // Never use a custom-message triggerTurn: that skips input preparation
1600
+ // and before_agent_start. The void API is NOT an acceptance promise.
1601
+ const missing = batch.filter(([runId]) => !persisted(runId));
1602
+ if (missing.length > 0)
1603
+ pi.sendMessage(
1604
+ {
1605
+ content: missing.map(([, marker]) => marker.text).join("\n\n"),
1606
+ display: true,
1607
+ customType: "pi-workflows:delivery",
1608
+ details: { runIds: missing.map(([runId]) => runId) },
1609
+ },
1610
+ { triggerTurn: false },
1611
+ );
1612
+ if (!isCurrent() || !batch.every(([runId]) => persisted(runId))) return;
1613
+ advanceDeliveryAnchor();
1614
+ for (const [runId] of batch) {
1615
+ if (!isCurrent()) return;
1616
+ const priorEntryIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
1265
1617
  pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, { runId, timestamp: Date.now() });
1618
+ if (!isCurrent()) return;
1619
+ const acknowledged = ctx.sessionManager.getBranch().some((entry) => {
1620
+ const shaped = entry as { type?: string; customType?: string; data?: { runId?: unknown } };
1621
+ return (
1622
+ !priorEntryIds.has(entry.id) &&
1623
+ shaped.type === "custom" &&
1624
+ shaped.customType === PENDING_DELIVERY_ACK_TYPE &&
1625
+ shaped.data?.runId === runId
1626
+ );
1627
+ });
1628
+ if (!acknowledged) return;
1629
+ advanceDeliveryAnchor();
1266
1630
  pendingDeliveryMarkers.delete(runId);
1267
- } catch {
1268
- // Keep pending until both delivery and its durable acknowledgement succeed.
1269
1631
  }
1632
+ // An earlier input hook can await while the same manager navigates. Keep
1633
+ // exact owner evidence until our input hook consumes this private wake.
1634
+ if (isCurrent() && engine) {
1635
+ const wake = `${DELIVERY_WAKE_PREFIX}${randomUUID()}] ${DELIVERY_WAKE_TEXT}`;
1636
+ wakeAdmissions.set(wake, { generation, context: ctx, engine, anchor: deliveryBranchAnchor });
1637
+ while (wakeAdmissions.size > MAX_WAKE_ADMISSIONS) {
1638
+ const oldest = wakeAdmissions.keys().next().value;
1639
+ if (oldest === undefined) break;
1640
+ wakeAdmissions.delete(oldest);
1641
+ }
1642
+ // Disable commands/templates explicitly for internal notification input.
1643
+ pi.sendUserMessage(wake, { deliverAs: "followUp", expandPromptTemplates: false });
1644
+ }
1645
+ } catch {
1646
+ // Retain unacknowledged markers. Retry only at a later lifecycle boundary.
1270
1647
  }
1271
1648
  };
1649
+ const schedulePendingDeliveries = (): void => {
1650
+ if (deliveryTimer || pendingDeliveryMarkers.size === 0 || !ownsDeliveryBranch()) return;
1651
+ const generation = deliveryGeneration;
1652
+ // Defer handoff out of our notification-only agent_settled handler;
1653
+ // later handlers may navigate or start compaction in either load order.
1654
+ deliveryTimer = setTimeout(() => {
1655
+ deliveryTimer = undefined;
1656
+ if (generation === deliveryGeneration) flushPendingDeliveries();
1657
+ }, 0);
1658
+ };
1272
1659
 
1273
1660
  /** Build a script from a natural-language prompt and run it in background. */
1274
1661
  const runWorkflowFromPrompt = async (ctx: ExtensionCommandContext, prompt: string): Promise<string | undefined> => {
@@ -1286,7 +1673,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1286
1673
  void deliverBackgroundResult(workflowEngine, result.runId);
1287
1674
  return result.runId;
1288
1675
  } catch (error: unknown) {
1289
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1676
+ notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
1290
1677
  return undefined;
1291
1678
  }
1292
1679
  };
@@ -1297,10 +1684,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1297
1684
  throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
1298
1685
  }
1299
1686
  saveWorkflow(name, state.run.script, sessionCwd);
1300
- ctx.ui.notify(`Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
1687
+ notifyWorkflow(ctx, `Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
1301
1688
  };
1302
1689
 
1303
1690
  pi.on("session_start", async (_event, ctx) => {
1691
+ turnArming.retire();
1304
1692
  if (active || engine) return;
1305
1693
  const generation = ++lifecycleGeneration;
1306
1694
  sessionCwd = ctx.cwd ?? process.cwd();
@@ -1407,56 +1795,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1407
1795
  widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
1408
1796
  registerSurface();
1409
1797
 
1410
- // Bind per-session delivery endpoint (A10): capture the session-bound send
1411
- // at this moment so completions deliver to the originating session, not
1412
- // whichever session happens to be current later. Never fall back to the
1413
- // shared pi.sendMessage — a missing or suspended endpoint leaves the
1414
- // result pending and flushes on the next successful bind (fail-closed).
1415
- try {
1416
- const capturedSend = pi.sendMessage;
1417
- if (typeof capturedSend === "function") {
1418
- deliverResult = (text: string) => {
1419
- // Use the captured function value for session affinity; if the
1420
- // captured function is itself a shared accessor, the flush is still
1421
- // fail-closed (delivery only when bound).
1422
- (capturedSend as unknown as (message: unknown, options: unknown) => unknown).call(
1423
- pi,
1424
- {
1425
- content: text,
1426
- display: true,
1427
- customType: "pi-workflows:delivery",
1428
- },
1429
- { deliverAs: "followUp", triggerTurn: true },
1430
- );
1431
- };
1432
- }
1433
- } catch {
1434
- // binding is best-effort; pending stays buffered
1435
- }
1436
- // Hydrate any pending markers persisted before this bind (e.g. runs that
1437
- // settled before the first session_start) — scan the branch for
1438
- // PENDING_DELIVERY_TYPE entries that have no in-memory counterpart.
1798
+ // Public API methods target the current session: generation + branch
1799
+ // ownership, not a captured function value, provide session affinity.
1800
+ deliveryContext = ctx;
1801
+ deliveryBranchAnchor = journalBranchAnchor;
1802
+ workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
1803
+ workflowUiBridge.publish();
1804
+ inputPreparing = false;
1805
+ compacting = false;
1806
+ navigating = false;
1439
1807
  try {
1440
- const acknowledged = new Set<string>();
1441
- for (const entry of initialEntries) {
1442
- const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
1443
- if (shaped.type !== "custom" || typeof shaped.customType !== "string") continue;
1444
- const data = shaped.data as Record<string, unknown> | undefined;
1445
- const entryRunId = typeof data?.runId === "string" ? data.runId : undefined;
1446
- if (!entryRunId) continue;
1447
- if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) {
1448
- acknowledged.add(entryRunId);
1449
- } else if (shaped.customType === PENDING_DELIVERY_TYPE) {
1450
- const pendingText = typeof data?.text === "string" ? data.text : undefined;
1451
- const timestamp = typeof data?.timestamp === "number" ? data.timestamp : Date.now();
1452
- if (pendingText && !acknowledged.has(entryRunId)) rememberPendingDelivery(entryRunId, pendingText, timestamp);
1453
- }
1454
- }
1455
- for (const runId of acknowledged) pendingDeliveryMarkers.delete(runId);
1808
+ hydratePendingDeliveries(ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
1456
1809
  } catch {
1457
- // scan is best-effort
1810
+ pendingDeliveryMarkers.clear(); // fail closed on an unreadable branch
1458
1811
  }
1459
- flushPendingDeliveries();
1812
+ schedulePendingDeliveries();
1460
1813
 
1461
1814
  // Saved workflow commands register at session_start (Pi cannot unregister
1462
1815
  // or replace command metadata, so factory-time registration would freeze the
@@ -1482,9 +1835,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1482
1835
  };
1483
1836
  const result = await workflowEngine.start(script, options);
1484
1837
  void deliverBackgroundResult(workflowEngine, result.runId);
1485
- commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
1838
+ notifyWorkflow(commandCtx, `Workflow ${savedName} started in background: ${result.runId}`, "info");
1486
1839
  } catch (error: unknown) {
1487
- commandCtx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1840
+ notifyWorkflow(commandCtx, error instanceof Error ? error.message : String(error), "error");
1488
1841
  }
1489
1842
  },
1490
1843
  });
@@ -1493,10 +1846,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1493
1846
  });
1494
1847
 
1495
1848
  pi.on("session_shutdown", async () => {
1496
- deliveryGeneration += 1;
1497
- // Invalidate all long-lived command/tool/widget closures before awaiting
1498
- // engine quiescence; the old session must not accept another run.
1499
- active = false;
1849
+ // This synchronous boundary is shared with the bus-first shutdown path.
1850
+ const quiescence = beginShutdownQuiesce();
1851
+ workflowUiBridge?.dispose();
1852
+ workflowUiBridge = undefined;
1500
1853
  clearWidget();
1501
1854
  widgetUi = undefined;
1502
1855
  if (widgetTimer) {
@@ -1507,7 +1860,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1507
1860
  if (closingEngine) {
1508
1861
  let timer: ReturnType<typeof setTimeout> | undefined;
1509
1862
  const result = await Promise.race([
1510
- beginShutdownQuiesce(),
1863
+ quiescence,
1511
1864
  new Promise<undefined>((resolve) => {
1512
1865
  timer = setTimeout(() => resolve(undefined), 6_000);
1513
1866
  }),
@@ -1529,7 +1882,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1529
1882
  protocolProbe = undefined;
1530
1883
  protocolInFlight = undefined;
1531
1884
  protocolError = undefined;
1532
- deliverResult = undefined;
1885
+ deliveryContext = undefined;
1533
1886
  });
1534
1887
  }
1535
1888