@llblab/pi-kit 0.8.1 → 0.9.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 (31) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +9 -2
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +12 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +11 -9
  7. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -5
  8. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +2 -2
  9. package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
  10. package/node_modules/@llblab/pi-state-flow/lib/context.ts +1 -1
  11. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +159 -67
  12. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +6 -0
  13. package/node_modules/@llblab/pi-state-flow/lib/state.ts +7 -0
  14. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +4 -4
  15. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +15 -6
  16. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  17. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +4 -4
  18. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  19. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -1
  20. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +8 -0
  21. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  22. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  23. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +4 -4
  24. package/node_modules/@llblab/pi-telegram/docs/outbound.md +1 -1
  25. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
  26. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  27. package/node_modules/@llblab/pi-telegram/lib/config.ts +6 -4
  28. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -1
  29. package/node_modules/@llblab/pi-telegram/lib/preview.ts +20 -0
  30. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  31. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.9.1 - 2026-09-11
6
+
7
+ - `Telegram Draft Cadence`: Advances the exact Telegram pin to `0.45.4`, so each preview segment holds its first frame for one full two-second interval from its first visible text; the opening draft is an accumulated passage instead of a single streamed word, later frames keep the trailing cadence, and rollover after tool work reopens the window while sealing or final publication still cancels pending timers. The package set, resource inventory, and explicit load order remain unchanged.
8
+ - `State Flow Preserved Answers`: Advances the exact State Flow pin to `0.8.1`, preserving the first terminal draft as the runtime-owned response even without `final:true` and bounding same-run fallback turns to eliciting the final patch without replacing the visible answer. The package set, resource inventory, and explicit load order remain unchanged.
9
+
10
+ ## 0.9.0 - 2026-09-11
11
+
12
+ - `State Flow Atomic Cohorts`: Advances the exact State Flow pin to `0.8.0`, replacing the single-scope PATCH/UNCHANGED protocol with one `patch_state({global?, cwd?, session?, final?})` primitive whose supplied scopes commit as one atomic transition, turning `final:true` into an ephemeral per-iteration terminal-eligibility latch with bounded three-attempt steering, and retaining exact rejected-call arguments in opt-in diagnostics while preserving the package set, resource inventory, and explicit load order.
13
+
5
14
  ## 0.8.1 - 2026-09-11
6
15
 
7
16
  - `Telegram Schema Hotfix`: Advances the exact Telegram pin to `0.45.2`, whose bounded-depth `telegram_bind` argument schema is accepted by OpenAI and Gemini tool APIs again, restoring working agent requests while the bridge is connected. The package set, resource inventory, and explicit load order remain unchanged.
package/README.md CHANGED
@@ -14,8 +14,8 @@ 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.9.4` | Compact Codex/Spark subscription-limit status |
16
16
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.7.5` | Visible continuation scheduling and bounded worker Skills |
17
- | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.7.0` | Explicit scoped state resolution and bounded session continuation |
18
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.2` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
17
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.8.1` | Atomic scoped state cohorts with final-eligibility resolution |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.4` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
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.
@@ -23,16 +23,16 @@
23
23
  - At instance initialization resolve the repository, CWD key, and session key; load each scope's anchored checkpoint and tail, materialize at the selected temporal boundary, then overlay `global → cwd → session`. Install cached view and publication basis atomically only after successful restoration/initialization; unavailable publication is an error, not a semantic no-op. Preserve the selected revision across transient restore failure so an 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.
24
24
  - Bind every loaded scope and active Pi branch/checkpoint to the corresponding State Flow Git revision. Resume and tree restoration must recover branch-correct runtime config and semantic layers from that revision without blindly importing Git `HEAD`; reading an older revision must use object-level Git reads and never reset or check out the shared repository worktree.
25
25
  - Every accepted semantic change, including session-only and response-only changes, atomically updates affected checkpoint/tail pairs and temporal metadata and creates one immediate local Git commit when Git is available; each commit stages the complete non-ignored worktree delta (`git add -A` semantics, including manual deletions) before overlaying exact prepared State Flow outputs, respects `.gitignore`, keeps State Flow-owned active files under compare-and-swap protection, and synchronizes the caller-visible index to the committed tree. `remotePublication` is branch runtime policy: `turn-end` queues only the newest accepted target for asynchronous non-interactive push, `off` remains local-only, and `transition` preserves synchronous legacy behavior. Queue state is operational metadata beneath the Git common directory, not semantic history; use exact targets, destination identity, symlink-safe atomic CAS, descendant-only coalescing, cross-process worker leases, restart recovery, and truthful failure diagnostics. Remote failure never rolls back accepted state or regenerates an answer. A repository with no remote is intentionally local-only; only Git executable `ENOENT` authorizes file-only mode.
26
- - Register `patch_state` as the sole model-authored semantic mutation protocol. It accepts exactly one validated `session`, `cwd`, or `global` semantic patch, or `{unchanged:true}` as a mutually exclusive explicit no-change judgement. A real patch immediately folds/publishes, records lineage and the durable revision, and rematerializes `state[0]`; unchanged changes neither state, temporal history, identity, nor Git. Never accept model-authored `response`.
27
- - Treat a real `patch_state` as a strict inference barrier. Give it sequential execution mode, inspect Pi's synchronized current assistant message during tool preflight, require exactly one `patch_state` call in that response, and block every sibling tool call before execution. Every enabled turn begins with resolution pending. If the model attempts terminal prose before a successful patch or unchanged acknowledgement, replace that draft with a hidden same-run instruction; the draft is not `response`, a subsequent patch is legal, and only the later accepted ordinary answer is reconciled.
28
- - Reconcile `response` only from the actually accepted ordinary assistant answer at `turn_end`; it remains runtime-owned. State Flow has no terminal HTML-comment mutation protocol and does not parse generic service comments. A draft intercepted for pending turn resolution is not final, must never reach `response`, and may be followed by `patch_state` in the same unfinished run. Other extensions retain ownership of their own comments and output handling.
26
+ - Register `patch_state` as the sole model-authored semantic mutation protocol. It accepts optional fixed `global`, `cwd`, and `session` semantic patches plus optional `final:true`; require at least one scope or `final:true`, reject unknown fields, `final:false`, empty supplied scopes, material no-ops, and the retired `{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`.
27
+ - Treat every semantic `patch_state` as a strict inference barrier. Give it sequential execution mode, inspect Pi's synchronized current assistant message during tool preflight, require exactly one `patch_state` call in that response, and block every sibling tool call before execution. Every enabled iteration begins terminal-ineligible; only a successful call containing `final:true` latches eligibility for the next accepted `turn_end`, without stopping later reasoning, tools, or patches. If a terminal draft ends before eligibility, preserve it as the runtime-owned response at that `turn_end` and start at most two same-run fallback turns whose only purpose is the `final:true` patch; an eligible draft whose final validation fails after a later acquisition follows the same path. Fallback turns never enter `response`, and their only instruction is to apply `patch_state` with any durable changes and `final:true`, or `{final:true}` alone. A successful fallback closes resolution with the preserved answer intact; two failed fallbacks close the iteration with the preserved answer and current state plus one bounded warning and a finalization diagnostic. Failed patch calls do not consume fallback turns. A subsequent legal patch remains possible, and only an accepted ordinary answer is reconciled.
28
+ - Reconcile `response` only from the actually accepted ordinary assistant answer at `turn_end`; it remains runtime-owned. State Flow has no terminal HTML-comment mutation protocol and does not parse generic service comments. The first terminal draft that cannot be reconciled is preserved as the iteration response rather than discarded; fallback turns during pending resolution never reach `response`. Other extensions retain ownership of their own comments and output handling.
29
29
  - Treat terminal state as a decision-relevant handoff, not narration: retain source-addressed reusable operational knowledge in `artifacts`; compile stable requirements, confirmed decisions, rejected approaches, and interface commitments into `contract`; retain observations, validation, failures, current domain state, unresolved work, interaction consequences, and exact continuation in `working`. Preserve relevant completed prerequisites and verified outcomes while removing obsolete progress narration; reconcile only information affected by the run and relevant existing commitments, not every scope or repository surface.
30
30
  - Preserve active constraints, unresolved questions, consequential negative results, and the next discriminating check before compression. Distinguish observations, user requirements, assistant decisions, and hypotheses; do not promote assistant conclusions to user requirements. Retain useful source locators and validity conditions for consequential facts without mandatory per-value metadata. Keep rejection reasons and reconsideration conditions. Reconcile contradictions through evidence or user clarification instead of silently overwriting established constraints or observations; retain unresolved conflicts and decision-relevant hypotheses as uncertain. These are protocol obligations, not deterministic semantic validation gates.
31
31
  - Treat `working` as last observations, not a live workspace. Revalidate volatile facts before consequential actions; after interruption or branch navigation inspect relevant external effects before repeating operations. Failed state commits and restored memory do not undo tool effects. Missing evidence proves neither success nor absence of effects: retain uncertainty and the next check. Keep revalidation targeted, without action ledgers or runtime freshness/rollback guarantees.
32
- - Treat each successful `SKILL.md` read as CWD artifact acquisition using the finalized tool-execution arguments after mutable interception: require a non-empty compiler output in the next CWD `patch_state` call under `patch.artifacts[exactReadPath]` with `description`, `kind: "skill"`, and a flexible non-empty `compilation` object; hash the executed source bytes and record runtime-owned `sourceHash` plus `skill-artifact-v1` `compilerRevision` in the CWD provenance registry; reject missing, unhashable, malformed, or forged freshness data; replace the complete prior Skill artifact and its provenance entry on refresh so obsolete evidence cannot survive. `contract.compiled_skills` is retired and rejected; migrate useful legacy entries into artifacts while preserving behavior and marking fallback hashes unverified when the source is unavailable.
32
+ - Treat each successful `SKILL.md` read as CWD artifact acquisition using the finalized tool-execution arguments after mutable interception: require a non-empty compiler output in the next `patch_state` call under `cwd.artifacts[exactReadPath]` with `description`, `kind: "skill"`, and a flexible non-empty `compilation` object; hash the executed source bytes and record runtime-owned `sourceHash` plus `skill-artifact-v1` `compilerRevision` in the CWD provenance registry; reject missing, unhashable, malformed, or forged freshness data; replace the complete prior Skill artifact and its provenance entry on refresh so obsolete evidence cannot survive. `contract.compiled_skills` is retired and rejected; migrate useful legacy entries into artifacts while preserving behavior and marking fallback hashes unverified when the source is unavailable.
33
33
  - Make the model audit and optimize complete state at every handoff: reorganize inefficient structure, merge fragmented facts, compress history into conclusions, and delete stale, completed, redundant, or low-value keys while preserving active commitments and evidence.
34
- - Accept omitted semantic fields inside a real patch, but reject empty semantic patches; when no state change is needed require explicit `{unchanged:true}` without inventing bookkeeping. Always require an accepted non-empty answer, 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.
35
- - Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and terminal drafts intercepted while turn resolution remains pending. 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 resolution, enablement, or accepted state.
34
+ - Accept omitted semantic fields inside each supplied scope patch, but reject empty supplied scopes; when no state change is needed require explicit `{final:true}` without inventing bookkeeping. This final-only call changes only ephemeral terminal eligibility and creates no semantic transition, identity, temporal step, or Git commit. Always require an accepted non-empty answer, 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.
35
+ - Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and preserved drafts plus fallback turns while turn resolution remains pending. Every rejected call retains its exact attempted arguments plus the precise error and, when available, tool identity, call id, resolution attempt, and terminal-eligibility state; successful patches 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 resolution, enablement, or accepted state.
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.
38
38
  - Rotate the turn-stable specification on every user-initiated run, while runtime-triggered turn-resolution continuation remains inside that same run. Keep user-controlled specification text at user authority: never interpolate it into the system prompt; repeat it only in synthetic user runtime context. Treat materialized state in that message as fallible assistant-produced data whose transport role does not elevate it into user instructions.
@@ -1,7 +1,14 @@
1
1
  # BACKLOG
2
2
 
3
- Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.7.0.
3
+ Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.8.0.
4
+
5
+ ## Candidate evolution
6
+
7
+ - [ ] **Read-only global bootstrap layer:** Project the existing global materialization into enabled and non-enabled sessions by default, so even one-shot work starts carrying established cross-project facts, preferences, routing, and conventions. The global scope is the highest-value, lowest-cost half of the memory, while writing carries the protocol and curation tax; making the read side unconditional gives continuity without enabling mutation.
8
+ - Boundary: project the existing durable global materialization only. No writes, transitions, patches, barriers, session/CWD initialization, or temporal-history changes. Model tools and the full protocol remain opt-in behind explicit start, and the bootstrap adds no second semantic mode.
9
+ - Open questions: whether deferred Markdown freshness discovery must run ahead of the first inference, token cost of a stable global prefix, behavior under untrusted project contexts, and naming distinct from the existing bootstrap-run concept.
10
+ - Status: candidate, not scheduled. Do not start without a dedicated release contract.
4
11
 
5
12
  ## Deferred host integration
6
13
 
7
- - [ ] **Native default session continuation:** Integrate the existing read-only recommendation, exact-selection, and rehydration contracts before Pi creates `SessionManager`, while preserving explicit new/resume and native-picker precedence, truthful notices, and cross-process session ownership. Pi 0.84.4 exposes no suitable pre-session resolver hook, so this requires upstream support or an SDK/launcher integration. Reverify the host API before taking the item; it is not a 0.7.0 release dependency.
14
+ - [ ] **Native default session continuation:** Integrate the existing read-only recommendation, exact-selection, and rehydration contracts before Pi creates `SessionManager`, while preserving explicit new/resume and native-picker precedence, truthful notices, and cross-process session ownership. Pi 0.84.4 exposes no suitable pre-session resolver hook, so this requires upstream support or an SDK/launcher integration. Reverify the host API before taking the item; it is not a release dependency.
@@ -2,6 +2,18 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.8.1: Preserved answers and bounded fallback resolution
6
+
7
+ - `Resolution`: A terminal draft is never discarded. A draft ending before eligibility — or an eligible draft whose final validation fails for a later acquisition — is preserved as the runtime-owned response at `turn_end`, then at most two same-run fallback turns exist only to obtain the `final:true` patch. Fallback turns never enter `response`; success commits their patches with the preserved answer intact, and exhaustion closes the iteration with the preserved answer, current state, one bounded warning, and a finalization diagnostic.
8
+
9
+ ## 0.8.0: Atomic cohorts and final eligibility
10
+
11
+ - `Protocol`: Replaced the single-scope PATCH and UNCHANGED forms with one canonical `patch_state({global?, cwd?, session?, final?})` call. Supplied scopes validate against one causal basis and publish as one atomic transition with a single identity, temporal boundary, durable cohort, and inference barrier; unknown fields, `final:false`, empty scopes, and material no-ops fail closed.
12
+ - `Eligibility`: `final:true` is now an ephemeral per-iteration latch rather than a resolution form. It permits a later `turn_end` without stopping reasoning, tools, or further patches; eligibility survives later activity and repeated final calls, resets only with the next enabled iteration, and `{final:true}` alone creates no semantic transition, identity, step, or Git commit.
13
+ - `Resolution`: An ineligible terminal draft is discarded and steered for at most three attempted completions. The first two attempts emit hidden same-run instructions; the third reports one bounded error while preserving committed state, enablement, and runtime-owned response. Failed and host-rejected patch calls consume no attempts, and only the accepted post-eligibility answer becomes `response`.
14
+ - `Diagnostics`: Rejected `patch_state` calls now retain their exact attempted arguments alongside the precise error, tool identity, call id, resolution-attempt count, and terminal-eligibility state; publication conflicts use the same record and successful patches are never logged.
15
+ - `Compatibility`: Persisted checkpoints, tails, provenance registries, scope layout, and Git/file durability are unchanged and need no migration; the removed model-facing grammar is not accepted as a compatibility alias.
16
+
5
17
  ## 0.7.0: Explicit resolution and leaner runtime
6
18
 
7
19
  - `Protocol`: Made `patch_state` the sole model-authored semantic mutation path. Every enabled turn now requires a successful resolution call, each exactly PATCH `{scope, patch}` or UNCHANGED `{"unchanged":true}`; unresolved prose is discarded and steered within the same run, failed calls remain unresolved, and only the later accepted ordinary answer becomes runtime-owned `response`.
@@ -45,7 +45,7 @@ Optional `~/.pi/agent/state-flow.json` (or `state-flow.json` beneath `PI_CODING_
45
45
 
46
46
  - `directory`: State storage location; omitted defaults to `state-flow/` beneath Pi's agent directory. Absolute paths, `~`/`~/` and relative paths are supported; relative paths resolve from the configuration directory, not the project CWD.
47
47
  - `autoStart`: Set to `true` to enable State Flow automatically for genuinely new sessions, including fresh CWDs. Omitted defaults to `false`: manual activation, even when previous CWD materialization exists.
48
- - `logging`: Set to `true` to append JSONL diagnostics for rejected `patch_state` calls and unresolved terminal drafts to `tmp/state-flow/logs.jsonl` beneath Pi's agent directory; omitted or `false` writes nothing. Exact text blocks are retained only when useful, while reasoning bodies are excluded. The file stays local unless you move it, deleting or rotating it is operator-owned, and logging never enters semantic state, metadata, checkpoints, or the state repository. If a custom state repository contains the diagnostic path, capture fails closed rather than committing the log. A write failure changes nothing except one local warning.
48
+ - `logging`: Set to `true` to append JSONL diagnostics for rejected `patch_state` calls and unresolved terminal drafts to `tmp/state-flow/logs.jsonl` beneath Pi's agent directory; omitted or `false` writes nothing. Rejected calls retain their exact attempted arguments, the precise error, and, when available, the tool identity, call id, resolution attempt, and terminal-eligibility state; successful patches are never logged. Exact text blocks are retained only when useful, while reasoning bodies are excluded. The file stays local unless you move it, deleting or rotating it is operator-owned, and logging never enters semantic state, metadata, checkpoints, or the state repository. If a custom state repository contains the diagnostic path, capture fails closed rather than committing the log. A write failure changes nothing except one local warning.
49
49
  - `remotePublication`: For new runtimes, `turn-end` (default) queues one newest target after local acceptance and pushes asynchronously; `off` keeps commits local; `transition` preserves legacy synchronous push behavior. A resumed branch keeps its persisted policy.
50
50
 
51
51
  Configuration is read at extension load; edit it and use `/reload` (or restart Pi) before opening a new session. Existing branches retain their stored enablement on resume/tree navigation. `/state-flow-stop` does not rewrite this file or disable automatic mode for later new sessions. Invalid JSON, unknown keys or invalid values fail extension loading rather than silently choosing another directory. A missing file uses defaults and is not generated automatically.
@@ -173,7 +173,7 @@ Session `config.json` owns runtime behavior such as `enabled`. Session `meta.jso
173
173
 
174
174
  Git-backed Pi checkpoint data is exactly `{"revision":"<full commit hash>"}`; file-only checkpoints use `{"revision":"file:<64 lowercase hex>"}` for an exact current-cohort reference. `{"disabled":true}` denotes an ordinary disabled branch without durable runtime. Configuration, counters, specification, lineage, and semantic state are not copied into these entries. Older config/meta and state-bearing checkpoints remain read-only compatibility input. Invalid immutable pointer targets fall back through the selected branch; transient publication-lock failure instead retains the selected revision for retry. Explicit start on a pre-runtime branch establishes an empty session origin, even if a later branch already created same-session files. Shared streams remain unchanged and the later session state remains recoverable from its Git revision; it is not imported into the new origin. Legacy checkpoint syntax and immutable targets undergo the same fallback checks, while an unanchored legacy semantic payload cannot be silently reduced to an ordinary-disabled marker. Stop on an unproven branch fails visibly instead of turning failed recovery into permission to replace existing runtime; select a valid checkpoint before retrying.
175
175
 
176
- Every materially effective semantic transition, including response-only and session-only changes, immediately publishes affected checkpoint/tail pairs and necessary lineage metadata. In Git mode each accepted cohort gets its own consistent local commit; `turn-end` queues the newest target for asynchronous replication, `off` stays local, and compatibility `transition` attempts the push synchronously. Intermediate patches are not batched until turn end. With no remote configured, Git persistence is intentionally local-only, not a pending publication error. With Git absent, persistence remains file-only and does not report fake commits or pending pushes. One accepted cohort can share one identity and commit across its affected scopes; model-authored multi-scope work uses sequential one-scope barriers. Config-only changes may persist runtime state but never invent semantic transitions.
176
+ Every materially effective semantic transition, including response-only and session-only changes, immediately publishes affected checkpoint/tail pairs and necessary lineage metadata. In Git mode each accepted cohort gets its own consistent local commit; `turn-end` queues the newest target for asynchronous replication, `off` stays local, and compatibility `transition` attempts the push synchronously. Intermediate patches are not batched until turn end. With no remote configured, Git persistence is intentionally local-only, not a pending publication error. With Git absent, persistence remains file-only and does not report fake commits or pending pushes. One model-authored call may update global, CWD, and session together; every supplied scope shares one validation basis, transition identity, temporal boundary, and durable commit. Config-only changes may persist runtime state but never invent semantic transitions.
177
177
 
178
178
  Writes use regular non-symlink owned files, same-directory atomic rename, isolated Git indexes, and compare-and-swap against the reconciled base. Cooperating State Flow Git publishers hold a common-Git-directory publication lock through capture, commit, and rollback. Raw bytes own file identity and recovery; non-UTF-8 explanatory journals are not reconstructed from decoded strings. Per-file publication checks catch changed bases. Both semantic and migration publishers retain exact prepared-output receipts: Git commits those bytes, not later worktree contents, and rollback requires the receipt to preserve detected external changes rather than replacing them. Low-level file helpers require caller exclusion; this is not kernel-atomic multi-file CAS against writers ignoring the protocol. An existing publication lock fails before writes and is not silently stolen; reconcile its active or interrupted owner before retrying. Scope keys mirror Pi's CWD/session directory names and retain separately verified canonical identity provenance inside owned state. Conflicts fail rather than silently selecting another scope or auto-merging semantic state. Every Git commit stages the complete non-ignored worktree delta — tracked edits, new files, and manual deletions, respecting `.gitignore` — then overlays the exact prepared State Flow outputs and synchronizes the caller-visible index to the committed tree, so no artificial staged or unstaged entries remain; unrelated user worktree changes may therefore be committed while ignored files stay untouched. With distinct roots, the Knowledge repository's files, index, HEAD, locks, and remote are not used for state publication; keep custom stores separate from sources to retain this isolation.
179
179
 
@@ -198,7 +198,7 @@ An existing current `state.json` becomes the initial checkpoint state, anchored
198
198
  Use `patch_state` only for established future-relevant state whose delayed persistence risks meaningful loss, never scratchpad, narration, routine progress, or speculative churn:
199
199
 
200
200
  ```json
201
- {"scope":"session","patch":{"working":{"verified":"result"}}}
201
+ {"session":{"working":{"verified":"result"}},"final":true}
202
202
  ```
203
203
 
204
204
  ```text
@@ -211,15 +211,17 @@ LLM(state[0] = Sn)
211
211
 
212
212
  The barrier executes alone. State Flow opts into sequential preflight, inspects Pi's synchronized assistant response, requires exactly one `patch_state` call, and blocks every sibling tool before execution. The next inference sees one rebuilt current-state projection; it must not reconstruct the update itself. Tool calls/results remain current-run causality, not duplicate full-state messages.
213
213
 
214
- `patch_state` is the only model-authored semantic mutation mechanism. Every enabled turn begins with resolution pending and needs at least one successful call before a visible final answer. Each invocation has exactly one of two exclusive forms: `patch_state({ scope, patch })` or `patch_state({ unchanged: true })`. The latter records an explicit no-change judgment only: it creates no patch, transition identity, temporal step, or Git commit.
214
+ `patch_state` is the only model-authored semantic mutation mechanism. Each invocation may supply any combination of fixed `global`, `cwd`, and `session` patches plus optional `final:true`. Supplied scopes are validated and published atomically; at least one scope or `final:true` is required. The retired `{scope, patch}` and `unchanged` forms are rejected.
215
215
 
216
216
  ```text
217
- patch_state(patch or unchanged) → accepted state → visible answer → runtime records response
217
+ patch_state({global?, cwd?, session?}) → atomic accepted state → further work
218
+ patch_state({...?, final:true}) → atomic accepted state + terminal eligibility → later turn_end
219
+ patch_state({final:true}) → terminal eligibility only, with no semantic transition
218
220
  ```
219
221
 
220
- An unresolved visible draft is not accepted as a response. State Flow replaces it with a hidden same-run resolution instruction, accepts a successful `patch_state`, then permits the final ordinary answer. This continuation remains in the same unfinished assistant run; only its accepted final answer reaches `response`. Aborted, length-limited, and provider-error endings never become the accepted response.
222
+ Every enabled iteration begins terminal-ineligible. `final:true` latches eligibility for the next accepted `turn_end`; it does not terminate the turn, stop tools or reasoning, freeze state, or prevent later `patch_state` calls. A terminal draft that ends before eligibility, or an eligible draft whose final validation fails after a later acquisition, is preserved: it is reconciled as the runtime-owned `response` at that `turn_end` instead of being discarded, and State Flow starts at most two same-run fallback turns whose only purpose is to obtain the `final:true` patch. Fallback turns are never the response. A successful `final:true` during a fallback commits its patches and closes resolution with the preserved answer intact; when both fallbacks pass without it, the iteration closes with the preserved answer and current state, plus one bounded warning and a finalization diagnostic. Failed patch calls do not consume fallback turns. Only an accepted answer reaches `response`; aborted, length-limited, and provider-error endings never do.
221
223
 
222
- A real patch remains an inference barrier. It is validated, durably accepted, and rematerialized before further reasoning. Multiple scopes require explicit sequential calls. Empty and materially no-op patches are rejected; they are not an unchanged acknowledgement. `response` is never accepted from the model. The final answer is ordinary assistant output, not a state transport. State Flow has no terminal `state_flow` HTML-comment protocol and does not parse generic service comments. Historical `state_flow` text is ordinary text. Other extensions retain ownership of their own comments such as `telegram_button`. Never put secrets in state.
224
+ A semantic patch remains an inference barrier. The complete scope cohort is validated, durably accepted, and rematerialized before further reasoning. Empty supplied scopes and materially no-op scope cohorts are rejected; omit unchanged scopes and use `{final:true}` alone when no semantic update is needed. `response` is never accepted from the model. The final answer is ordinary assistant output, not a state transport. State Flow has no terminal `state_flow` HTML-comment protocol and does not parse generic service comments. Historical `state_flow` text is ordinary text. Other extensions retain ownership of their own comments such as `telegram_button`. Never put secrets in state.
223
225
 
224
226
  ## Artifact compilation and acquisition
225
227
 
@@ -259,7 +261,7 @@ Correlation follows finalized mutable `tool_call` arguments with execution-start
259
261
 
260
262
  ### Optional memory curation
261
263
 
262
- The package includes the progressively disclosed `state-flow-memory` Skill for explicit audits, contradiction cleanup, scope narrowing, stale continuation review, and ownership migration. It is not part of routine retention and never runs as background maintenance. The Skill preserves useful uncertainty, confirmed decisions, bounded results, interaction consequences, and completed prerequisites without fixing provisional methods. Its compilation-first sequence writes and separately verifies a destination before source deletion, then inspects the effective overlay; separate single-scope calls are not atomic migration. External promotion cannot delete the only accepted copy without destination identity, content, and revision evidence. State Flow remains the memory owner and global memory remains available throughout.
264
+ The package includes the progressively disclosed `state-flow-memory` Skill for explicit audits, contradiction cleanup, scope narrowing, stale continuation review, and ownership migration. It is not part of routine retention and never runs as background maintenance. The Skill preserves useful uncertainty, confirmed decisions, bounded results, interaction consequences, and completed prerequisites without fixing provisional methods. Its compilation-first sequence writes and separately verifies a destination before source deletion, then inspects the effective overlay; it deliberately preserves that verification boundary even though one `patch_state` call can atomically update multiple scopes. External promotion cannot delete the only accepted copy without destination identity, content, and revision evidence. State Flow remains the memory owner and global memory remains available throughout.
263
265
 
264
266
  External handoffs may use the generic global `working.memory_promotions` map. Each entry requires `status` (`pending`, `accepted`, `failed`, or `unknown`) and a non-empty `owner`; accepted entries additionally require an exact destination `pointer` and `revision`. Other fields remain forward-compatible and destination-specific. This bookkeeping does not replace the retained candidate: failures and uncertainty keep the accepted State Flow body plus exact continuation, while proven acceptance may remove the duplicate body and retain the compact pointer record. `/state-flow-status` reports owner mode, fallback activation, memory-bearing scopes, promotion counts, and bounded pointer/error details; malformed or incompletely evidenced accepted records appear as invalid rather than accepted.
265
267
 
@@ -292,7 +294,7 @@ npm install
292
294
  npm run validate
293
295
  ```
294
296
 
295
- Validation runs TypeScript checks, automated tests, and an import smoke check. Temporal algebra tests compare all available offsets with independently retained test snapshots through dense/sparse repeated folding, shared transitions, deletion overlays, no-ops, and forks. Temporary-repository tests also cover explicit migration, ten committed sparse transitions with folding and all hot offsets, cold Git scope recovery, and historical symlink rejection. Storage-level tests also prove atomic runtime/semantic publication, config-only stop without semantic history, runtime identity and lineage validation, self-reference resolution across unrelated commits, and exact accepted-commit retry. Real Pi tests also exercise configured automatic activation, deferred source discovery, strict draft → resolution → accepted-answer continuation, temporal barriers and next-inference state, lazy runtime offset-one reads, unchanged shared-stream inheritance, old-branch stop/resume, and bounded passive stop/restart projection. The complete twenty-case temporal acceptance audit is documented in the property map; the canonical backlog identifies any subsequent open work.
297
+ Validation runs TypeScript checks, automated tests, and an import smoke check. Temporal algebra tests compare all available offsets with independently retained test snapshots through dense/sparse repeated folding, shared transitions, deletion overlays, no-ops, and forks. Temporary-repository tests also cover explicit migration, ten committed sparse transitions with folding and all hot offsets, cold Git scope recovery, and historical symlink rejection. Storage-level tests also prove atomic runtime/semantic publication, config-only stop without semantic history, runtime identity and lineage validation, self-reference resolution across unrelated commits, and exact accepted-commit retry. Real Pi tests also exercise configured automatic activation, deferred source discovery, strict draft → preserved answer and fallback resolution → accepted-answer continuation, temporal barriers and next-inference state, lazy runtime offset-one reads, unchanged shared-stream inheritance, old-branch stop/resume, and bounded passive stop/restart projection. The complete twenty-case temporal acceptance audit is documented in the property map; the canonical backlog identifies any subsequent open work.
296
298
 
297
299
  - [Canonical open work](BACKLOG.md)
298
300
  - [Delivery history](CHANGELOG.md)
@@ -63,21 +63,21 @@ The checkpoint is an older anchored materialization. The tail contains at most s
63
63
 
64
64
  `state[n]`, `state.global[n]`, `state.cwd[n]` and `state.session[n]` resolve the same nth previous causal boundary. They are not independent per-scope patch counters. Pre-origin history is unavailable rather than empty.
65
65
 
66
- True semantic no-ops and explicit `patch_state({"unchanged":true})` acknowledgements create no identity, commit or history step. A changed accepted response is runtime-owned semantic state and advances history.
66
+ A final-only `patch_state({"final":true})` call changes only ephemeral terminal eligibility and creates no identity, commit, or history step. A changed accepted response is runtime-owned semantic state and advances history.
67
67
 
68
68
  ## Pi lifecycle
69
69
 
70
- `patch_state` is the sole mutation tool. It validates and publishes one scope patch immediately, then acts as an inference barrier. Pi executes no sibling tools from the same assistant response; the next inference sees rematerialized `state[0]`.
70
+ `patch_state` is the sole mutation tool. It validates any supplied global/CWD/session patches against one causal basis and publishes them as one atomic transition, then acts as an inference barrier. Pi executes no sibling tools from the same assistant response; the next inference sees rematerialized `state[0]`.
71
71
 
72
72
  `read_state` reads one cached effective or scoped projection at offsets zero through seven. It never publishes or advances history.
73
73
 
74
- Every enabled assistant turn starts with turn resolution pending. A real `patch_state({scope,patch})` or `patch_state({unchanged:true})` satisfies it; real patches remain immediate one-scope inference barriers, so multi-scope work uses sequential calls. If terminal prose arrives while resolution is pending, State Flow replaces that draft with a hidden same-run instruction. The draft is not a final response, and a following patch is legal; only the later accepted ordinary answer is reconciled into runtime-owned `response` at `turn_end`. State Flow no longer parses `state_flow` or generic HTML comments; historical comments are ordinary text and other extensions retain their own comment handling.
74
+ Every enabled assistant iteration starts terminal-ineligible. Only a successful `patch_state` call containing `final:true` latches eligibility for the next accepted `turn_end`; the call may atomically include global, CWD, and session patches. Eligibility does not stop later reasoning, tools, or patches. If terminal prose arrives before eligibility, State Flow preserves that draft and reconciles it into runtime-owned `response` at `turn_end`, then starts at most two same-run fallback turns whose only purpose is the `final:true` patch. The same path covers an eligible draft whose final validation fails after a later acquisition. Fallback turns never become the response: a successful `final:true` commits its patches and closes resolution with the preserved answer intact, while two failed fallbacks close the iteration with the preserved answer and current state plus one bounded warning and a finalization diagnostic. Failed patch calls do not consume the budget. A following legal patch remains possible, and only an accepted ordinary answer is reconciled into runtime-owned `response` at `turn_end`. State Flow no longer parses `state_flow` or generic HTML comments; historical comments are ordinary text and other extensions retain their own comment handling.
75
75
 
76
76
  ## Lifecycle planes
77
77
 
78
78
  ```text
79
79
  SEMANTIC STATE artifacts + contract + working
80
- TURN RESOLUTION pendingpatch | unchanged satisfied
80
+ TURN ELIGIBILITY falsepatch_state(..., final:true)latched true
81
81
  CONTEXT PROJECTION active State Flow projection | passive post-stop handoff
82
82
  ```
83
83
 
@@ -199,7 +199,7 @@ Unknown keys fail loading. State Flow memory ownership and global availability a
199
199
 
200
200
  `/state-flow-status` reports branch mode, runtime revision, temporal head/history depth, scope keys, patch tails, artifact freshness, memory-bearing scopes, external-promotion summaries, remote policy/queue state, and pending publication. Unavailable materialization is reported as unavailable, never fabricated as empty. Artifact source bodies are not read for status.
201
201
 
202
- Opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` calls and terminal drafts intercepted while turn resolution is pending. Records preserve useful exact text blocks and reduce other blocks to structural identity without duplicating reasoning. They are never semantic state, scope `meta.json`, Pi checkpoints, or repository input. Write failure changes no resolution, enablement, or accepted state and reports at most one bounded local warning.
202
+ Opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` calls and preserved drafts plus fallback turns while turn resolution is pending. A rejected call retains its exact attempted arguments, the precise error, and, when available, tool identity, call id, resolution attempt, and terminal-eligibility state; accepted patches are never logged. Records preserve useful exact text blocks and reduce other blocks to structural identity without duplicating reasoning. They are never semantic state, scope `meta.json`, Pi checkpoints, or repository input. Write failure changes no resolution, enablement, or accepted state and reports at most one bounded local warning.
203
203
 
204
204
  ## Validation boundaries
205
205
 
@@ -17,8 +17,8 @@ The numbering below follows the twenty required tests in the temporal design cor
17
17
  11. **Barrier shifts current to offset 1:** `tests/integration.test.ts` — “real Pi patch_state barriers rematerialize every scope before the next inference” observes the predecessor immediately after a barrier.
18
18
  12. **Next inference sees new current state:** The same real-Pi test inspects actual model-input projections after session, CWD, and global barriers. “real Pi reads prior scoped state lazily after a barrier and rejects offset eight without a transition” adds model-tool access to the predecessor.
19
19
  13. **No automatic old full-state duplication:** `tests/context.test.ts` — “projects only the latest seven compact accepted transitions” rejects full-state records in transition context. The real-Pi barrier test requires exactly one current runtime projection per inference. Explicitly requested history remains ordinary tool-result trajectory, not eager snapshot injection.
20
- 14. **Accepted response changes are transitions:** `tests/resolution.test.ts` verifies that only the ordinary answer after successful patch/unchanged resolution enters runtime-owned `response`; intercepted drafts never do.
21
- 15. **Explicit unchanged creates no fake history:** `tests/resolution.test.ts` checks that `{unchanged:true}` satisfies turn resolution without changing semantic lineage, tails, step, or Git. A later changed accepted response remains a runtime-owned transition.
20
+ 14. **Accepted response changes are transitions:** `tests/extension.test.ts` “the preserved answer stands after two fallback turns without final:true” and `tests/skills.test.ts` — “a late Skill obligation that outlives the fallback budget keeps the preserved answer verify that a primary draft without a reconciled `final:true` is preserved as the runtime-owned response, fallback turns never enter `response`, and an exhausted fallback budget keeps the preserved answer with its current state. `tests/integration.test.ts` — “real Pi keeps the preserved answer after the fallback budget” repeats the closure through the native Pi turn loop.
21
+ 15. **Final-only eligibility creates no fake history:** `tests/extension.test.ts` — “a primary draft without eligibility is preserved as the response and final-only fallback resolution keeps it” checks that `{final:true}` latches turn eligibility without changing semantic lineage, tails, step, or Git. A later changed accepted response remains a runtime-owned transition.
22
22
  16. **Offset eight is outside hot history:** `tests/temporal.test.ts` — “hot range and unproven pre-migration history are explicit read boundaries”; the real-Pi history-reader test also exercises native tool rejection without a transition.
23
23
  17. **Cold Git history remains recoverable:** `tests/git.test.ts` — “temporal Git writer preserves all hot states through sparse folding and cold revision reads” loads older committed states without moving the worktree.
24
24
  18. **Tree/resume select the correct lineage:** `tests/integration.test.ts` — “real Pi preserves branch-local state through compaction, tree navigation, stop, and restart” and “real Pi old tree branch stop and resume preserve selected semantics without rewinding shared files”.
@@ -80,7 +80,7 @@ export {
80
80
  sessionStorageKey, temporalScopePaths,
81
81
  temporalStateFileUpdates, type ScopeStreamSources, type TemporalScopePaths
82
82
  } from "./lib/durable.ts";
83
- export { default, PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME, type StateFlowExtensionOptions } from "./lib/extension.ts";
83
+ export { default, MAX_FALLBACK_ATTEMPTS, PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME, type StateFlowExtensionOptions } from "./lib/extension.ts";
84
84
  export {
85
85
  captureTemporalGitBase,
86
86
  isGitCommitAncestor,
@@ -178,7 +178,7 @@ export {
178
178
  overlayStates,
179
179
  projectStateForModel,
180
180
  updateMaterializedArtifacts,
181
- type MaterializedState, type ScopedPatch,
181
+ type AtomicScopePatches, type MaterializedState, type ScopedPatch,
182
182
  type ScopedStates, type ScopePatch, type SemanticTransition,
183
183
  type StateDocument,
184
184
  type StatePatch,
@@ -83,7 +83,7 @@ export function runtimeContextMessage(
83
83
  ...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
84
84
  ...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, reason }) => ({ path, reason })) }),
