@llblab/pi-kit 0.25.0 → 0.26.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 (104) hide show
  1. package/BACKLOG.md +5 -1
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +9 -7
  4. package/node_modules/@llblab/pi-actors/AGENTS.md +2 -0
  5. package/node_modules/@llblab/pi-actors/CHANGELOG.md +4 -1
  6. package/node_modules/@llblab/pi-actors/LICENSE +21 -0
  7. package/node_modules/@llblab/pi-actors/README.md +1 -1
  8. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +1 -1
  9. package/node_modules/@llblab/pi-actors/package.json +4 -3
  10. package/node_modules/@llblab/pi-claude-usage/AGENTS.md +6 -3
  11. package/node_modules/@llblab/pi-claude-usage/BACKLOG.md +2 -1
  12. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +8 -0
  13. package/node_modules/@llblab/pi-claude-usage/README.md +48 -3
  14. package/node_modules/@llblab/pi-claude-usage/index.ts +8 -1159
  15. package/node_modules/@llblab/pi-claude-usage/lib/extension.ts +30 -0
  16. package/node_modules/@llblab/pi-claude-usage/lib/fast.ts +24 -0
  17. package/node_modules/@llblab/pi-claude-usage/lib/query.ts +146 -0
  18. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +297 -0
  19. package/node_modules/@llblab/pi-claude-usage/lib/status.ts +366 -0
  20. package/node_modules/@llblab/pi-claude-usage/lib/telegram.ts +44 -0
  21. package/node_modules/@llblab/pi-claude-usage/lib/usage-store.ts +221 -0
  22. package/node_modules/@llblab/pi-claude-usage/lib/usage.ts +128 -0
  23. package/node_modules/@llblab/pi-claude-usage/package.json +9 -5
  24. package/node_modules/@llblab/pi-clean-room/AGENTS.md +1 -0
  25. package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +5 -0
  26. package/node_modules/@llblab/pi-clean-room/LICENSE +21 -0
  27. package/node_modules/@llblab/pi-clean-room/README.md +1 -1
  28. package/node_modules/@llblab/pi-clean-room/package.json +3 -2
  29. package/node_modules/@llblab/pi-codex-usage/AGENTS.md +9 -6
  30. package/node_modules/@llblab/pi-codex-usage/BACKLOG.md +2 -1
  31. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +17 -0
  32. package/node_modules/@llblab/pi-codex-usage/README.md +75 -17
  33. package/node_modules/@llblab/pi-codex-usage/index.ts +8 -1602
  34. package/node_modules/@llblab/pi-codex-usage/lib/extension.ts +25 -0
  35. package/node_modules/@llblab/pi-codex-usage/lib/fast.ts +23 -0
  36. package/node_modules/@llblab/pi-codex-usage/lib/query.ts +368 -0
  37. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +347 -0
  38. package/node_modules/@llblab/pi-codex-usage/lib/status.ts +435 -0
  39. package/node_modules/@llblab/pi-codex-usage/lib/telegram.ts +45 -0
  40. package/node_modules/@llblab/pi-codex-usage/lib/usage-store.ts +229 -0
  41. package/node_modules/@llblab/pi-codex-usage/lib/usage.ts +425 -0
  42. package/node_modules/@llblab/pi-codex-usage/package.json +11 -6
  43. package/node_modules/@llblab/pi-command-fast/AGENTS.md +7 -0
  44. package/node_modules/@llblab/pi-command-fast/BACKLOG.md +9 -0
  45. package/node_modules/@llblab/pi-command-fast/CHANGELOG.md +7 -0
  46. package/node_modules/@llblab/pi-command-fast/LICENSE +21 -0
  47. package/node_modules/@llblab/pi-command-fast/README.md +42 -0
  48. package/node_modules/@llblab/pi-command-fast/dist/command.d.ts +8 -0
  49. package/node_modules/@llblab/pi-command-fast/dist/command.js +52 -0
  50. package/node_modules/@llblab/pi-command-fast/dist/index.d.ts +3 -0
  51. package/node_modules/@llblab/pi-command-fast/dist/index.js +3 -0
  52. package/node_modules/@llblab/pi-command-fast/dist/models-json.d.ts +10 -0
  53. package/node_modules/@llblab/pi-command-fast/dist/models-json.js +81 -0
  54. package/node_modules/@llblab/pi-command-fast/package.json +49 -0
  55. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -0
  56. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -1
  57. package/node_modules/@llblab/pi-grow-loop/LICENSE +21 -0
  58. package/node_modules/@llblab/pi-grow-loop/README.md +1 -1
  59. package/node_modules/@llblab/pi-grow-loop/package.json +3 -2
  60. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -5
  61. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -2
  62. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +7 -1
  63. package/node_modules/@llblab/pi-state-flow/LICENSE +21 -0
  64. package/node_modules/@llblab/pi-state-flow/README.md +5 -5
  65. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +1 -1
  66. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +1 -1
  67. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +238 -46
  68. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -2
  69. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +16 -5
  70. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +10 -1
  71. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +60 -1
  72. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +6 -0
  73. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +8 -5
  74. package/node_modules/@llblab/pi-state-flow/dist/package.json +10 -9
  75. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -7
  76. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +2 -2
  77. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +4 -2
  78. package/node_modules/@llblab/pi-state-flow/docs/usage.md +14 -13
  79. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +2 -2
  80. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +221 -46
  81. package/node_modules/@llblab/pi-state-flow/lib/git.ts +14 -5
  82. package/node_modules/@llblab/pi-state-flow/lib/session.ts +52 -1
  83. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +7 -5
  84. package/node_modules/@llblab/pi-state-flow/package.json +10 -9
  85. package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
  86. package/node_modules/jsonc-parser/LICENSE.md +21 -0
  87. package/node_modules/jsonc-parser/README.md +364 -0
  88. package/node_modules/jsonc-parser/SECURITY.md +41 -0
  89. package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
  90. package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
  91. package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
  92. package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
  93. package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
  94. package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
  95. package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
  96. package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
  97. package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
  98. package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
  99. package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
  100. package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
  101. package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
  102. package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
  103. package/node_modules/jsonc-parser/package.json +37 -0
  104. package/package.json +7 -7
@@ -34,9 +34,9 @@ import { readProjectedState, readStatePath } from "./query.ts";
34
34
  import { selectedBoundaryFailure, selectRetainedCheckpoint, waitForRecovery } from "./recovery.ts";
35
35
  import type { RehydrationPhase } from "./rehydration.ts";
36
36
  import { TemporalRuntime, type RuntimePublication } from "./runtime.ts";
37
- import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, hasUncheckpointedConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
37
+ import { discoverSnapshotData, findAssistantToolBatch, findBranchPolicy, findPassiveStopBoundary, hasPendingFork, hasPriorConversation, hasUncheckpointedConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
38
38
  import { hasCompiledSkillArtifact, hashSkillSource, registeredSkillResolver, SkillReadTracker, type SuccessfulSkillRead } from "./skills.ts";
39
- import { HistoryBoundaryExpiredError, emptySnapshot, migrationFailure, preRuntimeCheckpoint, type InactiveMode, type RetainedBoundaryCheckpoint, type Snapshot, type StateFlowMode } from "./snapshot.ts";
39
+ import { HistoryBoundaryExpiredError, emptySnapshot, migrationFailure, preRuntimeCheckpoint, type InactiveMode, type RetainedBoundaryCheckpoint, type RetainedPiCheckpoint, type Snapshot, type StateFlowMode } from "./snapshot.ts";
40
40
  import { emptyState, projectStateForModel, type AtomicScopePatches, type SemanticState, type ModelState, type ScopedSemanticStates, type ScopedStates, type StateScope } from "./state.ts";
41
41
  import { compactStatus, detailedStatus, STATUS_KEY, type StatusDiagnostics } from "./status.ts";
42
42
  import { PublicationBusyError } from "./storage.ts";
@@ -97,15 +97,18 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
97
97
  let startActivation: { controller: AbortController; operation?: Promise<StateFlowTelegramControlResult> } | undefined;
98
98
  let forkInitialization = false;
99
99
  let branchRestoration: BranchRestoration | undefined;
100
+ let deferredBranch: { reason: unknown; owner: string; cwd: string } | undefined;
100
101
  const restorationOperations = new Set<Promise<void>>();
101
102
  let responseReconciliation: AbortController | undefined;
102
103
  let sharedInspectionLifetime = new AbortController();
104
+ let memoryToolLifetime = new AbortController();
103
105
  let completedRunAccepted = false;
104
106
  let turnAcceptedForBackup = false;
105
107
  let compactionPlan: StateFlowCompactionPlan | undefined;
106
108
  let compactionInFlight = false;
107
109
  let compactionStopped = false;
108
- const compactionMarker = `state-flow-boundary:${randomUUID()}`;
110
+ const compactionPrefix = `state-flow-boundary:${randomUUID()}:`;
111
+ let compactionMarker: string | undefined;
109
112
  let passiveContinuation: PassiveContinuation | undefined;
110
113
  let bootstrapContinuation: PassiveContinuation | undefined;
111
114
  const contextProjection = new ContextProjection();
@@ -117,7 +120,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
117
120
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
118
121
  const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
119
122
  let backupPending = false;
120
- const backupLifetime = new AbortController();
123
+ let backupLifetime = new AbortController();
121
124
  const backupOperations = new Set<Promise<string | undefined>>();
122
125
  let shuttingDown = false;
123
126
  let pushFailureNotified = false;
@@ -230,6 +233,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
230
233
  sharedInspectionLifetime = new AbortController();
231
234
  }
232
235
 
236
+ function cancelBackupWork(): void {
237
+ backupLifetime.abort();
238
+ backupLifetime = new AbortController();
239
+ backupPending = false;
240
+ }
241
+
233
242
  function cancelInferencePreparation(): void {
234
243
  inferencePreparation?.controller.abort();
235
244
  inferencePreparation = undefined;
@@ -319,17 +328,28 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
319
328
 
320
329
  async function inspectTelegramState(scope: StateFlowTelegramScope): Promise<StateFlowTelegramInspection> {
321
330
  if (!activeContext || shuttingDown) throw new Error("State Flow is not attached to an active session yet");
322
- if (scope === "session" || scope === "effective") assertSelectedBranchAvailable();
323
- const selected = runtime ??= createRuntime(activeContext);
331
+ const ctx = activeContext;
332
+ const owner = sessionAddress(ctx).key;
333
+ const cwd = ctx.cwd;
334
+ const off = snapshot.config.mode === "off";
335
+ const privateScope = scope === "session" || scope === "effective";
336
+ if (!off && privateScope) assertSelectedBranchAvailable();
337
+ // Off operator reads are disposable: they confer no selected-branch/cache authority.
338
+ const selected = off ? createRuntime(ctx) : runtime ??= createRuntime(ctx);
324
339
  const signal = sharedInspectionLifetime.signal;
325
- if (!selected.view || (scope !== "session" && !modePersistenceError)) await selected.refreshShared(signal);
340
+ let privateAvailable = true;
341
+ if (off && privateScope) privateAvailable = await selected.refreshCurrentMemory(signal) !== undefined;
342
+ else if (!selected.view || (scope !== "session" && !modePersistenceError)) await selected.refreshShared(signal);
326
343
  signal.throwIfAborted();
327
- if (runtime !== selected) throw new Error("State Flow inspection selection changed; select the scope again");
328
- if (scope === "session" || scope === "effective") assertSelectedBranchAvailable();
344
+ if (activeContext !== ctx || ctx.cwd !== cwd || sessionAddress(ctx).key !== owner || (!off && runtime !== selected)) {
345
+ throw new Error("State Flow inspection selection changed; select the scope again");
346
+ }
347
+ if (!privateAvailable) throw new Error("Current State Flow session memory is unavailable");
348
+ if (!off && privateScope) assertSelectedBranchAvailable();
329
349
  if (!selected.view) throw new Error("State Flow temporal runtime is unavailable");
330
- installScopeStates();
331
- const state = scope === "effective" ? effectiveState : selected.readView(0, scope);
332
- return { state: { ...projectModelState(state), ...(state.lazy === undefined ? {} : { lazy: structuredClone(state.lazy) }) }, revisions: scopeRevisions(), signal };
350
+ if (!off) installScopeStates();
351
+ const state = selected.readView(0, scope === "effective" ? undefined : scope);
352
+ return { state: { ...projectModelState(state), ...(state.lazy === undefined ? {} : { lazy: structuredClone(state.lazy) }) }, revisions: temporalScopeRevisions(selected.view), signal };
333
353
  }
334
354
 
335
355
  function syncStateFlowTools(): void {
@@ -473,8 +493,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
473
493
  }
474
494
 
475
495
  /** Select the active native branch under one owned restoration lifetime; only current accepted work installs memory. */
476
- function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown, notifyRecovery = true, startOwner?: AbortController): Promise<void> {
496
+ function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown, notifyRecovery = true, startOwner?: AbortController, requestedMode?: InactiveMode): Promise<void> {
477
497
  contextProjection.reset();
498
+ memoryToolLifetime.abort();
499
+ memoryToolLifetime = new AbortController();
478
500
  cancelBranchRestoration();
479
501
  // Start-owned attachment/fork recovery keeps its owner; only accepted Start cancels Stop.
480
502
  if (!startOwner) {
@@ -488,6 +510,51 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
488
510
  artifactReads.setCandidates([]);
489
511
  telegramStartPending = false;
490
512
  activeContext = ctx;
513
+ if (!startOwner) {
514
+ let mode: StateFlowMode = config.inactiveMode;
515
+ let adoptDefault = false;
516
+ let withoutRuntime = false;
517
+ let persistenceError: string | undefined;
518
+ let pendingFork = false;
519
+ try {
520
+ const branch = ctx.sessionManager.getBranch();
521
+ const discovery = discoverSnapshotData(branch);
522
+ withoutRuntime = discovery.candidates.length === 0 && discovery.errors.length === 0;
523
+ adoptDefault = withoutRuntime && isNewSession(sessionStartReason, branch);
524
+ pendingFork = hasPendingFork(branch, ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
525
+ const inherited = sessionStartReason === "fork" || pendingFork;
526
+ const policy = findBranchPolicy(branch, inherited ? undefined : ctx.sessionManager.getSessionId(),
527
+ inherited || retainsPhysicalSessionProjection(sessionStartReason) ? PASSIVE_STOP_ENTRY_TYPE : undefined, config.inactiveMode);
528
+ mode = policy?.mode ?? (adoptDefault ? config.mode : config.inactiveMode);
529
+ persistenceError = inherited
530
+ ? findBranchPolicy(branch, ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE, config.inactiveMode)?.persistenceError
531
+ : policy?.persistenceError;
532
+ } catch {
533
+ // Off needs only policy; unavailable native evidence grants no memory authority.
534
+ }
535
+ if (mode === "off") {
536
+ cancelBackupWork();
537
+ const owner = sessionAddress(ctx).key;
538
+ const reason = pendingFork || (deferredBranch?.owner === owner && deferredBranch.cwd === ctx.cwd && deferredBranch.reason === "fork"
539
+ && retainsPhysicalSessionProjection(sessionStartReason)) ? "fork" : sessionStartReason;
540
+ if (reason === "fork" && !pendingFork) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { owner: ctx.sessionManager.getSessionId(), forkPending: true });
541
+ deferredBranch = { reason, owner, cwd: ctx.cwd };
542
+ runtime = undefined;
543
+ snapshot = emptySnapshot("off");
544
+ branchStartsWithoutRuntime = withoutRuntime;
545
+ selectedHistoryExpired = false;
546
+ modePersistenceError = persistenceError;
547
+ forkInitialization = reason === "fork";
548
+ installScopeStates();
549
+ artifactHints = {};
550
+ if (adoptDefault) appendCheckpoint();
551
+ syncStateFlowTools();
552
+ updateUi(ctx);
553
+ return Promise.resolve();
554
+ }
555
+ if (pendingFork) sessionStartReason = "fork";
556
+ }
557
+ deferredBranch = undefined;
491
558
  runtime = createRuntime(ctx);
492
559
  installScopeStates();
493
560
  branchStartsWithoutRuntime = false;
@@ -548,9 +615,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
548
615
  snapshot = migrationFailure({}, "State Flow branch restoration is pending", selection.kind === "current" ? selection.mode
549
616
  : selection.kind === "auto-start" ? config.inactiveMode : inactiveSelection(selection.checkpoint.mode));
550
617
  }
618
+ if (requestedMode) snapshot = deactivateEpisode(snapshot, requestedMode);
551
619
  const pending: BranchRestoration = {
552
620
  controller: new AbortController(),
553
621
  awaitingAcceptance: selection.kind !== "settled" && selection.kind !== "current",
622
+ ...(requestedMode === undefined ? {} : { requestedMode }),
554
623
  };
555
624
  branchRestoration = pending;
556
625
  const operation = attachBranch(ctx, pending, selection, sessionStartReason, notifyRecovery, skipped);
@@ -687,25 +756,24 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
687
756
  return passive;
688
757
  }
689
758
 
