@llblab/pi-kit 0.24.0 → 0.25.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 (112) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +6 -5
  4. package/node_modules/@llblab/pi-claude-usage/AGENTS.md +20 -0
  5. package/node_modules/@llblab/pi-claude-usage/BACKLOG.md +3 -0
  6. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +13 -0
  7. package/node_modules/@llblab/pi-claude-usage/LICENSE +22 -0
  8. package/node_modules/@llblab/pi-claude-usage/README.md +110 -0
  9. package/node_modules/@llblab/pi-claude-usage/banner.jpg +0 -0
  10. package/node_modules/@llblab/pi-claude-usage/index.ts +1159 -0
  11. package/node_modules/@llblab/pi-claude-usage/package.json +60 -0
  12. package/node_modules/@llblab/pi-state-flow/AGENTS.md +42 -56
  13. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +16 -3
  14. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -0
  15. package/node_modules/@llblab/pi-state-flow/README.md +15 -12
  16. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +7 -3
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +16 -7
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +9 -9
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +5 -4
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -1
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -4
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +3 -3
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +5 -5
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +3 -5
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +275 -199
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +11 -4
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +6 -7
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +4 -1
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +1 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +4 -5
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +13 -13
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +7 -6
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +9 -9
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +3 -2
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +17 -12
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +4 -1
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +2 -1
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +17 -8
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +49 -20
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +22 -3
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +30 -10
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +5 -3
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +19 -28
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +17 -15
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +52 -52
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +8 -4
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +34 -18
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +5 -5
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +13 -19
  53. package/node_modules/@llblab/pi-state-flow/dist/package.json +3 -3
  54. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +2 -2
  55. package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -1
  56. package/node_modules/@llblab/pi-state-flow/docs/agent-contract-relocation.md +72 -0
  57. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -32
  58. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +12 -4
  59. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +5 -5
  60. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +6 -6
  61. package/node_modules/@llblab/pi-state-flow/docs/performance.md +1 -1
  62. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +13 -12
  63. package/node_modules/@llblab/pi-state-flow/docs/usage.md +32 -29
  64. package/node_modules/@llblab/pi-state-flow/index.ts +3 -2
  65. package/node_modules/@llblab/pi-state-flow/lib/config.ts +20 -10
  66. package/node_modules/@llblab/pi-state-flow/lib/context.ts +15 -14
  67. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +1 -1
  68. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -6
  69. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +6 -6
  70. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +274 -197
  71. package/node_modules/@llblab/pi-state-flow/lib/history.ts +16 -11
  72. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +5 -1
  73. package/node_modules/@llblab/pi-state-flow/lib/query.ts +16 -16
  74. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +11 -11
  75. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +19 -13
  76. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -3
  77. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +55 -22
  78. package/node_modules/@llblab/pi-state-flow/lib/state.ts +46 -13
  79. package/node_modules/@llblab/pi-state-flow/lib/status.ts +23 -32
  80. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +66 -65
  81. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +39 -19
  82. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +19 -27
  83. package/node_modules/@llblab/pi-state-flow/package.json +3 -3
  84. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +2 -2
  85. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  86. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  87. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +2 -0
  88. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +55 -2
  89. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -0
  90. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +144 -1
  91. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +9 -0
  92. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +19 -0
  93. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +13 -0
  94. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +29 -6
  95. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +16 -0
  96. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +5 -1
  97. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +6 -2
  98. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +7 -0
  99. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +13 -5
  100. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  101. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  102. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  103. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +79 -1
  104. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +197 -0
  105. package/node_modules/@llblab/pi-telegram/lib/bus.ts +33 -0
  106. package/node_modules/@llblab/pi-telegram/lib/commands.ts +38 -6
  107. package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -0
  108. package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
  109. package/node_modules/@llblab/pi-telegram/lib/polling.ts +10 -2
  110. package/node_modules/@llblab/pi-telegram/lib/threads.ts +19 -5
  111. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  112. package/package.json +7 -3
@@ -7,11 +7,11 @@ import { isAbsolute, join, resolve } from "node:path";
7
7
  import { ArtifactReadTracker } from "./acquisition.js";
8
8
  import { classifyArtifactCompilationNeed, inspectRegisteredArtifactPaths, ORDINARY_ARTIFACT_COMPILER, sameArtifactSourceFingerprint, } from "./artifact.js";
9
9
  import { hasCompactionSizedTranscript, planStateFlowCompaction, shouldRequestStateFlowCompaction, stateFlowCompactionResult } from "./compaction.js";
10
- import { loadStateFlowConfig } from "./config.js";
10
+ import { inactiveModeFor, loadStateFlowConfig } from "./config.js";
11
11
  import { ContextProjection, contextView, createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, projectSystemProtocol, runtimeContextHead, syntheticUser } from "./context.js";
12
12
  import { readNativeSessionHeader } from "./continuation.js";
13
13
  import { cwdScopeKey, resolveSessionAddress, sessionScopeKey, } from "./durable.js";
14
- import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.js";
14
+ import { completeRun, deactivateEpisode, prepareRun, resumeEpisode, startEpisode } from "./episode.js";
15
15
  import { awaitInFlightBackupPushes, backupCurrentStateFlowFiles, startStateFlowBackupPush } from "./git.js";
16
16
  import { projectRecentTransitionsWithLimit } from "./history.js";
17
17
  import { isObject, presentationJson, sameJson } from "./json.js";
@@ -22,8 +22,8 @@ import { selectedBoundaryFailure, selectRetainedCheckpoint, waitForRecovery } fr
22
22
  import { TemporalRuntime } from "./runtime.js";
23
23
  import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, hasUncheckpointedConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.js";
24
24
  import { hasCompiledSkillArtifact, hashSkillSource, registeredSkillResolver, SkillReadTracker } from "./skills.js";
25
- import { HistoryBoundaryExpiredError, emptySnapshot, migrationFailure } from "./snapshot.js";
26
- import { emptyState, overlayStates, projectStateForModel } from "./state.js";
25
+ import { HistoryBoundaryExpiredError, emptySnapshot, migrationFailure, preRuntimeCheckpoint } from "./snapshot.js";
26
+ import { emptyState, projectStateForModel } from "./state.js";
27
27
  import { compactStatus, detailedStatus, STATUS_KEY } from "./status.js";
28
28
  import { PublicationBusyError } from "./storage.js";
29
29
  import { createStateFlowTelegramAdapter } from "./telegram.js";
@@ -36,17 +36,16 @@ const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
36
36
  export default function stateFlowExtension(pi, options = {}) {
37
37
  const agentDir = options.agentDir ?? getAgentDir();
38
38
  const loadedConfig = loadStateFlowConfig(agentDir, options.repositoryRoot);
39
- const config = {
40
- ...loadedConfig,
41
- passiveBootstrap: options.passive?.bootstrap ?? loadedConfig.passiveBootstrap,
42
- passiveTools: options.passive?.tools ?? loadedConfig.passiveTools,
43
- };
44
- let snapshot = emptySnapshot();
39
+ const config = options.mode === undefined ? loadedConfig
40
+ : { ...loadedConfig, mode: options.mode, inactiveMode: inactiveModeFor(options.mode) };
41
+ let snapshot = emptySnapshot(config.inactiveMode);
45
42
  let scopeStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
43
+ let effectiveState = {};
46
44
  let branchStartsWithoutRuntime = false;
47
45
  let selectedHistoryExpired = false;
48
- let stopPersistenceError;
49
- let stopPersistence;
46
+ let modePersistenceError;
47
+ /** One pending inactive-mode persistence; later inactive choices coalesce until it publishes. */
48
+ let inactivePersistence;
50
49
  let startActivation;
51
50
  let forkInitialization = false;
52
51
  let branchRestoration;
@@ -113,18 +112,26 @@ export default function stateFlowExtension(pi, options = {}) {
113
112
  assertSelectedBranchAvailable();
114
113
  if (!runtime)
115
114
  throw new Error("State Flow temporal runtime is unavailable");
116
- return projectModelState(runtime.read(offset, scope));
115
+ const { lazy: _lazy, ...defaults } = emptyState();
116
+ return { ...defaults, ...projectModelState(runtime.readView(offset, scope)) };
117
117
  } });