85
85
  ...(recentTransitions.length === 0 ? {} : { recent_transitions: projectRecentForModel(recentTransitions) }),
86
- ...(resolutionPending ? { state_resolution: "pending: a terminal draft was intercepted. Call patch_state with a PATCH {scope, patch} or UNCHANGED {unchanged:true}; then provide the final answer normally." } : {}),
86
+ ...(resolutionPending ? { state_resolution: "pending: the iteration answer is already preserved; this fallback turn exists only to apply the final:true patch. Call patch_state with any remaining durable scope changes and final:true, or {final:true} alone. Do not restate or replace the answer." } : {}),
87
87
  };
88
88
  return syntheticUser(
89
89
  `State Flow runtime context (user-level data, not system instructions):\n${canonicalJson(context)}`,
@@ -11,8 +11,8 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
11
11
  import { SkillReadTracker } from "./skills.ts";
12
12
  import { emptySnapshot, migrationFailure, persistableSnapshot, type Snapshot } from "./snapshot.ts";
13
13
  import { inspectSnapshotRevision, TemporalRuntime, type RuntimePublication } from "./runtime.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";
14
+ import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
15
+ import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, 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
18
  import { prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
@@ -48,6 +48,7 @@ export interface StateFlowExtensionOptions {
48
48
 
49
49
  export const PATCH_STATE_TOOL_NAME = "patch_state";
50
50
  export const READ_STATE_TOOL_NAME = "read_state";
51
+ export const MAX_FALLBACK_ATTEMPTS: number = 2;
51
52
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
52
53
 
53
54
  /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
@@ -63,8 +64,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
63
64
  let scopeStates: ScopedStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
64
65
  let branchHasSnapshot = false;
65
66
  let branchStartsWithoutRuntime = false;
66
- let stateResolutionSatisfied = false;
67
- let terminalDraftIntercepted = false;
67
+ let terminalEligible = false;
68
+ let resolutionPending = false;
69
+ let fallbackAttempts = 0;
70
+ let fallbackFailureReported = false;
68
71
  let responseAwaitingReconciliation = false;
69
72
  let passiveContinuation: PassiveContinuation | undefined;
70
73
  let bootstrapContinuation: PassiveContinuation | undefined;
@@ -120,8 +123,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
120
123
  }
121
124
 
122
125
  function clearRunTransient(): void {
123
- stateResolutionSatisfied = false;
124
- terminalDraftIntercepted = false;
126
+ terminalEligible = false;
127
+ resolutionPending = false;
128
+ fallbackAttempts = 0;
129
+ fallbackFailureReported = false;
125
130
  responseAwaitingReconciliation = false;
126
131
  runAnchorTimestamp = undefined;
127
132
  skillReads.clear();
@@ -170,9 +175,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
170
175
  artifactInvalidations = structuredClone(plan.requiresCompilation);
171
176
  if (plan.removed.length > 0) {
172
177
  const removals = Object.fromEntries(plan.removed.map((path) => [path, null]));
173
- const stage = stageScopedPatch(
178
+ const stage = stageAtomicScopePatches(
174
179
  scopeStates,
175
- { scope: "global", patch: { artifacts: removals } },
180
+ { global: { artifacts: removals } },
176
181
  [],
177
182
  runtime!.causalBasis(),
178
183
  );
@@ -268,10 +273,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
268
273
  }, runtime!.causalBasis(), { finalizeRun });
269
274
  if (!committed) return false;
270
275
  installScopeStates();
271
- artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
272
- artifactReads.setCandidates(artifactInvalidations);
273
- skillReads.clear();
274
- artifactReads.clear();
276
+ // A preserved primary response commits mid-run; pending acquisition obligations must
277
+ // still block final eligibility until the fallback turns resolve or expire.
278
+ if (!(finalizeRun && resolutionPending)) {
279
+ artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
280
+ artifactReads.setCandidates(artifactInvalidations);
281
+ skillReads.clear();
282
+ artifactReads.clear();
283
+ }
275
284
  persist();
276
285
  return true;
277
286
  }
@@ -537,7 +546,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
537
546
  updateUi(ctx);
538
547
  }
539
548
 
540
- function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, content?: unknown): void {
549
+ interface DiagnosticExtras {
550
+ content?: unknown;
551
+ input?: unknown;
552
+ tool?: string;
553
+ toolCallId?: string;
554
+ resolutionAttempt?: number;
555
+ terminalEligible?: boolean;
556
+ }
557
+
558
+ function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, extras: DiagnosticExtras = {}): void {
541
559
  if (!config.logging) return;
542
560
  try {
543
561
  const path = stateFlowLogPath(agentDir);
@@ -551,7 +569,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
551
569
  cwd: resolve(ctx.cwd),
552
570
  category,
553
571
  error,
554
- ...(content === undefined ? {} : { content: projectDiagnosticContent(content) }),
572
+ ...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
573
+ ...(extras.input === undefined ? {} : { input: extras.input }),
574
+ ...(extras.tool === undefined ? {} : { tool: extras.tool }),
575
+ ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
576
+ ...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
577
+ ...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
555
578
  });
556
579
  } catch (failure) {
557
580
  if (loggingWarningReported) return;
@@ -560,15 +583,77 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
560
583
  }
561
584
  }
562
585
 
