@llblab/pi-kit 0.21.0 → 0.21.2

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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +2 -2
  6. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +17 -6
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +4 -0
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +29 -1
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +3 -0
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +33 -7
  11. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  12. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
  13. package/node_modules/@llblab/pi-state-flow/docs/performance.md +10 -2
  14. package/node_modules/@llblab/pi-state-flow/docs/usage.md +4 -4
  15. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +15 -6
  16. package/node_modules/@llblab/pi-state-flow/lib/git.ts +24 -1
  17. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +31 -6
  18. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  19. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  20. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +3 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +6 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +5 -0
  26. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +9 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +5 -1
  28. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +8 -0
  29. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +63 -11
  30. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +4 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +14 -0
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +5 -0
  34. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  35. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  36. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  37. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  38. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +7 -0
  39. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +13 -2
  40. package/node_modules/@llblab/pi-telegram/lib/commands.ts +19 -0
  41. package/node_modules/@llblab/pi-telegram/lib/extension.ts +9 -0
  42. package/node_modules/@llblab/pi-telegram/lib/queue.ts +75 -16
  43. package/node_modules/@llblab/pi-telegram/lib/replies.ts +18 -0
  44. package/node_modules/@llblab/pi-telegram/lib/routing.ts +7 -0
  45. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  46. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.21.2 - 2026-09-23
6
+
7
+ - `Backup Push Reliability`: Advances the exact State Flow pin to `0.18.1`. Backup replication permits one in-flight push per repository; overlapping attempts are skipped until a later accepted turn, while session shutdown waits for the active process to close. Canonical state acceptance remains independent of Git replication.
8
+ - `Failure Diagnostics`: Push failures warn once per streak and retain redacted Git detail in the local diagnostic log even when general logging is off; a successful push resets warning suppression. If logging fails, the warning includes the available detail instead.
9
+ - `Prompt-Prefix Evidence`: The State Flow v3 synthetic benchmark now measures serialized per-inference context and within-run shared prefixes, including isolated resume probes. It does not change model projection or claim provider cache gains.
10
+
11
+ ## 0.21.1 - 2026-09-23
12
+
13
+ - `Queue Transition Reliability`: Advances the exact Telegram pin to `0.51.1`. Busy `/next` now reports the interrupted turn and exact selected queued prompt in order, preserves one reply-header owner through agent start, keeps rapid ordinary messages separate, and lets `/abort` or `/stop` cancel stale notices without allowing notice failures to block dispatch.
14
+ - `Follower Heartbeat Stability`: Uses a dedicated eight-second follower heartbeat response deadline aligned with leader stale-liveness policy, preventing ordinary multi-second Pi/TUI event-loop stalls from destroying a healthy socket, producing leader `EPIPE` noise, or cycling registration status.
15
+ - `Package Cohort`: Keeps every other bundled package at its current exact version. Package membership, resource paths, load order, Pi minimum, and bundled Skill ownership remain unchanged.
16
+
5
17
  ## 0.21.0 - 2026-09-23
6
18
 
7
19
  - `Independent Scope Revisions`: Advances the exact State Flow pin to `0.18.0`. Global, CWD, and Session now persist independent semantic revision counters, Effective displays the truthful `G#/C#/S#` vector, and Session exclusively owns response state while Global and CWD retain only their structural placeholders.
package/README.md CHANGED
@@ -14,15 +14,15 @@ Package links lead to the owning repositories for usage, documentation, issues,
14
14
  | [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.1.1` | Isolated nested Pi TUI with explicitly selected extensions |
15
15
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.10.0` | Compact Codex/Spark subscription-limit and Business credit-usage status |
16
16
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.8.1` | Visible continuation scheduling and bounded worker Skills |
17
- | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.18.0` | Incremental scoped context/memory compiler with independent scope revisions, canonical file persistence, optional replicated Git backups, native working context within each run, and targeted historical reads |
18
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.51.0` | Telegram companion with pressure-safe Workspace slot rotation, fenced follower readiness, adaptive Thread continuity, exact queues, files, voice, controls, and bundled Telegram interaction Skills |
17
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.18.1` | Incremental scoped context/memory compiler with independent scope revisions, canonical file persistence, bounded and diagnosable Git backup replication, native working context within each run, and targeted historical reads |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.51.1` | Telegram companion with stable follower heartbeats, exact queue-transition notices, pressure-safe Workspace rotation, adaptive Thread continuity, files, voice, controls, and bundled Telegram interaction Skills |
19
19
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
20
20
 
21
21
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
22
22
 
23
23
  ## Install
24
24
 
25
- Requires **Pi 0.87.0+** and **Node.js 22.19.0+**. State Flow 0.17 introduces a breaking storage-format boundary with no in-place predecessor converter. Preserve existing State Flow stores and review the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.18.0/docs/usage.md#moving-a-store-and-the-017-format-boundary) before upgrading from an earlier kit.
25
+ Requires **Pi 0.87.0+** and **Node.js 22.19.0+**. State Flow 0.17 introduces a breaking storage-format boundary with no in-place predecessor converter. Preserve existing State Flow stores and review the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.18.1/docs/usage.md#moving-a-store-and-the-017-format-boundary) before upgrading from an earlier kit.
26
26
 
27
27
  From npm:
28
28
 
@@ -21,7 +21,7 @@
21
21
  - Read and write only regular non-symlink State Flow-owned files at those exact paths, publish each file by same-directory atomic rename, preserve arbitrary repository contents, and classify ownership exactly. Retain opaque source bytes for file identity and rollback, not decoded-text reconstructions. Use validated structural JSON equality for in-process semantic comparisons; reserve cryptographic hashes for compact identities that cross persistence/process boundaries. Serialize cooperating canonical-file writers through file-cohort exclusion and CAS. Recheck prepared bases before each replacement/deletion and restore only bytes still matching the publisher's own output. Preserve detected concurrent changes and report unresolved rollback conflicts; do not claim kernel-atomic multi-file CAS against nonparticipating writers. Inspect only exact already-registered artifact paths; do not discover or own a Knowledge directory.
22
22
  - At instance initialization resolve the repository, CWD key, and session key; load each scope's anchored canonical checkpoint and tail, materialize at the selected retained boundary, then overlay `global → cwd → session`. Install cached view and file publication basis atomically only after successful restoration/initialization. Revalidate mutable file cohorts; never turn inspection into a long-lived publication-basis cache. Preserve selected retained-boundary evidence across transient restore failure so explicit Start can retry it. A genuinely new session gets an empty session layer and inherits only global/CWD values; it must never reuse another same-CWD session layer. Explicit native fork adoption follows the [session-copy contract](docs/fork-contract.md): copy the proven retained source-session stream and provenance into a distinct fresh canonical origin over current shared streams, without modifying parent-private files. Reject occupied targets and checkpoint the child only after CAS acceptance. Inherited parent pointers never authorize an empty reset; fence parent passive Stop projection across child reload.
23
23
  - Bind active Pi checkpoints to retained semantic boundaries in the current canonical lineage. Resume and tree restoration select only retained private session history while global/CWD scopes remain live; every failure resolving a selected boundary fails closed without falling through to older checkpoints, disabled markers, Git revisions, or unrelated newer private state. Passive shared-memory availability does not prove the selected private layer: preserve the recovery failure, reject session reads and all publication until it is resolved, and let explicit Start retry the exact selection. Never checkpoint a passive substitute over failed branch recovery.
24
- - Every accepted semantic change, including session-only and response-only changes, atomically updates affected canonical checkpoint/tail pairs and temporal metadata without creating a Git commit. Each materially changed scope advances its own persisted revision exactly once; Global and CWD revisions are shared across their writers, Session is private, and Effective is represented by the `G#/C#/S#` revision vector rather than an invented scalar owner. After an accepted turn reconciles its response and reaches `agent_before_settle`, one best-effort backup may commit only State Flow-owned already-written files. Acquire the backup mutex before briefly locking canonical storage for bounded root/CWD/session namespace inventory and regular-file byte capture; canonical writers never acquire the backup mutex. Release the canonical lock before every Git command, filter, staging write, ref update, or index synchronization. Stage only the captured snapshot through a private temporary worktree/index, preserve Git ignore/filter policy, and never inventory artifact sources or unrelated directory trees. Keep unrelated staged/index-only content and worktree edits intact; synchronize only exact owned index paths, include HEAD-owned deletions even when already absent from the caller index, and never create an unowned-only initial backup. Backup failure is diagnostic-only and never rolls back state, suppresses an answer, requests continuation, or blocks a later turn. After each successful backup attempt, asynchronously push the exact current commit to the attached branch's explicitly configured remote/ref without force. Push failure is visible but has no semantic effect; a later accepted turn retries the latest backup. Durable push queues, publication workers, worker leases, and remote retry generations do not exist.
24
+ - Every accepted semantic change, including session-only and response-only changes, atomically updates affected canonical checkpoint/tail pairs and temporal metadata without creating a Git commit. Each materially changed scope advances its own persisted revision exactly once; Global and CWD revisions are shared across their writers, Session is private, and Effective is represented by the `G#/C#/S#` revision vector rather than an invented scalar owner. After an accepted turn reconciles its response and reaches `agent_before_settle`, one best-effort backup may commit only State Flow-owned already-written files. Acquire the backup mutex before briefly locking canonical storage for bounded root/CWD/session namespace inventory and regular-file byte capture; canonical writers never acquire the backup mutex. Release the canonical lock before every Git command, filter, staging write, ref update, or index synchronization. Stage only the captured snapshot through a private temporary worktree/index, preserve Git ignore/filter policy, and never inventory artifact sources or unrelated directory trees. Keep unrelated staged/index-only content and worktree edits intact; synchronize only exact owned index paths, include HEAD-owned deletions even when already absent from the caller index, and never create an unowned-only initial backup. Backup failure is diagnostic-only and never rolls back state, suppresses an answer, requests continuation, or blocks a later turn. After each successful backup attempt, asynchronously push the exact current commit to the attached branch's explicitly configured remote/ref without force. Permit at most one in-flight push per repository root in process; skip overlapping attempts, leaving a later accepted turn to push the latest HEAD. Session shutdown waits for the in-flight process to close, within the existing Git push timeout and termination behavior; suppress push-failure reporting after shutdown begins. Push failure has no semantic effect; while active, warn once per failure streak with a concise message and reset suppression after successful replication. Record the available redacted Git failure detail in the local diagnostic log even when general logging is off; if local recording fails, warn once with the available detail. A later accepted turn retries the latest backup. Durable push queues, publication workers, worker leases, and remote retry generations do not exist.
25
25
  - Register `patch_state` as the sole model-authored semantic mutation protocol. Its canonical grammar accepts one or more fixed `global`, `cwd`, and `session` semantic patches; reject unknown fields, empty supplied scopes, material no-ops, and retired finalization or `{scope, patch}` / `unchanged` grammar. Validate every supplied scope against one causal basis and publish it all-or-nothing with one identity, temporal boundary, and durable cohort. Never accept model-authored `response`.
26
26
  - Treat every semantic `patch_state` as a strict inference barrier. Give it sequential execution mode; find the matching synchronized assistant through public parent traversal from the selected leaf without constructing a full branch during tool preflight; require exactly one `patch_state` call in that response and block every sibling tool call before execution. The next inference sees rematerialized state. Ordinary accepted answers require no finalization patch or fallback inference.
27
27
  - Reconcile `response` only from the actually accepted ordinary assistant answer at `turn_end`; it remains runtime-owned, and an accepted empty answer reconciles to `""` rather than causing a finalization failure. State Flow has no terminal HTML-comment mutation protocol and does not parse generic service comments. Other extensions retain ownership of their own comments and output handling.
@@ -31,7 +31,7 @@
31
31
  - Recognize Skill acquisition only when a successful finalized `read` path exactly matches a registered Pi Skill exposed by the public `getCommands()` resource metadata. Map `sourceInfo.scope` deterministically as user → global, project → CWD, and temporary → session; never infer ownership from path shape. Hash the executed source bytes. Matching current provenance creates no acquisition request. Otherwise append a concise tool-result hint naming the exact target, but keep the read volatile unless the model supplies durable compiler output; pending acquisition never blocks an unrelated semantic patch or ordinary completion. Validate an attempted compilation at the reported scope/path with non-empty `description`, `kind: "skill"`, and flexible non-empty `compilation`, then record runtime-owned `sourceHash` plus `skill-artifact-v1` `compilerRevision` in that scope's provenance. Replace the complete prior Skill artifact and provenance on refresh so obsolete evidence cannot survive. Unregistered `SKILL.md` reads have no acquisition semantics. `contract.compiled_skills` is retired and rejected; never fabricate hashes or bypass explicit source acquisition.
32
32
  - Make state stewardship an ordinary model responsibility without a background loop or gratuitous full-state rewrite. Every handoff curates touched and obviously stale or mis-scoped visible branches: place new knowledge at the narrowest valid scope, merge fragmented facts, compress history into conclusions, and delete stale, completed, redundant, or low-value keys while preserving active commitments and evidence. Dedicated cleanup and scope audits require an explicit user request; a feature/release/project boundary may motivate a recommendation, not an automatic audit. For a proven move within one store, inspect both owners, resolve conflicts, apply destination/source changes through one atomic multi-scope patch, and verify both owners plus the effective overlay. External transfers require destination-native acceptance verification before source deletion. Global is only established cross-project/user/environment knowledge, CWD is reusable project truth, and session is branch/run continuation.
33
33
  - Accept omitted semantic fields inside each supplied scope patch, but reject empty supplied scopes and require at least one material semantic or provenance change. When no state change is needed, do not call `patch_state`. Accept the exact ordinary answer, including an empty string, which runtime owns as session `response`. A changed response is a semantic transition; identical complete semantic state finalizes lifecycle without a patch, identity, or temporal step. Semantic usefulness and optimization remain protocol-owned because deterministic validation cannot prove them.
34
- - Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and response-finalization failures. Every rejected call retains its exact attempted arguments plus the precise error and, when available, tool identity and call id; successful patches and accepted answers are never logged. Preserve exact text blocks only where useful, reduce other blocks to structural identity, never duplicate reasoning bodies, and keep logging outside semantic state, scope metadata, checkpoints, and publication. Logging failure emits at most one warning and never changes enablement or accepted state.
34
+ - Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and response-finalization failures; asynchronous Git push failures are logged there even without opt-in so terse interactive warnings retain local detail. Every rejected call retains its exact attempted arguments plus the precise error and, when available, tool identity and call id; successful patches and accepted answers are never logged. Preserve exact text blocks only where useful, reduce other blocks to structural identity, never duplicate reasoning bodies, and keep logging outside semantic state, scope metadata, checkpoints, and publication. Logging failure emits at most one warning and never changes enablement or accepted state.
35
35
  - Keep `applyPatch` results and staged mutable drafts detached from caller patches and accepted scopes. Detach once at the public patch boundary; private owned-draft COW may share untouched paths only within that isolated basis. Clone incoming replacements, detach inherited objects before preserving their merge behavior, and retain late-response/artifact mutation isolation. Do not trade CAS, publication rollback or public/historical read isolation for cross-boundary sharing.
36
36
  - Recursively materialize patches immediately; empty objects preserve, nested object-key `null` deletes, and `null` anywhere in semantic state including arrays is invalid. This prohibition does not apply to runtime envelopes such as an origin's null parent. Persist runtime-normalized replay patches that exactly reproduce accepted state, including complete artifact replacement; runtime-owned provenance is stored in scope `meta.json` and is not part of semantic replay.
37
37
  - Do not impose project schemas, state or patch byte caps, dynamic growth pressure, observation envelopes, action authorization, action ledgers, or state-size limits.
@@ -2,6 +2,12 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.18.1: Backup Reliability
6
+
7
+ - `Push lifecycle`: Bounds backup replication to one in-flight push per repository, skips overlap until a later accepted turn, and awaits process closure at session shutdown under the existing push timeout. Suppresses failure reporting after shutdown starts; test fixtures settle pushes before cleanup. Canonical acceptance stays independent of Git replication.
8
+ - `Push diagnostics`: Repeated asynchronous push failures produce one concise warning per failure streak until a successful push. Available redacted Git errors are recorded locally even without general logging; an unavailable diagnostic log falls back to one warning with the failure detail. Canonical acceptance and later-turn retry are unchanged.
9
+ - `Prompt-prefix evidence`: Version 3 synthetic benchmark reports each inference's serialized context size and within-run shared byte prefix, accepted patch barriers, and matching native user/specification value sizes for State Flow and native Pi, including isolated resume probes. A source-bound local sample and regression coverage inform a later projection decision without changing 0.18.1 model behavior.
10
+
5
11
  ## 0.18.0: Independent Revisions and Replicated Backups
6
12
 
7
13
  - `Independent scope revisions`: Persists independent Global, CWD and Session semantic counters. Each materially changed scope advances once per atomic cohort; no-ops advance none, Session owns response changes, folding preserves counts, and pre-revision 0.17 stores start from retained-tail evidence. Effective uses the truthful `G#/C#/S#` vector rather than a scalar observer count. Metadata v2 keeps v1 readable and fences older writers after a revision-aware scope write.
@@ -124,11 +124,11 @@ Array ranges, structural `keys` reads and path-intersected `patch` projections s
124
124
 
125
125
  The default store is `~/.pi/agent/state-flow/`, independent of registered source files. Canonical `checkpoint.json`, `patches.jsonl` and `meta.json` files hold each scope's state, retained changes and metadata; session configuration and runtime identity are stored separately.
126
126
 
127
- **Git backups are optional.** When the store is a configured Git repository, accepted active turns may create versioned backups of State Flow-owned files. If the attached branch has an explicitly configured remote, State Flow then pushes the exact current backup commit there asynchronously and without force. Commit or push failure produces a warning without rejecting or rolling back accepted memory; a later accepted turn tries the latest backup again. Backup needs a Git commit identity, but accepting and persisting state does not. Git history can be inspected separately, but it is not the authority for `read_state` or automatic restoration of expired semantic boundaries.
127
+ **Git backups are optional.** When the store is a configured Git repository, accepted active turns may create versioned backups of State Flow-owned files. If the attached branch has an explicitly configured remote, State Flow then pushes the exact current backup commit there asynchronously and without force. Within one Pi process, only one push per repository can run at a time; overlapping attempts are skipped and a later accepted turn pushes the latest backup. Session shutdown waits for that repository's active push to close or time out. Commit failures warn locally; repeated push failures produce one concise warning until a successful push, with redacted Git detail kept in the local diagnostic log. Neither failure rejects or rolls back accepted memory. Backup needs a Git commit identity, but accepting and persisting state does not. Git history can be inspected separately, but it is not the authority for `read_state` or automatic restoration of expired semantic boundaries.
128
128
 
129
129
  Resume and tree navigation restore the selected retained session boundary over current shared global/CWD memory. A new session gets its own session layer. Supported native forks copy selected session state into a new owner without changing the parent's private data. Expired, incomplete or contradictory boundaries fail closed rather than silently substituting newer state. See [fork support](docs/usage.md#fork-support-and-limits) and [storage recovery](docs/usage.md#storage-and-recovery).
130
130
 
131
- Version 0.17 accepts only the current canonical storage contract and has no in-place predecessor converter. Preserve existing data and check the [format boundary](docs/usage.md#moving-a-store-and-the-017-format-boundary) before changing versions or moving a store.
131
+ The canonical storage-format boundary introduced in 0.17 still applies: current versions accept only the canonical store contract and provide no in-place predecessor converter. Preserve existing data and check the [format boundary](docs/usage.md#moving-a-store-and-the-017-format-boundary) before changing versions or moving a store.
132
132
 
133
133
  ## Operational boundaries
134
134
 
@@ -12,7 +12,7 @@ import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, pa
12
12
  import { readNativeSessionHeader } from "./continuation.js";
13
13
  import { cwdScopeKey, resolveSessionAddress, sessionScopeKey, } from "./durable.js";
14
14
  import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.js";
15
- import { backupCurrentStateFlowFiles, pushCurrentStateFlowBackup } from "./git.js";
15
+ import { awaitInFlightBackupPushes, backupCurrentStateFlowFiles, startStateFlowBackupPush } from "./git.js";
16
16
  import { projectRecentTransitionsWithLimit } from "./history.js";
17
17
  import { isObject, presentationJson, sameJson } from "./json.js";
18
18
  import { StateFlowDiagnosticWriter, stateFlowLogPath } from "./logging.js";
@@ -62,6 +62,8 @@ export default function stateFlowExtension(pi, options = {}) {
62
62
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
63
63
  const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
64
64
  let backupPending = false;
65
+ let shuttingDown = false;
66
+ let pushFailureNotified = false;
65
67
  const skillReads = new SkillReadTracker(hashSkillSource, (path) => activeContext
66
68
  ? registeredSkillResolver(activeContext.cwd, pi.getCommands())(path)
67
69
  : undefined);
@@ -932,11 +934,18 @@ export default function stateFlowExtension(pi, options = {}) {
932
934
  backupCurrentStateFlowFiles(repositoryRoot);
933
935
  const pushSessionId = sessionAddress(ctx).id;
934
936
  const pushCwd = ctx.cwd;
935
- void pushCurrentStateFlowBackup(repositoryRoot).catch((error) => {
937
+ startStateFlowBackupPush(repositoryRoot, (error) => {
938
+ if (shuttingDown)
939
+ return;
936
940
  const message = error instanceof Error ? error.message : String(error);
937
- diagnosticWriter.record(pushSessionId, pushCwd, message, "publication-conflict");
938
- notifyActiveContext(`State Flow accepted canonical state; Git backup push failed: ${message}`);
939
- });
941
+ const recorded = diagnosticWriter.recordBackupPushFailure(pushSessionId, pushCwd, message);
942
+ if (pushFailureNotified)
943
+ return;
944
+ pushFailureNotified = true;
945
+ notifyActiveContext(recorded
946
+ ? `State Flow Git backup push failed; state is saved locally. Details: ${stateFlowLogPath(agentDir)}. A later accepted turn retries.`
947
+ : `State Flow Git backup push failed; local diagnostics unavailable: ${message}`);
948
+ }, () => { pushFailureNotified = false; });
940
949
  }
941
950
  }
942
951
  catch (error) {
@@ -980,11 +989,13 @@ export default function stateFlowExtension(pi, options = {}) {
980
989
  runAnchorTimestamp = undefined;
981
990
  restoreActiveBranch(ctx);
982
991
  });
983
- pi.on("session_shutdown", (_event, _ctx) => {
992
+ pi.on("session_shutdown", async (_event, _ctx) => {
993
+ shuttingDown = true;
984
994
  telegramStartPending = false;
985
995
  compactionStopped = true;
986
996
  completedRunAccepted = false;
987
997
  activeContext = undefined;
988
998
  telegram.dispose();
999
+ await awaitInFlightBackupPushes(repositoryRoot);
989
1000
  });
990
1001
  }
@@ -1,5 +1,9 @@
1
1
  /** Commit only current State Flow-owned files; never changes canonical acceptance. */
2
2
  export declare function backupCurrentStateFlowFiles(repositoryRoot: string): string | undefined;
3
+ /** Skip overlapping pushes; the next accepted turn can push the latest HEAD. */
4
+ export declare function startStateFlowBackupPush(repositoryRoot: string, onFailure: (error: unknown) => void, onSuccess?: () => void): boolean;
5
+ /** Resolve only after the push process has closed (including timeout termination). */
6
+ export declare function awaitInFlightBackupPushes(repositoryRoot: string): Promise<void>;
3
7
  /** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
4
8
  export declare function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{
5
9
  commit: string;
@@ -7,6 +7,7 @@ import { captureOwnedFileBases, isStateFlowOwnedPath } from "./durable.js";
7
7
  import { acquirePublicationLock, withStoragePublicationLock } from "./storage.js";
8
8
  const GIT_TIMEOUT_MS = 15_000;
9
9
  const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
10
+ const activePushes = new Map();
10
11
  function redactGitDiagnostic(value) {
11
12
  return value
12
13
  .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@")
@@ -205,6 +206,33 @@ function commitCurrentOwnedFiles(repositoryRoot, expectedHead) {
205
206
  export function backupCurrentStateFlowFiles(repositoryRoot) {
206
207
  return withBackupLock(repositoryRoot, (root) => commitCurrentOwnedFiles(root, currentHead(root)));
207
208
  }
209
+ /** Skip overlapping pushes; the next accepted turn can push the latest HEAD. */
210
+ export function startStateFlowBackupPush(repositoryRoot, onFailure, onSuccess) {
211
+ const root = resolve(repositoryRoot);
212
+ if (activePushes.has(root))
213
+ return false;
214
+ const push = pushCurrentStateFlowBackup(root).then((result) => {
215
+ if (result) {
216
+ try {
217
+ onSuccess?.();
218
+ }
219
+ catch { /* Reporting cannot change push acceptance. */ }
220
+ }
221
+ }, (error) => {
222
+ try {
223
+ onFailure(error);
224
+ }
225
+ catch { /* Reporting cannot revive a failed push. */ }
226
+ }).finally(() => { activePushes.delete(root); });
227
+ activePushes.set(root, push);
228
+ return true;
229
+ }
230
+ /** Resolve only after the push process has closed (including timeout termination). */
231
+ export async function awaitInFlightBackupPushes(repositoryRoot) {
232
+ const push = activePushes.get(resolve(repositoryRoot));
233
+ if (push)
234
+ await push;
235
+ }
208
236
  /** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
209
237
  export function pushCurrentStateFlowBackup(repositoryRoot) {
210
238
  return new Promise((resolvePush, rejectPush) => {
@@ -217,7 +245,7 @@ export function pushCurrentStateFlowBackup(repositoryRoot) {
217
245
  destination = configuredPushDestination(root);
218
246
  }
219
247
  catch (error) {
220
- rejectPush(error);
248
+ rejectPush(new Error(redactGitDiagnostic(error instanceof Error ? error.message : String(error))));
221
249
  return;
222
250
  }
223
251
  if (commit === undefined || destination === undefined) {
@@ -36,4 +36,7 @@ export declare class StateFlowDiagnosticWriter {
36
36
  private readonly notify;
37
37
  constructor(enabled: boolean, path: string, repositoryRoot: string, notify: (message: string) => void);
38
38
  record(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras?: DiagnosticExtras): void;
39
+ /** Push warnings stay short; retain the available Git failure detail locally even without opt-in logging. */
40
+ recordBackupPushFailure(sessionId: string, cwd: string, error: string): boolean;
41
+ private write;
39
42
  }
@@ -1,5 +1,5 @@
1
1
  // Domain: opt-in diagnostic capture for rejected State Flow resolutions.
2
- import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { appendFileSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { isObject } from "./json.js";
5
5
  /** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
@@ -18,9 +18,26 @@ export function projectDiagnosticContent(content) {
18
18
  export function stateFlowLogPath(agentDir) {
19
19
  return join(agentDir, "tmp", "state-flow", "logs.jsonl");
20
20
  }
21
+ function ensureDiagnosticDirectory(path) {
22
+ if (dirname(path) !== path)
23
+ ensureDiagnosticDirectory(dirname(path));
24
+ if (!lstatSync(path, { throwIfNoEntry: false }))
25
+ mkdirSync(path);
26
+ const stat = lstatSync(path);
27
+ if (!stat.isDirectory() || stat.isSymbolicLink())
28
+ throw new Error(`State Flow diagnostic directory is not a regular directory: ${path}`);
29
+ }
21
30
  export function appendStateFlowDiagnostic(path, record) {
22
- mkdirSync(dirname(path), { recursive: true });
23
- appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
31
+ ensureDiagnosticDirectory(dirname(path));
32
+ const descriptor = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
33
+ try {
34
+ if (!fstatSync(descriptor).isFile())
35
+ throw new Error(`State Flow diagnostic destination is not a regular file: ${path}`);
36
+ appendFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" });
37
+ }
38
+ finally {
39
+ closeSync(descriptor);
40
+ }
24
41
  }
25
42
  /** Own diagnostic path safety, projection, persistence, and one-shot failure reporting. */
26
43
  export class StateFlowDiagnosticWriter {
@@ -38,6 +55,13 @@ export class StateFlowDiagnosticWriter {
38
55
  record(sessionId, cwd, error, category, extras = {}) {
39
56
  if (!this.enabled)
40
57
  return;
58
+ this.write(sessionId, cwd, error, category, extras);
59
+ }
60
+ /** Push warnings stay short; retain the available Git failure detail locally even without opt-in logging. */
61
+ recordBackupPushFailure(sessionId, cwd, error) {
62
+ return this.write(sessionId, cwd, error, "publication-conflict", {}, false);
63
+ }
64
+ write(sessionId, cwd, error, category, extras, reportFailure = true) {
41
65
  try {
42
66
  const fromRepository = relative(this.repositoryRoot, this.path);
43
67
  if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
@@ -54,12 +78,14 @@ export class StateFlowDiagnosticWriter {
54
78
  ...(extras.tool === undefined ? {} : { tool: extras.tool }),
55
79
  ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
56
80
  });
81
+ return true;
57
82
  }
58
83
  catch (failure) {
59
- if (this.warningReported)
60
- return;
61
- this.warningReported = true;
62
- this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
84
+ if (reportFailure && !this.warningReported) {
85
+ this.warningReported = true;
86
+ this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
87
+ }
88
+ return false;
63
89
  }
64
90
  }
65
91
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -10,7 +10,7 @@ The current repository-local stack uses Linux/x64, Node 26.8.1, Git 2.55.0, and
10
10
 
11
11
  | Pi SDK stack | Validation | Evidence status |
12
12
  | --- | --- | --- |
13
- | 0.87.0 | Build, typecheck, import, package dry-run, 438/438 tests | Current full-suite baseline |
13
+ | 0.87.0 | Build, typecheck, import, package dry-run; 472/472 tests in each of five ordinary and five `push.negotiate=true` isolated-Git runs | 0.18.1 candidate source-bound acceptance (2026-09-23); not a live-provider or cross-platform claim |
14
14
  | 0.84.4 | Historical full-suite baseline | Unsupported by State Flow 0.17.0 |
15
15
  | 0.85.1 | Historical full-suite baseline | Unsupported by State Flow 0.17.0 |
16
16
 
@@ -22,16 +22,24 @@ BENCH_PATCHES=2 BENCH_SAMPLES=1 BENCH_ROUNDS=2 BENCH_STATE_BYTES=1024 npm run be
22
22
 
23
23
  Treat timings as observations from the named host and source identity, not universal thresholds. Compare runs only when workload fingerprints, dependencies, payloads, and validation outcomes match. A failed correctness probe invalidates its timing sample.
24
24
 
25
+ ### Within-run prompt-prefix probe (0.18.1)
26
+
27
+ The v3 report records `promptPrefixRuns` for each State Flow and native Pi user run, with no duplication across lifecycle checkpoints. Per inference, `contextBytes` is the UTF-8 byte length of `JSON.stringify(context.messages)` as seen by the installed faux provider; `sharedPrefixBytes` is the longest common byte prefix of that serialization with the previous inference **in the same run**, or `null` on the first inference. Per run, `patchStateBarriers` counts successful tool completions, and `nativeUserBytes` / `specificationBytes` count JSON-serialized *string values*, excluding their containing message/field frames (`specificationBytes` is `null` for native Pi). Isolated post-resume probes expose the same per-run metrics.
28
+
29
+ A bounded local sample used `BENCH_PATCHES=2 BENCH_SAMPLES=1 BENCH_ROUNDS=2 BENCH_STATE_BYTES=1024 BENCH_POST_RESUME=1 npm run benchmark` on Node 26.8.1 Linux/x64 and Pi/AI 0.87.0. The runtime-source SHA-256 was `0f4eea2394c52c6ac545260cae967a16581b8768a24a8c611c13a389e6df01a9`, workload-source SHA-256 `e9b8158d953d8622776aa075835c4aa95c98855ed5c90e85de05dccae1087e91`, base commit `5f0f688650738d4d0e9850b2529b0273c47121a8` (the measured tree had uncommitted 0.18.1 changes). Both source hashes stayed unchanged during the run and all correctness phases passed. At user run 1, native Pi's second inference shared 2,776 of 2,777 prior serialized bytes; State Flow shared 9,365 of 9,467 at inference 2 and 9,173 with inference 2 at inference 3 (one accepted barrier). In both State Flow runs, the 21-byte serialized `specification` value duplicated the 21-byte current native user string value; native Pi had no projection field. These are synthetic short-run observations, not representative cache-hit rates or timing predictions.
30
+
31
+ A later 0.19 decision about freezing the projected head must compare compatible workloads and include correctness of fresh state visibility after barriers. Byte-prefix measurements omit provider framing, tool schemas, tokenization, cache policies and quality; this probe does **not** justify changing context projection in 0.18.1.
32
+
25
33
  ## Current cost model
26
34
 
27
35
  - Current semantic projection is proportional to projected state size.
28
36
  - Retained temporal reads are bounded by configured `historyLimit` (`0..100`, default `7`).
29
37
  - Canonical publication writes the affected scope/runtime cohort under file CAS and cooperating-writer exclusion; it executes no Git command.
30
38
  - Registered-artifact maintenance is proportional to already-registered paths and uses metadata-only `size + mtimeNs` inspection. It performs no directory discovery or generic body hashing.
31
- - Optional Git backup runs only after accepted work reaches `agent_before_settle`. Its canonical-lock capture costs are proportional to owned file count and bytes; all Git commands and filters run after that lock is released. Git remains synchronous within the calling settled-turn callback: independent canonical processes can publish during slow Git, but this is not a host-event-loop latency bound. Backup is not an acceptance or recovery authority.
39
+ - Optional Git backup runs only after accepted work reaches `agent_before_settle`. Its canonical-lock capture costs are proportional to owned file count and bytes; all Git commands and filters run after that lock is released. The commit remains synchronous within the calling settled-turn callback: independent canonical processes can publish during slow Git, but this is not a host-event-loop latency bound. Remote push runs asynchronously, skips overlapping attempts per repository within one Pi process, and is awaited at session shutdown within its existing timeout and process-group termination behavior. Backup is not an acceptance or recovery authority.
32
40
  - Native transcript opening, Pi context construction, and foreign custom-context preservation remain Pi/history costs rather than canonical-state storage costs.
33
41
 
34
- Discarded semantic history is unavailable. Benchmarks must not count Git cold reads, revision restoration, queue workers, remote pushes, migration, terminal repair, or fallback inference; those mechanisms do not exist in the 0.17 architecture.
42
+ Discarded semantic history is unavailable. Git cold reads, revision restoration, queue workers, migration, terminal repair and fallback inference are absent from the current architecture. Remote pushes do exist but are asynchronous and outside these local benchmark workloads; do not count them as measured costs.
35
43
 
36
44
  ## Tool-preflight parent traversal
37
45
 
@@ -46,7 +46,7 @@ The canonical store is `state-flow/` beneath the agent directory. Keeping config
46
46
  - `autoStart`: Defaults to `false`. When `true`, genuinely new sessions use the same initialization as explicit Start, including fresh CWDs.
47
47
  - `passiveBootstrap`: Defaults to `true`. Projects existing effective durable memory into ordinary model context without creating scopes, publishing, or starting an episode.
48
48
  - `passiveTools`: Defaults to `true`. Exposes `read_state` and `patch_state` outside active episodes. Reads remain side-effect free; the first explicit patch may initialize absent canonical storage but never converts predecessor formats or enables an episode or State Flow compaction.
49
- - `logging`: Defaults to `false`. When enabled, records rejected patches and accepted-answer reconciliation failures locally at `tmp/state-flow/logs.jsonl` beneath the agent directory.
49
+ - `logging`: Defaults to `false`. When enabled, records rejected patches and accepted-answer reconciliation failures locally at `tmp/state-flow/logs.jsonl` beneath the agent directory. Asynchronous Git push failures are recorded there even when this setting is off.
50
50
  - `showSuccessfulPatches`: Defaults to `true`. In interactive Pi, successful `patch_state` rows show only the applied pretty-printed JSON arguments, with blank lines between adjacent memory sections; set it to `false` to keep only the compact summary. Rejected calls still use ordinary error rendering; State Flow adds no private validation turn.
51
51
  - `historyLimit`: Defaults to `7` and accepts integers from `0` through `100`. It bounds materialized-history and scope patch-history offsets. Lowering it on reload/restore/fork folds excess tails forward without losing current state; selected boundaries outside the new window are unavailable. Zero retains only current checkpoints. Raising the limit affects only future retention and cannot reconstruct discarded history.
52
52
 
@@ -56,7 +56,7 @@ Settings are read once at extension load. After editing, use `/reload` or restar
56
56
 
57
57
  ### Diagnostic logging and privacy
58
58
 
59
- Rejected-call records may contain exact attempted arguments and useful draft text, plus the error category and tool/call identity. Successful patches are not logged; reasoning bodies are excluded. Logs are not semantic state, scope metadata, Pi checkpoints, or publication input. If the log path overlaps a custom state repository, capture fails closed instead of committing it. A logging failure changes no accepted state and produces at most one local warning.
59
+ Rejected-call records may contain exact attempted arguments and useful draft text, plus the error category and tool/call identity. Asynchronous push failures retain the available redacted Git error there regardless of `logging`; interactive warnings stay short and appear once per failure streak, then reset on success. If logging the push failure is unavailable, one warning exposes the available detail instead. Successful patches are not logged; reasoning bodies are excluded. Logs are not semantic state, scope metadata, Pi checkpoints, or publication input. If the log path overlaps a custom state repository, capture fails closed instead of committing it. A logging failure changes no accepted state and produces at most one local warning.
60
60
 
61
61
  Logs remain local unless you move them; rotation/deletion is operator-owned. Treat them and state files as private. Removing a secret from current state does not erase older offsets, Git history, native sessions, or remote copies.
62
62
 
@@ -98,13 +98,13 @@ Missing artifact provenance inside an otherwise complete scope `meta.json` means
98
98
 
99
99
  Canonical scope/runtime files own current materialization and retained hot history regardless of Git availability. Pi checkpoints identify a retained semantic boundary, not a Git commit or arbitrary historical snapshot. Restart and branch restoration fail closed when the selected boundary has expired rather than substituting newer files as the selected past.
100
100
 
101
- After an accepted turn has reconciled its response, Pi 0.87's final actionable `agent_before_settle` boundary may create one best-effort backup commit when Git is available. If the attached branch has an explicitly configured remote/ref, State Flow starts a non-interactive asynchronous push of the exact current commit without force. Settlement does not wait for the network. Commit or push failure is diagnostic-only, and the next accepted turn retries the latest backup without a durable queue. Git availability never changes semantic authority, step, or retained lineage.
101
+ After an accepted turn has reconciled its response, Pi 0.87's final actionable `agent_before_settle` boundary may create one best-effort backup commit when Git is available. If the attached branch has an explicitly configured remote/ref, State Flow starts a non-interactive asynchronous push of the exact current commit without force. Settlement does not wait for the network. Within one Pi process, an in-flight push per repository skips overlapping attempts; a later accepted turn retries the latest backup without a durable queue. Session shutdown waits for that repository's in-flight push to close or time out, suppressing push-failure reporting after shutdown begins. Commit or push failure is diagnostic-only; repeated push failures warn once per failure streak and remain locally diagnosable. Git availability never changes semantic authority, step, or retained lineage.
102
102
 
103
103
  ### Moving a store and the 0.17 format boundary
104
104
 
105
105
  An SDK `repositoryRoot` override or a different `PI_CODING_AGENT_DIR` selects a location; it does not relocate existing state or retained history. Copy the complete canonical store while all writers are quiescent, or use a genuinely new Pi session for an independent store. Copying only current checkpoints without their tails and metadata cannot preserve retained boundaries.
106
106
 
107
- State Flow 0.17 accepts only its canonical checkpoint/tail, temporal metadata, and separate session config/runtime contract. A canonical 0.17 scope written before independent revisions remains readable: its initial counter uses only the retained semantic tail and is persisted in metadata version 2 on the next owned write, without inventing folded ancestry. Version 1 remains readable; older writers refuse version 2 through the existing provenance-version fence, so cooperating instances should upgrade together. Predecessor checkpoint envelopes, combined session metadata, pre-intents checkpoints, `state.json`, hashed layouts, and semantic Pi checkpoint envelopes fail as unsupported without rewriting existing bytes. State Flow does not provide an in-place converter; external conversion or a fresh store is operator-owned.
107
+ Starting with 0.17, State Flow accepts only its canonical checkpoint/tail, temporal metadata, and separate session config/runtime contract; that boundary still applies to current versions. A canonical 0.17 scope written before independent revisions remains readable: its initial counter uses only the retained semantic tail and is persisted in metadata version 2 on the next owned write, without inventing folded ancestry. Version 1 remains readable; older writers refuse version 2 through the existing provenance-version fence, so cooperating instances should upgrade together. Predecessor checkpoint envelopes, combined session metadata, pre-intents checkpoints, `state.json`, hashed layouts, and semantic Pi checkpoint envelopes fail as unsupported without rewriting existing bytes. State Flow does not provide an in-place converter; external conversion or a fresh store is operator-owned.
108
108
 
109
109
  ### Conflicts and interrupted publication
110
110
 
@@ -25,7 +25,7 @@ import {
25
25
  type SessionAddress,
26
26
  } from "./durable.ts";
27
27
  import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
28
- import { backupCurrentStateFlowFiles, pushCurrentStateFlowBackup } from "./git.ts";
28
+ import { awaitInFlightBackupPushes, backupCurrentStateFlowFiles, startStateFlowBackupPush } from "./git.ts";
29
29
  import { projectRecentTransitionsWithLimit } from "./history.ts";
30
30
  import { isObject, presentationJson, sameJson, type JsonObject } from "./json.ts";
31
31
  import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
@@ -88,6 +88,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
88
88
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
89
89
  const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
90
90
  let backupPending = false;
91
+ let shuttingDown = false;
92
+ let pushFailureNotified = false;
91
93
  const skillReads = new SkillReadTracker(hashSkillSource, (path) => activeContext
92
94
  ? registeredSkillResolver(activeContext.cwd, pi.getCommands())(path)
93
95
  : undefined);
@@ -935,11 +937,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
935
937
  backupCurrentStateFlowFiles(repositoryRoot);
936
938
  const pushSessionId = sessionAddress(ctx).id;
937
939
  const pushCwd = ctx.cwd;
938
- void pushCurrentStateFlowBackup(repositoryRoot).catch((error) => {
940
+ startStateFlowBackupPush(repositoryRoot, (error) => {
941
+ if (shuttingDown) return;
939
942
  const message = error instanceof Error ? error.message : String(error);
940
- diagnosticWriter.record(pushSessionId, pushCwd, message, "publication-conflict");
941
- notifyActiveContext(`State Flow accepted canonical state; Git backup push failed: ${message}`);
942
- });
943
+ const recorded = diagnosticWriter.recordBackupPushFailure(pushSessionId, pushCwd, message);
944
+ if (pushFailureNotified) return;
945
+ pushFailureNotified = true;
946
+ notifyActiveContext(recorded
947
+ ? `State Flow Git backup push failed; state is saved locally. Details: ${stateFlowLogPath(agentDir)}. A later accepted turn retries.`
948
+ : `State Flow Git backup push failed; local diagnostics unavailable: ${message}`);
949
+ }, () => { pushFailureNotified = false; });
943
950
  }
944
951
  } catch (error) {
945
952
  const message = error instanceof Error ? error.message : String(error);
@@ -981,11 +988,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
981
988
  runAnchorTimestamp = undefined;
982
989
  restoreActiveBranch(ctx);
983
990
  });
984
- pi.on("session_shutdown", (_event, _ctx) => {
991
+ pi.on("session_shutdown", async (_event, _ctx) => {
992
+ shuttingDown = true;
985
993
  telegramStartPending = false;
986
994
  compactionStopped = true;
987
995
  completedRunAccepted = false;
988
996
  activeContext = undefined;
989
997
  telegram.dispose();
998
+ await awaitInFlightBackupPushes(repositoryRoot);
990
999
  });
991
1000
  }
@@ -8,6 +8,7 @@ import { acquirePublicationLock, withStoragePublicationLock } from "./storage.ts
8
8
 
9
9
  const GIT_TIMEOUT_MS = 15_000;
10
10
  const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
11
+ const activePushes = new Map<string, Promise<void>>();
11
12
 
12
13
  function redactGitDiagnostic(value: string): string {
13
14
  return value
@@ -206,6 +207,28 @@ export function backupCurrentStateFlowFiles(repositoryRoot: string): string | un
206
207
  return withBackupLock(repositoryRoot, (root) => commitCurrentOwnedFiles(root, currentHead(root)));
207
208
  }
208
209
 
210
+ /** Skip overlapping pushes; the next accepted turn can push the latest HEAD. */
211
+ export function startStateFlowBackupPush(repositoryRoot: string, onFailure: (error: unknown) => void, onSuccess?: () => void): boolean {
212
+ const root = resolve(repositoryRoot);
213
+ if (activePushes.has(root)) return false;
214
+ const push = pushCurrentStateFlowBackup(root).then(
215
+ (result) => {
216
+ if (result) { try { onSuccess?.(); } catch { /* Reporting cannot change push acceptance. */ } }
217
+ },
218
+ (error) => {
219
+ try { onFailure(error); } catch { /* Reporting cannot revive a failed push. */ }
220
+ },
221
+ ).finally(() => { activePushes.delete(root); });
222
+ activePushes.set(root, push);
223
+ return true;
224
+ }
225
+
226
+ /** Resolve only after the push process has closed (including timeout termination). */
227
+ export async function awaitInFlightBackupPushes(repositoryRoot: string): Promise<void> {
228
+ const push = activePushes.get(resolve(repositoryRoot));
229
+ if (push) await push;
230
+ }
231
+
209
232
  /** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
210
233
  export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ commit: string; remote: string; ref: string } | undefined> {
211
234
  return new Promise((resolvePush, rejectPush) => {
@@ -217,7 +240,7 @@ export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ co
217
240
  commit = currentHead(root);
218
241
  destination = configuredPushDestination(root);
219
242
  } catch (error) {
220
- rejectPush(error);
243
+ rejectPush(new Error(redactGitDiagnostic(error instanceof Error ? error.message : String(error))));
221
244
  return;
222
245
  }
223
246
  if (commit === undefined || destination === undefined) {
@@ -1,5 +1,5 @@
1
1
  // Domain: opt-in diagnostic capture for rejected State Flow resolutions.
2
- import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { appendFileSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { isObject } from "./json.ts";
5
5
 
@@ -39,9 +39,22 @@ export function stateFlowLogPath(agentDir: string): string {
39
39
  return join(agentDir, "tmp", "state-flow", "logs.jsonl");
40
40
  }
41
41
 
42
+ function ensureDiagnosticDirectory(path: string): void {
43
+ if (dirname(path) !== path) ensureDiagnosticDirectory(dirname(path));
44
+ if (!lstatSync(path, { throwIfNoEntry: false })) mkdirSync(path);
45
+ const stat = lstatSync(path);
46
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`State Flow diagnostic directory is not a regular directory: ${path}`);
47
+ }
48
+
42
49
  export function appendStateFlowDiagnostic(path: string, record: StateFlowDiagnosticRecord): void {
43
- mkdirSync(dirname(path), { recursive: true });
44
- appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
50
+ ensureDiagnosticDirectory(dirname(path));
51
+ const descriptor = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
52
+ try {
53
+ if (!fstatSync(descriptor).isFile()) throw new Error(`State Flow diagnostic destination is not a regular file: ${path}`);
54
+ appendFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" });
55
+ } finally {
56
+ closeSync(descriptor);
57
+ }
45
58
  }
46
59
 
47
60
  export interface DiagnosticExtras {
@@ -68,6 +81,15 @@ export class StateFlowDiagnosticWriter {
68
81
 
69
82
  record(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras: DiagnosticExtras = {}): void {
70
83
  if (!this.enabled) return;
84
+ this.write(sessionId, cwd, error, category, extras);
85
+ }
86
+
87
+ /** Push warnings stay short; retain the available Git failure detail locally even without opt-in logging. */
88
+ recordBackupPushFailure(sessionId: string, cwd: string, error: string): boolean {
89
+ return this.write(sessionId, cwd, error, "publication-conflict", {}, false);
90
+ }
91
+
92
+ private write(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras: DiagnosticExtras, reportFailure = true): boolean {
71
93
  try {
72
94
  const fromRepository = relative(this.repositoryRoot, this.path);
73
95
  if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
@@ -84,10 +106,13 @@ export class StateFlowDiagnosticWriter {
84
106
  ...(extras.tool === undefined ? {} : { tool: extras.tool }),
85
107
  ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
86
108
  });
109
+ return true;
87
110
  } catch (failure) {
88
- if (this.warningReported) return;
89
- this.warningReported = true;
90
- this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
111
+ if (reportFailure && !this.warningReported) {
112
+ this.warningReported = true;
113
+ this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
114
+ }
115
+ return false;
91
116
  }
92
117
  }
93
118
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [