@llblab/pi-kit 0.10.8 → 0.11.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 (27) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +13 -10
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +15 -1
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +13 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +52 -261
  7. package/node_modules/@llblab/pi-state-flow/docs/README.md +5 -1
  8. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +47 -26
  9. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +97 -0
  10. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +47 -0
  11. package/node_modules/@llblab/pi-state-flow/docs/performance.md +459 -0
  12. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +35 -3
  13. package/node_modules/@llblab/pi-state-flow/docs/usage.md +134 -0
  14. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +19 -3
  15. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +74 -0
  16. package/node_modules/@llblab/pi-state-flow/lib/context.ts +12 -12
  17. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -2
  18. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +21 -5
  19. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +146 -37
  20. package/node_modules/@llblab/pi-state-flow/lib/git.ts +142 -42
  21. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +80 -27
  22. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +109 -7
  23. package/node_modules/@llblab/pi-state-flow/lib/status.ts +7 -12
  24. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +2 -1
  25. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -3
  26. package/node_modules/@llblab/pi-state-flow/package.json +5 -4
  27. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
- import { execFile } from "node:child_process";
2
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { randomUUID } from "node:crypto";
3
3
  import { StringEnum, Type } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -10,8 +10,9 @@ import { loadStateFlowConfig } from "./config.ts";
10
10
  import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
11
11
  import { isAbsolute, relative, resolve, sep } from "node:path";
12
12
  import { SkillReadTracker } from "./skills.ts";
13
- import { emptySnapshot, migrationFailure, persistableSnapshot, type Snapshot } from "./snapshot.ts";
14
- import { inspectSnapshotRevision, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
13
+ import { emptySnapshot, migrationFailure, persistableSnapshot, RevisionUnavailableError, type Snapshot } from "./snapshot.ts";
14
+ import { readNativeSessionHeader } from "./continuation.ts";
15
+ import { MissingSessionRuntimeError, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
15
16
  import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
16
17
  import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
17
18
  import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
@@ -33,12 +34,13 @@ import {
33
34
  } from "./durable.ts";
34
35
  import { projectRecentTransitionsWithLimit, RECENT_TRANSITION_LIMIT } from "./history.ts";
35
36
  import { appendStateFlowDiagnostic, projectDiagnosticContent, stateFlowLogPath, type StateFlowDiagnosticCategory } from "./logging.ts";
36
- import { isGitCommitAncestor, pushGitCommit, resolveGitPushDestination } from "./git.ts";
37
+ import { isGitCommitAncestor, pushGitCommit, pushGitTarget, resolveGitPushDestination } from "./git.ts";
37
38
  import {
38
39
  ORDINARY_ARTIFACT_COMPILER,
39
40
  planArtifactInvalidation,
40
41
  type ArtifactInvalidationRequest,
41
42
  } from "./artifact.ts";
43
+ import { planStateFlowCompaction, stateFlowCompactionResult, type StateFlowCompactionPlan } from "./compaction.ts";
42
44
 
43
45
  export interface StateFlowExtensionOptions {
44
46
  agentDir?: string;
@@ -52,6 +54,7 @@ export const PATCH_STATE_TOOL_NAME = "patch_state";
52
54
  export const READ_STATE_TOOL_NAME = "read_state";
53
55
  export const MAX_FALLBACK_ATTEMPTS: number = 2;
54
56
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
57
+ const PUBLICATION_SHUTDOWN_WAIT_MS = 2_000;
55
58
 
56
59
  /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
57
60
  function separatedFailure(error: unknown): Error {
@@ -66,11 +69,17 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
66
69
  let scopeStates: ScopedStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
67
70
  let branchHasSnapshot = false;
68
71
  let branchStartsWithoutRuntime = false;
72
+ let forkInitialization = false;
69
73
  let terminalEligible = false;
70
74
  let resolutionPending = false;
71
75
  let fallbackAttempts = 0;
72
76
  let fallbackFailureReported = false;
73
77
  let responseAwaitingReconciliation = false;
78
+ let completedRunAccepted = false;
79
+ let compactionPlan: StateFlowCompactionPlan | undefined;
80
+ let compactionInFlight = false;
81
+ let compactionStopped = false;
82
+ const compactionMarker = `state-flow-boundary:${randomUUID()}`;
74
83
  let passiveContinuation: PassiveContinuation | undefined;
75
84
  let bootstrapContinuation: PassiveContinuation | undefined;
76
85
  let artifactRefreshPending = false;
@@ -80,7 +89,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
80
89
  let pendingPublication: PendingPublicationDiagnostic | undefined;
81
90
  let rehydrationPhase: RehydrationPhase | undefined;
82
91
  let turnPublicationTarget: string | undefined;
83
- const activePublicationWorkers = new Set<string>();
92
+ const activePublicationWorkers = new Map<string, { controller: AbortController; done: Promise<void> }>();
93
+ let publicationStopped = false;
94
+ let publicationShutdown: Promise<void> | undefined;
84
95
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
85
96
  const skillReads = new SkillReadTracker();
86
97
  const artifactReads = new ArtifactReadTracker();
@@ -131,17 +142,24 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
131
142
  fallbackAttempts = 0;
132
143
  fallbackFailureReported = false;
133
144
  responseAwaitingReconciliation = false;
145
+ completedRunAccepted = false;
146
+ compactionPlan = undefined;
147
+ compactionInFlight = false;
134
148
  runAnchorTimestamp = undefined;
135
149
  skillReads.clear();
136
150
  artifactReads.clear();
137
151
  }
138
152
 
139
- function passiveStopTimestamp(ctx: ExtensionContext): number | undefined {
153
+ function passiveStopBoundary(ctx: ExtensionContext): { at: number; from?: number } | undefined {
140
154
  for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
141
155
  try {
142
156
  if (entry?.type !== "custom" || entry.customType !== PASSIVE_STOP_ENTRY_TYPE) continue;
143
- const at = (entry.data as { at?: unknown } | undefined)?.at;
144
- if (typeof at === "number" && Number.isSafeInteger(at) && at >= 0) return at;
157
+ const { at, from, reset, owner } = (entry.data as { at?: unknown; from?: unknown; reset?: unknown; owner?: unknown } | undefined) ?? {};
158
+ if (reset === true && owner === ctx.sessionManager.getSessionId()) return undefined;
159
+ if (typeof at === "number" && Number.isSafeInteger(at) && at >= 0) return {
160
+ at,
161
+ ...(typeof from === "number" && Number.isSafeInteger(from) && from >= 0 ? { from } : {}),
162
+ };
145
163
  } catch {
146
164
  // A hostile unrelated branch entry cannot manufacture or suppress a valid marker.
147
165
  }
@@ -167,12 +185,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
167
185
  return;
168
186
  }
169
187
  try {
170
- const discovery = globalMarkdown.refresh();
188
+ const discovery = globalMarkdown.refresh(Object.keys(scopeStates.global.artifacts));
171
189
  const plan = planArtifactInvalidation(
172
190
  discovery.sources,
173
191
  scopeStates.global.artifacts,
174
192
  ORDINARY_ARTIFACT_COMPILER,
175
- {},
193
+ { removed: discovery.removed },
176
194
  runtime?.artifactProvenance("global") ?? {},
177
195
  );
178
196
  artifactInvalidations = structuredClone(plan.requiresCompilation);
@@ -211,9 +229,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
211
229
  }
212
230
 
213
231
  function assistantToolBatch(ctx: ExtensionContext, toolCallId: string): string[] | undefined {
214
- const branch = ctx.sessionManager.getBranch();
215
- for (let index = branch.length - 1; index >= 0; index--) {
216
- const entry = branch[index] as { type?: unknown; message?: { role?: unknown; content?: unknown } };
232
+ for (let cursor = ctx.sessionManager.getLeafEntry(); cursor; cursor = cursor.parentId ? ctx.sessionManager.getEntry(cursor.parentId) : undefined) {
233
+ const entry = cursor as { type?: unknown; message?: { role?: unknown; content?: unknown } };
217
234
  if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
218
235
  const calls = entry.message.content.filter((block): block is { type: "toolCall"; id: string; name: string } => {
219
236
  return typeof block === "object" && block !== null
@@ -308,6 +325,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
308
325
  }
309
326
 
310
327
  function launchPublicationWorker(): void {
328
+ if (publicationStopped) return;
311
329
  let destination: ReturnType<typeof resolveGitPushDestination>;
312
330
  try {
313
331
  destination = resolveGitPushDestination(repositoryRoot);
@@ -328,17 +346,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
328
346
  return;
329
347
  }
330
348
  if (!lease) return;
331
- activePublicationWorkers.add(path);
332
- void runPublicationWorker(
349
+ const controller = new AbortController();
350
+ const done = runPublicationWorker(
333
351
  queued,
334
- ({ target }) => new Promise<void>((resolve, reject) => {
335
- execFile("git", ["-C", repositoryRoot, "push", destination.remote, `${target}:${destination.ref}`], {
336
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
337
- }, (error) => error ? reject(error) : resolve());
338
- }),
352
+ ({ target }) => pushGitTarget(repositoryRoot, destination, target, controller.signal),
339
353
  () => loadPublicationQueue(path) ?? queued,
340
354
  (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant),
341
355
  ).then((result) => {
356
+ if (publicationStopped) return; // Late outcomes belong to an unconfirmed queue, not the replacement generation.
342
357
  const current = loadPublicationQueue(path);
343
358
  if (!current) return;
344
359
  if (result.next === undefined) {
@@ -352,13 +367,33 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
352
367
  // Queue/CAS truth remains durable; status and a later activation expose retry.
353
368
  }).finally(() => {
354
369
  activePublicationWorkers.delete(path);
355
- lease.release();
356
370
  try {
357
- if (loadPublicationQueue(path)?.status === "pending") launchPublicationWorker();
371
+ lease.release();
372
+ if (!publicationStopped && loadPublicationQueue(path)?.status === "pending") launchPublicationWorker();
358
373
  } catch {
359
- // Malformed queue persistence stays inert until an explicit retry or repair.
374
+ // Failed lease cleanup or malformed persistence stays inert until retry or repair.
360
375
  }
361
376
  });
377
+ activePublicationWorkers.set(path, { controller, done });
378
+ }
379
+
380
+ function shutdownPublicationWorkers(ctx: ExtensionContext): Promise<void> {
381
+ publicationStopped = true;
382
+ return publicationShutdown ??= (async () => {
383
+ const workers = [...activePublicationWorkers.values()];
384
+ for (const { controller } of workers) controller.abort();
385
+ if (workers.length === 0) return;
386
+ let timeout: ReturnType<typeof setTimeout> | undefined;
387
+ try {
388
+ const completed = await Promise.race([
389
+ Promise.all(workers.map(({ done }) => done)).then(() => true),
390
+ new Promise<false>((resolve) => { timeout = setTimeout(() => resolve(false), PUBLICATION_SHUTDOWN_WAIT_MS); }),
391
+ ]);
392
+ 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");
393
+ } finally {
394
+ clearTimeout(timeout);
395
+ }
396
+ })();
362
397
  }
363
398
 
364
399
  function retryPendingPush(ctx: ExtensionContext): void {
@@ -409,12 +444,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
409
444
  artifactFreshnessError = "durable global artifact registry is unavailable";
410
445
  } else {
411
446
  try {
412
- const discovery = globalMarkdown.refresh();
447
+ const discovery = globalMarkdown.refresh(Object.keys(diagnosticStates.global.artifacts));
448
+ artifactFreshnessError = discovery.unavailable;
413
449
  const plan = planArtifactInvalidation(
414
450
  discovery.sources,
415
451
  diagnosticStates.global.artifacts,
416
452
  ORDINARY_ARTIFACT_COMPILER,
417
- {},
453
+ { removed: discovery.removed },
418
454
  runtime?.artifactProvenance("global") ?? {},
419
455
  );
420
456
  staleArtifacts = [
@@ -456,6 +492,34 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
456
492
  };
457
493
  }
458
494
 
495
+ function prepareBranchRestore(ctx: ExtensionContext, revision: string, legacy?: Snapshot): { snapshot: Snapshot; restore: () => Snapshot } {
496
+ if (!forkInitialization) {
497
+ try {
498
+ return runtime!.prepareRestore(revision, legacy);
499
+ } catch (error) {
500
+ if (error instanceof MissingSessionRuntimeError && typeof ctx.sessionManager.getHeader()?.parentSession === "string") {
501
+ throw new RevisionUnavailableError("State Flow checkpoint has no child-owned runtime; select a child checkpoint or resume the parent");
502
+ }
503
+ throw error;
504
+ }
505
+ }
506
+ const file = ctx.sessionManager.getHeader()?.parentSession;
507
+ if (typeof file !== "string" || !isAbsolute(file)) throw new Error("State Flow fork requires a persisted native parent session");
508
+ const parent = readNativeSessionHeader(file);
509
+ if (parent.cwd !== resolve(ctx.cwd)) throw new Error("State Flow fork parent CWD identity mismatch");
510
+ const source = resolveSessionAddress(parent.file, parent.id, parent.timestamp);
511
+ const prepared = runtime!.prepareFork(source, revision);
512
+ return { snapshot: prepared.snapshot, restore: () => {
513
+ const accepted = prepared.fork();
514
+ snapshot = accepted.snapshot;
515
+ recordPolicyPublication(accepted.publication, ctx);
516
+ // Copied Stop markers belong to the parent, including after a child reload/resume.
517
+ pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { reset: true, owner: ctx.sessionManager.getSessionId() });
518
+ forkInitialization = false;
519
+ return snapshot;
520
+ } };
521
+ }
522
+
459
523
  function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown): void {
460
524
  clearRunTransient();
461
525
  passiveContinuation = undefined;
@@ -471,11 +535,22 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
471
535
  pendingPublication = undefined;
472
536
  branchHasSnapshot = false;
473
537
  branchStartsWithoutRuntime = false;
538
+ forkInitialization = sessionStartReason === "fork";
474
539
  try {
475
540
  const branch = ctx.sessionManager.getBranch();
476
541
  const discovery = discoverSnapshotData(branch);
477
- const recovery = recoverSnapshot(discovery.candidates, (revision, legacy) =>
478
- inspectSnapshotRevision(ctx.cwd, session.id, repositoryRoot, revision, legacy, session.key).snapshot);
542
+ let restoreSelected: (() => Snapshot) | undefined;
543
+ const recovery = recoverSnapshot(discovery.candidates, (revision, legacy) => {
544
+ try {
545
+ const prepared = prepareBranchRestore(ctx, revision, legacy);
546
+ restoreSelected = prepared.restore;
547
+ return prepared.snapshot;
548
+ } catch (error) {
549
+ // A failed source proof never licenses an older/empty private copy.
550
+ if (forkInitialization) throw new RevisionUnavailableError(`Cannot copy State Flow fork source: ${error instanceof Error ? error.message : String(error)}`);
551
+ throw error;
552
+ }
553
+ });
479
554
  branchStartsWithoutRuntime = recovery.disabledMarker === true
480
555
  || (discovery.candidates.length === 0 && discovery.errors.length === 0);
481
556
  const skipped = discovery.errors.length + recovery.skipped.length;
@@ -486,7 +561,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
486
561
  snapshot = recovery.snapshot;
487
562
  if (branchHasSnapshot && snapshot.meta.durableBase) {
488
563
  const selectedRevision = snapshot.meta.durableBase;
489
- snapshot = runtime.restore(selectedRevision, snapshot);
564
+ snapshot = restoreSelected ? restoreSelected() : prepareBranchRestore(ctx, selectedRevision, snapshot).restore();
490
565
  if (snapshot.meta.durableBase !== selectedRevision) persist();
491
566
  } else if (branchHasSnapshot && snapshot.config.enabled) {
492
567
  const publication = runtime.initialize(snapshot, true);
@@ -540,11 +615,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
540
615
  ctx.ui.notify(`State Flow restored disabled: ${snapshot.meta.validation.error}`, "error");
541
616
  }
542
617
  if (retainsPhysicalSessionProjection(sessionStartReason) && runtime?.view) {
543
- const stoppedAt = passiveStopTimestamp(ctx);
544
- if (stoppedAt !== undefined) {
618
+ const boundary = passiveStopBoundary(ctx);
619
+ if (boundary !== undefined) {
545
620
  const continuation = createPassiveContinuation(
546
621
  projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
547
- stoppedAt,
622
+ boundary.at,
623
+ boundary.from,
548
624
  );
549
625
  if (!snapshot.config.enabled) passiveContinuation = continuation;
550
626
  else if (snapshot.meta.bootstrap) bootstrapContinuation = continuation;
@@ -776,7 +852,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
776
852
  const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
777
853
  && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
778
854
  if (!runtime.view && snapshot.meta.durableBase) {
779
- snapshot = runtime.restore(snapshot.meta.durableBase, snapshot);
855
+ snapshot = prepareBranchRestore(ctx, snapshot.meta.durableBase, snapshot).restore();
780
856
  setPendingPublication(snapshot.meta.pendingPublication);
781
857
  branchHasSnapshot = true;
782
858
  }
@@ -855,6 +931,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
855
931
  ? createPassiveContinuation(
856
932
  projectStateForModel(overlayStates(exitStates.global, exitStates.cwd, exitStates.session)),
857
933
  stoppedAt,
934
+ ctx.isIdle() ? undefined : runAnchorTimestamp,
858
935
  )
859
936
  : undefined;
860
937
  const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
@@ -873,7 +950,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
873
950
  artifactInvalidations = [];
874
951
  artifactReads.setCandidates([]);
875
952
  artifactRefreshPending = false;
876
- if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { at: stoppedAt });
953
+ if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, {
954
+ at: stoppedAt,
955
+ ...(exitHandoff.activeRunStartedAt === undefined ? {} : { from: exitHandoff.activeRunStartedAt }),
956
+ });
877
957
  syncStateFlowTools();
878
958
  persist();
879
959
  updateUi(ctx);
@@ -929,10 +1009,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
929
1009
  fallbackAttempts = 0;
930
1010
  fallbackFailureReported = false;
931
1011
  responseAwaitingReconciliation = false;
1012
+ completedRunAccepted = false;
932
1013
  const rotatesRun = snapshot.meta.specification !== undefined;
933
1014
  if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
934
1015
  if (prepareRun(snapshot, event.prompt)) {
935
- if (rotatesRun) runAnchorTimestamp = undefined;
1016
+ runAnchorTimestamp = undefined;
936
1017
  persist();
937
1018
  }
938
1019
  return {
@@ -945,7 +1026,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
945
1026
  return { messages: passiveContinuationMessages(event.messages as AgentMessage[], passiveContinuation) };
946
1027
  }
947
1028
  if (!snapshot.config.enabled || snapshot.meta.specification === undefined) return;
948
- const effectiveState = projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session));
1029
+ const effectiveState = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
949
1030
  const invalidations = artifactInvalidations.map(({ path, reason }) => ({ path, reason }));
950
1031
  const recentTransitions = projectRecentTransitionsWithLimit(
951
1032
  RECENT_TRANSITION_LIMIT,
@@ -1010,7 +1091,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1010
1091
  });
1011
1092
 
1012
1093
  pi.on("message_end", (event, ctx): any => {
1013
- if (!snapshot.config.enabled || event.message.role !== "assistant") return;
1094
+ if (!snapshot.config.enabled) return;
1095
+ // Capture the first native user boundary even during bootstrap; steering keeps that anchor.
1096
+ if (event.message.role === "user" && runAnchorTimestamp === undefined) runAnchorTimestamp = event.message.timestamp;
1097
+ if (event.message.role !== "assistant") return;
1014
1098
  const message = event.message as unknown as { role: "assistant"; stopReason?: string; content?: unknown };
1015
1099
  if (message.stopReason === "aborted" || assistantToolCallCount(message.content) > 0 || message.stopReason === "toolUse") {
1016
1100
  responseAwaitingReconciliation = false;
@@ -1036,6 +1120,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1036
1120
  });
1037
1121
 
1038
1122
  pi.on("turn_end", (event, ctx) => {
1123
+ const wasBootstrap = snapshot.meta.bootstrap === true;
1039
1124
  if (!snapshot.config.enabled || !responseAwaitingReconciliation) {
1040
1125
  updateUi(ctx);
1041
1126
  return;
@@ -1050,6 +1135,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1050
1135
  rehydrationPhase = "step";
1051
1136
  enqueueTurnPublication(ctx);
1052
1137
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1138
+ completedRunAccepted = !wasBootstrap;
1053
1139
  } catch (error) {
1054
1140
  recordDiagnostic(error instanceof Error ? error.message : String(error), "finalization", ctx);
1055
1141
  ctx.ui.notify(responseCommitted
@@ -1061,12 +1147,32 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1061
1147
  updateUi(ctx);
1062
1148
  });
1063
1149
 
1150
+ pi.on("session_before_compact", (event) => {
1151
+ if (!compactionPlan) return;
1152
+ if (compactionStopped && event.reason === "manual" && event.customInstructions === compactionMarker) return { cancel: true };
1153
+ const result = stateFlowCompactionResult(compactionPlan, compactionMarker, event);
1154
+ if (result === undefined || "cancel" in result) return result;
1155
+ return { compaction: result };
1156
+ });
1157
+
1064
1158
  pi.on("agent_settled", (_event, ctx) => {
1065
1159
  if (telegramStartPending && !snapshot.config.enabled) {
1066
1160
  telegramStartPending = false;
1067
1161
  startStateFlow(ctx);
1068
1162
  }
1069
1163
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1164
+ if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap || resolutionPending
1165
+ || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !snapshot.meta.durableBase) return;
1166
+ completedRunAccepted = false;
1167
+ const plan = planStateFlowCompaction(ctx.sessionManager.buildContextEntries(), snapshot.meta.durableBase, snapshot.meta.step);
1168
+ if (!plan) return;
1169
+ compactionPlan = plan;
1170
+ compactionInFlight = true;
1171
+ ctx.compact({
1172
+ customInstructions: compactionMarker,
1173
+ onComplete: () => { compactionPlan = undefined; compactionInFlight = false; },
1174
+ onError: () => { compactionPlan = undefined; compactionInFlight = false; },
1175
+ });
1070
1176
  });
1071
1177
 
1072
1178
  pi.on("session_start", (event, ctx) => {
@@ -1080,8 +1186,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1080
1186
  pi.on("session_tree", (_event, ctx) => {
1081
1187
  restoreActiveBranch(ctx);
1082
1188
  });
1083
- pi.on("session_shutdown", () => {
1189
+ pi.on("session_shutdown", (_event, ctx) => {
1084
1190
  telegramStartPending = false;
1191
+ compactionStopped = true;
1192
+ completedRunAccepted = false;
1085
1193
  telegram.dispose();
1194
+ return shutdownPublicationWorkers(ctx);
1086
1195
  });
1087
1196
  }