@llblab/pi-kit 0.18.2 → 0.19.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 (113) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +3 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +35 -37
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -1
  6. package/node_modules/@llblab/pi-state-flow/README.md +102 -48
  7. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +6 -9
  8. package/node_modules/@llblab/pi-state-flow/dist/index.js +6 -9
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +2 -2
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +21 -8
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +40 -24
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +93 -63
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +4 -3
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +9 -9
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +1 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +6 -5
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -7
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +56 -58
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +12 -24
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +7 -18
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +47 -78
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +4 -6
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +241 -437
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -72
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +120 -499
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +2 -1
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +4 -3
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +3 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +43 -22
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +1 -5
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +0 -2
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +1 -14
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +5 -37
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +13 -32
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +6 -6
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +25 -21
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +3 -3
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +15 -12
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +2 -4
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +5 -1
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +26 -88
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +159 -255
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +0 -3
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +1 -47
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +16 -33
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +48 -137
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +16 -11
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +13 -14
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -9
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +15 -43
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +5 -9
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +13 -45
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +15 -7
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +104 -24
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +0 -2
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +11 -14
  59. package/node_modules/@llblab/pi-state-flow/dist/package.json +9 -6
  60. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +11 -17
  61. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +8 -8
  62. package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -2
  63. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +64 -67
  64. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +24 -6
  65. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -15
  66. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +22 -27
  67. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +25 -41
  68. package/node_modules/@llblab/pi-state-flow/docs/performance.md +38 -421
  69. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +33 -46
  70. package/node_modules/@llblab/pi-state-flow/docs/usage.md +37 -61
  71. package/node_modules/@llblab/pi-state-flow/index.ts +7 -71
  72. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +26 -11
  73. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +116 -88
  74. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +12 -10
  75. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -6
  76. package/node_modules/@llblab/pi-state-flow/lib/context.ts +52 -59
  77. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +11 -20
  78. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +44 -86
  79. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +237 -460
  80. package/node_modules/@llblab/pi-state-flow/lib/git.ts +110 -552
  81. package/node_modules/@llblab/pi-state-flow/lib/history.ts +4 -3
  82. package/node_modules/@llblab/pi-state-flow/lib/json.ts +39 -23
  83. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +1 -7
  84. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +5 -44
  85. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -25
  86. package/node_modules/@llblab/pi-state-flow/lib/query.ts +26 -22
  87. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +17 -11
  88. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +7 -5
  89. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +154 -254
  90. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +1 -49
  91. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +58 -142
  92. package/node_modules/@llblab/pi-state-flow/lib/state.ts +27 -19
  93. package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -54
  94. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +12 -40
  95. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +1 -1
  96. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +104 -22
  97. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +16 -26
  98. package/node_modules/@llblab/pi-state-flow/package.json +9 -6
  99. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +11 -17
  100. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +8 -8
  101. package/package.json +2 -2
  102. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +0 -21
  103. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +0 -125
  104. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +0 -36
  105. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +0 -98
  106. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +0 -13
  107. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +0 -167
  108. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +0 -86
  109. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +0 -437
  110. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +0 -133
  111. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +0 -147
  112. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +0 -171
  113. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +0 -458
@@ -1,63 +1,61 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { randomUUID } from "node:crypto";
3
2
  import { StringEnum, Type } from "@earendil-works/pi-ai";
4
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
5
  import { Text } from "@earendil-works/pi-tui";
7
- import { assistantToolCallCount, finalizedAssistantResponse, formatPatchStateArguments, normalizePatchStateArguments, separatedFailure, separatedOutput, stateFlowProtocol } from "./protocol.ts";
8
- import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, passiveContinuationMessages, runtimeContextMessage, syntheticUser, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
6
+ import { randomUUID } from "node:crypto";
7
+ import { existsSync } from "node:fs";
8
+ import { isAbsolute, join, resolve } from "node:path";
9
9
  import { ArtifactReadTracker } from "./acquisition.ts";
10
+ import {
11
+ classifyArtifactCompilationNeed,
12
+ inspectRegisteredArtifactPaths,
13
+ ORDINARY_ARTIFACT_COMPILER,
14
+ sameArtifactSourceFingerprint,
15
+ type ArtifactInvalidationRequest,
16
+ } from "./artifact.ts";
17
+ import { hasCompactionSizedTranscript, planStateFlowCompaction, shouldRequestStateFlowCompaction, stateFlowCompactionResult, type StateFlowCompactionPlan } from "./compaction.ts";
10
18
  import { loadStateFlowConfig } from "./config.ts";
11
- import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
12
- import { isAbsolute, resolve } from "node:path";
13
- import { SkillReadTracker } from "./skills.ts";
14
- import { emptySnapshot, migrationFailure, persistableSnapshot, RevisionUnavailableError, type Snapshot } from "./snapshot.ts";
19
+ import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, passiveContinuationMessages, projectSystemProtocol, runtimeContextMessage, syntheticUser, type PassiveContinuation } from "./context.ts";
15
20
  import { readNativeSessionHeader } from "./continuation.ts";
16
- import { MissingSessionRuntimeError, SharedScopeRemovalConflictError, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
17
- import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
18
- import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
19
- import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
20
- import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
21
- import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
22
- import { recoverSnapshot } from "./recovery.ts";
23
- import type { RehydrationPhase } from "./rehydration.ts";
24
- import { loadPublicationQueue, publicationQueuePath, PublicationWorkerController, resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument, type PublicationQueueState } from "./publication.ts";
25
- import { getKnowledgeRoot, GlobalMarkdownDiscovery } from "./discovery.ts";
26
- import { canonicalJson, isObject, sameJson } from "./json.ts";
27
- import { readProjectedState, readStatePath } from "./query.ts";
28
21
  import {
29
- cwdScopeKey,
30
- resolveSessionAddress,
31
- sessionScopeKey,
32
- type SessionAddress,
22
+ cwdScopeKey,
23
+ resolveSessionAddress,
24
+ sessionScopeKey,
25
+ type SessionAddress,
33
26
  } from "./durable.ts";
34
- import { projectRecentTransitionsWithLimit, RECENT_TRANSITION_LIMIT } from "./history.ts";
27
+ import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
28
+ import { backupCurrentStateFlowFiles } from "./git.ts";
29
+ import { projectRecentTransitionsWithLimit } from "./history.ts";
30
+ import { isObject, presentationJson, sameJson, type JsonObject } from "./json.ts";
35
31
  import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
36
- import { isGitCommitAncestor, pushGitCommit, pushGitTarget, resolveGitPushDestination } from "./git.ts";
37
- import {
38
- ORDINARY_ARTIFACT_COMPILER,
39
- planArtifactInvalidation,
40
- type ArtifactInvalidationRequest,
41
- } from "./artifact.ts";
42
- import { hasCompactionSizedTranscript, planStateFlowCompaction, shouldRequestStateFlowCompaction, stateFlowCompactionResult, type StateFlowCompactionPlan } from "./compaction.ts";
32
+ import { assistantToolCallCount, finalizedAssistantResponse, formatPatchStateArguments, PASSIVE_MEMORY_PROTOCOL, separatedFailure, separatedOutput, stateFlowProtocol } from "./protocol.ts";
33
+ import { readProjectedState, readStatePath } from "./query.ts";
34
+ import { recoverSnapshot } from "./recovery.ts";
35
+ import type { RehydrationPhase } from "./rehydration.ts";
36
+ import { SharedScopeRemovalConflictError, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
37
+ import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
38
+ import { SkillReadTracker } from "./skills.ts";
39
+ import { emptySnapshot, migrationFailure, type Snapshot } from "./snapshot.ts";
40
+ import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ModelState, type ScopedStates, type StateScope } from "./state.ts";
41
+ import { compactStatus, detailedStatus, STATUS_KEY, type StatusDiagnostics } from "./status.ts";
42
+ import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
43
+ import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, type StagedScopedTransition } from "./transition.ts";
43
44
 
44
45
  export interface StateFlowExtensionOptions {
45
46
  agentDir?: string;
46
47
  repositoryRoot?: string;
47
- knowledgeRoot?: string;
48
- onRuntime?: (accessor: { read(offset?: number, scope?: StateScope): MaterializedState }) => void;
48
+ onRuntime?: (accessor: { read(offset?: number, scope?: StateScope): ModelState }) => void;
49
49
  telegram?: { load?: StateFlowTelegramLoader };
50
50
  /** Test/SDK capability override; repository config remains the Pi default. */
51
51
  passive?: { bootstrap?: boolean; tools?: boolean };
52
52
  }
53
53
 
54
- export { formatPatchStateArguments, normalizePatchStateArguments };
54
+ export { formatPatchStateArguments };
55
55
 
56
56
  export const PATCH_STATE_TOOL_NAME = "patch_state";
57
57
  export const READ_STATE_TOOL_NAME = "read_state";
58
- export const MAX_FALLBACK_ATTEMPTS: number = 2;
59
58
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
60
- const PUBLICATION_SHUTDOWN_WAIT_MS = 2_000;
61
59
 
62
60
  export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
63
61
  const agentDir = options.agentDir ?? getAgentDir();
@@ -72,12 +70,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
72
70
  let branchHasSnapshot = false;
73
71
  let branchStartsWithoutRuntime = false;
74
72
  let forkInitialization = false;
75
- let terminalEligible = false;
76
- let resolutionPending = false;
77
- let fallbackAttempts = 0;
78
- let fallbackFailureReported = false;
79
73
  let responseAwaitingReconciliation = false;
80
74
  let completedRunAccepted = false;
75
+ let turnAcceptedForBackup = false;
81
76
  let compactionPlan: StateFlowCompactionPlan | undefined;
82
77
  let compactionInFlight = false;
83
78
  let compactionStopped = false;
@@ -88,25 +83,15 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
88
83
  let runAnchorTimestamp: number | undefined;
89
84
  let runtime: TemporalRuntime | undefined;
90
85
  let activeContext: ExtensionContext | undefined;
91
- let pendingPublication: PendingPublicationDiagnostic | undefined;
92
86
  let rehydrationPhase: RehydrationPhase | undefined;
93
- let turnPublicationTarget: string | undefined;
94
- let publicationShutdown: Promise<void> | undefined;
95
87
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
96
88
  const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => activeContext?.ui.notify(message, "warning"));
97
- const publicationWorker = new PublicationWorkerController({
98
- resolveDestination: () => resolveGitPushDestination(repositoryRoot),
99
- isAncestor: (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant),
100
- push: (destination, target, signal) => pushGitTarget(repositoryRoot, destination, target, signal),
101
- onDiverged: (dropped, target) => activeContext && recordDiagnostic(
102
- `Retired publication queue target ${dropped.target} after a journal lineage rewrite; retargeting to ${target}`,
103
- "publication-conflict", activeContext,
104
- ),
105
- });
89
+ let backupPending = false;
106
90
  const skillReads = new SkillReadTracker();
107
91
  const artifactReads = new ArtifactReadTracker();
108
- const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(agentDir));
109
92
  let artifactInvalidations: ArtifactInvalidationRequest[] = [];
93
+ let artifactHints: Record<string, string> = {};
94
+ const SOURCE_CHANGED_HINT = "Source changed since this artifact was compiled. Read and recompile it before relying on it.";
110
95
  let telegramStartPending = false;
111
96
 
112
97
  function sessionAddress(ctx: ExtensionContext): SessionAddress {
@@ -114,47 +99,49 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
114
99
  }
115
100
 
116
101
  function createRuntime(ctx: ExtensionContext): TemporalRuntime {
117
- return new TemporalRuntime(ctx.cwd, sessionAddress(ctx), repositoryRoot);
102
+ return new TemporalRuntime(ctx.cwd, sessionAddress(ctx), repositoryRoot, undefined, config.historyLimit);
103
+ }
104
+
105
+ function projectModelState(state: MaterializedState): ModelState {
106
+ return projectStateForModel(state, artifactHints);
107
+ }
108
+
109
+ function assertSelectedBranchAvailable(): void {
110
+ if (snapshot.meta.validation?.attempt === 0) {
111
+ throw new Error(`State Flow selected branch is unavailable: ${snapshot.meta.validation.error}. Restore its canonical files and retry /state-flow-start, or select a retained boundary.`);
112
+ }
118
113
  }
119
114
 
120
115
  options.onRuntime?.({ read: (offset, scope) => {
116
+ if (scope === "session") assertSelectedBranchAvailable();
121
117
  if (!runtime) throw new Error("State Flow temporal runtime is unavailable");
122
- return projectStateForModel(runtime.read(offset, scope));
118
+ return projectModelState(runtime.read(offset, scope));
123
119
  } });
124
120
 
125
121
  function persist(): void {
126
- const mode = snapshot.meta.remotePublication?.mode ?? "transition";
127
- const publication = runtime?.view ? runtime.publish(snapshot, false, undefined, { pushRemote: mode === "transition" }) : undefined;
128
- if (publication?.commit && mode === "turn-end") turnPublicationTarget = publication.commit;
122
+ assertSelectedBranchAvailable();
123
+ const previousView = runtime?.view;
124
+ const publication = !branchStartsWithoutRuntime && runtime?.view ? runtime.publish(snapshot, false, undefined) : undefined;
125
+ if (runtime?.view !== previousView) installScopeStates();
129
126
  if (publication && activeContext) recordPublication(publication, activeContext);
130
- const checkpoint = persistableSnapshot(snapshot);
127
+ const checkpoint = branchStartsWithoutRuntime ? { disabled: true } : runtime?.retainedCheckpoint(snapshot) ?? { disabled: true };
131
128
  if ("disabled" in checkpoint && !branchStartsWithoutRuntime) {
132
129
  throw new Error("State Flow cannot checkpoint an unproven branch as ordinary disabled; restore a valid checkpoint first");
133
130
  }
134
131
  pi.appendEntry(SNAPSHOT_ENTRY_TYPE, checkpoint);
135
- if ("revision" in checkpoint) branchStartsWithoutRuntime = false;
132
+ if ("boundary" in checkpoint) branchStartsWithoutRuntime = false;
136
133
  }
137
134
 
138
135
  function updateUi(ctx: ExtensionContext): void {
139
136
  ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, (color, text) => ctx.ui.theme.fg(color, text)));
140
137
  }
141
138
 
142
- function setPendingPublication(value: PendingPublicationDiagnostic | undefined): void {
143
- pendingPublication = value === undefined ? undefined : structuredClone(value);
144
- if (value === undefined) delete snapshot.meta.pendingPublication;
145
- else snapshot.meta.pendingPublication = structuredClone(value);
146
- }
147
-
148
139
  function clearRunTransient(): void {
149
- terminalEligible = false;
150
- resolutionPending = false;
151
- fallbackAttempts = 0;
152
- fallbackFailureReported = false;
153
140
  responseAwaitingReconciliation = false;
154
141
  completedRunAccepted = false;
142
+ turnAcceptedForBackup = false;
155
143
  compactionPlan = undefined;
156
144
  compactionInFlight = false;
157
- runAnchorTimestamp = undefined;
158
145
  skillReads.clear();
159
146
  artifactReads.clear();
160
147
  }
@@ -165,44 +152,59 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
165
152
  artifactRefreshPending = true;
166
153
  }
167
154
 
