@llblab/pi-kit 0.9.1 โ†’ 0.10.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.10.1 - 2026-09-11
6
+
7
+ - `State Flow Telegram Surface`: Advances the exact State Flow pin to `0.9.2`, so State Flow appears in Telegram exactly once: the pre-Settings section button carries the live status (`๐ŸŒ€ State Flow: #N`, `โšซ๏ธ State Flow` while off) and the duplicated Status-screen row is removed. The package set, resource inventory, and explicit load order remain unchanged.
8
+ - `Grow Loop Status Text`: Advances the exact Grow Loop pin to `0.8.1`, mirroring the terminal status literally as `Grow Loop โˆžN` with no waiting, running, or countdown decoration. The package set, resource inventory, and explicit load order remain unchanged.
9
+ - `Queue Refresh Icon`: Advances the exact Telegram pin to `0.45.5`, so the queue menu's Refresh button renders `๐Ÿ”„ Refresh` while `๐ŸŒ€` stays the State Flow identity. The package set, resource inventory, and explicit load order remain unchanged.
10
+
11
+ ## 0.10.0 - 2026-09-11
12
+
13
+ - `State Flow Telegram Surface`: Advances the exact State Flow pin to `0.9.0`, adding the optional fail-open Telegram status line (`State Flow: on ยท step #N`) and the inline main-menu section button before Settings with Start, Stop, Refresh, and deferred-start control for the current session branch. The package set, resource inventory, and explicit load order remain unchanged.
14
+ - `Grow Loop Telegram Mirror`: Advances the exact Grow Loop pin to `0.8.0`, mirroring the visible loop rhythm (`Grow Loop: #N ยท waiting`, `#N ยท Ns`, `#N ยท running`) into the Telegram status screen with the same fail-open, no-scheduling-change guarantees. The package set, resource inventory, and explicit load order remain unchanged.
15
+
5
16
  ## 0.9.1 - 2026-09-11
6
17
 
7
18
  - `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.
package/README.md CHANGED
@@ -13,9 +13,9 @@ Package links lead to the owning repositories for usage, documentation, issues,
13
13
  | [`@llblab/pi-actors`](https://github.com/llblab/pi-actors) | `0.53.0` | Inspectable local Runs, reusable Recipes, persistent tools, and delegation Skills |
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
- | [`@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.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 |
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.9.2` | Atomic scoped state cohorts with final-eligibility resolution |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.5` | 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.
@@ -27,6 +27,7 @@
27
27
  - There is no slash-command control surface. Any ordinary user prompt exits the active runtime rhythm by clearing pending scheduling and hiding loop status; restart/continuation intent belongs to the agent and Grow Loop skill, not to a runtime latch or regex.
28
28
  - No start slash commands, budgets, cycle counts, hidden processes, or background agents.
29
29
  - Loop status is dynamic: show warning-colored `grow-loop โˆžN` only while the next iteration is deferred until idle, `grow-loop Ns` only during the configured countdown, and dim `grow-loop โˆžN` only while a loop-scheduled turn is active. Clear active status only after Pi fully settles without an armed successor; low-level run endings may still lead to retry or compaction recovery. Any user input except the runtime's exact expected continuation prompt clears pending loop scheduling and hides loop status, including operator input injected through another extension.
30
+ - Telegram status mirror: when `pi-telegram` is installed, the optional fail-open status-line adapter mirrors the same identity (`Grow Loop: โˆžN`) and hides with it. It reads the same runtime state, registers once per process lifetime, disposes on session shutdown, and a missing or unready transport changes nothing.
30
31
  - Grow Loop does not own Escape/abort semantics; Escape remains baseline Pi behavior for active agent turns. The runtime does not block future `grow_loop` tool calls; the skill contract owns whether recent user context means continue, stop, restart, or change direction.
31
32
 
32
33
  ## Topology
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1: Telegram Status Text Hotfix
4
+
5
+ - `Status Text`: The Telegram Status screen now copies the compact terminal identity exactly: `Grow Loop: โˆžN` for the armed or running iteration, without countdown, phase, or separator text. The row still hides whenever the terminal status is hidden.
6
+
7
+ ## 0.8.0: Telegram Status Mirror
8
+
9
+ - `Telegram Mirror`: With the optional `pi-telegram` extension installed, the Telegram Status screen mirrors the same loop rhythm as the terminal status and hides with it: `Grow Loop: #N ยท waiting` while the next prompt is armed until idle, `#N ยท 2.4s` during the configured countdown, and `#N ยท running` while the loop-scheduled turn is active.
10
+ - `Compatibility`: The mirror is optional and fail-open, reads the same runtime state, registers once, disposes on session shutdown, and changes no scheduling behavior; absence of the transport changes nothing.
11
+
3
12
  ## 0.7.5: Status Identity Hotfix
4
13
 
5
14
  - `Status Identity`: Labels deferred, countdown, and active runtime status as `grow-loop` instead of the ambiguous `loop`, matching the extension's public identity and the neighboring State Flow status convention.
@@ -150,6 +150,8 @@ The tool never blocks future calls. Whether to continue belongs to the agent and
150
150
 
151
151
  `N` is monotonic within the current extension instance and advances only once when a turn arms its deferred successor. Repeated `grow_loop` calls before that turn ends retain `N`, replace the pending delay, and report that the iteration was already scheduled. Active status clears when the scheduled agent run fully settles without arming a successor, so automatic retry or compaction recovery does not produce a false idle state. There is no `grow-loop stopped` or `grow-loop paused` status; absence of Grow Loop status means the runtime rhythm is no longer active.
152
152
 
153
+ With the optional `pi-telegram` extension installed, the Telegram Status screen mirrors the same identity and stays hidden whenever the terminal status is hidden: `Grow Loop: โˆžN` for the armed or running iteration, with no countdown or phase text. The mirror reads the same runtime state, fails open without the transport, and changes no scheduling behavior.
154
+
153
155
  ## Interruption Model
154
156
 
155
157
  Any user prompt except the scheduler's exact expected continuation prompt exits the active runtime rhythm. This includes operator input delivered through Telegram, RPC bridges, or other extensions:
@@ -14,16 +14,47 @@ const DEFAULT_FOLLOW_UP_DELAY_MS = 3000;
14
14
  const DEFAULT_COUNTDOWN_TICK_MS = 100;
15
15
  const MIN_AFTER_SECONDS = 3;
16
16
  const MAX_AFTER_SECONDS = 3600;
