@signalridge/pi-workflows 1.7.3 → 1.7.5

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.5
4
+ ### Patch Changes
5
+
6
+ - 3364cc0: Declare `@sinclair/typebox` as a host-provided peer dependency with a `*` range instead of a runtime dependency, avoiding duplicate runtime modules and Pi extension-loader warnings.
7
+ - 3364cc0: Adopt Pi 1.0.4's host-provided dependency contract: declare imported Pi and TypeBox modules as `*` peers rather than installing duplicate runtime modules, and align Pi development pins with the repository's tested 1.0.4 baseline. The wildcard identifies host module ownership; it does not promise compatibility with every Pi version.
8
+ - 3364cc0: Bind code-review discovery and workflow executions (including resumes and nested scripts) to the session working directory. Preserve structured user content when deduplicating the workflow directive.
9
+ - Updated dependencies [3364cc0]
10
+ - Updated dependencies [3364cc0]
11
+ - @signalridge/pi-subagents-protocol@1.5.1
12
+
13
+ ## 1.7.4
14
+ ### Patch Changes
15
+
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+ - 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.
22
+
3
23
  ## 1.7.3
4
24
  ### Patch Changes
5
25
 
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.3",
3
+ "version": "1.7.5",
4
4
  "description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,13 +15,13 @@
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",
20
- "@signalridge/pi-subagents": ">=1.9.0"
18
+ "@earendil-works/pi-coding-agent": "*",
19
+ "@earendil-works/pi-tui": "*",
20
+ "@signalridge/pi-subagents": ">=1.9.0",
21
+ "@sinclair/typebox": "*"
21
22
  },
22
23
  "dependencies": {
23
- "@signalridge/pi-subagents-protocol": "^1.5.0",
24
- "@sinclair/typebox": "^0.34.50",
24
+ "@signalridge/pi-subagents-protocol": "^1.5.1",
25
25
  "acorn": "^8.16.0"
26
26
  },
27
27
  "pi": {
@@ -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.3"
5
+ version: "1.7.5"
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.3`
5
+ Contract content / skill / extension: `1.7.5`
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.3`
5
+ Contract content / skill / extension: `1.7.5`
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.3"
5
+ version: "1.7.5"
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.3"
5
+ version: "1.7.5"
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
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>();
@@ -348,6 +351,7 @@ export class WorkflowEngine {
348
351
  private readonly journal: JournalWriter,
349
352
  private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
350
353
  protocolGate?: () => Promise<ManagedProtocolCheck>,
354
+ private readonly cwd: string = process.cwd(),
351
355
  ) {
352
356
  this.protocolGate =
353
357
  protocolGate ??
@@ -378,6 +382,38 @@ export class WorkflowEngine {
378
382
  return this.disposed;
379
383
  }
380
384
 
385
+ /** A synchronous snapshot for cancellable navigation, not an admission lock. */
386
+ hasUnsettledWork(): boolean {
387
+ return (
388
+ this.pendingAdmissions > 0 ||
389
+ this.branchQuiescePromise !== undefined ||
390
+ this.journalRetries.size > 0 ||
391
+ this.providerResumeTimers.size > 0 ||
392
+ [...this.runs.values()].some(
393
+ (state) =>
394
+ state.run.status === "running" ||
395
+ state.run.status === "pausing" ||
396
+ state.run.status === "stopping" ||
397
+ state.pendingSpawns.size > 0 ||
398
+ state.agentWaiters.size > 0 ||
399
+ state.pendingJournal.size > 0,
400
+ )
401
+ );
402
+ }
403
+
404
+ /** Pi has committed the new leaf: stop old callbacks without journaling there. */
405
+ fenceCommittedBranch(): void {
406
+ if (this.disposed || this.lifecyclePaused) return;
407
+ this.branchEpoch++;
408
+ this.admissionsClosed = true;
409
+ this.suspendLifecycle();
410
+ this.clearJournalRetries();
411
+ for (const state of this.runs.values()) {
412
+ state.lifecycleSuspended = true;
413
+ state.controller.abort(new DOMException("Workflow branch replaced", "AbortError"));
414
+ }
415
+ }
416
+
381
417
  private attachLifecycle(): () => void {
382
418
  const unsubs = [
383
419
  "subagents:created",
@@ -579,6 +615,7 @@ export class WorkflowEngine {
579
615
  async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
580
616
  if (this.disposed) return { settled: true, pending: [] };
581
617
  if (this.branchQuiescePromise) return this.branchQuiescePromise;
618
+ this.admissionsClosed = true;
582
619
  this.clearJournalRetries();
583
620
 
584
621
  // Suspend and abort synchronously, before any await can let a stale runtime
@@ -661,8 +698,18 @@ export class WorkflowEngine {
661
698
  */
662
699
  async start(script: string, options: ScriptStartOptions = {}): Promise<ScriptStartResult> {
663
700
  if (this.disposed) throw new WorkflowEngineDisposedError();
664
- const protocol = await this.awaitProtocol();
701
+ if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
702
+ const epoch = this.branchEpoch;
703
+ this.pendingAdmissions++;
704
+ let protocol: ManagedProtocolCheck;
705
+ try {
706
+ protocol = await this.awaitProtocol();
707
+ } finally {
708
+ this.pendingAdmissions--;
709
+ }
665
710
  if (this.disposed) throw new WorkflowEngineDisposedError();
711
+ if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
712
+ throw new Error("Workflow branch changed before admission");
666
713
  const { meta } = parseWorkflowScript(script);
667
714
  const now = Date.now();
668
715
  const runId = randomUUID();
@@ -798,8 +845,18 @@ export class WorkflowEngine {
798
845
  replacementScript?: string,
799
846
  ): Promise<ScriptStartResult | undefined> {
800
847
  if (this.disposed) throw new WorkflowEngineDisposedError();
801
- const protocol = await this.awaitProtocol();
848
+ if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
849
+ const epoch = this.branchEpoch;
850
+ this.pendingAdmissions++;
851
+ let protocol: ManagedProtocolCheck;
852
+ try {
853
+ protocol = await this.awaitProtocol();
854
+ } finally {
855
+ this.pendingAdmissions--;
856
+ }
802
857
  if (this.disposed) throw new WorkflowEngineDisposedError();
858
+ if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
859
+ throw new Error("Workflow branch changed before admission");
803
860
  const state = this.runs.get(runId);
804
861
  if (!state) return undefined;
805
862
  if (state.lifecycleSuspended) return undefined;
@@ -1016,6 +1073,13 @@ export class WorkflowEngine {
1016
1073
  const state = this.runs.get(runId);
1017
1074
  if (!state) throw new Error(`workflow run not found: ${runId}`);
1018
1075
  const generation = executionGeneration ?? state.executionGeneration;
1076
+ const epoch = this.branchEpoch;
1077
+ const isSameBranch = () =>
1078
+ this.runs.get(runId) === state &&
1079
+ state.executionGeneration === generation &&
1080
+ this.branchEpoch === epoch &&
1081
+ !this.disposed;
1082
+ const isCurrent = () => isSameBranch() && !state.lifecycleSuspended && !this.lifecyclePaused;
1019
1083
  const executionSignal =
1020
1084
  !options.background && options.signal
1021
1085
  ? AbortSignal.any([state.controller.signal, options.signal])
@@ -1024,8 +1088,7 @@ export class WorkflowEngine {
1024
1088
  const cleanupTasks = new Set<Promise<void>>();
1025
1089
  const runner: WorkflowAgentRunner = {
1026
1090
  run: async (prompt: string, runOptions: AgentRunOptions = {}) => {
1027
- if (state.executionGeneration !== generation)
1028
- throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
1091
+ if (!isCurrent()) throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
1029
1092
  const dispatch = this.dispatchAgent(runId, prompt, runOptions);
1030
1093
  const settled = dispatch.then(
1031
1094
  () => {},
@@ -1048,6 +1111,7 @@ export class WorkflowEngine {
1048
1111
 
1049
1112
  try {
1050
1113
  const result = await runWorkflow(script, {
1114
+ cwd: this.cwd,
1051
1115
  args: options.args,
1052
1116
  agent: runner,
1053
1117
  mainModel: options.mainModel,
@@ -1076,18 +1140,18 @@ export class WorkflowEngine {
1076
1140
  confirm: options.confirm,
1077
1141
  loadSavedWorkflow: options.loadSavedWorkflow,
1078
1142
  onAgentJournal: (entry) => {
1079
- if (state.executionGeneration !== generation) return;
1143
+ if (!isCurrent()) return;
1080
1144
  // Nested workflow frames keep their own lifecycle visibility, but their
1081
1145
  // individual call facts are not top-level replay keys. The parent
1082
1146
  // workflow_result fact below owns the nested replay boundary.
1083
1147
  if (entry.runId === undefined || entry.runId === runId) this.journalCallResult(runId, entry);
1084
1148
  },
1085
1149
  onWorkflowJournal: (entry) => {
1086
- if (state.executionGeneration !== generation) return;
1150
+ if (!isCurrent()) return;
1087
1151
  this.journalWorkflowResult(runId, entry);
1088
1152
  },
1089
1153
  onRuntimeEvent: (event) => {
1090
- if (state.executionGeneration !== generation) return;
1154
+ if (!isCurrent()) return;
1091
1155
  try {
1092
1156
  this.events.emit("pi-workflows:runtime", { runId, event: cloneRuntimeEvent(event) });
1093
1157
  } catch {
@@ -1108,7 +1172,7 @@ export class WorkflowEngine {
1108
1172
  }
1109
1173
  const live = this.runs.get(runId);
1110
1174
  const run = live?.run;
1111
- if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
1175
+ if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
1112
1176
  this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
1113
1177
  live.result = result.result;
1114
1178
  }
@@ -1116,7 +1180,7 @@ export class WorkflowEngine {
1116
1180
  } catch (error: unknown) {
1117
1181
  await runner.waitForCleanup?.();
1118
1182
  const run = this.runs.get(runId)?.run;
1119
- if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
1183
+ if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
1120
1184
  const message = error instanceof Error ? error.message : String(error);
1121
1185
  const workflowError = error instanceof WorkflowError ? error : wrapError(error);
1122
1186
  if (workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
@@ -1147,7 +1211,7 @@ export class WorkflowEngine {
1147
1211
  } finally {
1148
1212
  // Reject any waiters that never settled (agents left in flight).
1149
1213
  const run = this.runs.get(runId)?.run;
1150
- if (run && state.executionGeneration === generation) this.settleWaiters(runId, run);
1214
+ if (run && isSameBranch()) this.settleWaiters(runId, run);
1151
1215
  }
1152
1216
  }
1153
1217
 
@@ -1168,6 +1232,7 @@ export class WorkflowEngine {
1168
1232
  }
1169
1233
  const run = state.run;
1170
1234
  const executionGeneration = state.executionGeneration;
1235
+ const dispatchEpoch = this.branchEpoch;
1171
1236
  const callIndex = runOptions.callIndex ?? state.generations.size;
1172
1237
  const frameRunId = runOptions.runId ?? runId;
1173
1238
  const nested = frameRunId !== runId;
@@ -1282,6 +1347,10 @@ export class WorkflowEngine {
1282
1347
  const responseId = typeof response === "string" ? response : response.id;
1283
1348
  if (
1284
1349
  state.executionGeneration !== executionGeneration ||
1350
+ this.runs.get(runId) !== state ||
1351
+ this.branchEpoch !== dispatchEpoch ||
1352
+ state.lifecycleSuspended ||
1353
+ this.lifecyclePaused ||
1285
1354
  state.controller.signal.aborted ||
1286
1355
  externalSignal.aborted
1287
1356
  ) {
@@ -1399,6 +1468,14 @@ export class WorkflowEngine {
1399
1468
  externalSignal.removeEventListener("abort", waitAbort);
1400
1469
  });
1401
1470
 
1471
+ if (
1472
+ this.runs.get(runId) !== state ||
1473
+ this.branchEpoch !== dispatchEpoch ||
1474
+ state.lifecycleSuspended ||
1475
+ this.lifecyclePaused
1476
+ ) {
1477
+ throw new WorkflowError("stale workflow terminal", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
1478
+ }
1402
1479
  const snapshot = terminal.terminal;
1403
1480
  if (!snapshot)
1404
1481
  throw new WorkflowError("subagent settled without a terminal snapshot", WorkflowErrorCode.AGENT_EXECUTION_ERROR, {
@@ -1924,6 +2001,7 @@ export class WorkflowEngine {
1924
2001
  }
1925
2002
  this.rejectWaiters(new WorkflowWaitAbortedError());
1926
2003
  this.runs.clear();
2004
+ this.admissionsClosed = false;
1927
2005
  this.recoveryBranchGeneration = branchGeneration;
1928
2006
  const runs = replayJournal(entries, {
1929
2007
  onInvalid: (diagnostic) => console.warn(`[pi-workflows] ${diagnostic}`),
package/src/index.ts CHANGED
@@ -118,9 +118,10 @@ function textResult(text: string, details: unknown = {}) {
118
118
  * Run one command for diff discovery. argv array, never a shell string — a
119
119
  * branch name or path reaches this from user input.
120
120
  */
121
- function runCommand(file: string, args: string[]): CommandResult {
121
+ function runCommand(file: string, args: string[], cwd: string): CommandResult {
122
122
  try {
123
123
  const stdout = execFileSync(file, args, {
124
+ cwd,
124
125
  encoding: "utf8",
125
126
  maxBuffer: 32 * 1024 * 1024,
126
127
  stdio: ["ignore", "pipe", "ignore"],
@@ -331,8 +332,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
331
332
  let active = false;
332
333
  let surfaceRegistered = false;
333
334
  const registeredWorkflowCommands = new Set<string>();
334
- let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
335
+ let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
335
336
  let lifecycleGeneration = 0;
337
+ let deliveryGeneration = 0;
338
+ let handledTreeEvent: unknown;
339
+ let journalBranchAnchor: string | null | undefined;
336
340
  let protocolAbortController: AbortController | undefined;
337
341
  let sessionCwd = process.cwd();
338
342
  let workflowSettings = loadWorkflowSettings(sessionCwd);
@@ -356,6 +360,54 @@ export default function piWorkflows(pi: ExtensionAPI): void {
356
360
  return engine;
357
361
  };
358
362
 
363
+ // Public bus requests are synchronous snapshots, not a lock across Pi's
364
+ // later summarization/leaf replacement. Both extensions can veto in any load
365
+ // order without importing one another's source.
366
+ pi.events.on("pi:navigation-preflight", (raw: unknown) => {
367
+ if (!active || !engine?.hasUnsettledWork()) return;
368
+ const report = (raw as { report?: unknown } | null)?.report;
369
+ if (typeof report === "function") report("workflows");
370
+ });
371
+ const navigationPreflight = (ctx: ExtensionContext): { cancel: true } | undefined => {
372
+ let busy = false;
373
+ pi.events.emit("pi:navigation-preflight", {
374
+ report: () => {
375
+ busy = true;
376
+ },
377
+ });
378
+ if (!busy) return undefined;
379
+ const notice =
380
+ "Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
381
+ if (ctx.hasUI) ctx.ui.notify(notice, "warning");
382
+ else console.warn(`[pi-workflows] ${notice}`);
383
+ return { cancel: true };
384
+ };
385
+ const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
386
+ if (shutdownQuiesce) return shutdownQuiesce;
387
+ 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
+ }));
394
+ return shutdownQuiesce;
395
+ };
396
+ // Subagents starts this handshake before retiring the owned-agent RPC. When
397
+ // workflows shuts down first, its native hook performs the same operation.
398
+ pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
399
+ if (!active || !engine) return;
400
+ const respond = (raw as { respond?: unknown } | null)?.respond;
401
+ if (typeof respond === "function") respond(beginShutdownQuiesce());
402
+ });
403
+ pi.events.on("subagents:session_tree_committed", (raw: unknown) => {
404
+ if (!active || !engine) return;
405
+ const event = (raw as { event?: unknown } | null)?.event;
406
+ if (!event || event === handledTreeEvent) return;
407
+ handledTreeEvent = event;
408
+ engine.fenceCommittedBranch();
409
+ });
410
+
359
411
  const clearWidget = (): void => {
360
412
  widgetUi?.setWidget("pi-workflows", undefined);
361
413
  };
@@ -997,11 +1049,29 @@ export default function piWorkflows(pi: ExtensionAPI): void {
997
1049
  }
998
1050
  }
999
1051
  const message = lastUserMessage;
1000
- if (lastUserIndex < 0 || !message || !("content" in message) || typeof message.content !== "string") return;
1001
- const deduplicatedText = deduplicateWorkflowDirective(message.content);
1002
- if (deduplicatedText === message.content) return;
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;
1003
1073
  const messages = event.messages.slice();
1004
- messages[lastUserIndex] = { ...message, content: deduplicatedText };
1074
+ messages[lastUserIndex] = { ...message, content };
1005
1075
  return { messages };
1006
1076
  });
1007
1077
 
@@ -1064,7 +1134,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1064
1134
  // takes a diff rather than something typed, so it resolves one first.
1065
1135
  let scriptArgs: Record<string, unknown> | undefined;
1066
1136
  if (name === "code-review") {
1067
- const scope = resolveCodeReviewScope(text, runCommand);
1137
+ const cwd = ctx.cwd;
1138
+ const scope = resolveCodeReviewScope(text, (file, argv) => runCommand(file, argv, cwd));
1068
1139
  for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
1069
1140
  if (!scope.diff.trim()) return;
1070
1141
  scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
@@ -1098,58 +1169,38 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1098
1169
  registeredWorkflowCommands.add(name);
1099
1170
  }
1100
1171
 
1101
- // Live progress widget refreshes through the current engine, so its timer
1102
- // and settlement callback remain safe across session replacement.
1103
- const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
1104
- if (!branchQuiesce) {
1105
- let workflowEngine: WorkflowEngine;
1106
- try {
1107
- workflowEngine = currentEngine();
1108
- } catch {
1109
- return Promise.resolve({ settled: true, pending: [] });
1110
- }
1111
- branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
1112
- if (!result.settled) {
1113
- console.warn(
1114
- `[pi-workflows] ${result.diagnostic ?? "branch quiescence did not settle; stale events are quarantined"}`,
1115
- );
1116
- }
1117
- return result;
1118
- });
1119
- }
1120
- return branchQuiesce;
1121
- };
1122
-
1123
- pi.events.on("subagents:session_before_tree", () => {
1124
- void beginBranchQuiesce();
1125
- });
1126
-
1127
- pi.on("session_before_tree", async () => {
1128
- await beginBranchQuiesce();
1129
- });
1130
- pi.on("session_tree", (_event, ctx) => {
1131
- let entries: SessionEntryLike[];
1132
- try {
1133
- entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
1134
- } catch {
1135
- return;
1136
- }
1172
+ // Neither before-event is a commit point. Pi can veto in a later handler,
1173
+ // 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));
1176
+ pi.on("session_tree", (event, ctx) => {
1137
1177
  let workflowEngine: WorkflowEngine;
1138
1178
  try {
1139
1179
  workflowEngine = currentEngine();
1140
1180
  } catch {
1141
1181
  return;
1142
1182
  }
1143
- workflowEngine.suspendLifecycle();
1183
+ if (handledTreeEvent !== event) {
1184
+ // A controller abort can synchronously call stop-owned RPC. Detach the
1185
+ // old subagent records first, regardless of extension load order, so its
1186
+ // managed-stop tombstone cannot be appended on the committed new leaf.
1187
+ pi.events.emit("pi-workflows:session_tree_committed", { event });
1188
+ workflowEngine.fenceCommittedBranch();
1189
+ }
1190
+ handledTreeEvent = event;
1191
+ deliveryGeneration++;
1144
1192
  try {
1193
+ const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
1194
+ journalBranchAnchor =
1195
+ ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
1145
1196
  workflowEngine.restore(entries);
1197
+ workflowEngine.resumeLifecycle();
1146
1198
  } catch (error: unknown) {
1199
+ active = false; // never resume an old branch after a failed restore
1147
1200
  console.warn(
1148
1201
  `[pi-workflows] session tree recovery failed: ${error instanceof Error ? error.message : String(error)}`,
1149
1202
  );
1150
1203
  } finally {
1151
- workflowEngine.resumeLifecycle();
1152
- branchQuiesce = undefined;
1153
1204
  refreshWidget();
1154
1205
  }
1155
1206
  });
@@ -1170,8 +1221,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1170
1221
 
1171
1222
  /** Background-run result delivery: fail-closed, session-bound (A10). */
1172
1223
  const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
1224
+ const generation = deliveryGeneration;
1173
1225
  const run = await engine.waitFor(runId).catch(() => undefined);
1174
- if (!run) return;
1226
+ if (!run || generation !== deliveryGeneration) return;
1175
1227
  const header = `Workflow ${run.runId} ${run.status}`;
1176
1228
  const state = engine.getState(runId);
1177
1229
  const finalResult = state?.result ?? run.finalResult;
@@ -1283,6 +1335,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1283
1335
  protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
1284
1336
  });
1285
1337
 
1338
+ journalBranchAnchor =
1339
+ ctx.sessionManager.getLeafId?.() ?? (initialEntries.at(-1) as { id?: string } | undefined)?.id ?? null;
1286
1340
  let recoveredEngine: WorkflowEngine | undefined;
1287
1341
  let recoveryError: unknown;
1288
1342
  for (let attempt = 0; attempt < 3 && !recoveredEngine; attempt += 1) {
@@ -1291,7 +1345,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1291
1345
  client,
1292
1346
  {
1293
1347
  append(event) {
1348
+ if (generation !== lifecycleGeneration) throw new Error("workflow session retired before journal append");
1349
+ // Every append moves Pi's leaf. Remember the new leaf afterwards;
1350
+ // unrelated appends beneath it remain valid, but a navigated-away
1351
+ // branch must never receive an old terminal fact. Only traverse the
1352
+ // branch when the leaf has moved (or the host cannot report it).
1353
+ if (journalBranchAnchor && ctx.sessionManager.getLeafId?.() !== journalBranchAnchor) {
1354
+ const branch = ctx.sessionManager.getBranch() as Array<{ id?: string }>;
1355
+ if (!branch.some((entry) => entry.id === journalBranchAnchor)) {
1356
+ throw new Error("workflow journal branch changed before append");
1357
+ }
1358
+ }
1294
1359
  pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
1360
+ // Some hosts cannot report a leaf ID. Read the new branch tip after
1361
+ // every append rather than retaining the old (now-ancestor) anchor:
1362
+ // a later run on a sibling would otherwise pass the ancestry check.
1363
+ journalBranchAnchor =
1364
+ ctx.sessionManager.getLeafId?.() ?? (ctx.sessionManager.getBranch() as Array<{ id?: string }>).at(-1)?.id;
1365
+ if (!journalBranchAnchor) throw new Error("workflow journal leaf unavailable after append");
1295
1366
  // Journal writes precede some state mutations; refresh once applied.
1296
1367
  queueMicrotask(() => {
1297
1368
  if (generation === lifecycleGeneration) refreshWidget();
@@ -1306,6 +1377,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1306
1377
  }
1307
1378
  },
1308
1379
  awaitProtocol,
1380
+ sessionCwd,
1309
1381
  );
1310
1382
  try {
1311
1383
  const entries =
@@ -1421,7 +1493,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1421
1493
  });
1422
1494
 
1423
1495
  pi.on("session_shutdown", async () => {
1424
- lifecycleGeneration += 1;
1496
+ deliveryGeneration += 1;
1425
1497
  // Invalidate all long-lived command/tool/widget closures before awaiting
1426
1498
  // engine quiescence; the old session must not accept another run.
1427
1499
  active = false;
@@ -1433,13 +1505,26 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1433
1505
  }
1434
1506
  const closingEngine = engine;
1435
1507
  if (closingEngine) {
1436
- await closingEngine.quiesceForBranchChange().catch(() => undefined);
1508
+ let timer: ReturnType<typeof setTimeout> | undefined;
1509
+ const result = await Promise.race([
1510
+ beginShutdownQuiesce(),
1511
+ new Promise<undefined>((resolve) => {
1512
+ timer = setTimeout(() => resolve(undefined), 6_000);
1513
+ }),
1514
+ ]);
1515
+ if (timer) clearTimeout(timer);
1516
+ if (!result?.settled) {
1517
+ console.warn(
1518
+ `[pi-workflows] shutdown quiescence timed out or failed; ${result?.diagnostic ?? `pending: ${result?.pending.join(", ") ?? "unknown"}`}; late callbacks fenced`,
1519
+ );
1520
+ }
1437
1521
  }
1522
+ lifecycleGeneration += 1;
1438
1523
  protocolAbortController?.abort();
1439
1524
  protocolAbortController = undefined;
1440
1525
  closingEngine?.dispose();
1441
1526
  engine = undefined;
1442
- branchQuiesce = undefined;
1527
+ shutdownQuiesce = undefined;
1443
1528
  protocolCheck = undefined;
1444
1529
  protocolProbe = undefined;
1445
1530
  protocolInFlight = undefined;