168
- function refreshArtifactInvalidations(ctx: ExtensionContext): void {
169
- artifactRefreshPending = false;
170
- if (!snapshot.config.enabled) {
155
+ function refreshArtifactHints(): void {
156
+ if (!runtime?.view) {
157
+ artifactHints = {};
171
158
  artifactInvalidations = [];
172
159
  artifactReads.setCandidates([]);
173
160
  return;
174
161
  }
175
- try {
176
- const discovery = globalMarkdown.refresh(Object.keys(scopeStates.global.artifacts));
177
- const plan = planArtifactInvalidation(
178
- discovery.sources,
179
- scopeStates.global.artifacts,
180
- ORDINARY_ARTIFACT_COMPILER,
181
- { removed: discovery.removed },
182
- runtime?.artifactProvenance("global") ?? {},
183
- );
184
- artifactInvalidations = structuredClone(plan.requiresCompilation);
185
- if (plan.removed.length > 0) {
186
- const removals = Object.fromEntries(plan.removed.map((path) => [path, null]));
187
- const stage = stageAtomicScopePatches(
188
- scopeStates,
189
- { global: { artifacts: removals } },
190
- [],
191
- runtime!.causalBasis(),
192
- );
193
- commitStage(stage, ctx, false);
162
+ const paths = new Set<string>();
163
+ for (const scope of ["global", "cwd", "session"] as const) for (const path of Object.keys(scopeStates[scope].artifacts)) paths.add(path);
164
+ const observations = new Map(inspectRegisteredArtifactPaths(paths).map((observation) => [observation.path, observation]));
165
+ const nextHints: Record<string, string> = {};
166
+ const nextInvalidations = new Map<string, ArtifactInvalidationRequest>();
167
+ for (const scope of ["global", "cwd", "session"] as const) {
168
+ const provenance = runtime.artifactProvenance(scope);
169
+ for (const [path, metadata] of Object.entries(scopeStates[scope].artifacts)) {
170
+ delete nextHints[path];
171
+ nextInvalidations.delete(path);
172
+ if (metadata.kind === "skill") continue;
173
+ const observed = observations.get(path);
174
+ if (observed?.kind !== "present") continue;
175
+ const need = classifyArtifactCompilationNeed({ path, scope, sourceFingerprint: observed.fingerprint }, metadata, ORDINARY_ARTIFACT_COMPILER, false, provenance[path]);
176
+ if (need.kind !== "requires-compilation") continue;
177
+ if (need.reason === "source-changed") nextHints[path] = SOURCE_CHANGED_HINT;
178
+ nextInvalidations.set(path, { path, scope, reason: need.reason });
194
179
  }
195
- artifactReads.setCandidates(artifactInvalidations);
196
- } catch (error) {
197
- artifactInvalidations = [];
198
- artifactReads.setCandidates([]);
199
- ctx.ui.notify(
200
- `State Flow could not discover global Markdown sources: ${error instanceof Error ? error.message : String(error)}`,
201
- "warning",
202
- );
203
180
  }
181
+ artifactHints = nextHints;
182
+ artifactInvalidations = [...nextInvalidations.values()].sort((left, right) => left.path.localeCompare(right.path));
183
+ artifactReads.setCandidates(artifactInvalidations);
184
+ }
185
+
186
+ function removeMissingRegisteredArtifacts(ctx: ExtensionContext): void {
187
+ if (!snapshot.config.enabled || !runtime?.view) return;
188
+ const owners = new Map<string, StateScope[]>();
189
+ for (const scope of ["global", "cwd", "session"] as const) for (const path of Object.keys(scopeStates[scope].artifacts)) {
190
+ owners.set(path, [...owners.get(path) ?? [], scope]);
191
+ }
192
+ const removals: AtomicScopePatches = {};
193
+ for (const observation of inspectRegisteredArtifactPaths(owners.keys())) {
194
+ if (observation.kind !== "missing") continue;
195
+ for (const scope of owners.get(observation.path) ?? []) {
196
+ const artifacts = (removals[scope]?.artifacts ?? {}) as JsonObject;
197
+ removals[scope] = { ...(removals[scope] ?? {}), artifacts: { ...artifacts, [observation.path]: null } };
198
+ }
199
+ }
200
+ if (Object.keys(removals).length > 0) {
201
+ const stage = stageAtomicScopePatches(scopeStates, removals, [], runtime.causalBasis());
202
+ commitStage(stage, ctx, false);
203
+ }
204
+ refreshArtifactHints();
204
205
  }
205
206
 
207
+
206
208
  function installScopeStates(): void {
207
209
  scopeStates = runtime?.view ? runtime.states() : { global: emptyState(), cwd: emptyState(), session: emptyState() };
208
210
  }
@@ -221,40 +223,15 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
221
223
  : active.filter((name) => !owned.includes(name)));
222
224
  }
223
225
 
224
- function recordPublication(publication: RuntimePublication, ctx: ExtensionContext): void {
225
- const revision = publication.revision ?? publication.commit;
226
- if (revision !== undefined) snapshot.meta.durableBase = revision;
227
- if (publication.push?.status === "pending") {
228
- setPendingPublication({
229
- commit: publication.push.commit,
230
- error: publication.push.error ?? "unknown push failure",
231
- });
232
- ctx.ui.notify(
233
- `State Flow accepted durable commit ${publication.push.commit.slice(0, 12)}, but push is pending: ${publication.push.error}`,
234
- "warning",
235
- );
236
- } else if (revision !== undefined) {
237
- setPendingPublication(undefined);
238
- }
226
+ function recordPublication(_publication: RuntimePublication, _ctx: ExtensionContext): void {
227
+ // A passive view is not runtime authority; only accepted canonical publication establishes it.
228
+ branchStartsWithoutRuntime = false;
229
+ backupPending = true;
239
230
  }
240
231
 
241
- /** Accept an activation or lifecycle commit locally; turn-end policy queues it for the asynchronous worker. */
232
+ /** Record accepted lifecycle persistence; Git backup is scheduled only after an accepted turn settles. */
242
233
  function recordPolicyPublication(publication: RuntimePublication | undefined, ctx: ExtensionContext): void {
243
- if (!publication) return;
244
- recordPublication(publication, ctx);
245
- const mode = snapshot.meta.remotePublication?.mode ?? "transition";
246
- const target = publication.commit ?? publication.revision;
247
- if (mode !== "turn-end" || target === undefined || !/^[0-9a-f]{40,64}$/.test(target)) return;
248
- turnPublicationTarget = target;
249
- try {
250
- enqueueTurnPublication();
251
- publicationWorker.launch();
252
- } catch (error) {
253
- ctx.ui.notify(
254
- `State Flow accepted the local commit; remote publication is deferred: ${error instanceof Error ? error.message : String(error)}`,
255
- "warning",
256
- );
257
- }
234
+ if (publication) recordPublication(publication, ctx);
258
235
  }
259
236
 
260
237
  function commitStage(stage: StagedScopedTransition, ctx: ExtensionContext, finalizeRun: boolean): boolean {
@@ -263,12 +240,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
263
240
  try {
264
241
  committed = commitScopedTransition(snapshot, scopeStates, stage, (accepted, nextSnapshot) => {
265
242
  if (!runtime?.view) throw new Error("Temporal State Flow runtime is unavailable; reload before publishing");
266
- const mode = nextSnapshot.meta.remotePublication?.mode ?? "transition";
267
243
  const publication = runtime.publish(nextSnapshot, accepted !== undefined, accepted, {
268
- pushRemote: mode === "transition",
269
244
  provenance: stage.provenanceUpdates,
270
245
  });
271
- if (publication?.commit && mode === "turn-end") turnPublicationTarget = publication.commit;
272
246
  if (publication) recordPublication(publication, ctx);
273
247
  }, runtime!.causalBasis(), { finalizeRun });
274
248
  } catch (error) {
@@ -277,59 +251,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
277
251
  }
278
252
  if (!committed) return false;
279
253
  installScopeStates();
280
- // A preserved primary response commits mid-run; pending acquisition obligations must
281
- // still block final eligibility until the fallback turns resolve or expire.
282
- if (!(finalizeRun && resolutionPending)) {
283
- artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
284
- artifactReads.setCandidates(artifactInvalidations);
285
- skillReads.clear();
286
- artifactReads.clear();
287
- }
254
+ artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
255
+ artifactReads.setCandidates(artifactInvalidations);
256
+ skillReads.clear();
257
+ artifactReads.clear();
288
258
  persist();
289
259
  return true;
290
260
  }
291
261
 
292
- function enqueueTurnPublication(): void {
293
- const target = turnPublicationTarget;
294
- turnPublicationTarget = undefined;
295
- if (target) publicationWorker.enqueue(target);
296
- }
297
-
298
- function shutdownPublicationWorkers(ctx: ExtensionContext): Promise<void> {
299
- return publicationShutdown ??= publicationWorker.shutdown(PUBLICATION_SHUTDOWN_WAIT_MS).then((completed) => {
300
- if (!completed) ctx.ui.notify(`State Flow push cleanup is unconfirmed after ${PUBLICATION_SHUTDOWN_WAIT_MS}ms; worker leases remain held until child exit.`, "warning");
301
- });
302
- }
303
-
304
- function retryPendingPush(ctx: ExtensionContext): void {
305
- if (pendingPublication === undefined || !runtime?.view) return;
306
- const mode = snapshot.meta.remotePublication?.mode ?? "transition";
307
- if (mode !== "transition") {
308
- const target = pendingPublication.commit;
309
- setPendingPublication(undefined);
310
- if (mode === "turn-end") {
311
- turnPublicationTarget = target;
312
- try {
313
- enqueueTurnPublication();
314
- publicationWorker.launch();
315
- } catch (error) {
316
- ctx.ui.notify(`State Flow retained local state; asynchronous publication recovery is deferred: ${error instanceof Error ? error.message : String(error)}`, "warning");
317
- }
318
- }
319
- return;
320
- }
321
- const result = pushGitCommit(repositoryRoot, pendingPublication.commit);
322
- if (result.status !== "pending") {
323
- setPendingPublication(undefined);
324
- persist();
325
- ctx.ui.notify(result.status === "local"
326
- ? `State Flow retained local-only durable commit ${result.commit.slice(0, 12)}; no remote configured.`
327
- : `State Flow pushed pending durable commit ${result.commit.slice(0, 12)}.`, "info");
328
- } else {
329
- setPendingPublication({ commit: result.commit, error: result.error ?? "unknown push failure" });
330
- persist();
331
- }
332
- }
333
262
 
334
263
  function currentRehydrationPhase(): RehydrationPhase | undefined {
335
264
  return rehydrationPhase;
@@ -340,89 +269,36 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
340
269
  const diagnosticStates = scopeStates;
341
270
  const view = runtime?.view;
342
271
  const diagnosticRecent = runtime?.recent() ?? [];
343
- const durableStateError = view ? undefined
344
- : snapshot.meta.validation?.attempt === 0 ? snapshot.meta.validation.error : "no temporal runtime is selected on this branch";
345
-
346
- let staleArtifacts: StatusDiagnostics["staleArtifacts"] = [];
347
- let artifactFreshnessError: string | undefined;
348
- if (durableStateError !== undefined) {
349
- artifactFreshnessError = "durable global artifact registry is unavailable";
350
- } else {
351
- try {
352
- const discovery = globalMarkdown.refresh(Object.keys(diagnosticStates.global.artifacts));
353
- artifactFreshnessError = discovery.unavailable;
354
- const plan = planArtifactInvalidation(
355
- discovery.sources,
356
- diagnosticStates.global.artifacts,
357
- ORDINARY_ARTIFACT_COMPILER,
358
- { removed: discovery.removed },
359
- runtime?.artifactProvenance("global") ?? {},
360
- );
361
- staleArtifacts = [
362
- ...plan.requiresCompilation.map(({ path, reason }) => ({ scope: "global" as const, path, reason })),
363
- ...plan.removed.map((path) => ({ scope: "global" as const, path, reason: "source-removed" as const })),
364
- ];
365
- } catch (error) {
366
- artifactFreshnessError = error instanceof Error ? error.message : String(error);
367
- }
368
- }
272
+ const durableStateError = snapshot.meta.validation?.attempt === 0 ? snapshot.meta.validation.error
273
+ : view ? undefined : "no temporal runtime is selected on this branch";
369
274
 
275
+ const staleArtifacts: StatusDiagnostics["staleArtifacts"] = durableStateError === undefined
276
+ ? artifactInvalidations.map(({ path, scope, reason }) => ({ scope: scope ?? "global", path, reason }))
277
+ : [];
370
278
  const session = sessionAddress(ctx);
371
- let publicationQueue: PublicationQueueState | undefined;
372
- let publicationQueueError: string | undefined;
373
- try {
374
- const destination = resolveGitPushDestination(repositoryRoot);
375
- publicationQueue = destination ? loadPublicationQueue(publicationQueuePath(destination)) : undefined;
376
- } catch (error) {
377
- publicationQueue = undefined;
378
- publicationQueueError = error instanceof Error ? error.message : String(error);
379
- }
380
279
  return {
381
280
  repositoryRoot,
382
281
  cwdScopeKey: cwdScopeKey(cwd),
383
282
  sessionScopeKey: sessionScopeKey(session.key),
384
283
  scopeStates: diagnosticStates,
385
284
  recent: diagnosticRecent,
285
+ historyLimit: config.historyLimit,
386
286
  ...(view === undefined ? {} : { temporal: {
387
287
  head: structuredClone(view.lineage.at(-1)!),
388
288
  historyDepth: view.lineage.length - 1,
389
289
  tailCounts: { global: view.scopes.global.patches.length, cwd: view.scopes.cwd.patches.length, session: view.scopes.session.patches.length },
390
290
  } }),
391
291
  staleArtifacts,
392
- ...(artifactFreshnessError === undefined ? {} : { artifactFreshnessError }),
393
292
  ...(durableStateError === undefined ? {} : { durableStateError }),
394
- ...(pendingPublication === undefined ? {} : { pendingPublication }),
395
- ...(publicationQueue === undefined ? {} : { publicationQueue }),
396
- ...(publicationQueueError === undefined ? {} : { publicationQueueError }),
397
293
  };
398
294
  }
399
295
 
400
- function prepareBranchRestore(ctx: ExtensionContext, revision: string, legacy?: Snapshot): { snapshot: Snapshot; restore: () => Snapshot } {
401
- if (!forkInitialization) {
402
- try {
403
- return runtime!.prepareRestore(revision, legacy);
404
- } catch (error) {
405
- if (error instanceof MissingSessionRuntimeError && typeof ctx.sessionManager.getHeader()?.parentSession === "string") {
406
- throw new RevisionUnavailableError("State Flow checkpoint has no child-owned runtime; select a child checkpoint or resume the parent");
407
- }
408
- throw error;
409
- }
410
- }
296
+ function forkSource(ctx: ExtensionContext): SessionAddress {
411
297
  const file = ctx.sessionManager.getHeader()?.parentSession;
412
298
  if (typeof file !== "string" || !isAbsolute(file)) throw new Error("State Flow fork requires a persisted native parent session");
413
299
  const parent = readNativeSessionHeader(file);
414
300
  if (parent.cwd !== resolve(ctx.cwd)) throw new Error("State Flow fork parent CWD identity mismatch");
415
- const source = resolveSessionAddress(parent.file, parent.id, parent.timestamp);
416
- const prepared = runtime!.prepareFork(source, revision);
417
- return { snapshot: prepared.snapshot, restore: () => {
418
- const accepted = prepared.fork();
419
- snapshot = accepted.snapshot;
420
- recordPolicyPublication(accepted.publication, ctx);
421
- // Copied Stop markers belong to the parent, including after a child reload/resume.
422
- pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { reset: true, owner: ctx.sessionManager.getSessionId() });
423
- forkInitialization = false;
424
- return snapshot;
425
- } };
301
+ return resolveSessionAddress(parent.file, parent.id, parent.timestamp);
426
302
  }
427
303
 
428
304
  function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown): void {
@@ -435,9 +311,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
435
311
  telegramStartPending = false;
436
312
  activeContext = ctx;
437
313
  const session = sessionAddress(ctx);
438
- runtime = new TemporalRuntime(ctx.cwd, session, repositoryRoot);
314
+ runtime = new TemporalRuntime(ctx.cwd, session, repositoryRoot, undefined, config.historyLimit);
439
315
  installScopeStates();
440
- pendingPublication = undefined;
441
316
  branchHasSnapshot = false;
442
317
  branchStartsWithoutRuntime = false;
443
318
  forkInitialization = sessionStartReason === "fork";
@@ -445,16 +320,27 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
445
320
  const branch = ctx.sessionManager.getBranch();
446
321
  const discovery = discoverSnapshotData(branch);
447
322
  let restoreSelected: (() => Snapshot) | undefined;
448
- const recovery = recoverSnapshot(discovery.candidates, (revision) => {
449
- try {
450
- const prepared = prepareBranchRestore(ctx, revision);
451
- restoreSelected = prepared.restore;
323
+ const recovery = recoverSnapshot(discovery.candidates, (checkpoint) => {
324
+ if (forkInitialization) {
325
+ const prepared = runtime!.prepareBoundaryFork(forkSource(ctx), checkpoint);
326
+ restoreSelected = () => {
327
+ const accepted = prepared.fork();
328
+ snapshot = accepted.snapshot;
329
+ recordPolicyPublication(accepted.publication, ctx);
330
+ pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { reset: true, owner: ctx.sessionManager.getSessionId() });
331
+ forkInitialization = false;
332
+ return snapshot;
333
+ };
452
334
  return prepared.snapshot;
453
- } catch (error) {
454
- // A failed source proof never licenses an older/empty private copy.
455
- if (forkInitialization) throw new RevisionUnavailableError(`Cannot copy State Flow fork source: ${error instanceof Error ? error.message : String(error)}`);
456
- throw error;
457
335
  }
336
+ const prepared = runtime!.prepareBoundaryRestore(checkpoint);
337
+ restoreSelected = () => {
338
+ const restored = prepared.restore();
339
+ const publication = runtime!.acceptRestoredOrigin(restored);
340
+ recordPolicyPublication(publication, ctx);
341
+ return restored;
342
+ };
343
+ return prepared.snapshot;
458
344
  });
459
345
  branchStartsWithoutRuntime = recovery.disabledMarker === true
460
346
  || (discovery.candidates.length === 0 && discovery.errors.length === 0);
@@ -464,10 +350,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
464
350
  snapshot = migrationFailure({}, `Snapshot restoration failed: ${discovery.errors[0]}`);
465
351
  } else if (discovery.candidates.length > 0) {
466
352
  snapshot = recovery.snapshot;
467
- if (branchHasSnapshot && snapshot.meta.durableBase) {
468
- const selectedRevision = snapshot.meta.durableBase;
469
- snapshot = restoreSelected ? restoreSelected() : prepareBranchRestore(ctx, selectedRevision, snapshot).restore();
470
- if (snapshot.meta.durableBase !== selectedRevision) persist();
353
+ if (branchHasSnapshot && restoreSelected) {
354
+ snapshot = restoreSelected();
355
+ persist();
471
356
  } else if (branchHasSnapshot && snapshot.config.enabled) {
472
357
  const publication = runtime.initialize(snapshot, true);
473
358
  recordPolicyPublication(publication, ctx);
@@ -475,9 +360,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
475
360
  installScopeStates();
476
361
  } else if (config.autoStart && isNewSession(sessionStartReason, branch)) {
477
362
  snapshot = startEpisode(hasPriorConversation(branch));
478
- snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
479
- resolveRemotePublicationPolicy(config.remotePublication, { legacyRuntime: false }),
480
- );
481
363
  runtime.prepare();
482
364
  const initialization = runtime.initialize(snapshot, true);
483
365
  recordPolicyPublication(initialization, ctx);
@@ -502,27 +384,17 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
502
384
  meta: { ...snapshot.meta, validation: failure.meta.validation },
503
385
  };
504
386
  }
505
- if (branchHasSnapshot && snapshot.meta.remotePublication === undefined) {
506
- snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
507
- resolveRemotePublicationPolicy(undefined, { legacyRuntime: true }),
508
- );
509
- }
510
- if (snapshot.config.enabled && snapshot.meta.remotePublication === undefined) {
511
- snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
512
- resolveRemotePublicationPolicy(undefined, { legacyRuntime: true }),
513
- );
514
- }
515
- pendingPublication = snapshot.meta.pendingPublication === undefined
516
- ? undefined
517
- : structuredClone(snapshot.meta.pendingPublication);
518
387
  if (!snapshot.config.enabled && snapshot.meta.validation?.attempt === 0) {
388
+ // A failed acceptance may have installed a prepared view; only shared passive reads may survive.
389
+ runtime = createRuntime(ctx);
390
+ installScopeStates();
519
391
  ctx.ui.notify(`State Flow restored disabled: ${snapshot.meta.validation.error}`, "error");
520
392
  }
521
393
  if (retainsPhysicalSessionProjection(sessionStartReason) && runtime?.view) {
522
394
  const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
523
395
  if (boundary !== undefined) {
524
396
  const continuation = createPassiveContinuation(
525
- projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
397
+ projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
526
398
  boundary.at,
527
399
  boundary.from,
528
400
  );
@@ -547,77 +419,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
547
419
  diagnosticWriter.record(sessionAddress(ctx).id, ctx.cwd, error, category, extras);
548
420
  }
549
421
 
550
- function continueFallbackResolution(lastWarning: boolean): void {
551
- resolutionPending = true;
552
- pi.sendMessage({
553
- customType: VALIDATION_MESSAGE_TYPE,
554
- content: lastWarning
555
- ? "This is the last State Flow fallback turn. The iteration's answer is preserved and will not change. Apply the final:true patch now: call patch_state with any remaining durable scope changes and final:true, or with {\"final\":true} when nothing remains. Do not restate the answer."
556
- : "The iteration's answer is preserved as the final response; later turns cannot replace it. Apply the final:true patch: call patch_state with any durable scope changes from this iteration (including required artifact or Skill compilation) and final:true, or with {\"final\":true} when nothing remains to persist. Do not restate the answer.",
557
- display: false,
558
- }, { deliverAs: "steer", triggerTurn: true });
559
- }
560
-
561
- /** Preserve the primary answer as this iteration's response and steer bounded fallback turns whose only purpose is the final:true patch. */
562
- function beginFallbackResolution(ctx: ExtensionContext, message: { content?: unknown }, reason: string): void {
563
- responseAwaitingReconciliation = true;
564
- fallbackAttempts = 0;
565
- fallbackFailureReported = false;
566
- recordDiagnostic(`Preserved the terminal draft as the iteration response; fallback resolution started: ${reason}`, "terminal-pending", ctx, {
567
- content: message.content,
568
- resolutionAttempt: 0,
569
- terminalEligible,
570
- });
571
- continueFallbackResolution(MAX_FALLBACK_ATTEMPTS === 1);
572
- }
573
-
574
- /** Fallback turns never become the response; they exist only to supply the final:true patch. */
575
- function resolveFallbackTurn(ctx: ExtensionContext, message: { content?: unknown }): any {
576
- let resolved = terminalEligible;
577
- if (resolved) {
578
- try {
579
- validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
580
- } catch (error) {
581
- resolved = false;
582
- recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, {
583
- content: message.content,
584
- resolutionAttempt: fallbackAttempts,
585
- terminalEligible,
586
- });
587
- }
588
- }
589
- if (resolved) {
590
- resolutionPending = false;
591
- recordDiagnostic(`Fallback resolution obtained final:true after ${fallbackAttempts} fallback turn${fallbackAttempts === 1 ? "" : "s"}; the preserved answer stands`, "finalization", ctx, {
592
- content: message.content,
593
- resolutionAttempt: fallbackAttempts,
594
- terminalEligible,
595
- });
596
- return { message: { ...message, role: "assistant" as const, content: [] } };
597
- }
598
- fallbackAttempts = Math.min(MAX_FALLBACK_ATTEMPTS, fallbackAttempts + 1);
599
- if (fallbackAttempts < MAX_FALLBACK_ATTEMPTS) {
600
- recordDiagnostic(`Fallback turn ended without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS})`, "terminal-pending", ctx, {
601
- content: message.content,
602
- resolutionAttempt: fallbackAttempts,
603
- terminalEligible,
604
- });
605
- continueFallbackResolution(fallbackAttempts + 1 >= MAX_FALLBACK_ATTEMPTS);
606
- return { message: { ...message, role: "assistant" as const, content: [] } };
607
- }
608
- resolutionPending = false;
609
- recordDiagnostic(`Fallback resolution exhausted without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS}); the preserved response and current state remain`, "finalization", ctx, {
610
- content: message.content,
611
- resolutionAttempt: fallbackAttempts,
612
- terminalEligible,
613
- });
614
- if (!fallbackFailureReported) {
615
- fallbackFailureReported = true;
616
- ctx.ui.notify(`State Flow kept the preserved answer; no final:true patch arrived after ${MAX_FALLBACK_ATTEMPTS} fallback turns, so the iteration closed with its current state.`, "warning");
617
- }
618
- return { message: { ...message, role: "assistant" as const, content: [] } };
619
- }
620
-
621
422
  pi.registerTool({
622
423
  name: READ_STATE_TOOL_NAME,
623
424
  label: "Read State",
@@ -638,16 +439,17 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
638
439
  if (params.path !== undefined && params.paths !== undefined) throw new Error("read_state accepts path or paths, not both");
639
440
  {
640
441
  const paths = params.paths ?? [params.path!];
442
+ if (paths.some((path) => /^session(?:\.|\[|$)/.test(path))) assertSelectedBranchAvailable();
641
443
  if (paths.length === 1 && /^(?:global|cwd|session)\.patches(?:\[\d+\])?$/.test(paths[0]!)) {
642
444
  if (params.projection !== undefined && params.projection !== "value") throw new Error("Scope patch paths support only the value projection");
643
- const result = readStatePath(runtime.view, paths[0]!);
445
+ const result = readStatePath(runtime.view, paths[0]!, config.historyLimit);
644
446
  if (!("patch" in result)) throw new Error("Expected a scope patch path");
645
447
  return {
646
448
  content: [{ type: "text", text: `\n${JSON.stringify({ patch: result.patch })}` }],
647
449
  details: { path: paths[0], transitionId: result.boundary.id } as ReadDetails,
648
450
  };
649
451
  }
650
- const result = readProjectedState(runtime.view, paths, params.projection);
452
+ const result = readProjectedState(runtime.view, paths, params.projection, config.historyLimit);
651
453
  return {
652
454
  content: [{ type: "text", text: `\n${JSON.stringify(result)}` }],
653
455
  details: { ...(params.path === undefined ? { paths } : { path: params.path }), projection: params.projection ?? "value" } as ReadDetails,
@@ -662,10 +464,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
662
464
  pi.registerTool({
663
465
  name: PATCH_STATE_TOOL_NAME,
664
466
  label: "Patch State",
665
- description: "The sole State Flow semantic mutation protocol. Supply any combination of global, cwd, and session patches; all supplied scopes commit atomically. In an active State Flow episode, set final:true when the current iteration may finish at a later turn_end; use {final:true} when no semantic update is needed. Passive turns have no terminal barrier: never call patch_state only to set final:true. This call must be the only State Flow barrier in its assistant response.",
666
- promptSnippet: "Atomically patch global/cwd/session; active episodes use final:true before turn_end",
467
+ description: "The sole State Flow semantic mutation protocol. Supply one or more global, cwd, or session patches; all supplied scopes commit atomically. This call must be the only State Flow barrier in its assistant response.",
468
+ promptSnippet: "Atomically patch one or more global/cwd/session scopes",
667
469
  promptGuidelines: [
668
- "Use patch_state for durable semantic changes. Only in an active State Flow episode, make the iteration terminal-eligible before a final answer with final:true, optionally alongside atomic global/cwd/session patches. In passive mode, never call patch_state only to set final:true.",
470
+ "Use patch_state only for material durable semantic changes; ordinary answers need no finalization call.",
669
471
  "Use patch_state as reconciliation, not append-only notes: place new knowledge at the narrowest valid scope and remove superseded or completed state from touched branches.",
670
472
  "Call patch_state alone in an assistant response; after its acknowledgement, further reasoning, tools, and later patch_state calls remain allowed.",
671
473
  ],
@@ -674,9 +476,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
674
476
  global: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional global semantic patch" })),
675
477
  cwd: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional project semantic patch" })),
676
478
  session: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional session semantic patch" })),
677
- final: Type.Optional(Type.Boolean({ description: "Set exactly true to permit this iteration to finish at a later turn_end" })),
678
479
  }, { additionalProperties: false }),
679
- prepareArguments: normalizePatchStateArguments,
680
480
  renderResult(result, { isPartial }, theme, context) {
681
481
  const text = result.content.find((block) => block.type === "text")?.text ?? "";
682
482
  if (context.isError) return new Text(separatedOutput(text), 0, 0);
@@ -686,13 +486,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
686
486
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
687
487
  try {
688
488
  if (!passiveToolsAvailable()) throw new Error("State Flow tools are disabled by configuration");
489
+ assertSelectedBranchAvailable();
689
490
  if (signal?.aborted) throw new Error("State Flow patch was aborted before materialization");
690
491
  if (!isObject(params)) throw new Error("patch_state requires an object");
691
- const allowed = new Set(["global", "cwd", "session", "final"]);
492
+ const allowed = new Set(["global", "cwd", "session"]);
692
493
  for (const key of Object.keys(params)) {
693
494
  if (!allowed.has(key)) throw new Error(`patch_state does not accept field ${key}`);
694
495
  }
695
- if (Object.hasOwn(params, "final") && typeof params.final !== "boolean") throw new Error("patch_state final must be a Boolean when supplied");
696
496
  const patches: AtomicScopePatches = {};
697
497
  for (const scope of ["global", "cwd", "session"] as const) {
698
498
  if (!Object.hasOwn(params, scope)) continue;
@@ -702,16 +502,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
702
502
  patches[scope] = patch;
703
503
  }
704
504
  const scopes = Object.keys(patches) as StateScope[];
705
- if (scopes.length === 0) {
706
- if (params.final === false) {
707
- return { content: [{ type: "text", text: "\nState unchanged; iteration remains non-terminal." }], details: { final: false } };
708
- }
709
- if (params.final !== true) throw new Error('patch_state requires at least one scope patch or {"final":true}');
710
- if (!snapshot.config.enabled) return { content: [{ type: "text", text: "\nState unchanged; passive turns have no terminal barrier." }], details: { final: false } };
711
- validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
712
- terminalEligible = true;
713
- return { content: [{ type: "text", text: "\nState iteration is terminal-eligible." }], details: { final: true } };
714
- }
505
+ if (scopes.length === 0) throw new Error("patch_state requires at least one materially changed scope patch");
715
506
  if (!runtime?.view) {
716
507
  runtime ??= createRuntime(ctx);
717
508
  runtime.prepare();
@@ -720,19 +511,21 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
720
511
  installScopeStates();
721
512
  branchHasSnapshot = true;
722
513
  }
723
- runtime.migrateLegacyStorage();
724
514
  installScopeStates();
515
+ for (const acquired of artifactReads.successful.values()) {
516
+ const observation = inspectRegisteredArtifactPaths([acquired.path])[0];
517
+ if (acquired.sourceFingerprint === undefined || observation?.kind !== "present"
518
+ || !sameArtifactSourceFingerprint(acquired.sourceFingerprint, observation.fingerprint)) {
519
+ throw new Error(`Artifact source changed after acquisition: ${acquired.path}`);
520
+ }
521
+ }
725
522
  const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime.causalBasis(), artifactReads.successful.values());
726
523
  const semanticChange = (["global", "cwd", "session"] as const).some((scope) => !sameJson(scopeStates[scope], stage.nextStates[scope]));
727
524
  const provenanceChange = Object.values(stage.provenanceUpdates).some((updates) => Object.keys(updates).length > 0);
728
- if (!semanticChange && !provenanceChange) throw new Error('patch_state scope patches must materially update state or required provenance; omit them and use {"final":true} when unchanged');
525
+ if (!semanticChange && !provenanceChange) throw new Error("patch_state scope patches must materially update state or required provenance");
729
526
  commitStage(stage, ctx, false);
730
- if (params.final === true && snapshot.config.enabled) {
731
- terminalEligible = true;
732
- }
733
527
  updateUi(ctx);
734
- const publication = pendingPublication === undefined ? "" : "; durable publication pending";
735
- return { content: [{ type: "text", text: `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}${publication}.` }], details: { scopes, final: params.final === true, step: snapshot.meta.step } };
528
+ return { content: [{ type: "text", text: `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.` }], details: { scopes, step: snapshot.meta.step } };
736
529
  } catch (error) {
737
530
  let attempted: unknown;
738
531
  try { attempted = structuredClone(params); } catch { attempted = undefined; }
@@ -740,8 +533,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
740
533
  input: attempted,
741
534
  tool: PATCH_STATE_TOOL_NAME,
742
535
  toolCallId,
743
- resolutionAttempt: fallbackAttempts,
744
- terminalEligible,
745
536
  });
746
537
  throw separatedFailure(error);
747
538
  }
@@ -757,36 +548,28 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
757
548
  const previousArtifactRefreshPending = artifactRefreshPending;
758
549
  const previousArtifactInvalidations = structuredClone(artifactInvalidations);
759
550
  try {
760
- if (!runtime?.view && !snapshot.meta.durableBase && !branchStartsWithoutRuntime) {
761
- throw new Error("Selected branch revision is unavailable; restore its original Git history before starting State Flow");
551
+ if (!branchStartsWithoutRuntime && (!runtime?.view || snapshot.meta.validation?.attempt === 0)) {
552
+ const retryFork = forkInitialization;
553
+ restoreActiveBranch(ctx, retryFork ? "fork" : undefined);
762
554
  }
555
+ assertSelectedBranchAvailable();
556
+ if (!runtime?.view && !branchStartsWithoutRuntime) throw new Error("Selected State Flow boundary is unavailable; restore its canonical files before starting State Flow");
763
557
  const branch = ctx.sessionManager.getBranch();
764
558
  activeContext = ctx;
765
559
  runtime ??= createRuntime(ctx);
766
560
  if (branchStartsWithoutRuntime) runtime.prepare();
767
- runtime.migrateLegacyStorage();
768
561
  const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
769
562
  && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
770
- if (!runtime.view && snapshot.meta.durableBase) {
771
- snapshot = prepareBranchRestore(ctx, snapshot.meta.durableBase, snapshot).restore();
772
- setPendingPublication(snapshot.meta.pendingPublication);
773
- branchHasSnapshot = true;
774
- }
775
563
  const existingBranch = branchHasSnapshot;
776
564
  snapshot = existingBranch
777
565
  ? resumeEpisode(snapshot, bootstrap)
778
566
  : startEpisode(bootstrap);
779
- if (snapshot.meta.remotePublication === undefined) {
780
- snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
781
- resolveRemotePublicationPolicy(existingBranch ? undefined : config.remotePublication, { legacyRuntime: existingBranch }),
782
- );
783
- }
784
567
  branchHasSnapshot = true;
785
568
  // Activation reasserts the selected session stream as a complete cohort. A resumed
786
569
  // branch may have no reusable temporal revision after an extension upgrade; a
787
570
  // runtime-only write would then reject its selected session as an omitted change.
788
571
  const publication = runtime.view
789
- ? runtime.promote(snapshot) ?? runtime.publish(snapshot, true)
572
+ ? runtime.publish(snapshot, true)
790
573
  : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
791
574
  recordPolicyPublication(publication, ctx);
792
575
  installScopeStates();
@@ -836,29 +619,22 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
836
619
 
837
620
  function stopStateFlow(ctx: ExtensionContext): StateFlowTelegramControlResult {
838
621
  if (!activeContext) restoreActiveBranch(ctx);
622
+ assertSelectedBranchAvailable();
839
623
  telegramStartPending = false;
840
- let selected = runtime;
841
- let current = snapshot;
842
- if (!selected?.view && snapshot.meta.durableBase) {
843
- selected = createRuntime(ctx);
844
- current = selected.restore(snapshot.meta.durableBase, snapshot);
845
- }
624
+ const current = snapshot;
846
625
  const stoppedAt = Date.now();
847
- const exitStates = selected?.view ? selected.states() : undefined;
848
- const exitHandoff = current.config.enabled && exitStates
626
+ const stopped = stopEpisode(current);
627
+ const publication = !branchStartsWithoutRuntime && runtime?.view ? runtime.publish(stopped) : undefined;
628
+ installScopeStates();
629
+ // Freeze the accepted shared adoption, not the pre-publication cache.
630
+ const exitHandoff = current.config.enabled && runtime?.view
849
631
  ? createPassiveContinuation(
850
- projectStateForModel(overlayStates(exitStates.global, exitStates.cwd, exitStates.session)),
632
+ projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
851
633
  stoppedAt,
852
634
  ctx.isIdle() ? undefined : runAnchorTimestamp,
853
635
  )
854
636
  : undefined;
855
637
  const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
856
- const stopped = stopEpisode(current);
857
- const publication = selected?.view ? selected.publish(stopped) : undefined;
858
- if (selected !== runtime) {
859
- runtime = selected;
860
- installScopeStates();
861
- }
862
638
  snapshot = stopped;
863
639
  recordPolicyPublication(publication, ctx);
864
640
  branchHasSnapshot = true;
@@ -895,13 +671,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
895
671
  startPending: telegramStartPending,
896
672
  }),
897
673
  state: (scope) => {
674
+ if (scope === "session") assertSelectedBranchAvailable();
898
675
  const selected = scope === "effective"
899
676
  ? overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)
900
677
  : scopeStates[scope];
901
- return {
902
- ...projectStateForModel(selected),
903
- ...(Object.hasOwn(selected, "lazy") ? { lazy: structuredClone(selected.lazy) } : {}),
904
- };
678
+ return { ...projectModelState(selected), lazy: structuredClone(selected.lazy) };
905
679
  },
906
680
  canStartNow: () => activeContext === undefined || activeContext.isIdle(),
907
681
  start: () => {
@@ -927,46 +701,50 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
927
701
  void telegram.ensure();
928
702
 
929
703
  pi.on("before_agent_start", (event, ctx) => {
704
+ // Native run identity is independent of semantic enablement and survives mode toggles.
705
+ runAnchorTimestamp = undefined;
930
706
  if (!snapshot.config.enabled) {
931
707
  if (!config.passiveBootstrap || !runtime?.view) return;
932
- return {
933
- systemPrompt: `${event.systemPrompt}\n\nState Flow passive memory is available. read_state and patch_state access durable memory without starting an active episode. Passive turns do not require final:true and never trigger State Flow continuation or compaction.`,
934
- };
708
+ (event.systemPromptOptions.sections ??= {}).state_flow = PASSIVE_MEMORY_PROTOCOL;
709
+ return;
935
710
  }
936
711
  skillReads.clear();
937
712
  artifactReads.clear();
938
- if (artifactRefreshPending) refreshArtifactInvalidations(ctx);
939
- terminalEligible = false;
940
- resolutionPending = false;
941
- fallbackAttempts = 0;
942
- fallbackFailureReported = false;
943
713
  responseAwaitingReconciliation = false;
944
714
  completedRunAccepted = false;
945
715
  const rotatesRun = snapshot.meta.specification !== undefined;
946
716
  if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
947
- if (prepareRun(snapshot, event.prompt)) {
948
- runAnchorTimestamp = undefined;
949
- persist();
717
+ if (prepareRun(snapshot, event.prompt)) persist();
718
+ try {
719
+ removeMissingRegisteredArtifacts(ctx);
720
+ } catch (error) {
721
+ ctx.ui.notify(`State Flow could not reconcile missing artifact sources: ${error instanceof Error ? error.message : String(error)}`, "warning");
950
722
  }
951
- return {
952
- systemPrompt: `${event.systemPrompt}\n\n${stateFlowProtocol(snapshot.meta.bootstrap === true)}`,
953
- };
723
+ artifactRefreshPending = false;
724
+ (event.systemPromptOptions.sections ??= {}).state_flow = stateFlowProtocol(snapshot.meta.bootstrap === true);
725
+ });
726
+
727
+ pi.on("context_with_system", (event) => {
728
+ const protocol = snapshot.config.enabled ? stateFlowProtocol(snapshot.meta.bootstrap === true)
729
+ : config.passiveBootstrap && runtime?.view ? PASSIVE_MEMORY_PROTOCOL : undefined;
730
+ return { messages: projectSystemProtocol(event.messages, protocol) };
954
731
  });
955
732
 
956
733
  pi.on("context", (event) => {
734
+ if (runtime?.view) refreshArtifactHints();
957
735
  if (passiveContinuation) {
958
736
  return { messages: passiveContinuationMessages(event.messages as AgentMessage[], passiveContinuation) };
959
737
  }
960
738
  if (!snapshot.config.enabled) {
961
739
  if (!config.passiveBootstrap || !runtime?.view) return;
962
- const state = projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session));
963
- return { messages: [syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${canonicalJson({ state, lazy_navigation: lazyNavigationHint(state) })}`), ...(event.messages as AgentMessage[])] };
740
+ const effective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
741
+ const state = projectModelState(effective);
742
+ return { messages: [syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state, lazy_navigation: lazyNavigationHint(effective) })}`), ...(event.messages as AgentMessage[])] };
964
743
  }
965
- if (snapshot.meta.specification === undefined) return;
966
744
  const effectiveState = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
967
- const invalidations = artifactInvalidations.map(({ path, reason }) => ({ path, reason }));
745
+ const invalidations = artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason }));
968
746
  const recentTransitions = projectRecentTransitionsWithLimit(
969
- RECENT_TRANSITION_LIMIT,
747
+ config.historyLimit,
970
748
  runtime?.recent() ?? [],
971
749
  );
972
750
  const activeRehydrationPhase = currentRehydrationPhase();
@@ -974,18 +752,17 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
974
752
  const sourceMessages = bootstrapContinuation
975
753
  ? passiveContinuationMessages(event.messages as AgentMessage[], bootstrapContinuation)
976
754
  : event.messages as AgentMessage[];
977
- const messages = withoutPrivateValidation(sourceMessages);
978
- return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending), ...messages] };
755
+ return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, artifactHints), ...sourceMessages] };
979
756
  }
757
+ // Native user events own the run anchor; projection must never rebase it.
980
758
  const trajectory = currentRunTrajectory(
981
759
  event.messages as AgentMessage[],
982
760
  snapshot.meta.specification,
983
761
  runAnchorTimestamp,
984
762
  );
985
- runAnchorTimestamp = trajectory.anchorTimestamp;
986
763
  return {
987
764
  messages: [
988
- runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending),
765
+ runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, artifactHints),
989
766
  ...trajectory.messages,
990
767
  ],
991
768
  };
@@ -1028,9 +805,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1028
805
  });
1029
806
 
1030
807
  pi.on("message_end", (event, ctx): any => {
1031
- if (!snapshot.config.enabled) return;
1032
- // Capture the first native user boundary even during bootstrap; steering keeps that anchor.
808
+ // Observe actual user events even while disabled; Start/Stop cannot invent or erase them.
1033
809
  if (event.message.role === "user" && runAnchorTimestamp === undefined) runAnchorTimestamp = event.message.timestamp;
810
+ if (!snapshot.config.enabled) return;
1034
811
  if (event.message.role !== "assistant") return;
1035
812
  const message = event.message as unknown as { role: "assistant"; stopReason?: string; content?: unknown };
1036
813
  if (message.stopReason === "aborted" || assistantToolCallCount(message.content) > 0 || message.stopReason === "toolUse") {
@@ -1039,18 +816,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1039
816
  }
1040
817
  if (message.stopReason === "length" || message.stopReason === "error") {
1041
818
  responseAwaitingReconciliation = false;
1042
- recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, { content: message.content, terminalEligible });
1043
- return;
1044
- }
1045
- if (resolutionPending) return resolveFallbackTurn(ctx, message);
1046
- if (!terminalEligible) {
1047
- beginFallbackResolution(ctx, message, "the terminal draft ended without State Flow eligibility");
1048
- return;
1049
- }
1050
- try {
1051
- validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
1052
- } catch (error) {
1053
- beginFallbackResolution(ctx, message, error instanceof Error ? error.message : String(error));
819
+ recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, { content: message.content });
1054
820
  return;
1055
821
  }
