@signalridge/pi-workflows 1.7.4 → 1.8.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
5
  import { Text } 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";
@@ -118,9 +115,10 @@ function textResult(text: string, details: unknown = {}) {
118
115
  * Run one command for diff discovery. argv array, never a shell string — a
119
116
  * branch name or path reaches this from user input.
120
117
  */
121
- function runCommand(file: string, args: string[]): CommandResult {
118
+ function runCommand(file: string, args: string[], cwd: string): CommandResult {
122
119
  try {
123
120
  const stdout = execFileSync(file, args, {
121
+ cwd,
124
122
  encoding: "utf8",
125
123
  maxBuffer: 32 * 1024 * 1024,
126
124
  stdio: ["ignore", "pipe", "ignore"],
@@ -334,6 +332,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
334
332
  let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
335
333
  let lifecycleGeneration = 0;
336
334
  let deliveryGeneration = 0;
335
+ const deliveryObservers = new Map<string, string>();
337
336
  let handledTreeEvent: unknown;
338
337
  let journalBranchAnchor: string | null | undefined;
339
338
  let protocolAbortController: AbortController | undefined;
@@ -342,9 +341,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
342
341
  let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
343
342
  let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
344
343
  let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
345
- /** Per-session delivery endpoint for background-run results (A10 fail-closed). */
346
- let deliverResult: ((text: string, details?: unknown) => void) | undefined;
347
- /** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
344
+ /** Public sends are void: acceptance is checked against the owning branch. */
345
+ let deliveryContext: ExtensionContext | undefined;
346
+ let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
347
+ let inputPreparing = false;
348
+ const turnArming = new WorkflowTurnArming();
349
+ let compacting = false;
350
+ let navigating = false;
351
+ let deliveryBranchAnchor: string | null | undefined;
352
+ const DELIVERY_WAKE_PREFIX = "[pi-workflows:wake:";
353
+ const DELIVERY_WAKE_TEXT = "Workflow results are ready in the preceding context.";
354
+ const MAX_WAKE_ADMISSIONS = 256;
355
+ // Private, one-use admissions survive an earlier awaited input handler, not
356
+ // a lifecycle retirement. Missing/evicted IDs fail closed without tombstones.
357
+ const wakeAdmissions = new Map<
358
+ string,
359
+ { generation: number; context: ExtensionContext; engine: WorkflowEngine; anchor: string | null | undefined }
360
+ >();
361
+ /** Pending deliveries for completed runs, including durable recovery (A10). */
348
362
  const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
349
363
  const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
350
364
  const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
@@ -381,21 +395,46 @@ export default function piWorkflows(pi: ExtensionAPI): void {
381
395
  else console.warn(`[pi-workflows] ${notice}`);
382
396
  return { cancel: true };
383
397
  };
398
+ const invalidateDelivery = (): void => {
399
+ deliveryGeneration++;
400
+ deliveryObservers.clear();
401
+ turnArming.retire();
402
+ wakeAdmissions.clear();
403
+ if (deliveryTimer) clearTimeout(deliveryTimer);
404
+ deliveryTimer = undefined;
405
+ };
384
406
  const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
385
407
  if (shutdownQuiesce) return shutdownQuiesce;
408
+ // Retire admission and delivery BEFORE aborting children, including when
409
+ // subagents initiates shutdown first. Journal cleanup retains its separate
410
+ // lifecycle generation until the native shutdown hook finishes quiescence.
411
+ active = false;
412
+ invalidateDelivery();
413
+ deliveryContext = undefined;
386
414
  const closingEngine = engine;
387
- if (!closingEngine || closingEngine.isDisposed()) return Promise.resolve({ settled: true, pending: [] });
388
- shutdownQuiesce = closingEngine.quiesceForBranchChange().catch((error: unknown) => ({
389
- settled: false,
390
- pending: [],
391
- diagnostic: error instanceof Error ? error.message : String(error),
392
- }));
415
+ if (!closingEngine || closingEngine.isDisposed()) {
416
+ shutdownQuiesce = Promise.resolve({ settled: true, pending: [] });
417
+ return shutdownQuiesce;
418
+ }
419
+ // Publish the handshake promise before abort can synchronously re-enter
420
+ // the bus through an owned-child cleanup callback.
421
+ let settle: (result: { settled: boolean; pending: string[]; diagnostic?: string }) => void = () => {};
422
+ shutdownQuiesce = new Promise((resolve) => {
423
+ settle = resolve;
424
+ });
425
+ void closingEngine.quiesceForBranchChange().then(settle, (error: unknown) => {
426
+ settle({
427
+ settled: false,
428
+ pending: [],
429
+ diagnostic: error instanceof Error ? error.message : String(error),
430
+ });
431
+ });
393
432
  return shutdownQuiesce;
394
433
  };
395
434
  // Subagents starts this handshake before retiring the owned-agent RPC. When
396
435
  // workflows shuts down first, its native hook performs the same operation.
397
436
  pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
398
- if (!active || !engine) return;
437
+ if (!engine) return;
399
438
  const respond = (raw as { respond?: unknown } | null)?.respond;
400
439
  if (typeof respond === "function") respond(beginShutdownQuiesce());
401
440
  });
@@ -404,6 +443,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
404
443
  const event = (raw as { event?: unknown } | null)?.event;
405
444
  if (!event || event === handledTreeEvent) return;
406
445
  handledTreeEvent = event;
446
+ invalidateDelivery();
447
+ pendingDeliveryMarkers.clear();
407
448
  engine.fenceCommittedBranch();
408
449
  });
409
450
 
@@ -488,7 +529,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
488
529
  active = true;
489
530
  const workflowEngine = currentEngine();
490
531
  workflowEngine.onRunSettled = notifyRunChanged;
532
+ workflowEngine.onBackgroundExecution = (runId) => void deliverBackgroundResult(workflowEngine, runId);
491
533
  workflowEngine.onRuntimeEvent = notifyRunChanged;
534
+ if (typeof pi.registerMessageRenderer === "function") {
535
+ pi.registerMessageRenderer("pi-workflows:delivery", (message, { expanded }, theme) => {
536
+ const original =
537
+ typeof message.content === "string"
538
+ ? message.content
539
+ : message.content
540
+ .filter((part) => part.type === "text")
541
+ .map((part) => part.text)
542
+ .join("\n");
543
+ const display = sanitizeDisplayText(original, { multiline: true });
544
+ return new Text(theme.fg("toolOutput", display.slice(0, expanded ? 8000 : 2000)), 0, 0);
545
+ });
546
+ }
492
547
  // `control("resume")` and the provider-limit retry start executions with no
493
548
  // caller to pass options for them, and the strength table is deliberately
494
549
  // never frozen onto a run. Without this they would resume with no table and
@@ -518,11 +573,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
518
573
  ],
