@llblab/pi-kit 0.17.1 → 0.18.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 (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +5 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +1 -1
  6. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +3 -3
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -1
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +1 -1
  9. package/node_modules/@llblab/pi-state-flow/dist/package.json +5 -5
  10. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +27 -82
  11. package/node_modules/@llblab/pi-state-flow/docs/performance.md +2 -2
  12. package/node_modules/@llblab/pi-state-flow/docs/usage.md +1 -1
  13. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +3 -3
  14. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +1 -1
  15. package/node_modules/@llblab/pi-state-flow/package.json +5 -5
  16. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  17. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  18. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  19. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +3 -1
  20. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +40 -9
  21. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +3 -2
  22. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +32 -30
  23. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  25. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  26. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +46 -9
  27. package/node_modules/@llblab/pi-telegram/lib/commands.ts +39 -29
  28. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  29. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.18.1 - 2026-09-20
6
+
7
+ - `Passive State Flow Guidance`: Advances the exact State Flow pin to `0.16.3`, requiring finalization barriers only in active episodes while preventing gratuitous final-only patches during passive memory use.
8
+ - `Compatibility Contract`: Carries the minimum Pi SDK version `0.84.4` without an artificial upper peer-dependency bound and removes stale compatibility evidence without dropping supported storage migrations or current safety checks.
9
+ - `Package Cohort`: Keeps every other bundled package at its current exact version; the package set, resource inventory, and explicit load order remain unchanged.
10
+
11
+ ## 0.18.0 - 2026-09-18
12
+
13
+ - `Telegram Lifecycle Gateway`: Advances the exact Telegram pin to `0.50.0`, replacing the session-specific technical command with one guarded internal gateway for runtime-armed lifecycle actions while preserving `/new` settlement, continuity, and terminal-result guarantees.
14
+ - `Thinking Cadence`: Buffers the first Telegram thinking frame for two seconds and throttles later updates to the same two-second cadence as answer drafts, while still flushing remaining reasoning when the block completes.
15
+ - `Package Cohort`: Keeps every other bundled package at its current exact version; the package set, resource inventory, and explicit load order remain unchanged.
16
+
5
17
  ## 0.17.1 - 2026-09-18
6
18
 
7
19
  - `State Flow Activation Hotfixes`: Advances the exact State Flow pin to `0.16.2`, allowing resumed activation to reassert its selected session cohort and allowing passive global memory before a new CWD has materialized, while retaining fail-closed handling for genuinely incomplete ownership.
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.10.0` | Compact Codex/Spark subscription-limit and Business credit-usage status |
16
16
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.8.1` | Visible continuation scheduling and bounded worker Skills |
17
- | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.16.2` | Incremental scoped state/context/memory compiler with proportional operational guidance, intentional agency, reactive dangling-reference diagnostics, compiled runtime delivery, safe compaction, and exact publication |
18
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.49.0` | Telegram companion with native fresh-session replacement, adaptive Thread continuity, exact queues, files, voice, controls, and Generative Apps guidance |
17
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.16.3` | Incremental scoped state/context/memory compiler with mode-aware passive guidance, proportional operational guidance, intentional agency, reactive dangling-reference diagnostics, safe compaction, and exact publication |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.50.0` | Telegram companion with native fresh-session replacement, a guarded internal lifecycle gateway, buffered thinking cadence, adaptive Thread continuity, exact 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.
@@ -44,7 +44,7 @@
44
44
  - Explicit `/state-flow-start` creates the State Flow directory when missing and returns after locally usable runtime acceptance for normal `turn-end`/`off` policy. If Git is installed, initialize an exact-root Git repository when needed, including a populated file-only store, preserving all existing bytes and unrelated files; an ancestor repository is not a valid substitute. Skip full predecessor-format migration planning only when all exact legacy snapshot names are absent, and defer Markdown freshness discovery until before the next enabled inference. If Git is absent, use file persistence. Manual-mode startup/status/restore do not initialize Git; configured automatic start of a genuinely new session uses the same initialization as explicit start. Never create external accounts, remote repositories, credentials, or remote configuration; those remain operator-owned. Never auto-import, delete, or reset files/history in a previous Knowledge-backed store; old branch revisions require their original Git history to remain available in the selected store. `/state-flow-start` must initialize missing global, CWD, and current-session checkpoint/tail pairs plus session config/meta through compare-and-swap publication when required, enable only the current session branch, and bootstrap prior conversation when needed. Explicit start on a proven pre-runtime branch (no checkpoint or an ordinary-disabled marker) establishes an empty session origin rather than importing a later same-session layer; validate existing runtime identity, retain shared streams unchanged, and preserve later branch data in cold Git history. Ordinary new sessions remain manual unless agent-level `autoStart` is true; CWD materialization alone grants no automatic activation. Configured new sessions may initialize missing CWD state and receive distinct empty session layers while inheriting global/CWD values. Resumed and tree-selected branches restore their own config and temporal lineage, regardless of the global flag.
45
45
  - `/state-flow-stop` returns to the configured passive bootstrap/tool combination and must persist only the current session/branch's `config.enabled = false` and necessary runtime provenance, preserving all semantic checkpoints/tails and creating no semantic transition. Retain a frozen handoff plus the active user-run trajectory (including later tool results), post-stop conversation, and foreign context-bearing custom messages across same-physical-session reload/resume/tree. Exclude completed pre-run conversation and private State Flow validation feedback; idle Stop does not retain the completed run. Active restart replaces passive mode but uses that bounded boundary for its one migration run; new and forked physical sessions inherit neither projection. It does not rewrite agent-level `autoStart` or change its policy for future new sessions.
46
46
  - After an accepted non-bootstrap run settles with no pending input, State Flow may request native manual compaction under a generation-private marker only when public `getContextUsage()` reports at least 24,000 tokens. Keep the complete latest accepted user iteration, store only revision/step identity in details, and never duplicate state in the summary. Unknown or smaller usage skips compaction; a benign native refusal releases the attempt for later work. Skip prefixes containing foreign custom context. Never customize user manual or native threshold/overflow compaction, discard unfinished pre-patch work, create a State Flow origin, or rewrite Pi's append-only JSONL/tree.
47
- - Keep the injected runtime protocol compact and normative; put rationale and extended explanation in README rather than the model prompt. Never parse or strip State Flow HTML comments; they are ordinary historical text, while foreign comment handling remains owned by other extensions.
47
+ - Keep the injected runtime protocol compact and normative; put rationale and extended explanation in README rather than the model prompt. Foreign comment handling remains owned by other extensions.
48
48
  - Do not claim strict boundedness for state, the current run trajectory, the turn specification, or the external full trace.
49
49
  - Remain extension-agnostic in core semantics, storage, and inference: core modules never import, name, special-case, or encode policy for another extension or transport. One optional leaf presentation adapter (`lib/telegram.ts`) may import public `pi-telegram` membranes to mirror the live status on exactly one main-menu section button and expose the same start/stop affordances already owned by `state-flow-start` and `state-flow-stop`; it must fail open when the transport is absent or its registry is unready and must never alter core behavior.
50
50
  - Activate State Flow model tools while an episode is enabled or passive tools are configured; preserve every unrelated active tool when toggling them. Passive reads never initialize storage, while an explicit passive patch may initialize or migrate storage without enabling episode barriers, continuation, or compaction. Keep mutation confined to `patch_state` and historical observation read-only.
@@ -4,6 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.16.3: Passive guidance and compatibility cleanup
8
+
9
+ - `Passive finalization guidance`: Makes the always-available `patch_state` tool contract mode-aware, requiring `final:true` only for active episodes and explicitly forbidding final-only calls in passive turns so tool-level guidance no longer contradicts passive runtime context.
10
+ - `Pi compatibility range`: Declares `0.84.4` as the minimum Pi SDK version without an upper peer-dependency bound, replaces stale candidate reports and historical run internals with a concise tested-stack contract, and removes dead legacy test scaffolding plus retired HTML-envelope fixtures without dropping supported storage migrations or current negative-boundary coverage.
11
+
7
12
  ## 0.16.2: Passive first-CWD memory hotfix
8
13
 
9
14
  - `Passive first-CWD memory`: Treats global-only storage as a valid passive-memory state before the current CWD has ever materialized, projecting an empty CWD overlay instead of warning that shared storage is incomplete; CWD-without-global storage still fails closed.
@@ -26,7 +26,7 @@ The agent curates what matters; the extension validates, persists, and projects
26
26
 
27
27
  ## Quick start
28
28
 
29
- Requires Pi `0.84.4–0.84.x` or `0.85.1–0.85.x` and Node.js `22.19.0` or newer. See the [SDK compatibility matrix](docs/compatibility.md) for exact tested stacks. Git is optional; using it requires a configured commit identity.
29
+ Requires Pi `0.84.4` or newer and Node.js `22.19.0` or newer. There is no declared upper Pi version bound; see the [SDK compatibility matrix](docs/compatibility.md) for exact tested stacks. Git is optional; using it requires a configured commit identity.
30
30
 
31
31
  From npm:
32
32
 
@@ -619,10 +619,10 @@ export default function stateFlowExtension(pi, options = {}) {
619
619
  pi.registerTool({
620
620
  name: PATCH_STATE_TOOL_NAME,
621
621
  label: "Patch State",
622
- 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.",
623
- promptSnippet: "Atomically patch global/cwd/session; final:true permits a later turn_end",
622
+ description: "The sole State Flow semantic mutation protocol. Supply any combination of global, cwd, and session patches; all supplied scopes commit atomically. In an active State Flow episode, set final:true when the current iteration may finish at a later turn_end; use {final:true} when no semantic update is needed. Passive turns have no terminal barrier: never call patch_state only to set final:true. This call must be the only State Flow barrier in its assistant response.",
623
+ promptSnippet: "Atomically patch global/cwd/session; active episodes use final:true before turn_end",
624
624
  promptGuidelines: [
625
- "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.",
625
+ "Use patch_state for durable semantic changes. Only in an active State Flow episode, make the iteration terminal-eligible before a final answer with final:true, optionally alongside atomic global/cwd/session patches. In passive mode, never call patch_state only to set final:true.",
626
626
  "Use patch_state as reconciliation, not append-only notes: place new knowledge at the narrowest valid scope and remove superseded or completed state from touched branches.",
627
627
  "Call patch_state alone in an assistant response; after its acknowledgement, further reasoning, tools, and later patch_state calls remain allowed.",
628
628
  ],
@@ -10,5 +10,5 @@ export declare function separatedFailure(error: unknown): Error;
10
10
  /** The compact model-facing contract. Semantic writes never travel through terminal prose. */
11
11
  export declare function stateFlowProtocol(bootstrap: boolean): string;
12
12
  export declare function assistantToolCallCount(content: unknown): number;
13
- /** The post-handler assistant message is authoritative; State Flow does not parse service comments. */
13
+ /** The accepted post-handler assistant text is authoritative. */
14
14
  export declare function finalizedAssistantResponse(message: AgentMessage): string;
@@ -89,7 +89,7 @@ export function assistantToolCallCount(content) {
89
89
  return 0;
90
90
  return content.filter((block) => typeof block === "object" && block !== null && block.type === "toolCall").length;
91
91
  }
92
- /** The post-handler assistant message is authoritative; State Flow does not parse service comments. */
92
+ /** The accepted post-handler assistant text is authoritative. */
93
93
  export function finalizedAssistantResponse(message) {
94
94
  if (message.role !== "assistant" || !Array.isArray(message.content)) {
95
95
  throw new Error("Finalized State Flow turn does not contain an assistant response");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -66,10 +66,10 @@
66
66
  "node": ">=22.19.0"
67
67
  },
68
68
  "peerDependencies": {
69
- "@earendil-works/pi-agent-core": "^0.84.4 || ^0.85.1",
70
- "@earendil-works/pi-ai": "^0.84.4 || ^0.85.1",
71
- "@earendil-works/pi-coding-agent": "^0.84.4 || ^0.85.1",
72
- "@earendil-works/pi-tui": "^0.84.4 || ^0.85.1"
69
+ "@earendil-works/pi-agent-core": ">=0.84.4",
70
+ "@earendil-works/pi-ai": ">=0.84.4",
71
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
72
+ "@earendil-works/pi-tui": ">=0.84.4"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@earendil-works/pi-tui": "0.84.4",
@@ -1,101 +1,46 @@
1
1
  # Pi SDK compatibility
2
2
 
3
- This matrix records exact tested dependency stacks, not the version of an operator's running Pi process. Package peer ranges admit `^0.84.4 || ^0.85.1`; keep Pi, AI and agent-core on a matching release line. Mixed-version stacks and later releases are not separate test evidence.
3
+ State Flow requires matching Pi SDK packages at `>=0.84.4` without an upper peer-dependency bound. Keep `pi-coding-agent`, `pi-agent-core`, `pi-ai`, and `pi-tui` on the same release line.
4
4
 
5
- ## Current release candidate
5
+ The peer range permits newer releases so npm does not impose an artificial ceiling. It does not claim that every future SDK release has been tested. Revalidate the public host seams below when adopting a new Pi release line.
6
6
 
7
- The 0.16.0 candidate passes `npm run validate` on the repository-local 0.84.4 dependency graph: build, typecheck, import check, package dry run, and 444/444 tests. Its focused context/Skill/invariant cohort passes 43/43, including discovery of the separate operational and memory-curation Skills and release-package inventory checks for both compiled Skill paths. Current and historical reads use semantic `path`/`paths`; the retired top-level `state` segment and legacy top-level `offset`/`scope` inputs are rejected. The 0.85.1 full-suite evidence below belongs to the earlier recorded source checkpoint and has not been repeated for this candidate.
7
+ ## Tested stacks
8
8
 
9
- ## Tested matrix
9
+ The current repository-local stack uses Linux/x64, Node 26.8.1, Git 2.55.0, and Pi SDK 0.84.4.
10
10
 
11
- The historical compatibility checks use Linux/x64, Node 26.8.1, Git 2.55.0, TypeScript 7.0.2 and Node types 26.4.0.
11
+ | Pi SDK stack | Validation | Evidence status |
12
+ | --- | --- | --- |
13
+ | 0.84.4 | Build, typecheck, import, package dry-run, 445/445 tests | Current full-suite baseline |
14
+ | 0.85.1 | Build, typecheck, import, full suite | Earlier compatibility baseline; not rerun for every later State Flow change |
12
15
 
13
- | Pi / AI / agent-core | SDK's pi-tui / TypeBox | Typecheck / import | Full suite |
14
- | --- | --- | --- | --- |
15
- | 0.84.4 / 0.84.4 / 0.84.4 | 0.84.4 / 1.3.7 | Pass / pass | 417/417 |
16
- | 0.85.1 / 0.85.1 / 0.85.1 | 0.85.1 / 1.3.7 | Pass / pass | 417/417 |
17
-
18
- The first row is the repository-local stack. The second used a disposable copy of the same working source, with read-only dependency links to the already installed 0.85.1 SDK and its actual AI/agent-core/pi-tui graph. All commands exited zero for the accepted results. The repository lockfile still resolves 0.84.4; broadening its root peer metadata did not install or upgrade dependencies. No live Pi process, installation, production session or state store was modified.
19
-
20
- Both stacks passed `npm run validate` after adding the four [fatal-writer interruption witnesses](temporal-acceptance.md#fatal-writer-interruption). No runtime correction was needed: runtime SHA-256 remains `ce820cd33c34c4c882c11fc55e93f3914cfc8dffae57683e32cdcaa70f225cd2` from the [context-copy correction](performance.md#context-projection-and-trajectory-selection). Sorted `index.ts`, `lib/*.ts` and `tests/*.ts`, framed as path + NUL + bytes + NUL, now yield `1c5fafc1433631fed33177381e925eeeca52827932ba327690f615d2a4db81ba`. Copied/original source, runtime, test and package bytes were checked unchanged through validation, along with both selected dependency-resolution graphs and their manifests. These hashes do not cover every installed binary or environmental influence. The [benchmark workload fingerprint](performance.md) remains `b861fae36798bf76f56f411cc654c70fc948597bb84cc7a422c83f69ba1ca3e9`. Subsequent [matched runtime-performance controls](performance.md#matched-final-candidate-controls) use the 0.84.4 graph only, not a two-SDK timing comparison. The earlier fork/context witnesses remain included; these results do not replace separate integrated-candidate acceptance.
21
-
22
- ### Post-measurement integrated acceptance
23
-
24
- After the nine-invocation performance series, inline review traced accumulated Git/CAS, provenance, worker ownership, fork/recovery, Stop/context and discovery changes against their callers and negative/native witnesses, without a confirmed blocker in that inspected closure. Separate `state-flow-integrated-0844` and `state-flow-integrated-0851` Runs repeated `npm run validate`: each passed 417/417 with no failed, cancelled, skipped or todo tests, plus typecheck and import-check, and actual command exit zero. Full captures are 47,957 and 47,950 bytes. All 78 selected runtime/test/package files and both actual dependency graphs remained unchanged; documentation is separately reviewed rather than represented by that source hash.
25
-
26
- The twenty-property map resolves its quoted witnesses; that structural check is not exhaustive semantic proof. Domain validation reports 31 source files/133 acyclic local edges and no reverse entrypoint imports, with 25 existing header warnings. Context validation reports zero errors/five warnings. The package dry run includes 44 files and no tests. This accepts the local candidate within the documented synthetic/Linux/SDK boundaries, not arbitrary-host compatibility, production-incident attribution, publication permission or remote release-CI success. That checkpoint preceded the final compaction/status slice and its intentional 0.10.0 version alignment.
27
-
28
- The final slice adds seven compaction policy/race tests and two native lifecycle witnesses, bringing the complete repository-local 0.84.4 suite to 426/426 with typecheck/import-check. On 0.85.1, all affected policy/status and native compaction/tree tests plus typecheck pass; the operator declined a redundant second full-suite repetition after this focused equivalence evidence. Native completed-history compaction preserves full JSONL/tree/state/UUID while shortening active/resumed entries without another model summary; threshold compaction before the first patch remains Pi-owned. Package version is 0.10.0. Remote release CI remains authoritative for the tagged tree.
29
-
30
- Matching versions do not imply one physical dependency instance. Before/after resolution walks retain separate root and SDK-local 0.84.4 AI/agent-core copies and three TypeBox 1.3.7 locations in the repository installation. The first row describes that actual graph, not a deduplicated installation. The disposable 0.85.1 copy explicitly shares the SDK's AI/agent-core/pi-tui instances. Record canonical resolution edges as well as versions for comparisons; no dependency installation was changed to force a verifier assumption.
31
-
32
- The focused parent-traversal and barrier cohort passed 3/3 on each stack. The earlier 0.85.1 native cohort passed 10/10: large state/answers, selected-tree restoration, quit/reload push ownership, ordinary/bootstrap mid-tool Stop, compaction/tree/restart, scoped barriers, historical reads, and sibling-tool rejection. The full suite includes those witnesses plus benchmark, persistence, provenance, migration and concurrency contracts; tests are available in a source checkout, not the published runtime package.
16
+ Only exact matching stacks that were actually exercised are test evidence. Mixed SDK versions and untested newer releases are permitted by package metadata but remain unverified.
33
17
 
34
18
  ## Public host seams
35
19
 
36
- Inspect the installed package's `dist/core/` implementations and declarations alongside upstream [SDK documentation](https://github.com/earendil-works/pi-mono/blob/v0.85.1/packages/coding-agent/docs/sdk.md). Both tested stacks retain these seams:
37
-
38
- - `session-manager.js` / `session-manager.d.ts`: Read-only extension context exposes `getLeafEntry()`, `getEntry(id)` and each entry's `parentId`. Both lookups use the native ID map; `getBranch()` instead walks to the root and reverses a newly allocated path. State Flow now uses that public parent traversal for preflight without deleting or replacing native history.
39
- - `agent-session.js`, `_handleAgentEvent()` / `_installAgentToolHooks()`, and agent-core's `agent-loop.js`: `message_end` extension handlers run before session persistence; the awaited event then appends the accepted assistant message before tool preflight. Inspect that synchronized current assistant, not a presumed last persisted assistant inside `message_end`. The native sibling-tool regression is the executable barrier witness.
40
- - `sdk.js` and `extensions/runner.js`, `emitContext()`: Pi connects context transformation to the extension runner, which first `structuredClone`s native messages. State Flow projection runs after that host copy. Reducing extension traversal cannot make total inference work independent of history size.
41
- - `agent-session.js`, `prompt()` / `reload()` / `dispose()`: The system prompt is composed at `before_agent_start`; Stop can change tools/projection but not that already composed prompt for the current run. Reload awaits `session_shutdown` before replacement. Bare disposal invalidates/disconnects without emitting shutdown; use the [embedding contract](architecture.md#embedding).
42
- - `agent-session-runtime.js`, `teardownCurrent()`: Owner-driven new/resume/fork aborts the outgoing session, awaits shutdown, then disposes and creates the replacement. Active-push witnesses now cover new, same-file resume and fork teardown before invalidation, alongside quit and actual reload. Correct teardown does not imply successful fork restoration.
43
- - `sdk.js`, `createAgentSession()`: Session selection precedes default resource/extension loading. No extension-only pre-session resolver seam was established; native default continuation remains an [external integration boundary](architecture.md#session-continuation).
44
-
45
- These observations establish the specific seams used here, not compatibility with every Pi UI mode, extension combination, provider or operating system. Native tests use deterministic faux inference, temporary sessions/stores and local remotes. An installed SDK version does not fingerprint a still-running host. Actual-process incident attribution remains unproven; final comparable measurements and integrated acceptance remain in [BACKLOG.md](../BACKLOG.md).
46
-
47
- ## Native replacement witnesses
20
+ State Flow depends on these public Pi SDK behaviors:
48
21
 
49
- `tests/pi-harness.ts` now constructs a public `AgentSessionRuntime` from its existing isolated sessions and coherent CWD-bound services, forwarding complete session-start metadata and binding each replacement. No private host hook or production runtime change was needed. The three `tests/integration.test.ts` replacement witnesses pass on both SDK stacks and require:
22
+ - Extension lifecycle events and branch metadata for start, stop, reload, resume, fork, and tree navigation.
23
+ - Read-only session parent traversal through `getLeafEntry()` and `getEntry(id)`.
24
+ - `message_end` and `turn_end` ordering before accepted assistant messages are reconciled.
25
+ - Context transformation through the extension runner.
26
+ - `getContextUsage()` and native compaction hooks.
27
+ - Sequential tool execution and tool-call preflight.
28
+ - Session replacement awaiting outgoing shutdown before invalidation.
50
29
 
51
- - An accepted multi-scope patch and answer to supersede a still-running activation push.
52
- - The exact shutdown reason/target, child exit and a claimable lease at `setBeforeSessionInvalidate()`, before a successor can start.
53
- - Byte-identical outgoing JSONL and session checkpoint/tail/config/meta, the same selected leaf, and exact cold state at the accepted revision.
54
- - An empty private layer plus inherited shared state for configured new sessions, and exact selected state/identity for same-file resume.
55
- - No old-generation relaunch or queue writes after replacement; only a valid successor attempts the exact pending target and acknowledges its controlled child result.
30
+ Compatibility with those seams does not prove every UI mode, provider, operating system, extension combination, or future SDK release.
56
31
 
57
- **Native fork replacement now creates a child-owned session copy over current shared memory.** The active-push case forks before the first user request: private state is empty at that selected origin, not the parent's later accepted private work. Current global/CWD state remains visible, and the new child publication target descends from the retained parent target. Native session naming still does not prove the child JSONL is already persisted when its prefix contains no assistant.
32
+ ## Validation procedure
58
33
 
59
- Further native witnesses cover nonempty selected private state versus newer parent/shared state, retained native prefix and checkpoint/tail data, owned reload/resume, independent child writes and aligned post-origin history. Disabled sources remain disabled and copied parent Stop projection cannot reappear after child reload. Header UUID/CWD mismatches leave storage and selection unchanged; Start retries the exact source in the same loaded fork when evidence is corrected. Selecting inherited pre-origin pointers is unavailable, not permission to fall through to an older disabled marker and erase child state. That last witness failed with `true !== false` before the targeted ownership-aware recovery correction.
34
+ Validate another Pi SDK line in an isolated copy so the live extension, sessions, and runtime store remain unchanged:
60
35
 
61
- The fork-specific 13-test focused cohort covers these native cases plus runtime copying/CAS/file-expiration and header safety. An initial prefix assertion compared persisted JSON with in-memory objects containing explicit `undefined` fields; it was corrected to compare native persisted entries, without changing runtime behavior. The earlier outgoing-shutdown falsifier failed before invalidation with `null !== 'SIGKILL'`. Controlled push children do not prove external delivery; arbitrary cross-CWD replacement and every UI mode remain outside these witnesses. Ordinary inference abort retains accepted patches for same-session continuation and has no new immediate-enqueue requirement. See [fork support and limits](usage.md#fork-support-and-limits) and the [copy contract](fork-contract.md).
62
-
63
- ## Isolated validation procedure
64
-
65
- Keep the live host and repository dependencies unchanged:
66
-
67
- 1. Copy the intended current source, including retained uncommitted files and tests, to a fresh temporary directory. Do not substitute baseline `HEAD` for a dirty candidate. Give the copy its own synthetic Git `HEAD` for benchmark source-identity checks; never copy production session/store data.
68
- 2. Create a real `node_modules` directory in the copy. Link the existing development dependencies, then link `@earendil-works/pi-coding-agent` to the selected installed SDK and AI/agent-core/pi-tui to the dependencies actually resolved by that SDK. Do not use `--preserve-symlinks` or install into the live package. Verify the SDK and extension resolve the same selected dependency instances.
69
- 3. Resolve import-only package manifests with `findPackageJSON(name, pathToFileURL(ownerManifest))`, not `require.resolve(name)`. Record canonical manifest paths, package names/versions and hashes before and after testing. For example, run the following inside the copy:
70
-
71
- ```bash
72
- node --input-type=module <<'JS'
73
- import { findPackageJSON } from 'node:module';
74
- import { readFileSync, realpathSync } from 'node:fs';
75
- import { pathToFileURL } from 'node:url';
76
- const owner = pathToFileURL(`${process.cwd()}/package.json`);
77
- for (const name of ['pi-coding-agent', 'pi-ai', 'pi-agent-core', 'pi-tui']) {
78
- const path = realpathSync(findPackageJSON(`@earendil-works/${name}`, owner));
79
- console.log(path, JSON.parse(readFileSync(path, 'utf8')).version);
80
- }
81
- JS
82
- ```
83
-
84
- Use a fresh `PI_CODING_AGENT_DIR`, `PI_OFFLINE=1`, `GIT_CONFIG_NOSYSTEM=1` and a temporary `GIT_CONFIG_GLOBAL` containing only a synthetic commit identity and disabled commit signing. Fixtures already supply in-memory credentials, no model-catalog refresh and explicit resource/session roots. Leave fixture-local Git hooks enabled: tests intentionally use temporary `pre-receive`/`post-receive` hooks.
85
-
86
- Inside that prepared copy, the compatibility checks are ordinary project commands:
36
+ 1. Copy the complete candidate source, including retained uncommitted changes, into a temporary directory.
37
+ 2. Install or link one matching version of `pi-coding-agent`, `pi-agent-core`, `pi-ai`, and `pi-tui`.
38
+ 3. Confirm all four packages resolve to the intended release line.
39
+ 4. Use isolated Pi agent/session directories and synthetic Git identity; do not copy production session or State Flow data.
40
+ 5. Run:
87
41
 
88
42
  ```bash
89
- npm run typecheck
90
- npm run check
91
- node --experimental-strip-types --test \
92
- --test-name-pattern='tool preflight walks only|patch_state is the only tool|real Pi executes only patch_state|replacement closes its outgoing publisher' \
93
- tests/extension.test.ts tests/integration.test.ts
94
- npm test
43
+ npm run validate
95
44
  ```
96
45
 
97
- `npm run validate` combines typecheck, the full suite and import smoke for subsequent candidates. Retain actual command exits, complete logs and source/dependency identities; a filtered command succeeding does not replace the full suite.
98
-
99
- ### Rejected setup and correction
100
-
101
- The first 0.85.1 full run passed 395/397 because the temporary global Git config incorrectly set `core.hooksPath` to an empty directory. This disabled two fixtures' hooks: prepared receipts no longer observed the synthetic post-push write, and file-to-Git adoption no longer observed the intentionally pending push. The same two failures reproduced on 0.84.4 with that config. Removing only the temporary override made both witnesses pass on both SDKs, followed by 397/397 on 0.85.1. No runtime or test correction was needed; the failed run is not an SDK incompatibility or a successful full-suite result.
46
+ A successful focused test does not replace the full suite. Record the exact dependency graph and command exit for any compatibility claim.
@@ -131,7 +131,7 @@ Native controls have two rather than three inferences per request and no State F
131
131
  - **Short runs are not uniformly faster:** At twenty 8 KiB requests, B2's 1132.5ms p50 exceeds A1's 1115.7ms despite fewer calls. Both B short-case p95 values exceed A1's 1140.8ms. A1/A2 also change materially without code changes: long 8 KiB run p50 is 1373.7 → 1165.1ms, while short 128 KiB is 1602.8 → 1234.7ms. Keep these observations rather than collapsing the controls into one average.
132
132
  - **No history-independent latency claim:** B1's first-context medians rise 316.6 → 387.4ms and 297.6 → 347.5ms with unchanged 31 calls; B2's 128 KiB case rises 311.9 → 388.2ms. Identical counts do not bound internal Git work, host cloning, allocation or scheduling. These samples do not isolate the cause.
133
133
  - **Projection and native trace remain distinct:** First resumed enabled contexts are exactly 9694 / 9712 bytes at 8 KiB and 132574 / 132592 at 128 KiB across all four invocations. Native first-context medians grow from about 105 KB to 1.054 MB. Preserving a compact delivered context does not bound semantic state, the full native trace or Pi's pre-projection history clone.
134
- - **Remaining evidence is separate:** This block covers the 4 KiB-read lifecycle comparison only. The larger-read pair and current large-state boundary follow below; the publisher pair also follows. Separate [integrated-candidate acceptance](compatibility.md#post-measurement-integrated-acceptance) is recorded independently. It establishes neither the production incident's cause nor real-provider latency or universal responsiveness.
134
+ - **Remaining evidence is separate:** This block covers the 4 KiB-read lifecycle comparison only. The larger-read pair and current large-state boundary follow below; the publisher pair also follows. Repository validation is recorded independently from these measurements. It establishes neither the production incident's cause nor real-provider latency or universal responsiveness.
135
135
 
136
136
  ### Larger delivered history: matched A/B pair
137
137
 
@@ -191,7 +191,7 @@ Reported mixed-attempt p50/p95 milliseconds are A/B: same-session 3.1/465.9 vers
191
191
 
192
192
  All nine planned invocations are accepted, sequential and source-bound; the seven lifecycle invocations contain 108 preserved copied probes, excluding six setup probes. Complete captures, actual exits and guards are retained with `/tmp/state-flow-final-controls-CzaHqM/final-series-receipts.json`. Lifecycle distributions were independently recomputed from retained samples; publisher aggregates have the narrower evidence boundary above.
193
193
 
194
- The candidate reduces proven Git/allocation work and shows lower enabled long-case medians in these controls while preserving selected state and accepted continuation. It does not eliminate native history handling, payload cost, timing variability or safe contention refusal. Adverse short-case/tail observations remain. No further runtime correction is justified by these measurements alone; original production slowdown attribution remains unproven. Separate [integrated review/acceptance](compatibility.md#post-measurement-integrated-acceptance), not this measurement closure, governs local candidate readiness; release authorization and remote CI remain separate gates.
194
+ The candidate reduces proven Git/allocation work and shows lower enabled long-case medians in these controls while preserving selected state and accepted continuation. It does not eliminate native history handling, payload cost, timing variability or safe contention refusal. Adverse short-case/tail observations remain. No further runtime correction is justified by these measurements alone; original production slowdown attribution remains unproven. Separate repository validation, not this measurement closure, governs local candidate readiness; release authorization and remote CI remain separate gates.
195
195
 
196
196
  The following older datasets retain their own source identities and adverse observations; they are not substituted for this matched final-runtime comparison.
197
197
 
@@ -24,7 +24,7 @@ The child starts at step zero and a new temporal origin. Its copied tail is pres
24
24
 
25
25
  Initial copying requires a native fork start event, a regular canonical direct-parent session file, matching CWD/identity, a readable temporal source and an unused child namespace. Missing/unsafe evidence or CAS conflicts leave the copy unavailable rather than importing unrelated or newer private state. Explicit Start can retry an unaccepted copy in the same loaded fork after the cause is corrected.
26
26
 
27
- Selecting a copied parent checkpoint through the child's `/tree` does not make it child-owned: State Flow stays disabled without resetting existing child data. Select a child-owned checkpoint or resume the original session. Cold recovery before the first child checkpoint, startup paths lacking the fork event, in-memory parent locators and cross-CWD imports remain outside this slice. File-only copying requires an exact still-available source cohort. See the [contract](fork-contract.md) and [SDK evidence](compatibility.md#native-replacement-witnesses); do not rewrite UUIDs or delete pointers to force recovery.
27
+ Selecting a copied parent checkpoint through the child's `/tree` does not make it child-owned: State Flow stays disabled without resetting existing child data. Select a child-owned checkpoint or resume the original session. Cold recovery before the first child checkpoint, startup paths lacking the fork event, in-memory parent locators and cross-CWD imports remain outside this slice. File-only copying requires an exact still-available source cohort. See the [contract](fork-contract.md) and [SDK compatibility boundary](compatibility.md#public-host-seams); do not rewrite UUIDs or delete pointers to force recovery.
28
28
 
29
29
  ## Configuration
30
30
 
@@ -662,10 +662,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
662
662
  pi.registerTool({
663
663
  name: PATCH_STATE_TOOL_NAME,
664
664
  label: "Patch State",
665
- 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.",
666
- promptSnippet: "Atomically patch global/cwd/session; final:true permits a later turn_end",
665
+ description: "The sole State Flow semantic mutation protocol. Supply any combination of global, cwd, and session patches; all supplied scopes commit atomically. In an active State Flow episode, set final:true when the current iteration may finish at a later turn_end; use {final:true} when no semantic update is needed. Passive turns have no terminal barrier: never call patch_state only to set final:true. This call must be the only State Flow barrier in its assistant response.",
666
+ promptSnippet: "Atomically patch global/cwd/session; active episodes use final:true before turn_end",
667
667
  promptGuidelines: [
668
- "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.",
668
+ "Use patch_state for durable semantic changes. Only in an active State Flow episode, make the iteration terminal-eligible before a final answer with final:true, optionally alongside atomic global/cwd/session patches. In passive mode, never call patch_state only to set final:true.",
669
669
  "Use patch_state as reconciliation, not append-only notes: place new knowledge at the narrowest valid scope and remove superseded or completed state from touched branches.",
670
670
  "Call patch_state alone in an assistant response; after its acknowledgement, further reasoning, tools, and later patch_state calls remain allowed.",
671
671
  ],
@@ -91,7 +91,7 @@ export function assistantToolCallCount(content: unknown): number {
91
91
  return content.filter((block) => typeof block === "object" && block !== null && (block as { type?: unknown }).type === "toolCall").length;
92
92
  }
93
93
 
94
- /** The post-handler assistant message is authoritative; State Flow does not parse service comments. */
94
+ /** The accepted post-handler assistant text is authoritative. */
95
95
  export function finalizedAssistantResponse(message: AgentMessage): string {
96
96
  if (message.role !== "assistant" || !Array.isArray(message.content)) {
97
97
  throw new Error("Finalized State Flow turn does not contain an assistant response");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -66,10 +66,10 @@
66
66
  "node": ">=22.19.0"
67
67
  },
68
68
  "peerDependencies": {
69
- "@earendil-works/pi-agent-core": "^0.84.4 || ^0.85.1",
70
- "@earendil-works/pi-ai": "^0.84.4 || ^0.85.1",
71
- "@earendil-works/pi-coding-agent": "^0.84.4 || ^0.85.1",
72
- "@earendil-works/pi-tui": "^0.84.4 || ^0.85.1"
69
+ "@earendil-works/pi-agent-core": ">=0.84.4",
70
+ "@earendil-works/pi-ai": ">=0.84.4",
71
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
72
+ "@earendil-works/pi-tui": ">=0.84.4"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@earendil-works/pi-tui": "0.84.4",
@@ -121,7 +121,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
121
121
  - Command templates remain compact and shell-free. Use string leaves or ordered `template` arrays; shell operators are not an execution contract. Examples use portable executable placeholders, never machine-local paths.
122
122
  - `telegram_attach` is the canonical file path and `telegram_message` the direct Markdown text/buttons path. Both require current direct or registered-follower authority and must not replace the normal active-turn reply.
123
123
  - Inbound handlers transform text/media before queueing; outbound handlers precede programmatic/provider fallbacks. Public contracts and ordering live in `docs/inbound.md`, `docs/outbound.md`, and `docs/public-api.md`.
124
- - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through an internal Pi command via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; the command handler receives the real `ExtensionCommandContext` and calls `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
124
+ - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
125
125
 
126
126
  ## 7. Engineering Conventions
127
127
 
@@ -4,6 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.50.0: Internal lifecycle gateway and thinking cadence
8
+
9
+ - `Internal lifecycle gateway`: Replaces the session-specific technical command with one `/telegram-internal` gateway for runtime-armed typed actions. Manual invocation performs no action and explains that the command cannot be run manually; settled `/new` replacement retains the same lifecycle and terminal-result guarantees.
10
+ - `Thinking cadence`: Thinking now follows the same cadence as answer drafts: it accumulates the opening frame for two seconds, then updates at most once every two seconds while still flushing buffered reasoning when the block completes.
11
+
7
12
  ## 0.49.0: Unified fresh-session continuity
8
13
 
9
14
  - `/new`: Classic and Threaded Mode now share one confirmed fresh-session flow. The settled callback deletes its dialog and publishes an expiring exact-target intent; a same- or cross-process successor preserves the classic chat or re-keys the Thread/slot/name, atomically claims once, then sends one terminal result. Identity mismatches fail closed.
@@ -126,7 +126,7 @@ Enable the optional capabilities the bridge needs in the [@BotFather](https://t.
126
126
  | Model and thinking | Switch model or thinking level from Telegram through safe continuation flows. | Mobile control can adjust execution strategy without tearing down the current session. |
127
127
  | Compaction | Confirm `/compact`, show native active status during compaction, and preserve Telegram-owned turn semantics. | Context maintenance is visible and safe from the phone. |
128
128
  | Draft previews | Show Telegram's native `…typing` indicator whenever the connected instance is doing agent work, or enable Rich Draft previews for streamed answer text. | Local prompts, Telegram turns, and autonomous continuations remain visibly active while draft visibility stays independent from final rendering. |
129
- | Activity | Keep the default `verbose` technical surface, show only `thinking`, show only `tools`, or select `quiet` for answer-only delivery. Every instance reloads this shared file-backed choice before a new agent run; thinking uses a headerless expandable quote, while each tool uses one iconless closed root row containing nested evidence details. | Persistent collapsed technical activity minimizes chat height and stays bounded, redacted, target-fenced, free of URL previews, and visually separate from semantic assistant answers. |
129
+ | Activity | Keep the default `verbose` technical surface, show only `thinking`, show only `tools`, or select `quiet` for answer-only delivery. Every instance reloads this shared file-backed choice before a new agent run; thinking accumulates for two seconds and then updates at most every two seconds in a headerless expandable quote, while each tool uses one iconless closed root row containing nested evidence details. | Persistent collapsed technical activity minimizes chat height and stays bounded, redacted, target-fenced, free of URL previews, and visually separate from semantic assistant answers. |
130
130
  | Assistant rendering | Choose Native Rich Markdown or legacy Markdown-to-HTML for final assistant replies. | Renderer compatibility is explicit instead of being conflated with draft previews. |
131
131
  | Bridge UI rendering | Render thinking through headerless expandable HTML with inline emphasis/code, render each tool as an iconless native Rich root details tree with immediately visible arguments and collapsed secondary evidence, and keep menus, queue controls, status, settings, diagnostics, and sections on Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
132
132
  | Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
@@ -11,7 +11,7 @@ export declare const TELEGRAM_ACTIVITY_MESSAGE_MAX_CHARS = 3900;
11
11
  export declare const TELEGRAM_ACTIVITY_MESSAGE_MAX_TOOLS = 6;
12
12
  export declare const TELEGRAM_REASONING_MESSAGE_MAX_FRAMES = 24;
13
13
  export declare const TELEGRAM_REASONING_BUFFER_MAX_CHARS = 1200;
14
- export declare const TELEGRAM_REASONING_MIN_INTERVAL_MS = 1200;
14
+ export declare const TELEGRAM_REASONING_MIN_INTERVAL_MS = 2000;
15
15
  export declare const TELEGRAM_TOOL_UPDATE_MAX_ENTRIES = 4;
16
16
  interface ToolActivity {
17
17
  id: string;
@@ -41,6 +41,8 @@ export declare function createTelegramActivityVerbosityRuntime<TAuthority>(deps:
41
41
  getActivityMode: () => "quiet" | "thinking" | "tools" | "verbose";
42
42
  refreshActivityMode?: () => Promise<void>;
43
43
  getNowMs?: () => number;
44
+ setReasoningTimeout?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
45
+ clearReasoningTimeout?: (timer: ReturnType<typeof setTimeout>) => void;
44
46
  resolveTarget: (event: TelegramActivityEvent) => TelegramTarget | undefined;
45
47
  captureAuthority: () => TAuthority;
46
48
  isAuthorityActive: (authority: TAuthority) => boolean;
@@ -9,7 +9,9 @@ export const TELEGRAM_ACTIVITY_MESSAGE_MAX_CHARS = 3_900;
9
9
  export const TELEGRAM_ACTIVITY_MESSAGE_MAX_TOOLS = 6;
10
10
  export const TELEGRAM_REASONING_MESSAGE_MAX_FRAMES = 24;
11
11
  export const TELEGRAM_REASONING_BUFFER_MAX_CHARS = 1_200;
12
- export const TELEGRAM_REASONING_MIN_INTERVAL_MS = 1_200;
12
+ // Match native answer drafts: accumulate the opening frame for one full
13
+ // interval, then publish at most one updated frame per interval.
14
+ export const TELEGRAM_REASONING_MIN_INTERVAL_MS = 2_000;
13
15
  export const TELEGRAM_TOOL_UPDATE_MAX_ENTRIES = 4;
14
16
  function targetEquals(left, right) {
15
17
  return left.chatId === right.chatId && left.threadId === right.threadId;
@@ -234,10 +236,18 @@ export function createTelegramActivityVerbosityRuntime(deps) {
234
236
  let reasoningMessage;
235
237
  let reasoningBlocked = false;
236
238
  let lastReasoningPublishMs = 0;
239
+ let reasoningFlushTimer;
237
240
  let toolMessage;
238
241
  const tools = new Map();
239
242
  const toolOrder = [];
243
+ const clearReasoningFlushTimer = () => {
244
+ if (!reasoningFlushTimer)
245
+ return;
246
+ (deps.clearReasoningTimeout ?? clearTimeout)(reasoningFlushTimer);
247
+ reasoningFlushTimer = undefined;
248
+ };
240
249
  const clearActivity = () => {
250
+ clearReasoningFlushTimer();
241
251
  activityId = undefined;
242
252
  authority = undefined;
243
253
  target = undefined;
@@ -330,6 +340,30 @@ export function createTelegramActivityVerbosityRuntime(deps) {
330
340
  deps.recordFailure?.(canEdit ? "reasoning-edit" : "reasoning-send", event, error);
331
341
  }
332
342
  };
343
+ const scheduleReasoningPublish = (event, acceptedGeneration) => {
344
+ if (reasoningFlushTimer || reasoningBlocked)
345
+ return;
346
+ const elapsed = reasoningMessageFrames === 0
347
+ ? 0
348
+ : getNowMs() - lastReasoningPublishMs;
349
+ const delayMs = Math.max(0, TELEGRAM_REASONING_MIN_INTERVAL_MS - elapsed);
350
+ const schedule = deps.setReasoningTimeout ?? setTimeout;
351
+ reasoningFlushTimer = schedule(() => {
352
+ reasoningFlushTimer = undefined;
353
+ const admittedAuthority = authority;
354
+ const task = async () => {
355
+ if (!isCurrent(acceptedGeneration, admittedAuthority) ||
356
+ reasoningChars <= lastReasoningMessageChars)
357
+ return;
358
+ await publishReasoning(event, acceptedGeneration);
359
+ };
360
+ const enqueue = deps.enqueue ?? ((next) => tail.then(next));
361
+ tail = enqueue(task).catch((error) => {
362
+ deps.recordFailure?.("reasoning-send", event, error);
363
+ });
364
+ }, delayMs);
365
+ reasoningFlushTimer?.unref?.();
366
+ };
333
367
  const publishTool = async (event, tool, acceptedGeneration) => {
334
368
  const admittedAuthority = authority;
335
369
  if (!isCurrent(acceptedGeneration, admittedAuthority) || !target) {
@@ -461,12 +495,8 @@ export function createTelegramActivityVerbosityRuntime(deps) {
461
495
  return;
462
496
  reasoningChars += event.delta.length;
463
497
  reasoningBuffer = `${reasoningBuffer}${event.delta}`.slice(-TELEGRAM_REASONING_BUFFER_MAX_CHARS);
464
- if (reasoningMessageFrames < TELEGRAM_REASONING_MESSAGE_MAX_FRAMES &&
465
- (reasoningMessageFrames === 0 ||
466
- (getNowMs() - lastReasoningPublishMs >=
467
- TELEGRAM_REASONING_MIN_INTERVAL_MS &&
468
- reasoningChars - lastReasoningMessageChars >= 160))) {
469
- await publishReasoning(event, acceptedGeneration);
498
+ if (reasoningMessageFrames < TELEGRAM_REASONING_MESSAGE_MAX_FRAMES) {
499
+ scheduleReasoningPublish(event, acceptedGeneration);
470
500
  }
471
501
  return;
472
502
  }
@@ -477,6 +507,7 @@ export function createTelegramActivityVerbosityRuntime(deps) {
477
507
  reasoningChars = event.text.length;
478
508
  reasoningBuffer = event.text.slice(-TELEGRAM_REASONING_BUFFER_MAX_CHARS);
479
509
  }
510
+ clearReasoningFlushTimer();
480
511
  if (reasoningChars > 0 &&
481
512
  reasoningChars > lastReasoningMessageChars &&
482
513
  !reasoningBlocked) {
@@ -550,8 +581,8 @@ export function createTelegramActivityVerbosityRuntime(deps) {
550
581
  return;
551
582
  }
552
583
  if (event.type === "agent-end" || event.type === "agent-settled") {
553
- if (reasoningMessage &&
554
- reasoningChars > lastReasoningMessageChars &&
584
+ clearReasoningFlushTimer();
585
+ if (reasoningChars > lastReasoningMessageChars &&
555
586
  !reasoningBlocked) {
556
587
  await publishReasoning(event, acceptedGeneration);
557
588
  }
@@ -574,8 +574,9 @@ export declare function createTelegramCommandHandler<TMessage extends TelegramCo
574
574
  export declare function createTelegramCommandOrPromptRuntime<TMessage, TContext>(deps: TelegramCommandOrPromptRuntimeDeps<TMessage, TContext>): {
575
575
  dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
576
576
  };
577
- export declare const TELEGRAM_SESSION_ACTION_COMMAND_NAME = "telegram-session-action";
578
- export declare const TELEGRAM_SESSION_ACTION_COMMAND_DESCRIPTION = "(internal) replace the current Pi session after Telegram settlement";
577
+ export declare const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
578
+ export declare const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION = "(internal) dispatch one settled Telegram lifecycle action";
579
+ export declare const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE = "This internal Telegram command cannot be run manually.";
579
580
  export declare function delayTelegramSessionAction(delayMs: number): Promise<void>;
580
581
  export interface TelegramSessionActionRuntimeDeps {
581
582
  registerCommand: Pi.ExtensionAPI["registerCommand"];
@@ -1232,8 +1232,9 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1232
1232
  },
1233
1233
  }, commandArgs);
1234
1234
  }
1235
- export const TELEGRAM_SESSION_ACTION_COMMAND_NAME = "telegram-session-action";
1236
- export const TELEGRAM_SESSION_ACTION_COMMAND_DESCRIPTION = "(internal) replace the current Pi session after Telegram settlement";
1235
+ export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
1236
+ export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION = "(internal) dispatch one settled Telegram lifecycle action";
1237
+ export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE = "This internal Telegram command cannot be run manually.";
1237
1238
  export function delayTelegramSessionAction(delayMs) {
1238
1239
  return new Promise((resolve) => setTimeout(resolve, delayMs));
1239
1240
  }
@@ -1364,8 +1365,7 @@ export function createTelegramSessionActionAssembly(deps) {
1364
1365
  export function createTelegramSessionActionRuntime(deps) {
1365
1366
  let pendingUpdateId;
1366
1367
  let pendingTarget;
1367
- let commandPending = false;
1368
- let commandUpdateId;
1368
+ let pendingAction;
1369
1369
  let registered = false;
1370
1370
  const reportFailure = (error) => {
1371
1371
  try {
@@ -1380,33 +1380,34 @@ export function createTelegramSessionActionRuntime(deps) {
1380
1380
  if (registered)
1381
1381
  return;
1382
1382
  registered = true;
1383
- deps.registerCommand(TELEGRAM_SESSION_ACTION_COMMAND_NAME, {
1384
- description: TELEGRAM_SESSION_ACTION_COMMAND_DESCRIPTION,
1383
+ deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
1384
+ description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
1385
1385
  handler: async (_args, ctx) => {
1386
- if (!commandPending)
1386
+ const action = pendingAction;
1387
+ if (!action) {
1388
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
1387
1389
  return;
1388
- commandPending = false;
1389
- const target = pendingTarget;
1390
- const updateId = commandUpdateId;
1391
- pendingTarget = undefined;
1392
- commandUpdateId = undefined;
1393
- if (!target || updateId === undefined)
1394
- return;
1395
- try {
1396
- await deps.prepareReplacement?.(ctx, updateId, target);
1397
- const result = await ctx.newSession();
1398
- if (result.cancelled)
1399
- await deps.notifyResult(target, "cancelled");
1400
1390
  }
1401
- catch (error) {
1402
- reportFailure(error);
1403
- await deps.notifyResult(target, "failure");
1391
+ pendingAction = undefined;
1392
+ switch (action.kind) {
1393
+ case "replace-session":
1394
+ try {
1395
+ await deps.prepareReplacement?.(ctx, action.updateId, action.target);
1396
+ const result = await ctx.newSession();
1397
+ if (result.cancelled)
1398
+ await deps.notifyResult(action.target, "cancelled");
1399
+ }
1400
+ catch (error) {
1401
+ reportFailure(error);
1402
+ await deps.notifyResult(action.target, "failure");
1403
+ }
1404
+ return;
1404
1405
  }
1405
1406
  },
1406
1407
  });
1407
1408
  },
1408
1409
  scheduleAfterUpdate(updateId, target) {
1409
- if (pendingUpdateId !== undefined || commandPending)
1410
+ if (pendingUpdateId !== undefined || pendingAction !== undefined)
1410
1411
  return false;
1411
1412
  pendingUpdateId = updateId;
1412
1413
  pendingTarget = { ...target };
@@ -1416,21 +1417,22 @@ export function createTelegramSessionActionRuntime(deps) {
1416
1417
  if (pendingUpdateId !== updateId)
1417
1418
  return;
1418
1419
  pendingUpdateId = undefined;
1419
- commandUpdateId = updateId;
1420
- commandPending = true;
1420
+ const target = pendingTarget;
1421
+ pendingTarget = undefined;
1422
+ if (!target)
1423
+ return;
1424
+ pendingAction = { kind: "replace-session", updateId, target };
1421
1425
  void Promise.resolve()
1422
- .then(() => deps.sendUserMessage(`/${TELEGRAM_SESSION_ACTION_COMMAND_NAME}`, {
1426
+ .then(() => deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME}`, {
1423
1427
  expandPromptTemplates: true,
1424
1428
  }))
1425
1429
  .catch((error) => {
1426
- commandPending = false;
1427
- commandUpdateId = undefined;
1428
- pendingTarget = undefined;
1430
+ pendingAction = undefined;
1429
1431
  reportFailure(error);
1430
1432
  });
1431
1433
  },
1432
1434
  hasPending() {
1433
- return pendingUpdateId !== undefined || commandPending;
1435
+ return pendingUpdateId !== undefined || pendingAction !== undefined;
1434
1436
  },
1435
1437
  };
1436
1438
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -610,7 +610,7 @@ Queue reaction behavior, lane-tail transitions, Keep/Skip independence, multi-re
610
610
 
611
611
  Complete intermediate assistant text blocks from Telegram-originated activity are sent once to the immutable originating target before active-turn final delivery; final and terminal-partial segments stay with settlement so replies are not duplicated. While this instance has exact direct or follower transport authority, completed public blocks from local/autonomous work are always sent once and in source order to the instance's authorized target. Connected companion projection is not configurable; disconnect or authority loss is its boundary. Both paths use the configured Rich or HTML renderer and exclude reasoning, tool traffic, token deltas, local prompt text, unknown sources, and stale generations. Each admitted block remains fenced to its exact target, profile/token stamp, leader epoch or follower registration generation, and session generation; non-idempotent acknowledgement ambiguity never authorizes replay.
612
612
 
613
- `assistant.activity` is an independent bridge-owned projection over normalized Activity events. Each process reloads the shared file-backed setting at `agent-start` before activity admission, so multi-instance mode cannot continue projecting a stale broader process-local selection. Omitted values resolve to `verbose`, while invalid values fail closed to `quiet`; `thinking` and `tools` select one technical class, while `verbose` enables both. Provider-exposed thinking uses persistent ordinary HTML containing only a standard expandable blockquote with a bounded redacted latest-text window and inline Markdown rendered as Telegram HTML. Completed executed tools use native Rich Messages: each closed `<Tool>: <status>` root details node renders snake-case names as title words while preserving an uppercase two- or three-letter repeated prefix per word, then the native disclosure chevron reveals an open-by-default `arguments` child plus closed retained `update N` and `result`/`error` child details with lowercase monospaced, marker-free summaries and JSON pre blocks; known-safe Rich rejections fall back to the previous HTML disclosure. The projection captures the exact target and transport stamp at activity admission, serializes updates, preserves tool-start order, closes coalescing across assistant/thinking boundaries, bounds retained text/update memory plus edit frames and message/tool size, disables previews and HTTP(S) auto-link recognition inside technical evidence, and never replays a possibly committed send. Session generations own independent queues, so replacement drops queued old work without waiting on an old call. Bridge-owned assistant output and activity projection share one activity-publication sequence admitted synchronously from the activity bus, so later tool disclosures cannot overtake earlier assistant blocks. Activity targets and transport authority are captured before queued work starts. Active Telegram-turn final replies/artifacts and automatic compaction notices use that same publication owner before the existing direct/follower transport split, without mutually waiting on separate output tails. Final settlement captures the exact active turn and assistant result and reserves its publication position before config loading. Empty outcomes without a publication candidate reserve no position. Replacement, preparation failure, or an unused reservation releases the position without resetting or replying for a replacement turn. Final errors also publish through this owner. Reservations accept one task; cancellation cannot undo a published task, and session reset releases unresolved reservations while fencing old queued tasks. Terminal assistant messages with a publication candidate reserve the final position synchronously; settlement consumes that reservation only for the exact originating turn. Compaction notices enter the same queue immediately, behind the reserved final rather than through a separate notice buffer. Settlement, a new agent run, or session replacement cancels an unconsumed reservation. Notice target and authority are captured when the event is observed. Pi lifecycle completion does not wait for network publication. This order is process-local and does not serialize unrelated instances or independent registered activity handlers. Settlement, replacement, disconnect, failure, or stale authority clears only local ownership; already-sent activity messages remain in chat.
613
+ `assistant.activity` is an independent bridge-owned projection over normalized Activity events. Each process reloads the shared file-backed setting at `agent-start` before activity admission, so multi-instance mode cannot continue projecting a stale broader process-local selection. Omitted values resolve to `verbose`, while invalid values fail closed to `quiet`; `thinking` and `tools` select one technical class, while `verbose` enables both. Provider-exposed thinking uses persistent ordinary HTML containing only a standard expandable blockquote with a bounded redacted latest-text window and inline Markdown rendered as Telegram HTML. Like answer drafts, it accumulates for two seconds before the first frame, publishes at most one update every two seconds, and flushes remaining buffered reasoning on terminal completion. Completed executed tools use native Rich Messages: each closed `<Tool>: <status>` root details node renders snake-case names as title words while preserving an uppercase two- or three-letter repeated prefix per word, then the native disclosure chevron reveals an open-by-default `arguments` child plus closed retained `update N` and `result`/`error` child details with lowercase monospaced, marker-free summaries and JSON pre blocks; known-safe Rich rejections fall back to the previous HTML disclosure. The projection captures the exact target and transport stamp at activity admission, serializes updates, preserves tool-start order, closes coalescing across assistant/thinking boundaries, bounds retained text/update memory plus edit frames and message/tool size, disables previews and HTTP(S) auto-link recognition inside technical evidence, and never replays a possibly committed send. Session generations own independent queues, so replacement drops queued old work without waiting on an old call. Bridge-owned assistant output and activity projection share one activity-publication sequence admitted synchronously from the activity bus, so later tool disclosures cannot overtake earlier assistant blocks. Activity targets and transport authority are captured before queued work starts. Active Telegram-turn final replies/artifacts and automatic compaction notices use that same publication owner before the existing direct/follower transport split, without mutually waiting on separate output tails. Final settlement captures the exact active turn and assistant result and reserves its publication position before config loading. Empty outcomes without a publication candidate reserve no position. Replacement, preparation failure, or an unused reservation releases the position without resetting or replying for a replacement turn. Final errors also publish through this owner. Reservations accept one task; cancellation cannot undo a published task, and session reset releases unresolved reservations while fencing old queued tasks. Terminal assistant messages with a publication candidate reserve the final position synchronously; settlement consumes that reservation only for the exact originating turn. Compaction notices enter the same queue immediately, behind the reserved final rather than through a separate notice buffer. Settlement, a new agent run, or session replacement cancels an unconsumed reservation. Notice target and authority are captured when the event is observed. Pi lifecycle completion does not wait for network publication. This order is process-local and does not serialize unrelated instances or independent registered activity handlers. Settlement, replacement, disconnect, failure, or stale authority clears only local ownership; already-sent activity messages remain in chat.
614
614
 
615
615
  Telegram prompt guidance is context- and authority-aware. The package and source-checkout extension contribute `telegram-bridge`, optional `generated-control-surface`, and `generative-apps` Skills through Pi resource discovery. Generated Control Surface treats `interface = f(state, capabilities, intent)` as a renderer-neutral primitive, compiling transient evidence-backed controls over domain-owned workflows, systems, navigation, supervision, and decisions without creating parallel application state. It composes an ordered ragged sequence of independently sized semantic rows rather than filling a rectangular grid: compact rows contain genuine peers, singleton rows isolate structurally independent actions, and rectangular layouts remain reserved for genuinely spatial state. Text-bearing controls use at most two columns and flow into additional rows, while denser rows are reserved for short position-bearing glyphs or codes and never exceed the eight-column phone-width UX maximum. Vertical extent is independent: a true spatial surface may retain substantially more rows, while non-spatial button walls route to grouping, disclosure, or pagination. Symmetry is treated as an evidence claim about equal relationships or real spatial topology; an abstract layout catalog supplies adaptable singleton, peer, staged, navigational, repeated-pair, and rectangular shapes without forcing tasks into preset grids. Repeated controls carry the smallest sufficient action delta when visible conversation is unambiguous; larger or error-prone state moves to a deterministic task-owned Markdown artifact, correctness-sensitive transitions move to a small domain-owned transition implementation, and repeated clicks are adjudicated against current state rather than stale button appearance. Its filesystem adapter reserves the first full-width row for parent traversal outside root, places available Previous/Next controls together in one compact row immediately afterward, orders visible directories, hidden directories, visible files, and hidden files alphabetically within each category before fixed ten-entry pagination, renders path/range metadata as stacked status-style key-value rows instead of middle-dot prose, emits the complete Telegram control set through one JSON-matrix action, suppresses duplicate plain/monospaced listings and default Refresh unless user preference overrides presentation, and retains an ordinary numbered fallback when buttons are unavailable. Only an exact direct owner or live registered follower exposes the two pi-telegram delivery tools, their active-tool metadata, and the compact routing suffix. Disconnect or authority loss removes those tool surfaces for subsequent requests without touching foreign tools; reconnect/recovery restores only the pi-telegram subset that was active before suspension, including across same-process reload. Repeated stable interactions may graduate from that model-mediated surface into a reviewed Generative App whose deterministic bound methods bypass Pi queue admission; the `generative-apps` Skill owns this compilation and operating workflow while the underlying capability retains domain authority. Telegram-originated turns route to the stable Skill contracts and retain dynamic blocks such as `[voice] delivery: automatic voice`; the Skills and public documentation own syntax, target routing, Threaded Mode behavior, Generative App operation, and diagnostics.
616
616
 
@@ -51,7 +51,7 @@ Stable commands inside Pi:
51
51
  Stable commands inside the paired Telegram DM:
52
52
 
53
53
  - `/start` — pair when needed and open the main application menu.
54
- - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches an internal Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Its real `ExtensionCommandContext` calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
54
+ - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
55
55
  - `/compact` — open confirmation and compact when idle.
56
56
  - `/next` — abort active work first when needed, let the interrupted prompt receive its abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. The command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
57
  - `/continue` — enqueue a priority `continue` prompt.
@@ -24,7 +24,9 @@ export const TELEGRAM_ACTIVITY_MESSAGE_MAX_CHARS = 3_900;
24
24
  export const TELEGRAM_ACTIVITY_MESSAGE_MAX_TOOLS = 6;
25
25
  export const TELEGRAM_REASONING_MESSAGE_MAX_FRAMES = 24;
26
26
  export const TELEGRAM_REASONING_BUFFER_MAX_CHARS = 1_200;
27
- export const TELEGRAM_REASONING_MIN_INTERVAL_MS = 1_200;
27
+ // Match native answer drafts: accumulate the opening frame for one full
28
+ // interval, then publish at most one updated frame per interval.
29
+ export const TELEGRAM_REASONING_MIN_INTERVAL_MS = 2_000;
28
30
  export const TELEGRAM_TOOL_UPDATE_MAX_ENTRIES = 4;
29
31
 
30
32
  interface ToolActivity {
@@ -325,6 +327,11 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
325
327
  getActivityMode: () => "quiet" | "thinking" | "tools" | "verbose";
326
328
  refreshActivityMode?: () => Promise<void>;
327
329
  getNowMs?: () => number;
330
+ setReasoningTimeout?: (
331
+ callback: () => void,
332
+ delayMs: number,
333
+ ) => ReturnType<typeof setTimeout>;
334
+ clearReasoningTimeout?: (timer: ReturnType<typeof setTimeout>) => void;
328
335
  resolveTarget: (event: TelegramActivityEvent) => TelegramTarget | undefined;
329
336
  captureAuthority: () => TAuthority;
330
337
  isAuthorityActive: (authority: TAuthority) => boolean;
@@ -360,11 +367,18 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
360
367
  let reasoningMessage: ReasoningMessage | undefined;
361
368
  let reasoningBlocked = false;
362
369
  let lastReasoningPublishMs = 0;
370
+ let reasoningFlushTimer: ReturnType<typeof setTimeout> | undefined;
363
371
  let toolMessage: ToolMessage | undefined;
364
372
  const tools = new Map<string, ToolActivity>();
365
373
  const toolOrder: string[] = [];
366
374
 
375
+ const clearReasoningFlushTimer = () => {
376
+ if (!reasoningFlushTimer) return;
377
+ (deps.clearReasoningTimeout ?? clearTimeout)(reasoningFlushTimer);
378
+ reasoningFlushTimer = undefined;
379
+ };
367
380
  const clearActivity = () => {
381
+ clearReasoningFlushTimer();
368
382
  activityId = undefined;
369
383
  authority = undefined;
370
384
  target = undefined;
@@ -466,6 +480,33 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
466
480
  );
467
481
  }
468
482
  };
483
+ const scheduleReasoningPublish = (
484
+ event: TelegramActivityEvent,
485
+ acceptedGeneration: number,
486
+ ) => {
487
+ if (reasoningFlushTimer || reasoningBlocked) return;
488
+ const elapsed = reasoningMessageFrames === 0
489
+ ? 0
490
+ : getNowMs() - lastReasoningPublishMs;
491
+ const delayMs = Math.max(0, TELEGRAM_REASONING_MIN_INTERVAL_MS - elapsed);
492
+ const schedule = deps.setReasoningTimeout ?? setTimeout;
493
+ reasoningFlushTimer = schedule(() => {
494
+ reasoningFlushTimer = undefined;
495
+ const admittedAuthority = authority;
496
+ const task = async () => {
497
+ if (
498
+ !isCurrent(acceptedGeneration, admittedAuthority) ||
499
+ reasoningChars <= lastReasoningMessageChars
500
+ ) return;
501
+ await publishReasoning(event, acceptedGeneration);
502
+ };
503
+ const enqueue = deps.enqueue ?? ((next: () => Promise<void>) => tail.then(next));
504
+ tail = enqueue(task).catch((error) => {
505
+ deps.recordFailure?.("reasoning-send", event, error);
506
+ });
507
+ }, delayMs) as ReturnType<typeof setTimeout>;
508
+ reasoningFlushTimer?.unref?.();
509
+ };
469
510
  const publishTool = async (
470
511
  event: TelegramActivityEvent,
471
512
  tool: ToolActivity,
@@ -602,13 +643,8 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
602
643
  reasoningBuffer = `${reasoningBuffer}${event.delta}`.slice(
603
644
  -TELEGRAM_REASONING_BUFFER_MAX_CHARS,
604
645
  );
605
- if (reasoningMessageFrames < TELEGRAM_REASONING_MESSAGE_MAX_FRAMES &&
606
- (reasoningMessageFrames === 0 ||
607
- (getNowMs() - lastReasoningPublishMs >=
608
- TELEGRAM_REASONING_MIN_INTERVAL_MS &&
609
- reasoningChars - lastReasoningMessageChars >= 160))
610
- ) {
611
- await publishReasoning(event, acceptedGeneration);
646
+ if (reasoningMessageFrames < TELEGRAM_REASONING_MESSAGE_MAX_FRAMES) {
647
+ scheduleReasoningPublish(event, acceptedGeneration);
612
648
  }
613
649
  return;
614
650
  }
@@ -620,6 +656,7 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
620
656
  -TELEGRAM_REASONING_BUFFER_MAX_CHARS,
621
657
  );
622
658
  }
659
+ clearReasoningFlushTimer();
623
660
  if (
624
661
  reasoningChars > 0 &&
625
662
  reasoningChars > lastReasoningMessageChars &&
@@ -687,8 +724,8 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
687
724
  return;
688
725
  }
689
726
  if (event.type === "agent-end" || event.type === "agent-settled") {
727
+ clearReasoningFlushTimer();
690
728
  if (
691
- reasoningMessage &&
692
729
  reasoningChars > lastReasoningMessageChars &&
693
730
  !reasoningBlocked
694
731
  ) {
@@ -2335,9 +2335,11 @@ async function handleTelegramCommandRuntime<
2335
2335
  );
2336
2336
  }
2337
2337
 
2338
- export const TELEGRAM_SESSION_ACTION_COMMAND_NAME = "telegram-session-action";
2339
- export const TELEGRAM_SESSION_ACTION_COMMAND_DESCRIPTION =
2340
- "(internal) replace the current Pi session after Telegram settlement";
2338
+ export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
2339
+ export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION =
2340
+ "(internal) dispatch one settled Telegram lifecycle action";
2341
+ export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE =
2342
+ "This internal Telegram command cannot be run manually.";
2341
2343
 
2342
2344
  export function delayTelegramSessionAction(delayMs: number): Promise<void> {
2343
2345
  return new Promise<void>((resolve) => setTimeout(resolve, delayMs));
@@ -2538,6 +2540,12 @@ export function createTelegramSessionActionAssembly(
2538
2540
  return { action, settlement };
2539
2541
  }
2540
2542
 
2543
+ type TelegramPendingInternalAction = {
2544
+ kind: "replace-session";
2545
+ updateId: number;
2546
+ target: { chatId: number; threadId?: number; messageId: number };
2547
+ };
2548
+
2541
2549
  export interface TelegramSessionActionRuntime {
2542
2550
  register: () => void;
2543
2551
  scheduleAfterUpdate: (
@@ -2557,8 +2565,7 @@ export function createTelegramSessionActionRuntime(
2557
2565
  threadId?: number;
2558
2566
  messageId: number;
2559
2567
  } | undefined;
2560
- let commandPending = false;
2561
- let commandUpdateId: number | undefined;
2568
+ let pendingAction: TelegramPendingInternalAction | undefined;
2562
2569
  let registered = false;
2563
2570
 
2564
2571
  const reportFailure = (error: unknown): void => {
@@ -2573,29 +2580,32 @@ export function createTelegramSessionActionRuntime(
2573
2580
  register() {
2574
2581
  if (registered) return;
2575
2582
  registered = true;
2576
- deps.registerCommand(TELEGRAM_SESSION_ACTION_COMMAND_NAME, {
2577
- description: TELEGRAM_SESSION_ACTION_COMMAND_DESCRIPTION,
2583
+ deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
2584
+ description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
2578
2585
  handler: async (_args, ctx) => {
2579
- if (!commandPending) return;
2580
- commandPending = false;
2581
- const target = pendingTarget;
2582
- const updateId = commandUpdateId;
2583
- pendingTarget = undefined;
2584
- commandUpdateId = undefined;
2585
- if (!target || updateId === undefined) return;
2586
- try {
2587
- await deps.prepareReplacement?.(ctx, updateId, target);
2588
- const result = await ctx.newSession();
2589
- if (result.cancelled) await deps.notifyResult(target, "cancelled");
2590
- } catch (error) {
2591
- reportFailure(error);
2592
- await deps.notifyResult(target, "failure");
2586
+ const action = pendingAction;
2587
+ if (!action) {
2588
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
2589
+ return;
2590
+ }
2591
+ pendingAction = undefined;
2592
+ switch (action.kind) {
2593
+ case "replace-session":
2594
+ try {
2595
+ await deps.prepareReplacement?.(ctx, action.updateId, action.target);
2596
+ const result = await ctx.newSession();
2597
+ if (result.cancelled) await deps.notifyResult(action.target, "cancelled");
2598
+ } catch (error) {
2599
+ reportFailure(error);
2600
+ await deps.notifyResult(action.target, "failure");
2601
+ }
2602
+ return;
2593
2603
  }
2594
2604
  },
2595
2605
  });
2596
2606
  },
2597
2607
  scheduleAfterUpdate(updateId, target) {
2598
- if (pendingUpdateId !== undefined || commandPending) return false;
2608
+ if (pendingUpdateId !== undefined || pendingAction !== undefined) return false;
2599
2609
  pendingUpdateId = updateId;
2600
2610
  pendingTarget = { ...target };
2601
2611
  return true;
@@ -2603,23 +2613,23 @@ export function createTelegramSessionActionRuntime(
2603
2613
  onUpdateCompleted(updateId) {
2604
2614
  if (pendingUpdateId !== updateId) return;
2605
2615
  pendingUpdateId = undefined;
2606
- commandUpdateId = updateId;
2607
- commandPending = true;
2616
+ const target = pendingTarget;
2617
+ pendingTarget = undefined;
2618
+ if (!target) return;
2619
+ pendingAction = { kind: "replace-session", updateId, target };
2608
2620
  void Promise.resolve()
2609
2621
  .then(() =>
2610
- deps.sendUserMessage(`/${TELEGRAM_SESSION_ACTION_COMMAND_NAME}`, {
2622
+ deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME}`, {
2611
2623
  expandPromptTemplates: true,
2612
2624
  }),
2613
2625
  )
2614
2626
  .catch((error) => {
2615
- commandPending = false;
2616
- commandUpdateId = undefined;
2617
- pendingTarget = undefined;
2627
+ pendingAction = undefined;
2618
2628
  reportFailure(error);
2619
2629
  });
2620
2630
  },
2621
2631
  hasPending() {
2622
- return pendingUpdateId !== undefined || commandPending;
2632
+ return pendingUpdateId !== undefined || pendingAction !== undefined;
2623
2633
  },
2624
2634
  };
2625
2635
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
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.17.1",
3
+ "version": "0.18.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.10.0",
46
46
  "@llblab/pi-grow-loop": "0.8.1",
47
- "@llblab/pi-state-flow": "0.16.2",
48
- "@llblab/pi-telegram": "0.49.0",
47
+ "@llblab/pi-state-flow": "0.16.3",
48
+ "@llblab/pi-telegram": "0.50.0",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [