@llblab/pi-kit 0.7.1 → 0.8.1

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 (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +21 -21
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -122
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +41 -35
  7. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -16
  8. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -4
  9. package/node_modules/@llblab/pi-state-flow/index.ts +23 -1
  10. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +3 -0
  11. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +191 -29
  12. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
  13. package/node_modules/@llblab/pi-state-flow/lib/context.ts +42 -7
  14. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +38 -8
  15. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +1 -13
  16. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +290 -134
  17. package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -16
  18. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +41 -0
  19. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +12 -6
  20. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +16 -0
  21. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +3 -0
  22. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +167 -31
  23. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +15 -6
  24. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +40 -34
  25. package/node_modules/@llblab/pi-state-flow/lib/state.ts +6 -0
  26. package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -9
  27. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +25 -5
  28. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +17 -147
  29. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +49 -27
  30. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  31. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +4 -0
  32. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  33. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +21 -19
  34. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  35. package/package.json +3 -3
  36. package/node_modules/@llblab/pi-state-flow/lib/validation.ts +0 -27
@@ -2,20 +2,20 @@ import { execFile } from "node:child_process";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import { StringEnum, Type } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { assistantToolCallCount, finalizedAssistantResponse, parseTerminalPatch, stateFlowProtocol, stripStateComments } from "./terminal.ts";
6
- import { currentRunTrajectory, runtimeContextMessage, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation } from "./context.ts";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol } from "./terminal.ts";
7
+ import { createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, runtimeContextMessage, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
7
8
  import { ArtifactReadTracker } from "./acquisition.ts";
8
9
  import { loadStateFlowConfig } from "./config.ts";
9
- import { resolve } from "node:path";
10
+ import { isAbsolute, relative, resolve, sep } from "node:path";
10
11
  import { SkillReadTracker } from "./skills.ts";
11
12
  import { emptySnapshot, migrationFailure, persistableSnapshot, type Snapshot } from "./snapshot.ts";
12
13
  import { inspectSnapshotRevision, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
13
- import { emptyState, overlayStates, type MaterializedState, type ScopePatch, type ScopedStates, type StateScope } from "./state.ts";
14
- import { commitScopedTransition, stageScopedPatch, stageScopedTransition, type StagedScopedTransition } from "./transition.ts";
15
- import { MAX_VALIDATION_RETRIES, nextValidation } from "./validation.ts";
14
+ import { emptyState, overlayStates, projectStateForModel, type MaterializedState, type ScopePatch, type ScopedStates, type StateScope } from "./state.ts";
15
+ import { commitScopedTransition, stageScopedPatch, stageScopedTransition, validateUnchangedResolution, type StagedScopedTransition } from "./transition.ts";
16
16
  import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
17
17
  import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
18
- import { abandonValidation, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
18
+ import { prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
19
19
  import { recoverSnapshot } from "./recovery.ts";
20
20
  import type { RehydrationPhase } from "./rehydration.ts";
21
21
  import { resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument } from "./publication.ts";
@@ -23,13 +23,15 @@ import { coalescePublicationTarget, createPublicationQueue, type PublicationQueu
23
23
  import { acquirePublicationWorkerLease, loadPublicationQueue, publicationQueuePath, removePublicationQueue, savePublicationQueue } from "./publication.ts";
24
24
  import { runPublicationWorker } from "./publication.ts";
25
25
  import { getKnowledgeRoot, GlobalMarkdownDiscovery } from "./discovery.ts";
26
+ import { isObject, sameJson } from "./json.ts";
26
27
  import {
27
28
  cwdScopeKey,
28
29
  resolveSessionAddress,
29
30
  sessionScopeKey,
30
31
  type SessionAddress,
31
32
  } from "./durable.ts";
32
- import { projectRecentTransitionsWithLimit } from "./history.ts";
33
+ import { projectRecentTransitionsWithLimit, RECENT_TRANSITION_LIMIT } from "./history.ts";
34
+ import { appendStateFlowDiagnostic, projectDiagnosticContent, stateFlowLogPath, type StateFlowDiagnosticCategory } from "./logging.ts";
33
35
  import { isGitCommitAncestor, pushGitCommit, resolveGitPushDestination } from "./git.ts";
34
36
  import {
35
37
  ORDINARY_ARTIFACT_COMPILER,
@@ -46,15 +48,27 @@ export interface StateFlowExtensionOptions {
46
48
 
47
49
  export const PATCH_STATE_TOOL_NAME = "patch_state";
48
50
  export const READ_STATE_TOOL_NAME = "read_state";
51
+ const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
52
+
53
+ /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
54
+ function separatedFailure(error: unknown): Error {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ return new Error(`\n${message}`, error instanceof Error ? { cause: error } : undefined);
57
+ }
49
58
 
50
59
  export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
51
- const config = loadStateFlowConfig(options.agentDir);
60
+ const agentDir = options.agentDir ?? getAgentDir();
61
+ const config = loadStateFlowConfig(agentDir);
52
62
  let snapshot: Snapshot = emptySnapshot();
53
63
  let scopeStates: ScopedStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
54
64
  let branchHasSnapshot = false;
55
65
  let branchStartsWithoutRuntime = false;
56
- let stagedFinal: StagedScopedTransition | undefined;
57
- let retryQueued = false;
66
+ let stateResolutionSatisfied = false;
67
+ let terminalDraftIntercepted = false;
68
+ let responseAwaitingReconciliation = false;
69
+ let passiveContinuation: PassiveContinuation | undefined;
70
+ let bootstrapContinuation: PassiveContinuation | undefined;
71
+ let artifactRefreshPending = false;
58
72
  let runAnchorTimestamp: number | undefined;
59
73
  let runtime: TemporalRuntime | undefined;
60
74
  let activeContext: ExtensionContext | undefined;
@@ -65,8 +79,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
65
79
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
66
80
  const skillReads = new SkillReadTracker();
67
81
  const artifactReads = new ArtifactReadTracker();
68
- const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(options.agentDir));
82
+ const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(agentDir));
69
83
  let artifactInvalidations: ArtifactInvalidationRequest[] = [];
84
+ let loggingWarningReported = false;
70
85
 
71
86
  function sessionAddress(ctx: ExtensionContext): SessionAddress {
72
87
  return resolveSessionAddress(ctx.sessionManager.getSessionFile(), ctx.sessionManager.getSessionId(), ctx.sessionManager.getHeader()?.timestamp);
@@ -78,7 +93,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
78
93
 
79
94
  options.onRuntime?.({ read: (offset, scope) => {
80
95
  if (!runtime) throw new Error("State Flow temporal runtime is unavailable");
81
- return runtime.read(offset, scope);
96
+ return projectStateForModel(runtime.read(offset, scope));
82
97
  } });
83
98
 
84
99
  function persist(): void {
@@ -105,14 +120,39 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
105
120
  }
106
121
 
107
122
  function clearRunTransient(): void {
108
- stagedFinal = undefined;
109
- retryQueued = false;
123
+ stateResolutionSatisfied = false;
124
+ terminalDraftIntercepted = false;
125
+ responseAwaitingReconciliation = false;
110
126
  runAnchorTimestamp = undefined;
111
127
  skillReads.clear();
112
128
  artifactReads.clear();
113
129
  }
114
130
 
131
+ function passiveStopTimestamp(ctx: ExtensionContext): number | undefined {
132
+ for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
133
+ try {
134
+ if (entry?.type !== "custom" || entry.customType !== PASSIVE_STOP_ENTRY_TYPE) continue;
135
+ const at = (entry.data as { at?: unknown } | undefined)?.at;
136
+ if (typeof at === "number" && Number.isSafeInteger(at) && at >= 0) return at;
137
+ } catch {
138
+ // A hostile unrelated branch entry cannot manufacture or suppress a valid marker.
139
+ }
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ function retainsPhysicalSessionProjection(reason: unknown): boolean {
145
+ return reason === undefined || reason === "startup" || reason === "reload" || reason === "resume";
146
+ }
147
+
148
+ function deferArtifactRefresh(): void {
149
+ artifactInvalidations = [];
150
+ artifactReads.setCandidates([]);
151
+ artifactRefreshPending = true;
152
+ }
153
+
115
154
  function refreshArtifactInvalidations(ctx: ExtensionContext): void {
155
+ artifactRefreshPending = false;
116
156
  if (!snapshot.config.enabled) {
117
157
  artifactInvalidations = [];
118
158
  artifactReads.setCandidates([]);
@@ -124,6 +164,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
124
164
  discovery.sources,
125
165
  scopeStates.global.artifacts,
126
166
  ORDINARY_ARTIFACT_COMPILER,
167
+ {},
168
+ runtime?.artifactProvenance("global") ?? {},
127
169
  );
128
170
  artifactInvalidations = structuredClone(plan.requiresCompilation);
129
171
  if (plan.removed.length > 0) {
@@ -193,12 +235,34 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
193
235
  }
194
236
  }
195
237
 
238
+ /** Accept an activation or lifecycle commit locally; turn-end policy queues it for the asynchronous worker. */
239
+ function recordPolicyPublication(publication: RuntimePublication | undefined, ctx: ExtensionContext): void {
240
+ if (!publication) return;
241
+ recordPublication(publication, ctx);
242
+ const mode = snapshot.meta.remotePublication?.mode ?? "transition";
243
+ const target = publication.commit ?? publication.revision;
244
+ if (mode !== "turn-end" || target === undefined || !/^[0-9a-f]{40,64}$/.test(target)) return;
245
+ turnPublicationTarget = target;
246
+ try {
247
+ enqueueTurnPublication();
248
+ launchPublicationWorker();
249
+ } catch (error) {
250
+ ctx.ui.notify(
251
+ `State Flow accepted the local commit; remote publication is deferred: ${error instanceof Error ? error.message : String(error)}`,
252
+ "warning",
253
+ );
254
+ }
255
+ }
256
+
196
257
  function commitStage(stage: StagedScopedTransition, ctx: ExtensionContext, finalizeRun: boolean): boolean {
197
258
  const acquiredArtifactPaths = new Set(artifactReads.successful.keys());
198
259
  const committed = commitScopedTransition(snapshot, scopeStates, stage, (accepted, nextSnapshot) => {
199
260
  if (!runtime?.view) throw new Error("Temporal State Flow runtime is unavailable; reload before publishing");
200
261
  const mode = nextSnapshot.meta.remotePublication?.mode ?? "transition";
201
- const publication = runtime.publish(nextSnapshot, accepted !== undefined, accepted, { pushRemote: mode === "transition" });
262
+ const publication = runtime.publish(nextSnapshot, accepted !== undefined, accepted, {
263
+ pushRemote: mode === "transition",
264
+ provenance: stage.provenanceUpdates,
265
+ });
202
266
  if (publication?.commit && mode === "turn-end") turnPublicationTarget = publication.commit;
203
267
  if (publication) recordPublication(publication, ctx);
204
268
  }, runtime!.causalBasis(), { finalizeRun });
@@ -272,12 +336,31 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
272
336
  }).finally(() => {
273
337
  activePublicationWorkers.delete(path);
274
338
  lease.release();
275
- if (loadPublicationQueue(path)?.status === "pending") launchPublicationWorker();
339
+ try {
340
+ if (loadPublicationQueue(path)?.status === "pending") launchPublicationWorker();
341
+ } catch {
342
+ // Malformed queue persistence stays inert until an explicit retry or repair.
343
+ }
276
344
  });
277
345
  }
278
346
 
279
347
  function retryPendingPush(ctx: ExtensionContext): void {
280
348
  if (pendingPublication === undefined || !runtime?.view) return;
349
+ const mode = snapshot.meta.remotePublication?.mode ?? "transition";
350
+ if (mode !== "transition") {
351
+ const target = pendingPublication.commit;
352
+ setPendingPublication(undefined);
353
+ if (mode === "turn-end") {
354
+ turnPublicationTarget = target;
355
+ try {
356
+ enqueueTurnPublication();
357
+ launchPublicationWorker();
358
+ } catch (error) {
359
+ ctx.ui.notify(`State Flow retained local state; asynchronous publication recovery is deferred: ${error instanceof Error ? error.message : String(error)}`, "warning");
360
+ }
361
+ }
362
+ return;
363
+ }
281
364
  const result = pushGitCommit(repositoryRoot, pendingPublication.commit);
282
365
  if (result.status !== "pending") {
283
366
  setPendingPublication(undefined);
@@ -314,6 +397,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
314
397
  discovery.sources,
315
398
  diagnosticStates.global.artifacts,
316
399
  ORDINARY_ARTIFACT_COMPILER,
400
+ {},
401
+ runtime?.artifactProvenance("global") ?? {},
317
402
  );
318
403
  staleArtifacts = [
319
404
  ...plan.requiresCompilation.map(({ path, reason }) => ({ scope: "global" as const, path, reason })),
@@ -349,7 +434,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
349
434
  ...(artifactFreshnessError === undefined ? {} : { artifactFreshnessError }),
350
435
  ...(durableStateError === undefined ? {} : { durableStateError }),
351
436
  ...(pendingPublication === undefined ? {} : { pendingPublication }),
352
- retryQueued,
353
437
  ...(publicationQueue === undefined ? {} : { publicationQueue }),
354
438
  ...(publicationQueueError === undefined ? {} : { publicationQueueError }),
355
439
  };
@@ -357,6 +441,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
357
441
 
358
442
  function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown): void {
359
443
  clearRunTransient();
444
+ passiveContinuation = undefined;
445
+ bootstrapContinuation = undefined;
446
+ artifactInvalidations = [];
447
+ artifactReads.setCandidates([]);
448
+ artifactRefreshPending = false;
360
449
  activeContext = ctx;
361
450
  const session = sessionAddress(ctx);
362
451
  runtime = new TemporalRuntime(ctx.cwd, session, repositoryRoot);
@@ -383,7 +472,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
383
472
  if (snapshot.meta.durableBase !== selectedRevision) persist();
384
473
  } else if (branchHasSnapshot && snapshot.config.enabled) {
385
474
  const publication = runtime.initialize(snapshot, true);
386
- if (publication) recordPublication(publication, ctx);
475
+ recordPolicyPublication(publication, ctx);
387
476
  delete snapshot.legacySession;
388
477
  }
389
478
  installScopeStates();
@@ -394,7 +483,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
394
483
  );
395
484
  runtime.prepare();
396
485
  const initialization = runtime.initialize(snapshot, true);
397
- if (initialization) recordPublication(initialization, ctx);
486
+ recordPolicyPublication(initialization, ctx);
398
487
  if (!runtime.view) snapshot = emptySnapshot();
399
488
  installScopeStates();
400
489
  if (snapshot.config.enabled) {
@@ -432,47 +521,52 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
432
521
  if (!snapshot.config.enabled && snapshot.meta.validation?.attempt === 0) {
433
522
  ctx.ui.notify(`State Flow restored disabled: ${snapshot.meta.validation.error}`, "error");
434
523
  }
524
+ if (retainsPhysicalSessionProjection(sessionStartReason) && runtime?.view) {
525
+ const stoppedAt = passiveStopTimestamp(ctx);
526
+ if (stoppedAt !== undefined) {
527
+ const continuation = createPassiveContinuation(
528
+ projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
529
+ stoppedAt,
530
+ );
531
+ if (!snapshot.config.enabled) passiveContinuation = continuation;
532
+ else if (snapshot.meta.bootstrap) bootstrapContinuation = continuation;
533
+ }
534
+ }
535
+ if (snapshot.config.enabled) deferArtifactRefresh();
435
536
  syncStateFlowTools();
436
537
  updateUi(ctx);
437
538
  }
438
539
 
439
- function abandonRun(ctx: ExtensionContext): void {
440
- const changed = abandonValidation(snapshot);
441
- clearRunTransient();
442
- if (changed) persist();
443
- updateUi(ctx);
444
- }
445
-
446
- function queueTerminalRegeneration(error: string, ctx: ExtensionContext): void {
447
- const decision = nextValidation(snapshot.meta.validation, error);
448
- if (decision.kind === "retry") {
449
- snapshot.meta.validation = decision.feedback;
450
- persist();
451
- retryQueued = true;
452
- pi.sendMessage({
453
- customType: VALIDATION_MESSAGE_TYPE,
454
- content: `State Flow rejected the terminal response (attempt ${decision.feedback.attempt}/${MAX_VALIDATION_RETRIES}). ${decision.feedback.instruction}`,
455
- display: false,
456
- }, { deliverAs: "steer", triggerTurn: true });
457
- return;
540
+ function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, content?: unknown): void {
541
+ if (!config.logging) return;
542
+ try {
543
+ const path = stateFlowLogPath(agentDir);
544
+ const fromRepository = relative(repositoryRoot, path);
545
+ if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
546
+ throw new Error("diagnostic path overlaps the State Flow repository");
547
+ }
548
+ appendStateFlowDiagnostic(path, {
549
+ at: new Date().toISOString(),
550
+ sessionId: sessionAddress(ctx).id,
551
+ cwd: resolve(ctx.cwd),
552
+ category,
553
+ error,
554
+ ...(content === undefined ? {} : { content: projectDiagnosticContent(content) }),
555
+ });
556
+ } catch (failure) {
557
+ if (loggingWarningReported) return;
558
+ loggingWarningReported = true;
559
+ ctx.ui.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`, "warning");
458
560
  }
459
- retryQueued = false;
460
- snapshot.meta.validation = undefined;
461
- skillReads.clear();
462
- artifactReads.clear();
463
- persist();
464
- ctx.ui.notify(`State Flow remains enabled after ${MAX_VALIDATION_RETRIES} automatic regeneration attempts; the last committed state was preserved: ${decision.error}`, "error");
465
561
  }
466
562
 
467
- function rejectTerminal(message: { role: "assistant"; content?: unknown }, error: string, ctx: ExtensionContext) {
468
- queueTerminalRegeneration(error, ctx);
469
- return {
470
- message: {
471
- ...message,
472
- role: "assistant" as const,
473
- content: [],
474
- },
475
- };
563
+ function continueForResolution(): void {
564
+ terminalDraftIntercepted = true;
565
+ pi.sendMessage({
566
+ customType: VALIDATION_MESSAGE_TYPE,
567
+ content: "Before completing this turn, resolve State Flow. Call patch_state with durable semantic changes, or call patch_state with {\"unchanged\":true} if no state update is required. Then provide the final answer normally.",
568
+ display: false,
569
+ }, { deliverAs: "steer", triggerTurn: true });
476
570
  }
477
571
 
478
572
  pi.registerTool({
@@ -485,51 +579,74 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
485
579
  scope: Type.Optional(StringEnum(["effective", "global", "cwd", "session"] as const, { description: "Projection at that same boundary; defaults to effective" })),
486
580
  }, { additionalProperties: false }),
487
581
  async execute(_toolCallId, params, signal) {
488
- if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
489
- if (signal?.aborted) throw new Error("State Flow read was aborted");
490
- if (!runtime?.view) throw new Error("State Flow temporal runtime is unavailable");
491
- const { offset = 0, scope = "effective" } = params;
492
- const state = runtime.read(offset, scope === "effective" ? undefined : scope);
493
- const boundary = runtime.view.lineage.at(-1 - offset)!;
494
- return {
495
- content: [{ type: "text", text: JSON.stringify({ offset, scope, boundary, state }) }],
496
- details: { offset, scope, transitionId: boundary.id },
497
- };
582
+ try {
583
+ if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
584
+ if (signal?.aborted) throw new Error("State Flow read was aborted");
585
+ if (!runtime?.view) throw new Error("State Flow temporal runtime is unavailable");
586
+ const { offset = 0, scope = "effective" } = params;
587
+ const state = runtime.read(offset, scope === "effective" ? undefined : scope);
588
+ const boundary = runtime.view.lineage.at(-1 - offset)!;
589
+ return {
590
+ content: [{ type: "text", text: `\n${JSON.stringify({ offset, scope, boundary, state: projectStateForModel(state) })}` }],
591
+ details: { offset, scope, transitionId: boundary.id },
592
+ };
593
+ } catch (error) {
594
+ throw separatedFailure(error);
595
+ }
498
596
  },
499
597
  });
500
598
 
501
599
  pi.registerTool({
502
600
  name: PATCH_STATE_TOOL_NAME,
503
601
  label: "Patch State",
504
- description: "Materialize established future-relevant semantic state at a session, CWD, or global barrier, including a necessary write-and-verify step during explicitly requested curation. Do not use for scratchpad, narration, routine progress, or speculative churn. This call must be the only State Flow barrier in its assistant response; sibling tool calls are blocked and reconsidered after rematerialization.",
505
- promptSnippet: "Materialize established future-relevant state as an immediate inference barrier",
602
+ description: "The sole State Flow semantic mutation protocol. Use exactly one form: PATCH {scope, patch} to materialize established future-relevant state, or UNCHANGED {unchanged:true} after explicitly deciding no durable update is needed. Never combine unchanged with scope or patch. This call must be the only State Flow barrier in its assistant response; sibling tool calls are reconsidered after rematerialization.",
603
+ promptSnippet: "PATCH {scope, patch} or UNCHANGED {unchanged:true}",
506
604
  promptGuidelines: [
507
- "Use patch_state when established future-relevant information would face meaningful loss or recovery risk if delayed until terminal reconciliation, or for a necessary write-and-verify step in explicitly requested curation.",
508
- "Call patch_state alone in an assistant response; choose subsequent actions only after its compact acknowledgement and rematerialized State Flow context.",
605
+ "Use patch_state for every durable semantic change. Before a final answer, resolve State Flow with PATCH {scope, patch} or UNCHANGED {unchanged:true}.",
606
+ "Call patch_state alone in an assistant response; choose subsequent actions only after its acknowledgement and rematerialized State Flow context.",
509
607
  ],
510
608
  executionMode: "sequential",
609
+ // Type.Union/Type.Literal schemas are not portable across Pi's Google-compatible tool adapters.
610
+ // Field descriptions expose the discriminated forms while runtime validation preserves exclusivity.
511
611
  parameters: Type.Object({
512
- scope: StringEnum(["session", "cwd", "global"] as const),
513
- patch: Type.Record(Type.String(), Type.Unknown()),
612
+ scope: Type.Optional(StringEnum(["session", "cwd", "global"] as const, { description: "PATCH form only: required with patch; forbidden with unchanged" })),
613
+ patch: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "PATCH form only: required with scope; forbidden with unchanged" })),
614
+ unchanged: Type.Optional(Type.Boolean({ description: "UNCHANGED form only: set exactly true and omit scope and patch" })),
514
615
  }, { additionalProperties: false }),
515
616
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
516
- if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
517
- if (signal?.aborted) throw new Error("State Flow patch was aborted before materialization");
518
- const transition = { scope: params.scope as StateScope, patch: params.patch as ScopePatch };
519
- const stage = stageScopedPatch(
520
- scopeStates,
521
- transition,
522
- skillReads.successful.values(),
523
- runtime!.causalBasis(),
524
- artifactReads.successful.values(),
525
- );
526
- commitStage(stage, ctx, false);
527
- updateUi(ctx);
528
- const publication = pendingPublication === undefined ? "" : "; durable publication pending";
529
- return {
530
- content: [{ type: "text", text: `\nState materialized at ${params.scope} scope${publication}.` }],
531
- details: { scope: params.scope, step: snapshot.meta.step },
532
- };
617
+ try {
618
+ if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
619
+ if (signal?.aborted) throw new Error("State Flow patch was aborted before materialization");
620
+ if (!isObject(params)) throw new Error("patch_state requires an object in exactly one supported form");
621
+ const keys = Object.keys(params).sort();
622
+ if (params.unchanged === true) {
623
+ if (keys.length !== 1 || keys[0] !== "unchanged") throw new Error('patch_state {"unchanged":true} cannot include any other field');
624
+ validateUnchangedResolution(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
625
+ stateResolutionSatisfied = true;
626
+ terminalDraftIntercepted = false;
627
+ return { content: [{ type: "text", text: "\nState resolution acknowledged unchanged." }], details: { unchanged: true } };
628
+ }
629
+ if (keys.length !== 2 || keys[0] !== "patch" || keys[1] !== "scope"
630
+ || params.scope === undefined || !isObject(params.patch)) {
631
+ throw new Error("patch_state requires exactly scope and patch, or {\"unchanged\":true}");
632
+ }
633
+ if (Object.keys(params.patch).length === 0) throw new Error("An empty semantic patch is not an unchanged acknowledgement; use {\"unchanged\":true}");
634
+ const transition = { scope: params.scope as StateScope, patch: params.patch as ScopePatch };
635
+ const stage = stageScopedPatch(scopeStates, transition, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
636
+ const semanticChange = (["global", "cwd", "session"] as const).some((scope) => !sameJson(scopeStates[scope], stage.nextStates[scope]));
637
+ const provenanceChange = Object.values(stage.provenanceUpdates).some((updates) => Object.keys(updates).length > 0);
638
+ if (!semanticChange && !provenanceChange) throw new Error('patch_state PATCH must materially update state or required provenance; use {"unchanged":true} instead');
639
+ commitStage(stage, ctx, false);
640
+ stateResolutionSatisfied = true;
641
+ terminalDraftIntercepted = false;
642
+ updateUi(ctx);
643
+ const publication = pendingPublication === undefined ? "" : "; durable publication pending";
644
+ return { content: [{ type: "text", text: `\nState materialized at ${params.scope} scope${publication}.` }], details: { scope: params.scope, step: snapshot.meta.step } };
645
+ } catch (error) {
646
+ stateResolutionSatisfied = false;
647
+ recordDiagnostic(error instanceof Error ? error.message : String(error), /concurrently|advanced/.test(String(error)) ? "publication-conflict" : "invalid-patch", ctx);
648
+ throw separatedFailure(error);
649
+ }
533
650
  },
534
651
  });
535
652
 
@@ -538,6 +655,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
538
655
  handler: async (_args, ctx) => {
539
656
  if (!activeContext) restoreActiveBranch(ctx);
540
657
  const previousSnapshot = structuredClone(snapshot);
658
+ const previousPassiveContinuation = passiveContinuation;
659
+ const previousBootstrapContinuation = bootstrapContinuation;
660
+ const previousArtifactRefreshPending = artifactRefreshPending;
661
+ const previousArtifactInvalidations = structuredClone(artifactInvalidations);
541
662
  try {
542
663
  if (!runtime?.view && !snapshot.meta.durableBase && !branchStartsWithoutRuntime) {
543
664
  throw new Error("Selected branch revision is unavailable; restore its original Git history before starting State Flow");
@@ -547,7 +668,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
547
668
  runtime ??= createRuntime(ctx);
548
669
  if (branchStartsWithoutRuntime) runtime.prepare();
549
670
  const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
550
- && hasPriorConversation(branch);
671
+ && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
551
672
  if (!runtime.view && snapshot.meta.durableBase) {
552
673
  snapshot = runtime.restore(snapshot.meta.durableBase, snapshot);
553
674
  setPendingPublication(snapshot.meta.pendingPublication);
@@ -566,11 +687,15 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
566
687
  const publication = runtime.view
567
688
  ? runtime.promote(snapshot) ?? runtime.publish(snapshot)
568
689
  : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
569
- if (publication) recordPublication(publication, ctx);
690
+ recordPolicyPublication(publication, ctx);
570
691
  installScopeStates();
571
692
  delete snapshot.legacySession;
572
693
  clearRunTransient();
573
- refreshArtifactInvalidations(ctx);
694
+ passiveContinuation = undefined;
695
+ bootstrapContinuation = snapshot.meta.bootstrap
696
+ ? previousPassiveContinuation ?? previousBootstrapContinuation
697
+ : undefined;
698
+ deferArtifactRefresh();
574
699
  syncStateFlowTools();
575
700
  persist();
576
701
  updateUi(ctx);
@@ -582,6 +707,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
582
707
  );
583
708
  } catch (error) {
584
709
  snapshot = previousSnapshot;
710
+ passiveContinuation = previousPassiveContinuation;
711
+ bootstrapContinuation = previousBootstrapContinuation;
712
+ artifactRefreshPending = previousArtifactRefreshPending;
713
+ artifactInvalidations = previousArtifactInvalidations;
714
+ artifactReads.setCandidates(artifactInvalidations);
585
715
  syncStateFlowTools();
586
716
  ctx.ui.notify(
587
717
  `State Flow could not initialize CWD state: ${error instanceof Error ? error.message : String(error)}`,
@@ -608,6 +738,15 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
608
738
  selected = createRuntime(ctx);
609
739
  current = selected.restore(snapshot.meta.durableBase, snapshot);
610
740
  }
741
+ const stoppedAt = Date.now();
742
+ const exitStates = selected?.view ? selected.states() : undefined;
743
+ const exitHandoff = current.config.enabled && exitStates
744
+ ? createPassiveContinuation(
745
+ projectStateForModel(overlayStates(exitStates.global, exitStates.cwd, exitStates.session)),
746
+ stoppedAt,
747
+ )
748
+ : undefined;
749
+ const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
611
750
  const stopped = stopEpisode(current);
612
751
  const publication = selected?.view ? selected.publish(stopped) : undefined;
613
752
  if (selected !== runtime) {
@@ -615,24 +754,32 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
615
754
  installScopeStates();
616
755
  }
617
756
  snapshot = stopped;
618
- if (publication) recordPublication(publication, ctx);
757
+ recordPolicyPublication(publication, ctx);
619
758
  branchHasSnapshot = true;
620
759
  clearRunTransient();
760
+ passiveContinuation = retainedHandoff;
761
+ bootstrapContinuation = undefined;
762
+ artifactInvalidations = [];
763
+ artifactReads.setCandidates([]);
764
+ artifactRefreshPending = false;
765
+ if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { at: stoppedAt });
621
766
  syncStateFlowTools();
622
767
  persist();
623
768
  updateUi(ctx);
624
769
  },
625
770
  });
626
771
 
627
- pi.on("before_agent_start", (event) => {
772
+ pi.on("before_agent_start", (event, ctx) => {
628
773
  if (!snapshot.config.enabled) return;
629
- if (!retryQueued) {
630
- skillReads.clear();
631
- artifactReads.clear();
632
- }
633
- const rotatesRun = snapshot.meta.specification !== undefined && !retryQueued;
634
- if (rotatesRun) rehydrationPhase = "step";
635
- if (prepareRun(snapshot, event.prompt, retryQueued)) {
774
+ skillReads.clear();
775
+ artifactReads.clear();
776
+ if (artifactRefreshPending) refreshArtifactInvalidations(ctx);
777
+ stateResolutionSatisfied = false;
778
+ terminalDraftIntercepted = false;
779
+ responseAwaitingReconciliation = false;
780
+ const rotatesRun = snapshot.meta.specification !== undefined;
781
+ if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
782
+ if (prepareRun(snapshot, event.prompt)) {
636
783
  if (rotatesRun) runAnchorTimestamp = undefined;
637
784
  persist();
638
785
  }
@@ -642,16 +789,23 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
642
789
  });
643
790
 
644
791
  pi.on("context", (event) => {
792
+ if (passiveContinuation) {
793
+ return { messages: passiveContinuationMessages(event.messages as AgentMessage[], passiveContinuation) };
794
+ }
645
795
  if (!snapshot.config.enabled || snapshot.meta.specification === undefined) return;
646
- const effectiveState = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
796
+ const effectiveState = projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session));
797
+ const invalidations = artifactInvalidations.map(({ path, reason }) => ({ path, reason }));
647
798
  const recentTransitions = projectRecentTransitionsWithLimit(
648
- snapshot.config.transitionWindow,
799
+ RECENT_TRANSITION_LIMIT,
649
800
  runtime?.recent() ?? [],
650
801
  );
651
802
  const activeRehydrationPhase = currentRehydrationPhase();
652
803
  if (snapshot.meta.bootstrap) {
653
- const messages = withoutPrivateValidation(event.messages as AgentMessage[]);
654
- return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, artifactInvalidations, activeRehydrationPhase), ...messages] };
804
+ const sourceMessages = bootstrapContinuation
805
+ ? passiveContinuationMessages(event.messages as AgentMessage[], bootstrapContinuation)
806
+ : event.messages as AgentMessage[];
807
+ const messages = withoutPrivateValidation(sourceMessages);
808
+ return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, terminalDraftIntercepted), ...messages] };
655
809
  }
656
810
  const trajectory = currentRunTrajectory(
657
811
  event.messages as AgentMessage[],
@@ -661,7 +815,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
661
815
  runAnchorTimestamp = trajectory.anchorTimestamp;
662
816
  return {
663
817
  messages: [
664
- runtimeContextMessage(snapshot, effectiveState, recentTransitions, artifactInvalidations, activeRehydrationPhase),
818
+ runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, terminalDraftIntercepted),
665
819
  ...trajectory.messages,
666
820
  ],
667
821
  };
@@ -699,76 +853,78 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
699
853
 
700
854
  pi.on("tool_execution_end", (event) => {
701
855
  if (!snapshot.config.enabled) return;
856
+ if (event.toolName === PATCH_STATE_TOOL_NAME && event.isError) stateResolutionSatisfied = false;
702
857
  skillReads.recordEnd(event.toolCallId, event.toolName, event.isError);
703
858
  artifactReads.recordEnd(event.toolCallId, event.toolName, event.isError);
704
859
  });
705
860
 
706
861
  pi.on("message_end", (event, ctx): any => {
707
862
  if (!snapshot.config.enabled || event.message.role !== "assistant") return;
708
- stagedFinal = undefined;
709
863
  const message = event.message as unknown as { role: "assistant"; stopReason?: string; content?: unknown };
710
- if (message.stopReason === "aborted") {
711
- abandonRun(ctx);
864
+ if (message.stopReason === "aborted" || assistantToolCallCount(message.content) > 0 || message.stopReason === "toolUse") {
865
+ responseAwaitingReconciliation = false;
712
866
  return;
713
867
  }
714
868
  if (message.stopReason === "length" || message.stopReason === "error") {
715
- return rejectTerminal(message, `Assistant response ended with ${message.stopReason}`, ctx);
869
+ responseAwaitingReconciliation = false;
870
+ recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, message.content);
871
+ return;
716
872
  }
717
- if (assistantToolCallCount(message.content) > 0 || message.stopReason === "toolUse") {
718
- retryQueued = snapshot.meta.validation !== undefined;
719
- const cleaned = stripStateComments(message.content);
720
- return cleaned.changed
721
- ? { message: { ...message, role: "assistant" as const, content: cleaned.content } }
722
- : undefined;
873
+ if (!stateResolutionSatisfied) {
874
+ responseAwaitingReconciliation = false;
875
+ terminalDraftIntercepted = true;
876
+ recordDiagnostic("Terminal draft intercepted before State Flow resolution", "terminal-pending", ctx, message.content);
877
+ continueForResolution();
878
+ return { message: { ...message, role: "assistant" as const, content: [] } };
723
879
  }
724
880
  try {
725
- const parsed = parseTerminalPatch(message.content);
726
- stagedFinal = stageScopedTransition(
727
- scopeStates,
728
- parsed.transition,
729
- skillReads.successful.values(),
730
- runtime!.causalBasis(),
731
- artifactReads.successful.values(),
732
- );
733
- retryQueued = false;
734
- return { message: { ...message, role: "assistant" as const, content: parsed.responseContent } };
881
+ validateUnchangedResolution(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
735
882
  } catch (error) {
736
- return rejectTerminal(message, error instanceof Error ? error.message : String(error), ctx);
883
+ stateResolutionSatisfied = false;
884
+ responseAwaitingReconciliation = false;
885
+ terminalDraftIntercepted = true;
886
+ recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, message.content);
887
+ continueForResolution();
888
+ return { message: { ...message, role: "assistant" as const, content: [] } };
737
889
  }
890
+ responseAwaitingReconciliation = true;
738
891
  });
739
892
 
740
893
  pi.on("turn_end", (event, ctx) => {
741
- if (!snapshot.config.enabled || !stagedFinal) {
894
+ if (!snapshot.config.enabled || !responseAwaitingReconciliation) {
742
895
  updateUi(ctx);
743
896
  return;
744
897
  }
745
898
  try {
746
- stagedFinal.nextStates.session.response = finalizedAssistantResponse(event.message);
747
- commitStage(stagedFinal, ctx, true);
899
+ const response = finalizedAssistantResponse(event.message);
900
+ const stage = stageScopedTransition(scopeStates, { transitions: [], response }, [], runtime!.causalBasis());
901
+ commitStage(stage, ctx, true);
902
+ bootstrapContinuation = undefined;
903
+ rehydrationPhase = "step";
748
904
  enqueueTurnPublication();
749
905
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
750
906
  } catch (error) {
751
- queueTerminalRegeneration(error instanceof Error ? error.message : String(error), ctx);
907
+ recordDiagnostic(error instanceof Error ? error.message : String(error), "finalization", ctx);
908
+ ctx.ui.notify(`State Flow could not reconcile the final response: ${error instanceof Error ? error.message : String(error)}`, "error");
909
+ } finally {
910
+ responseAwaitingReconciliation = false;
911
+ terminalDraftIntercepted = false;
752
912
  }
753
- stagedFinal = undefined;
754
913
  updateUi(ctx);
755
914
  });
756
915
 
757
- pi.on("agent_settled", (_event, ctx) => {
758
- if (snapshot.config.enabled && retryQueued) abandonRun(ctx);
916
+ pi.on("agent_settled", (_event, _ctx) => {
759
917
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
760
918
  });
761
919
 
762
920
  pi.on("session_start", (event, ctx) => {
763
921
  rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
764
922
  restoreActiveBranch(ctx, event.reason);
765
- refreshArtifactInvalidations(ctx);
766
923
  retryPendingPush(ctx);
767
924
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
768
925
  updateUi(ctx);
769
926
  });
770
927
  pi.on("session_tree", (_event, ctx) => {
771
928
  restoreActiveBranch(ctx);
772
- refreshArtifactInvalidations(ctx);
773
929
  });
774
930
  }