17
+ const TELEGRAM_STATUS_IMPORT_SPECIFIERS = [
18
+ "@llblab/pi-telegram/status",
19
+ new URL("../pi-telegram/api/status.ts", import.meta.url).href,
20
+ ];
17
21
 
18
22
  type Timer = ReturnType<typeof setTimeout> & { unref?: () => void };
19
23
  type PendingIteration = {
20
24
  interval: Timer;
21
25
  timeout?: Timer;
26
+ countdownStartedAt?: number;
27
+ countdownDelayMs?: number;
22
28
  };
23
29
 
30
+ export interface GrowLoopTelegramProgress {
31
+ iteration: number;
32
+ state: "waiting" | "countdown" | "running";
33
+ remainingSeconds?: number;
34
+ }
35
+
36
+ export interface GrowLoopTelegramStatusLine {
37
+ label: string;
38
+ value: string;
39
+ }
40
+
41
+ export type GrowLoopTelegramStatusProvider = () => GrowLoopTelegramStatusLine | undefined;
42
+ export type GrowLoopTelegramStatusRegistrar = (
43
+ provider: GrowLoopTelegramStatusProvider,
44
+ ) => (() => void) | undefined;
45
+
46
+ interface TelegramStatusLineModule {
47
+ registerTelegramStatusLineProvider?: (
48
+ provider: GrowLoopTelegramStatusProvider,
49
+ options: { id: string },
50
+ ) => () => void;
51
+ }
52
+
24
53
  type GrowLoopOptions = {
25
54
  followUpDelayMs?: number;
26
55
  countdownTickMs?: number;
56
+ /** Injection seam for the optional pi-telegram status line; defaults to the public pi-telegram membrane. */
57
+ registerTelegramStatusLine?: GrowLoopTelegramStatusRegistrar;
27
58
  };
28
59
 
29
60
  export function buildGrowLoopPrompt(): string {
@@ -39,6 +70,26 @@ export function getExistingExtensionSkillPaths(extensionUrl: string): string[] {
39
70
  return existsSync(skillsDir) ? [skillsDir] : [];
40
71
  }
41
72
 
73
+ export function formatGrowLoopTelegramValue(progress: GrowLoopTelegramProgress): string {
74
+ return `โˆž${progress.iteration}`;
75
+ }
76
+
77
+ async function registerGrowLoopTelegramStatus(
78
+ provider: GrowLoopTelegramStatusProvider,
79
+ ): Promise<(() => void) | undefined> {
80
+ for (const specifier of TELEGRAM_STATUS_IMPORT_SPECIFIERS) {
81
+ try {
82
+ const imported = (await import(specifier)) as TelegramStatusLineModule;
83
+ if (typeof imported.registerTelegramStatusLineProvider === "function") {
84
+ return imported.registerTelegramStatusLineProvider(provider, { id: "@llblab/pi-grow-loop" });
85
+ }
86
+ } catch {
87
+ // pi-telegram is optional; its absence only disables the Telegram status line.
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+
42
93
  function statusCountdown(ctx: ExtensionContext, seconds: number) {
43
94
  const theme = ctx.ui.theme;
44
95
  ctx.ui.setStatus(
@@ -82,20 +133,21 @@ function scheduleIteration(
82
133
  iteration: number,
83
134
  clearPending: () => void,
84
135
  expectOwnPrompt: () => void,
85
- options: Required<GrowLoopOptions>,
136
+ options: Required<Pick<GrowLoopOptions, "followUpDelayMs" | "countdownTickMs">>,
86
137
  ): PendingIteration {
87
- let countdownStartedAt: number | undefined;
88
138
  statusDeferred(ctx, iteration);
89
139
  const pending = {} as PendingIteration;
90
140
  pending.interval = setInterval(() => {
91
- if (!countdownStartedAt) {
141
+ if (pending.countdownStartedAt === undefined) {
92
142
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
93
- countdownStartedAt = Date.now();
143
+ pending.countdownStartedAt = Date.now();
144
+ pending.countdownDelayMs = options.followUpDelayMs;
94
145
  statusCountdown(ctx, options.followUpDelayMs / 1000);
95
146
  pending.timeout = setTimeout(() => {
96
147
  pending.timeout = undefined;
97
148
  if (!ctx.isIdle() || ctx.hasPendingMessages()) {
98
- countdownStartedAt = undefined;
149
+ pending.countdownStartedAt = undefined;
150
+ pending.countdownDelayMs = undefined;
99
151
  statusDeferred(ctx, iteration);
100
152
  return;
101
153
  }
@@ -105,7 +157,7 @@ function scheduleIteration(
105
157
  pending.timeout.unref?.();
106
158
  return;
107
159
  }
108
- const elapsed = Date.now() - countdownStartedAt;
160
+ const elapsed = Date.now() - pending.countdownStartedAt;
109
161
  const remainingMs = Math.max(options.followUpDelayMs - elapsed, 0);
110
162
  if (remainingMs > 0) statusCountdown(ctx, remainingMs / 1000);
111
163
  }, options.countdownTickMs) as Timer;
@@ -122,23 +174,59 @@ export default function growLoopExtension(
122
174
  partialOptions.followUpDelayMs ?? DEFAULT_FOLLOW_UP_DELAY_MS,
123
175
  countdownTickMs:
124
176
  partialOptions.countdownTickMs ?? DEFAULT_COUNTDOWN_TICK_MS,
177
+ registerTelegramStatusLine: partialOptions.registerTelegramStatusLine,
125
178
  };
126
179
  let iteration = 0;
127
180
  let lastCtx: ExtensionContext | undefined;
128
181
  let pendingIteration: PendingIteration | undefined;
129
182
  let ownPromptPending = false;
130
183
  let scheduledThisTurn = false;
184
+ let runningIteration: number | undefined;
185
+ let unregisterTelegramStatus: (() => void) | undefined;
186
+ let telegramRegistration: Promise<void> | undefined;
187
+ let telegramGeneration = 0;
131
188
  const clearPending = () => {
132
189
  if (!pendingIteration) return;
133
190
  if (pendingIteration.timeout) clearTimeout(pendingIteration.timeout);
134
191
  clearInterval(pendingIteration.interval);
135
192
  pendingIteration = undefined;
136
193
  };
194
+ const telegramStatusProvider = (): GrowLoopTelegramStatusLine | undefined => {
195
+ if (pendingIteration) {
196
+ const state = pendingIteration.countdownStartedAt === undefined ? "waiting" : "countdown";
197
+ return { label: "Grow Loop", value: formatGrowLoopTelegramValue({ iteration, state }) };
198
+ }
199
+ if (runningIteration !== undefined) {
200
+ return { label: "Grow Loop", value: formatGrowLoopTelegramValue({ iteration: runningIteration, state: "running" }) };
201
+ }
202
+ return undefined;
203
+ };
204
+ const ensureTelegramStatusRegistered = () => {
205
+ if (unregisterTelegramStatus || telegramRegistration) return;
206
+ if (options.registerTelegramStatusLine) {
207
+ unregisterTelegramStatus = options.registerTelegramStatusLine(telegramStatusProvider) ?? undefined;
208
+ return;
209
+ }
210
+ const generation = telegramGeneration;
211
+ telegramRegistration = registerGrowLoopTelegramStatus(telegramStatusProvider)
212
+ .then((unregister) => {
213
+ if (generation !== telegramGeneration) {
214
+ unregister?.();
215
+ return;
216
+ }
217
+ unregisterTelegramStatus = unregister;
218
+ })
219
+ .finally(() => {
220
+ if (generation === telegramGeneration) telegramRegistration = undefined;
221
+ });
222
+ };
137
223
  const hideLoopStatus = (ctx: ExtensionContext) => {
138
224
  ownPromptPending = false;
225
+ runningIteration = undefined;
139
226
  clearPending();
140
227
  ctx.ui.setStatus(STATUS_KEY, undefined);
141
228
  };
229
+ ensureTelegramStatusRegistered();
142
230
  pi.on("resources_discover", async () => {
143
231
  const skillPaths = getExistingExtensionSkillPaths(import.meta.url);
144
232
  if (skillPaths.length === 0) return;
@@ -147,12 +235,23 @@ export default function growLoopExtension(
147
235
  pi.on("session_shutdown", async () => {
148
236
  ownPromptPending = false;
149
237
  scheduledThisTurn = false;
238
+ runningIteration = undefined;
150
239
  clearPending();
240
+ telegramGeneration += 1;
241
+ unregisterTelegramStatus?.();
242
+ unregisterTelegramStatus = undefined;
243
+ telegramRegistration = undefined;
151
244
  lastCtx?.ui.setStatus(STATUS_KEY, undefined);
152
245
  });
246
+ pi.on("session_start", async () => {
247
+ ensureTelegramStatusRegistered();
248
+ });
153
249
  pi.on("agent_settled", async (_event, ctx) => {
154
250
  lastCtx = ctx;
155
- if (!pendingIteration) ctx.ui.setStatus(STATUS_KEY, undefined);
251
+ if (!pendingIteration) {
252
+ runningIteration = undefined;
253
+ ctx.ui.setStatus(STATUS_KEY, undefined);
254
+ }
156
255
  });
157
256
  pi.on("input", async (event, ctx) => {
158
257
  lastCtx = ctx;
@@ -194,6 +293,7 @@ export default function growLoopExtension(
194
293
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
195
294
  lastCtx = ctx;
196
295
  ownPromptPending = false;
296
+ runningIteration = undefined;
197
297
  clearPending();
198
298
  const isReschedule = scheduledThisTurn;
199
299
  if (!isReschedule) {
@@ -212,8 +312,9 @@ export default function growLoopExtension(
212
312
  clearPending,
213
313
  () => {
214
314
  ownPromptPending = true;
315
+ runningIteration = nextIteration;
215
316
  },
216
- { ...options, followUpDelayMs: delayMs },
317
+ { followUpDelayMs: delayMs, countdownTickMs: options.countdownTickMs },
217
318
  );
218
319
  return {
219
320
  content: [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-grow-loop",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "Semantic loop-engineering for agent-owned, visible, interruptible continuation in Pi",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  - `/state-flow-stop` 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. It must retain a same-physical-session bounded passive context handoff across reload, resume, and tree restoration so disabling semantics never re-exposes pre-stop raw history. 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.
44
44
  - 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.
45
45
  - Do not claim strict boundedness for state, the current run trajectory, the turn specification, or the external full trace.
46
- - Remain extension-agnostic: do not import, name, special-case, or encode policy for any other extension or transport.
46
+ - 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.
47
47
  - Activate State Flow model tools only while enabled on the selected branch; preserve every unrelated active tool when toggling them. Keep mutation confined to `patch_state` and historical observation read-only.
48
48
  - Keep `.github/workflows/release.yml` as the sole version-tag release owner: it validates immutable tag identity, publishes through npm Trusted Publisher with provenance, verifies the public package, and only then creates the GitHub Release. Keep package, lockfile, tag, and changelog versions aligned; never add a long-lived npm token fallback.
49
49
  - Run `npm run validate` after retained code changes.
@@ -1,6 +1,6 @@
1
1
  # BACKLOG
2
2
 
3
- Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.8.0.
3
+ Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.9.0.
4
4
 
5
5
  ## Candidate evolution
6
6
 
@@ -8,6 +8,9 @@ Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementatio
8
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
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
10
  - Status: candidate, not scheduled. Do not start without a dedicated release contract.
11
+ - [ ] **Telegram submenu state analyzer:** Extend the State Flow Telegram section with a read-only analyzer view over the same diagnostics `/state-flow-status` already reports (branch mode, temporal head and hot depth, scope keys, retained tails, artifact freshness, publication). The operator deliberately deferred this beyond the 0.9.0 control surface.
12
+ - Boundary: presentation only. Reuse existing status diagnostics; no new semantic mode, and never mutate state from the analyzer view.
13
+ - Status: candidate, not scheduled. Do not start without a dedicated release contract.
11
14
 
12
15
  ## Deferred host integration
13
16
 
@@ -2,6 +2,21 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.9.2: Single Telegram surface
6
+
7
+ - `Interop`: State Flow now appears on Telegram exactly once. The inline main-menu section button before Settings carries the live status (`๐ŸŒ€ State Flow: #N`, `โšซ๏ธ State Flow` while off) and opens the existing start/stop submenu; the duplicated Status-screen row is removed, so the same value no longer renders in two places. Fail-open registration and shutdown disposal are unchanged.
8
+
9
+ ## 0.9.1: Status text mirrors the terminal line
10
+
11
+ - `Interop`: The `pi-telegram` surfaces now copy the compact terminal status instead of decorating it. The Status screen shows the terminal value itself (`State Flow: #N`) and hides entirely while State Flow is off; the main-menu button shows `๐ŸŒ€ State Flow: #N` or a plain `โšซ๏ธ State Flow` without on/off, starting, bootstrap, or separator text.
12
+ - `Testing`: The shared test harness removes its temporary fixture repositories at process exit; runaway fixtures previously exhausted the `/tmp` inode table and made later runs fail with ENOSPC.
13
+
14
+ ## 0.9.0: Telegram status surface
15
+
16
+ - `Interop`: Added an optional fail-open `pi-telegram` presentation adapter. With the transport installed, the status screen shows `State Flow: on ยท step #N` while enabled, and the inline main menu places a `๐ŸŒ€ State Flow: #N` or `โšซ๏ธ State Flow: off` section button directly before Settings.
17
+ - `Control`: The section submenu exposes the branch lifecycle already owned by the commands: Start, Stop, Refresh, and Cancel start while a start deferred behind an active run is pending. A busy start waits for the current run to settle, Stop applies immediately, and the adapter calls the same start/stop functions as `/state-flow-start` and `/state-flow-stop`.
18
+ - `Compatibility`: Registration fails open when pi-telegram is absent or its section registry is not ready, retries on session start, and disposes on session shutdown. Core semantics, storage, inference, and hosts without pi-telegram are unchanged.
19
+
5
20
  ## 0.8.1: Preserved answers and bounded fallback resolution
6
21
 
7
22
  - `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.
@@ -66,6 +66,8 @@ Stopping changes the current session/branch configuration, preserves semantic ch
66
66
 
67
67
  The compact status is an accent `state-flow` and dim `#<step>`. Detailed status distinguishes runtime config/meta from semantic materialization, reports scope keys, the selected temporal head and runtime revision, available hot offsets, per-scope retained patch tails, artifact counts, stale Markdown reasons, and pending publication. Retained tails may include inherited pre-origin records, so their counts are not active history depth. It labels global/CWD/session/effective state without dumping source bodies. Failed inspection means unavailable evidence, never a falsely clean count.
68
68
 
69
+ When the optional `pi-telegram` extension is installed, State Flow appears exactly once in Telegram: the inline main menu places a `๐ŸŒ€ State Flow: #N` or `โšซ๏ธ State Flow` section button directly before Settings, and that button's label carries the live status. The button opens a compact control submenu for the current session branch: `Start` or `Stop`, `Refresh`, and `Cancel start` while a start deferred behind an active run is pending. Starting while a run is active waits for that run to settle; stopping applies immediately. Registration fails open when the transport is absent, retries when the section registry becomes ready, and disposes on session shutdown; the `/state-flow-*` commands remain the authority and nothing changes for hosts without `pi-telegram`.
70
+
69
71
  ## State, scope, and time
70
72
 
71
73
  Every scope has exactly the same semantic shape:
@@ -19,7 +19,7 @@ The extension owns durable memory while enabled. Global semantic memory is alway
19
19
  - `memory`: external promotion records and memory diagnostics.
20
20
  - `continuation`: native-header discovery, runtime-provenance inspection, deterministic recommendation, and host startup precedence.
21
21
  - `publication`: remote policy, durable CAS queue/store, cross-process leases, and asynchronous worker lifecycle.
22
- - `status`, `extension`: operator projection and Pi adapter wiring.
22
+ - `status`, `telegram`, `extension`: operator projection, the optional fail-open pi-telegram presentation adapter, and Pi adapter wiring.
23
23
 
24
24
  ## Semantic state
25
25
 
@@ -199,6 +199,8 @@ 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
+ With `pi-telegram` installed, the optional `telegram` adapter mirrors the same identity in Telegram: the main-menu section button before Settings carries the live `๐ŸŒ€ State Flow: #N` or `โšซ๏ธ State Flow` label and opens the branch start/stop submenu. Registration is fail-open and retried on session start, disposal happens on session shutdown, the adapter reads the same snapshot as the compact terminal status, and its start/stop actions call the same functions as the commands.
203
+
202
204
  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
205
 
204
206
  ## Validation boundaries
@@ -185,4 +185,22 @@ export {
185
185
  type StateScope,
186
186
  type TerminalTransition
187
187
  } from "./lib/state.ts";
188
+ export {
189
+ buildStateFlowSectionView,
190
+ createStateFlowTelegramAdapter,
191
+ formatStateFlowSectionLabel,
192
+ loadStateFlowTelegramModules,
193
+ STATE_FLOW_TELEGRAM_ID,
194
+ type StateFlowTelegramAdapter,
195
+ type StateFlowTelegramButton,
196
+ type StateFlowTelegramCallbackContext,
197
+ type StateFlowTelegramControlResult,
198
+ type StateFlowTelegramLoader,
199
+ type StateFlowTelegramModules,
200
+ type StateFlowTelegramPort,
201
+ type StateFlowTelegramSectionContext,
202
+ type StateFlowTelegramSectionModule,
203
+ type StateFlowTelegramSnapshot,
204
+ type StateFlowTelegramView
205
+ } from "./lib/telegram.ts";
188
206
  export { advanceTemporalState, readTemporalState, type TemporalState } from "./lib/temporal.ts";
@@ -7,6 +7,7 @@ import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol }
7
7
  import { createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, runtimeContextMessage, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
8
8
  import { ArtifactReadTracker } from "./acquisition.ts";
9
9
  import { loadStateFlowConfig } from "./config.ts";
10
+ import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
10
11
  import { isAbsolute, relative, resolve, sep } from "node:path";
11
12
  import { SkillReadTracker } from "./skills.ts";
12
13
  import { emptySnapshot, migrationFailure, persistableSnapshot, type Snapshot } from "./snapshot.ts";
@@ -44,6 +45,7 @@ export interface StateFlowExtensionOptions {
44
45
  repositoryRoot?: string;
45
46
  knowledgeRoot?: string;
46
47
  onRuntime?: (accessor: { read(offset?: number, scope?: StateScope): MaterializedState }) => void;
48
+ telegram?: { load?: StateFlowTelegramLoader };
47
49
  }
48
50
 
49
51
  export const PATCH_STATE_TOOL_NAME = "patch_state";
@@ -85,6 +87,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
85
87
  const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(agentDir));
86
88
  let artifactInvalidations: ArtifactInvalidationRequest[] = [];
87
89
  let loggingWarningReported = false;
90
+ let telegramStartPending = false;
88
91
 
89
92
  function sessionAddress(ctx: ExtensionContext): SessionAddress {
90
93
  return resolveSessionAddress(ctx.sessionManager.getSessionFile(), ctx.sessionManager.getSessionId(), ctx.sessionManager.getHeader()?.timestamp);
@@ -455,6 +458,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
455
458
  artifactInvalidations = [];
456
459
  artifactReads.setCandidates([]);
457
460
  artifactRefreshPending = false;
461
+ telegramStartPending = false;
458
462
  activeContext = ctx;
459
463
  const session = sessionAddress(ctx);
460
464
  runtime = new TemporalRuntime(ctx.cwd, session, repositoryRoot);
@@ -748,74 +752,79 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
748
752
  },
749
753
  });
750
754
 
751
- pi.registerCommand("state-flow-start", {
752
- description: "Start State Flow mode",
753
- handler: async (_args, ctx) => {
754
- if (!activeContext) restoreActiveBranch(ctx);
755
- const previousSnapshot = structuredClone(snapshot);
756
- const previousPassiveContinuation = passiveContinuation;
757
- const previousBootstrapContinuation = bootstrapContinuation;
758
- const previousArtifactRefreshPending = artifactRefreshPending;
759
- const previousArtifactInvalidations = structuredClone(artifactInvalidations);
760
- try {
761
- if (!runtime?.view && !snapshot.meta.durableBase && !branchStartsWithoutRuntime) {
762
- throw new Error("Selected branch revision is unavailable; restore its original Git history before starting State Flow");
763
- }
764
- const branch = ctx.sessionManager.getBranch();
765
- activeContext = ctx;
766
- runtime ??= createRuntime(ctx);
767
- if (branchStartsWithoutRuntime) runtime.prepare();
768
- const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
769
- && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
770
- if (!runtime.view && snapshot.meta.durableBase) {
771
- snapshot = runtime.restore(snapshot.meta.durableBase, snapshot);
772
- setPendingPublication(snapshot.meta.pendingPublication);
773
- branchHasSnapshot = true;
774
- }
775
- const existingBranch = branchHasSnapshot;
776
- snapshot = existingBranch
777
- ? resumeEpisode(snapshot, bootstrap)
778
- : startEpisode(bootstrap);
779
- if (snapshot.meta.remotePublication === undefined) {
780
- snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
781
- resolveRemotePublicationPolicy(existingBranch ? undefined : config.remotePublication, { legacyRuntime: existingBranch }),
782
- );
783
- }
755
+ function startStateFlow(ctx: ExtensionContext): StateFlowTelegramControlResult {
756
+ if (!activeContext) restoreActiveBranch(ctx);
757
+ telegramStartPending = false;
758
+ const previousSnapshot = structuredClone(snapshot);
759
+ const previousPassiveContinuation = passiveContinuation;
760
+ const previousBootstrapContinuation = bootstrapContinuation;
761
+ const previousArtifactRefreshPending = artifactRefreshPending;
762
+ const previousArtifactInvalidations = structuredClone(artifactInvalidations);
763
+ try {
764
+ if (!runtime?.view && !snapshot.meta.durableBase && !branchStartsWithoutRuntime) {
765
+ throw new Error("Selected branch revision is unavailable; restore its original Git history before starting State Flow");
766
+ }
767
+ const branch = ctx.sessionManager.getBranch();
768
+ activeContext = ctx;
769
+ runtime ??= createRuntime(ctx);
770
+ if (branchStartsWithoutRuntime) runtime.prepare();
771
+ const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
772
+ && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
773
+ if (!runtime.view && snapshot.meta.durableBase) {
774
+ snapshot = runtime.restore(snapshot.meta.durableBase, snapshot);
775
+ setPendingPublication(snapshot.meta.pendingPublication);
784
776
  branchHasSnapshot = true;
785
- const publication = runtime.view
786
- ? runtime.promote(snapshot) ?? runtime.publish(snapshot)
787
- : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
788
- recordPolicyPublication(publication, ctx);
789
- installScopeStates();
790
- delete snapshot.legacySession;
791
- clearRunTransient();
792
- passiveContinuation = undefined;
793
- bootstrapContinuation = snapshot.meta.bootstrap
794
- ? previousPassiveContinuation ?? previousBootstrapContinuation
795
- : undefined;
796
- deferArtifactRefresh();
797
- syncStateFlowTools();
798
- persist();
799
- updateUi(ctx);
800
- ctx.ui.notify(
801
- snapshot.meta.bootstrap
802
- ? "State Flow enabled. The next complete agent run will migrate active context into state."
803
- : "State Flow enabled. The next prompt starts a stateful agent run.",
804
- "info",
805
- );
806
- } catch (error) {
807
- snapshot = previousSnapshot;
808
- passiveContinuation = previousPassiveContinuation;
809
- bootstrapContinuation = previousBootstrapContinuation;
810
- artifactRefreshPending = previousArtifactRefreshPending;
811
- artifactInvalidations = previousArtifactInvalidations;
812
- artifactReads.setCandidates(artifactInvalidations);
813
- syncStateFlowTools();
814
- ctx.ui.notify(
815
- `State Flow could not initialize CWD state: ${error instanceof Error ? error.message : String(error)}`,
816
- "error",
777
+ }
778
+ const existingBranch = branchHasSnapshot;
779
+ snapshot = existingBranch
780
+ ? resumeEpisode(snapshot, bootstrap)
781
+ : startEpisode(bootstrap);
782
+ if (snapshot.meta.remotePublication === undefined) {
783
+ snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
784
+ resolveRemotePublicationPolicy(existingBranch ? undefined : config.remotePublication, { legacyRuntime: existingBranch }),
817
785
  );
818
786
  }
787
+ branchHasSnapshot = true;
788
+ const publication = runtime.view
789
+ ? runtime.promote(snapshot) ?? runtime.publish(snapshot)
790
+ : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
791
+ recordPolicyPublication(publication, ctx);
792
+ installScopeStates();
793
+ delete snapshot.legacySession;
794
+ clearRunTransient();
795
+ passiveContinuation = undefined;
796
+ bootstrapContinuation = snapshot.meta.bootstrap
797
+ ? previousPassiveContinuation ?? previousBootstrapContinuation
798
+ : undefined;
799
+ deferArtifactRefresh();
800
+ syncStateFlowTools();
801
+ persist();
802
+ updateUi(ctx);
803
+ ctx.ui.notify(
804
+ snapshot.meta.bootstrap
805
+ ? "State Flow enabled. The next complete agent run will migrate active context into state."
806
+ : "State Flow enabled. The next prompt starts a stateful agent run.",
807
+ "info",
808
+ );
809
+ return { ok: true, message: "State Flow enabled" };
810
+ } catch (error) {
811
+ snapshot = previousSnapshot;
812
+ passiveContinuation = previousPassiveContinuation;
813
+ bootstrapContinuation = previousBootstrapContinuation;
814
+ artifactRefreshPending = previousArtifactRefreshPending;
815
+ artifactInvalidations = previousArtifactInvalidations;
816
+ artifactReads.setCandidates(artifactInvalidations);
817
+ syncStateFlowTools();
818
+ const message = `State Flow could not initialize CWD state: ${error instanceof Error ? error.message : String(error)}`;
819
+ ctx.ui.notify(message, "error");
820
+ return { ok: false, message };
821
+ }
822
+ }
823
+
824
+ pi.registerCommand("state-flow-start", {
825
+ description: "Start State Flow mode",
826
+ handler: async (_args, ctx) => {
827
+ startStateFlow(ctx);
819
828
  },
820
829
  });
821
830
 
@@ -826,47 +835,85 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
826
835
  },
827
836
  });
828
837
 
838
+ function stopStateFlow(ctx: ExtensionContext): StateFlowTelegramControlResult {
839
+ if (!activeContext) restoreActiveBranch(ctx);
840
+ telegramStartPending = false;
841
+ let selected = runtime;
842
+ let current = snapshot;
843
+ if (!selected?.view && snapshot.meta.durableBase) {
844
+ selected = createRuntime(ctx);
845
+ current = selected.restore(snapshot.meta.durableBase, snapshot);
846
+ }
847
+ const stoppedAt = Date.now();
848
+ const exitStates = selected?.view ? selected.states() : undefined;
849
+ const exitHandoff = current.config.enabled && exitStates
850
+ ? createPassiveContinuation(
851
+ projectStateForModel(overlayStates(exitStates.global, exitStates.cwd, exitStates.session)),
852
+ stoppedAt,
853
+ )
854
+ : undefined;
855
+ const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
856
+ const stopped = stopEpisode(current);
857
+ const publication = selected?.view ? selected.publish(stopped) : undefined;
858
+ if (selected !== runtime) {
859
+ runtime = selected;
860
+ installScopeStates();
861
+ }
862
+ snapshot = stopped;
863
+ recordPolicyPublication(publication, ctx);
864
+ branchHasSnapshot = true;
865
+ clearRunTransient();
866
+ passiveContinuation = retainedHandoff;
867
+ bootstrapContinuation = undefined;
868
+ artifactInvalidations = [];
869
+ artifactReads.setCandidates([]);
870
+ artifactRefreshPending = false;
871
+ if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { at: stoppedAt });
872
+ syncStateFlowTools();
873
+ persist();
874
+ updateUi(ctx);
875
+ return { ok: true, message: "State Flow disabled" };
876
+ }
877
+
829
878
  pi.registerCommand("state-flow-stop", {
830
879
  description: "Stop State Flow on the current session branch",
831
880
  handler: async (_args, ctx) => {
832
- if (!activeContext) restoreActiveBranch(ctx);
833
- let selected = runtime;
834
- let current = snapshot;
835
- if (!selected?.view && snapshot.meta.durableBase) {
836
- selected = createRuntime(ctx);
837
- current = selected.restore(snapshot.meta.durableBase, snapshot);
838
- }
839
- const stoppedAt = Date.now();
840
- const exitStates = selected?.view ? selected.states() : undefined;
841
- const exitHandoff = current.config.enabled && exitStates
842
- ? createPassiveContinuation(
843
- projectStateForModel(overlayStates(exitStates.global, exitStates.cwd, exitStates.session)),
844
- stoppedAt,
845
- )
846
- : undefined;
847
- const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
848
- const stopped = stopEpisode(current);
849
- const publication = selected?.view ? selected.publish(stopped) : undefined;
850
- if (selected !== runtime) {
851
- runtime = selected;
852
- installScopeStates();
853
- }
854
- snapshot = stopped;
855
- recordPolicyPublication(publication, ctx);
856
- branchHasSnapshot = true;
857
- clearRunTransient();
858
- passiveContinuation = retainedHandoff;
859
- bootstrapContinuation = undefined;
860
- artifactInvalidations = [];
861
- artifactReads.setCandidates([]);
862
- artifactRefreshPending = false;
863
- if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { at: stoppedAt });
864
- syncStateFlowTools();
865
- persist();
866
- updateUi(ctx);
881
+ stopStateFlow(ctx);
867
882
  },
868
883
  });
869
884
 
885
+ const telegram = createStateFlowTelegramAdapter({
886
+ ...(options.telegram?.load === undefined ? {} : { load: options.telegram.load }),
887
+ port: {
888
+ snapshot: () => ({
889
+ enabled: snapshot.config.enabled,
890
+ step: snapshot.meta.step,
891
+ bootstrap: snapshot.meta.bootstrap === true,
892
+ startPending: telegramStartPending,
893
+ }),
894
+ canStartNow: () => activeContext === undefined || activeContext.isIdle(),
895
+ start: () => {
896
+ if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
897
+ return startStateFlow(activeContext);
898
+ },
899
+ stop: () => {
900
+ if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
901
+ try {
902
+ return stopStateFlow(activeContext);
903
+ } catch (error) {
904
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
905
+ }
906
+ },
907
+ deferStart: () => {
908
+ telegramStartPending = true;
909
+ },
910
+ cancelStart: () => {
911
+ telegramStartPending = false;
912
+ },
913
+ },
914
+ });
915
+ void telegram.ensure();
916
+
870
917
  pi.on("before_agent_start", (event, ctx) => {
871
918
  if (!snapshot.config.enabled) return;
872
919
  skillReads.clear();
@@ -1005,7 +1052,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1005
1052
  updateUi(ctx);
1006
1053
  });
1007
1054
 
1008
- pi.on("agent_settled", (_event, _ctx) => {
1055
+ pi.on("agent_settled", (_event, ctx) => {
1056
+ if (telegramStartPending && !snapshot.config.enabled) {
1057
+ telegramStartPending = false;
1058
+ startStateFlow(ctx);
1059
+ }
1009
1060
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1010
1061
  });
1011
1062
 
@@ -1015,8 +1066,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1015
1066
  retryPendingPush(ctx);
1016
1067
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1017
1068
  updateUi(ctx);
1069
+ void telegram.ensure();
1018
1070
  });
1019
1071
  pi.on("session_tree", (_event, ctx) => {
1020
1072
  restoreActiveBranch(ctx);
1021
1073
  });
1074
+ pi.on("session_shutdown", () => {
1075
+ telegramStartPending = false;
1076
+ telegram.dispose();
1077
+ });
1022
1078
  }
@@ -0,0 +1,220 @@
1
+ // Domain: optional pi-telegram presentation adapter for the State Flow main-menu section.
2
+ //
3
+ // This is a leaf adapter. Core semantics, storage, and inference never depend on it; when
4
+ // pi-telegram is absent or its registry is not ready, registration fails open and retries.
5
+
6
+ export const STATE_FLOW_TELEGRAM_ID = "@llblab/pi-state-flow";
7
+ const SECTIONS_IMPORT_SPECIFIERS = [
8
+ "@llblab/pi-telegram/sections",
9
+ new URL("../../pi-telegram/api/sections.ts", import.meta.url).href,
10
+ ];
11
+
12
+ export interface StateFlowTelegramSnapshot {
13
+ enabled: boolean;
14
+ step: number;
15
+ bootstrap: boolean;
16
+ startPending: boolean;
17
+ }
18
+
19
+ export interface StateFlowTelegramButton {
20
+ text: string;
21
+ callback_data: string;
22
+ }
23
+
24
+ export interface StateFlowTelegramView {
25
+ text: string;
26
+ parseMode?: "markdown" | "html" | "plain";
27
+ replyMarkup?: { inline_keyboard: StateFlowTelegramButton[][] };
28
+ }
29
+
30
+ export interface StateFlowTelegramSectionContext {
31
+ callbackData(action: string, payload?: string): string;
32
+ edit(view: StateFlowTelegramView): Promise<void>;
33
+ answerCallback(text?: string): Promise<void>;
34
+ }
35
+
36
+ export interface StateFlowTelegramCallbackContext extends StateFlowTelegramSectionContext {
37
+ action: string;
38
+ payload: string;
39
+ }
40
+
41
+ export interface StateFlowTelegramSectionModule {
42
+ registerTelegramSection(section: {
43
+ id: string;
44
+ label: string;
45
+ getLabel?: () => string;
46
+ render: (ctx: StateFlowTelegramSectionContext) => StateFlowTelegramView | Promise<StateFlowTelegramView>;
47
+ handleCallback?: (ctx: StateFlowTelegramCallbackContext) => "handled" | "pass" | Promise<"handled" | "pass">;
48
+ }): () => void;
49
+ }
50
+
51
+ export interface StateFlowTelegramModules {
52
+ sections?: StateFlowTelegramSectionModule;
53
+ }
54
+
55
+ export type StateFlowTelegramLoader = () => Promise<StateFlowTelegramModules>;
56
+
57
+ export interface StateFlowTelegramControlResult {
58
+ ok: boolean;
59
+ message: string;
60
+ }
61
+
62
+ export interface StateFlowTelegramPort {
63
+ snapshot(): StateFlowTelegramSnapshot;
64
+ canStartNow(): boolean;
65
+ start(): StateFlowTelegramControlResult;
66
+ stop(): StateFlowTelegramControlResult;
67
+ deferStart(): void;
68
+ cancelStart(): void;
69
+ }
70
+
71
+ export interface StateFlowTelegramAdapter {
72
+ ensure(): Promise<boolean>;
73
+ dispose(): void;
74
+ }
75
+
76
+ /** Main-menu section label doubles as the live status value; the disabled row carries no status text. */
77
+ export function formatStateFlowSectionLabel(snapshot: StateFlowTelegramSnapshot): string {
78
+ return snapshot.enabled ? `๐ŸŒ€ State Flow: #${snapshot.step}` : "โšซ๏ธ State Flow";
79
+ }
80
+
81
+ export function buildStateFlowSectionView(
82
+ snapshot: StateFlowTelegramSnapshot,
83
+ callbackData: (action: string) => string,
84
+ ): StateFlowTelegramView {
85
+ const lines = ["<b>๐ŸŒ€ State Flow</b>", ""];
86
+ if (snapshot.enabled) {
87
+ lines.push("Status: <b>enabled</b>", `State iteration: <code>#${snapshot.step}</code>`);
88
+ if (snapshot.bootstrap) lines.push("Bootstrap run: the next completed run migrates active context into state.");
89
+ } else if (snapshot.startPending) {
90
+ lines.push("Status: <b>off</b>", "Start is pending until the current turn settles.");
91
+ } else {
92
+ lines.push("Status: <b>off</b>", "State Flow is disabled on this session branch.");
93
+ }
94
+ const buttons: StateFlowTelegramButton[] = [];
95
+ if (snapshot.startPending) {
96
+ buttons.push({ text: "โœ–๏ธ Cancel start", callback_data: callbackData("cancel") });
97
+ } else if (snapshot.enabled) {
98
+ buttons.push({ text: "โน Stop", callback_data: callbackData("stop") });
99
+ } else {
100
+ buttons.push({ text: "โ–ถ๏ธ Start", callback_data: callbackData("start") });
101
+ }
102
+ buttons.push({ text: "๐Ÿ”„ Refresh", callback_data: callbackData("refresh") });
103
+ return { text: lines.join("\n"), parseMode: "html", replyMarkup: { inline_keyboard: [buttons] } };
104
+ }
105
+
106
+ function buildStateFlowTelegramSection(port: StateFlowTelegramPort) {
107
+ return {
108
+ id: STATE_FLOW_TELEGRAM_ID,
109
+ label: "๐ŸŒ€ State Flow",
110
+ getLabel: () => formatStateFlowSectionLabel(port.snapshot()),
111
+ render: (ctx: StateFlowTelegramSectionContext) =>
112
+ buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action)),
113
+ handleCallback: async (ctx: StateFlowTelegramCallbackContext) => {
114
+ if (ctx.action !== "start" && ctx.action !== "stop" && ctx.action !== "cancel" && ctx.action !== "refresh") return "pass" as const;
115
+ let notice: string | undefined;
116
+ try {
117
+ if (ctx.action === "start") {
118
+ if (port.canStartNow()) notice = port.start().message;
119
+ else {
120
+ port.deferStart();
121
+ notice = "State Flow will start after the current turn";
122
+ }
123
+ } else if (ctx.action === "stop") {
124
+ notice = port.stop().message;
125
+ } else if (ctx.action === "cancel") {
126
+ port.cancelStart();
127
+ notice = "Pending start cancelled";
128
+ }
129
+ } catch (error) {
130
+ notice = error instanceof Error ? error.message : String(error);
131
+ }
132
+ await ctx.answerCallback(notice);
133
+ await ctx.edit(buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action)));
134
+ return "handled" as const;
135
+ },
136
+ };
137
+ }
138
+
139
+ async function importTelegramModule<TModule>(
140
+ specifiers: readonly string[],
141
+ guard: (module: unknown) => module is TModule,
142
+ ): Promise<TModule | undefined> {
143
+ for (const specifier of specifiers) {
144
+ try {
145
+ const imported = await import(specifier);
146
+ if (guard(imported)) return imported;
147
+ } catch {
148
+ // pi-telegram is optional; its absence only disables the Telegram surface.
149
+ }
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ /** Default loader; injectable so tests and embedded hosts can control transport presence. */
155
+ export async function loadStateFlowTelegramModules(): Promise<StateFlowTelegramModules> {
156
+ const sections = await importTelegramModule<StateFlowTelegramSectionModule>(
157
+ SECTIONS_IMPORT_SPECIFIERS,
158
+ (module): module is StateFlowTelegramSectionModule =>
159
+ typeof (module as StateFlowTelegramSectionModule | undefined)?.registerTelegramSection === "function",
160
+ );
161
+ return { ...(sections === undefined ? {} : { sections }) };
162
+ }
163
+
164
+ export function createStateFlowTelegramAdapter(options: {
165
+ port: StateFlowTelegramPort;
166
+ load?: StateFlowTelegramLoader;
167
+ }): StateFlowTelegramAdapter {
168
+ const load = options.load ?? loadStateFlowTelegramModules;
169
+ let generation = 0;
170
+ let sectionRegistered = false;
171
+ let registration: Promise<boolean> | undefined;
172
+ const disposers: Array<() => void> = [];
173
+
174
+ const register = async (): Promise<boolean> => {
175
+ const epoch = generation;
176
+ let modules: StateFlowTelegramModules;
177
+ try {
178
+ modules = await load();
179
+ } catch {
180
+ return false;
181
+ }
182
+ // A shutdown during loading must not leave a registration behind.
183
+ if (epoch !== generation) return false;
184
+ if (!sectionRegistered && modules.sections) {
185
+ try {
186
+ const dispose = modules.sections.registerTelegramSection(buildStateFlowTelegramSection(options.port));
187
+ if (epoch === generation) {
188
+ disposers.push(dispose);
189
+ sectionRegistered = true;
190
+ } else {
191
+ dispose();
192
+ }
193
+ } catch {
194
+ // Registry not initialized yet; the next ensure retries.
195
+ }
196
+ }
197
+ return sectionRegistered;
198
+ };
199
+
200
+ return {
201
+ async ensure(): Promise<boolean> {
202
+ if (sectionRegistered) return true;
203
+ registration ??= register().finally(() => {
204
+ registration = undefined;
205
+ });
206
+ return registration;
207
+ },
208
+ dispose(): void {
209
+ generation += 1;
210
+ for (const dispose of disposers.splice(0)) {
211
+ try {
212
+ dispose();
213
+ } catch {
214
+ // Disposal is best-effort; pi-telegram owns its registry lifetime.
215
+ }
216
+ }
217
+ sectionRegistered = false;
218
+ },
219
+ };
220
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.8.1",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -4,6 +4,10 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.45.5: Queue Refresh Icon Hotfix
8
+
9
+ - `Queue Refresh Icon`: The queue menu's Refresh row now uses `๐Ÿ”„`, the canonical refresh glyph, reserving `๐ŸŒ€` for the State Flow Telegram identity. The button label is the only change; queue refresh behavior, callbacks, and the rotating empty-queue notices are unchanged.
10
+
7
11
  ## 0.45.4: Draft Cadence Hotfix
8
12
 
9
13
  - `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.
@@ -48,7 +48,7 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
48
48
  | `โน๏ธ` | Abort current Pi work | `/abort` command description and active `/stop` result | Stops active work; accompanying copy states separately when queued work is cleared. |
49
49
  | `๐ŸŸฅ` | Destructive stop command | `/stop` command description | Strong warning at the command/action entrypoint; standalone results use the more precise idle or abort state icon. |
50
50
  | `๐Ÿ†•` | New session / fresh start | Reserved visible extension command example for `/new`-like flows | Same-thread Telegram `/new` is currently blocked by Pi core API; keep this meaning reserved. |
51
- | `๐ŸŒ€` | Refresh | Queue refresh row and future refresh buttons | Re-fetch/re-render current surface, not transport reconnect. |
51
+ | `๐Ÿ”„` | Refresh | Queue refresh row and future refresh buttons | Re-fetch/re-render current surface, not transport reconnect. |
52
52
  | `โ†ช๏ธ` | Reroute to an existing target | Thread chooser buttons that send a captured command/message from one thread to another live thread | Curved arrow means the message arrived here but bends to another target. |
53
53
  | `๐Ÿ”` | Replace/restore mode | Thread replace/restore chooser entrypoints | Opens a second step for moving a Pi instance binding to the current source thread. |
54
54
  | `โžก๏ธ` | Choose replacement target | Thread replace/restore target buttons that select which Pi instance should move to the current thread | Use inside the second replace/restore chooser, not for ordinary reroutes. |
@@ -88,7 +88,7 @@ function buildTelegramQueueMenuReplyMarkup(
88
88
  items.length === 0
89
89
  ? `queue:refresh:${nextEmptyRefreshIndex}`
90
90
  : "queue:refresh";
91
- const refreshRow = [{ text: "๐ŸŒ€ Refresh", callback_data: refreshData }];
91
+ const refreshRow = [{ text: "๐Ÿ”„ Refresh", callback_data: refreshData }];
92
92
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
93
93
  const rows = items.map((item) => {
94
94
  const prefix = item.reactionSuppressionEmoji
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.45.4",
3
+ "version": "0.45.5",
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.9.1",
3
+ "version": "0.10.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -43,9 +43,9 @@
43
43
  "@llblab/pi-actors": "0.53.0",
44
44
  "@llblab/pi-clean-room": "0.1.1",
45
45
  "@llblab/pi-codex-usage": "0.9.4",
46
- "@llblab/pi-grow-loop": "0.7.5",
47
- "@llblab/pi-state-flow": "0.8.1",
48
- "@llblab/pi-telegram": "0.45.4",
46
+ "@llblab/pi-grow-loop": "0.8.1",
47
+ "@llblab/pi-state-flow": "0.9.2",
48
+ "@llblab/pi-telegram": "0.45.5",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [