@llblab/pi-kit 0.16.0 → 0.17.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 (51) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -1
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +16 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +5 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -1
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +2 -4
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +31 -239
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +1 -1
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +18 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +44 -1
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +2 -2
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +21 -3
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +7 -0
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +55 -5
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +17 -0
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +102 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +16 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +98 -12
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +17 -0
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +38 -0
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +5 -0
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +10 -2
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +1 -0
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +4 -3
  29. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  30. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +79 -0
  31. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +23 -129
  32. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +22 -17
  33. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
  34. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +15 -6
  35. package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -2
  36. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +6 -1
  37. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +33 -228
  38. package/node_modules/@llblab/pi-state-flow/lib/history.ts +1 -1
  39. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +53 -1
  40. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +18 -4
  41. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +51 -5
  42. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +96 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/query.ts +99 -11
  44. package/node_modules/@llblab/pi-state-flow/lib/session.ts +45 -0
  45. package/node_modules/@llblab/pi-state-flow/lib/state.ts +13 -2
  46. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -1
  47. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +4 -3
  48. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  49. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +79 -0
  50. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +23 -129
  51. package/package.json +2 -2
@@ -20,7 +20,7 @@ State Flow does not undo tool effects. After interruption or returning to an old
20
20
 
21
21
  Native fork replacement copies the source session checkpoint, retained patch tail and matching provenance into the new session's own storage. Global/CWD streams and provenance stay current and unchanged. An earlier fork selection copies that point's private state, not the parent's later private work. Parent data/history remain intact; selected enablement is retained, so a stopped source does not become enabled automatically.
22
22
 
23
- The child starts at step zero and a new temporal origin. Its copied tail is preserved, but pre-origin records are not seven past aligned `state[n]` boundaries. The child's own transitions build its hot window; owned checkpoints support normal reload/resume. Parent Stop projection is not inherited, including after child reload.
23
+ The child starts at step zero and a new temporal origin. Its copied tail is preserved, but pre-origin records are not seven past aligned causal boundaries addressable through `effective[n]` or scoped paths. The child's own transitions build its hot window; owned checkpoints support normal reload/resume. Parent Stop projection is not inherited, including after child reload.
24
24
 
25
25
  Initial copying requires a native fork start event, a regular canonical direct-parent session file, matching CWD/identity, a readable temporal source and an unused child namespace. Missing/unsafe evidence or CAS conflicts leave the copy unavailable rather than importing unrelated or newer private state. Explicit Start can retry an unaccepted copy in the same loaded fork after the cause is corrected.
26
26
 
@@ -144,4 +144,4 @@ The agent should use sufficient materialized knowledge before rereading files. R
144
144
 
145
145
  Knowledge discovery finds regular lowercase `*.md` beneath its configured root, hashes opaque bytes, and skips symlinks. Only confirmed missing Markdown paths within an available root are pruned; external/non-Markdown artifacts and state under a missing whole root are preserved. An unavailable root makes freshness unknown in status. Successful reads of stale ordinary candidates require same-path global compilation; Skill reads require CWD compilation. The runtime owns provenance: model patches cannot write or delete individual freshness fields, including legacy spellings. Existing legacy entries remain readable and semantically editable. Missing freshness evidence means unknown-but-usable, not proof that a source was acquired.
146
146
 
147
- The optional `state-flow-memory` Skill handles explicit bounded curation, required feature/release/project phase-boundary reconciliation, and external promotion. Ordinary handoffs clean only touched and obviously stale visible branches; the Skill supplies the fuller scoped migration procedure when a phase boundary or explicit request earns it. It is not a background maintenance loop. Promotion must verify the destination before removing the only accepted source copy. Artifact/compiler details and model-tool contracts belong in the [architecture](architecture.md#artifact-routing).
147
+ The packaged `state-flow-guide` Skill answers concrete operational questions about reads, patches, inheritance, acquisition, finalization, and recovery without initiating cleanup. The separate `state-flow-memory` Skill handles explicit bounded curation, feature/release/project phase-boundary reconciliation, and external promotion. Ordinary handoffs clean only touched and obviously stale visible branches; neither Skill is a background maintenance loop. Promotion must verify the destination before removing the only accepted source copy. Artifact/compiler details and model-tool contracts belong in the [architecture](architecture.md#artifact-routing).
@@ -20,7 +20,7 @@ import {
20
20
  } from "./artifact.ts";
21
21
  import { canonicalJson, isJsonValue, isObject } from "./json.ts";
22
22
  import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
23
- import type { StateScope } from "./state.ts";
23
+ import { migratePreIntentState, type StateScope } from "./state.ts";
24
24
 
25
25
  const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
26
26
  const STATE_FILE = "state.json";
@@ -100,6 +100,7 @@ export function classifyScopeStream(
100
100
  }
101
101
  const temporal = meta?.temporal;
102
102
  if (isObject(temporal) && Object.hasOwn(temporal, "checkpoint") && Array.isArray(temporal.patches)) {
103
+ checkpoint = migratePreIntentState(checkpoint) ?? checkpoint;
103
104
  const owner = meta?.owner;
104
105
  if (scope === "cwd" && expectedCwd !== undefined
105
106
  && (!isObject(owner) || Object.keys(owner).join(",") !== "cwd" || owner.cwd !== resolve(expectedCwd))) {
@@ -126,6 +127,10 @@ export function classifyScopeStream(
126
127
  } else if (scope === "cwd" && expectedCwd !== undefined) {
127
128
  throw new Error("State Flow CWD scope identity is missing");
128
129
  }
130
+ if (isObject(legacyCheckpoint) && isObject(legacyCheckpoint.state)) {
131
+ const migrated = migratePreIntentState(legacyCheckpoint.state);
132
+ if (migrated) legacyCheckpoint = { ...legacyCheckpoint, state: migrated };
133
+ }
129
134
  const stream = { checkpoint: legacyCheckpoint, patches };
130
135
  validateScopeStream(stream, scope);
131
136
  return { kind: "present", stream };
@@ -4,27 +4,24 @@ 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";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
- import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol } from "./protocol.ts";
7
+ import { assistantToolCallCount, finalizedAssistantResponse, formatPatchStateArguments, normalizePatchStateArguments, separatedFailure, separatedOutput, stateFlowProtocol } from "./protocol.ts";
8
8
  import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, passiveContinuationMessages, runtimeContextMessage, syntheticUser, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
9
9
  import { ArtifactReadTracker } from "./acquisition.ts";
10
10
  import { loadStateFlowConfig } from "./config.ts";
11
11
  import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
12
- import { isAbsolute, relative, resolve, sep } from "node:path";
12
+ import { isAbsolute, resolve } from "node:path";
13
13
  import { SkillReadTracker } from "./skills.ts";
14
14
  import { emptySnapshot, migrationFailure, persistableSnapshot, RevisionUnavailableError, type Snapshot } from "./snapshot.ts";
15
15
  import { readNativeSessionHeader } from "./continuation.ts";
16
16
  import { MissingSessionRuntimeError, SharedScopeRemovalConflictError, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
17
17
  import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
18
18
  import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
19
- import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
19
+ import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
20
20
  import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
21
21
  import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
22
22
  import { recoverSnapshot } from "./recovery.ts";
23
23
  import type { RehydrationPhase } from "./rehydration.ts";
24
- import { resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument } from "./publication.ts";
25
- import { coalescePublicationTarget, createPublicationQueue, type PublicationQueueState } from "./publication.ts";
26
- import { acquirePublicationWorkerLease, loadPublicationQueue, publicationQueuePath, removePublicationQueue, savePublicationQueue } from "./publication.ts";
27
- import { runPublicationWorker } from "./publication.ts";
24
+ import { loadPublicationQueue, publicationQueuePath, PublicationWorkerController, resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument, type PublicationQueueState } from "./publication.ts";
28
25
  import { getKnowledgeRoot, GlobalMarkdownDiscovery } from "./discovery.ts";
29
26
  import { canonicalJson, isObject, sameJson } from "./json.ts";
30
27
  import { readProjectedState, readStatePath } from "./query.ts";
@@ -35,7 +32,7 @@ import {
35
32
  type SessionAddress,
36
33
  } from "./durable.ts";
37
34
  import { projectRecentTransitionsWithLimit, RECENT_TRANSITION_LIMIT } from "./history.ts";
38
- import { appendStateFlowDiagnostic, projectDiagnosticContent, stateFlowLogPath, type StateFlowDiagnosticCategory } from "./logging.ts";
35
+ import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
39
36
  import { isGitCommitAncestor, pushGitCommit, pushGitTarget, resolveGitPushDestination } from "./git.ts";
40
37
  import {
41
38
  ORDINARY_ARTIFACT_COMPILER,
@@ -54,64 +51,13 @@ export interface StateFlowExtensionOptions {
54
51
  passive?: { bootstrap?: boolean; tools?: boolean };
55
52
  }
56
53
 
54
+ export { formatPatchStateArguments, normalizePatchStateArguments };
55
+
57
56
  export const PATCH_STATE_TOOL_NAME = "patch_state";
58
57
  export const READ_STATE_TOOL_NAME = "read_state";
59
58
  export const MAX_FALLBACK_ATTEMPTS: number = 2;
60
59
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
61
60
  const PUBLICATION_SHUTDOWN_WAIT_MS = 2_000;
62
- const PATCH_DISPLAY_SECTION_KEYS = new Set([
63
- "global",
64
- "cwd",
65
- "session",
66
- "artifacts",
67
- "contract",
68
- "working",
69
- "response",
70
- "final",
71
- ]);
72
-
73
- /** Keep successful patch JSON valid while separating adjacent scopes and memory sections visually. */
74
- export function formatPatchStateArguments(args: unknown): string {
75
- const seenAtIndent = new Set<number>();
76
- return JSON.stringify(args, null, 2).split("\n").flatMap((line) => {
77
- const indent = line.length - line.trimStart().length;
78
- const match = /^(\s+)"([^"]+)":/.exec(line);
79
- if (match === null || !PATCH_DISPLAY_SECTION_KEYS.has(match[2])) {
80
- for (const seenIndent of seenAtIndent) {
81
- if (seenIndent > indent) seenAtIndent.delete(seenIndent);
82
- }
83
- return [line];
84
- }
85
- const separator = seenAtIndent.has(indent) ? [""] : [];
86
- seenAtIndent.add(indent);
87
- return [...separator, line];
88
- }).join("\n");
89
- }
90
-
91
- /** Normalize a bounded compatibility superset without advertising aliases in the model-facing contract. */
92
- export function normalizePatchStateArguments(args: unknown): any {
93
- if (!isObject(args) || !Object.hasOwn(args, "final")) return args;
94
- const value = args.final;
95
- let final: boolean;
96
- if (typeof value === "boolean") final = value;
97
- else if (value === 1) final = true;
98
- else if (value === 0) final = false;
99
- else if (typeof value === "string" && value.trim().toLowerCase() === "true") final = true;
100
- else if (typeof value === "string" && value.trim().toLowerCase() === "false") final = false;
101
- else return args;
102
- return { ...args, final };
103
- }
104
-
105
- /** Keep tool output visually separated from its heading with exactly one leading newline. */
106
- function separatedOutput(text: string): string {
107
- return `\n${text.replace(/^\n+/, "")}`;
108
- }
109
-
110
- /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
111
- function separatedFailure(error: unknown): Error {
112
- const message = error instanceof Error ? error.message : String(error);
113
- return new Error(separatedOutput(message), error instanceof Error ? { cause: error } : undefined);
114
- }
115
61
 
116
62
  export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
117
63
  const agentDir = options.agentDir ?? getAgentDir();
@@ -145,15 +91,22 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
145
91
  let pendingPublication: PendingPublicationDiagnostic | undefined;
146
92
  let rehydrationPhase: RehydrationPhase | undefined;
147
93
  let turnPublicationTarget: string | undefined;
148
- const activePublicationWorkers = new Map<string, { controller: AbortController; done: Promise<void> }>();
149
- let publicationStopped = false;
150
94
  let publicationShutdown: Promise<void> | undefined;
151
95
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
96
+ 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
+ });
152
106
  const skillReads = new SkillReadTracker();
153
107
  const artifactReads = new ArtifactReadTracker();
154
108
  const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(agentDir));
155
109
  let artifactInvalidations: ArtifactInvalidationRequest[] = [];
156
- let loggingWarningReported = false;
157
110
  let telegramStartPending = false;
158
111
 
159
112
  function sessionAddress(ctx: ExtensionContext): SessionAddress {
@@ -206,27 +159,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
206
159
  artifactReads.clear();
207
160
  }
208
161
 
209
- function passiveStopBoundary(ctx: ExtensionContext): { at: number; from?: number } | undefined {
210
- for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
211
- try {
212
- if (entry?.type !== "custom" || entry.customType !== PASSIVE_STOP_ENTRY_TYPE) continue;
213
- const { at, from, reset, owner } = (entry.data as { at?: unknown; from?: unknown; reset?: unknown; owner?: unknown } | undefined) ?? {};
214
- if (reset === true && owner === ctx.sessionManager.getSessionId()) return undefined;
215
- if (typeof at === "number" && Number.isSafeInteger(at) && at >= 0) return {
216
- at,
217
- ...(typeof from === "number" && Number.isSafeInteger(from) && from >= 0 ? { from } : {}),
218
- };
219
- } catch {
220
- // A hostile unrelated branch entry cannot manufacture or suppress a valid marker.
221
- }
222
- }
223
- return undefined;
224
- }
225
-
226
- function retainsPhysicalSessionProjection(reason: unknown): boolean {
227
- return reason === undefined || reason === "startup" || reason === "reload" || reason === "resume";
228
- }
229
-
230
162
  function deferArtifactRefresh(): void {
231
163
  artifactInvalidations = [];
232
164
  artifactReads.setCandidates([]);
@@ -289,21 +221,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
289
221
  : active.filter((name) => !owned.includes(name)));
