@llblab/pi-kit 0.22.2 → 0.23.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +4 -4
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +10 -6
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +7 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +2 -0
  6. package/node_modules/@llblab/pi-grow-loop/dist/index.d.ts +33 -0
  7. package/node_modules/@llblab/pi-grow-loop/dist/index.js +286 -0
  8. package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.d.ts +1 -0
  9. package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.js +1 -0
  10. package/node_modules/@llblab/pi-grow-loop/dist/skills/grow-loop/SKILL.md +117 -0
  11. package/node_modules/@llblab/pi-grow-loop/dist/skills/while-true/SKILL.md +233 -0
  12. package/node_modules/@llblab/pi-grow-loop/index.ts +67 -12
  13. package/node_modules/@llblab/pi-grow-loop/package.json +9 -8
  14. package/node_modules/@llblab/pi-state-flow/AGENTS.md +23 -17
  15. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
  16. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
  17. package/node_modules/@llblab/pi-state-flow/README.md +18 -6
  18. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/index.js +1 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +2 -2
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +5 -5
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +3 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +7 -2
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.d.ts +5 -5
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +54 -40
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +20 -20
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +755 -325
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -2
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +14 -17
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +4 -0
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +65 -23
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +16 -5
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +32 -15
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +28 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +276 -26
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +5 -0
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +36 -2
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +3 -0
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +3 -0
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -0
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +3 -1
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +11 -0
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +150 -24
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +14 -1
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +52 -18
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +8 -8
  47. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  48. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +3 -1
  49. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +53 -7
  50. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +84 -44
  51. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -2
  52. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +6 -4
  53. package/node_modules/@llblab/pi-state-flow/docs/performance.md +2 -2
  54. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +28 -8
  55. package/node_modules/@llblab/pi-state-flow/docs/usage.md +41 -14
  56. package/node_modules/@llblab/pi-state-flow/index.ts +3 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +5 -5
  58. package/node_modules/@llblab/pi-state-flow/lib/context.ts +8 -3
  59. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +57 -40
  60. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +20 -20
  61. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +719 -316
  62. package/node_modules/@llblab/pi-state-flow/lib/git.ts +16 -18
  63. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +60 -24
  64. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +34 -21
  65. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +290 -25
  66. package/node_modules/@llblab/pi-state-flow/lib/session.ts +37 -2
  67. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +3 -0
  68. package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -1
  69. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +141 -22
  70. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +60 -19
  71. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +8 -8
  72. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  73. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +3 -1
  74. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  75. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  76. package/node_modules/@llblab/pi-telegram/README.md +2 -2
  77. package/node_modules/@llblab/pi-telegram/dist/lib/skills.d.ts +7 -1
  78. package/node_modules/@llblab/pi-telegram/dist/lib/skills.js +32 -7
  79. package/node_modules/@llblab/pi-telegram/dist/package.json +3 -8
  80. package/node_modules/@llblab/pi-telegram/lib/skills.ts +43 -7
  81. package/node_modules/@llblab/pi-telegram/package.json +3 -8
  82. package/node_modules/@llblab/pi-telegram/scripts/build-dist.mjs +103 -32
  83. package/package.json +6 -6
@@ -29,19 +29,20 @@ import { awaitInFlightBackupPushes, backupCurrentStateFlowFiles, startStateFlowB
29
29
  import { projectRecentTransitionsWithLimit } from "./history.ts";
30
30
  import { isObject, presentationJson, sameJson, type JsonObject } from "./json.ts";
31
31
  import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
32
- import { assistantToolCallCount, finalizedAssistantResponse, formatPatchStateArguments, PASSIVE_MEMORY_PROTOCOL, separatedFailure, separatedOutput, stateFlowProtocol } from "./protocol.ts";
32
+ import { assistantToolCallCount, conciseDiagnostic, diagnosticText, finalizedAssistantResponse, formatPatchStateArguments, PASSIVE_MEMORY_PROTOCOL, separatedFailure, separatedOutput, stateFlowProtocol } from "./protocol.ts";
33
33
  import { readProjectedState, readStatePath } from "./query.ts";
34
- import { recoverSnapshot } from "./recovery.ts";
34
+ import { selectedBoundaryFailure, selectRetainedCheckpoint, waitForRecovery } from "./recovery.ts";
35
35
  import type { RehydrationPhase } from "./rehydration.ts";
36
- import { SharedScopeRemovalConflictError, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
37
- import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
36
+ import { TemporalRuntime, type RuntimePublication } from "./runtime.ts";
37
+ import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, hasUncheckpointedConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
38
38
  import { hasCompiledSkillArtifact, hashSkillSource, registeredSkillResolver, SkillReadTracker, type SuccessfulSkillRead } from "./skills.ts";
39
- import { emptySnapshot, migrationFailure, type Snapshot } from "./snapshot.ts";
39
+ import { HistoryBoundaryExpiredError, emptySnapshot, migrationFailure, type RetainedBoundaryCheckpoint, type Snapshot } from "./snapshot.ts";
40
40
  import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ModelState, type ScopedStates, type StateScope } from "./state.ts";
41
41
  import { compactStatus, detailedStatus, STATUS_KEY, type StatusDiagnostics } from "./status.ts";
42
- import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader, type StateFlowTelegramScope } from "./telegram.ts";
42
+ import { PublicationBusyError } from "./storage.ts";
43
+ import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramInspection, type StateFlowTelegramLoader, type StateFlowTelegramScope } from "./telegram.ts";
43
44
  import { temporalScopeRevisions, type ScopeRevisions } from "./temporal.ts";
44
- import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, type StagedScopedTransition } from "./transition.ts";
45
+ import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition } from "./transition.ts";
45
46
 
46
47
  export interface StateFlowExtensionOptions {
47
48
  agentDir?: string;
@@ -58,6 +59,28 @@ export const PATCH_STATE_TOOL_NAME = "patch_state";
58
59
  export const READ_STATE_TOOL_NAME = "read_state";
59
60
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
60
61
 
62
+ interface InferencePreparation {
63
+ readonly controller: AbortController;
64
+ readonly prompt?: string;
65
+ accepted?: boolean;
66
+ operation?: Promise<void>;
67
+ }
68
+
69
+ interface BranchRestoration {
70
+ readonly controller: AbortController;
71
+ /** Memory acceptance is independent of the requested active/passive policy. */
72
+ awaitingAcceptance: boolean;
73
+ passiveRequested?: boolean;
74
+ operation?: Promise<void>;
75
+ }
76
+
77
+ type BranchSelection =
78
+ | { kind: "settled" }
79
+ | { kind: "current" }
80
+ | { kind: "restore"; checkpoint: RetainedBoundaryCheckpoint }
81
+ | { kind: "fork"; checkpoint: RetainedBoundaryCheckpoint; source: SessionAddress }
82
+ | { kind: "auto-start" };
83
+
61
84
  export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
62
85
  const agentDir = options.agentDir ?? getAgentDir();
63
86
  const loadedConfig = loadStateFlowConfig(agentDir, options.repositoryRoot);
@@ -68,10 +91,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
68
91
  };
69
92
  let snapshot: Snapshot = emptySnapshot();
70
93
  let scopeStates: ScopedStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
71
- let branchHasSnapshot = false;
72
94
  let branchStartsWithoutRuntime = false;
95
+ let selectedHistoryExpired = false;
96
+ let stopPersistenceError: string | undefined;
97
+ let stopPersistence: { controller: AbortController; operation?: Promise<StateFlowTelegramControlResult> } | undefined;
98
+ let startActivation: { controller: AbortController; operation?: Promise<StateFlowTelegramControlResult> } | undefined;
73
99
  let forkInitialization = false;
74
- let responseAwaitingReconciliation = false;
100
+ let branchRestoration: BranchRestoration | undefined;
101
+ const restorationOperations = new Set<Promise<void>>();
102
+ let responseReconciliation: AbortController | undefined;
103
+ let sharedInspectionLifetime = new AbortController();
75
104
  let completedRunAccepted = false;
76
105
  let turnAcceptedForBackup = false;
77
106
  let compactionPlan: StateFlowCompactionPlan | undefined;
@@ -80,7 +109,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
80
109
  const compactionMarker = `state-flow-boundary:${randomUUID()}`;
81
110
  let passiveContinuation: PassiveContinuation | undefined;
82
111
  let bootstrapContinuation: PassiveContinuation | undefined;
83
- let artifactRefreshPending = false;
112
+ let inferencePreparation: InferencePreparation | undefined;
84
113
  let runAnchorTimestamp: number | undefined;
85
114
  let runtime: TemporalRuntime | undefined;
86
115
  let activeContext: ExtensionContext | undefined;
@@ -88,6 +117,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
88
117
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
89
118
  const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
90
119
  let backupPending = false;
120
+ const backupLifetime = new AbortController();
121
+ const backupOperations = new Set<Promise<string | undefined>>();
91
122
  let shuttingDown = false;
92
123
  let pushFailureNotified = false;
93
124
  const skillReads = new SkillReadTracker(hashSkillSource, (path) => activeContext
@@ -103,9 +134,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
103
134
  return resolveSessionAddress(ctx.sessionManager.getSessionFile(), ctx.sessionManager.getSessionId(), ctx.sessionManager.getHeader()?.timestamp);
104
135
  }
105
136
 
137
+ function notifyProblem(ctx: ExtensionContext, message: string, level: "warning" | "error"): void {
138
+ ctx.ui.notify(conciseDiagnostic(message), level);
139
+ }
140
+
106
141
  function notifyActiveContext(message: string): void {
107
142
  try {
108
- activeContext?.ui.notify(message, "warning");
143
+ if (activeContext) notifyProblem(activeContext, message, "warning");
109
144
  } catch {
110
145
  // An asynchronous push attempt can outlive the Pi context that launched it.
111
146
  }
@@ -121,7 +156,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
121
156
 
122
157
  function assertSelectedBranchAvailable(): void {
123
158
  if (snapshot.meta.validation?.attempt === 0) {
124
- throw new Error(`State Flow selected branch is unavailable: ${snapshot.meta.validation.error}. Restore its canonical files and retry /state-flow-start, or select a retained boundary.`);
159
+ throw new Error(`State Flow selected branch is unavailable: ${snapshot.meta.validation.error}`);
125
160
  }
126
161
  }
127
162
 
@@ -131,12 +166,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
131
166
  return projectModelState(runtime.read(offset, scope));
132
167
  } });
133
168
 
134
- function persist(): void {
169
+ function assertPublicationAvailable(): void {
135
170
  assertSelectedBranchAvailable();
136
- const previousView = runtime?.view;
137
- const publication = !branchStartsWithoutRuntime && runtime?.view ? runtime.publish(snapshot, false, undefined) : undefined;
138
- if (runtime?.view !== previousView) installScopeStates();
139
- if (publication && activeContext) recordPublication(publication, activeContext);
171
+ if (stopPersistenceError) throw new Error(`Memory writes paused after Stop: ${stopPersistenceError}; use /state-flow-start`);
172
+ }
173
+
174
+ function appendCheckpoint(): void {
140
175
  const checkpoint = branchStartsWithoutRuntime ? { disabled: true } : runtime?.retainedCheckpoint(snapshot) ?? { disabled: true };
141
176
  if ("disabled" in checkpoint && !branchStartsWithoutRuntime) {
142
177
  throw new Error("State Flow cannot checkpoint an unproven branch as ordinary disabled; restore a valid checkpoint first");
@@ -155,8 +190,46 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
155
190
  ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, scopeRevisions(), (color, text) => ctx.ui.theme.fg(color, text)));
156
191
  }
157
192
 
193
+ function cancelStopPersistence(): Promise<StateFlowTelegramControlResult> | undefined {
194
+ const pending = stopPersistence;
195
+ pending?.controller.abort();
196
+ stopPersistence = undefined;
197
+ return pending?.operation;
198
+ }
199
+
200
+ function cancelStartActivation(): Promise<StateFlowTelegramControlResult> | undefined {
201
+ const pending = startActivation;
202
+ pending?.controller.abort();
203
+ startActivation = undefined;
204
+ return pending?.operation;
205
+ }
206
+
207
+ function cancelBranchRestoration(): Promise<void> | undefined {
208
+ const pending = branchRestoration;
209
+ pending?.controller.abort();
210
+ branchRestoration = undefined;
211
+ return pending?.operation;
212
+ }
213
+
214
+ function cancelResponseReconciliation(): void {
215
+ responseReconciliation?.abort();
216
+ responseReconciliation = undefined;
217
+ }
218
+
219
+ function cancelSharedInspections(): void {
220
+ sharedInspectionLifetime.abort(new Error("State Flow inspection was cancelled; select the scope again"));
221
+ sharedInspectionLifetime = new AbortController();
222
+ }
223
+
224
+ function cancelInferencePreparation(): void {
225
+ inferencePreparation?.controller.abort();
226
+ inferencePreparation = undefined;
227
+ }
228
+
158
229
  function clearRunTransient(): void {
159
- responseAwaitingReconciliation = false;
230
+ cancelInferencePreparation();
231
+ cancelResponseReconciliation();
232
+ cancelSharedInspections();
160
233
  completedRunAccepted = false;
161
234
  turnAcceptedForBackup = false;
162
235
  compactionPlan = undefined;
@@ -165,10 +238,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
165
238
  artifactReads.clear();
166
239
  }
167
240
 
168
- function deferArtifactRefresh(): void {
241
+ function deferInferencePreparation(prompt?: string): void {
242
+ cancelInferencePreparation();
243
+ inferencePreparation = { controller: new AbortController(), prompt };
169
244
  artifactInvalidations = [];
170
245
  artifactReads.setCandidates([]);
171
- artifactRefreshPending = true;
172
246
  }
173
247
 
174
248
  function refreshArtifactHints(): void {
@@ -202,10 +276,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
202
276
  artifactReads.setCandidates(artifactInvalidations);
203
277
  }
204
278
 
205
- function removeMissingRegisteredArtifacts(ctx: ExtensionContext): void {
206
- if (!snapshot.config.enabled || !runtime?.view) return;
279
+ function missingArtifactRemovals(states: ScopedStates): AtomicScopePatches {
207
280
  const owners = new Map<string, StateScope[]>();
208
- for (const scope of ["global", "cwd", "session"] as const) for (const path of Object.keys(scopeStates[scope].artifacts)) {
281
+ for (const scope of ["global", "cwd", "session"] as const) for (const path of Object.keys(states[scope].artifacts)) {
209
282
  owners.set(path, [...owners.get(path) ?? [], scope]);
210
283
  }
211
284
  const removals: AtomicScopePatches = {};
@@ -216,12 +289,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
216
289
  removals[scope] = { ...(removals[scope] ?? {}), artifacts: { ...artifacts, [observation.path]: null } };
217
290
  }
218
291
  }
219
- if (Object.keys(removals).length > 0) {
220
- const stage = stageAtomicScopePatches(scopeStates, removals, [], runtime.causalBasis());
221
- commitStage(stage, ctx, false);
222
- updateUi(ctx);
223
- }
224
- refreshArtifactHints();
292
+ return removals;
225
293
  }
226
294
 
227
295
 
@@ -233,16 +301,21 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
233
301
  return snapshot.config.enabled || config.passiveTools;
234
302
  }
235
303
 
236
- function refreshTelegramStateView(scope: StateFlowTelegramScope): void {
304
+ async function inspectTelegramState(scope: StateFlowTelegramScope): Promise<StateFlowTelegramInspection> {
305
+ if (!activeContext || shuttingDown) throw new Error("State Flow is not attached to an active session yet");
237
306
  if (scope === "session" || scope === "effective") assertSelectedBranchAvailable();
238
- if (!runtime?.view) {
239
- if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
240
- runtime ??= createRuntime(activeContext);
241
- runtime.loadPassive();
242
- } else if (scope !== "session") {
243
- runtime.refreshShared();
244
- }
307
+ const selected = runtime ??= createRuntime(activeContext);
308
+ const signal = sharedInspectionLifetime.signal;
309
+ if (!selected.view || (scope !== "session" && !stopPersistenceError)) await selected.refreshShared(signal);
310
+ signal.throwIfAborted();
311
+ if (runtime !== selected) throw new Error("State Flow inspection selection changed; select the scope again");
312
+ if (scope === "session" || scope === "effective") assertSelectedBranchAvailable();
313
+ if (!selected.view) throw new Error("State Flow temporal runtime is unavailable");
245
314
  installScopeStates();
315
+ const state = scope === "effective"
316
+ ? overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)
317
+ : scopeStates[scope];
318
+ return { state: { ...projectModelState(state), lazy: structuredClone(state.lazy) }, revisions: scopeRevisions(), signal };
246
319
  }
247
320
 
248
321
  function syncStateFlowTools(): void {
@@ -287,29 +360,57 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
287
360
  return `State Flow acquisition: this registered Skill belongs at ${target}. If durable compiled guidance is useful, include a non-empty description, kind:"skill", and compilation object there. Unrelated semantic patches do not need to include it.`;
288
361
  }
289
362
 
290
- function commitStage(stage: StagedScopedTransition, ctx: ExtensionContext, finalizeRun: boolean): boolean {
291
- const acquiredArtifactPaths = new Set(artifactReads.successful.keys());
292
- let committed: boolean;
293
- try {
294
- committed = commitScopedTransition(snapshot, scopeStates, stage, (accepted, nextSnapshot) => {
295
- if (!runtime?.view) throw new Error("Temporal State Flow runtime is unavailable; reload before publishing");
296
- const publication = runtime.publish(nextSnapshot, accepted !== undefined, accepted, {
297
- provenance: stage.provenanceUpdates,
298
- });
299
- if (publication) recordPublication(publication, ctx);
300
- }, runtime!.causalBasis(), { finalizeRun });
301
- } catch (error) {
302
- if (error instanceof SharedScopeRemovalConflictError) installScopeStates();
303
- throw error;
304
- }
305
- if (!committed) return false;
306
- installScopeStates();
363
+ function clearAcceptedAcquisitions(acquiredArtifactPaths = new Set(artifactReads.successful.keys())): void {
307
364
  artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
308
365
  artifactReads.setCandidates(artifactInvalidations);
309
366
  dropCurrentSkillReads();
310
367
  artifactReads.clear();
311
- persist();
312
- return true;
368
+ }
369
+
370
+ async function prepareInference(pending: InferencePreparation, selected: TemporalRuntime | undefined, ctx: ExtensionContext, operationSignal: AbortSignal): Promise<void> {
371
+ const signal = AbortSignal.any([pending.controller.signal, operationSignal]);
372
+ try {
373
+ if (!selected?.view) throw new Error("Temporal State Flow runtime is unavailable; reload before publishing");
374
+ await selected.withPatchTransaction((transaction) => {
375
+ signal.throwIfAborted();
376
+ if (runtime !== selected || inferencePreparation !== pending || !snapshot.config.enabled || shuttingDown) {
377
+ throw new Error("State Flow inference selection changed while awaiting publication");
378
+ }
379
+ assertPublicationAvailable();
380
+ const nextSnapshot = structuredClone(snapshot);
381
+ const rotatesRun = pending.prompt !== undefined && nextSnapshot.meta.specification !== undefined;
382
+ if (pending.prompt !== undefined) prepareRun(nextSnapshot, pending.prompt);
383
+ // Exact-path metadata validation belongs to the locked current head, including newly adopted registrations.
384
+ const removals = missingArtifactRemovals(transaction.states);
385
+ let publication: RuntimePublication | undefined;
386
+ if (Object.keys(removals).length > 0) {
387
+ const stage = stageAtomicScopePatches(transaction.states, removals, [], transaction.causalBasis);
388
+ commitScopedTransition(nextSnapshot, transaction.states, stage, (transition, next) => {
389
+ publication = transaction.publish(next, transition);
390
+ pending.accepted = true;
391
+ }, transaction.causalBasis, { finalizeRun: false });
392
+ } else {
393
+ publication = transaction.publish(nextSnapshot);
394
+ pending.accepted = true;
395
+ }
396
+ snapshot = nextSnapshot;
397
+ installScopeStates();
398
+ if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
399
+ if (publication?.changed) recordPublication(publication, ctx);
400
+ if (pending.prompt !== undefined || publication?.changed) appendCheckpoint();
401
+ if (Object.keys(removals).length > 0) clearAcceptedAcquisitions();
402
+ updateUi(ctx);
403
+ }, signal);
404
+ } catch (error) {
405
+ if (signal.aborted || runtime !== selected || inferencePreparation !== pending || shuttingDown) return;
406
+ // Pi reports context-hook exceptions and continues. Abort through its public port rather than infer from stale preparation.
407
+ ctx.abort();
408
+ const cause = diagnosticText(error);
409
+ recordDiagnostic(cause, "publication-conflict", ctx);
410
+ notifyProblem(ctx, pending.accepted
411
+ ? `State Flow preparation saved; lifecycle update failed: ${cause}`
412
+ : `State Flow inference preparation failed: ${cause}`, "error");
413
+ }
313
414
  }
314
415
 
315
416
 
@@ -344,6 +445,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
344
445
  } }),