118
118
  function assertPublicationAvailable() {
119
119
  assertSelectedBranchAvailable();
120
- if (stopPersistenceError)
121
- throw new Error(`Memory writes paused after Stop: ${stopPersistenceError}; use /state-flow-start`);
120
+ if (modePersistenceError)
121
+ throw new Error(`Memory writes paused after mode change: ${modePersistenceError}; use /state-flow-active`);
122
+ }
123
+ function isActive() {
124
+ return snapshot.config.mode === "active";
125
+ }
126
+ /** An unavailable or pending active selection keeps the configured inactive policy, never an invented one. */
127
+ function inactiveSelection(mode) {
128
+ return mode === "active" ? config.inactiveMode : mode;
122
129
  }
123
130
  function appendCheckpoint() {
124
- const checkpoint = branchStartsWithoutRuntime ? { disabled: true } : runtime?.retainedCheckpoint(snapshot) ?? { disabled: true };
125
- if ("disabled" in checkpoint && !branchStartsWithoutRuntime) {
126
- throw new Error("State Flow cannot checkpoint an unproven branch as ordinary disabled; restore a valid checkpoint first");
127
- }
131
+ const checkpoint = branchStartsWithoutRuntime ? preRuntimeCheckpoint(snapshot.config.mode)
132
+ : runtime?.view ? runtime.retainedCheckpoint(snapshot) : undefined;
133
+ if (!checkpoint)
134
+ throw new Error("State Flow cannot checkpoint an unproven branch; restore a valid checkpoint first");
128
135
  pi.appendEntry(SNAPSHOT_ENTRY_TYPE, checkpoint);
129
136
  if ("boundary" in checkpoint)
130
137
  branchStartsWithoutRuntime = false;
@@ -137,10 +144,10 @@ export default function stateFlowExtension(pi, options = {}) {
137
144
  function updateUi(ctx) {
138
145
  ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, scopeRevisions(), (color, text) => ctx.ui.theme.fg(color, text)));
139
146
  }
140
- function cancelStopPersistence() {
141
- const pending = stopPersistence;
147
+ function cancelInactivePersistence() {
148
+ const pending = inactivePersistence;
142
149
  pending?.controller.abort();
143
- stopPersistence = undefined;
150
+ inactivePersistence = undefined;
144
151
  return pending?.operation;
145
152
  }
146
153
  function cancelStartActivation() {
@@ -223,7 +230,7 @@ export default function stateFlowExtension(pi, options = {}) {
223
230
  function missingArtifactRemovals(states) {
224
231
  const owners = new Map();
225
232
  for (const scope of ["global", "cwd", "session"])
226
- for (const path of Object.keys(states[scope].artifacts)) {
233
+ for (const path of Object.keys(states[scope].artifacts ?? {})) {
227
234
  owners.set(path, [...owners.get(path) ?? [], scope]);
228
235
  }
229
236
  const removals = {};
@@ -239,9 +246,15 @@ export default function stateFlowExtension(pi, options = {}) {
239
246
  }
240
247
  function installScopeStates() {
241
248
  scopeStates = runtime?.view ? runtime.states() : { global: emptyState(), cwd: emptyState(), session: emptyState() };
249
+ effectiveState = runtime?.view ? runtime.readView() : {};
242
250
  }
243
- function passiveToolsAvailable() {
244
- return snapshot.config.enabled || config.passiveTools;
251
+ /** Active and passive expose both memory tools; Off exposes neither. */
252
+ function memoryToolsAvailable() {
253
+ return snapshot.config.mode !== "off";
254
+ }
255
+ /** Passive memory context needs a loaded view; Off and Active never inject it. */
256
+ function passiveMemoryAvailable() {
257
+ return snapshot.config.mode === "passive" && runtime?.view !== undefined;
245
258
  }
246
259
  async function inspectTelegramState(scope) {
247
260
  if (!activeContext || shuttingDown)
@@ -250,7 +263,7 @@ export default function stateFlowExtension(pi, options = {}) {
250
263
  assertSelectedBranchAvailable();
251
264
  const selected = runtime ??= createRuntime(activeContext);
252
265
  const signal = sharedInspectionLifetime.signal;
253
- if (!selected.view || (scope !== "session" && !stopPersistenceError))
266
+ if (!selected.view || (scope !== "session" && !modePersistenceError))
254
267
  await selected.refreshShared(signal);
255
268
  signal.throwIfAborted();
256
269
  if (runtime !== selected)
@@ -260,15 +273,13 @@ export default function stateFlowExtension(pi, options = {}) {
260
273
  if (!selected.view)
261
274
  throw new Error("State Flow temporal runtime is unavailable");
262
275
  installScopeStates();
263
- const state = scope === "effective"
264
- ? overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)
265
- : scopeStates[scope];
266
- return { state: { ...projectModelState(state), lazy: structuredClone(state.lazy) }, revisions: scopeRevisions(), signal };
276
+ const state = scope === "effective" ? effectiveState : selected.readView(0, scope);
277
+ return { state: { ...projectModelState(state), ...(state.lazy === undefined ? {} : { lazy: structuredClone(state.lazy) }) }, revisions: scopeRevisions(), signal };
267
278
  }
268
279
  function syncStateFlowTools() {
269
280
  const active = pi.getActiveTools();
270
281
  const owned = [PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME];
271
- const available = passiveToolsAvailable();
282
+ const available = memoryToolsAvailable();
272
283
  if (owned.every((name) => active.includes(name) === available))
273
284
  return;
274
285
  pi.setActiveTools(available
@@ -313,7 +324,7 @@ export default function stateFlowExtension(pi, options = {}) {
313
324
  throw new Error("Temporal State Flow runtime is unavailable; reload before publishing");
314
325
  await selected.withPatchTransaction((transaction) => {
315
326
  signal.throwIfAborted();
316
- if (runtime !== selected || inferencePreparation !== pending || !snapshot.config.enabled || shuttingDown) {
327
+ if (runtime !== selected || inferencePreparation !== pending || !isActive() || shuttingDown) {
317
328
  throw new Error("State Flow inference selection changed while awaiting publication");
318
329
  }
319
330
  assertPublicationAvailable();
@@ -379,6 +390,7 @@ export default function stateFlowExtension(pi, options = {}) {
379
390
  cwdScopeKey: cwdScopeKey(cwd),
380
391
  sessionScopeKey: sessionScopeKey(session.key),
381
392
  scopeStates: diagnosticStates,
393
+ effectiveState,
382
394
  recent: diagnosticRecent,
383
395
  historyLimit: config.historyLimit,
384
396
  ...(view === undefined ? {} : { temporal: {
@@ -389,7 +401,7 @@ export default function stateFlowExtension(pi, options = {}) {
389
401
  } }),
390
402
  staleArtifacts,
391
403
  ...(durableStateError === undefined ? {} : { durableStateError }),
392
- ...(stopPersistenceError === undefined ? {} : { publicationError: stopPersistenceError }),
404
+ ...(modePersistenceError === undefined ? {} : { publicationError: modePersistenceError }),
393
405
  };
394
406
  }
395
407
  function forkSource(ctx) {
@@ -408,7 +420,7 @@ export default function stateFlowExtension(pi, options = {}) {
408
420
  // Start-owned attachment/fork recovery keeps its owner; only accepted Start cancels Stop.
409
421
  if (!startOwner) {
410
422
  cancelStartActivation();
411
- cancelStopPersistence();
423
+ cancelInactivePersistence();
412
424
  }
413
425
  clearRunTransient();
414
426
  passiveContinuation = undefined;
@@ -427,53 +439,66 @@ export default function stateFlowExtension(pi, options = {}) {
427
439
  try {
428
440
  const branch = ctx.sessionManager.getBranch();
429
441
  const fence = retainsPhysicalSessionProjection(sessionStartReason)
430
- ? findPassiveStopBoundary(branch, ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE)?.persistenceError
442
+ ? findPassiveStopBoundary(branch, ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE)
431
443
  : undefined;
432
- stopPersistenceError = fence ?? (startOwner ? stopPersistenceError : undefined);
433
- if (fence) {
434
- // The native Stop owns policy only: read proven memory, never replay or publish a stale selection.
435
- selection = { kind: "current" };
444
+ modePersistenceError = fence?.persistenceError ?? (startOwner ? modePersistenceError : undefined);
445
+ if (fence?.persistenceError) {
446
+ // The native marker owns policy only: read proven memory, never replay or publish a stale selection.
447
+ selection = { kind: "current", mode: fence.mode ?? config.inactiveMode };
436
448
  }
437
449
  else {
438
450
  const discovery = discoverSnapshotData(branch);
439
- const selected = selectRetainedCheckpoint(discovery.candidates);
451
+ const selected = selectRetainedCheckpoint(discovery.candidates, config.inactiveMode);
440
452
  skipped = discovery.errors.length + selected.skipped.length;
441
- branchStartsWithoutRuntime = selected.kind === "disabled" || (discovery.candidates.length === 0 && discovery.errors.length === 0);
453
+ branchStartsWithoutRuntime = selected.kind === "pre-runtime" || (discovery.candidates.length === 0 && discovery.errors.length === 0);
442
454
  if (discovery.candidates.length === 0 && discovery.errors.length > 0) {
443
- snapshot = migrationFailure({}, `Snapshot restoration failed: ${discovery.errors[0]}`);
455
+ snapshot = migrationFailure({}, `Snapshot restoration failed: ${discovery.errors[0]}`, config.inactiveMode);
444
456
  }
445
457
  else if (selected.kind === "boundary" && forkInitialization) {
446
458
  try {
447
459
  // Native parent acquisition stays outside canonical exclusion.
448
460
  const source = forkSource(ctx);
449
- const sourceStopped = findPassiveStopBoundary(branch, source.id, PASSIVE_STOP_ENTRY_TYPE)?.persistenceError !== undefined;
450
- selection = { kind: "fork", source, checkpoint: sourceStopped ? { ...selected.checkpoint, enabled: false } : selected.checkpoint };
461
+ const sourceFence = findPassiveStopBoundary(branch, source.id, PASSIVE_STOP_ENTRY_TYPE);
462
+ selection = { kind: "fork", source, checkpoint: sourceFence?.persistenceError !== undefined
463
+ ? { ...selected.checkpoint, mode: sourceFence.mode ?? config.inactiveMode } : selected.checkpoint };
451
464
  }
452
465
  catch (error) {
453
- snapshot = selectedBoundaryFailure(diagnosticText(error));
466
+ snapshot = selectedBoundaryFailure(diagnosticText(error), inactiveSelection(selected.checkpoint.mode));
454
467
  }
455
468
  }
456
469
  else if (selected.kind === "boundary") {
457
470
  selection = { kind: "restore", checkpoint: selected.checkpoint };
458
471
  }
472
+ else if (selected.kind === "pre-runtime") {
473
+ snapshot = emptySnapshot(selected.mode);
474
+ }
459
475
  else if (discovery.candidates.length > 0) {
460
- snapshot = selected.kind === "disabled" ? emptySnapshot() : selected.snapshot;
476
+ snapshot = selected.snapshot;
461
477
  }
462
- else if (config.autoStart && isNewSession(sessionStartReason, branch)) {
463
- selection = { kind: "auto-start" };
478
+ else if (isNewSession(sessionStartReason, branch)) {
479
+ // The repository mode is only a default for genuinely new sessions.
480
+ if (config.mode === "active")
481
+ selection = { kind: "auto-start" };
482
+ else {
483
+ snapshot = emptySnapshot(config.mode);
484
+ // Adopt the default once without initializing semantic storage.
485
+ appendCheckpoint();
486
+ }
464
487
  }
465
488
  else {
466
- snapshot = emptySnapshot();
489
+ snapshot = emptySnapshot(config.inactiveMode);
467
490
  }
468
491
  }
469
492
  }
470
493
  catch (error) {
471
494
  selection = { kind: "settled" };
472
- snapshot = selectedBoundaryFailure(diagnosticText(error));
495
+ snapshot = selectedBoundaryFailure(diagnosticText(error), config.inactiveMode);
473
496
  }
474
497
  // Pending selection grants neither private reads nor publication until its acceptance installs memory.
475
- if (selection.kind !== "settled")
476
- snapshot = migrationFailure({}, "State Flow branch restoration is pending");
498
+ if (selection.kind !== "settled") {
499
+ snapshot = migrationFailure({}, "State Flow branch restoration is pending", selection.kind === "current" ? selection.mode
500
+ : selection.kind === "auto-start" ? config.inactiveMode : inactiveSelection(selection.checkpoint.mode));
501
+ }
477
502
  const pending = {
478
503
  controller: new AbortController(),
479
504
  awaitingAcceptance: selection.kind !== "settled" && selection.kind !== "current",
@@ -534,17 +559,18 @@ export default function stateFlowExtension(pi, options = {}) {
534
559
  assertCurrent();
535
560
  if (current)
536
561
  runtime = selected = candidate;
537
- snapshot = current ?? migrationFailure({}, "Current State Flow session memory is unavailable");
562
+ snapshot = current ? deactivateEpisode(current, pending.requestedMode ?? selection.mode)
563
+ : migrationFailure({}, "Current State Flow session memory is unavailable", pending.requestedMode ?? selection.mode);
538
564
  installScopeStates();
539
565
  }
540
566
  else if (selection.kind === "restore") {
541
567
  await candidate.withRestoreTransaction(selection.checkpoint, (restored, publish) => {
542
568
  assertCurrent();
543
569
  // Canceled preparation or boundary continuation may have no specification. Retain uncompiled native context.
544
- if (restored.config.enabled && restored.meta.specification === undefined
570
+ if (restored.config.mode === "active" && restored.meta.specification === undefined
545
571
  && retainsPhysicalSessionProjection(reason) && hasUncheckpointedConversation(ctx.sessionManager.getBranch()))
546
572
  restored.meta.bootstrap = true;
547
- const next = pending.passiveRequested ? stopEpisode(restored) : restored;
573
+ const next = pending.requestedMode ? deactivateEpisode(restored, pending.requestedMode) : restored;
548
574
  accept(candidate, next, publish(next), appendCheckpoint);
549
575
  }, signal);
550
576
  }
@@ -552,7 +578,7 @@ export default function stateFlowExtension(pi, options = {}) {
552
578
  await candidate.withForkTransaction(selection.source, selection.checkpoint, (child, publish) => {
553
579
  assertCurrent();
554
580
  // Mode is selected independently of creating the child's private memory.
555
- const next = pending.passiveRequested ? stopEpisode(child) : child;
581
+ const next = pending.requestedMode ? deactivateEpisode(child, pending.requestedMode) : child;
556
582
  const publication = publish(next);
557
583
  forkInitialization = false;
558
584
  accept(candidate, next, publication, () => {
@@ -570,7 +596,7 @@ export default function stateFlowExtension(pi, options = {}) {
570
596
  if (current || discovery.candidates.length > 0 || discovery.errors.length > 0)
571
597
  throw new Error("Existing session runtime requires retained-boundary restoration");
572
598
  const activated = startEpisode(hasPriorConversation(branch));
573
- const next = pending.passiveRequested ? stopEpisode(activated) : activated;
599
+ const next = pending.requestedMode ? deactivateEpisode(activated, pending.requestedMode) : activated;
574
600
  accept(candidate, next, publish(next), appendCheckpoint);
575
601
  }, signal, true);
576
602
  }
@@ -585,7 +611,7 @@ export default function stateFlowExtension(pi, options = {}) {
585
611
  return;
586
612
  if (error instanceof HistoryBoundaryExpiredError)
587
613
  selectedHistoryExpired = true;
588
- snapshot = selectedBoundaryFailure(diagnosticText(error));
614
+ snapshot = selectedBoundaryFailure(diagnosticText(error), snapshot.config.mode === "active" ? config.inactiveMode : snapshot.config.mode);
589
615
  installScopeStates();
590
616
  }
591
617
  finally {
@@ -595,21 +621,14 @@ export default function stateFlowExtension(pi, options = {}) {
595
621
  return;
596
622
  }
597
623
  settleSelection(ctx, reason, notifyRecovery, skipped, selected !== placeholder);
598
- if (snapshot.config.enabled || !(config.passiveBootstrap || config.passiveTools) || runtime?.view)
624
+ if (snapshot.config.mode !== "passive" || runtime?.view)
599
625
  return;
600
- const passive = createRuntime(ctx);
601
- const priorView = selected?.view;
602
- try {
603
- await passive.refreshShared(signal);
604
- }
605
- catch (error) {
606
- if (isCurrent() && !signal.aborted && notifyRecovery && !stopPersistenceError)
607
- notifyProblem(ctx, `State Flow passive memory is unavailable: ${diagnosticText(error)}`, "warning");
626
+ const passive = await loadPassiveView(ctx, signal, isCurrent, notifyRecovery);
627
+ if (passive === undefined)
608
628
  return;
609
- }
610
629
  assertCurrent();
611
630
  // An intervening passive patch or inspection owns its newer cache, even on the same physical branch.
612
- if (selected?.view !== priorView)
631
+ if (runtime?.view)
613
632
  return;
614
633
  runtime = selected = passive;
615
634
  installScopeStates();
@@ -619,7 +638,7 @@ export default function stateFlowExtension(pi, options = {}) {
619
638
  // Host failures before acceptance leave the selection unavailable, never invented empty memory.
620
639
  if (accepted || !isCurrent())
621
640
  return;
622
- snapshot = selectedBoundaryFailure(diagnosticText(error));
641
+ snapshot = selectedBoundaryFailure(diagnosticText(error), snapshot.config.mode === "active" ? config.inactiveMode : snapshot.config.mode);
623
642
  installScopeStates();
624
643
  }
625
644
  finally {
@@ -628,28 +647,42 @@ export default function stateFlowExtension(pi, options = {}) {
628
647
  branchRestoration = undefined;
629
648
  }
630
649
  }
650
+ /** Load current shared memory for passive projection without initializing or publishing it. */
651
+ async function loadPassiveView(ctx, signal, isCurrent, notify) {
652
+ const passive = createRuntime(ctx);
653
+ try {
654
+ await passive.refreshShared(signal);
655
+ }
656
+ catch (error) {
657
+ if (isCurrent() && !signal.aborted && notify && !modePersistenceError)
658
+ notifyProblem(ctx, `State Flow passive memory is unavailable: ${diagnosticText(error)}`, "warning");
659
+ return undefined;
660
+ }
661
+ return passive;
662
+ }
631
663
  function settleSelection(ctx, reason, notifyRecovery, skipped, selectedMemory) {
632
- const failure = !snapshot.config.enabled && snapshot.meta.validation?.attempt === 0 ? snapshot.meta.validation.error : undefined;
633
- if (failure !== undefined && notifyRecovery && !stopPersistenceError) {
664
+ const failure = !isActive() && snapshot.meta.validation?.attempt === 0 ? snapshot.meta.validation.error : undefined;
665
+ if (failure !== undefined && notifyRecovery && !modePersistenceError) {
634
666
  if (selectedHistoryExpired)
635
- notifyProblem(ctx, "State Flow history is outside the retained temporal window; /state-flow-start can use current session memory.", "warning");
667
+ notifyProblem(ctx, "State Flow history is outside the retained temporal window; /state-flow-active can use current session memory.", "warning");
636
668
  else
637
669
  notifyProblem(ctx, `State Flow restore failed: ${failure}`, "error");
638
670
  }
639
671
  if (selectedMemory && retainsPhysicalSessionProjection(reason) && runtime?.view) {
640
672
  const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
641
673
  if (boundary !== undefined) {
642
- const continuation = createPassiveContinuation(projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)), boundary.at, boundary.from, boundary.preserveContext);
643
- if (!snapshot.config.enabled)
674
+ const continuation = createPassiveContinuation(projectModelState(effectiveState), boundary.at, boundary.from, boundary.preserveContext);
675
+ // Off retains the handoff boundary for a later Passive or Active choice but never projects it.
676
+ if (!isActive())
644
677
  passiveContinuation = continuation;
645
678
  else if (snapshot.meta.bootstrap)
646
679
  bootstrapContinuation = continuation;
647
680
  }
648
681
  }
649
- if (notifyRecovery && snapshot.config.enabled && skipped > 0) {
682
+ if (notifyRecovery && isActive() && skipped > 0) {
650
683
  notifyProblem(ctx, `State Flow skipped ${skipped} malformed snapshot(s); restored the last valid one.`, "warning");
651
684
  }
652
- if (snapshot.config.enabled)
685
+ if (isActive())
653
686
  deferInferencePreparation();
654
687
  syncStateFlowTools();
655
688
  updateUi(ctx);
@@ -669,8 +702,8 @@ export default function stateFlowExtension(pi, options = {}) {
669
702
  }, { additionalProperties: false }),
670
703
  async execute(_toolCallId, params, signal) {
671
704
  try {
672
- if (!passiveToolsAvailable())
673
- throw new Error("State Flow tools are disabled by configuration");
705
+ if (!memoryToolsAvailable())
706
+ throw new Error("State Flow tools are off for this session");
674
707
  if (signal?.aborted)
675
708
  throw new Error("State Flow read was aborted");
676
709
  if (!runtime?.view)
@@ -732,8 +765,8 @@ export default function stateFlowExtension(pi, options = {}) {
732
765
  },
733
766
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
734
767
  try {
735
- if (!passiveToolsAvailable())
736
- throw new Error("State Flow tools are disabled by configuration");
768
+ if (!memoryToolsAvailable())
769
+ throw new Error("State Flow tools are off for this session");
737
770
  assertPublicationAvailable();
738
771
  if (signal?.aborted)
739
772
  throw new Error("State Flow patch was aborted before materialization");
@@ -762,12 +795,12 @@ export default function stateFlowExtension(pi, options = {}) {
762
795
  const selected = runtime ??= createRuntime(ctx);
763
796
  const acquiredArtifacts = structuredClone([...artifactReads.successful.values()]);
764
797
  const acquiredSkills = structuredClone([...skillReads.successful.values()]);
765
- const previousEffective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
798
+ const previousEffective = effectiveState;
766
799
  return await selected.withPatchTransaction((transaction) => {
767
800
  if (runtime !== selected)
768
801
  throw new Error("State Flow session selection changed while awaiting publication");
769
- if (!passiveToolsAvailable())
770
- throw new Error("State Flow tools are disabled by configuration");
802
+ if (!memoryToolsAvailable())
803
+ throw new Error("State Flow tools are off for this session");
771
804
  assertPublicationAvailable();
772
805
  for (const acquired of acquiredArtifacts) {
773
806
  const observation = inspectRegisteredArtifactPaths([acquired.path])[0];
@@ -792,7 +825,7 @@ export default function stateFlowExtension(pi, options = {}) {
792
825
  }
793
826
  clearAcceptedAcquisitions(new Set(acquiredArtifacts.map(({ path }) => path)));
794
827
  updateUi(ctx);
795
- const updates = contextProjection.acceptPatch(previousEffective, overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session), patches, artifactHints);
828
+ const updates = contextProjection.acceptPatch(previousEffective, effectiveState, patches, artifactHints);
796
829
  const acknowledgement = changed
797
830
  ? `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.`
798
831
  : "\nState already current.";
@@ -826,8 +859,8 @@ export default function stateFlowExtension(pi, options = {}) {
826
859
  if (startActivation?.operation)
827
860
  return startActivation.operation;
828
861
  telegramStartPending = false;
829
- if (activeContext && !branchRestoration && snapshot.config.enabled) {
830
- return Promise.resolve({ ok: true, message: "State Flow is already enabled", signal: sharedInspectionLifetime.signal });
862
+ if (activeContext && !branchRestoration && isActive()) {
863
+ return Promise.resolve({ ok: true, message: "State Flow is already active", signal: sharedInspectionLifetime.signal });
831
864
  }
832
865
  const pending = { controller: new AbortController() };
833
866
  startActivation = pending;
@@ -850,7 +883,7 @@ export default function stateFlowExtension(pi, options = {}) {
850
883
  && ctx.sessionManager.getHeader()?.timestamp === timestamp;
851
884
  const superseded = () => {
852
885
  pending.abort();
853
- return { ok: false, message: "State Flow Start was superseded", signal: pending.signal };
886
+ return { ok: false, message: "State Flow activation was superseded", signal: pending.signal };
854
887
  };
855
888
  try {
856
889
  if (!activeContext)
@@ -859,11 +892,11 @@ export default function stateFlowExtension(pi, options = {}) {
859
892
  await waitForRecovery(branchRestoration.operation, signal);
860
893
  if (!isOwner())
861
894
  return superseded();
862
- if (snapshot.config.enabled)
863
- return { ok: true, message: "State Flow is already enabled", signal: sharedInspectionLifetime.signal };
895
+ if (isActive())
896
+ return { ok: true, message: "State Flow is already active", signal: sharedInspectionLifetime.signal };
864
897
  if (forkInitialization) {
865
- // Withdraw this join on cancellation without revoking the independently owned Stop.
866
- const stopping = stopPersistence?.operation;
898
+ // Withdraw this join on cancellation without revoking the independently owned mode persistence.
899
+ const stopping = inactivePersistence?.operation;
867
900
  if (stopping)
868
901
  await waitForRecovery(stopping, signal);
869
902
  if (!isOwner())
@@ -878,7 +911,7 @@ export default function stateFlowExtension(pi, options = {}) {
878
911
  catch (error) {
879
912
  if (!isOwner())
880
913
  return superseded();
881
- const message = conciseDiagnostic(`State Flow Start failed: ${diagnosticText(error)}`);
914
+ const message = conciseDiagnostic(`State Flow activation failed: ${diagnosticText(error)}`);
882
915
  notifyProblem(ctx, message, "error");
883
916
  return { ok: false, message, signal: AbortSignal.any([pending.signal, sharedInspectionLifetime.signal]) };
884
917
  }
@@ -889,15 +922,15 @@ export default function stateFlowExtension(pi, options = {}) {
889
922
  const cwd = ctx.cwd;
890
923
  const file = ctx.sessionManager.getSessionFile();
891
924
  const timestamp = ctx.sessionManager.getHeader()?.timestamp;
892
- const initiallyEnabled = snapshot.config.enabled;
925
+ const initiallyActive = isActive();
893
926
  let accepted = false;
894
927
  let receipt = AbortSignal.any([pending.signal, sharedInspectionLifetime.signal]);
895
928
  const isCurrent = () => startActivation?.controller === pending && runtime === selected && !shuttingDown
896
929
  && ctx.cwd === cwd && ctx.sessionManager.getSessionId() === owner && ctx.sessionManager.getSessionFile() === file
897
- && ctx.sessionManager.getHeader()?.timestamp === timestamp && snapshot.config.enabled === (accepted || initiallyEnabled);
930
+ && ctx.sessionManager.getHeader()?.timestamp === timestamp && isActive() === (accepted || initiallyActive);
898
931
  const superseded = () => {
899
932
  pending.abort();
900
- return { ok: false, message: "State Flow Start was superseded", signal: pending.signal };
933
+ return { ok: false, message: "State Flow activation was superseded", signal: pending.signal };
901
934
  };
902
935
  try {
903
936
  const activation = createRuntime(ctx);
@@ -905,7 +938,7 @@ export default function stateFlowExtension(pi, options = {}) {
905
938
  const result = await activation.withStartTransaction((current, publish) => {
906
939
  signal.throwIfAborted();
907
940
  if (!isCurrent())
908
- throw new Error("State Flow Start selection changed while awaiting publication");
941
+ throw new Error("State Flow activation selection changed while awaiting publication");
909
942
  if (!current && !branchStartsWithoutRuntime)
910
943
  throw new Error(snapshot.meta.validation?.error ?? "Current State Flow session memory is unavailable");
911
944
  const continuation = passiveContinuation ?? bootstrapContinuation;
@@ -914,12 +947,12 @@ export default function stateFlowExtension(pi, options = {}) {
914
947
  const recoveredCurrent = selectedHistoryExpired;
915
948
  const publication = publish(activated);
916
949
  accepted = true;
917
- cancelStopPersistence();
950
+ cancelInactivePersistence();
918
951
  runtime = selected = activation;
919
952
  snapshot = activated;
920
953
  activeContext = ctx;
921
954
  selectedHistoryExpired = false;
922
- stopPersistenceError = undefined;
955
+ modePersistenceError = undefined;
923
956
  installScopeStates();
924
957
  clearRunTransient();
925
958
  contextProjection.reset();
@@ -932,127 +965,176 @@ export default function stateFlowExtension(pi, options = {}) {
932
965
  updateUi(ctx);
933
966
  appendCheckpoint();
934
967
  ctx.ui.notify(recoveredCurrent
935
- ? "State Flow enabled from current session memory; unavailable historical state was not restored."
968
+ ? "State Flow active from current session memory; unavailable historical state was not restored."
936
969
  : snapshot.meta.bootstrap
937
- ? "State Flow enabled. The next complete agent run will migrate active context into state."
938
- : "State Flow enabled. The next prompt starts a stateful agent run.", "info");
939
- return { ok: true, message: "State Flow enabled", signal: receipt };
970
+ ? "State Flow active. The next complete agent run will migrate active context into state."
971
+ : "State Flow active. The next prompt starts a stateful agent run.", "info");
972
+ return { ok: true, message: "State Flow active", signal: receipt };
940
973
  }, signal, branchStartsWithoutRuntime);
941
974
  return isCurrent() ? result : superseded();
942
975
  }
943
976
  catch (error) {
944
977
  if (!isCurrent())
945
978
  return superseded();
946
- const message = conciseDiagnostic(`${accepted ? "State Flow enabled; lifecycle update failed" : "State Flow Start failed"}: ${diagnosticText(error)}`);
979
+ const message = conciseDiagnostic(`${accepted ? "State Flow active; lifecycle update failed" : "State Flow activation failed"}: ${diagnosticText(error)}`);
947
980
  notifyProblem(ctx, message, accepted ? "warning" : "error");
948
981
  return { ok: accepted, message, signal: receipt };
949
982
  }
950
983
  }
951
- pi.registerCommand("state-flow-start", {
952
- description: "Start State Flow mode",
953
- handler: async (_args, ctx) => {
954
- await startStateFlow(ctx);
955
- },
956
- });
984
+ const MODE_COMMAND_DESCRIPTIONS = {
985
+ active: "Make State Flow active on the current session branch",
986
+ passive: "Use passive State Flow memory on the current session branch",
987
+ off: "Turn State Flow tools and context off on the current session branch",
988
+ };
989
+ /** Terminal commands and Telegram controls share these lifecycle owners. */
990
+ function selectMode(ctx, mode) {
991
+ return mode === "active" ? startStateFlow(ctx) : deactivateStateFlow(ctx, mode);
992
+ }
993
+ for (const mode of ["active", "passive", "off"]) {
994
+ pi.registerCommand(`state-flow-${mode}`, {
995
+ description: MODE_COMMAND_DESCRIPTIONS[mode],
996
+ handler: async (_args, ctx) => {
997
+ await selectMode(ctx, mode);
998
+ },
999
+ });
1000
+ }
957
1001
  pi.registerCommand("state-flow-status", {
958
1002
  description: "Show State Flow runtime status",
959
1003
  handler: async (_args, ctx) => {
960
1004
  ctx.ui.notify(detailedStatus(snapshot, statusDiagnostics(ctx)), "info");
961
1005
  },
962
1006
  });
963
- function stopStateFlow(ctx) {
1007
+ /** Local policy, tools and UI change before any canonical wait. */
1008
+ function applyInactiveMode(ctx, mode) {
1009
+ snapshot = deactivateEpisode(snapshot, mode);
1010
+ clearRunTransient();
1011
+ syncStateFlowTools();
1012
+ updateUi(ctx);
1013
+ }
1014
+ function deactivateStateFlow(ctx, mode) {
964
1015
  cancelStartActivation();
965
1016
  if (shuttingDown)
966
1017
  return Promise.resolve({ ok: false, message: "State Flow is shutting down" });
967
- if (stopPersistence?.operation)
968
- return stopPersistence.operation;
1018
+ telegramStartPending = false;
1019
+ const persisting = inactivePersistence;
1020
+ if (persisting?.operation && !persisting.published) {
1021
+ // Pending inactive choices coalesce: acceptance persists whichever inactive mode is current then.
1022
+ if (snapshot.config.mode !== mode)
1023
+ applyInactiveMode(ctx, mode);
1024
+ return persisting.operation;
1025
+ }
969
1026
  if (!activeContext)
970
1027
  void restoreActiveBranch(ctx, undefined, false);
971
1028
  const restoring = branchRestoration;
1029
+ // Read-only recovery also retains the latest policy; its native write fence is updated below.
1030
+ if (restoring)
1031
+ restoring.requestedMode = mode;
972
1032
  if (restoring?.awaitingAcceptance) {
973
- // Stop changes policy without cancelling memory restoration or initialization.
974
- restoring.passiveRequested = true;
975
- snapshot = stopEpisode(snapshot);
976
- clearRunTransient();
977
- syncStateFlowTools();
978
- updateUi(ctx);
979
- return Promise.resolve({ ok: true, message: "State Flow disabled; memory restoration continues" });
1033
+ // A mode change never cancels memory restoration or initialization.
1034
+ applyInactiveMode(ctx, mode);
1035
+ return Promise.resolve({ ok: true, message: `State Flow ${mode}; memory restoration continues` });
980
1036
  }
1037
+ if (snapshot.config.mode === mode) {
1038
+ const retained = selectRetainedCheckpoint(discoverSnapshotData(ctx.sessionManager.getBranch()).candidates, config.inactiveMode);
1039
+ const recordedMode = retained.kind === "pre-runtime" ? retained.mode : retained.kind === "boundary" ? retained.checkpoint.mode : undefined;
1040
+ if (modePersistenceError || recordedMode === mode)
1041
+ return Promise.resolve({ ok: true, message: `State Flow is already ${mode}` });
1042
+ }
1043
+ cancelInactivePersistence();
981
1044
  const pending = { controller: new AbortController() };
982
- stopPersistence = pending;
983
- const operation = persistStoppedState(ctx, pending.controller);
1045
+ inactivePersistence = pending;
1046
+ const operation = persistInactiveMode(ctx, pending, mode);
984
1047
  pending.operation = operation;
985
- const finished = () => { if (stopPersistence === pending)
986
- stopPersistence = undefined; };
1048
+ const finished = () => { if (inactivePersistence === pending)
1049
+ inactivePersistence = undefined; };
987
1050
  void operation.then(finished, finished);
988
1051
  return operation;
989
1052
  }
990
- async function persistStoppedState(ctx, pending) {
991
- telegramStartPending = false;
992
- const selected = runtime;
1053
+ async function persistInactiveMode(ctx, pending, mode) {
1054
+ let selected = runtime;
993
1055
  const owner = ctx.sessionManager.getSessionId();
994
1056
  const current = snapshot;
1057
+ const wasActive = current.config.mode === "active";
995
1058
  let unfinished = current.meta.specification !== undefined
996
1059
  || (inferencePreparation?.prompt !== undefined && !inferencePreparation.accepted);
997
- snapshot = stopEpisode(current);
998
- clearRunTransient();
999
- syncStateFlowTools();
1000
- updateUi(ctx);
1001
- if (stopPersistenceError)
1002
- return { ok: true, message: "State Flow disabled; memory writes remain paused" };
1060
+ applyInactiveMode(ctx, mode);
1003
1061
  const stoppedAt = Date.now();
1062
+ if (modePersistenceError) {
1063
+ // Writes stay paused; the native marker records only the newly selected inactive policy.
1064
+ pending.published = true;
1065
+ pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, {
1066
+ at: passiveContinuation?.startedAt ?? stoppedAt,
1067
+ ...(passiveContinuation?.activeRunStartedAt === undefined ? {} : { from: passiveContinuation.activeRunStartedAt }),
1068
+ preserveContext: true, owner, persistenceError: modePersistenceError, mode,
1069
+ });
1070
+ return { ok: true, message: `State Flow ${mode}; memory writes remain paused` };
1071
+ }
1004
1072
  const incomingBoundary = current.meta.bootstrap ? bootstrapContinuation : undefined;
1005
1073
  const anchor = runAnchorTimestamp;
1006
1074
  const idle = ctx.isIdle();
1007
- const isCurrent = () => stopPersistence?.controller === pending && runtime === selected
1008
- && !shuttingDown && ctx.sessionManager.getSessionId() === owner && !snapshot.config.enabled;
1009
- const superseded = () => ({ ok: false, message: "State Flow Stop was superseded" });
1075
+ const isCurrent = () => inactivePersistence === pending && runtime === selected
1076
+ && !shuttingDown && ctx.sessionManager.getSessionId() === owner && !isActive();
1077
+ const superseded = () => ({ ok: false, message: "State Flow mode change was superseded" });
1010
1078
  const freezeHandoff = () => {
1011
1079
  contextProjection.reset();
1012
- const handoff = (current.config.enabled || stopPersistenceError) && selected?.view && current.meta.validation?.attempt !== 0
1013
- ? createPassiveContinuation(projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)), incomingBoundary?.startedAt ?? stoppedAt, incomingBoundary ? incomingBoundary.activeRunStartedAt : !idle || unfinished ? anchor : undefined, stopPersistenceError !== undefined || (incomingBoundary ? incomingBoundary.preserveContext : current.meta.bootstrap === true || (unfinished && anchor === undefined)))
1080
+ const handoff = (wasActive || modePersistenceError) && selected?.view && current.meta.validation?.attempt !== 0
1081
+ ? createPassiveContinuation(projectModelState(effectiveState), incomingBoundary?.startedAt ?? stoppedAt, incomingBoundary ? incomingBoundary.activeRunStartedAt : !idle || unfinished ? anchor : undefined, modePersistenceError !== undefined || (incomingBoundary ? incomingBoundary.preserveContext : current.meta.bootstrap === true || (unfinished && anchor === undefined)))
1014
1082
  : undefined;
1015
- passiveContinuation = handoff ?? (!current.config.enabled ? passiveContinuation : undefined);
1083
+ // Off keeps the frozen boundary for a later Passive/Active choice but never projects it.
1084
+ passiveContinuation = handoff ?? (!wasActive ? passiveContinuation : undefined);
1016
1085
  return handoff;
1017
1086
  };
1018
1087
  const complete = () => {
1088
+ pending.published = true;
1019
1089
  const exitHandoff = freezeHandoff();
1020
- if (exitHandoff || stopPersistenceError)
1090
+ const selectedMode = snapshot.config.mode;
1091
+ if (exitHandoff || modePersistenceError)
1021
1092
  pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, {
1022
1093
  at: exitHandoff?.startedAt ?? stoppedAt,
1023
1094
  ...(exitHandoff?.activeRunStartedAt === undefined ? {} : { from: exitHandoff.activeRunStartedAt }),
1024
- ...(exitHandoff?.preserveContext || stopPersistenceError ? { preserveContext: true } : {}),
1025
- ...(stopPersistenceError === undefined ? {} : { owner, persistenceError: stopPersistenceError }),
1095
+ ...(exitHandoff?.preserveContext || modePersistenceError ? { preserveContext: true } : {}),
1096
+ ...(modePersistenceError === undefined ? {} : { owner, persistenceError: modePersistenceError, mode: selectedMode }),
1026
1097
  });
1027
- if (!stopPersistenceError) {
1098
+ if (!modePersistenceError) {
1028
1099
  appendCheckpoint();
1029
- return { ok: true, message: "State Flow disabled" };
1100
+ return { ok: true, message: `State Flow ${selectedMode}` };
1030
1101
  }
1031
- const message = conciseDiagnostic(`State Flow disabled; memory writes paused: ${stopPersistenceError}`);
1102
+ const message = conciseDiagnostic(`State Flow ${selectedMode}; memory writes paused: ${modePersistenceError}`);
1032
1103
  notifyProblem(ctx, message, "warning");
1033
1104
  return { ok: true, message };
1034
1105
  };
1035
1106
  let accepted = false;
1036
1107
  try {
1037
- unfinished ||= current.config.enabled && hasUncheckpointedConversation(ctx.sessionManager.getBranch());
1108
+ unfinished ||= wasActive && hasUncheckpointedConversation(ctx.sessionManager.getBranch());
1038
1109
  // Projection changes before any wait; capture the old run's boundary before later native input can replace it.
1039
1110
  freezeHandoff();
1040
1111
  bootstrapContinuation = undefined;
1041
1112
  artifactInvalidations = [];
1042
1113
  artifactReads.setCandidates([]);
1043
1114
  assertPublicationAvailable();
1044
- if (branchStartsWithoutRuntime || !selected?.view)
1045
- return complete();
1046
- const signal = ctx.signal ? AbortSignal.any([pending.signal, ctx.signal]) : pending.signal;
1115
+ const signal = ctx.signal ? AbortSignal.any([pending.controller.signal, ctx.signal]) : pending.controller.signal;
1116
+ if (branchStartsWithoutRuntime || !selected?.view) {
1117
+ // A pre-runtime choice is native-only; Passive then loads shared memory without initializing storage.
1118
+ const result = complete();
1119
+ if (snapshot.config.mode !== "passive" || runtime?.view)
1120
+ return result;
1121
+ const passive = await loadPassiveView(ctx, signal, isCurrent, true);
1122
+ if (passive && isCurrent() && !signal.aborted && !runtime?.view) {
1123
+ runtime = selected = passive;
1124
+ installScopeStates();
1125
+ updateUi(ctx);
1126
+ }
1127
+ return isCurrent() ? result : superseded();
1128
+ }
1047
1129
  const result = await selected.withLifecycleTransaction((publish) => {
1048
1130
  signal.throwIfAborted();
1049
1131
  if (!isCurrent())
1050
- throw new Error("State Flow Stop selection changed while awaiting publication");
1132
+ throw new Error("State Flow mode selection changed while awaiting publication");
1051
1133
  assertPublicationAvailable();
1052
- const stopped = stopEpisode(snapshot);
1053
- const publication = publish(stopped);
1134
+ const next = structuredClone(snapshot);
1135
+ const publication = publish(next);
1054
1136
  accepted = true;
1055
- snapshot = stopped;
1137
+ snapshot = next;
1056
1138
  installScopeStates();
1057
1139
  recordPolicyPublication(publication, ctx);
1058
1140
  return complete();
@@ -1064,28 +1146,22 @@ export default function stateFlowExtension(pi, options = {}) {
1064
1146
  return superseded();
1065
1147
  const cause = diagnosticText(error);
1066
1148
  if (accepted) {
1067
- const message = conciseDiagnostic(`State Flow disabled; lifecycle update failed: ${cause}`);
1149
+ const message = conciseDiagnostic(`State Flow ${snapshot.config.mode}; lifecycle update failed: ${cause}`);
1068
1150
  notifyProblem(ctx, message, "warning");
1069
1151
  return { ok: true, message };
1070
1152
  }
1071
- stopPersistenceError = cause.trim() ? cause : "Canonical State Flow persistence failed";
1153
+ modePersistenceError = cause.trim() ? cause : "Canonical State Flow persistence failed";
1072
1154
  bootstrapContinuation = undefined;
1073
1155
  artifactInvalidations = [];
1074
1156
  artifactReads.setCandidates([]);
1075
1157
  return complete();
1076
1158
  }
1077
1159
  }
1078
- pi.registerCommand("state-flow-stop", {
1079
- description: "Stop State Flow on the current session branch",
1080
- handler: async (_args, ctx) => {
1081
- await stopStateFlow(ctx);
1082
- },
1083
- });
1084
1160
  const telegram = createStateFlowTelegramAdapter({
1085
1161
  ...(options.telegram?.load === undefined ? {} : { load: options.telegram.load }),
1086
1162
  port: {
1087
1163
  snapshot: () => ({
1088
- enabled: snapshot.config.enabled,
1164
+ mode: snapshot.config.mode,
1089
1165
  step: snapshot.meta.step,
1090
1166
  revisions: scopeRevisions(),
1091
1167
  bootstrap: snapshot.meta.bootstrap === true,
@@ -1093,15 +1169,13 @@ export default function stateFlowExtension(pi, options = {}) {
1093
1169
  }),
1094
1170
  inspect: inspectTelegramState,
1095
1171
  canStartNow: () => activeContext === undefined || activeContext.isIdle(),
1096
- start: () => {
1172
+ select: async (mode) => {
1097
1173
  if (!activeContext)
1098
1174
  throw new Error("State Flow is not attached to an active session yet");
1099
- return startStateFlow(activeContext);
1100
- },
1101
- stop: async () => {
1102
- if (!activeContext)
1103
- throw new Error("State Flow is not attached to an active session yet");
1104
- const operation = stopStateFlow(activeContext);
1175
+ const operation = selectMode(activeContext, mode);
1176
+ if (mode === "active")
1177
+ return operation;
1178
+ // Inactive choices revoke older presentation at once; the receipt belongs to the new lifetime.
1105
1179
  const signal = sharedInspectionLifetime.signal;
1106
1180
  try {
1107
1181
  return { ...await operation, signal };
@@ -1123,8 +1197,8 @@ export default function stateFlowExtension(pi, options = {}) {
1123
1197
  pi.on("before_agent_start", (event) => {
1124
1198
  // Native run identity is independent of semantic enablement and survives mode toggles.
1125
1199
  runAnchorTimestamp = undefined;
1126
- if (!snapshot.config.enabled) {
1127
- if (!config.passiveBootstrap || !runtime?.view)
1200
+ if (!isActive()) {
1201
+ if (!passiveMemoryAvailable())
1128
1202
  return;
1129
1203
  (event.systemPromptOptions.sections ??= {}).state_flow = PASSIVE_MEMORY_PROTOCOL;
1130
1204
  return;
@@ -1139,21 +1213,25 @@ export default function stateFlowExtension(pi, options = {}) {
1139
1213
  (event.systemPromptOptions.sections ??= {}).state_flow = stateFlowProtocol(snapshot.meta.bootstrap === true);
1140
1214
  });
1141
1215
  pi.on("context_with_system", (event) => {
1142
- const protocol = snapshot.config.enabled ? stateFlowProtocol(snapshot.meta.bootstrap === true)
1143
- : config.passiveBootstrap && runtime?.view ? PASSIVE_MEMORY_PROTOCOL : undefined;
1216
+ // Off removes the owned section, including one contributed before an in-flight mode change.
1217
+ const protocol = isActive() ? stateFlowProtocol(snapshot.meta.bootstrap === true)
1218
+ : passiveMemoryAvailable() ? PASSIVE_MEMORY_PROTOCOL : undefined;
1144
1219
  return { messages: projectSystemProtocol(event.messages, protocol) };
1145
1220
  });
1146
1221
  function projectContext(messages) {
1222
+ // Off injects no State Flow context, including a retained passive handoff.
1223
+ if (snapshot.config.mode === "off")
1224
+ return;
1147
1225
  if (runtime?.view)
1148
1226
  refreshArtifactHints();
1149
- if (!snapshot.config.enabled && !passiveContinuation && (!config.passiveBootstrap || !runtime?.view))
1227
+ if (!isActive() && !passiveContinuation && !passiveMemoryAvailable())
1150
1228
  return;
1151
1229
  // Idle inspection must not freeze a pre-acceptance snapshot for the live inference.
1152
- const projection = snapshot.config.enabled && inferencePreparation && !inferencePreparation.accepted
1230
+ const projection = isActive() && inferencePreparation && !inferencePreparation.accepted
1153
1231
  ? new ContextProjection() : contextProjection;
1154
- const effective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
1232
+ const effective = effectiveState;
1155
1233
  const invalidations = artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason }));
1156
- const phase = snapshot.config.enabled ? currentRehydrationPhase() : undefined;
1234
+ const phase = isActive() ? currentRehydrationPhase() : undefined;
1157
1235
  const view = contextView(effective, artifactHints, invalidations, phase);
1158
1236
  if (passiveContinuation) {
1159
1237
  const retained = passiveContinuationMessages(messages, passiveContinuation);
@@ -1161,7 +1239,7 @@ export default function stateFlowExtension(pi, options = {}) {
1161
1239
  return { messages: retained };
1162
1240
  return { messages: projection.project(retained.slice(1), view, () => passiveContinuation.handoff, { state: passiveContinuation.state, lazy_navigation: view.lazy_navigation, artifact_invalidations: [], knowledge_rehydration: null }) };
1163
1241
  }
1164
- if (!snapshot.config.enabled) {
1242
+ if (!isActive()) {
1165
1243
  return { messages: projection.project(messages, view, () => syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state: view.state, lazy_navigation: view.lazy_navigation })}`)) };
1166
1244
  }
1167
1245
  // Native user events own the run anchor; projection must never rebase it.
@@ -1175,7 +1253,7 @@ export default function stateFlowExtension(pi, options = {}) {
1175
1253
  const selected = runtime;
1176
1254
  const operationSignal = ctx.signal;
1177
1255
  // Idle projections remain observational; only a live native operation may await preparation.
1178
- if (!snapshot.config.enabled || !pending || !operationSignal || shuttingDown)
1256
+ if (!isActive() || !pending || !operationSignal || shuttingDown)
1179
1257
  return projectContext(messages);
1180
1258
  pending.operation ??= prepareInference(pending, selected, ctx, operationSignal).finally(() => {
1181
1259
  // A late withdrawal must not clear newer work; accepted lifecycle is never replayed after an error.
@@ -1185,20 +1263,20 @@ export default function stateFlowExtension(pi, options = {}) {
1185
1263
  return pending.operation.then(() => {
1186
1264
  if (shuttingDown || operationSignal.aborted || runtime !== selected || ctx.signal !== operationSignal)
1187
1265
  return;
1188
- if (snapshot.config.enabled && inferencePreparation !== pending) {
1266
+ if (isActive() && inferencePreparation !== pending) {
1189
1267
  // Start may require same-run maintenance. A new captured user prompt instead owns a different context request.
1190
1268
  if (inferencePreparation?.prompt === undefined)
1191
1269
  return prepareContext(messages, ctx);
1192
1270
  return;
1193
1271
  }
1194
- if (snapshot.config.enabled && !pending.accepted)
1272
+ if (isActive() && !pending.accepted)
1195
1273
  return;
1196
1274
  return projectContext(messages);
1197
1275
  });
1198
1276
  }
1199
1277
  pi.on("context", (event, ctx) => prepareContext(event.messages, ctx));
1200
1278
  pi.on("tool_execution_start", (event) => {
1201
- if (!snapshot.config.enabled)
1279
+ if (!isActive())
1202
1280
  return;
1203
1281
  // Pi emits this before tool_call. Keep the argument object as a fallback;
1204
1282
  // tool_call replaces it with the mutable, post-preflight input reference.
@@ -1206,36 +1284,34 @@ export default function stateFlowExtension(pi, options = {}) {
1206
1284
  artifactReads.recordStart(event.toolCallId, event.toolName, event.args);
1207
1285
  });
1208
1286
  pi.on("tool_call", (event, ctx) => {
1209
- if (!snapshot.config.enabled)
1287
+ if (!isActive())
1210
1288
  return;
1211
1289
  const batch = findAssistantToolBatch(ctx.sessionManager, event.toolCallId);
1212
1290
  const patchCalls = batch?.filter((name) => name === PATCH_STATE_TOOL_NAME).length ?? 0;
1213
1291
  if (patchCalls > 0) {
1214
1292
  if (patchCalls !== 1) {
1215
- return {
1216
- block: true,
1217
- reason: "A State Flow barrier response must contain exactly one patch_state call",
1218
- };
1293
+ const reason = "A State Flow barrier response must contain exactly one patch_state call";
1294
+ recordDiagnostic(reason, "barrier-block", ctx, { tool: event.toolName, toolCallId: event.toolCallId, batchToolNames: batch });
1295
+ return { block: true, reason };
1219
1296
  }
1220
1297
  if (event.toolName !== PATCH_STATE_TOOL_NAME) {
1221
- return {
1222
- block: true,
1223
- reason: "Blocked by the patch_state barrier; reconsider this action after State Flow rematerializes context",
1224
- };
1298
+ const reason = "Blocked by the patch_state barrier; reconsider this action after State Flow rematerializes context";
1299
+ recordDiagnostic(reason, "barrier-block", ctx, { tool: event.toolName, toolCallId: event.toolCallId, batchToolNames: batch });
1300
+ return { block: true, reason };
1225
1301
  }
1226
1302
  }
1227
1303
  skillReads.recordCall(event.toolCallId, event.toolName, event.input);
1228
1304
  artifactReads.recordCall(event.toolCallId, event.toolName, event.input);
1229
1305
  });
1230
1306
  pi.on("tool_execution_end", (event) => {
1231
- if (!snapshot.config.enabled)
1307
+ if (!isActive())
1232
1308
  return;
1233
1309
  skillReads.recordEnd(event.toolCallId, event.toolName, event.isError);
1234
1310
  dropCurrentSkillReads();
1235
1311
  artifactReads.recordEnd(event.toolCallId, event.toolName, event.isError);
1236
1312
  });
1237
1313
  pi.on("tool_result", (event) => {
1238
- if (!snapshot.config.enabled)
1314
+ if (!isActive())
1239
1315
  return;
1240
1316
  const read = skillReads.recordResult(event.toolName, event.input, event.isError);
1241
1317
  if (!read)
@@ -1250,7 +1326,7 @@ export default function stateFlowExtension(pi, options = {}) {
1250
1326
  // Observe actual user events even while disabled; Start/Stop cannot invent or erase them.
1251
1327
  if (event.message.role === "user" && runAnchorTimestamp === undefined)
1252
1328
  runAnchorTimestamp = event.message.timestamp;
1253
- if (shuttingDown || !snapshot.config.enabled)
1329
+ if (shuttingDown || !isActive())
1254
1330
  return;
1255
1331
  if (event.message.role !== "assistant")
1256
1332
  return;
@@ -1268,7 +1344,7 @@ export default function stateFlowExtension(pi, options = {}) {
1268
1344
  if (shuttingDown)
1269
1345
  return;
1270
1346
  const pending = responseReconciliation;
1271
- if (!snapshot.config.enabled || !pending) {
1347
+ if (!isActive() || !pending) {
1272
1348
  updateUi(ctx);
1273
1349
  return;
1274
1350
  }
@@ -1281,7 +1357,7 @@ export default function stateFlowExtension(pi, options = {}) {
1281
1357
  throw new Error("Temporal State Flow runtime is unavailable; reload before publishing");
1282
1358
  await selected.withPatchTransaction((transaction) => {
1283
1359
  signal.throwIfAborted();
1284
- if (runtime !== selected || responseReconciliation !== pending || !snapshot.config.enabled) {
1360
+ if (runtime !== selected || responseReconciliation !== pending || !isActive()) {
1285
1361
  throw new Error("State Flow response selection changed while awaiting publication");
1286
1362
  }
1287
1363
  assertPublicationAvailable();
@@ -1383,12 +1459,12 @@ export default function stateFlowExtension(pi, options = {}) {
1383
1459
  }
1384
1460
  });
1385
1461
  pi.on("agent_settled", async (_event, ctx) => {
1386
- if (telegramStartPending && !snapshot.config.enabled) {
1462
+ if (telegramStartPending && !isActive()) {
1387
1463
  telegramStartPending = false;
1388
1464
  await startStateFlow(ctx);
1389
1465
  return;
1390
1466
  }
1391
- if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap
1467
+ if (!completedRunAccepted || compactionStopped || !isActive() || snapshot.meta.bootstrap
1392
1468
  || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !runtime?.view
1393
1469
  || !shouldRequestStateFlowCompaction(ctx.getContextUsage()))
1394
1470
  return;
@@ -1424,7 +1500,7 @@ export default function stateFlowExtension(pi, options = {}) {
1424
1500
  contextProjection.reset();
1425
1501
  const restoring = cancelBranchRestoration();
1426
1502
  const starting = cancelStartActivation();
1427
- const stopping = cancelStopPersistence();
1503
+ const stopping = cancelInactivePersistence();
1428
1504
  backupLifetime.abort();
1429
1505
  cancelInferencePreparation();
1430
1506
  cancelResponseReconciliation();