519
574
  parameters: WorkflowToolSchema,
520
575
  renderCall(args, theme) {
521
- const label = args.name ?? (args.script !== undefined ? "script" : "");
576
+ const label = sanitizeDisplayText(args.name ?? (args.script !== undefined ? "script" : ""));
522
577
  return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${label}`)), 0, 0);
523
578
  },
524
579
  renderResult(result, { expanded, isPartial }, theme) {
525
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
580
+ const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
581
+ multiline: true,
582
+ });
526
583
  if (isPartial) return new Text(theme.fg("warning", "Workflow running…"), 0, 0);
527
584
  const preview = expanded ? text.slice(0, 8_000) : text.split("\n").slice(0, 4).join("\n");
528
585
  return new Text(
@@ -584,7 +641,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
584
641
  replacement,
585
642
  );
586
643
  if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
587
- if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
588
644
  return textResult(formatStart(resumed), resumed);
589
645
  }
590
646
  const result = await workflowEngine.start(script, options);
@@ -609,10 +665,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
609
665
  "List, inspect, pause, resume, stop, or remove workflow runs with bounded machine-readable output.",
610
666
  parameters: ControlSchema,
611
667
  renderCall(args, theme) {
612
- return new Text(theme.fg("toolTitle", theme.bold(`Workflow control: ${args.action ?? ""}`)), 0, 0);
668
+ return new Text(
669
+ theme.fg("toolTitle", theme.bold(`Workflow control: ${sanitizeDisplayText(args.action ?? "")}`)),
670
+ 0,
671
+ 0,
672
+ );
613
673
  },
614
674
  renderResult(result, { expanded }, theme) {
615
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
675
+ const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
676
+ multiline: true,
677
+ });
616
678
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
617
679
  },
618
680
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -949,7 +1011,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
949
1011
  // /workflows-trigger: keyword arming (A5). Typing the bounded word
950
1012
  // `workflow`/`workflows` — or a configured synonym — in an ordinary message
951
1013
  // counts as an explicit opt-in to multi-agent orchestration, and the message
952
- // is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
1014
+ // receives hidden model guidance. Arming AUTHORIZES the tool; it never forces a run,
953
1015
  // and it never opens UI. A user asking a question about workflows still gets
954
1016
  // an ordinary answer.
955
1017
  const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
@@ -1002,59 +1064,66 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1002
1064
  },
1003
1065
  });
1004
1066
 
1005
- // Keyword arming input hook (A5). The message is annotated rather than
1006
- // acted on: the model is told it may use the `workflow` tool and may still
1067
+ // Keyword arming input hook (A5). Record provenance, never append model
1068
+ // guidance to native user text: the model may use `workflow` and may still
1007
1069
  // decline, which is what keeps "how do workflows work?" an ordinary
1008
1070
  // question. Nothing is swallowed and no UI opens.
1009
- pi.on("input", (event) => {
1010
- if (event.source === "extension") return { action: "continue" as const };
1011
- const deduplicatedText = deduplicateWorkflowDirective(event.text);
1012
- if (deduplicatedText !== event.text) {
1071
+ pi.on("input", (event, ctx) => {
1072
+ const ownWake = event.source === "extension" && event.text.startsWith(DELIVERY_WAKE_PREFIX);
1073
+ if (ownWake) {
1074
+ const admission = wakeAdmissions.get(event.text);
1075
+ wakeAdmissions.delete(event.text); // exact, one-use ownership, even on rejection
1076
+ if (
1077
+ !admission ||
1078
+ admission.generation !== deliveryGeneration ||
1079
+ admission.context !== deliveryContext ||
1080
+ admission.engine !== engine ||
1081
+ !canDeliver()
1082
+ ) {
1083
+ turnArming.retire();
1084
+ return { action: "handled" as const };
1085
+ }
1086
+ try {
1087
+ if (
1088
+ admission.anchor &&
1089
+ !admission.context.sessionManager.getBranch().some((entry) => entry.id === admission.anchor)
1090
+ ) {
1091
+ turnArming.retire();
1092
+ return { action: "handled" as const };
1093
+ }
1094
+ } catch {
1095
+ turnArming.retire();
1096
+ return { action: "handled" as const };
1097
+ }
1098
+ }
1099
+ inputPreparing = true;
1100
+ navigating = false; // a fresh prompt supersedes an uncommitted navigation
1101
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1102
+ deliveryTimer = undefined;
1103
+ const text = event.text;
1104
+ const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(text, triggerKeyword);
1105
+ const substantive = effortLevel !== "off" && text.trim().length >= 16 && !text.trim().startsWith("/");
1106
+ turnArming.observe(
1107
+ { ...event, text },
1108
+ ctx,
1109
+ active && (explicitTrigger || substantive),
1110
+ effortLevel,
1111
+ explicitTrigger,
1112
+ );
1113
+ if (ownWake)
1013
1114
  return {
1014
1115
  action: "transform" as const,
1015
- text: deduplicatedText,
1116
+ text: DELIVERY_WAKE_TEXT,
1016
1117
  ...(event.images ? { images: event.images } : {}),
1017
1118
  };
1018
- }
1019
- const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(event.text, triggerKeyword);
1020
- const substantive = effortLevel !== "off" && event.text.trim().length >= 16 && !event.text.trim().startsWith("/");
1021
- if (!explicitTrigger && !substantive) return { action: "continue" as const };
1022
- if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
1023
- const effortDirective =
1024
- effortLevel === "ultra"
1025
- ? '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.'
1026
- : effortLevel === "high"
1027
- ? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
1028
- : "";
1029
- return {
1030
- action: "transform" as const,
1031
- text: `${event.text}\n\n${WORKFLOW_ARMED_DIRECTIVE}${effortDirective ? `\n${effortDirective}` : ""}`,
1032
- ...(event.images ? { images: event.images } : {}),
1033
- };
1119
+ return { action: "continue" as const };
1034
1120
  });
1035
1121
 
1036
- // Input transforms can be chained with handlers loaded after this package.
1037
- // Normalize only our own repeated marker again at the final context boundary;
1038
- // unrelated plugin content and hooks remain untouched.
1039
- pi.on("context", (event) => {
1040
- type UserMessage = Extract<(typeof event.messages)[number], { role: "user" }>;
1041
- let lastUserIndex = -1;
1042
- let lastUserMessage: UserMessage | undefined;
1043
- for (let index = 0; index < event.messages.length; index += 1) {
1044
- const message = event.messages[index];
1045
- if (message && "role" in message && message.role === "user") {
1046
- lastUserIndex = index;
1047
- lastUserMessage = message;
1048
- }
1049
- }
1050
- const message = lastUserMessage;
1051
- if (lastUserIndex < 0 || !message || !("content" in message) || typeof message.content !== "string") return;
1052
- const deduplicatedText = deduplicateWorkflowDirective(message.content);
1053
- if (deduplicatedText === message.content) return;
1054
- const messages = event.messages.slice();
1055
- messages[lastUserIndex] = { ...message, content: deduplicatedText };
1056
- return { messages };
1057
- });
1122
+ // Hidden guidance is turn-local, including on reload and after later hooks
1123
+ // navigate while before_agent_start is awaiting. Never edit native history.
1124
+ // Request-local overlays never enter native history or summarization.
1125
+ pi.on("message_start", (event, ctx) => turnArming.messageStarted(event, ctx));
1126
+ pi.on("context", (event, ctx) => turnArming.filterContext(event, ctx));
1058
1127
 
1059
1128
  // Standing effort mode mirrors the reference: it auto-arms substantive
1060
1129
  // interactive messages but remains guidance, not an inferred spend ceiling.
@@ -1115,7 +1184,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1115
1184
  // takes a diff rather than something typed, so it resolves one first.
1116
1185
  let scriptArgs: Record<string, unknown> | undefined;
1117
1186
  if (name === "code-review") {
1118
- const scope = resolveCodeReviewScope(text, runCommand);
1187
+ const cwd = ctx.cwd;
1188
+ const scope = resolveCodeReviewScope(text, (file, argv) => runCommand(file, argv, cwd));
1119
1189
  for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
1120
1190
  if (!scope.diff.trim()) return;
1121
1191
  scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
@@ -1151,8 +1221,64 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1151
1221
 
1152
1222
  // Neither before-event is a commit point. Pi can veto in a later handler,
1153
1223
  // or summarization may abort after every handler has run.
1154
- pi.on("session_before_switch", (_event, ctx) => navigationPreflight(ctx));
1155
- pi.on("session_before_tree", (_event, ctx) => navigationPreflight(ctx));
1224
+ const beforeNavigation = (ctx: ExtensionContext): { cancel: true } | undefined => {
1225
+ turnArming.retire();
1226
+ const veto = navigationPreflight(ctx);
1227
+ if (!veto) {
1228
+ navigating = true;
1229
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1230
+ deliveryTimer = undefined;
1231
+ }
1232
+ return veto;
1233
+ };
1234
+ pi.on("session_before_switch", (_event, ctx) => beforeNavigation(ctx));
1235
+ pi.on("session_before_tree", (event, ctx) => {
1236
+ // The public preparation array is mutable; remove only legacy owned
1237
+ // custom guidance, never quoted user content or stored history.
1238
+ const entries = event.preparation?.entriesToSummarize ?? [];
1239
+ for (let index = entries.length - 1; index >= 0; index--) {
1240
+ const entry = entries[index];
1241
+ if (entry?.type === "custom_message" && entry.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
1242
+ entries.splice(index, 1);
1243
+ }
1244
+ return beforeNavigation(ctx);
1245
+ });
1246
+ pi.on("session_before_fork", (_event, ctx) => beforeNavigation(ctx));
1247
+ pi.on("before_agent_start", (event, ctx) => {
1248
+ inputPreparing = true;
1249
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1250
+ deliveryTimer = undefined;
1251
+ return turnArming.consume(event, ctx);
1252
+ });
1253
+ pi.on("agent_start", () => {
1254
+ inputPreparing = false;
1255
+ });
1256
+ pi.on("session_before_compact", (event) => {
1257
+ for (const messages of [
1258
+ event.preparation?.messagesToSummarize ?? [],
1259
+ event.preparation?.turnPrefixMessages ?? [],
1260
+ ]) {
1261
+ for (let index = messages.length - 1; index >= 0; index--) {
1262
+ const message = messages[index];
1263
+ if (message?.role === "custom" && message.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
1264
+ messages.splice(index, 1);
1265
+ }
1266
+ }
1267
+ compacting = true;
1268
+ if (deliveryTimer) clearTimeout(deliveryTimer);
1269
+ deliveryTimer = undefined;
1270
+ });
1271
+ const afterCompact = (): void => {
1272
+ compacting = false;
1273
+ schedulePendingDeliveries();
1274
+ };
1275
+ pi.on("session_compact", afterCompact);
1276
+ pi.on("session_compact_failed", afterCompact);
1277
+ pi.on("agent_settled", () => {
1278
+ turnArming.retire();
1279
+ inputPreparing = false;
1280
+ schedulePendingDeliveries();
1281
+ });
1156
1282
  pi.on("session_tree", (event, ctx) => {
1157
1283
  let workflowEngine: WorkflowEngine;
1158
1284
  try {
@@ -1160,6 +1286,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1160
1286
  } catch {
1161
1287
  return;
1162
1288
  }
1289
+ invalidateDelivery();
1290
+ pendingDeliveryMarkers.clear();
1291
+ inputPreparing = false;
1292
+ compacting = false;
1293
+ navigating = false;
1163
1294
  if (handledTreeEvent !== event) {
1164
1295
  // A controller abort can synchronously call stop-owned RPC. Detach the
1165
1296
  // old subagent records first, regardless of extension load order, so its
@@ -1168,13 +1299,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1168
1299
  workflowEngine.fenceCommittedBranch();
1169
1300
  }
1170
1301
  handledTreeEvent = event;
1171
- deliveryGeneration++;
1172
1302
  try {
1173
1303
  const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
1174
1304
  journalBranchAnchor =
1175
1305
  ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
1176
1306
  workflowEngine.restore(entries);
1177
1307
  workflowEngine.resumeLifecycle();
1308
+ deliveryContext = ctx;
1309
+ deliveryBranchAnchor = journalBranchAnchor;
1310
+ hydratePendingDeliveries(entries);
1311
+ schedulePendingDeliveries();
1178
1312
  } catch (error: unknown) {
1179
1313
  active = false; // never resume an old branch after a failed restore
1180
1314
  console.warn(
@@ -1200,12 +1334,28 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1200
1334
  };
1201
1335
 
1202
1336
  /** Background-run result delivery: fail-closed, session-bound (A10). */
1203
- const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
1337
+ const deliverBackgroundResult = async (workflowEngine: WorkflowEngine, runId: string): Promise<void> => {
1204
1338
  const generation = deliveryGeneration;
1205
- const run = await engine.waitFor(runId).catch(() => undefined);
1206
- if (!run || generation !== deliveryGeneration) return;
1339
+ const executionGeneration = workflowEngine.getState(runId)?.executionGeneration;
1340
+ const observerKey = `${generation}:${executionGeneration}`;
1341
+ if (deliveryObservers.get(runId) === observerKey) return;
1342
+ deliveryObservers.set(runId, observerKey);
1343
+ while (deliveryObservers.size > 256) {
1344
+ const oldest = deliveryObservers.keys().next().value;
1345
+ if (oldest === undefined) break;
1346
+ deliveryObservers.delete(oldest);
1347
+ }
1348
+ const run = await workflowEngine.waitFor(runId).catch(() => undefined);
1349
+ if (
1350
+ !run ||
1351
+ generation !== deliveryGeneration ||
1352
+ workflowEngine !== engine ||
1353
+ executionGeneration !== workflowEngine.getState(runId)?.executionGeneration ||
1354
+ !["completed", "failed", "stopped"].includes(run.status)
1355
+ )
1356
+ return;
1207
1357
  const header = `Workflow ${run.runId} ${run.status}`;
1208
- const state = engine.getState(runId);
1358
+ const state = workflowEngine.getState(runId);
1209
1359
  const finalResult = state?.result ?? run.finalResult;
1210
1360
  const body =
1211
1361
  run.error ??
@@ -1213,42 +1363,154 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1213
1363
  ? `completed with ${Object.keys(run.callResults).length} agent call(s).`
1214
1364
  : `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
1215
1365
  const text = `${header}: ${body}`;
1216
- if (deliverResult) {
1217
- try {
1218
- deliverResult(text);
1219
- } catch {
1220
- // Endpoint suspended or send failed — leave pending for next bind (fail-closed).
1221
- rememberPendingDelivery(runId, text);
1222
- try {
1223
- pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
1224
- } catch {
1225
- // appendEntry is best-effort; in-memory marker still ensures flush on next bind
1226
- }
1227
- }
1228
- } else {
1229
- // No bound session endpoint — fail-closed: persist a pending marker and
1230
- // flush it when a session binds, never fall back to shared pi.sendMessage.
1231
- rememberPendingDelivery(runId, text);
1232
- try {
1233
- pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
1234
- } catch {
1235
- // best-effort persistence; in-memory marker still ensures retry
1236
- }
1366
+ if (!ownsDeliveryBranch()) return;
1367
+ rememberPendingDelivery(runId, text);
1368
+ try {
1369
+ pi.appendEntry(PENDING_DELIVERY_TYPE, {
1370
+ runId,
1371
+ text: text.slice(0, 8_000),
1372
+ status: run.status,
1373
+ timestamp: Date.now(),
1374
+ });
1375
+ advanceDeliveryAnchor();
1376
+ } catch {
1377
+ // Best-effort persistence; the in-memory marker remains retryable.
1237
1378
  }
1379
+ schedulePendingDeliveries();
1238
1380
  };
1239
1381
 
1382
+ const ownsDeliveryBranch = (): boolean => {
1383
+ if (!active || !deliveryContext || !engine || engine.isDisposed()) return false;
1384
+ try {
1385
+ const branch = deliveryContext.sessionManager.getBranch();
1386
+ return [deliveryBranchAnchor, journalBranchAnchor].every(
1387
+ (anchor) => !anchor || branch.some((entry) => entry.id === anchor),
1388
+ );
1389
+ } catch {
1390
+ return false;
1391
+ }
1392
+ };
1393
+ const advanceDeliveryAnchor = (): void => {
1394
+ if (!deliveryContext) return;
1395
+ deliveryBranchAnchor =
1396
+ deliveryContext.sessionManager.getLeafId?.() ?? deliveryContext.sessionManager.getBranch().at(-1)?.id ?? null;
1397
+ };
1398
+ const canDeliver = (): boolean => {
1399
+ if (!ownsDeliveryBranch() || inputPreparing || compacting || navigating) return false;
1400
+ try {
1401
+ return deliveryContext?.isIdle() === true && deliveryContext.hasPendingMessages() === false;
1402
+ } catch {
1403
+ return false;
1404
+ }
1405
+ };
1406
+ const hydratePendingDeliveries = (entries: SessionEntryLike[]): void => {
1407
+ pendingDeliveryMarkers.clear();
1408
+ for (const entry of entries) {
1409
+ const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
1410
+ if (shaped.type !== "custom") continue;
1411
+ const data = shaped.data as Record<string, unknown> | undefined;
1412
+ if (typeof data?.runId !== "string") continue;
1413
+ if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) pendingDeliveryMarkers.delete(data.runId);
1414
+ else if (shaped.customType === PENDING_DELIVERY_TYPE && typeof data.text === "string") {
1415
+ rememberPendingDelivery(
1416
+ data.runId,
1417
+ data.text,
1418
+ typeof data.timestamp === "number" ? data.timestamp : Date.now(),
1419
+ );
1420
+ }
1421
+ }
1422
+ };
1240
1423
  const flushPendingDeliveries = (): void => {
1241
- if (!deliverResult || pendingDeliveryMarkers.size === 0) return;
1242
- for (const [runId, pending] of [...pendingDeliveryMarkers]) {
1243
- try {
1244
- deliverResult(pending.text);
1424
+ if (!canDeliver() || !deliveryContext || pendingDeliveryMarkers.size === 0) return;
1425
+ if (typeof pi.sendMessage !== "function" || typeof pi.sendUserMessage !== "function") return;
1426
+ const ctx = deliveryContext;
1427
+ const generation = deliveryGeneration;
1428
+ // Bounded batches coalesce simultaneous completions without model polling.
1429
+ const batch = [...pendingDeliveryMarkers].slice(0, 8);
1430
+ const isCurrent = (): boolean => generation === deliveryGeneration && ctx === deliveryContext && canDeliver();
1431
+ const persisted = (runId: string): boolean =>
1432
+ ctx.sessionManager.getBranch().some((entry) => {
1433
+ const shaped = entry as {
1434
+ type?: string;
1435
+ customType?: string;
1436
+ content?: unknown;
1437
+ details?: { runIds?: unknown };
1438
+ };
1439
+ const ids = shaped.details?.runIds;
1440
+ return (
1441
+ shaped.type === "custom_message" &&
1442
+ shaped.customType === "pi-workflows:delivery" &&
1443
+ typeof shaped.content === "string" &&
1444
+ Array.isArray(ids) &&
1445
+ ids.length > 0 &&
1446
+ ids.length <= 8 &&
1447
+ ids.every((id) => typeof id === "string" && id.length > 0 && id.length <= 128) &&
1448
+ new Set(ids).size === ids.length &&
1449
+ ids.includes(runId)
1450
+ );
1451
+ });
1452
+ try {
1453
+ if (!isCurrent()) return;
1454
+ // Never use a custom-message triggerTurn: that skips input preparation
1455
+ // and before_agent_start. The void API is NOT an acceptance promise.
1456
+ const missing = batch.filter(([runId]) => !persisted(runId));
1457
+ if (missing.length > 0)
1458
+ pi.sendMessage(
1459
+ {
1460
+ content: missing.map(([, marker]) => marker.text).join("\n\n"),
1461
+ display: true,
1462
+ customType: "pi-workflows:delivery",
1463
+ details: { runIds: missing.map(([runId]) => runId) },
1464
+ },
1465
+ { triggerTurn: false },
1466
+ );
1467
+ if (!isCurrent() || !batch.every(([runId]) => persisted(runId))) return;
1468
+ advanceDeliveryAnchor();
1469
+ for (const [runId] of batch) {
1470
+ if (!isCurrent()) return;
1471
+ const priorEntryIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
1245
1472
  pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, { runId, timestamp: Date.now() });
1473
+ if (!isCurrent()) return;
1474
+ const acknowledged = ctx.sessionManager.getBranch().some((entry) => {
1475
+ const shaped = entry as { type?: string; customType?: string; data?: { runId?: unknown } };
1476
+ return (
1477
+ !priorEntryIds.has(entry.id) &&
1478
+ shaped.type === "custom" &&
1479
+ shaped.customType === PENDING_DELIVERY_ACK_TYPE &&
1480
+ shaped.data?.runId === runId
1481
+ );
1482
+ });
1483
+ if (!acknowledged) return;
1484
+ advanceDeliveryAnchor();
1246
1485
  pendingDeliveryMarkers.delete(runId);
1247
- } catch {
1248
- // Keep pending until both delivery and its durable acknowledgement succeed.
1249
1486
  }
1487
+ // An earlier input hook can await while the same manager navigates. Keep
1488
+ // exact owner evidence until our input hook consumes this private wake.
1489
+ if (isCurrent() && engine) {
1490
+ const wake = `${DELIVERY_WAKE_PREFIX}${randomUUID()}] ${DELIVERY_WAKE_TEXT}`;
1491
+ wakeAdmissions.set(wake, { generation, context: ctx, engine, anchor: deliveryBranchAnchor });
1492
+ while (wakeAdmissions.size > MAX_WAKE_ADMISSIONS) {
1493
+ const oldest = wakeAdmissions.keys().next().value;
1494
+ if (oldest === undefined) break;
1495
+ wakeAdmissions.delete(oldest);
1496
+ }
1497
+ // Disable commands/templates explicitly for internal notification input.
1498
+ pi.sendUserMessage(wake, { deliverAs: "followUp", expandPromptTemplates: false });
1499
+ }
1500
+ } catch {
1501
+ // Retain unacknowledged markers. Retry only at a later lifecycle boundary.
1250
1502
  }
1251
1503
  };
1504
+ const schedulePendingDeliveries = (): void => {
1505
+ if (deliveryTimer || pendingDeliveryMarkers.size === 0 || !ownsDeliveryBranch()) return;
1506
+ const generation = deliveryGeneration;
1507
+ // Defer handoff out of our notification-only agent_settled handler;
1508
+ // later handlers may navigate or start compaction in either load order.
1509
+ deliveryTimer = setTimeout(() => {
1510
+ deliveryTimer = undefined;
1511
+ if (generation === deliveryGeneration) flushPendingDeliveries();
1512
+ }, 0);
1513
+ };
1252
1514
 
1253
1515
  /** Build a script from a natural-language prompt and run it in background. */
1254
1516
  const runWorkflowFromPrompt = async (ctx: ExtensionCommandContext, prompt: string): Promise<string | undefined> => {
@@ -1281,6 +1543,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1281
1543
  };
1282
1544
 
1283
1545
  pi.on("session_start", async (_event, ctx) => {
1546
+ turnArming.retire();
1284
1547
  if (active || engine) return;
1285
1548
  const generation = ++lifecycleGeneration;
1286
1549
  sessionCwd = ctx.cwd ?? process.cwd();
@@ -1357,6 +1620,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1357
1620
  }
1358
1621
  },
1359
1622
  awaitProtocol,
1623
+ sessionCwd,
1360
1624
  );