290
222
  }
291
223
 
292
- function assistantToolBatch(ctx: ExtensionContext, toolCallId: string): string[] | undefined {
293
- for (let cursor = ctx.sessionManager.getLeafEntry(); cursor; cursor = cursor.parentId ? ctx.sessionManager.getEntry(cursor.parentId) : undefined) {
294
- const entry = cursor as { type?: unknown; message?: { role?: unknown; content?: unknown } };
295
- if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
296
- const calls = entry.message.content.filter((block): block is { type: "toolCall"; id: string; name: string } => {
297
- return typeof block === "object" && block !== null
298
- && (block as { type?: unknown }).type === "toolCall"
299
- && typeof (block as { id?: unknown }).id === "string"
300
- && typeof (block as { name?: unknown }).name === "string";
301
- });
302
- if (calls.some(({ id }) => id === toolCallId)) return calls.map(({ name }) => name);
303
- }
304
- return undefined;
305
- }
306
-
307
224
  function recordPublication(publication: RuntimePublication, ctx: ExtensionContext): void {
308
225
  const revision = publication.revision ?? publication.commit;
309
226
  if (revision !== undefined) snapshot.meta.durableBase = revision;
@@ -330,8 +247,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
330
247
  if (mode !== "turn-end" || target === undefined || !/^[0-9a-f]{40,64}$/.test(target)) return;
331
248
  turnPublicationTarget = target;
332
249
  try {
333
- enqueueTurnPublication(ctx);
334
- launchPublicationWorker();
250
+ enqueueTurnPublication();
251
+ publicationWorker.launch();
335
252
  } catch (error) {
336
253
  ctx.ui.notify(
337
254
  `State Flow accepted the local commit; remote publication is deferred: ${error instanceof Error ? error.message : String(error)}`,
@@ -372,95 +289,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
372
289
  return true;
373
290
  }
374
291
 
375
- function enqueueTurnPublication(ctx: ExtensionContext): void {
292
+ function enqueueTurnPublication(): void {
376
293
  const target = turnPublicationTarget;
377
294
  turnPublicationTarget = undefined;
378
- if (!target) return;
379
- const destination = resolveGitPushDestination(repositoryRoot);
380
- if (!destination) return;
381
- const path = publicationQueuePath(destination);
382
- const previous = loadPublicationQueue(path);
383
- const next = previous
384
- ? coalescePublicationTarget(previous, destination, target, (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant), {
385
- onDivergedLineage: (dropped) => recordDiagnostic(
386
- `Retired publication queue target ${dropped.target} after a journal lineage rewrite; retargeting to ${target}`,
387
- "publication-conflict", ctx,
388
- ),
389
- })
390
- : createPublicationQueue(destination, target);
391
- savePublicationQueue(path, next, previous);
392
- }
393
-
394
- function launchPublicationWorker(): void {
395
- if (publicationStopped) return;
396
- let destination: ReturnType<typeof resolveGitPushDestination>;
397
- try {
398
- destination = resolveGitPushDestination(repositoryRoot);
399
- } catch (error) {
400
- if (error instanceof Error && /ENOENT/.test(error.message)) return;
401
- throw error;
402
- }
403
- if (!destination) return;
404
- const path = publicationQueuePath(destination);
405
- if (activePublicationWorkers.has(path)) return;
406
- let queued: PublicationQueueState | undefined;
407
- let lease: ReturnType<typeof acquirePublicationWorkerLease>;
408
- try {
409
- queued = loadPublicationQueue(path);
410
- if (!queued) return;
411
- lease = acquirePublicationWorkerLease(path);
412
- } catch {
413
- return;
414
- }
415
- if (!lease) return;
416
- const controller = new AbortController();
417
- const done = runPublicationWorker(
418
- queued,
419
- ({ target }) => pushGitTarget(repositoryRoot, destination, target, controller.signal),
420
- () => loadPublicationQueue(path) ?? queued,
421
- (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant),
422
- ).then((result) => {
423
- if (publicationStopped) return; // Late outcomes belong to an unconfirmed queue, not the replacement generation.
424
- const current = loadPublicationQueue(path);
425
- if (!current) return;
426
- if (result.next === undefined) {
427
- if (current.target === result.attempted.target) removePublicationQueue(path, current);
428
- return;
429
- }
430
- if (current.target === result.attempted.target || result.next.target === current.target) {
431
- savePublicationQueue(path, result.next, current);
432
- }
433
- }).catch(() => {
434
- // Queue/CAS truth remains durable; status and a later activation expose retry.
435
- }).finally(() => {
436
- activePublicationWorkers.delete(path);
437
- try {
438
- lease.release();
439
- if (!publicationStopped && loadPublicationQueue(path)?.status === "pending") launchPublicationWorker();
440
- } catch {
441
- // Failed lease cleanup or malformed persistence stays inert until retry or repair.
442
- }
443
- });
444
- activePublicationWorkers.set(path, { controller, done });
295
+ if (target) publicationWorker.enqueue(target);
445
296
  }
446
297
 
447
298
  function shutdownPublicationWorkers(ctx: ExtensionContext): Promise<void> {
448
- publicationStopped = true;
449
- return publicationShutdown ??= (async () => {
450
- const workers = [...activePublicationWorkers.values()];
451
- for (const { controller } of workers) controller.abort();
452
- if (workers.length === 0) return;
453
- let timeout: ReturnType<typeof setTimeout> | undefined;
454
- try {
455
- const completed = await Promise.race([
456
- Promise.all(workers.map(({ done }) => done)).then(() => true),
457
- new Promise<false>((resolve) => { timeout = setTimeout(() => resolve(false), PUBLICATION_SHUTDOWN_WAIT_MS); }),
458
- ]);
459
- 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");
460
- } finally {
461
- clearTimeout(timeout);
462
- }
463
- })();
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
+ });
464
302
  }
465
303
 
466
304
  function retryPendingPush(ctx: ExtensionContext): void {
@@ -472,8 +310,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
472
310
  if (mode === "turn-end") {
473
311
  turnPublicationTarget = target;
474
312
  try {
475
- enqueueTurnPublication(ctx);
476
- launchPublicationWorker();
313
+ enqueueTurnPublication();
314
+ publicationWorker.launch();
477
315
  } catch (error) {
478
316
  ctx.ui.notify(`State Flow retained local state; asynchronous publication recovery is deferred: ${error instanceof Error ? error.message : String(error)}`, "warning");
479
317
  }
@@ -681,7 +519,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
681
519
  ctx.ui.notify(`State Flow restored disabled: ${snapshot.meta.validation.error}`, "error");
682
520
  }
683
521
  if (retainsPhysicalSessionProjection(sessionStartReason) && runtime?.view) {
684
- const boundary = passiveStopBoundary(ctx);
522
+ const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
685
523
  if (boundary !== undefined) {
686
524
  const continuation = createPassiveContinuation(
687
525
  projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
@@ -705,41 +543,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
705
543
  updateUi(ctx);
706
544
  }
707
545
 
708
- interface DiagnosticExtras {
709
- content?: unknown;
710
- input?: unknown;
711
- tool?: string;
712
- toolCallId?: string;
713
- resolutionAttempt?: number;
714
- terminalEligible?: boolean;
715
- }
716
-
717
546
  function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, extras: DiagnosticExtras = {}): void {
718
- if (!config.logging) return;
719
- try {
720
- const path = stateFlowLogPath(agentDir);
721
- const fromRepository = relative(repositoryRoot, path);
722
- if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
723
- throw new Error("diagnostic path overlaps the State Flow repository");
724
- }
725
- appendStateFlowDiagnostic(path, {
726
- at: new Date().toISOString(),
727
- sessionId: sessionAddress(ctx).id,
728
- cwd: resolve(ctx.cwd),
729
- category,
730
- error,
731
- ...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
732
- ...(extras.input === undefined ? {} : { input: extras.input }),
733
- ...(extras.tool === undefined ? {} : { tool: extras.tool }),
734
- ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
735
- ...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
736
- ...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
737
- });
738
- } catch (failure) {
739
- if (loggingWarningReported) return;
740
- loggingWarningReported = true;
741
- ctx.ui.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`, "warning");
742
- }
547
+ diagnosticWriter.record(sessionAddress(ctx).id, ctx.cwd, error, category, extras);
743
548
  }
744
549
 
745
550
  function continueFallbackResolution(lastWarning: boolean): void {
@@ -1193,7 +998,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1193
998
 
1194
999
  pi.on("tool_call", (event, ctx) => {
1195
1000
  if (!snapshot.config.enabled) return;
1196
- const batch = assistantToolBatch(ctx, event.toolCallId);
1001
+ const batch = findAssistantToolBatch(ctx.sessionManager, event.toolCallId);
1197
1002
  const patchCalls = batch?.filter((name) => name === PATCH_STATE_TOOL_NAME).length ?? 0;
1198
1003
  if (patchCalls > 0) {
1199
1004
  if (patchCalls !== 1) {
@@ -1264,8 +1069,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1264
1069
  responseCommitted = true;
1265
1070
  bootstrapContinuation = undefined;
1266
1071
  rehydrationPhase = "step";
1267
- enqueueTurnPublication(ctx);
1268
- if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1072
+ enqueueTurnPublication();
1073
+ if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1269
1074
  completedRunAccepted = !wasBootstrap;
1270
1075
  } catch (error) {
1271
1076
  if (!responseCommitted && specification !== undefined) snapshot.meta.specification = specification;
@@ -1292,7 +1097,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1292
1097
  telegramStartPending = false;
1293
1098
  startStateFlow(ctx);
1294
1099
  }
1295
- if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1100
+ if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1296
1101
  if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap || resolutionPending
1297
1102
  || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !snapshot.meta.durableBase
1298
1103
  || !shouldRequestStateFlowCompaction(ctx.getContextUsage())) return;
@@ -1314,7 +1119,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1314
1119
  rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
1315
1120
  restoreActiveBranch(ctx, event.reason);
1316
1121
  retryPendingPush(ctx);
1317
- if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1122
+ if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
1318
1123
  updateUi(ctx);
1319
1124
  void telegram.ensure();
1320
1125
  });
@@ -22,7 +22,7 @@ export interface RecentTransition extends AcceptedTransition {
22
22
 
23
23
  export type RecentTransitionWindow = RecentTransition[];
24
24
  const SCOPES = new Set<StateScope>(["global", "cwd", "session"]);
25
- const PATCH_KEYS = new Set(["artifacts", "contract", "working", "response", "lazy"]);
25
+ const PATCH_KEYS = new Set(["artifacts", "contract", "working", "intents", "response", "lazy"]);
26
26
 
27
27
  /** Normalize accepted replacements into recursive-merge replay, including removals. */
28
28
  function replayPatch(before: JsonObject, after: JsonObject): JsonObject {
@@ -1,6 +1,6 @@
1
1
  // Domain: opt-in diagnostic capture for rejected State Flow resolutions.
2
2
  import { appendFileSync, mkdirSync } from "node:fs";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { isObject } from "./json.ts";
5
5
 
6
6
  export type StateFlowDiagnosticCategory = "invalid-patch" | "publication-conflict" | "terminal-pending" | "finalization";
@@ -45,3 +45,55 @@ export function appendStateFlowDiagnostic(path: string, record: StateFlowDiagnos
45
45
  mkdirSync(dirname(path), { recursive: true });
46
46
  appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
47
47
  }
48
+
49
+ export interface DiagnosticExtras {
50
+ content?: unknown;
51
+ input?: unknown;
52
+ tool?: string;
53
+ toolCallId?: string;
54
+ resolutionAttempt?: number;
55
+ terminalEligible?: boolean;
56
+ }
57
+
58
+ /** Own diagnostic path safety, projection, persistence, and one-shot failure reporting. */
59
+ export class StateFlowDiagnosticWriter {
60
+ private warningReported = false;
61
+ private readonly enabled: boolean;
62
+ private readonly path: string;
63
+ private readonly repositoryRoot: string;
64
+ private readonly notify: (message: string) => void;
65
+
66
+ constructor(enabled: boolean, path: string, repositoryRoot: string, notify: (message: string) => void) {
67
+ this.enabled = enabled;
68
+ this.path = path;
69
+ this.repositoryRoot = repositoryRoot;
70
+ this.notify = notify;
71
+ }
72
+
73
+ record(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras: DiagnosticExtras = {}): void {
74
+ if (!this.enabled) return;
75
+ try {
76
+ const fromRepository = relative(this.repositoryRoot, this.path);
77
+ if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
78
+ throw new Error("diagnostic path overlaps the State Flow repository");
79
+ }
80
+ appendStateFlowDiagnostic(this.path, {
81
+ at: new Date().toISOString(),
82
+ sessionId,
83
+ cwd: resolve(cwd),
84
+ category,
85
+ error,
86
+ ...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
87
+ ...(extras.input === undefined ? {} : { input: extras.input }),
88
+ ...(extras.tool === undefined ? {} : { tool: extras.tool }),
89
+ ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
90
+ ...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
91
+ ...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
92
+ });
93
+ } catch (failure) {
94
+ if (this.warningReported) return;
95
+ this.warningReported = true;
96
+ this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
97
+ }
98
+ }
99
+ }
@@ -13,7 +13,7 @@ import {
13
13
  type OwnedFileUpdate,
14
14
  } from "./durable.ts";
15
15
  import { parseSessionRuntime, serializeSessionRuntime } from "./snapshot.ts";
16
- import type { StateScope } from "./state.ts";
16
+ import { migratePreIntentState, type StateScope } from "./state.ts";
17
17
 
18
18
  export interface LegacyStorageMigration {
19
19
  bases: DurableFileBase[];
@@ -57,9 +57,14 @@ function migrationDirectories(cwd: string, sessionId: string, root: string, sess
57
57
  try { sessionScopeKey(entry.name); } catch { continue; }
58
58
  const directory = join(cwdDirectory, entry.name);
59
59
  let meta: unknown;
60
+ let runtime: unknown;
60
61
  try { meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8")); }
61
62
  catch { continue; }
62
- const identity = meta && typeof meta === "object" && !Array.isArray(meta) ? (meta as { identity?: unknown }).identity : undefined;
63
+ try { runtime = JSON.parse(readFileSync(join(directory, "runtime.json"), "utf8")); }
64
+ catch { /* predecessor sessions keep identity in meta.json */ }
65
+ const identity = meta && typeof meta === "object" && !Array.isArray(meta) && (meta as { identity?: unknown }).identity !== undefined
66
+ ? (meta as { identity?: unknown }).identity
67
+ : runtime && typeof runtime === "object" && !Array.isArray(runtime) ? (runtime as { identity?: unknown }).identity : undefined;
63
68
  if (!identity || typeof identity !== "object" || Array.isArray(identity)) continue;
64
69
  const owner = identity as { cwd?: unknown; sessionId?: unknown };
65
70
  if (owner.cwd === cwdOwner && typeof owner.sessionId === "string" && owner.sessionId.length > 0) {
@@ -85,7 +90,14 @@ export function hasCwdMaterialization(cwd: string, repositoryRoot: string): bool
85
90
  return false;
86
91
  }
87
92
 
88
- /** Detect predecessor snapshots or temporal envelopes without mutating the store. */
93
+ function checkpointNeedsIntentMigration(source: string): boolean {
94
+ const checkpoint = JSON.parse(source) as unknown;
95
+ if (migratePreIntentState(checkpoint) !== undefined) return true;
96
+ return checkpoint !== null && typeof checkpoint === "object" && !Array.isArray(checkpoint)
97
+ && migratePreIntentState((checkpoint as { state?: unknown }).state) !== undefined;
98
+ }
99
+
100
+ /** Detect predecessor snapshots, temporal envelopes, or pre-intents semantic states without mutating the store. */
89
101
  export function hasLegacyStateSources(
90
102
  cwd: string,
91
103
  sessionId: string,
@@ -94,8 +106,10 @@ export function hasLegacyStateSources(
94
106
  ): boolean {
95
107
  const root = resolve(repositoryRoot);
96
108
  return migrationDirectories(cwd, sessionId, root, sessionKey).some(({ directory, scope }) => {
97
- if (lstatSync(join(directory, "checkpoint.json"), { throwIfNoEntry: false }) === undefined) return false;
109
+ const checkpointPath = join(directory, "checkpoint.json");
110
+ if (lstatSync(checkpointPath, { throwIfNoEntry: false }) === undefined) return false;
98
111
  try {
112
+ if (checkpointNeedsIntentMigration(readFileSync(checkpointPath, "utf8"))) return true;
99
113
  const meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8"));
100
114
  if (meta === null || typeof meta !== "object" || !("temporal" in meta)) return true;
101
115
  return scope === "session"