@signalridge/pi-workflows 1.7.2 → 1.7.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.4
4
+ ### Patch Changes
5
+
6
+ - 8190516: Clarify that keyword arming guides the model but is not an execution-time permission gate; direct workflow calls remain governed by the host's normal tool permissions.
7
+ - 8190516: Extend the tested Pi host compatibility range through 0.87.x while retaining supported older hosts. Validate the published extensions against Pi 0.87.1 and adapt changed provider, session-context, and lifecycle contracts where necessary.
8
+ - 8190516: Add Pi 0.99.1 to the supported host peer ranges while retaining Pi 0.84–0.87 compatibility. Align Pi development dependency pins with 0.99.1 for validation against the new host.
9
+ - 8190516: Retain Pi's tree-navigation attempt signal for branch ownership and deduplication. Active child or workflow work now causes a non-destructive preflight veto rather than pre-veto quiescence; accepted tree changes fence late old-branch callbacks. A second attempt rechecks live work instead of reusing state from a canceled attempt. Pi still lacks an atomic post-veto, pre-leaf commit hook: work started during asynchronous summarization may need to be quarantined on commit.
10
+ - 8190516: Skip the full Pi session-branch scan when the workflow journal's anchored leaf is still current. Keep the existing ancestry check when the leaf changes or cannot be reported, so navigated-away branches cannot receive old journal entries.
11
+ - 8190516: Block session switches and tree navigation while subagents, workflows, or provider cleanup remain unsettled, without stopping work on a cancellable attempt. Confirmed shutdown coordinates workflow-owned cleanup before retiring the subagent RPC responder; confirmed tree changes fence stale callbacks and keep old terminal facts off the new branch. Pi's hook ordering still leaves a narrow, non-atomic interval between preflight and commit, so new work during summarization is quarantined on commit rather than guaranteed to finish.
12
+
13
+ ## 1.7.3
14
+ ### Patch Changes
15
+
16
+ - e04f729: Remove repeated copies of the workflow arming reminder while preserving other input hooks.
17
+
3
18
  ## 1.7.2
4
19
  ### Patch Changes
5
20
 
package/README.md CHANGED
@@ -198,6 +198,10 @@ tell the model the `workflow` tool is authorized for this turn.
198
198
  Arming **authorizes; it does not compel**. The annotation says the model may run
199
199
  a workflow and may still decline, so "how do workflows work?" stays an ordinary
200
200
  question with an ordinary answer. Nothing is swallowed and no UI opens.
201
+ This is **model guidance, not an execution permission gate**: the extension does
202
+ not enforce the keyword inside `workflow.execute`. Direct tool calls remain
203
+ subject to Pi's ordinary tool permissions; do not use the trigger as a security
204
+ boundary.
201
205
 
202
206
  Only a standalone word arms. A workflow is also a thing people write code about,
203
207
  so none of `myworkflow`, `workflow_name`, `WorkflowEngine`, `--workflow-id`,
@@ -392,4 +396,4 @@ used it.
392
396
  - **Agent registry and tier catalogue ownership.** `agentType` and the tier catalogue resolve in pi-subagents; the workflow package supplies only a tier key its own `strengths` table chose, avoiding a second model/thinking policy.
393
397
  - **Schema validation is client-side.** The managed request does not assume a structured-output tool; the runtime asks for JSON, parses, validates (including `additionalProperties`), and repairs across bounded attempts.
394
398
 
395
- State is persisted as `pi.appendEntry("pi-workflows:journal", ...)` custom entries (schema v4), including frozen invocation args, resolved tier identity, terminal result previews, script revisions, named nested-workflow result boundaries, and durable removals. Interrupted/provider-limited runs replay from the journal; pre-schema-v4 journals are quarantined rather than replayed. Foreground `AbortSignal`s stop owned children through explicit owner-scoped stop/quiescence, and dispose/branch replacement reject stale waiters.
399
+ State is persisted as `pi.appendEntry("pi-workflows:journal", ...)` custom entries (schema v4), including frozen invocation args, resolved tier identity, terminal result previews, script revisions, named nested-workflow result boundaries, and durable removals. Interrupted/provider-limited runs replay from the journal; pre-schema-v4 journals are quarantined rather than replayed. Foreground `AbortSignal`s stop owned children through explicit owner-scoped stop/quiescence, and dispose/branch replacement reject stale waiters. For tree navigation, bus and native pre-tree notifications share quiescence within one host signal; a later attempt rechecks runs started since a veto, after any earlier cleanup settles. This only prevents reuse of stale quiescence: Pi does not provide a post-veto, precommit hook, so the initial canceled attempt can still interrupt active runs. Hosts without a pre-tree signal retain legacy deduplication.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-workflows",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "lifecycle": "stable"
16
16
  },
17
17
  "peerDependencies": {
18
- "@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0",
19
- "@earendil-works/pi-tui": "^0.84.0 || ^0.85.0",
18
+ "@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
19
+ "@earendil-works/pi-tui": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
20
20
  "@signalridge/pi-subagents": ">=1.9.0"
21
21
  },
22
22
  "dependencies": {
@@ -2,7 +2,7 @@
2
2
  name: workflow-authoring
3
3
  description: Design, write, review, and debug Pi JavaScript workflows that coordinate multiple subagents. Use when a request needs named dependencies, bounded fan-out, per-item pipelines, quality gates, saved or nested workflows, pause/resume, or workflow progress; do not use it merely to run an existing named workflow.
4
4
  metadata:
5
- version: "1.7.2"
5
+ version: "1.7.4"
6
6
  ---
7
7
 
8
8
  # Workflow authoring
@@ -2,7 +2,7 @@
2
2
  # Workflow capability index
3
3
 
4
4
  Contract format: `2.0.0`<br>
5
- Contract content / skill / extension: `1.7.2`
5
+ Contract content / skill / extension: `1.7.4`
6
6
 
7
7
  This compact generated index covers supported runtime globals and workflow-tool inputs. For constraints, compatibility behavior, internal boundaries, and dynamic-reference ownership, follow the [exhaustive generated facts](capability-details.md).
8
8
 
@@ -2,7 +2,7 @@
2
2
  # Exhaustive workflow capability facts
3
3
 
4
4
  Contract format: `2.0.0`<br>
5
- Contract content / skill / extension: `1.7.2`
5
+ Contract content / skill / extension: `1.7.4`
6
6
 
7
7
  Every exact fact below is projected from the installed extension's capability contract. Explanatory judgment belongs in the hand-written references next to this file.
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: workflow-patterns
3
3
  description: Choose and run one of Pi's built-in multi-agent workflows for research, adversarial review, code review, multi-perspective analysis, or codebase auditing. Use when the request matches a named pattern; use workflow-authoring instead for a new script or custom topology.
4
4
  metadata:
5
- version: "1.7.2"
5
+ version: "1.7.4"
6
6
  ---
7
7
 
8
8
  # Built-in workflow patterns
@@ -2,7 +2,7 @@
2
2
  name: workflow-review
3
3
  description: Audit an existing Pi JavaScript workflow for topology, prompt/data flow, limits, failure handling, cancellation, resume safety, and publication compatibility. Use when reviewing or debugging a workflow script or saved run; do not use to run a standard named workflow.
4
4
  metadata:
5
- version: "1.7.2"
5
+ version: "1.7.4"
6
6
  ---
7
7
 
8
8
  # Workflow review
package/src/arming.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * arming.ts — the keyword that authorizes the workflow tool for a turn.
2
+ * arming.ts — a keyword that tells the model a workflow is appropriate this turn.
3
+ * It is advisory prompt guidance, not an execution-time permission gate.
3
4
  *
4
5
  * Typing the bounded word `workflow` (or `workflows`, or a configured synonym)
5
6
  * in an ordinary message is an explicit opt-in to multi-agent orchestration, so
@@ -29,6 +30,16 @@ export const DEFAULT_TRIGGER_WORD = "workflow";
29
30
  export const WORKFLOW_ARMED_DIRECTIVE =
30
31
  "<system-reminder>You typed the workflow trigger word, which counts as an explicit opt-in to multi-agent orchestration: the `workflow` tool is authorized for this turn. It runs in the background by default — the turn ends and the result is delivered back into the conversation when it finishes, which is expected rather than a stall, so you need not stay and block. Pass background:false only when the user is waiting for the result inline. This is permission, not an instruction: if the request is conversational, trivial, or better answered directly, answer it directly and do not run a workflow.</system-reminder>";
31
32
 
33
+ /** Keep one copy when an earlier input transform already added this directive. */
34
+ export function deduplicateWorkflowDirective(text: string): string {
35
+ let seen = false;
36
+ return text.replaceAll(WORKFLOW_ARMED_DIRECTIVE, (directive) => {
37
+ if (seen) return "";
38
+ seen = true;
39
+ return directive;
40
+ });
41
+ }
42
+
32
43
  function escapeRegExp(text: string): string {
33
44
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
45
  }
package/src/engine.ts CHANGED
@@ -335,6 +335,9 @@ export class WorkflowEngine {
335
335
  private disposed = false;
336
336
  private lifecyclePaused = false;
337
337
  private branchQuiescePromise: Promise<WorkflowQuiesceResult> | undefined;
338
+ private branchEpoch = 0;
339
+ private admissionsClosed = false;
340
+ private pendingAdmissions = 0;
338
341
  private recoveryBranchGeneration = 0;
339
342
  private readonly journalRetries = new Map<string, { timer?: ReturnType<typeof setTimeout>; attempt: number }>();
340
343
  private readonly journalBlockedRuns = new Set<string>();
@@ -378,6 +381,38 @@ export class WorkflowEngine {
378
381
  return this.disposed;
379
382
  }
380
383
 
384
+ /** A synchronous snapshot for cancellable navigation, not an admission lock. */
385
+ hasUnsettledWork(): boolean {
386
+ return (
387
+ this.pendingAdmissions > 0 ||
388
+ this.branchQuiescePromise !== undefined ||
389
+ this.journalRetries.size > 0 ||
390
+ this.providerResumeTimers.size > 0 ||
391
+ [...this.runs.values()].some(
392
+ (state) =>
393
+ state.run.status === "running" ||
394
+ state.run.status === "pausing" ||
395
+ state.run.status === "stopping" ||
396
+ state.pendingSpawns.size > 0 ||
397
+ state.agentWaiters.size > 0 ||
398
+ state.pendingJournal.size > 0,
399
+ )
400
+ );
401
+ }
402
+
403
+ /** Pi has committed the new leaf: stop old callbacks without journaling there. */
404
+ fenceCommittedBranch(): void {
405
+ if (this.disposed || this.lifecyclePaused) return;
406
+ this.branchEpoch++;
407
+ this.admissionsClosed = true;
408
+ this.suspendLifecycle();
409
+ this.clearJournalRetries();
410
+ for (const state of this.runs.values()) {
411
+ state.lifecycleSuspended = true;
412
+ state.controller.abort(new DOMException("Workflow branch replaced", "AbortError"));
413
+ }
414
+ }
415
+
381
416
  private attachLifecycle(): () => void {
382
417
  const unsubs = [
383
418
  "subagents:created",
@@ -579,6 +614,7 @@ export class WorkflowEngine {
579
614
  async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
580
615
  if (this.disposed) return { settled: true, pending: [] };
581
616
  if (this.branchQuiescePromise) return this.branchQuiescePromise;
617
+ this.admissionsClosed = true;
582
618
  this.clearJournalRetries();
583
619
 
584
620
  // Suspend and abort synchronously, before any await can let a stale runtime
@@ -661,8 +697,18 @@ export class WorkflowEngine {
661
697
  */
662
698
  async start(script: string, options: ScriptStartOptions = {}): Promise<ScriptStartResult> {
663
699
  if (this.disposed) throw new WorkflowEngineDisposedError();
664
- const protocol = await this.awaitProtocol();
700
+ if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
701
+ const epoch = this.branchEpoch;
702
+ this.pendingAdmissions++;
703
+ let protocol: ManagedProtocolCheck;
704
+ try {
705
+ protocol = await this.awaitProtocol();
706
+ } finally {
707
+ this.pendingAdmissions--;
708
+ }
665
709
  if (this.disposed) throw new WorkflowEngineDisposedError();
710
+ if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
711
+ throw new Error("Workflow branch changed before admission");
666
712
  const { meta } = parseWorkflowScript(script);
667
713
  const now = Date.now();
668
714
  const runId = randomUUID();
@@ -798,8 +844,18 @@ export class WorkflowEngine {
798
844
  replacementScript?: string,
799
845
  ): Promise<ScriptStartResult | undefined> {
800
846
  if (this.disposed) throw new WorkflowEngineDisposedError();
801
- const protocol = await this.awaitProtocol();
847
+ if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
848
+ const epoch = this.branchEpoch;
849
+ this.pendingAdmissions++;
850
+ let protocol: ManagedProtocolCheck;
851
+ try {
852
+ protocol = await this.awaitProtocol();
853
+ } finally {
854
+ this.pendingAdmissions--;
855
+ }
802
856
  if (this.disposed) throw new WorkflowEngineDisposedError();
857
+ if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
858
+ throw new Error("Workflow branch changed before admission");
803
859
  const state = this.runs.get(runId);
804
860
  if (!state) return undefined;
805
861
  if (state.lifecycleSuspended) return undefined;
@@ -1016,6 +1072,13 @@ export class WorkflowEngine {
1016
1072
  const state = this.runs.get(runId);
1017
1073
  if (!state) throw new Error(`workflow run not found: ${runId}`);
1018
1074
  const generation = executionGeneration ?? state.executionGeneration;
1075
+ const epoch = this.branchEpoch;
1076
+ const isSameBranch = () =>
1077
+ this.runs.get(runId) === state &&
1078
+ state.executionGeneration === generation &&
1079
+ this.branchEpoch === epoch &&
1080
+ !this.disposed;
1081
+ const isCurrent = () => isSameBranch() && !state.lifecycleSuspended && !this.lifecyclePaused;
1019
1082
  const executionSignal =
1020
1083
  !options.background && options.signal
1021
1084
  ? AbortSignal.any([state.controller.signal, options.signal])
@@ -1024,8 +1087,7 @@ export class WorkflowEngine {
1024
1087
  const cleanupTasks = new Set<Promise<void>>();
1025
1088
  const runner: WorkflowAgentRunner = {
1026
1089
  run: async (prompt: string, runOptions: AgentRunOptions = {}) => {
1027
- if (state.executionGeneration !== generation)
1028
- throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
1090
+ if (!isCurrent()) throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
1029
1091
  const dispatch = this.dispatchAgent(runId, prompt, runOptions);
1030
1092
  const settled = dispatch.then(
1031
1093
  () => {},
@@ -1076,18 +1138,18 @@ export class WorkflowEngine {
1076
1138
  confirm: options.confirm,
1077
1139
  loadSavedWorkflow: options.loadSavedWorkflow,
1078
1140
  onAgentJournal: (entry) => {
1079
- if (state.executionGeneration !== generation) return;
1141
+ if (!isCurrent()) return;
1080
1142
  // Nested workflow frames keep their own lifecycle visibility, but their
1081
1143
  // individual call facts are not top-level replay keys. The parent
1082
1144
  // workflow_result fact below owns the nested replay boundary.
1083
1145
  if (entry.runId === undefined || entry.runId === runId) this.journalCallResult(runId, entry);
1084
1146
  },
1085
1147
  onWorkflowJournal: (entry) => {
1086
- if (state.executionGeneration !== generation) return;
1148
+ if (!isCurrent()) return;
1087
1149
  this.journalWorkflowResult(runId, entry);
1088
1150
  },
1089
1151
  onRuntimeEvent: (event) => {
1090
- if (state.executionGeneration !== generation) return;
1152
+ if (!isCurrent()) return;
1091
1153
  try {
1092
1154
  this.events.emit("pi-workflows:runtime", { runId, event: cloneRuntimeEvent(event) });
1093
1155
  } catch {
@@ -1108,7 +1170,7 @@ export class WorkflowEngine {
1108
1170
  }
1109
1171
  const live = this.runs.get(runId);
1110
1172
  const run = live?.run;
1111
- if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
1173
+ if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
1112
1174
  this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
1113
1175
  live.result = result.result;
1114
1176
  }
@@ -1116,7 +1178,7 @@ export class WorkflowEngine {
1116
1178
  } catch (error: unknown) {
1117
1179
  await runner.waitForCleanup?.();
1118
1180
  const run = this.runs.get(runId)?.run;
1119
- if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
1181
+ if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
1120
1182
  const message = error instanceof Error ? error.message : String(error);
1121
1183
  const workflowError = error instanceof WorkflowError ? error : wrapError(error);
1122
1184
  if (workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
@@ -1147,7 +1209,7 @@ export class WorkflowEngine {
1147
1209
  } finally {
1148
1210
  // Reject any waiters that never settled (agents left in flight).
1149
1211
  const run = this.runs.get(runId)?.run;
1150
- if (run && state.executionGeneration === generation) this.settleWaiters(runId, run);
1212
+ if (run && isSameBranch()) this.settleWaiters(runId, run);
1151
1213
  }
1152
1214
  }
1153
1215
 
@@ -1168,6 +1230,7 @@ export class WorkflowEngine {
1168
1230
  }
1169
1231
  const run = state.run;
1170
1232
  const executionGeneration = state.executionGeneration;
1233
+ const dispatchEpoch = this.branchEpoch;
1171
1234
  const callIndex = runOptions.callIndex ?? state.generations.size;
1172
1235
  const frameRunId = runOptions.runId ?? runId;
1173
1236
  const nested = frameRunId !== runId;
@@ -1282,6 +1345,10 @@ export class WorkflowEngine {
1282
1345
  const responseId = typeof response === "string" ? response : response.id;
1283
1346
  if (
1284
1347
  state.executionGeneration !== executionGeneration ||
1348
+ this.runs.get(runId) !== state ||
1349
+ this.branchEpoch !== dispatchEpoch ||
1350
+ state.lifecycleSuspended ||
1351
+ this.lifecyclePaused ||
1285
1352
  state.controller.signal.aborted ||
1286
1353
  externalSignal.aborted
1287
1354
  ) {
@@ -1399,6 +1466,14 @@ export class WorkflowEngine {
1399
1466
  externalSignal.removeEventListener("abort", waitAbort);
1400
1467
  });
1401
1468
 
1469
+ if (
1470
+ this.runs.get(runId) !== state ||
1471
+ this.branchEpoch !== dispatchEpoch ||
1472
+ state.lifecycleSuspended ||
1473
+ this.lifecyclePaused
1474
+ ) {
1475
+ throw new WorkflowError("stale workflow terminal", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
1476
+ }
1402
1477
  const snapshot = terminal.terminal;
1403
1478
  if (!snapshot)
1404
1479
  throw new WorkflowError("subagent settled without a terminal snapshot", WorkflowErrorCode.AGENT_EXECUTION_ERROR, {
@@ -1924,6 +1999,7 @@ export class WorkflowEngine {
1924
1999
  }
1925
2000
  this.rejectWaiters(new WorkflowWaitAbortedError());
1926
2001
  this.runs.clear();
2002
+ this.admissionsClosed = false;
1927
2003
  this.recoveryBranchGeneration = branchGeneration;
1928
2004
  const runs = replayJournal(entries, {
1929
2005
  onInvalid: (diagnostic) => console.warn(`[pi-workflows] ${diagnostic}`),
package/src/index.ts CHANGED
@@ -3,7 +3,12 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@e
3
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { Type } from "@sinclair/typebox";
6
- import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMED_DIRECTIVE } from "./arming.js";
6
+ import {
7
+ DEFAULT_TRIGGER_WORD,
8
+ deduplicateWorkflowDirective,
9
+ hasTriggerWord,
10
+ WORKFLOW_ARMED_DIRECTIVE,
11
+ } from "./arming.js";
7
12
  import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
8
13
  import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
9
14
  import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
@@ -326,8 +331,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
326
331
  let active = false;
327
332
  let surfaceRegistered = false;
328
333
  const registeredWorkflowCommands = new Set<string>();
329
- let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
334
+ let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
330
335
  let lifecycleGeneration = 0;
336
+ let deliveryGeneration = 0;
337
+ let handledTreeEvent: unknown;
338
+ let journalBranchAnchor: string | null | undefined;
331
339
  let protocolAbortController: AbortController | undefined;
332
340
  let sessionCwd = process.cwd();
333
341
  let workflowSettings = loadWorkflowSettings(sessionCwd);
@@ -351,6 +359,54 @@ export default function piWorkflows(pi: ExtensionAPI): void {
351
359
  return engine;
352
360
  };
353
361
 
362
+ // Public bus requests are synchronous snapshots, not a lock across Pi's
363
+ // later summarization/leaf replacement. Both extensions can veto in any load
364
+ // order without importing one another's source.
365
+ pi.events.on("pi:navigation-preflight", (raw: unknown) => {
366
+ if (!active || !engine?.hasUnsettledWork()) return;
367
+ const report = (raw as { report?: unknown } | null)?.report;
368
+ if (typeof report === "function") report("workflows");
369
+ });
370
+ const navigationPreflight = (ctx: ExtensionContext): { cancel: true } | undefined => {
371
+ let busy = false;
372
+ pi.events.emit("pi:navigation-preflight", {
373
+ report: () => {
374
+ busy = true;
375
+ },
376
+ });
377
+ if (!busy) return undefined;
378
+ const notice =
379
+ "Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
380
+ if (ctx.hasUI) ctx.ui.notify(notice, "warning");
381
+ else console.warn(`[pi-workflows] ${notice}`);
382
+ return { cancel: true };
383
+ };
384
+ const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
385
+ if (shutdownQuiesce) return shutdownQuiesce;
386
+ 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
+ }));
393
+ return shutdownQuiesce;
394
+ };
395
+ // Subagents starts this handshake before retiring the owned-agent RPC. When
396
+ // workflows shuts down first, its native hook performs the same operation.
397
+ pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
398
+ if (!active || !engine) return;
399
+ const respond = (raw as { respond?: unknown } | null)?.respond;
400
+ if (typeof respond === "function") respond(beginShutdownQuiesce());
401
+ });
402
+ pi.events.on("subagents:session_tree_committed", (raw: unknown) => {
403
+ if (!active || !engine) return;
404
+ const event = (raw as { event?: unknown } | null)?.event;
405
+ if (!event || event === handledTreeEvent) return;
406
+ handledTreeEvent = event;
407
+ engine.fenceCommittedBranch();
408
+ });
409
+
354
410
  const clearWidget = (): void => {
355
411
  widgetUi?.setWidget("pi-workflows", undefined);
356
412
  };
@@ -952,6 +1008,14 @@ export default function piWorkflows(pi: ExtensionAPI): void {
952
1008
  // question. Nothing is swallowed and no UI opens.
953
1009
  pi.on("input", (event) => {
954
1010
  if (event.source === "extension") return { action: "continue" as const };
1011
+ const deduplicatedText = deduplicateWorkflowDirective(event.text);
1012
+ if (deduplicatedText !== event.text) {
1013
+ return {
1014
+ action: "transform" as const,
1015
+ text: deduplicatedText,
1016
+ ...(event.images ? { images: event.images } : {}),
1017
+ };
1018
+ }
955
1019
  const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(event.text, triggerKeyword);
956
1020
  const substantive = effortLevel !== "off" && event.text.trim().length >= 16 && !event.text.trim().startsWith("/");
957
1021
  if (!explicitTrigger && !substantive) return { action: "continue" as const };
@@ -969,6 +1033,29 @@ export default function piWorkflows(pi: ExtensionAPI): void {
969
1033
  };
970
1034
  });
971
1035
 
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
+ });
1058
+
972
1059
  // Standing effort mode mirrors the reference: it auto-arms substantive
973
1060
  // interactive messages but remains guidance, not an inferred spend ceiling.
974
1061
  pi.registerCommand("effort", {
@@ -1062,58 +1149,38 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1062
1149
  registeredWorkflowCommands.add(name);
1063
1150
  }
1064
1151
 
1065
- // Live progress widget refreshes through the current engine, so its timer
1066
- // and settlement callback remain safe across session replacement.
1067
- const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
1068
- if (!branchQuiesce) {
1069
- let workflowEngine: WorkflowEngine;
1070
- try {
1071
- workflowEngine = currentEngine();
1072
- } catch {
1073
- return Promise.resolve({ settled: true, pending: [] });
1074
- }
1075
- branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
1076
- if (!result.settled) {
1077
- console.warn(
1078
- `[pi-workflows] ${result.diagnostic ?? "branch quiescence did not settle; stale events are quarantined"}`,
1079
- );
1080
- }
1081
- return result;
1082
- });
1083
- }
1084
- return branchQuiesce;
1085
- };
1086
-
1087
- pi.events.on("subagents:session_before_tree", () => {
1088
- void beginBranchQuiesce();
1089
- });
1090
-
1091
- pi.on("session_before_tree", async () => {
1092
- await beginBranchQuiesce();
1093
- });
1094
- pi.on("session_tree", (_event, ctx) => {
1095
- let entries: SessionEntryLike[];
1096
- try {
1097
- entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
1098
- } catch {
1099
- return;
1100
- }
1152
+ // Neither before-event is a commit point. Pi can veto in a later handler,
1153
+ // 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));
1156
+ pi.on("session_tree", (event, ctx) => {
1101
1157
  let workflowEngine: WorkflowEngine;
1102
1158
  try {
1103
1159
  workflowEngine = currentEngine();
1104
1160
  } catch {
1105
1161
  return;
1106
1162
  }
1107
- workflowEngine.suspendLifecycle();
1163
+ if (handledTreeEvent !== event) {
1164
+ // A controller abort can synchronously call stop-owned RPC. Detach the
1165
+ // old subagent records first, regardless of extension load order, so its
1166
+ // managed-stop tombstone cannot be appended on the committed new leaf.
1167
+ pi.events.emit("pi-workflows:session_tree_committed", { event });
1168
+ workflowEngine.fenceCommittedBranch();
1169
+ }
1170
+ handledTreeEvent = event;
1171
+ deliveryGeneration++;
1108
1172
  try {
1173
+ const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
1174
+ journalBranchAnchor =
1175
+ ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
1109
1176
  workflowEngine.restore(entries);
1177
+ workflowEngine.resumeLifecycle();
1110
1178
  } catch (error: unknown) {
1179
+ active = false; // never resume an old branch after a failed restore
1111
1180
  console.warn(
1112
1181
  `[pi-workflows] session tree recovery failed: ${error instanceof Error ? error.message : String(error)}`,
1113
1182
  );
1114
1183
  } finally {
1115
- workflowEngine.resumeLifecycle();
1116
- branchQuiesce = undefined;
1117
1184
  refreshWidget();
1118
1185
  }
1119
1186
  });
@@ -1134,8 +1201,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1134
1201
 
1135
1202
  /** Background-run result delivery: fail-closed, session-bound (A10). */
1136
1203
  const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
1204
+ const generation = deliveryGeneration;
1137
1205
  const run = await engine.waitFor(runId).catch(() => undefined);
1138
- if (!run) return;
1206
+ if (!run || generation !== deliveryGeneration) return;
1139
1207
  const header = `Workflow ${run.runId} ${run.status}`;
1140
1208
  const state = engine.getState(runId);
1141
1209
  const finalResult = state?.result ?? run.finalResult;
@@ -1247,6 +1315,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1247
1315
  protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
1248
1316
  });
1249
1317
 
1318
+ journalBranchAnchor =
1319
+ ctx.sessionManager.getLeafId?.() ?? (initialEntries.at(-1) as { id?: string } | undefined)?.id ?? null;
1250
1320
  let recoveredEngine: WorkflowEngine | undefined;
1251
1321
  let recoveryError: unknown;
1252
1322
  for (let attempt = 0; attempt < 3 && !recoveredEngine; attempt += 1) {
@@ -1255,7 +1325,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1255
1325
  client,
1256
1326
  {
1257
1327
  append(event) {
1328
+ if (generation !== lifecycleGeneration) throw new Error("workflow session retired before journal append");
1329
+ // Every append moves Pi's leaf. Remember the new leaf afterwards;
1330
+ // unrelated appends beneath it remain valid, but a navigated-away
1331
+ // branch must never receive an old terminal fact. Only traverse the
1332
+ // branch when the leaf has moved (or the host cannot report it).
1333
+ if (journalBranchAnchor && ctx.sessionManager.getLeafId?.() !== journalBranchAnchor) {
1334
+ const branch = ctx.sessionManager.getBranch() as Array<{ id?: string }>;
1335
+ if (!branch.some((entry) => entry.id === journalBranchAnchor)) {
1336
+ throw new Error("workflow journal branch changed before append");
1337
+ }
1338
+ }
1258
1339
  pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
1340
+ // Some hosts cannot report a leaf ID. Read the new branch tip after
1341
+ // every append rather than retaining the old (now-ancestor) anchor:
1342
+ // a later run on a sibling would otherwise pass the ancestry check.
1343
+ journalBranchAnchor =
1344
+ ctx.sessionManager.getLeafId?.() ?? (ctx.sessionManager.getBranch() as Array<{ id?: string }>).at(-1)?.id;
1345
+ if (!journalBranchAnchor) throw new Error("workflow journal leaf unavailable after append");
1259
1346
  // Journal writes precede some state mutations; refresh once applied.
1260
1347
  queueMicrotask(() => {
1261
1348
  if (generation === lifecycleGeneration) refreshWidget();
@@ -1385,7 +1472,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1385
1472
  });
1386
1473
 
1387
1474
  pi.on("session_shutdown", async () => {
1388
- lifecycleGeneration += 1;
1475
+ deliveryGeneration += 1;
1389
1476
  // Invalidate all long-lived command/tool/widget closures before awaiting
1390
1477
  // engine quiescence; the old session must not accept another run.
1391
1478
  active = false;
@@ -1397,13 +1484,26 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1397
1484
  }
1398
1485
  const closingEngine = engine;
1399
1486
  if (closingEngine) {
1400
- await closingEngine.quiesceForBranchChange().catch(() => undefined);
1487
+ let timer: ReturnType<typeof setTimeout> | undefined;
1488
+ const result = await Promise.race([
1489
+ beginShutdownQuiesce(),
1490
+ new Promise<undefined>((resolve) => {
1491
+ timer = setTimeout(() => resolve(undefined), 6_000);
1492
+ }),
1493
+ ]);
1494
+ if (timer) clearTimeout(timer);
1495
+ if (!result?.settled) {
1496
+ console.warn(
1497
+ `[pi-workflows] shutdown quiescence timed out or failed; ${result?.diagnostic ?? `pending: ${result?.pending.join(", ") ?? "unknown"}`}; late callbacks fenced`,
1498
+ );
1499
+ }
1401
1500
  }
1501
+ lifecycleGeneration += 1;
1402
1502
  protocolAbortController?.abort();
1403
1503
  protocolAbortController = undefined;
1404
1504
  closingEngine?.dispose();
1405
1505
  engine = undefined;
1406
- branchQuiesce = undefined;
1506
+ shutdownQuiesce = undefined;
1407
1507
  protocolCheck = undefined;
1408
1508
  protocolProbe = undefined;
1409
1509
  protocolInFlight = undefined;