759
+ function selectedContinuation(ctx: ExtensionContext, reason: unknown): PassiveContinuation | undefined {
760
+ if (!retainsPhysicalSessionProjection(reason) || !runtime?.view) return undefined;
761
+ const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
762
+ return boundary === undefined ? undefined : createPassiveContinuation(
763
+ projectModelState(effectiveState), boundary.at, boundary.from, boundary.preserveContext,
764
+ );
765
+ }
766
+
690
767
  function settleSelection(ctx: ExtensionContext, reason: unknown, notifyRecovery: boolean, skipped: number, selectedMemory: boolean): void {
691
768
  const failure = !isActive() && snapshot.meta.validation?.attempt === 0 ? snapshot.meta.validation.error : undefined;
692
769
  if (failure !== undefined && notifyRecovery && !modePersistenceError) {
693
770
  if (selectedHistoryExpired) notifyProblem(ctx, "State Flow history is outside the retained temporal window; /state-flow-active can use current session memory.", "warning");
694
771
  else notifyProblem(ctx, `State Flow restore failed: ${failure}`, "error");
695
772
  }
696
- if (selectedMemory && retainsPhysicalSessionProjection(reason) && runtime?.view) {
697
- const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
698
- if (boundary !== undefined) {
699
- const continuation = createPassiveContinuation(
700
- projectModelState(effectiveState),
701
- boundary.at,
702
- boundary.from,
703
- boundary.preserveContext,
704
- );
705
- // Off retains the handoff boundary for a later Passive or Active choice but never projects it.
706
- if (!isActive()) passiveContinuation = continuation;
707
- else if (snapshot.meta.bootstrap) bootstrapContinuation = continuation;
708
- }
773
+ const continuation = selectedMemory ? selectedContinuation(ctx, reason) : undefined;
774
+ if (continuation !== undefined) {
775
+ if (!isActive()) passiveContinuation = continuation;
776
+ else if (snapshot.meta.bootstrap) bootstrapContinuation = continuation;
709
777
  }
710
778
  if (notifyRecovery && isActive() && skipped > 0) {
711
779
  notifyProblem(ctx, `State Flow skipped ${skipped} malformed snapshot(s); restored the last valid one.`, "warning");
@@ -784,6 +852,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
784
852
  return new Text(separatedOutput(theme.fg("dim", formatPatchStateArguments(context.args))), 0, 0);
785
853
  },
786
854
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
855
+ signal = signal ? AbortSignal.any([signal, memoryToolLifetime.signal]) : memoryToolLifetime.signal;
787
856
  try {
788
857
  if (!memoryToolsAvailable()) throw new Error("State Flow tools are off for this session");
789
858
  assertPublicationAvailable();
@@ -850,7 +919,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
850
919
  let attempted: unknown;
851
920
  try { attempted = structuredClone(params); } catch { attempted = undefined; }
852
921
  const cause = diagnosticText(error);
853
- recordDiagnostic(cause, /concurrently|advanced/.test(cause) ? "publication-conflict" : "invalid-patch", ctx, {
922
+ if (!signal.aborted && memoryToolsAvailable()) recordDiagnostic(cause, /concurrently|advanced/.test(cause) ? "publication-conflict" : "invalid-patch", ctx, {
854
923
  input: attempted,
855
924
  tool: PATCH_STATE_TOOL_NAME,
856
925
  toolCallId,
@@ -891,7 +960,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
891
960
  return { ok: false, message: "State Flow activation was superseded", signal: pending.signal };
892
961
  };
893
962
  try {
894
- if (!activeContext) await waitForRecovery(restoreActiveBranch(ctx, undefined, false, pending), signal);
963
+ if (deferredBranch?.reason === "fork") await waitForRecovery(restoreActiveBranch(ctx, "fork", false, pending), signal);
964
+ else if (!activeContext) await waitForRecovery(restoreActiveBranch(ctx, undefined, false, pending), signal);
895
965
  else if (branchRestoration?.operation) await waitForRecovery(branchRestoration.operation, signal);
896
966
  if (!isOwner()) return superseded();
897
967
  if (isActive()) return { ok: true, message: "State Flow is already active", signal: sharedInspectionLifetime.signal };
@@ -922,7 +992,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
922
992
  const initiallyActive = isActive();
923
993
  let accepted = false;
924
994
  let receipt = AbortSignal.any([pending.signal, sharedInspectionLifetime.signal]);
925
- const isCurrent = () => startActivation?.controller === pending && runtime === selected && !shuttingDown
995
+ const signal = ctx.signal ? AbortSignal.any([pending.signal, ctx.signal]) : pending.signal;
996
+ const isCurrent = () => startActivation?.controller === pending && !signal.aborted && runtime === selected && !shuttingDown
926
997
  && ctx.cwd === cwd && ctx.sessionManager.getSessionId() === owner && ctx.sessionManager.getSessionFile() === file
927
998
  && ctx.sessionManager.getHeader()?.timestamp === timestamp && isActive() === (accepted || initiallyActive);
928
999
  const superseded = (): StateFlowTelegramControlResult => {
@@ -931,13 +1002,20 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
931
1002
  };
932
1003
  try {
933
1004
  const activation = createRuntime(ctx);
934
- const signal = ctx.signal ? AbortSignal.any([pending.signal, ctx.signal]) : pending.signal;
1005
+ const deferred = deferredBranch;
1006
+ if (deferred) {
1007
+ const discovery = discoverSnapshotData(ctx.sessionManager.getBranch());
1008
+ const retained = selectRetainedCheckpoint(discovery.candidates, config.inactiveMode);
1009
+ branchStartsWithoutRuntime = retained.kind === "pre-runtime" || (discovery.candidates.length === 0 && discovery.errors.length === 0);
1010
+ }
1011
+ // Off activation owns one cancellable current-head publication, not an independent read-only restore.
935
1012
  const result = await activation.withStartTransaction((current, publish) => {
936
1013
  signal.throwIfAborted();
937
1014
  if (!isCurrent()) throw new Error("State Flow activation selection changed while awaiting publication");
938
1015
  if (!current && !branchStartsWithoutRuntime) throw new Error(snapshot.meta.validation?.error ?? "Current State Flow session memory is unavailable");
939
- const continuation = passiveContinuation ?? bootstrapContinuation;
940
- const bootstrap = hasPriorConversation(ctx.sessionManager.getBranch()) || passiveContinuation !== undefined;
1016
+ const boundary = deferred && retainsPhysicalSessionProjection(deferred.reason)
1017
+ ? findPassiveStopBoundary(ctx.sessionManager.getBranch(), owner, PASSIVE_STOP_ENTRY_TYPE) : undefined;
1018
+ const bootstrap = hasPriorConversation(ctx.sessionManager.getBranch()) || passiveContinuation !== undefined || boundary !== undefined;
941
1019
  const activated = current ? resumeEpisode(current, bootstrap) : startEpisode(bootstrap);
942
1020
  const recoveredCurrent = selectedHistoryExpired;
943
1021
  const publication = publish(activated);
@@ -949,6 +1027,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
949
1027
  selectedHistoryExpired = false;
950
1028
  modePersistenceError = undefined;
951
1029
  installScopeStates();
1030
+ const continuation = passiveContinuation ?? bootstrapContinuation ?? (deferred ? selectedContinuation(ctx, deferred.reason) : undefined);
1031
+ deferredBranch = undefined;
952
1032
  clearRunTransient();
953
1033
  contextProjection.reset();
954
1034
  passiveContinuation = undefined;
@@ -958,6 +1038,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
958
1038
  recordPolicyPublication(publication, ctx);
959
1039
  syncStateFlowTools();
960
1040
  updateUi(ctx);
1041
+ if (forkInitialization) {
1042
+ forkInitialization = false;
1043
+ pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { reset: true, owner });
1044
+ }
961
1045
  appendCheckpoint();
962
1046
  ctx.ui.notify(
963
1047
  recoveredCurrent
@@ -1014,8 +1098,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1014
1098
  }
1015
1099
 
1016
1100
  function deactivateStateFlow(ctx: ExtensionContext, mode: InactiveMode): Promise<StateFlowTelegramControlResult> {
1017
- cancelStartActivation();
1018
1101
  if (shuttingDown) return Promise.resolve({ ok: false, message: "State Flow is shutting down" });
1102
+ if (mode === "off") return selectOff(ctx);
1103
+ cancelStartActivation();
1019
1104
  telegramStartPending = false;
1020
1105
  const persisting = inactivePersistence;
1021
1106
  if (persisting?.operation && !persisting.published) {
@@ -1028,7 +1113,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1028
1113
  // Read-only recovery also retains the latest policy; its native write fence is updated below.
1029
1114
  if (restoring) restoring.requestedMode = mode;
1030
1115
  if (restoring?.awaitingAcceptance) {
1031
- // A mode change never cancels memory restoration or initialization.
1116
+ // Passive keeps independently owned restoration; Off withdraws it through selectOff.
1032
1117
  applyInactiveMode(ctx, mode);
1033
1118
  return Promise.resolve({ ok: true, message: `State Flow ${mode}; memory restoration continues` });
1034
1119
  }
@@ -1040,13 +1125,96 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1040
1125
  cancelInactivePersistence();
1041
1126
  const pending: NonNullable<typeof inactivePersistence> = { controller: new AbortController() };
1042
1127
  inactivePersistence = pending;
1043
- const operation = persistInactiveMode(ctx, pending, mode);
1128
+ const operation = deferredBranch && mode === "passive" && !branchStartsWithoutRuntime
1129
+ ? acquirePassiveMode(ctx, pending, deferredBranch.reason)
1130
+ : persistInactiveMode(ctx, pending, mode);
1044
1131
  pending.operation = operation;
1045
1132
  const finished = () => { if (inactivePersistence === pending) inactivePersistence = undefined; };
1046
1133
  void operation.then(finished, finished);
1047
1134
  return operation;
1048
1135
  }
1049
1136
 
1137
+ /** Off owns cancellation and native policy only; no canonical transaction or semantic handoff. */
1138
+ function selectOff(ctx: ExtensionContext): Promise<StateFlowTelegramControlResult> {
1139
+ const address = sessionAddress(ctx);
1140
+ const owner = ctx.sessionManager.getSessionId();
1141
+ const branch = ctx.sessionManager.getBranch();
1142
+ const selected = runtime;
1143
+ const sameOwner = selected?.sessionKey === address.key && selected.cwd === ctx.cwd;
1144
+ const forkRecorded = hasPendingFork(branch, owner, PASSIVE_STOP_ENTRY_TYPE);
1145
+ const pendingFork = forkRecorded || (sameOwner && forkInitialization) || (deferredBranch?.owner === address.key && deferredBranch.cwd === ctx.cwd && deferredBranch.reason === "fork");
1146
+ const policy = findBranchPolicy(branch, owner, PASSIVE_STOP_ENTRY_TYPE, config.inactiveMode);
1147
+ const sameDeferred = deferredBranch?.owner === address.key && deferredBranch.cwd === ctx.cwd;
1148
+ const current = sameOwner || sameDeferred ? snapshot : emptySnapshot("off");
1149
+ if (!sameOwner && !sameDeferred) modePersistenceError = policy?.persistenceError;
1150
+ const alreadyOff = current.config.mode === "off" && policy?.mode === "off";
1151
+ const incoming = sameOwner || sameDeferred ? current.meta.bootstrap ? bootstrapContinuation : passiveContinuation : undefined;
1152
+ const unfinished = current.meta.specification !== undefined || (inferencePreparation?.prompt !== undefined && !inferencePreparation.accepted)
1153
+ || (isActive() && hasUncheckpointedConversation(branch));
1154
+ const at = incoming?.startedAt ?? Date.now();
1155
+ const from = incoming ? incoming.activeRunStartedAt : (sameOwner || sameDeferred) && (!ctx.isIdle() || unfinished) ? runAnchorTimestamp : undefined;
1156
+ const preserveContext = modePersistenceError !== undefined || (incoming ? incoming.preserveContext : current.meta.bootstrap === true || (unfinished && from === undefined));
1157
+ const discovery = discoverSnapshotData(branch);
1158
+ const preRuntime = (sameOwner && branchStartsWithoutRuntime) || (discovery.candidates.length === 0 && discovery.errors.length === 0);
1159
+ let checkpoint: RetainedPiCheckpoint | undefined;
1160
+ try {
1161
+ checkpoint = !modePersistenceError && current.meta.validation?.attempt !== 0
1162
+ ? preRuntime ? preRuntimeCheckpoint("off") : sameOwner && selected?.view ? selected.retainedCheckpoint(deactivateEpisode(current, "off")) : undefined
1163
+ : undefined;
1164
+ } catch {
1165
+ // An unusable cached bookmark cannot prevent native Off policy or authorize replacement memory.
1166
+ }
1167
+ cancelStartActivation();
1168
+ cancelBranchRestoration();
1169
+ cancelInactivePersistence();
1170
+ cancelBackupWork();
1171
+ memoryToolLifetime.abort();
1172
+ memoryToolLifetime = new AbortController();
1173
+ clearRunTransient();
1174
+ contextProjection.reset();
1175
+ telegramStartPending = false;
1176
+ bootstrapContinuation = undefined;
1177
+ passiveContinuation = undefined;
1178
+ artifactInvalidations = [];
1179
+ artifactHints = {};
1180
+ artifactReads.setCandidates([]);
1181
+ runtime = undefined;
1182
+ snapshot = emptySnapshot("off");
1183
+ selectedHistoryExpired = false;
1184
+ branchStartsWithoutRuntime = preRuntime;
1185
+ forkInitialization = pendingFork;
1186
+ deferredBranch = { owner: address.key, cwd: ctx.cwd, reason: pendingFork ? "fork" : undefined };
1187
+ activeContext = ctx;
1188
+ installScopeStates();
1189
+ syncStateFlowTools();
1190
+ updateUi(ctx);
1191
+ try {
1192
+ if (!alreadyOff || (pendingFork && !forkRecorded)) {
1193
+ if (!checkpoint || !preRuntime || pendingFork || preserveContext || from !== undefined || modePersistenceError) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, {
1194
+ owner, at, mode: "off", memoryDeferred: true,
1195
+ ...(from === undefined ? {} : { from }),
1196
+ ...(preserveContext ? { preserveContext: true } : {}),
1197
+ ...(pendingFork ? { forkPending: true } : {}),
1198
+ ...(modePersistenceError === undefined ? {} : { persistenceError: modePersistenceError }),
1199
+ });
1200
+ if (checkpoint) pi.appendEntry(SNAPSHOT_ENTRY_TYPE, checkpoint);
1201
+ }
1202
+ return Promise.resolve({ ok: true, message: alreadyOff ? "State Flow is already off" : "State Flow off" });
1203
+ } catch (error) {
1204
+ const message = conciseDiagnostic(`State Flow off; native mode recording failed: ${diagnosticText(error)}`);
1205
+ notifyProblem(ctx, message, "error");
1206
+ return Promise.resolve({ ok: false, message });
1207
+ }
1208
+ }
1209
+
1210
+ async function acquirePassiveMode(ctx: ExtensionContext, pending: NonNullable<typeof inactivePersistence>, reason: unknown): Promise<StateFlowTelegramControlResult> {
1211
+ await restoreActiveBranch(ctx, reason, true, pending.controller, "passive");
1212
+ if (inactivePersistence !== pending || pending.controller.signal.aborted || shuttingDown) {
1213
+ return { ok: false, message: "State Flow mode change was superseded" };
1214
+ }
1215
+ return persistInactiveMode(ctx, pending, "passive");
1216
+ }
1217
+
1050
1218
  async function persistInactiveMode(ctx: ExtensionContext, pending: NonNullable<typeof inactivePersistence>, mode: InactiveMode): Promise<StateFlowTelegramControlResult> {
1051
1219
  let selected = runtime;
1052
1220
  const owner = ctx.sessionManager.getSessionId();
@@ -1082,7 +1250,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1082
1250
  modePersistenceError !== undefined || (incomingBoundary ? incomingBoundary.preserveContext : current.meta.bootstrap === true || (unfinished && anchor === undefined)),
1083
1251
  )
1084
1252
  : undefined;
1085
- // Off keeps the frozen boundary for a later Passive/Active choice but never projects it.
1253
+ // Repeated Passive choices keep the already selected continuation boundary.
1086
1254
  passiveContinuation = handoff ?? (!wasActive ? passiveContinuation : undefined);
1087
1255
  return handoff;
1088
1256
  };
@@ -1383,20 +1551,22 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1383
1551
  });
1384
1552
 
1385
1553
  pi.on("session_before_compact", (event) => {
1386
- if (!compactionPlan) return;
1387
- if (compactionStopped && event.reason === "manual" && event.customInstructions === compactionMarker) return { cancel: true };
1388
- const result = stateFlowCompactionResult(compactionPlan, compactionMarker, event);
1554
+ if (event.reason !== "manual" || !event.customInstructions?.startsWith("state-flow-boundary:")) return;
1555
+ const marker = compactionMarker;
1556
+ if (shuttingDown || compactionStopped || !isActive() || !compactionPlan || !marker || event.customInstructions !== marker) return { cancel: true };
1557
+ const result = stateFlowCompactionResult(compactionPlan, marker, event);
1389
1558
  if (result === undefined || "cancel" in result) return result;
1390
1559
  return { compaction: result };
1391
1560
  });
1392
1561
 
1393
1562
  pi.on("agent_before_settle", async (_event, ctx) => {
1394
- if (shuttingDown || !turnAcceptedForBackup) return;
1563
+ if (shuttingDown || !memoryToolsAvailable() || !turnAcceptedForBackup) return;
1395
1564
  turnAcceptedForBackup = false;
1396
1565
  if (!backupPending) return;
1397
1566
  backupPending = false;
1398
1567
  const operationSignal = ctx.signal;
1399
- const signal = operationSignal ? AbortSignal.any([backupLifetime.signal, operationSignal]) : backupLifetime.signal;
1568
+ const lifetime = backupLifetime.signal;
1569
+ const signal = operationSignal ? AbortSignal.any([lifetime, operationSignal]) : lifetime;
1400
1570
  const selected = runtime;
1401
1571
  try {
1402
1572
  if (existsSync(join(repositoryRoot, ".git"))) {
@@ -1407,9 +1577,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1407
1577
  backupOperations.add(operation);
1408
1578
  try { await operation; }
1409
1579
  finally { backupOperations.delete(operation); }
1410
- if (signal.aborted) return;
1580
+ if (signal.aborted || snapshot.config.mode === "off") return;
1411
1581
  startStateFlowBackupPush(repositoryRoot, (error) => {
1412
- if (shuttingDown) return;
1582
+ if (shuttingDown || lifetime.aborted || snapshot.config.mode === "off") return;
1413
1583
  const message = diagnosticText(error);
1414
1584
  const recorded = diagnosticWriter.recordBackupPushFailure(pushSessionId, pushCwd, message);
1415
1585
  if (pushFailureNotified) return;
@@ -1417,7 +1587,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1417
1587
  notifyActiveContext(recorded
1418
1588
  ? `State Flow Git backup push failed; state is saved locally. Details: ${stateFlowLogPath(agentDir)}. A later accepted turn retries.`
1419
1589
  : `State Flow Git backup push failed; local diagnostics unavailable: ${message}`);
1420
- }, () => { pushFailureNotified = false; });
1590
+ }, () => { pushFailureNotified = false; }, lifetime);
1421
1591
  }
1422
1592
  } catch (error) {
1423
1593
  if (signal.aborted || runtime !== selected) return;
@@ -1444,11 +1614,15 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1444
1614
  const plan = planStateFlowCompaction(entries, runtime.causalBasis(), snapshot.meta.step, runAnchorTimestamp);
1445
1615
  if (!plan) return;
1446
1616
  compactionPlan = plan;
1617
+ const marker = compactionMarker = `${compactionPrefix}${randomUUID()}`;
1447
1618
  compactionInFlight = true;
1448
1619
  // Pi dispatches deferred companion prompts after all settled handlers return.
1449
1620
  await new Promise<void>((resolve) => {
1450
- const finished = () => { compactionPlan = undefined; compactionInFlight = false; resolve(); };
1451
- ctx.compact({ customInstructions: compactionMarker, onComplete: finished, onError: finished });
1621
+ const finished = () => {
1622
+ if (compactionMarker === marker) { compactionPlan = undefined; compactionInFlight = false; }
1623
+ resolve();
1624
+ };
1625
+ ctx.compact({ customInstructions: marker, onComplete: finished, onError: finished });
1452
1626
  });
1453
1627
  });
1454
1628
 
@@ -1466,6 +1640,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1466
1640
  });
1467
1641
  pi.on("session_shutdown", async (_event, _ctx) => {
1468
1642
  shuttingDown = true;
1643
+ memoryToolLifetime.abort();
1469
1644
  contextProjection.reset();
1470
1645
  const restoring = cancelBranchRestoration();
1471
1646
  const starting = cancelStartActivation();
@@ -206,14 +206,15 @@ export function backupCurrentStateFlowFiles(repositoryRoot: string, signal?: Abo
206
206
  }
207
207
 
208
208
  /** Skip overlapping pushes; the next accepted turn can push the latest HEAD. */
209
- export function startStateFlowBackupPush(repositoryRoot: string, onFailure: (error: unknown) => void, onSuccess?: () => void): boolean {
209
+ export function startStateFlowBackupPush(repositoryRoot: string, onFailure: (error: unknown) => void, onSuccess?: () => void, signal?: AbortSignal): boolean {
210
210
  const root = resolve(repositoryRoot);
211
- if (activePushes.has(root)) return false;
212
- const push = pushCurrentStateFlowBackup(root).then(
211
+ if (signal?.aborted || activePushes.has(root)) return false;
212
+ const push = pushCurrentStateFlowBackup(root, signal).then(
213
213
  (result) => {
214
- if (result) { try { onSuccess?.(); } catch { /* Reporting cannot change push acceptance. */ } }
214
+ if (result && !signal?.aborted) { try { onSuccess?.(); } catch { /* Reporting cannot change push acceptance. */ } }
215
215
  },
216
216
  (error) => {
217
+ if (signal?.aborted) return;
217
218
  try { onFailure(error); } catch { /* Reporting cannot revive a failed push. */ }
218
219
  },
219
220
  ).finally(() => { activePushes.delete(root); });
@@ -228,15 +229,17 @@ export async function awaitInFlightBackupPushes(repositoryRoot: string): Promise
228
229
  }
229
230
 
230
231
  /** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
231
- export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ commit: string; remote: string; ref: string } | undefined> {
232
+ export function pushCurrentStateFlowBackup(repositoryRoot: string, signal?: AbortSignal): Promise<{ commit: string; remote: string; ref: string } | undefined> {
232
233
  return new Promise((resolvePush, rejectPush) => {
233
234
  let root: string;
234
235
  let commit: string | undefined;
235
236
  let destination: { remote: string; ref: string } | undefined;
236
237
  try {
238
+ signal?.throwIfAborted();
237
239
  root = assertRepositoryRoot(repositoryRoot);
238
240
  commit = currentHead(root);
239
241
  destination = configuredPushDestination(root);
242
+ signal?.throwIfAborted();
240
243
  } catch (error) {
241
244
  rejectPush(new Error(redactGitDiagnostic(error instanceof Error ? error.message : String(error))));
242
245
  return;
@@ -273,6 +276,7 @@ export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ co
273
276
  if (settled) return;
274
277
  settled = true;
275
278
  clearTimeout(timeout);
279
+ signal?.removeEventListener("abort", cancelled);
276
280
  if (error) rejectPush(error);
277
281
  else resolvePush({ commit: commit!, ...destination! });
278
282
  }
@@ -284,5 +288,10 @@ export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ co
284
288
  child.once("exit", () => { if (failure) child.stderr?.destroy(); });
285
289
  child.once("close", (code, endedBy) => finish(failure ?? (code === 0 ? undefined
286
290
  : new Error(`Git backup push failed (${endedBy ?? code}): ${redactGitDiagnostic(stderr.trim()) || "no diagnostic output"}`))));
291
+ function cancelled(): void {
292
+ terminate(signal?.reason instanceof Error ? signal.reason : new Error("Git backup push cancelled"));
293
+ }
294
+ signal?.addEventListener("abort", cancelled, { once: true });
295
+ if (signal?.aborted) cancelled();
287
296
  });
288
297
  }
@@ -1,4 +1,4 @@
1
- import { parseRetainedPiCheckpoint, type InactiveMode } from "./snapshot.ts";
1
+ import { parseRetainedPiCheckpoint, readCheckpointMode, type InactiveMode, type StateFlowMode } from "./snapshot.ts";
2
2
 
3
3
  export const SNAPSHOT_ENTRY_TYPE = "state-flow-snapshot";
4
4
 
@@ -22,6 +22,8 @@ export interface PassiveStopBoundary {
22
22
  persistenceError?: string;
23
23
  /** The inactive mode selected by that failed change; legacy markers omit it. */
24
24
  mode?: InactiveMode;
25
+ /** Native-only Off choice; canonical runtime mode is deliberately not updated. */
26
+ memoryDeferred?: true;
25
27
  }
26
28
 
27
29
  export interface SnapshotDiscovery {
@@ -44,6 +46,55 @@ export function discoverSnapshotData(branch: readonly BranchEntry[]): SnapshotDi
44
46
  return { candidates, errors };
45
47
  }
46
48
 
49
+ /** Read native mode policy only; semantic checkpoint validity remains the recovery owner's concern. */
50
+ export function findBranchPolicy(
51
+ branch: readonly BranchEntry[], sessionId: string | undefined, stopEntryType: string | undefined, inactiveMode: InactiveMode,
52
+ ): { mode: StateFlowMode; persistenceError?: string } | undefined {
53
+ let stopReset = false;
54
+ for (let index = branch.length - 1; index >= 0; index--) {
55
+ try {
56
+ const entry = branch[index];
57
+ if (entry?.type !== "custom") continue;
58
+ if (entry.customType === SNAPSHOT_ENTRY_TYPE) {
59
+ const mode = readCheckpointMode(entry.data, inactiveMode);
60
+ if (mode !== undefined) return { mode };
61
+ const data = entry.data as { disabled?: unknown; mode?: unknown; enabled?: unknown } | undefined;
62
+ if (data?.disabled === true && !Object.hasOwn(data, "mode") && !Object.hasOwn(data, "enabled")) return { mode: inactiveMode };
63
+ } else if (stopEntryType !== undefined && entry.customType === stopEntryType) {
64
+ const data = entry.data as PassiveStopBoundary & { owner?: unknown; reset?: unknown } | undefined;
65
+ if (!data || stopReset || (sessionId === undefined ? typeof data.owner !== "string" || !data.owner.trim() : data.owner !== sessionId)) continue;
66
+ if (data.reset === true) { stopReset = true; continue; }
67
+ if (Number.isSafeInteger(data.at) && data.at >= 0 && data.memoryDeferred === true && data.mode === "off") {
68
+ return { mode: "off", ...(typeof data.persistenceError === "string" && data.persistenceError.trim() ? { persistenceError: data.persistenceError } : {}) };
69
+ }
70
+ if (Number.isSafeInteger(data.at) && data.at >= 0 && typeof data.persistenceError === "string" && data.persistenceError.trim()) {
71
+ return { mode: data.mode === "off" || data.mode === "passive" ? data.mode : inactiveMode, persistenceError: data.persistenceError };
72
+ }
73
+ }
74
+ } catch {
75
+ // Malformed native policy cannot prevent another explicit retained choice from being read.
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Native policy bookkeeping retains an unacquired fork across extension reloads. */
82
+ export function hasPendingFork(branch: readonly BranchEntry[], sessionId: string, entryType: string): boolean {
83
+ for (let index = branch.length - 1; index >= 0; index--) {
84
+ try {
85
+ const entry = branch[index];
86
+ if (entry?.type !== "custom" || entry.customType !== entryType) continue;
87
+ const data = entry.data as { owner?: unknown; reset?: unknown; forkPending?: unknown } | undefined;
88
+ if (data?.owner !== sessionId) continue;
89
+ if (data.reset === true) return false;
90
+ if (data.forkPending === true) return true;
91
+ } catch {
92
+ // Unrelated native entries grant no fork authority.
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
47
98
  export function snapshotDataNewestFirst(branch: readonly BranchEntry[]): unknown[] {
48
99
  return discoverSnapshotData(branch).candidates;
49
100
  }
@@ -26,10 +26,12 @@ export interface SnapshotConfig {
26
26
  }
27
27
 
28
28
  /**
29
- * Read-only compatibility for session config and native checkpoints written before `mode`:
30
- * `enabled:true` stays active; `enabled:false` stays the caller's inactive policy.
29
+ * Read only mode policy, without validating or accessing semantic checkpoint metadata.
30
+ * Missing/invalid policy stays undecided; callers must not treat this as restoration proof.
31
+ * Legacy `enabled:false` follows the caller's inactive policy.
31
32
  */
32
- function decodeLegacyMode(value: JsonObject, inactiveMode: InactiveMode): StateFlowMode | undefined {
33
+ export function readCheckpointMode(value: unknown, inactiveMode: InactiveMode = "passive"): StateFlowMode | undefined {
34
+ if (!isObject(value)) return undefined;
33
35
  if (Object.hasOwn(value, "mode")) return Object.hasOwn(value, "enabled") || !isStateFlowMode(value.mode) ? undefined : value.mode;
34
36
  return typeof value.enabled === "boolean" ? value.enabled ? "active" : inactiveMode : undefined;
35
37
  }
@@ -134,7 +136,7 @@ export function parseSessionRuntime(
134
136
  let runtime: unknown;
135
137
  try {
136
138
  const settings: unknown = JSON.parse(config);
137
- const mode = isObject(settings) && Object.keys(settings).length === 1 ? decodeLegacyMode(settings, "passive") : undefined;
139
+ const mode = isObject(settings) && Object.keys(settings).length === 1 ? readCheckpointMode(settings, "passive") : undefined;
138
140
  if (mode === undefined) throw new Error("Invalid State Flow runtime configuration or counters");
139
141
  runtime = { config: { mode }, meta: JSON.parse(runtimeSource) };
140
142
  } catch (error) {
@@ -230,7 +232,7 @@ export function parseRetainedPiCheckpoint(value: unknown, inactiveMode: Inactive
230
232
  if (keys.length === 1 && value.disabled === true) return { mode: inactiveMode };
231
233
  if (keys.length === 1 && (value.mode === "passive" || value.mode === "off")) return { mode: value.mode };
232
234
  const allowed = new Set(["boundary", "mode", "enabled", "step", "bootstrap", "specification"]);
233
- const mode = decodeLegacyMode(value, inactiveMode);
235
+ const mode = readCheckpointMode(value, inactiveMode);
234
236
  if (keys.some((key) => !allowed.has(key))
235
237
  || typeof value.boundary !== "string" || value.boundary.trim().length === 0
236
238
  || mode === undefined