1361
1625
  try {
1362
1626
  const entries =
@@ -1386,56 +1650,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1386
1650
  widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
1387
1651
  registerSurface();
1388
1652
 
1389
- // Bind per-session delivery endpoint (A10): capture the session-bound send
1390
- // at this moment so completions deliver to the originating session, not
1391
- // whichever session happens to be current later. Never fall back to the
1392
- // shared pi.sendMessage — a missing or suspended endpoint leaves the
1393
- // result pending and flushes on the next successful bind (fail-closed).
1653
+ // Public API methods target the current session: generation + branch
1654
+ // ownership, not a captured function value, provide session affinity.
1655
+ deliveryContext = ctx;
1656
+ deliveryBranchAnchor = journalBranchAnchor;
1657
+ inputPreparing = false;
1658
+ compacting = false;
1659
+ navigating = false;
1394
1660
  try {
1395
- const capturedSend = pi.sendMessage;
1396
- if (typeof capturedSend === "function") {
1397
- deliverResult = (text: string) => {
1398
- // Use the captured function value for session affinity; if the
1399
- // captured function is itself a shared accessor, the flush is still
1400
- // fail-closed (delivery only when bound).
1401
- (capturedSend as unknown as (message: unknown, options: unknown) => unknown).call(
1402
- pi,
1403
- {
1404
- content: text,
1405
- display: true,
1406
- customType: "pi-workflows:delivery",
1407
- },
1408
- { deliverAs: "followUp", triggerTurn: true },
1409
- );
1410
- };
1411
- }
1661
+ hydratePendingDeliveries(ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
1412
1662
  } catch {
1413
- // binding is best-effort; pending stays buffered
1663
+ pendingDeliveryMarkers.clear(); // fail closed on an unreadable branch
1414
1664
  }
1415
- // Hydrate any pending markers persisted before this bind (e.g. runs that
1416
- // settled before the first session_start) — scan the branch for
1417
- // PENDING_DELIVERY_TYPE entries that have no in-memory counterpart.
1418
- try {
1419
- const acknowledged = new Set<string>();
1420
- for (const entry of initialEntries) {
1421
- const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
1422
- if (shaped.type !== "custom" || typeof shaped.customType !== "string") continue;
1423
- const data = shaped.data as Record<string, unknown> | undefined;
1424
- const entryRunId = typeof data?.runId === "string" ? data.runId : undefined;
1425
- if (!entryRunId) continue;
1426
- if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) {
1427
- acknowledged.add(entryRunId);
1428
- } else if (shaped.customType === PENDING_DELIVERY_TYPE) {
1429
- const pendingText = typeof data?.text === "string" ? data.text : undefined;
1430
- const timestamp = typeof data?.timestamp === "number" ? data.timestamp : Date.now();
1431
- if (pendingText && !acknowledged.has(entryRunId)) rememberPendingDelivery(entryRunId, pendingText, timestamp);
1432
- }
1433
- }
1434
- for (const runId of acknowledged) pendingDeliveryMarkers.delete(runId);
1435
- } catch {
1436
- // scan is best-effort
1437
- }
1438
- flushPendingDeliveries();
1665
+ schedulePendingDeliveries();
1439
1666
 
1440
1667
  // Saved workflow commands register at session_start (Pi cannot unregister
1441
1668
  // or replace command metadata, so factory-time registration would freeze the
@@ -1472,10 +1699,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1472
1699
  });
1473
1700
 
1474
1701
  pi.on("session_shutdown", async () => {
1475
- deliveryGeneration += 1;
1476
- // Invalidate all long-lived command/tool/widget closures before awaiting
1477
- // engine quiescence; the old session must not accept another run.
1478
- active = false;
1702
+ // This synchronous boundary is shared with the bus-first shutdown path.
1703
+ const quiescence = beginShutdownQuiesce();
1479
1704
  clearWidget();
1480
1705
  widgetUi = undefined;
1481
1706
  if (widgetTimer) {
@@ -1486,7 +1711,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1486
1711
  if (closingEngine) {
1487
1712
  let timer: ReturnType<typeof setTimeout> | undefined;
1488
1713
  const result = await Promise.race([
1489
- beginShutdownQuiesce(),
1714
+ quiescence,
1490
1715
  new Promise<undefined>((resolve) => {
1491
1716
  timer = setTimeout(() => resolve(undefined), 6_000);
1492
1717
  }),
@@ -1508,7 +1733,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1508
1733
  protocolProbe = undefined;
1509
1734
  protocolInFlight = undefined;
1510
1735
  protocolError = undefined;
1511
- deliverResult = undefined;
1736
+ deliveryContext = undefined;
1512
1737
  });
1513
1738
  }
1514
1739