1056
822
  responseAwaitingReconciliation = true;
@@ -1072,14 +838,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1072
838
  responseCommitted = true;
1073
839
  bootstrapContinuation = undefined;
1074
840
  rehydrationPhase = "step";
1075
- enqueueTurnPublication();
1076
- if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1077
841
  completedRunAccepted = !wasBootstrap;
842
+ turnAcceptedForBackup = true;
1078
843
  } catch (error) {
1079
844
  if (!responseCommitted && specification !== undefined) snapshot.meta.specification = specification;
1080
845
  recordDiagnostic(error instanceof Error ? error.message : String(error), "finalization", ctx);
1081
846
  ctx.ui.notify(responseCommitted
1082
- ? `State Flow committed the final response; remote publication is deferred: ${error instanceof Error ? error.message : String(error)}`
847
+ ? `State Flow accepted the final response; a post-acceptance lifecycle update failed: ${error instanceof Error ? error.message : String(error)}`
1083
848
  : `State Flow could not reconcile the final response: ${error instanceof Error ? error.message : String(error)}`, "error");
1084
849
  } finally {
1085
850
  responseAwaitingReconciliation = false;
@@ -1095,38 +860,51 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1095
860
  return { compaction: result };
1096
861
  });
1097
862
 
1098
- pi.on("agent_settled", (_event, ctx) => {
863
+ pi.on("agent_before_settle", (_event, ctx) => {
864
+ if (!turnAcceptedForBackup) return;
865
+ turnAcceptedForBackup = false;
866
+ if (!backupPending) return;
867
+ backupPending = false;
868
+ try {
869
+ if (existsSync(join(repositoryRoot, ".git"))) backupCurrentStateFlowFiles(repositoryRoot);
870
+ } catch (error) {
871
+ const message = error instanceof Error ? error.message : String(error);
872
+ recordDiagnostic(message, "publication-conflict", ctx);
873
+ ctx.ui.notify(`State Flow accepted canonical state; Git backup failed: ${message}`, "warning");
874
+ }
875
+ });
876
+
877
+ pi.on("agent_settled", async (_event, ctx) => {
1099
878
  if (telegramStartPending && !snapshot.config.enabled) {
1100
879
  telegramStartPending = false;
1101
880
  startStateFlow(ctx);
1102
881
  }
1103
- if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1104
- if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap || resolutionPending
1105
- || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !snapshot.meta.durableBase
882
+ if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap
883
+ || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !runtime?.view
1106
884
  || !shouldRequestStateFlowCompaction(ctx.getContextUsage())) return;
1107
885
  completedRunAccepted = false;
1108
886
  const entries = ctx.sessionManager.buildContextEntries();
1109
887
  if (!hasCompactionSizedTranscript(entries)) return;
1110
- const plan = planStateFlowCompaction(entries, snapshot.meta.durableBase, snapshot.meta.step);
888
+ const plan = planStateFlowCompaction(entries, runtime.causalBasis(), snapshot.meta.step, runAnchorTimestamp);
1111
889
  if (!plan) return;
1112
890
  compactionPlan = plan;
1113
891
  compactionInFlight = true;
1114
- ctx.compact({
1115
- customInstructions: compactionMarker,
1116
- onComplete: () => { compactionPlan = undefined; compactionInFlight = false; },
1117
- onError: () => { compactionPlan = undefined; compactionInFlight = false; },
892
+ // Pi dispatches deferred companion prompts after all settled handlers return.
893
+ await new Promise<void>((resolve) => {
894
+ const finished = () => { compactionPlan = undefined; compactionInFlight = false; resolve(); };
895
+ ctx.compact({ customInstructions: compactionMarker, onComplete: finished, onError: finished });
1118
896
  });
1119
897
  });
1120
898
 
1121
899
  pi.on("session_start", (event, ctx) => {
900
+ runAnchorTimestamp = undefined;
1122
901
  rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
1123
902
  restoreActiveBranch(ctx, event.reason);
1124
- retryPendingPush(ctx);
1125
- if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1126
903
  updateUi(ctx);
1127
904
  void telegram.ensure();
1128
905
  });
1129
906
  pi.on("session_tree", (_event, ctx) => {
907
+ runAnchorTimestamp = undefined;
1130
908
  restoreActiveBranch(ctx);
1131
909
  });
1132
910
  pi.on("session_shutdown", (_event, ctx) => {
@@ -1134,6 +912,5 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1134
912
  compactionStopped = true;
1135
913
  completedRunAccepted = false;
1136
914
  telegram.dispose();
1137
- return shutdownPublicationWorkers(ctx);
1138
915
  });
1139
916
  }