563
- function continueForResolution(): void {
564
- terminalDraftIntercepted = true;
586
+ function continueFallbackResolution(lastWarning: boolean): void {
587
+ resolutionPending = true;
565
588
  pi.sendMessage({
566
589
  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.",
590
+ content: lastWarning
591
+ ? "This is the last State Flow fallback turn. The iteration's answer is preserved and will not change. Apply the final:true patch now: call patch_state with any remaining durable scope changes and final:true, or with {\"final\":true} when nothing remains. Do not restate the answer."
592
+ : "The iteration's answer is preserved as the final response; later turns cannot replace it. Apply the final:true patch: call patch_state with any durable scope changes from this iteration (including required artifact or Skill compilation) and final:true, or with {\"final\":true} when nothing remains to persist. Do not restate the answer.",
568
593
  display: false,
569
594
  }, { deliverAs: "steer", triggerTurn: true });
570
595
  }
571
596
 
597
+ /** Preserve the primary answer as this iteration's response and steer bounded fallback turns whose only purpose is the final:true patch. */
598
+ function beginFallbackResolution(ctx: ExtensionContext, message: { content?: unknown }, reason: string): void {
599
+ responseAwaitingReconciliation = true;
600
+ fallbackAttempts = 0;
601
+ fallbackFailureReported = false;
602
+ recordDiagnostic(`Preserved the terminal draft as the iteration response; fallback resolution started: ${reason}`, "terminal-pending", ctx, {
603
+ content: message.content,
604
+ resolutionAttempt: 0,
605
+ terminalEligible,
606
+ });
607
+ continueFallbackResolution(MAX_FALLBACK_ATTEMPTS === 1);
608
+ }
609
+
610
+ /** Fallback turns never become the response; they exist only to supply the final:true patch. */
611
+ function resolveFallbackTurn(ctx: ExtensionContext, message: { content?: unknown }): any {
612
+ let resolved = terminalEligible;
613
+ if (resolved) {
614
+ try {
615
+ validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
616
+ } catch (error) {
617
+ resolved = false;
618
+ recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, {
619
+ content: message.content,
620
+ resolutionAttempt: fallbackAttempts,
621
+ terminalEligible,
622
+ });
623
+ }
624
+ }
625
+ if (resolved) {
626
+ resolutionPending = false;
627
+ recordDiagnostic(`Fallback resolution obtained final:true after ${fallbackAttempts} fallback turn${fallbackAttempts === 1 ? "" : "s"}; the preserved answer stands`, "finalization", ctx, {
628
+ content: message.content,
629
+ resolutionAttempt: fallbackAttempts,
630
+ terminalEligible,
631
+ });
632
+ return { message: { ...message, role: "assistant" as const, content: [] } };
633
+ }
634
+ fallbackAttempts = Math.min(MAX_FALLBACK_ATTEMPTS, fallbackAttempts + 1);
635
+ if (fallbackAttempts < MAX_FALLBACK_ATTEMPTS) {
636
+ recordDiagnostic(`Fallback turn ended without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS})`, "terminal-pending", ctx, {
637
+ content: message.content,
638
+ resolutionAttempt: fallbackAttempts,
639
+ terminalEligible,
640
+ });
641
+ continueFallbackResolution(fallbackAttempts + 1 >= MAX_FALLBACK_ATTEMPTS);
642
+ return { message: { ...message, role: "assistant" as const, content: [] } };
643
+ }
644
+ resolutionPending = false;
645
+ recordDiagnostic(`Fallback resolution exhausted without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS}); the preserved response and current state remain`, "finalization", ctx, {
646
+ content: message.content,
647
+ resolutionAttempt: fallbackAttempts,
648
+ terminalEligible,
649
+ });
650
+ if (!fallbackFailureReported) {
651
+ fallbackFailureReported = true;
652
+ ctx.ui.notify(`State Flow kept the preserved answer; no final:true patch arrived after ${MAX_FALLBACK_ATTEMPTS} fallback turns, so the iteration closed with its current state.`, "warning");
653
+ }
654
+ return { message: { ...message, role: "assistant" as const, content: [] } };
655
+ }
656
+
572
657
  pi.registerTool({
573
658
  name: READ_STATE_TOOL_NAME,
574
659
  label: "Read State",
@@ -599,52 +684,65 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
599
684
  pi.registerTool({
600
685
  name: PATCH_STATE_TOOL_NAME,
601
686
  label: "Patch State",
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}",
687
+ description: "The sole State Flow semantic mutation protocol. Supply any combination of global, cwd, and session patches; all supplied scopes commit atomically. Set final:true when the current iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls. Use {final:true} when no semantic update is needed. This call must be the only State Flow barrier in its assistant response.",
688
+ promptSnippet: "Atomically patch global/cwd/session; final:true permits a later turn_end",
604
689
  promptGuidelines: [
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.",
690
+ "Use patch_state for durable semantic changes. Before a final answer, make the iteration terminal-eligible with final:true, optionally alongside atomic global/cwd/session patches.",
691
+ "Call patch_state alone in an assistant response; after its acknowledgement, further reasoning, tools, and later patch_state calls remain allowed.",
607
692
  ],
608
693
  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.
611
694
  parameters: Type.Object({
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" })),
695
+ global: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional global semantic patch" })),
696
+ cwd: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional project semantic patch" })),
697
+ session: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional session semantic patch" })),
698
+ final: Type.Optional(Type.Boolean({ description: "Set exactly true to permit this iteration to finish at a later turn_end" })),
615
699
  }, { additionalProperties: false }),
616
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
700
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
617
701
  try {
618
702
  if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
619
703
  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 } };
704
+ if (!isObject(params)) throw new Error("patch_state requires an object");
705
+ const allowed = new Set(["global", "cwd", "session", "final"]);
706
+ for (const key of Object.keys(params)) {
707
+ if (!allowed.has(key)) throw new Error(`patch_state does not accept field ${key}`);
628
708
  }
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}");
709
+ if (Object.hasOwn(params, "final") && params.final !== true) throw new Error("patch_state final must be exactly true when supplied");
710
+ const patches: AtomicScopePatches = {};
711
+ for (const scope of ["global", "cwd", "session"] as const) {
712
+ if (!Object.hasOwn(params, scope)) continue;
713
+ const patch = params[scope];
714
+ if (!isObject(patch)) throw new Error(`patch_state ${scope} must be a semantic patch object`);
715
+ if (Object.keys(patch).length === 0) throw new Error(`patch_state ${scope} cannot be empty; omit it when unchanged`);
716
+ patches[scope] = patch;
632
717
  }
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());
718
+ const scopes = Object.keys(patches) as StateScope[];
719
+ if (scopes.length === 0) {
720
+ if (params.final !== true) throw new Error('patch_state requires at least one scope patch or {"final":true}');
721
+ validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
722
+ terminalEligible = true;
723
+ return { content: [{ type: "text", text: "\nState iteration is terminal-eligible." }], details: { final: true } };
724
+ }
725
+ const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
636
726
  const semanticChange = (["global", "cwd", "session"] as const).some((scope) => !sameJson(scopeStates[scope], stage.nextStates[scope]));
637
727
  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');
728
+ if (!semanticChange && !provenanceChange) throw new Error('patch_state scope patches must materially update state or required provenance; omit them and use {"final":true} when unchanged');
639
729
  commitStage(stage, ctx, false);
640
- stateResolutionSatisfied = true;
641
- terminalDraftIntercepted = false;
730
+ if (params.final === true) {
731
+ terminalEligible = true;
732
+ }
642
733
  updateUi(ctx);
643
734
  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 } };
735
+ return { content: [{ type: "text", text: `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}${publication}.` }], details: { scopes, final: params.final === true, step: snapshot.meta.step } };
645
736
  } catch (error) {
646
- stateResolutionSatisfied = false;
647
- recordDiagnostic(error instanceof Error ? error.message : String(error), /concurrently|advanced/.test(String(error)) ? "publication-conflict" : "invalid-patch", ctx);
737
+ let attempted: unknown;
738
+ try { attempted = structuredClone(params); } catch { attempted = undefined; }
739
+ recordDiagnostic(error instanceof Error ? error.message : String(error), /concurrently|advanced/.test(String(error)) ? "publication-conflict" : "invalid-patch", ctx, {
740
+ input: attempted,
741
+ tool: PATCH_STATE_TOOL_NAME,
742
+ toolCallId,
743
+ resolutionAttempt: fallbackAttempts,
744
+ terminalEligible,
745
+ });
648
746
  throw separatedFailure(error);
649
747
  }
650
748
  },
@@ -774,8 +872,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
774
872
  skillReads.clear();
775
873
  artifactReads.clear();
776
874
  if (artifactRefreshPending) refreshArtifactInvalidations(ctx);
777
- stateResolutionSatisfied = false;
778
- terminalDraftIntercepted = false;
875
+ terminalEligible = false;
876
+ resolutionPending = false;
877
+ fallbackAttempts = 0;
878
+ fallbackFailureReported = false;
779
879
  responseAwaitingReconciliation = false;
780
880
  const rotatesRun = snapshot.meta.specification !== undefined;
781
881
  if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
@@ -805,7 +905,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
805
905
  ? passiveContinuationMessages(event.messages as AgentMessage[], bootstrapContinuation)
806
906
  : event.messages as AgentMessage[];
807
907
  const messages = withoutPrivateValidation(sourceMessages);
808
- return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, terminalDraftIntercepted), ...messages] };
908
+ return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending), ...messages] };
809
909
  }
810
910
  const trajectory = currentRunTrajectory(
811
911
  event.messages as AgentMessage[],
@@ -815,7 +915,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
815
915
  runAnchorTimestamp = trajectory.anchorTimestamp;
816
916
  return {
817
917
  messages: [
818
- runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, terminalDraftIntercepted),
918
+ runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending),
819
919
  ...trajectory.messages,
820
920
  ],
821
921
  };
@@ -853,7 +953,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
853
953
 
854
954
  pi.on("tool_execution_end", (event) => {
855
955
  if (!snapshot.config.enabled) return;
856
- if (event.toolName === PATCH_STATE_TOOL_NAME && event.isError) stateResolutionSatisfied = false;
857
956
  skillReads.recordEnd(event.toolCallId, event.toolName, event.isError);
858
957
  artifactReads.recordEnd(event.toolCallId, event.toolName, event.isError);
859
958
  });
@@ -867,25 +966,19 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
867
966
  }
868
967
  if (message.stopReason === "length" || message.stopReason === "error") {
869
968
  responseAwaitingReconciliation = false;
870
- recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, message.content);
969
+ recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, { content: message.content, terminalEligible });
871
970
  return;
872
971
  }
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: [] } };
972
+ if (resolutionPending) return resolveFallbackTurn(ctx, message);
973
+ if (!terminalEligible) {
974
+ beginFallbackResolution(ctx, message, "the terminal draft ended without State Flow eligibility");
975
+ return;
879
976
  }
880
977
  try {
881
- validateUnchangedResolution(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
978
+ validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
882
979
  } catch (error) {
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: [] } };
980
+ beginFallbackResolution(ctx, message, error instanceof Error ? error.message : String(error));
981
+ return;
889
982
  }
890
983
  responseAwaitingReconciliation = true;
891
984
  });
@@ -908,7 +1001,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
908
1001
  ctx.ui.notify(`State Flow could not reconcile the final response: ${error instanceof Error ? error.message : String(error)}`, "error");
909
1002
  } finally {
910
1003
  responseAwaitingReconciliation = false;
911
- terminalDraftIntercepted = false;
912
1004
  }
913
1005
  updateUi(ctx);
914
1006
  });
@@ -18,6 +18,12 @@ export interface StateFlowDiagnosticRecord {
18
18
  category: StateFlowDiagnosticCategory;
19
19
  error: string;
20
20
  content?: StateFlowDiagnosticBlock[];
21
+ /** Rejected tool arguments, captured for reproducible diagnosis. Never reasoning bodies. */
22
+ input?: unknown;
23
+ tool?: string;
24
+ toolCallId?: string;
25
+ resolutionAttempt?: number;
26
+ terminalEligible?: boolean;
21
27
  }
22
28
 
23
29
  /** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
@@ -40,6 +40,13 @@ export interface ScopedPatch {
40
40
  patch: ScopePatch;
41
41
  }
42
42
 
43
+ /** Canonical model-authored scope cohort before runtime final-eligibility handling. */
44
+ export interface AtomicScopePatches {
45
+ global?: ScopePatch;
46
+ cwd?: ScopePatch;
47
+ session?: ScopePatch;
48
+ }
49
+
43
50
  export interface SemanticTransition {
44
51
  transitions: ScopedPatch[];
45
52
  }
@@ -21,15 +21,15 @@ response: previous complete answer, owned by runtime.
21
21
 
22
22
  Use read_state only for a concrete historical or scope-specific gap. It reads one cached effective/global/cwd/session projection at offset 0..7 without mutation; all scopes use the same nth prior accepted semantic boundary.
23
23
 
24
- Use patch_state as the sole model-authored semantic mutation mechanism. When durable artifacts, contract, or working state should change, call patch_state with one scope and patch. A successful semantic patch is validated, durably accepted, and rematerialized before further reasoning. Call patch_state alone in its assistant response; after its acknowledgement choose the next action from accepted state.
24
+ Use patch_state as the sole model-authored semantic mutation mechanism. Supply any combination of global, cwd, and session patches; all supplied scopes are validated and durably accepted as one atomic transition before further reasoning. Call patch_state alone in its assistant response; after its acknowledgement choose the next action from accepted state.
25
25
 
26
- Every enabled turn starts with State Flow resolution pending. Before the final answer, make at least one successful patch_state call. Each call has exactly one of two exclusive forms: PATCH {"scope":"session|cwd|global","patch":{...}} or UNCHANGED {"unchanged":true}. A successful call satisfies resolution; a semantic patch is committed and rematerialized before further reasoning. Do not combine unchanged with scope or patch. Empty or materially no-op patches are not unchanged acknowledgements. If runtime intercepts an unresolved terminal draft, it is not a final answer: follow its instruction, resolve through patch_state, then provide the final answer normally. The unchanged form creates no transition. Never write response through patch_state; runtime records what was actually delivered.
26
+ Every enabled iteration starts terminal-ineligible. Set final:true in a successful patch_state call when the iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls, and repeated final:true calls are allowed. Use {"final":true} when no semantic update is needed. If you end a terminal turn without eligibility, runtime preserves that answer as the iteration response and starts bounded fallback turns whose only purpose is the final:true patch: call patch_state with any durable scope changes and final:true, or {"final":true} alone, and never restate or replace the answer. After two fallback turns without final:true the iteration closes with its preserved answer and current state. A final-only call creates no semantic transition. Never write response through patch_state; runtime records what was actually delivered at turn_end.
27
27
 
28
- SCOPES: session is branch/run continuation, cwd is project state and Skills, global is cross-project state. A patch changes one scope immediately. Use sequential calls for genuinely multi-scope work. Deleting an override affects only its scope and may reveal a parent value.
28
+ SCOPES: session is branch/run continuation, cwd is project state and Skills, global is cross-project state. Deleting an override affects only its scope and may reveal a parent value.
29
29
 
30
30
  ${baselineMemoryProtocol()}
31
31
 
32
- PATCH: A semantic call has exactly scope and patch. Patches use only object-valued artifacts, contract, and working; omitted fields preserve. Never patch runtime config/meta/response. Recursive merge; arrays/primitives replace; nested null deletes. Materialized null is forbidden.
32
+ PATCH: Fields are optional global, cwd, session semantic patches and optional final:true. At least one scope or final:true is required. Supplied scopes commit atomically; empty or materially no-op scopes must be omitted. Patches use only object-valued artifacts, contract, and working; omitted fields preserve. Never patch runtime config/meta/response. Recursive merge; arrays/primitives replace; nested null deletes. Materialized null is forbidden.
33
33
 
34
34
  HANDOFF: Preserve active commitments, unresolved questions, consequential results, and exact continuation. Distinguish user requirements, confirmed decisions, observations, assistant conclusions, and hypotheses. Remove stale narration and never invent memory changes.
35
35
 
@@ -12,6 +12,7 @@ import { applyPatch, containsNull, hashJson, isObject, validatePatch } from "./j
12
12
  import { hasCompiledSkillArtifact, SKILL_ARTIFACT_COMPILER, type SuccessfulSkillRead } from "./skills.ts";
13
13
  import type { Snapshot } from "./snapshot.ts";
14
14
  import type {
15
+ AtomicScopePatches,
15
16
  MaterializedState,
16
17
  ScopePatch,
17
18
  ScopedPatch,
@@ -194,8 +195,8 @@ function stageScopedSemanticTransition(
194
195
  };
195
196
  }
196
197
 
197
- /** Validate that explicit unchanged resolution has no pending acquisition/compilation obligation. */
198
- export function validateUnchangedResolution(
198
+ /** Validate that final eligibility has no pending acquisition/compilation obligation. */
199
+ export function validateFinalEligibility(
199
200
  currentStates: ScopedStates,
200
201
  successfulSkillReads: Iterable<SuccessfulSkillRead>,
201
202
  causalBasis: string,
@@ -210,17 +211,25 @@ export function validateUnchangedResolution(
210
211
  );
211
212
  }
212
213
 
213
- /** Stage one intermediate state barrier without changing the finalized response. */
214
- export function stageScopedPatch(
214
+ /** Stage one canonical atomic scope cohort without changing the finalized response. */
215
+ export function stageAtomicScopePatches(
215
216
  currentStates: ScopedStates,
216
- transition: ScopedPatch,
217
+ patches: AtomicScopePatches,
217
218
  successfulSkillReads: Iterable<SuccessfulSkillRead>,
218
219
  causalBasis: string,
219
220
  successfulArtifactReads: Iterable<SuccessfulArtifactRead> = [],
220
221
  ): StagedScopedTransition {
222
+ if (!isObject(patches)) throw new Error("Atomic State Flow scope patches must be an object");
223
+ for (const key of Object.keys(patches)) {
224
+ if (!SCOPES.has(key as StateScope)) throw new Error(`Unknown atomic State Flow scope: ${key}`);
225
+ }
226
+ const transitions: ScopedPatch[] = [];
227
+ for (const scope of ["global", "cwd", "session"] as const) {
228
+ if (Object.hasOwn(patches, scope)) transitions.push({ scope, patch: patches[scope] as ScopePatch });
229
+ }
221
230
  return stageScopedSemanticTransition(
222
231
  currentStates,
223
- { transitions: [transition] },
232
+ { transitions },
224
233
  successfulSkillReads,
225
234
  causalBasis,
226
235
  successfulArtifactReads,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -88,18 +88,18 @@ Adjust only identified defects. This is a semantic review, not a request for ext
88
88
 
89
89
  ## Apply one reconciliation cohort
90
90
 
91
- Use `patch_state` only for material changes to `artifacts`, `contract`, or `working`. Each call must be alone in its assistant response; reconsider subsequent actions from the rematerialized state. Do not patch runtime-owned `response`, config, or metadata, or bypass validation by editing backing files.
91
+ Use `patch_state` only for material changes to `artifacts`, `contract`, or `working`. One call may supply `global`, `cwd`, and `session` patches as one atomic cohort; each call must be alone in its assistant response, and subsequent actions must use the rematerialized state. Set `final:true` only when the iteration is eligible to finish at a later `turn_end`. Do not patch runtime-owned `response`, config, or metadata, or bypass validation by editing backing files.
92
92
 
93
93
  Schedule acquisition and migration barriers in this order:
94
94
 
95
95
  1. After reading this Skill, compile it into its exact-path CWD artifact before acquiring a stale global Markdown source or attempting an unrelated state write.
96
- 2. Read only the smallest required state projections. If a justified stale Markdown read creates a global compilation obligation, accept that compilation before an unrelated single-scope write.
96
+ 2. Read only the smallest required state projections. If a justified stale Markdown read creates a global compilation obligation, include every pending compilation scope in the next atomic patch before unrelated work.
97
97
  3. Write the migration destination with `patch_state`, verify it with a separate `read_state`, then delete or narrow the source and verify both its scope and the effective overlay. Do all readback before the terminal answer.
98
- 4. Complete one terminal reconciliation without repeating accepted compilations or inventing memory changes. Simultaneously pending CWD and global acquisitions require complete compilation through the existing terminal multi-scope reconciliation; do not bypass them with an insufficient single-scope patch.
98
+ 4. Complete one terminal reconciliation without repeating accepted compilations or inventing memory changes. Simultaneously pending CWD and global acquisitions must be compiled together in one atomic `patch_state` call; set `final:true` in that call only when the iteration is otherwise ready to finish.
99
99
 
100
100
  Scope-local deletion may reveal a lower-scope value. Deleting an override is not necessarily removal from effective state.
101
101
 
102
- For movement between State Flow scopes, resolve destination conflicts before writing; do not overwrite stronger or unrelated knowledge. Write and verify the destination before deleting the source. Separate calls are not an atomic multi-scope transaction; allow a temporary duplicate rather than a gap. Do not claim migration is complete until source cleanup and the effective result are verified.
102
+ For movement between State Flow scopes, resolve destination conflicts before writing; do not overwrite stronger or unrelated knowledge. Write and verify the destination before deleting the source. Do not combine destination creation and source deletion merely because multi-scope publication is atomic: preserve a temporary duplicate until readback proves the destination. Do not claim migration is complete until source cleanup and the effective result are verified.
103
103
 
104
104
  On rejection, interruption, or conflicting state, inspect what was actually accepted before continuing. Never assume the entire cohort succeeded or failed. Keep recovery bounded; report a blocker rather than repeatedly regenerating patches.
105
105
 
@@ -99,7 +99,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
99
99
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for active preview flushes and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
100
100
  - Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
101
101
  - Public activity handlers and connected companion delivery are asynchronous, target-bound, generation-fenced surfaces. Connected companion projection has no independent opt-out: disconnect or authority loss is its boundary. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
102
- - Thread display defaults to the profile-scoped Letters strategy, with Directories as the other automatic choice. A durable manual Thread display name retained on its Workspace binding overrides either until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
102
+ - Thread display defaults to the profile-scoped Letters strategy, with Names and Directories as the other automatic choices; Names projects the generated dictionary name for the slot. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
103
103
 
104
104
  ## 5. Domain Ownership Index
105
105
 
@@ -3,7 +3,7 @@
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
5
  - [ ] `Channel multimedia posts` (`0.45.1`, live-acceptance-gated): `telegram_message` channel delivery accepts one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video, uploads it through the multipart transport as `sendPhoto`/`sendVideo` with `text` as the HTML caption, validates kind and size (photo ≤ 10 MiB, video ≤ 50 MiB) plus ≤ 1024 visible caption characters before issuance, and rejects unsupported types and albums instead of downgrading them to links. The channel-post journal binds kind/file name/byte size/SHA-256 and caption, so duplicate requests and lost acknowledgements never re-upload; media-post edits replace the caption through `editMessageCaption`, and Markdown spoilers render as `<tg-spoiler>`. Live image publication passed on `@llb_log`. Regressions cover confirmed publication, duplicate requests, lost ACK, pre-issuance rejection, caption edits, and reconnect replacement. Remaining: operator-authorized disposable-channel acceptance of rejected upload, duplicate request, and caption edit.
6
- - [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes Letters/Directories, reset is leader/follower generation- and target-fenced, Letters is the default, and legacy Names resolves to Letters without rewriting recovery identity. Local review findings are remediated, including Bot-API-wait target-replacement regressions for leader/follower rename and reset. Remaining: disposable live acceptance for command-menu ordering, dialog, invalid input, duplicate callbacks, leader/follower rename and reset.
6
+ - [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes every automatic display mode, reset is leader/follower generation- and target-fenced, and Letters remains the default without rewriting recovery identity. Local review findings are remediated, including Bot-API-wait target-replacement regressions for leader/follower rename and reset. Remaining: disposable live acceptance for command-menu ordering, dialog, invalid input, duplicate callbacks, leader/follower rename and reset.
7
7
  - [ ] `OMP schema acceptance` ([#267](https://github.com/llblab/pi-telegram/issues/267), `human-/environment-gated`): Local emitted-schema, Pi process, and llama.cpp source checks now prove explicit recursive JSON values, root `$defs`, only supported local `#/...` references, and no bare boolean schema. Confirm one connected `telegram_bind` request through the reporter's OMP + llama-server build before closing interoperability acceptance; do not treat this progressive external check as a Pi release blocker.
8
8
  - [ ] [`Workspace operator gates`](./docs/multi-instance-bus.md#approved-next-contract-directory-names-and-reclaimable-slots): Complete the remaining external evidence for display modes, unique slots, and durable recovery. Local recovery and snapshot-equality reviews are closed; do not repeat them without changed relevant inputs. No live deletion, commit, publication, restart, or automatic retirement activation belongs to local implementation authority.
9
9
  - [ ] `Bus performance escalation` (`research`, deferred): The [isolated registry/store baselines](./docs/architecture.md#persistence-io-baseline) do not justify secondary indexes, shared mutable views, or IPC multiplexing at 26 slots. Resume full IPC/authenticated registration/routing and allocation-cost measurement only after attributable latency, event-loop blocking, or growing work establishes a concrete claim. Existing local counts are not throughput or end-to-end evidence. Preserve owner/generation fences, journal authority, and unknown-ACK behavior; do not repeat unchanged synthetic measurements merely to sustain the loop.
@@ -4,6 +4,14 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.45.4: Draft Cadence Hotfix
8
+
9
+ - `Draft Cadence`: Each preview segment now holds its first frame for one full two-second interval from its first visible text, so the opening draft is an accumulated passage instead of a single streamed word. Later frames keep the trailing cadence, message/turn rollover preserves the remaining interval and reopens the window, and sealing or final publication still cancels the pending timer; first frames no longer ship immediately.
10
+
11
+ ## 0.45.3: Thread Display Names Hotfix
12
+
13
+ - `Thread Display Names`: Settings again offers the dictionary naming mode as the second chooser between Letters and Directories. Names shows each Workspace's generated slot-letter palette word, such as `Anchor` for slot `A`; switching renames live tabs and fresh tabs start under the active projection. `profiles.<name>.threadDisplayMode` persists all three values, absent or invalid ones resolve to Letters, and Names works with legacy followers that predate `thread-display-mode-v1`.
14
+
7
15
  ## 0.45.2: Provider-Compatible Bind Schema Hotfix
8
16
 
9
17
  - `Provider-Compatible Bind Argument`: Serializes the `telegram_bind` `argument` schema as an inline builder-made JSON-value union bounded to four container levels, with no `$ref`/`$defs` recursion or raw TypeBox marker leakage; OpenAI no longer rejects every request with "Recursive JSON schemas are not currently supported" (#273) and Gemini no longer rejects the unknown `~optional` field (#269) while the tool is registered.
@@ -240,7 +240,7 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
240
240
  - Unknown threads are preserved and offered explicit reroute/restore choices.
241
241
  - Telegram never launches hidden Pi processes.
242
242
 
243
- In Threaded Mode, open Settings → **🧵 Thread display** to choose **Letters** (default) or **Directories** for this bot profile. Fresh tabs are created with the active mode's title instead of being visibly renamed afterward. Telegram tab titles, Pi terminal status, live Thread choosers/notices, prompt attribution, and named `telegram_message` targeting use the same acknowledged display name; target IDs and live registrations still own routing. Directory mode adds persistent global-letter suffixes when a Workspace has multiple instances, such as `extensions_a` and `extensions_c`. `/name` sets a manual Thread display name; **Reset to automatic** restores the selected automatic projection. Legacy persisted `names` values resolve to Letters. Switching preserves Thread IDs, slots, generated recovery identity, and queue ownership. Partial application reports an error and can be retried without recreating Threads.
243
+ In Threaded Mode, open Settings → **🧵 Thread display** to choose **Letters** (default), **Names**, or **Directories** for this bot profile. Fresh tabs are created with the active mode's title instead of being visibly renamed afterward. Telegram tab titles, Pi terminal status, live Thread choosers/notices, prompt attribution, and named `telegram_message` targeting use the same acknowledged display name; target IDs and live registrations still own routing. Names shows the generated dictionary name chosen for the slot, such as `Anchor` for slot `A`. Directory mode adds persistent global-letter suffixes when a Workspace has multiple instances, such as `extensions_a` and `extensions_c`. `/name` sets a manual Thread display name; **Reset to automatic** restores the selected automatic projection. Switching preserves Thread IDs, slots, generated recovery identity, and queue ownership. Partial application reports an error and can be retried without recreating Threads.
244
244
 
245
245
  | Mode | Best for | Runtime shape |
246
246
  | --- | --- | --- |
@@ -371,7 +371,7 @@ Profile reality follows three explicit storage classes. `telegram.json` shared s
371
371
 
372
372
  When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Followers heartbeat every `1s`; the leader uses a `15s` stale grace and a `1s` prune loop so transient IPC stalls do not create false routing gaps while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, `workspaceBindings` stores profile-scoped normalized exact-`cwd` target/name/slot reuse hints, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
373
373
 
374
- Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over either automatic display mode; bare `/name` opens five-minute exact-target input whose next valid text is consumed before agent dispatch. Name input, cancel, and reset are consume-once; stale scope, target, message ID, expiry, and duplicate callbacks cannot mutate. Reset clears only the override and restores the current automatic projection. Leader command routing reuses its already-held profile admission for the rename body rather than recursively entering the non-reentrant Workspace gate; standalone leader renames acquire their own admission. Followers send an authenticated exact-generation `workspace-thread-rename-v1` request, and the leader owns any Bot API mutation plus durable binding persistence. Concurrent processes from one directory receive deterministic Workspace suffixes, while leader/follower roles remain transient projections over that durable identity. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection. A confirmed runtime transition from Threaded to Singleton stops threaded transport and suspends the process-local leader target before classic polling can accept new work; durable Workspace slot, generated name, manual display name, and binding evidence remain retained. Re-enabling Threaded Mode restores or replaces that logical binding before publishing one new live target. Already-admitted turns keep their captured destination and are never silently retargeted or duplicated.
374
+ Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over the active automatic display projection; bare `/name` opens five-minute exact-target input whose next valid text is consumed before agent dispatch. Name input, cancel, and reset are consume-once; stale scope, target, message ID, expiry, and duplicate callbacks cannot mutate. Reset clears only the override and restores the current automatic projection. Leader command routing reuses its already-held profile admission for the rename body rather than recursively entering the non-reentrant Workspace gate; standalone leader renames acquire their own admission. Followers send an authenticated exact-generation `workspace-thread-rename-v1` request, and the leader owns any Bot API mutation plus durable binding persistence. Concurrent processes from one directory receive deterministic Workspace suffixes, while leader/follower roles remain transient projections over that durable identity. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection. A confirmed runtime transition from Threaded to Singleton stops threaded transport and suspends the process-local leader target before classic polling can accept new work; durable Workspace slot, generated name, manual display name, and binding evidence remain retained. Re-enabling Threaded Mode restores or replaces that logical binding before publishing one new live target. Already-admitted turns keep their captured destination and are never silently retargeted or duplicated.
375
375
 
376
376
  ### Unbound Thread Detection
377
377
 
@@ -145,11 +145,11 @@ A registered instance exposes:
145
145
  }
146
146
  ```
147
147
 
148
- `instanceId` is liveness identity. `owner` is explicit current binding identity (`leader`, `manual-follower`, or `pending-topic`). Internal compatibility keys may be derived, but `state.json` should not hide ownership direction inside legacy string keys. `threadName` is the stable named-mode and restoration identity; `displayTitle` is the separately acknowledged user-facing projection. Fresh threads receive a compact palette name from the assigned slot, while Letters and Directories project other titles without replacing the saved name.
148
+ `instanceId` is liveness identity. `owner` is explicit current binding identity (`leader`, `manual-follower`, or `pending-topic`). Internal compatibility keys may be derived, but `state.json` should not hide ownership direction inside legacy string keys. `threadName` is the stable named-mode and restoration identity; `displayTitle` is the separately acknowledged user-facing projection. Fresh threads receive a compact palette name from the assigned slot; Names projects that saved name, while Letters and Directories project other titles without replacing it.
149
149
 
150
150
  ## Approved Next Contract: Directory Names And Reclaimable Slots
151
151
 
152
- Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters, Names (default), and Directories; follower changes use the capability-gated leader-owned setting path. Live bot chooser/notice labels and cross-instance agent-target resolution use acknowledged titles without granting routing authority. Confirmed owner cleanup now persists the first proven `inactiveSinceMs` transition atomically with target invalidation; successful active provisioning clears it. Pressure selection, intents, mocked execution, and recovery are implemented. A 2/2 same-model independent post-fix quorum cleared the admission-composition blocker at 0.96 confidence per reviewer, but production deletion remains disconnected by this release scope; `BACKLOG.md` owns operator smoke and release readiness.
152
+ Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters (default), Names, and Directories; follower changes use the capability-gated leader-owned setting path. Live bot chooser/notice labels and cross-instance agent-target resolution use acknowledged titles without granting routing authority. Confirmed owner cleanup now persists the first proven `inactiveSinceMs` transition atomically with target invalidation; successful active provisioning clears it. Pressure selection, intents, mocked execution, and recovery are implemented. A 2/2 same-model independent post-fix quorum cleared the admission-composition blocker at 0.96 confidence per reviewer, but production deletion remains disconnected by this release scope; `BACKLOG.md` owns operator smoke and release readiness.
153
153
 
154
154
  The pure policy distinguishes a free letter, a proposed pressure-reclamation victim, and protected/invalid capacity. Its caller must supply a validated profile-wide snapshot, reservations, proven inactivity start, and explicit protection classification; duplicate legacy letters block selection. The policy performs no filesystem or Telegram operations and does not establish liveness or deletion authority. It proposes a victim only when every profile-wide letter is occupied or reserved; elapsed time alone never triggers retirement.
155
155
 
@@ -167,7 +167,7 @@ Production retirement requires a durable profile-scoped reader/writer ledger own
167
167
  - Retirement intent preparation/adoption/execution owns its exact gate-and-ledger protocol but remains absent from production composition. Status projection and polling/routing bot-mode writes change only diagnostic or capability metadata; they neither create nor remove Thread/Workspace authority and serialize through the store's local persistence queue.
168
168
  - Production callers of reservation, provision/cleanup intent, target-record, Workspace-binding, and display mutators are contained by the owners above. The generic store remains policy-free for isolated tests and domain composition; calling a primitive directly is not production retirement authority.
169
169
  - Workspace identity remains the selected bot profile plus normalized exact full `cwd`; directory basenames are presentation, never routing keys. Each concurrent binding receives one profile-wide unique lowercase slot from `a` through `z`, persisted on the wire/store as its uppercase equivalent, independent of directory and leader/follower role. This replaces the two competing displayed allocation identities; immutable legacy `instanceSlot` and `bindingKey` remain recovery keys, not another displayed pool.
170
- - Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters` then `directories`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; directories use the directory basename. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides either automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Legacy persisted `names` configuration is read compatibly but resolves to Letters; it is no longer an effective or offered automatic choice. Store the automatic preference at `profiles.<name>.threadDisplayMode`, not as a process-local choice or a setting shared by unrelated bots.
170
+ - Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters`, `names`, then `directories`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; Names shows the generated dictionary name for the slot, such as `Anchor` for `A`; directories use the directory basename. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides any automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Store the automatic preference at `profiles.<name>.threadDisplayMode`, not as a process-local choice or a setting shared by unrelated bots.
171
171
  - In directory mode, a singleton may hide its suffix; once another retained binding for that Workspace exists, all its labels expose their globally assigned suffixes (for example `extensions_a`, `skills_b`, `extensions_c`). Persist the decision to show suffixes so later closure does not make names oscillate. Equal basenames from different paths require a deterministic parent-path qualifier. Preserve the existing `threadName` as generated/recovery identity while automatic modes are selected. New manual names live only in `manualThreadName`; do not guess manual provenance from a legacy name or palette membership. `showSlotSuffix: true` is sticky binding metadata; sibling creation and legacy multi-binding loads expose it, and later upserts that omit it cannot reset it. Telegram `/name Name` changes the manual override and displayed title only for its exact originating target. Leader and follower requests carry that target through final generation/binding checks, so replacement cannot redirect a stale dialog mutation. Reset uses the same negotiated `workspace-thread-rename-v1` capability and exact follower generation; the leader computes the current automatic projection, edits the exact target, clears only `manualThreadName`, and persists before acknowledging. Follower metadata refresh preserves an acknowledged display title only while target and registration generation stay unchanged. Named-profile setup preserves the latest saved automatic preference even if another instance changes it while the token form is open.
172
172
  - Fresh provisioning projects the candidate together with retained bindings and sends the active mode's title in `createForumTopic`. The exact targeted provision retains creation-title evidence until the Workspace commit publishes the binding and consumes that evidence together. Recovery preserves it even when a starting record already exists; an untargeted or unknown creation never authorizes a title commit. Proven deletion removes exact-target pending creation evidence, including when no current record was committed. Older contradictory pending/deleted snapshots settle that evidence durably before replacement; closed targets and pending cleanup block recovery until reconciliation, rather than becoming active again. The same exact-target check protects follower reconnect/carried-target shortcuts and the final Workspace commit, before creation evidence can be consumed. A matching carried pending target resumes through the provisioner that owns its reserved slot and acknowledged title instead of allocating that slot again. The shared provision-commit helper first commits the claim, then applies the acknowledged title with exact-binding comparison, preserving generic stale-title rejection on target replacement. Switching display mode changes projection only: preserve Thread ID, binding identity, slot, queue ownership, and routing. `displayTitle` records a successful Telegram edit independently of `threadName`, survives same-target registration updates, and is cleared on target replacement. The title reconciler captures profile, mode, leader epoch, and exact live-binding authority before each edit, rechecks after ACK and persistence, and skips dormant bindings. Failed persistence retains acknowledged dirty metadata for a later persist without repeating that API edit; a late or unknown ACK never commits a title to a replacement binding. Keep the stable palette/manual name separate from the current display title so switching back does not generate a different name. The leader owns Telegram title edits and acknowledged follower/TUI convergence, with generation/profile fencing and truthful partial-failure recovery. Successful registration ACKs optionally carry the acknowledged `displayTitle` with the exact target and registration generation, making it available before the initial status refresh. Heartbeats carry later title changes; stale generations cannot update display state. Connected notices use acknowledged titles while runtime `threadName` remains the stable restoration identity. Live bot chooser/notice labels, prompt attribution, and cross-instance agent-target name selection use the same acknowledged projection, but candidate liveness and the captured numeric `{chatId, threadId}` remain authoritative. Ambiguous projected names fail closed. Older peers can ignore the optional field, and no separate polling connection or follower snapshot-read loop is needed; do not expose a setting control that merely stores a preference without updating its promised surfaces.
173
173
  - Reopening a retained inactive binding restores its slot and name without taking leadership. Explicit connection from the same directory may allocate a second binding. Startup restore remains restore-only: it must not evict another Workspace or allocate a fresh binding merely because all remembered bindings are owned. Explicit connection already skips a live peer's migrated binding instead of attempting to adopt it; that admission rule is independent of the display redesign.
@@ -236,7 +236,7 @@ Run this only with an operator-approved disposable bot/profile and disposable Th
236
236
  1. Start one Pi in directory A, enable private-chat Threaded Mode, and run `/telegram-connect`. Confirm leader ownership, one Thread created directly with the selected display-mode title, the same title in the connected notice and initial Pi status, and no generated-name flash or second polling owner.
237
237
  2. Start a Pi in directory B and connect it. Confirm follower registration rather than takeover, a distinct globally ordered slot/name, the selected title in creation/notice/initial status without waiting for a heartbeat, exact prompt/reply routing, and no traffic in the leader Thread.
238
238
  3. Explicitly connect a second Pi from directory A. Confirm it receives a separate binding/slot without copying the first target. Restart each follower independently and confirm restore-only startup reuses its remembered exact-directory binding without allocating a new Thread.
239
- 4. Rename the leader and a follower from their respective Telegram Threads with `/name Name`. Confirm each manual override converges in Telegram, Pi status, choosers, notices, and agent-target labels under both Letters and Directories. Reset each override to the current automatic projection; confirm target IDs never change and same-basename directory suffixes remain sticky after a sibling disconnects.
239
+ 4. Rename the leader and a follower from their respective Telegram Threads with `/name Name`. Confirm each manual override converges in Telegram, Pi status, choosers, notices, and agent-target labels under all three display modes. Reset each override to the current automatic projection; confirm target IDs never change and same-basename directory suffixes remain sticky after a sibling disconnects.
240
240
  5. From leader and follower Threads, exercise ordinary prompts, callback buttons, one file, and one voice response. Confirm each result remains reply-anchored to the originating numeric Thread and no upload, notice, or final is duplicated.
241
241
  6. From `All`, create an unbound disposable Thread. Test forward and Replace/restore separately. Confirm accepted content reaches only the selected live instance, Restore carries the selected identity onto the source target, and only the confirmed old/chooser targets are deleted.
242
242
  7. Replace a follower session, then stop the leader and allow follower promotion. Confirm profile, target, slot, saved name, acknowledged title, accepted queue work, and routing survive without a new Thread or competing poller.
@@ -16,7 +16,7 @@ Projected blocks use `assistant.rendering` independently of voice policy. Rich m
16
16
 
17
17
  Assistant-message completion seals its preview state: queued follow-up drafts and late updates are suppressed. Native final delivery still waits for the already-issued draft request before sending the permanent answer, so an older draft is not deliberately allowed to overtake the final. Intermediate publication seals and drains its captured preview before sending permanent text. Preview rollover itself sends no permanent message: it carries the preceding delivery boundary and draft identity into the next state without holding the Pi message-start hook. The next draft waits for that publication to settle, including failure or cancellation. Active-turn final delivery captures its preview operations before entering the background queue: it drains only the originating draft, leaves a successor's preview untouched, and cannot wait for a successor publication queued behind itself. Delivery authority is rechecked after the captured flush; if the original preview has been replaced, ordinary final sending remains the queue's responsibility. This does not promise instant delivery or eliminate Telegram/client latency.
18
18
 
19
- Assistant previews use a two-second leading/trailing throttle per preview controller, with at most one request in flight. The first eligible snapshot sends immediately; changes inside the window replace pending text, and one trailing timer sends the latest safe snapshot without moving its deadline on every delta. Message/turn rollover preserves the remaining interval. Sealing, clearing, or replacing preview state cancels its timer; final publication drains only an already-issued request and never waits for the throttle deadline.
19
+ Assistant previews use a two-second trailing throttle per preview controller, with at most one request in flight. Each preview segment holds its first frame for one full interval from its first visible text, so the opening frame is an accumulated passage instead of the first streamed word even when the previous cadence boundary has already passed (fresh turn, slow first token, or rollover after tool work); changes inside the window replace pending text, and one trailing timer sends the latest safe snapshot without moving its deadline on every delta. Message/turn rollover preserves the remaining interval and reopens the initial window for the next segment. Sealing, clearing, or replacing preview state cancels its timer; final publication drains only an already-issued request and never waits for the throttle deadline.
20
20
 
21
21
  The Bot API client does not replay draft snapshots through API retry backoff. After a retryable draft HTTP failure (`429` or `5xx`), it defers new draft requests for that bot/chat/thread using the existing `Retry-After` or default backoff delay; it stores a deadline, not a body or a background retry. A deferred update does not advance the preview's last-delivered text. Fresh updates can send after the deadline, while a sealed preview cannot resume. Different bots and thread targets have independent cooldowns; credential rotation for the same bot does not bypass its deadline. Permanent replies retain their existing retry policy. Already-issued requests and Telegram/client rendering can still delay visible completion.
22
22
 
@@ -46,7 +46,7 @@ Stable commands inside Pi:
46
46
 
47
47
  ### Telegram commands
48
48
 
49
- - `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title under either automatic mode. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
49
+ - `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title over the active automatic projection. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
50
50
 
51
51
  Stable commands inside the paired Telegram DM:
52
52
 
@@ -120,7 +120,7 @@ Bot/session identity always persists under `profiles.<name>`. The ordinary setup
120
120
 
121
121
  The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
122
122
 
123
- Threaded Mode Settings exposes **Thread display** as Letters (default) or Directories. `profiles.<name>.threadDisplayMode` is profile-scoped; absent, invalid, and legacy `names` values resolve to `letters`. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1` and its exact registration generation. Directories requires compatible connected followers and rechecks compatibility before live publication. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
123
+ Threaded Mode Settings exposes **Thread display** as Letters (default), Names, or Directories. `profiles.<name>.threadDisplayMode` is profile-scoped; absent and invalid values resolve to `letters`, and Names projects the generated dictionary name for the slot. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1` and its exact registration generation. Letters and Directories require compatible connected followers and recheck compatibility before live publication; Names remains usable with legacy peers because it is their generated-name behavior. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
124
124
 
125
125
  Hidden/default semantics are represented by absence:
126
126
 
@@ -232,7 +232,7 @@ Rules:
232
232
  - Explain what the setting does and what the options mean only as much as needed.
233
233
  - Order setting value descriptions exactly like the chooser: rows top-to-bottom and values in a shared row left-to-right. Keep `(default)` on the actual default wherever it falls; default status never changes order.
234
234
  - Keep descriptions short and clear.
235
- - Automatic Thread display uses the same setting card: current value in `<code>`, then descriptions ordered `letters`, `directories`, with `(default)` only on `letters`. Its vertical chooser marks only the current option. A manual `/name Name` sets the current Thread display name and supersedes either automatic projection until reset; switching automatic mode preserves the slot and override.
235
+ - Automatic Thread display uses the same setting card: current value in `<code>`, then descriptions ordered `letters`, `names`, `directories`, with `(default)` only on `letters`. Its vertical chooser marks only the current option. A manual `/name Name` sets the current Thread display name and supersedes any automatic projection until reset; switching automatic mode preserves the slot and override.
236
236
 
237
237
  Examples:
238
238
 
@@ -141,9 +141,11 @@ export type TelegramThreadDisplayMode = "letters" | "names" | "directories";
141
141
  export function resolveTelegramThreadDisplayMode(
142
142
  config: Pick<TelegramConfig, "threadDisplayMode">,
143
143
  ): TelegramThreadDisplayMode {
144
- return config.threadDisplayMode === "directories"
145
- ? "directories"
146
- : "letters";
144
+ return config.threadDisplayMode === "names"
145
+ ? "names"
146
+ : config.threadDisplayMode === "directories"
147
+ ? "directories"
148
+ : "letters";
147
149
  }
148
150
 
149
151
  export async function setTelegramThreadDisplayMode(
@@ -151,7 +153,7 @@ export async function setTelegramThreadDisplayMode(
151
153
  mode: TelegramThreadDisplayMode,
152
154
  isCurrent: () => boolean,
153
155
  ): Promise<void> {
154
- if (!["letters", "directories"].includes(mode)) {
156
+ if (!["letters", "names", "directories"].includes(mode)) {
155
157
  throw new Error("Invalid Telegram Thread display mode.");
156
158
  }
157
159
  const profile = store.getActiveProfileName();
@@ -182,6 +182,7 @@ export function buildThreadDisplaySettingsText(mode: TelegramThreadDisplayMode):
182
182
  "Choose how this bot profile labels Telegram tabs and Pi terminal status. Each slot is unique across this bot profile.",
183
183
  "",
184
184
  "<code>-</code> <code>letters</code> (default): show the unique slot, such as <b><i>A</i></b> or <b><i>B</i></b>.",
185
+ "<code>-</code> <code>names</code>: show the generated dictionary name for the slot, such as <b><i>Anchor</i></b> or <b><i>Briar</i></b>.",
185
186
  "<code>-</code> <code>directories</code>: show the directory, such as <b><i>extensions</i></b>; shared Workspaces keep slot suffixes, such as <b><i>extensions_a</i></b> and <b><i>extensions_c</i></b>.",
186
187
  "A manual <code>/name Name</code> overrides this Thread display name until reset.",
187
188
  ].join("\n");
@@ -414,7 +415,7 @@ export async function openTelegramSettingsMenu<
414
415
  export function buildThreadDisplaySettingsReplyMarkup(mode: TelegramThreadDisplayMode): TelegramSettingsMenuReplyMarkup {
415
416
  return { inline_keyboard: [
416
417
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
417
- ...(["letters", "directories"] as const).map((value) => [{
418
+ ...(["letters", "names", "directories"] as const).map((value) => [{
418
419
  text: `${mode === value ? "🟢 " : ""}${value}`,
419
420
  callback_data: `settings:set:thread-display:${value}`,
420
421
  }]),
@@ -15,6 +15,9 @@ import { shouldSuppressPreviewForVoice } from "./voice.ts";
15
15
 
16
16
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
17
17
  const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
18
+ // Native draft cadence: at most one frame per interval, and a fresh preview
19
+ // segment holds its first frame for one full interval so the opening frame is
20
+ // an accumulated passage rather than a single streamed word.
18
21
  const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
19
22
 
20
23
  export type TelegramDraftSupport = "unknown" | "supported";
@@ -90,6 +93,7 @@ export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
90
93
  createPreviewState: () => TelegramPreviewRuntimeState;
91
94
  canSend?: () => boolean;
92
95
  getMessageText: (message: TMessage) => string;
96
+ minDraftIntervalMs?: number;
93
97
  schedulePreviewFlush: (
94
98
  chatId: number,
95
99
  options?: { target?: TelegramTarget },
@@ -305,6 +309,7 @@ export function createTelegramAssistantPreviewRuntime<
305
309
  createPreviewState: controller.createState,
306
310
  canSend: deps.canSend,
307
311
  getMessageText: deps.getMessageText,
312
+ minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
308
313
  schedulePreviewFlush: controller.scheduleFlush,
309
314
  }),
310
315
  };
@@ -466,9 +471,24 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
466
471
  deps.setState(state);
467
472
  }
468
473
  if (state.sealed) return;
474
+ const hadVisibleText = state.pendingText.length > 0;
469
475
  state.pendingText = stripTelegramCommentMarkupForPreview(
470
476
  deps.getMessageText(message),
471
477
  );
478
+ // The first visible text of a preview segment opens an initial accumulation
479
+ // window, so the segment's first frame cannot ship as a single word even
480
+ // when the previous cadence boundary has already passed (fresh turn, slow
481
+ // first token, or message rollover after tool work). Later deltas keep the
482
+ // trailing deadline instead of sliding it on every update.
483
+ const interval = deps.minDraftIntervalMs ?? 0;
484
+ if (
485
+ interval > 0 &&
486
+ !hadVisibleText &&
487
+ !state.lastSentText &&
488
+ state.pendingText.length > 0
489
+ ) {
490
+ state.nextDraftAt = Math.max(state.nextDraftAt ?? 0, Date.now() + interval);
491
+ }
472
492
  deps.schedulePreviewFlush(turn.chatId, { target: turn.target });
473
493
  }
474
494
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.45.2",
3
+ "version": "0.45.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -44,8 +44,8 @@
44
44
  "@llblab/pi-clean-room": "0.1.1",
45
45
  "@llblab/pi-codex-usage": "0.9.4",
46
46
  "@llblab/pi-grow-loop": "0.7.5",
47
- "@llblab/pi-state-flow": "0.7.0",
48
- "@llblab/pi-telegram": "0.45.2",
47
+ "@llblab/pi-state-flow": "0.8.1",
48
+ "@llblab/pi-telegram": "0.45.4",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [