@signalridge/pi-workflows 1.7.5 → 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";
@@ -335,6 +332,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
335
332
  let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
336
333
  let lifecycleGeneration = 0;
337
334
  let deliveryGeneration = 0;
335
+ const deliveryObservers = new Map<string, string>();
338
336
  let handledTreeEvent: unknown;
339
337
  let journalBranchAnchor: string | null | undefined;
340
338
  let protocolAbortController: AbortController | undefined;
@@ -343,9 +341,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
343
341
  let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
344
342
  let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
345
343
  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). */
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). */
349
362
  const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
350
363
  const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
351
364
  const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
@@ -382,21 +395,46 @@ export default function piWorkflows(pi: ExtensionAPI): void {
382
395
  else console.warn(`[pi-workflows] ${notice}`);
383
396
  return { cancel: true };
384
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
+ };
385
406
  const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
386
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;
387
414
  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
- }));
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
+ });
394
432
  return shutdownQuiesce;
395
433
  };
396
434
  // Subagents starts this handshake before retiring the owned-agent RPC. When
397
435
  // workflows shuts down first, its native hook performs the same operation.
398
436
  pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
399
- if (!active || !engine) return;
437
+ if (!engine) return;
400
438
  const respond = (raw as { respond?: unknown } | null)?.respond;
401
439
  if (typeof respond === "function") respond(beginShutdownQuiesce());
402
440
  });
@@ -405,6 +443,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
405
443
  const event = (raw as { event?: unknown } | null)?.event;
406
444
  if (!event || event === handledTreeEvent) return;
407
445
  handledTreeEvent = event;
446
+ invalidateDelivery();
447
+ pendingDeliveryMarkers.clear();
408
448
  engine.fenceCommittedBranch();
409
449
  });
410
450
 
@@ -489,7 +529,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
489
529
  active = true;
490
530
  const workflowEngine = currentEngine();
491
531
  workflowEngine.onRunSettled = notifyRunChanged;
532
+ workflowEngine.onBackgroundExecution = (runId) => void deliverBackgroundResult(workflowEngine, runId);
492
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
+ }
493
547
  // `control("resume")` and the provider-limit retry start executions with no
494
548
  // caller to pass options for them, and the strength table is deliberately
495
549
  // never frozen onto a run. Without this they would resume with no table and
@@ -519,11 +573,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
519
573
  ],
520
574
  parameters: WorkflowToolSchema,
521
575
  renderCall(args, theme) {
522
- const label = args.name ?? (args.script !== undefined ? "script" : "");
576
+ const label = sanitizeDisplayText(args.name ?? (args.script !== undefined ? "script" : ""));
523
577
  return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${label}`)), 0, 0);
524
578
  },
525
579
  renderResult(result, { expanded, isPartial }, theme) {
526
- 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
+ });
527
583
  if (isPartial) return new Text(theme.fg("warning", "Workflow running…"), 0, 0);
528
584
  const preview = expanded ? text.slice(0, 8_000) : text.split("\n").slice(0, 4).join("\n");
529
585
  return new Text(
@@ -585,7 +641,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
585
641
  replacement,
586
642
  );
587
643
  if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
588
- if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
589
644
  return textResult(formatStart(resumed), resumed);
590
645
  }
591
646
  const result = await workflowEngine.start(script, options);
@@ -610,10 +665,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
610
665
  "List, inspect, pause, resume, stop, or remove workflow runs with bounded machine-readable output.",
611
666
  parameters: ControlSchema,
612
667
  renderCall(args, theme) {
613
- 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
+ );
614
673
  },
615
674
  renderResult(result, { expanded }, theme) {
616
- 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
+ });
617
678
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
618
679
  },
619
680
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -950,7 +1011,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
950
1011
  // /workflows-trigger: keyword arming (A5). Typing the bounded word
951
1012
  // `workflow`/`workflows` — or a configured synonym — in an ordinary message
952
1013
  // 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,
1014
+ // receives hidden model guidance. Arming AUTHORIZES the tool; it never forces a run,
954
1015
  // and it never opens UI. A user asking a question about workflows still gets
955
1016
  // an ordinary answer.
956
1017
  const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
@@ -1003,77 +1064,66 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1003
1064
  },
1004
1065
  });
1005
1066
 
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
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
1008
1069
  // decline, which is what keeps "how do workflows work?" an ordinary
1009
1070
  // 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) {
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)
1014
1114
  return {
1015
1115
  action: "transform" as const,
1016
- text: deduplicatedText,
1116
+ text: DELIVERY_WAKE_TEXT,
1017
1117
  ...(event.images ? { images: event.images } : {}),
1018
1118
  };
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
- };
1119
+ return { action: "continue" as const };
1035
1120
  });
1036
1121
 
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
- });
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));
1077
1127
 
1078
1128
  // Standing effort mode mirrors the reference: it auto-arms substantive
1079
1129
  // interactive messages but remains guidance, not an inferred spend ceiling.
@@ -1171,8 +1221,64 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1171
1221
 
1172
1222
  // Neither before-event is a commit point. Pi can veto in a later handler,
1173
1223
  // 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));
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
+ });
1176
1282
  pi.on("session_tree", (event, ctx) => {
1177
1283
  let workflowEngine: WorkflowEngine;
1178
1284
  try {
@@ -1180,6 +1286,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1180
1286
  } catch {
1181
1287
  return;
1182
1288
  }
1289
+ invalidateDelivery();
1290
+ pendingDeliveryMarkers.clear();
1291
+ inputPreparing = false;
1292
+ compacting = false;
1293
+ navigating = false;
1183
1294
  if (handledTreeEvent !== event) {
1184
1295
  // A controller abort can synchronously call stop-owned RPC. Detach the
1185
1296
  // old subagent records first, regardless of extension load order, so its
@@ -1188,13 +1299,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1188
1299
  workflowEngine.fenceCommittedBranch();
1189
1300
  }
1190
1301
  handledTreeEvent = event;
1191
- deliveryGeneration++;
1192
1302
  try {
1193
1303
  const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
1194
1304
  journalBranchAnchor =
1195
1305
  ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
1196
1306
  workflowEngine.restore(entries);
1197
1307
  workflowEngine.resumeLifecycle();
1308
+ deliveryContext = ctx;
1309
+ deliveryBranchAnchor = journalBranchAnchor;
1310
+ hydratePendingDeliveries(entries);
1311
+ schedulePendingDeliveries();
1198
1312
  } catch (error: unknown) {
1199
1313
  active = false; // never resume an old branch after a failed restore
1200
1314
  console.warn(
@@ -1220,12 +1334,28 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1220
1334
  };
1221
1335
 
1222
1336
  /** Background-run result delivery: fail-closed, session-bound (A10). */
1223
- const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
1337
+ const deliverBackgroundResult = async (workflowEngine: WorkflowEngine, runId: string): Promise<void> => {
1224
1338
  const generation = deliveryGeneration;
1225
- const run = await engine.waitFor(runId).catch(() => undefined);
1226
- 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;
1227
1357
  const header = `Workflow ${run.runId} ${run.status}`;
1228
- const state = engine.getState(runId);
1358
+ const state = workflowEngine.getState(runId);
1229
1359
  const finalResult = state?.result ?? run.finalResult;
1230
1360
  const body =
1231
1361
  run.error ??
@@ -1233,42 +1363,154 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1233
1363
  ? `completed with ${Object.keys(run.callResults).length} agent call(s).`
1234
1364
  : `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
1235
1365
  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
- }
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.
1257
1378
  }
1379
+ schedulePendingDeliveries();
1258
1380
  };
1259
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
+ };
1260
1423
  const flushPendingDeliveries = (): void => {
1261
- if (!deliverResult || pendingDeliveryMarkers.size === 0) return;
1262
- for (const [runId, pending] of [...pendingDeliveryMarkers]) {
1263
- try {
1264
- 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));
1265
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();
1266
1485
  pendingDeliveryMarkers.delete(runId);
1267
- } catch {
1268
- // Keep pending until both delivery and its durable acknowledgement succeed.
1269
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.
1270
1502
  }
1271
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
+ };
1272
1514
 
1273
1515
  /** Build a script from a natural-language prompt and run it in background. */
1274
1516
  const runWorkflowFromPrompt = async (ctx: ExtensionCommandContext, prompt: string): Promise<string | undefined> => {
@@ -1301,6 +1543,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1301
1543
  };
1302
1544
 
1303
1545
  pi.on("session_start", async (_event, ctx) => {
1546
+ turnArming.retire();
1304
1547
  if (active || engine) return;
1305
1548
  const generation = ++lifecycleGeneration;
1306
1549
  sessionCwd = ctx.cwd ?? process.cwd();
@@ -1407,56 +1650,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1407
1650
  widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
1408
1651
  registerSurface();
1409
1652
 
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).
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;
1415
1660
  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
- }
1661
+ hydratePendingDeliveries(ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
1433
1662
  } catch {
1434
- // binding is best-effort; pending stays buffered
1663
+ pendingDeliveryMarkers.clear(); // fail closed on an unreadable branch
1435
1664
  }
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.
1439
- 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);
1456
- } catch {
1457
- // scan is best-effort
1458
- }
1459
- flushPendingDeliveries();
1665
+ schedulePendingDeliveries();
1460
1666
 
1461
1667
  // Saved workflow commands register at session_start (Pi cannot unregister
1462
1668
  // or replace command metadata, so factory-time registration would freeze the
@@ -1493,10 +1699,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1493
1699
  });
1494
1700
 
1495
1701
  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;
1702
+ // This synchronous boundary is shared with the bus-first shutdown path.
1703
+ const quiescence = beginShutdownQuiesce();
1500
1704
  clearWidget();
1501
1705
  widgetUi = undefined;
1502
1706
  if (widgetTimer) {
@@ -1507,7 +1711,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1507
1711
  if (closingEngine) {
1508
1712
  let timer: ReturnType<typeof setTimeout> | undefined;
1509
1713
  const result = await Promise.race([
1510
- beginShutdownQuiesce(),
1714
+ quiescence,
1511
1715
  new Promise<undefined>((resolve) => {
1512
1716
  timer = setTimeout(() => resolve(undefined), 6_000);
1513
1717
  }),
@@ -1529,7 +1733,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1529
1733
  protocolProbe = undefined;
1530
1734
  protocolInFlight = undefined;
1531
1735
  protocolError = undefined;
1532
- deliverResult = undefined;
1736
+ deliveryContext = undefined;
1533
1737
  });
1534
1738
  }
1535
1739