345
446
  staleArtifacts,
346
447
  ...(durableStateError === undefined ? {} : { durableStateError }),
448
+ ...(stopPersistenceError === undefined ? {} : { publicationError: stopPersistenceError }),
347
449
  };
348
450
  }
349
451
 
@@ -355,116 +457,224 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
355
457
  return resolveSessionAddress(parent.file, parent.id, parent.timestamp);
356
458
  }
357
459
 
358
- function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown): void {
460
+ /** Select the active native branch under one owned restoration lifetime; only current accepted work installs memory. */
461
+ function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown, notifyRecovery = true, startOwner?: AbortController): Promise<void> {
462
+ cancelBranchRestoration();
463
+ // Start-owned attachment/fork recovery keeps its owner; only accepted Start cancels Stop.
464
+ if (!startOwner) {
465
+ cancelStartActivation();
466
+ cancelStopPersistence();
467
+ }
359
468
  clearRunTransient();
360
469
  passiveContinuation = undefined;
361
470
  bootstrapContinuation = undefined;
362
471
  artifactInvalidations = [];
363
472
  artifactReads.setCandidates([]);
364
- artifactRefreshPending = false;
365
473
  telegramStartPending = false;
366
474
  activeContext = ctx;
367
- const session = sessionAddress(ctx);
368
- runtime = new TemporalRuntime(ctx.cwd, session, repositoryRoot, undefined, config.historyLimit);
475
+ runtime = createRuntime(ctx);
369
476
  installScopeStates();
370
- branchHasSnapshot = false;
371
477
  branchStartsWithoutRuntime = false;
478
+ selectedHistoryExpired = false;
372
479
  forkInitialization = sessionStartReason === "fork";
480
+ let selection: BranchSelection = { kind: "settled" };
481
+ let skipped = 0;
373
482
  try {
374
483
  const branch = ctx.sessionManager.getBranch();
375
- const discovery = discoverSnapshotData(branch);
376
- let restoreSelected: (() => Snapshot) | undefined;
377
- const recovery = recoverSnapshot(discovery.candidates, (checkpoint) => {
378
- if (forkInitialization) {
379
- const prepared = runtime!.prepareBoundaryFork(forkSource(ctx), checkpoint);
380
- restoreSelected = () => {
381
- const accepted = prepared.fork();
382
- snapshot = accepted.snapshot;
383
- recordPolicyPublication(accepted.publication, ctx);
384
- pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { reset: true, owner: ctx.sessionManager.getSessionId() });
385
- forkInitialization = false;
386
- return snapshot;
387
- };
388
- return prepared.snapshot;
389
- }
390
- const prepared = runtime!.prepareBoundaryRestore(checkpoint);
391
- restoreSelected = () => {
392
- const restored = prepared.restore();
393
- const publication = runtime!.acceptRestoredOrigin(restored);
394
- recordPolicyPublication(publication, ctx);
395
- return restored;
396
- };
397
- return prepared.snapshot;
398
- });
399
- branchStartsWithoutRuntime = recovery.disabledMarker === true
400
- || (discovery.candidates.length === 0 && discovery.errors.length === 0);
401
- const skipped = discovery.errors.length + recovery.skipped.length;
402
- branchHasSnapshot = recovery.skipped.length < discovery.candidates.length;
403
- if (discovery.candidates.length === 0 && discovery.errors.length > 0) {
404
- snapshot = migrationFailure({}, `Snapshot restoration failed: ${discovery.errors[0]}`);
405
- } else if (discovery.candidates.length > 0) {
406
- snapshot = recovery.snapshot;
407
- if (branchHasSnapshot && restoreSelected) {
408
- snapshot = restoreSelected();
409
- persist();
410
- } else if (branchHasSnapshot && snapshot.config.enabled) {
411
- const publication = runtime.initialize(snapshot, true);
412
- recordPolicyPublication(publication, ctx);
484
+ const fence = retainsPhysicalSessionProjection(sessionStartReason)
485
+ ? findPassiveStopBoundary(branch, ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE)?.persistenceError
486
+ : undefined;
487
+ stopPersistenceError = fence ?? (startOwner ? stopPersistenceError : undefined);
488
+ if (fence) {
489
+ // The native Stop owns policy only: read proven memory, never replay or publish a stale selection.
490
+ selection = { kind: "current" };
491
+ } else {
492
+ const discovery = discoverSnapshotData(branch);
493
+ const selected = selectRetainedCheckpoint(discovery.candidates);
494
+ skipped = discovery.errors.length + selected.skipped.length;
495
+ branchStartsWithoutRuntime = selected.kind === "disabled" || (discovery.candidates.length === 0 && discovery.errors.length === 0);
496
+ if (discovery.candidates.length === 0 && discovery.errors.length > 0) {
497
+ snapshot = migrationFailure({}, `Snapshot restoration failed: ${discovery.errors[0]}`);
498
+ } else if (selected.kind === "boundary" && forkInitialization) {
499
+ try {
500
+ // Native parent acquisition stays outside canonical exclusion.
501
+ const source = forkSource(ctx);
502
+ const sourceStopped = findPassiveStopBoundary(branch, source.id, PASSIVE_STOP_ENTRY_TYPE)?.persistenceError !== undefined;
503
+ selection = { kind: "fork", source, checkpoint: sourceStopped ? { ...selected.checkpoint, enabled: false } : selected.checkpoint };
504
+ } catch (error) {
505
+ snapshot = selectedBoundaryFailure(diagnosticText(error));
506
+ }
507
+ } else if (selected.kind === "boundary") {
508
+ selection = { kind: "restore", checkpoint: selected.checkpoint };
509
+ } else if (discovery.candidates.length > 0) {
510
+ snapshot = selected.kind === "disabled" ? emptySnapshot() : selected.snapshot;
511
+ } else if (config.autoStart && isNewSession(sessionStartReason, branch)) {
512
+ selection = { kind: "auto-start" };
513
+ } else {
514
+ snapshot = emptySnapshot();
413
515
  }
414
- installScopeStates();
415
- } else if (config.autoStart && isNewSession(sessionStartReason, branch)) {
416
- snapshot = startEpisode(hasPriorConversation(branch));
417
- runtime.prepare();
418
- const initialization = runtime.initialize(snapshot, true);
419
- recordPolicyPublication(initialization, ctx);
420
- if (!runtime.view) snapshot = emptySnapshot();
421
- installScopeStates();
422
- if (snapshot.config.enabled) {
423
- branchHasSnapshot = true;
424
- persist();
516
+ }
517
+ } catch (error) {
518
+ selection = { kind: "settled" };
519
+ snapshot = selectedBoundaryFailure(diagnosticText(error));
520
+ }
521
+ // Pending selection grants neither private reads nor publication until its acceptance installs memory.
522
+ if (selection.kind !== "settled") snapshot = migrationFailure({}, "State Flow branch restoration is pending");
523
+ const pending: BranchRestoration = {
524
+ controller: new AbortController(),
525
+ awaitingAcceptance: selection.kind !== "settled" && selection.kind !== "current",
526
+ };
527
+ branchRestoration = pending;
528
+ const operation = attachBranch(ctx, pending, selection, sessionStartReason, notifyRecovery, skipped);
529
+ pending.operation = operation;
530
+ restorationOperations.add(operation);
531
+ const finished = () => { restorationOperations.delete(operation); };
532
+ void operation.then(finished, finished);
533
+ return operation;
534
+ }
535
+
536
+ async function attachBranch(
537
+ ctx: ExtensionContext, pending: BranchRestoration, selection: BranchSelection,
538
+ reason: unknown, notifyRecovery: boolean, skipped: number,
539
+ ): Promise<void> {
540
+ const placeholder = runtime;
541
+ let selected = runtime;
542
+ const owner = ctx.sessionManager.getSessionId();
543
+ const cwd = ctx.cwd;
544
+ const file = ctx.sessionManager.getSessionFile();
545
+ const timestamp = ctx.sessionManager.getHeader()?.timestamp;
546
+ const signal = AbortSignal.any([pending.controller.signal, ...(ctx.signal ? [ctx.signal] : [])]);
547
+ const isCurrent = () => branchRestoration === pending && !pending.controller.signal.aborted && !shuttingDown && runtime === selected
548
+ && ctx.cwd === cwd && ctx.sessionManager.getSessionId() === owner && ctx.sessionManager.getSessionFile() === file
549
+ && ctx.sessionManager.getHeader()?.timestamp === timestamp;
550
+ const assertCurrent = () => {
551
+ signal.throwIfAborted();
552
+ if (!isCurrent()) throw new Error("State Flow branch selection changed while awaiting publication");
553
+ };
554
+ let accepted = false;
555
+ // Install accepted memory before native writes; later ancillary failures cannot revert or replay it.
556
+ const accept = (candidate: TemporalRuntime, next: Snapshot, publication: RuntimePublication, nativeWrites: () => void): void => {
557
+ accepted = true;
558
+ pending.awaitingAcceptance = false;
559
+ runtime = selected = candidate;
560
+ snapshot = next;
561
+ installScopeStates();
562
+ recordPolicyPublication(publication, ctx);
563
+ let failure: unknown;
564
+ try { nativeWrites(); } catch (error) { failure = error; }
565
+ settleSelection(ctx, reason, notifyRecovery, skipped, true);
566
+ if (failure !== undefined) throw failure;
567
+ };
568
+ try {
569
+ if (selection.kind !== "settled") {
570
+ // Local policy is already passive while the selection waits for exclusion.
571
+ syncStateFlowTools();
572
+ updateUi(ctx);
573
+ const candidate = createRuntime(ctx);
574
+ try {
575
+ if (selection.kind === "current") {
576
+ const current = await candidate.refreshCurrentMemory(signal);
577
+ assertCurrent();
578
+ if (current) runtime = selected = candidate;
579
+ snapshot = current ?? migrationFailure({}, "Current State Flow session memory is unavailable");
580
+ installScopeStates();
581
+ } else if (selection.kind === "restore") {
582
+ await candidate.withRestoreTransaction(selection.checkpoint, (restored, publish) => {
583
+ assertCurrent();
584
+ // Canceled preparation or boundary continuation may have no specification. Retain uncompiled native context.
585
+ if (restored.config.enabled && restored.meta.specification === undefined
586
+ && retainsPhysicalSessionProjection(reason) && hasUncheckpointedConversation(ctx.sessionManager.getBranch())) restored.meta.bootstrap = true;
587
+ const next = pending.passiveRequested ? stopEpisode(restored) : restored;
588
+ accept(candidate, next, publish(next), appendCheckpoint);
589
+ }, signal);
590
+ } else if (selection.kind === "fork") {
591
+ await candidate.withForkTransaction(selection.source, selection.checkpoint, (child, publish) => {
592
+ assertCurrent();
593
+ // Mode is selected independently of creating the child's private memory.
594
+ const next = pending.passiveRequested ? stopEpisode(child) : child;
595
+ const publication = publish(next);
596
+ forkInitialization = false;
597
+ accept(candidate, next, publication, () => {
598
+ pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { reset: true, owner });
599
+ appendCheckpoint();
600
+ });
601
+ }, signal);
602
+ } else {
603
+ await candidate.withStartTransaction((current, publish) => {
604
+ assertCurrent();
605
+ // Truly new branch authority is rechecked after waiting; existing memory needs its own selection.
606
+ const branch = ctx.sessionManager.getBranch();
607
+ const discovery = discoverSnapshotData(branch);
608
+ if (current || discovery.candidates.length > 0 || discovery.errors.length > 0) throw new Error("Existing session runtime requires retained-boundary restoration");
609
+ const activated = startEpisode(hasPriorConversation(branch));
610
+ const next = pending.passiveRequested ? stopEpisode(activated) : activated;
611
+ accept(candidate, next, publish(next), appendCheckpoint);
612
+ }, signal, true);
613
+ }
614
+ } catch (error) {
615
+ if (accepted) {
616
+ if (isCurrent()) notifyProblem(ctx, `State Flow memory restored; lifecycle update failed: ${diagnosticText(error)}`, "warning");
617
+ return;
618
+ }
619
+ if (!isCurrent()) return;
620
+ if (error instanceof HistoryBoundaryExpiredError) selectedHistoryExpired = true;
621
+ snapshot = selectedBoundaryFailure(diagnosticText(error));
622
+ installScopeStates();
623
+ } finally {
624
+ pending.awaitingAcceptance = false;
425
625
  }
426
- } else {
427
- snapshot = emptySnapshot();
428
- installScopeStates();
626
+ if (accepted) return;
429
627
  }
430
- if (snapshot.config.enabled && skipped > 0) {
431
- ctx.ui.notify(`State Flow recovered the previous valid snapshot after skipping ${skipped} malformed newer snapshot(s).`, "warning");
628
+ settleSelection(ctx, reason, notifyRecovery, skipped, selected !== placeholder);
629
+ if (snapshot.config.enabled || !(config.passiveBootstrap || config.passiveTools) || runtime?.view) return;
630
+ const passive = createRuntime(ctx);
631
+ const priorView = selected?.view;
632
+ try {
633
+ await passive.refreshShared(signal);
634
+ } catch (error) {
635
+ if (isCurrent() && !signal.aborted && notifyRecovery && !stopPersistenceError) notifyProblem(ctx, `State Flow passive memory is unavailable: ${diagnosticText(error)}`, "warning");
636
+ return;
432
637
  }
638
+ assertCurrent();
639
+ // An intervening passive patch or inspection owns its newer cache, even on the same physical branch.
640
+ if (selected?.view !== priorView) return;
641
+ runtime = selected = passive;
642
+ installScopeStates();
643
+ updateUi(ctx);
433
644
  } catch (error) {
434
- const failure = migrationFailure({}, `Snapshot restoration failed: ${error instanceof Error ? error.message : String(error)}`);
435
- snapshot = {
436
- ...snapshot,
437
- config: { ...snapshot.config, enabled: false },
438
- meta: { ...snapshot.meta, validation: failure.meta.validation },
439
- };
440
- }
441
- if (!snapshot.config.enabled && snapshot.meta.validation?.attempt === 0) {
442
- // A failed acceptance may have installed a prepared view; only shared passive reads may survive.
443
- runtime = createRuntime(ctx);
645
+ // Host failures before acceptance leave the selection unavailable, never invented empty memory.
646
+ if (accepted || !isCurrent()) return;
647
+ snapshot = selectedBoundaryFailure(diagnosticText(error));
444
648
  installScopeStates();
445
- ctx.ui.notify(`State Flow restored disabled: ${snapshot.meta.validation.error}`, "error");
649
+ } finally {
650
+ pending.awaitingAcceptance = false;
651
+ if (branchRestoration === pending) branchRestoration = undefined;
652
+ }
653
+ }
654
+
655
+ function settleSelection(ctx: ExtensionContext, reason: unknown, notifyRecovery: boolean, skipped: number, selectedMemory: boolean): void {
656
+ const failure = !snapshot.config.enabled && snapshot.meta.validation?.attempt === 0 ? snapshot.meta.validation.error : undefined;
657
+ if (failure !== undefined && notifyRecovery && !stopPersistenceError) {
658
+ if (selectedHistoryExpired) notifyProblem(ctx, "State Flow history is outside the retained temporal window; /state-flow-start can use current session memory.", "warning");
659
+ else notifyProblem(ctx, `State Flow restore failed: ${failure}`, "error");
446
660
  }
447
- if (retainsPhysicalSessionProjection(sessionStartReason) && runtime?.view) {
661
+ if (selectedMemory && retainsPhysicalSessionProjection(reason) && runtime?.view) {
448
662
  const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
449
663
  if (boundary !== undefined) {
450
664
  const continuation = createPassiveContinuation(
451
665
  projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
452
666
  boundary.at,
453
667
  boundary.from,
668
+ boundary.preserveContext,
454
669
  );
455
670
  if (!snapshot.config.enabled) passiveContinuation = continuation;
456
671
  else if (snapshot.meta.bootstrap) bootstrapContinuation = continuation;
457
672
  }
458
673
  }
459
- if (!snapshot.config.enabled && (config.passiveBootstrap || config.passiveTools) && !runtime?.view) {
460
- try {
461
- runtime?.loadPassive();
462
- installScopeStates();
463
- } catch (error) {
464
- ctx.ui.notify(`State Flow passive memory is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
465
- }
674
+ if (notifyRecovery && snapshot.config.enabled && skipped > 0) {
675
+ notifyProblem(ctx, `State Flow skipped ${skipped} malformed snapshot(s); restored the last valid one.`, "warning");
466
676
  }
467
- if (snapshot.config.enabled) deferArtifactRefresh();
677
+ if (snapshot.config.enabled) deferInferencePreparation();
468
678
  syncStateFlowTools();
469
679
  updateUi(ctx);
470
680
  }
@@ -540,9 +750,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
540
750
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
541
751
  try {
542
752
  if (!passiveToolsAvailable()) throw new Error("State Flow tools are disabled by configuration");
543
- assertSelectedBranchAvailable();
753
+ assertPublicationAvailable();
544
754
  if (signal?.aborted) throw new Error("State Flow patch was aborted before materialization");
545
755
  if (!isObject(params)) throw new Error("patch_state requires an object");
756
+ params = structuredClone(params);
546
757
  const allowed = new Set(["global", "cwd", "session"]);
547
758
  for (const key of Object.keys(params)) {
548
759
  if (!allowed.has(key)) throw new Error(`patch_state does not accept field ${key}`);
@@ -556,34 +767,48 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
556
767
  patches[scope] = patch;
557
768
  }
558
769
  const scopes = Object.keys(patches) as StateScope[];
559
- if (scopes.length === 0) throw new Error("patch_state requires at least one materially changed scope patch");
560
- if (!runtime?.view) {
561
- runtime ??= createRuntime(ctx);
562
- runtime.prepare();
563
- const publication = runtime.initialize(snapshot, true, undefined, true);
564
- recordPolicyPublication(publication, ctx);
770
+ if (scopes.length === 0) throw new Error("patch_state requires at least one scope patch");
771
+ const selected = runtime ??= createRuntime(ctx);
772
+ const acquiredArtifacts = structuredClone([...artifactReads.successful.values()]);
773
+ const acquiredSkills = structuredClone([...skillReads.successful.values()]);
774
+ return await selected.withPatchTransaction((transaction) => {
775
+ if (runtime !== selected) throw new Error("State Flow session selection changed while awaiting publication");
776
+ if (!passiveToolsAvailable()) throw new Error("State Flow tools are disabled by configuration");
777
+ assertPublicationAvailable();
778
+ for (const acquired of acquiredArtifacts) {
779
+ const observation = inspectRegisteredArtifactPaths([acquired.path])[0];
780
+ if (acquired.sourceFingerprint === undefined || observation?.kind !== "present"
781
+ || !sameArtifactSourceFingerprint(acquired.sourceFingerprint, observation.fingerprint)) {
782
+ throw new Error(`Artifact source changed after acquisition: ${acquired.path}`);
783
+ }
784
+ }
785
+ const stage = stageAtomicScopePatches(transaction.states, patches, acquiredSkills, transaction.causalBasis, acquiredArtifacts);
786
+ const changed = (["global", "cwd", "session"] as const).some((scope) =>
787
+ !sameJson(transaction.states[scope], stage.nextStates[scope])
788
+ || Object.entries(stage.provenanceUpdates[scope]).some(([path, evidence]) =>
789
+ !Object.hasOwn(transaction.provenance[scope], path) || !sameJson(transaction.provenance[scope][path], evidence)));
790
+ const nextSnapshot = structuredClone(snapshot);
791
+ let publication: RuntimePublication | undefined;
792
+ commitScopedTransition(nextSnapshot, transaction.states, stage, (accepted, next) => {
793
+ publication = transaction.publish(next, accepted, stage.provenanceUpdates);
794
+ }, transaction.causalBasis, { finalizeRun: false });
795
+ snapshot = nextSnapshot;
565
796
  installScopeStates();
566
- branchHasSnapshot = true;
567
- }
568
- installScopeStates();
569
- for (const acquired of artifactReads.successful.values()) {
570
- const observation = inspectRegisteredArtifactPaths([acquired.path])[0];
571
- if (acquired.sourceFingerprint === undefined || observation?.kind !== "present"
572
- || !sameArtifactSourceFingerprint(acquired.sourceFingerprint, observation.fingerprint)) {
573
- throw new Error(`Artifact source changed after acquisition: ${acquired.path}`);
797
+ if (publication?.changed) {
798
+ recordPublication(publication, ctx);
799
+ appendCheckpoint();
574
800
  }
575
- }
576
- const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime.causalBasis(), artifactReads.successful.values());
577
- const semanticChange = (["global", "cwd", "session"] as const).some((scope) => !sameJson(scopeStates[scope], stage.nextStates[scope]));
578
- const provenanceChange = Object.values(stage.provenanceUpdates).some((updates) => Object.keys(updates).length > 0);
579
- if (!semanticChange && !provenanceChange) throw new Error("patch_state scope patches must materially update state or required provenance");
580
- commitStage(stage, ctx, false);
581
- updateUi(ctx);
582
- return { content: [{ type: "text", text: `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.` }], details: { scopes, step: snapshot.meta.step } };
801
+ clearAcceptedAcquisitions(new Set(acquiredArtifacts.map(({ path }) => path)));
802
+ updateUi(ctx);
803
+ return { content: [{ type: "text" as const, text: changed
804
+ ? `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.`
805
+ : "\nState already current." }], details: { scopes, step: snapshot.meta.step, changed } };
806
+ }, signal);
583
807
  } catch (error) {
584
808
  let attempted: unknown;
585
809
  try { attempted = structuredClone(params); } catch { attempted = undefined; }
586
- recordDiagnostic(error instanceof Error ? error.message : String(error), /concurrently|advanced/.test(String(error)) ? "publication-conflict" : "invalid-patch", ctx, {
810
+ const cause = diagnosticText(error);
811
+ recordDiagnostic(cause, /concurrently|advanced/.test(cause) ? "publication-conflict" : "invalid-patch", ctx, {
587
812
  input: attempted,
588
813
  tool: PATCH_STATE_TOOL_NAME,
589
814
  toolCallId,
@@ -593,74 +818,127 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
593
818
  },
594
819
  });
595
820
 
596
- function startStateFlow(ctx: ExtensionContext): StateFlowTelegramControlResult {
597
- if (!activeContext) restoreActiveBranch(ctx);
821
+ function startStateFlow(ctx: ExtensionContext): Promise<StateFlowTelegramControlResult> {
822
+ if (shuttingDown) return Promise.resolve({ ok: false, message: "State Flow is shutting down" });
823
+ if (startActivation?.operation) return startActivation.operation;
598
824
  telegramStartPending = false;
599
- const previousSnapshot = structuredClone(snapshot);
600
- const previousPassiveContinuation = passiveContinuation;
601
- const previousBootstrapContinuation = bootstrapContinuation;
602
- const previousArtifactRefreshPending = artifactRefreshPending;
603
- const previousArtifactInvalidations = structuredClone(artifactInvalidations);
825
+ if (activeContext && !branchRestoration && snapshot.config.enabled) {
826
+ return Promise.resolve({ ok: true, message: "State Flow is already enabled", signal: sharedInspectionLifetime.signal });
827
+ }
828
+ const pending: NonNullable<typeof startActivation> = { controller: new AbortController() };
829
+ startActivation = pending;
830
+ const operation = runStart(ctx, pending.controller);
831
+ pending.operation = operation;
832
+ const finished = () => { if (startActivation === pending) startActivation = undefined; };
833
+ void operation.then(finished, finished);
834
+ return operation;
835
+ }
836
+
837
+ /** Await restoration-owned attachment and exact-source fork recovery before current-head activation. */
838
+ async function runStart(ctx: ExtensionContext, pending: AbortController): Promise<StateFlowTelegramControlResult> {
839
+ const owner = ctx.sessionManager.getSessionId();
840
+ const cwd = ctx.cwd;
841
+ const file = ctx.sessionManager.getSessionFile();
842
+ const timestamp = ctx.sessionManager.getHeader()?.timestamp;
843
+ const signal = ctx.signal ? AbortSignal.any([pending.signal, ctx.signal]) : pending.signal;
844
+ const isOwner = () => startActivation?.controller === pending && !signal.aborted && !shuttingDown
845
+ && ctx.cwd === cwd && ctx.sessionManager.getSessionId() === owner && ctx.sessionManager.getSessionFile() === file
846
+ && ctx.sessionManager.getHeader()?.timestamp === timestamp;
847
+ const superseded = (): StateFlowTelegramControlResult => {
848
+ pending.abort();
849
+ return { ok: false, message: "State Flow Start was superseded", signal: pending.signal };
850
+ };
604
851
  try {
605
- if (!branchStartsWithoutRuntime && (!runtime?.view || snapshot.meta.validation?.attempt === 0)) {
606
- const retryFork = forkInitialization;
607
- restoreActiveBranch(ctx, retryFork ? "fork" : undefined);
852
+ if (!activeContext) await waitForRecovery(restoreActiveBranch(ctx, undefined, false, pending), signal);
853
+ else if (branchRestoration?.operation) await waitForRecovery(branchRestoration.operation, signal);
854
+ if (!isOwner()) return superseded();
855
+ if (snapshot.config.enabled) return { ok: true, message: "State Flow is already enabled", signal: sharedInspectionLifetime.signal };
856
+ if (forkInitialization) {
857
+ // Withdraw this join on cancellation without revoking the independently owned Stop.
858
+ const stopping = stopPersistence?.operation;
859
+ if (stopping) await waitForRecovery(stopping, signal);
860
+ if (!isOwner()) return superseded();
861
+ await waitForRecovery(restoreActiveBranch(ctx, "fork", false, pending), signal);
862
+ if (!isOwner()) return superseded();
863
+ assertSelectedBranchAvailable();
608
864
  }
609
- assertSelectedBranchAvailable();
610
- if (!runtime?.view && !branchStartsWithoutRuntime) throw new Error("Selected State Flow boundary is unavailable; restore its canonical files before starting State Flow");
611
- const branch = ctx.sessionManager.getBranch();
612
- activeContext = ctx;
613
- runtime ??= createRuntime(ctx);
614
- if (branchStartsWithoutRuntime) runtime.prepare();
615
- const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
616
- && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
617
- const existingBranch = branchHasSnapshot;
618
- snapshot = existingBranch
619
- ? resumeEpisode(snapshot, bootstrap)
620
- : startEpisode(bootstrap);
621
- branchHasSnapshot = true;
622
- // Activation reasserts the selected session stream as a complete cohort. A resumed
623
- // branch may have no reusable temporal revision after an extension upgrade; a
624
- // runtime-only write would then reject its selected session as an omitted change.
625
- const publication = runtime.view
626
- ? runtime.publish(snapshot, true)
627
- : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
628
- recordPolicyPublication(publication, ctx);
629
- installScopeStates();
630
- clearRunTransient();
631
- passiveContinuation = undefined;
632
- bootstrapContinuation = snapshot.meta.bootstrap
633
- ? previousPassiveContinuation ?? previousBootstrapContinuation
634
- : undefined;
635
- deferArtifactRefresh();
636
- syncStateFlowTools();
637
- persist();
638
- updateUi(ctx);
639
- ctx.ui.notify(
640
- snapshot.meta.bootstrap
641
- ? "State Flow enabled. The next complete agent run will migrate active context into state."
642
- : "State Flow enabled. The next prompt starts a stateful agent run.",
643
- "info",
644
- );
645
- return { ok: true, message: "State Flow enabled" };
865
+ return activateCurrentState(ctx, pending);
646
866
  } catch (error) {
647
- snapshot = previousSnapshot;
648
- passiveContinuation = previousPassiveContinuation;
649
- bootstrapContinuation = previousBootstrapContinuation;
650
- artifactRefreshPending = previousArtifactRefreshPending;
651
- artifactInvalidations = previousArtifactInvalidations;
652
- artifactReads.setCandidates(artifactInvalidations);
653
- syncStateFlowTools();
654
- const message = `State Flow could not initialize CWD state: ${error instanceof Error ? error.message : String(error)}`;
655
- ctx.ui.notify(message, "error");
656
- return { ok: false, message };
867
+ if (!isOwner()) return superseded();
868
+ const message = conciseDiagnostic(`State Flow Start failed: ${diagnosticText(error)}`);
869
+ notifyProblem(ctx, message, "error");
870
+ return { ok: false, message, signal: AbortSignal.any([pending.signal, sharedInspectionLifetime.signal]) };
871
+ }
872
+ }
873
+
874
+ async function activateCurrentState(ctx: ExtensionContext, pending: AbortController): Promise<StateFlowTelegramControlResult> {
875
+ let selected = runtime;
876
+ const owner = ctx.sessionManager.getSessionId();
877
+ const cwd = ctx.cwd;
878
+ const file = ctx.sessionManager.getSessionFile();
879
+ const timestamp = ctx.sessionManager.getHeader()?.timestamp;
880
+ const initiallyEnabled = snapshot.config.enabled;
881
+ let accepted = false;
882
+ let receipt = AbortSignal.any([pending.signal, sharedInspectionLifetime.signal]);
883
+ const isCurrent = () => startActivation?.controller === pending && runtime === selected && !shuttingDown
884
+ && ctx.cwd === cwd && ctx.sessionManager.getSessionId() === owner && ctx.sessionManager.getSessionFile() === file
885
+ && ctx.sessionManager.getHeader()?.timestamp === timestamp && snapshot.config.enabled === (accepted || initiallyEnabled);
886
+ const superseded = (): StateFlowTelegramControlResult => {
887
+ pending.abort();
888
+ return { ok: false, message: "State Flow Start was superseded", signal: pending.signal };
889
+ };
890
+ try {
891
+ const activation = createRuntime(ctx);
892
+ const signal = ctx.signal ? AbortSignal.any([pending.signal, ctx.signal]) : pending.signal;
893
+ const result = await activation.withStartTransaction((current, publish) => {
894
+ signal.throwIfAborted();
895
+ if (!isCurrent()) throw new Error("State Flow Start selection changed while awaiting publication");
896
+ if (!current && !branchStartsWithoutRuntime) throw new Error(snapshot.meta.validation?.error ?? "Current State Flow session memory is unavailable");
897
+ const continuation = passiveContinuation ?? bootstrapContinuation;
898
+ const bootstrap = hasPriorConversation(ctx.sessionManager.getBranch()) || passiveContinuation !== undefined;
899
+ const activated = current ? resumeEpisode(current, bootstrap) : startEpisode(bootstrap);
900
+ const recoveredCurrent = selectedHistoryExpired;
901
+ const publication = publish(activated);
902
+ accepted = true;
903
+ cancelStopPersistence();
904
+ runtime = selected = activation;
905
+ snapshot = activated;
906
+ activeContext = ctx;
907
+ selectedHistoryExpired = false;
908
+ stopPersistenceError = undefined;
909
+ installScopeStates();
910
+ clearRunTransient();
911
+ passiveContinuation = undefined;
912
+ bootstrapContinuation = snapshot.meta.bootstrap ? continuation : undefined;
913
+ deferInferencePreparation();
914
+ receipt = AbortSignal.any([pending.signal, sharedInspectionLifetime.signal]);
915
+ recordPolicyPublication(publication, ctx);
916
+ syncStateFlowTools();
917
+ updateUi(ctx);
918
+ appendCheckpoint();
919
+ ctx.ui.notify(
920
+ recoveredCurrent
921
+ ? "State Flow enabled from current session memory; unavailable historical state was not restored."
922
+ : snapshot.meta.bootstrap
923
+ ? "State Flow enabled. The next complete agent run will migrate active context into state."
924
+ : "State Flow enabled. The next prompt starts a stateful agent run.",
925
+ "info",
926
+ );
927
+ return { ok: true, message: "State Flow enabled", signal: receipt };
928
+ }, signal, branchStartsWithoutRuntime);
929
+ return isCurrent() ? result : superseded();
930
+ } catch (error) {
931
+ if (!isCurrent()) return superseded();
932
+ const message = conciseDiagnostic(`${accepted ? "State Flow enabled; lifecycle update failed" : "State Flow Start failed"}: ${diagnosticText(error)}`);
933
+ notifyProblem(ctx, message, accepted ? "warning" : "error");
934
+ return { ok: accepted, message, signal: receipt };
657
935
  }
658
936
  }
659
937
 
660
938
  pi.registerCommand("state-flow-start", {
661
939
  description: "Start State Flow mode",
662
940
  handler: async (_args, ctx) => {
663
- startStateFlow(ctx);
941
+ await startStateFlow(ctx);
664
942
  },
665
943
  });
666
944
 
@@ -671,47 +949,121 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
671
949
  },
672
950
  });
673
951
 
674
- function stopStateFlow(ctx: ExtensionContext): StateFlowTelegramControlResult {
675
- if (!activeContext) restoreActiveBranch(ctx);
676
- assertSelectedBranchAvailable();
952
+ function stopStateFlow(ctx: ExtensionContext): Promise<StateFlowTelegramControlResult> {
953
+ cancelStartActivation();
954
+ if (shuttingDown) return Promise.resolve({ ok: false, message: "State Flow is shutting down" });
955
+ if (stopPersistence?.operation) return stopPersistence.operation;
956
+ if (!activeContext) void restoreActiveBranch(ctx, undefined, false);
957
+ const restoring = branchRestoration;
958
+ if (restoring?.awaitingAcceptance) {
959
+ // Stop changes policy without cancelling memory restoration or initialization.
960
+ restoring.passiveRequested = true;
961
+ snapshot = stopEpisode(snapshot);
962
+ clearRunTransient();
963
+ syncStateFlowTools();
964
+ updateUi(ctx);
965
+ return Promise.resolve({ ok: true, message: "State Flow disabled; memory restoration continues" });
966
+ }
967
+ const pending: NonNullable<typeof stopPersistence> = { controller: new AbortController() };
968
+ stopPersistence = pending;
969
+ const operation = persistStoppedState(ctx, pending.controller);
970
+ pending.operation = operation;
971
+ const finished = () => { if (stopPersistence === pending) stopPersistence = undefined; };
972
+ void operation.then(finished, finished);
973
+ return operation;
974
+ }
975
+
976
+ async function persistStoppedState(ctx: ExtensionContext, pending: AbortController): Promise<StateFlowTelegramControlResult> {
677
977
  telegramStartPending = false;
978
+ const selected = runtime;
979
+ const owner = ctx.sessionManager.getSessionId();
678
980
  const current = snapshot;
679
- const stoppedAt = Date.now();
680
- const stopped = stopEpisode(current);
681
- const publication = !branchStartsWithoutRuntime && runtime?.view ? runtime.publish(stopped) : undefined;
682
- installScopeStates();
683
- // Freeze the accepted shared adoption, not the pre-publication cache.
684
- const exitHandoff = current.config.enabled && runtime?.view
685
- ? createPassiveContinuation(
686
- projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
687
- stoppedAt,
688
- ctx.isIdle() ? undefined : runAnchorTimestamp,
689
- )
690
- : undefined;
691
- const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
692
- snapshot = stopped;
693
- recordPolicyPublication(publication, ctx);
694
- branchHasSnapshot = true;
981
+ let unfinished = current.meta.specification !== undefined
982
+ || (inferencePreparation?.prompt !== undefined && !inferencePreparation.accepted);
983
+ snapshot = stopEpisode(current);
695
984
  clearRunTransient();
696
- passiveContinuation = retainedHandoff;
697
- bootstrapContinuation = undefined;
698
- artifactInvalidations = [];
699
- artifactReads.setCandidates([]);
700
- artifactRefreshPending = false;
701
- if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, {
702
- at: stoppedAt,
703
- ...(exitHandoff.activeRunStartedAt === undefined ? {} : { from: exitHandoff.activeRunStartedAt }),
704
- });
705
985
  syncStateFlowTools();
706
- persist();
707
986
  updateUi(ctx);
708
- return { ok: true, message: "State Flow disabled" };
987
+ if (stopPersistenceError) return { ok: true, message: "State Flow disabled; memory writes remain paused" };
988
+ const stoppedAt = Date.now();
989
+ const incomingBoundary = current.meta.bootstrap ? bootstrapContinuation : undefined;
990
+ const anchor = runAnchorTimestamp;
991
+ const idle = ctx.isIdle();
992
+ const isCurrent = () => stopPersistence?.controller === pending && runtime === selected
993
+ && !shuttingDown && ctx.sessionManager.getSessionId() === owner && !snapshot.config.enabled;
994
+ const superseded = (): StateFlowTelegramControlResult => ({ ok: false, message: "State Flow Stop was superseded" });
995
+ const freezeHandoff = (): PassiveContinuation | undefined => {
996
+ const handoff = (current.config.enabled || stopPersistenceError) && selected?.view && current.meta.validation?.attempt !== 0
997
+ ? createPassiveContinuation(
998
+ projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
999
+ incomingBoundary?.startedAt ?? stoppedAt,
1000
+ incomingBoundary ? incomingBoundary.activeRunStartedAt : !idle || unfinished ? anchor : undefined,
1001
+ stopPersistenceError !== undefined || (incomingBoundary ? incomingBoundary.preserveContext : current.meta.bootstrap === true || (unfinished && anchor === undefined)),
1002
+ )
1003
+ : undefined;
1004
+ passiveContinuation = handoff ?? (!current.config.enabled ? passiveContinuation : undefined);
1005
+ return handoff;
1006
+ };
1007
+ const complete = (): StateFlowTelegramControlResult => {
1008
+ const exitHandoff = freezeHandoff();
1009
+ if (exitHandoff || stopPersistenceError) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, {
1010
+ at: exitHandoff?.startedAt ?? stoppedAt,
1011
+ ...(exitHandoff?.activeRunStartedAt === undefined ? {} : { from: exitHandoff.activeRunStartedAt }),
1012
+ ...(exitHandoff?.preserveContext || stopPersistenceError ? { preserveContext: true } : {}),
1013
+ ...(stopPersistenceError === undefined ? {} : { owner, persistenceError: stopPersistenceError }),
1014
+ });
1015
+ if (!stopPersistenceError) {
1016
+ appendCheckpoint();
1017
+ return { ok: true, message: "State Flow disabled" };
1018
+ }
1019
+ const message = conciseDiagnostic(`State Flow disabled; memory writes paused: ${stopPersistenceError}`);
1020
+ notifyProblem(ctx, message, "warning");
1021
+ return { ok: true, message };
1022
+ };
1023
+ let accepted = false;
1024
+ try {
1025
+ unfinished ||= current.config.enabled && hasUncheckpointedConversation(ctx.sessionManager.getBranch());
1026
+ // Projection changes before any wait; capture the old run's boundary before later native input can replace it.
1027
+ freezeHandoff();
1028
+ bootstrapContinuation = undefined;
1029
+ artifactInvalidations = [];
1030
+ artifactReads.setCandidates([]);
1031
+ assertPublicationAvailable();
1032
+ if (branchStartsWithoutRuntime || !selected?.view) return complete();
1033
+ const signal = ctx.signal ? AbortSignal.any([pending.signal, ctx.signal]) : pending.signal;
1034
+ const result = await selected.withLifecycleTransaction((publish) => {
1035
+ signal.throwIfAborted();
1036
+ if (!isCurrent()) throw new Error("State Flow Stop selection changed while awaiting publication");
1037
+ assertPublicationAvailable();
1038
+ const stopped = stopEpisode(snapshot);
1039
+ const publication = publish(stopped);
1040
+ accepted = true;
1041
+ snapshot = stopped;
1042
+ installScopeStates();
1043
+ recordPolicyPublication(publication, ctx);
1044
+ return complete();
1045
+ }, signal);
1046
+ return isCurrent() ? result : superseded();
1047
+ } catch (error) {
1048
+ if (!isCurrent()) return superseded();
1049
+ const cause = diagnosticText(error);
1050
+ if (accepted) {
1051
+ const message = conciseDiagnostic(`State Flow disabled; lifecycle update failed: ${cause}`);
1052
+ notifyProblem(ctx, message, "warning");
1053
+ return { ok: true, message };
1054
+ }
1055
+ stopPersistenceError = cause.trim() ? cause : "Canonical State Flow persistence failed";
1056
+ bootstrapContinuation = undefined;
1057
+ artifactInvalidations = [];
1058
+ artifactReads.setCandidates([]);
1059
+ return complete();
1060
+ }
709
1061
  }
710
1062
 
711
1063
  pi.registerCommand("state-flow-stop", {
712
1064
  description: "Stop State Flow on the current session branch",
713
1065
  handler: async (_args, ctx) => {
714
- stopStateFlow(ctx);
1066
+ await stopStateFlow(ctx);
715
1067
  },
716
1068
  });
717
1069
 
@@ -725,25 +1077,20 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
725
1077
  bootstrap: snapshot.meta.bootstrap === true,
726
1078
  startPending: telegramStartPending,
727
1079
  }),
728
- state: (scope) => {
729
- refreshTelegramStateView(scope);
730
- const selected = scope === "effective"
731
- ? overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)
732
- : scopeStates[scope];
733
- return { ...projectModelState(selected), lazy: structuredClone(selected.lazy) };
734
- },
735
- revisions: () => scopeRevisions(),
1080
+ inspect: inspectTelegramState,
736
1081
  canStartNow: () => activeContext === undefined || activeContext.isIdle(),
737
1082
  start: () => {
738
1083
  if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
739
1084
  return startStateFlow(activeContext);
740
1085
  },
741
- stop: () => {
1086
+ stop: async () => {
742
1087
  if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
1088
+ const operation = stopStateFlow(activeContext);
1089
+ const signal = sharedInspectionLifetime.signal;
743
1090
  try {
744
- return stopStateFlow(activeContext);
1091
+ return { ...await operation, signal };
745
1092
  } catch (error) {
746
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
1093
+ return { ok: false, message: diagnosticText(error), signal };
747
1094
  }
748
1095
  },
749
1096
  deferStart: () => {
@@ -751,12 +1098,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
751
1098
  },
752
1099
  cancelStart: () => {
753
1100
  telegramStartPending = false;
1101
+ cancelStartActivation();
754
1102
  },
755
1103
  },
756
1104
  });
757
1105
  void telegram.ensure();
758
1106
 
759
- pi.on("before_agent_start", (event, ctx) => {
1107
+ pi.on("before_agent_start", (event) => {
760
1108
  // Native run identity is independent of semantic enablement and survives mode toggles.
761
1109
  runAnchorTimestamp = undefined;
762
1110
  if (!snapshot.config.enabled) {
@@ -766,17 +1114,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
766
1114
  }
767
1115
  skillReads.clear();
768
1116
  artifactReads.clear();
769
- responseAwaitingReconciliation = false;
1117
+ cancelResponseReconciliation();
770
1118
  completedRunAccepted = false;
771
- const rotatesRun = snapshot.meta.specification !== undefined;
772
- if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
773
- if (prepareRun(snapshot, event.prompt)) persist();
774
- try {
775
- removeMissingRegisteredArtifacts(ctx);
776
- } catch (error) {
777
- ctx.ui.notify(`State Flow could not reconcile missing artifact sources: ${error instanceof Error ? error.message : String(error)}`, "warning");
778
- }
779
- artifactRefreshPending = false;
1119
+ // This native hook has no operation signal. Capture only; the first active context owns acceptance.
1120
+ deferInferencePreparation(event.prompt);
780
1121
  (event.systemPromptOptions.sections ??= {}).state_flow = stateFlowProtocol(snapshot.meta.bootstrap === true);
781
1122
  });
782
1123
 
@@ -786,16 +1127,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
786
1127
  return { messages: projectSystemProtocol(event.messages, protocol) };
787
1128
  });
788
1129
 
789
- pi.on("context", (event) => {
1130
+ function projectContext(messages: AgentMessage[]) {
790
1131
  if (runtime?.view) refreshArtifactHints();
791
1132
  if (passiveContinuation) {
792
- return { messages: passiveContinuationMessages(event.messages as AgentMessage[], passiveContinuation) };
1133
+ return { messages: passiveContinuationMessages(messages, passiveContinuation) };
793
1134
  }
794
1135
  if (!snapshot.config.enabled) {
795
1136
  if (!config.passiveBootstrap || !runtime?.view) return;
796
1137
  const effective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
797
1138
  const state = projectModelState(effective);
798
- return { messages: [syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state, lazy_navigation: lazyNavigationHint(effective) })}`), ...(event.messages as AgentMessage[])] };
1139
+ return { messages: [syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state, lazy_navigation: lazyNavigationHint(effective) })}`), ...messages] };
799
1140
  }
800
1141
  const effectiveState = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
801
1142
  const invalidations = artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason }));
@@ -806,13 +1147,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
806
1147
  const activeRehydrationPhase = currentRehydrationPhase();
807
1148
  if (snapshot.meta.bootstrap) {
808
1149
  const sourceMessages = bootstrapContinuation
809
- ? passiveContinuationMessages(event.messages as AgentMessage[], bootstrapContinuation)
810
- : event.messages as AgentMessage[];
1150
+ ? passiveContinuationMessages(messages, bootstrapContinuation)
1151
+ : messages;
811
1152
  return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, artifactHints), ...sourceMessages] };
812
1153
  }
813
1154
  // Native user events own the run anchor; projection must never rebase it.
814
1155
  const trajectory = currentRunTrajectory(
815
- event.messages as AgentMessage[],
1156
+ messages,
816
1157
  snapshot.meta.specification,
817
1158
  runAnchorTimestamp,
818
1159
  );
@@ -822,7 +1163,31 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
822
1163
  ...trajectory.messages,
823
1164
  ],
824
1165
  };
825
- });
1166
+ }
1167
+
1168
+ function prepareContext(messages: AgentMessage[], ctx: ExtensionContext): ReturnType<typeof projectContext> | Promise<ReturnType<typeof projectContext>> {
1169
+ const pending = inferencePreparation;
1170
+ const selected = runtime;
1171
+ const operationSignal = ctx.signal;
1172
+ // Idle projections remain observational; only a live native operation may await preparation.
1173
+ if (!snapshot.config.enabled || !pending || !operationSignal || shuttingDown) return projectContext(messages);
1174
+ pending.operation ??= prepareInference(pending, selected, ctx, operationSignal).finally(() => {
1175
+ // A late withdrawal must not clear newer work; accepted lifecycle is never replayed after an error.
1176
+ if (!pending.accepted && inferencePreparation === pending) pending.operation = undefined;
1177
+ });
1178
+ return pending.operation.then(() => {
1179
+ if (shuttingDown || operationSignal.aborted || runtime !== selected || ctx.signal !== operationSignal) return;
1180
+ if (snapshot.config.enabled && inferencePreparation !== pending) {
1181
+ // Start may require same-run maintenance. A new captured user prompt instead owns a different context request.
1182
+ if (inferencePreparation?.prompt === undefined) return prepareContext(messages, ctx);
1183
+ return;
1184
+ }
1185
+ if (snapshot.config.enabled && !pending.accepted) return;
1186
+ return projectContext(messages);
1187
+ });
1188
+ }
1189
+
1190
+ pi.on("context", (event, ctx) => prepareContext(event.messages as AgentMessage[], ctx));
826
1191
 
827
1192
  pi.on("tool_execution_start", (event) => {
828
1193
  if (!snapshot.config.enabled) return;
@@ -874,49 +1239,68 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
874
1239
  pi.on("message_end", (event, ctx): any => {
875
1240
  // Observe actual user events even while disabled; Start/Stop cannot invent or erase them.
876
1241
  if (event.message.role === "user" && runAnchorTimestamp === undefined) runAnchorTimestamp = event.message.timestamp;
877
- if (!snapshot.config.enabled) return;
1242
+ if (shuttingDown || !snapshot.config.enabled) return;
878
1243
  if (event.message.role !== "assistant") return;
1244
+ cancelResponseReconciliation();
879
1245
  const message = event.message as unknown as { role: "assistant"; stopReason?: string; content?: unknown };
880
- if (message.stopReason === "aborted" || assistantToolCallCount(message.content) > 0 || message.stopReason === "toolUse") {
881
- responseAwaitingReconciliation = false;
882
- return;
883
- }
1246
+ if (message.stopReason === "aborted" || assistantToolCallCount(message.content) > 0 || message.stopReason === "toolUse") return;
884
1247
  if (message.stopReason === "length" || message.stopReason === "error") {
885
- responseAwaitingReconciliation = false;
886
1248
  recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, { content: message.content });
887
1249
  return;
888
1250
  }
889
- responseAwaitingReconciliation = true;
1251
+ responseReconciliation = new AbortController();
890
1252
  });
891
1253
 
892
- pi.on("turn_end", (event, ctx) => {
893
- const wasBootstrap = snapshot.meta.bootstrap === true;
894
- if (!snapshot.config.enabled || !responseAwaitingReconciliation) {
1254
+ pi.on("turn_end", async (event, ctx) => {
1255
+ if (shuttingDown) return;
1256
+ const pending = responseReconciliation;
1257
+ if (!snapshot.config.enabled || !pending) {
895
1258
  updateUi(ctx);
896
1259
  return;
897
1260
  }
1261
+ const selected = runtime;
1262
+ const signal = ctx.signal ? AbortSignal.any([pending.signal, ctx.signal]) : pending.signal;
898
1263
  let responseCommitted = false;
899
- const specification = snapshot.meta.specification;
900
1264
  try {
901
1265
  const response = finalizedAssistantResponse(event.message);
902
- const stage = stageScopedTransition(scopeStates, { transitions: [], response }, [], runtime!.causalBasis());
903
- completeRun(snapshot);
904
- commitStage(stage, ctx, true);
905
- responseCommitted = true;
906
- bootstrapContinuation = undefined;
907
- rehydrationPhase = "step";
908
- completedRunAccepted = !wasBootstrap;
909
- turnAcceptedForBackup = true;
1266
+ if (!selected?.view) throw new Error("Temporal State Flow runtime is unavailable; reload before publishing");
1267
+ await selected.withPatchTransaction((transaction) => {
1268
+ signal.throwIfAborted();
1269
+ if (runtime !== selected || responseReconciliation !== pending || !snapshot.config.enabled) {
1270
+ throw new Error("State Flow response selection changed while awaiting publication");
1271
+ }
1272
+ assertPublicationAvailable();
1273
+ const wasBootstrap = snapshot.meta.bootstrap === true;
1274
+ const nextSnapshot = structuredClone(snapshot);
1275
+ const stage = stageScopedTransition(transaction.states, { transitions: [], response }, [], transaction.causalBasis);
1276
+ completeRun(nextSnapshot);
1277
+ let publication: RuntimePublication | undefined;
1278
+ commitScopedTransition(nextSnapshot, transaction.states, stage, (accepted, next) => {
1279
+ publication = transaction.publish(next, accepted);
1280
+ }, transaction.causalBasis, { finalizeRun: true });
1281
+ snapshot = nextSnapshot;
1282
+ installScopeStates();
1283
+ responseCommitted = true;
1284
+ if (publication?.changed) recordPublication(publication, ctx);
1285
+ appendCheckpoint();
1286
+ clearAcceptedAcquisitions();
1287
+ bootstrapContinuation = undefined;
1288
+ rehydrationPhase = "step";
1289
+ completedRunAccepted = !wasBootstrap;
1290
+ turnAcceptedForBackup = true;
1291
+ updateUi(ctx);
1292
+ }, signal);
910
1293
  } catch (error) {
911
- if (!responseCommitted && specification !== undefined) snapshot.meta.specification = specification;
912
- recordDiagnostic(error instanceof Error ? error.message : String(error), "finalization", ctx);
913
- ctx.ui.notify(responseCommitted
914
- ? `State Flow accepted the final response; a post-acceptance lifecycle update failed: ${error instanceof Error ? error.message : String(error)}`
915
- : `State Flow could not reconcile the final response: ${error instanceof Error ? error.message : String(error)}`, "error");
1294
+ // Superseded completion owns neither the new lifecycle nor its diagnostics/UI.
1295
+ if (signal.aborted || runtime !== selected || responseReconciliation !== pending) return;
1296
+ const cause = diagnosticText(error);
1297
+ recordDiagnostic(cause, "finalization", ctx);
1298
+ notifyProblem(ctx, responseCommitted
1299
+ ? `State Flow response saved; lifecycle update failed: ${cause}`
1300
+ : `State Flow response reconciliation failed: ${cause}`, "error");
916
1301
  } finally {
917
- responseAwaitingReconciliation = false;
1302
+ if (responseReconciliation === pending) responseReconciliation = undefined;
918
1303
  }
919
- updateUi(ctx);
920
1304
  });
921
1305
 
922
1306
  pi.on("session_before_compact", (event) => {
@@ -927,19 +1311,27 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
927
1311
  return { compaction: result };
928
1312
  });
929
1313
 
930
- pi.on("agent_before_settle", (_event, ctx) => {
931
- if (!turnAcceptedForBackup) return;
1314
+ pi.on("agent_before_settle", async (_event, ctx) => {
1315
+ if (shuttingDown || !turnAcceptedForBackup) return;
932
1316
  turnAcceptedForBackup = false;
933
1317
  if (!backupPending) return;
934
1318
  backupPending = false;
1319
+ const operationSignal = ctx.signal;
1320
+ const signal = operationSignal ? AbortSignal.any([backupLifetime.signal, operationSignal]) : backupLifetime.signal;
1321
+ const selected = runtime;
935
1322
  try {
936
1323
  if (existsSync(join(repositoryRoot, ".git"))) {
937
- backupCurrentStateFlowFiles(repositoryRoot);
938
1324
  const pushSessionId = sessionAddress(ctx).id;
939
1325
  const pushCwd = ctx.cwd;
1326
+ // Pi 0.87 settles after its agent signal ends; waiting there would strand native Abort.
1327
+ const operation = backupCurrentStateFlowFiles(repositoryRoot, signal, operationSignal !== undefined);
1328
+ backupOperations.add(operation);
1329
+ try { await operation; }
1330
+ finally { backupOperations.delete(operation); }
1331
+ if (signal.aborted) return;
940
1332
  startStateFlowBackupPush(repositoryRoot, (error) => {
941
1333
  if (shuttingDown) return;
942
- const message = error instanceof Error ? error.message : String(error);
1334
+ const message = diagnosticText(error);
943
1335
  const recorded = diagnosticWriter.recordBackupPushFailure(pushSessionId, pushCwd, message);
944
1336
  if (pushFailureNotified) return;
945
1337
  pushFailureNotified = true;
@@ -949,16 +1341,20 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
949
1341
  }, () => { pushFailureNotified = false; });
950
1342
  }
951
1343
  } catch (error) {
952
- const message = error instanceof Error ? error.message : String(error);
1344
+ if (signal.aborted || runtime !== selected) return;
1345
+ const message = diagnosticText(error);
953
1346
  recordDiagnostic(message, "publication-conflict", ctx);
954
- ctx.ui.notify(`State Flow accepted canonical state; Git backup failed: ${message}`, "warning");
1347
+ notifyProblem(ctx, error instanceof PublicationBusyError
1348
+ ? "State Flow state saved; Git backup deferred: Pi supplied no cancellable settlement wait. A later accepted turn retries."
1349
+ : `State Flow state saved; Git backup failed: ${message}`, "warning");
955
1350
  }
956
1351
  });
957
1352
 
958
1353
  pi.on("agent_settled", async (_event, ctx) => {
959
1354
  if (telegramStartPending && !snapshot.config.enabled) {
960
1355
  telegramStartPending = false;
961
- startStateFlow(ctx);
1356
+ await startStateFlow(ctx);
1357
+ return;
962
1358
  }
963
1359
  if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap
964
1360
  || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !runtime?.view
@@ -977,24 +1373,31 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
977
1373
  });
978
1374
  });
979
1375
 
980
- pi.on("session_start", (event, ctx) => {
1376
+ pi.on("session_start", async (event, ctx) => {
981
1377
  runAnchorTimestamp = undefined;
982
1378
  rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
983
- restoreActiveBranch(ctx, event.reason);
984
- updateUi(ctx);
985
- void telegram.ensure();
1379
+ await restoreActiveBranch(ctx, event.reason);
1380
+ if (!shuttingDown) void telegram.ensure();
986
1381
  });
987
- pi.on("session_tree", (_event, ctx) => {
1382
+ pi.on("session_tree", async (_event, ctx) => {
988
1383
  runAnchorTimestamp = undefined;
989
- restoreActiveBranch(ctx);
1384
+ await restoreActiveBranch(ctx);
990
1385
  });
991
1386
  pi.on("session_shutdown", async (_event, _ctx) => {
992
1387
  shuttingDown = true;
1388
+ const restoring = cancelBranchRestoration();
1389
+ const starting = cancelStartActivation();
1390
+ const stopping = cancelStopPersistence();
1391
+ backupLifetime.abort();
1392
+ cancelInferencePreparation();
1393
+ cancelResponseReconciliation();
1394
+ cancelSharedInspections();
993
1395
  telegramStartPending = false;
994
1396
  compactionStopped = true;
995
1397
  completedRunAccepted = false;
996
1398
  activeContext = undefined;
997
1399
  telegram.dispose();
1400
+ await Promise.allSettled([...backupOperations, ...restorationOperations, ...[restoring, stopping, starting].filter((operation) => operation !== undefined)]);
998
1401
  await awaitInFlightBackupPushes(repositoryRoot);
999
1402
  });
1000
1403
  }