@llblab/pi-kit 0.9.1 → 0.10.0

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,11 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.10.0 - 2026-09-11
6
+
7
+ - `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.
8
+ - `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.
9
+
5
10
  ## 0.9.1 - 2026-09-11
6
11
 
7
12
  - `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,8 +13,8 @@ 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 |
16
+ | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.8.0` | Visible continuation scheduling and bounded worker Skills |
17
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.9.0` | Atomic scoped state cohorts with final-eligibility resolution |
18
18
  | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.4` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
19
19
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
20
20
 
@@ -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 visible rhythm (`Grow Loop: #N · waiting|Xs|running`) 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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0: Telegram Status Mirror
4
+
5
+ - `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.
6
+ - `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.
7
+
3
8
  ## 0.7.5: Status Identity Hotfix
4
9
 
5
10
  - `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 rhythm and stays hidden whenever the terminal status is hidden: `Grow Loop: #N · waiting`, `#N · 2.4s` during the countdown, and `#N · running` while the loop-scheduled turn is active. 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,28 @@ export function getExistingExtensionSkillPaths(extensionUrl: string): string[] {
39
70
  return existsSync(skillsDir) ? [skillsDir] : [];
40
71
  }
41
72
 
73
+ export function formatGrowLoopTelegramValue(progress: GrowLoopTelegramProgress): string {
74
+ if (progress.state === "countdown") return `#${progress.iteration} · ${(progress.remainingSeconds ?? 0).toFixed(1)}s`;
75
+ if (progress.state === "running") return `#${progress.iteration} · running`;
76
+ return `#${progress.iteration} · waiting`;
77
+ }
78
+
79
+ async function registerGrowLoopTelegramStatus(
80
+ provider: GrowLoopTelegramStatusProvider,
81
+ ): Promise<(() => void) | undefined> {
82
+ for (const specifier of TELEGRAM_STATUS_IMPORT_SPECIFIERS) {
83
+ try {
84
+ const imported = (await import(specifier)) as TelegramStatusLineModule;
85
+ if (typeof imported.registerTelegramStatusLineProvider === "function") {
86
+ return imported.registerTelegramStatusLineProvider(provider, { id: "@llblab/pi-grow-loop" });
87
+ }
88
+ } catch {
89
+ // pi-telegram is optional; its absence only disables the Telegram status line.
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+
42
95
  function statusCountdown(ctx: ExtensionContext, seconds: number) {
43
96
  const theme = ctx.ui.theme;
44
97
  ctx.ui.setStatus(
@@ -82,20 +135,21 @@ function scheduleIteration(
82
135
  iteration: number,
83
136
  clearPending: () => void,
84
137
  expectOwnPrompt: () => void,
85
- options: Required<GrowLoopOptions>,
138
+ options: Required<Pick<GrowLoopOptions, "followUpDelayMs" | "countdownTickMs">>,
86
139
  ): PendingIteration {
87
- let countdownStartedAt: number | undefined;
88
140
  statusDeferred(ctx, iteration);
89
141
  const pending = {} as PendingIteration;
90
142
  pending.interval = setInterval(() => {
91
- if (!countdownStartedAt) {
143
+ if (pending.countdownStartedAt === undefined) {
92
144
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
93
- countdownStartedAt = Date.now();
145
+ pending.countdownStartedAt = Date.now();
146
+ pending.countdownDelayMs = options.followUpDelayMs;
94
147
  statusCountdown(ctx, options.followUpDelayMs / 1000);
95
148
  pending.timeout = setTimeout(() => {
96
149
  pending.timeout = undefined;
97
150
  if (!ctx.isIdle() || ctx.hasPendingMessages()) {
98
- countdownStartedAt = undefined;
151
+ pending.countdownStartedAt = undefined;
152
+ pending.countdownDelayMs = undefined;
99
153
  statusDeferred(ctx, iteration);
100
154
  return;
101
155
  }
@@ -105,7 +159,7 @@ function scheduleIteration(
105
159
  pending.timeout.unref?.();
106
160
  return;
107
161
  }
108
- const elapsed = Date.now() - countdownStartedAt;
162
+ const elapsed = Date.now() - pending.countdownStartedAt;
109
163
  const remainingMs = Math.max(options.followUpDelayMs - elapsed, 0);
110
164
  if (remainingMs > 0) statusCountdown(ctx, remainingMs / 1000);
111
165
  }, options.countdownTickMs) as Timer;
@@ -122,23 +176,63 @@ export default function growLoopExtension(
122
176
  partialOptions.followUpDelayMs ?? DEFAULT_FOLLOW_UP_DELAY_MS,
123
177
  countdownTickMs:
124
178
  partialOptions.countdownTickMs ?? DEFAULT_COUNTDOWN_TICK_MS,
179
+ registerTelegramStatusLine: partialOptions.registerTelegramStatusLine,
125
180
  };
126
181
  let iteration = 0;
127
182
  let lastCtx: ExtensionContext | undefined;
128
183
  let pendingIteration: PendingIteration | undefined;
129
184
  let ownPromptPending = false;
130
185
  let scheduledThisTurn = false;
186
+ let runningIteration: number | undefined;
187
+ let unregisterTelegramStatus: (() => void) | undefined;
188
+ let telegramRegistration: Promise<void> | undefined;
189
+ let telegramGeneration = 0;
131
190
  const clearPending = () => {
132
191
  if (!pendingIteration) return;
133
192
  if (pendingIteration.timeout) clearTimeout(pendingIteration.timeout);
134
193
  clearInterval(pendingIteration.interval);
135
194
  pendingIteration = undefined;
136
195
  };
196
+ const telegramStatusProvider = (): GrowLoopTelegramStatusLine | undefined => {
197
+ if (pendingIteration) {
198
+ if (pendingIteration.countdownStartedAt === undefined) {
199
+ return { label: "Grow Loop", value: formatGrowLoopTelegramValue({ iteration, state: "waiting" }) };
200
+ }
201
+ const elapsed = Date.now() - pendingIteration.countdownStartedAt;
202
+ const remainingSeconds = Math.max((pendingIteration.countdownDelayMs ?? 0) - elapsed, 0) / 1000;
203
+ return { label: "Grow Loop", value: formatGrowLoopTelegramValue({ iteration, state: "countdown", remainingSeconds }) };
204
+ }
205
+ if (runningIteration !== undefined) {
206
+ return { label: "Grow Loop", value: formatGrowLoopTelegramValue({ iteration: runningIteration, state: "running" }) };
207
+ }
208
+ return undefined;
209
+ };
210
+ const ensureTelegramStatusRegistered = () => {
211
+ if (unregisterTelegramStatus || telegramRegistration) return;
212
+ if (options.registerTelegramStatusLine) {
213
+ unregisterTelegramStatus = options.registerTelegramStatusLine(telegramStatusProvider) ?? undefined;
214
+ return;
215
+ }
216
+ const generation = telegramGeneration;
217
+ telegramRegistration = registerGrowLoopTelegramStatus(telegramStatusProvider)
218
+ .then((unregister) => {
219
+ if (generation !== telegramGeneration) {
220
+ unregister?.();
221
+ return;
222
+ }
223
+ unregisterTelegramStatus = unregister;
224
+ })
225
+ .finally(() => {
226
+ if (generation === telegramGeneration) telegramRegistration = undefined;
227
+ });
228
+ };
137
229
  const hideLoopStatus = (ctx: ExtensionContext) => {
138
230
  ownPromptPending = false;
231
+ runningIteration = undefined;
139
232
  clearPending();
140
233
  ctx.ui.setStatus(STATUS_KEY, undefined);
141
234
  };
235
+ ensureTelegramStatusRegistered();
142
236
  pi.on("resources_discover", async () => {
143
237
  const skillPaths = getExistingExtensionSkillPaths(import.meta.url);
144
238
  if (skillPaths.length === 0) return;
@@ -147,12 +241,23 @@ export default function growLoopExtension(
147
241
  pi.on("session_shutdown", async () => {
148
242
  ownPromptPending = false;
149
243
  scheduledThisTurn = false;
244
+ runningIteration = undefined;
150
245
  clearPending();
246
+ telegramGeneration += 1;
247
+ unregisterTelegramStatus?.();
248
+ unregisterTelegramStatus = undefined;
249
+ telegramRegistration = undefined;
151
250
  lastCtx?.ui.setStatus(STATUS_KEY, undefined);
152
251
  });
252
+ pi.on("session_start", async () => {
253
+ ensureTelegramStatusRegistered();
254
+ });
153
255
  pi.on("agent_settled", async (_event, ctx) => {
154
256
  lastCtx = ctx;
155
- if (!pendingIteration) ctx.ui.setStatus(STATUS_KEY, undefined);
257
+ if (!pendingIteration) {
258
+ runningIteration = undefined;
259
+ ctx.ui.setStatus(STATUS_KEY, undefined);
260
+ }
156
261
  });
157
262
  pi.on("input", async (event, ctx) => {
158
263
  lastCtx = ctx;
@@ -194,6 +299,7 @@ export default function growLoopExtension(
194
299
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
195
300
  lastCtx = ctx;
196
301
  ownPromptPending = false;
302
+ runningIteration = undefined;
197
303
  clearPending();
198
304
  const isReschedule = scheduledThisTurn;
199
305
  if (!isReschedule) {
@@ -212,8 +318,9 @@ export default function growLoopExtension(
212
318
  clearPending,
213
319
  () => {
214
320
  ownPromptPending = true;
321
+ runningIteration = nextIteration;
215
322
  },
216
- { ...options, followUpDelayMs: delayMs },
323
+ { followUpDelayMs: delayMs, countdownTickMs: options.countdownTickMs },
217
324
  );
218
325
  return {
219
326
  content: [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-grow-loop",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
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 status 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.
@@ -2,6 +2,12 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.9.0: Telegram status surface
6
+
7
+ - `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.
8
+ - `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`.
9
+ - `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.
10
+
5
11
  ## 0.8.1: Preserved answers and bounded fallback resolution
6
12
 
7
13
  - `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 mirrors the same identity there: 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. 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 Status screen row shows `State Flow: on · step #N` while enabled, and the main-menu section button before Settings 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,25 @@ export {
185
185
  type StateScope,
186
186
  type TerminalTransition
187
187
  } from "./lib/state.ts";
188
+ export {
189
+ buildStateFlowSectionView,
190
+ createStateFlowTelegramAdapter,
191
+ formatStateFlowSectionLabel,
192
+ formatStateFlowStatusLine,
193
+ loadStateFlowTelegramModules,
194
+ STATE_FLOW_TELEGRAM_ID,
195
+ type StateFlowTelegramAdapter,
196
+ type StateFlowTelegramButton,
197
+ type StateFlowTelegramCallbackContext,
198
+ type StateFlowTelegramControlResult,
199
+ type StateFlowTelegramLoader,
200
+ type StateFlowTelegramModules,
201
+ type StateFlowTelegramPort,
202
+ type StateFlowTelegramSectionContext,
203
+ type StateFlowTelegramSectionModule,
204
+ type StateFlowTelegramSnapshot,
205
+ type StateFlowTelegramStatusLine,
206
+ type StateFlowTelegramStatusModule,
207
+ type StateFlowTelegramView
208
+ } from "./lib/telegram.ts";
188
209
  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,267 @@
1
+ // Domain: optional pi-telegram presentation adapter for State Flow status and branch controls.
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 STATUS_IMPORT_SPECIFIERS = [
8
+ "@llblab/pi-telegram/status",
9
+ new URL("../../pi-telegram/api/status.ts", import.meta.url).href,
10
+ ];
11
+ const SECTIONS_IMPORT_SPECIFIERS = [
12
+ "@llblab/pi-telegram/sections",
13
+ new URL("../../pi-telegram/api/sections.ts", import.meta.url).href,
14
+ ];
15
+
16
+ export interface StateFlowTelegramSnapshot {
17
+ enabled: boolean;
18
+ step: number;
19
+ bootstrap: boolean;
20
+ startPending: boolean;
21
+ }
22
+
23
+ export interface StateFlowTelegramStatusLine {
24
+ label: string;
25
+ value: string;
26
+ }
27
+
28
+ export interface StateFlowTelegramButton {
29
+ text: string;
30
+ callback_data: string;
31
+ }
32
+
33
+ export interface StateFlowTelegramView {
34
+ text: string;
35
+ parseMode?: "markdown" | "html" | "plain";
36
+ replyMarkup?: { inline_keyboard: StateFlowTelegramButton[][] };
37
+ }
38
+
39
+ export interface StateFlowTelegramSectionContext {
40
+ callbackData(action: string, payload?: string): string;
41
+ edit(view: StateFlowTelegramView): Promise<void>;
42
+ answerCallback(text?: string): Promise<void>;
43
+ }
44
+
45
+ export interface StateFlowTelegramCallbackContext extends StateFlowTelegramSectionContext {
46
+ action: string;
47
+ payload: string;
48
+ }
49
+
50
+ export interface StateFlowTelegramStatusModule {
51
+ registerTelegramStatusLineProvider(
52
+ provider: (ctx: { activeModel?: unknown }) => StateFlowTelegramStatusLine | undefined,
53
+ options: { id: string },
54
+ ): () => void;
55
+ }
56
+
57
+ export interface StateFlowTelegramSectionModule {
58
+ registerTelegramSection(section: {
59
+ id: string;
60
+ label: string;
61
+ getLabel?: () => string;
62
+ render: (ctx: StateFlowTelegramSectionContext) => StateFlowTelegramView | Promise<StateFlowTelegramView>;
63
+ handleCallback?: (ctx: StateFlowTelegramCallbackContext) => "handled" | "pass" | Promise<"handled" | "pass">;
64
+ }): () => void;
65
+ }
66
+
67
+ export interface StateFlowTelegramModules {
68
+ status?: StateFlowTelegramStatusModule;
69
+ sections?: StateFlowTelegramSectionModule;
70
+ }
71
+
72
+ export type StateFlowTelegramLoader = () => Promise<StateFlowTelegramModules>;
73
+
74
+ export interface StateFlowTelegramControlResult {
75
+ ok: boolean;
76
+ message: string;
77
+ }
78
+
79
+ export interface StateFlowTelegramPort {
80
+ snapshot(): StateFlowTelegramSnapshot;
81
+ canStartNow(): boolean;
82
+ start(): StateFlowTelegramControlResult;
83
+ stop(): StateFlowTelegramControlResult;
84
+ deferStart(): void;
85
+ cancelStart(): void;
86
+ }
87
+
88
+ export interface StateFlowTelegramAdapter {
89
+ ensure(): Promise<boolean>;
90
+ dispose(): void;
91
+ }
92
+
93
+ /** The Status screen mirrors the terminal status identity: hidden while State Flow is off. */
94
+ export function formatStateFlowStatusLine(snapshot: StateFlowTelegramSnapshot): StateFlowTelegramStatusLine | undefined {
95
+ if (!snapshot.enabled) return snapshot.startPending ? { label: "State Flow", value: "starting…" } : undefined;
96
+ return { label: "State Flow", value: snapshot.bootstrap ? "bootstrap" : `on · step #${snapshot.step}` };
97
+ }
98
+
99
+ /** Main-menu section label doubles as the live status text. */
100
+ export function formatStateFlowSectionLabel(snapshot: StateFlowTelegramSnapshot): string {
101
+ if (!snapshot.enabled) return snapshot.startPending ? "🌀 State Flow: starting…" : "⚫️ State Flow: off";
102
+ return snapshot.bootstrap ? "🌀 State Flow: bootstrap" : `🌀 State Flow: #${snapshot.step}`;
103
+ }
104
+
105
+ export function buildStateFlowSectionView(
106
+ snapshot: StateFlowTelegramSnapshot,
107
+ callbackData: (action: string) => string,
108
+ ): StateFlowTelegramView {
109
+ const lines = ["<b>🌀 State Flow</b>", ""];
110
+ if (snapshot.enabled) {
111
+ lines.push("Status: <b>enabled</b>", `State iteration: <code>#${snapshot.step}</code>`);
112
+ if (snapshot.bootstrap) lines.push("Bootstrap run: the next completed run migrates active context into state.");
113
+ } else if (snapshot.startPending) {
114
+ lines.push("Status: <b>off</b>", "Start is pending until the current turn settles.");
115
+ } else {
116
+ lines.push("Status: <b>off</b>", "State Flow is disabled on this session branch.");
117
+ }
118
+ const buttons: StateFlowTelegramButton[] = [];
119
+ if (snapshot.startPending) {
120
+ buttons.push({ text: "✖️ Cancel start", callback_data: callbackData("cancel") });
121
+ } else if (snapshot.enabled) {
122
+ buttons.push({ text: "⏹ Stop", callback_data: callbackData("stop") });
123
+ } else {
124
+ buttons.push({ text: "▶️ Start", callback_data: callbackData("start") });
125
+ }
126
+ buttons.push({ text: "🔄 Refresh", callback_data: callbackData("refresh") });
127
+ return { text: lines.join("\n"), parseMode: "html", replyMarkup: { inline_keyboard: [buttons] } };
128
+ }
129
+
130
+ function buildStateFlowTelegramSection(port: StateFlowTelegramPort) {
131
+ return {
132
+ id: STATE_FLOW_TELEGRAM_ID,
133
+ label: "🌀 State Flow",
134
+ getLabel: () => formatStateFlowSectionLabel(port.snapshot()),
135
+ render: (ctx: StateFlowTelegramSectionContext) =>
136
+ buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action)),
137
+ handleCallback: async (ctx: StateFlowTelegramCallbackContext) => {
138
+ if (ctx.action !== "start" && ctx.action !== "stop" && ctx.action !== "cancel" && ctx.action !== "refresh") return "pass" as const;
139
+ let notice: string | undefined;
140
+ try {
141
+ if (ctx.action === "start") {
142
+ if (port.canStartNow()) notice = port.start().message;
143
+ else {
144
+ port.deferStart();
145
+ notice = "State Flow will start after the current turn";
146
+ }
147
+ } else if (ctx.action === "stop") {
148
+ notice = port.stop().message;
149
+ } else if (ctx.action === "cancel") {
150
+ port.cancelStart();
151
+ notice = "Pending start cancelled";
152
+ }
153
+ } catch (error) {
154
+ notice = error instanceof Error ? error.message : String(error);
155
+ }
156
+ await ctx.answerCallback(notice);
157
+ await ctx.edit(buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action)));
158
+ return "handled" as const;
159
+ },
160
+ };
161
+ }
162
+
163
+ async function importTelegramModule<TModule>(
164
+ specifiers: readonly string[],
165
+ guard: (module: unknown) => module is TModule,
166
+ ): Promise<TModule | undefined> {
167
+ for (const specifier of specifiers) {
168
+ try {
169
+ const imported = await import(specifier);
170
+ if (guard(imported)) return imported;
171
+ } catch {
172
+ // pi-telegram is optional; its absence only disables the Telegram surface.
173
+ }
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ /** Default loader; injectable so tests and embedded hosts can control transport presence. */
179
+ export async function loadStateFlowTelegramModules(): Promise<StateFlowTelegramModules> {
180
+ const status = await importTelegramModule<StateFlowTelegramStatusModule>(
181
+ STATUS_IMPORT_SPECIFIERS,
182
+ (module): module is StateFlowTelegramStatusModule =>
183
+ typeof (module as StateFlowTelegramStatusModule | undefined)?.registerTelegramStatusLineProvider === "function",
184
+ );
185
+ const sections = await importTelegramModule<StateFlowTelegramSectionModule>(
186
+ SECTIONS_IMPORT_SPECIFIERS,
187
+ (module): module is StateFlowTelegramSectionModule =>
188
+ typeof (module as StateFlowTelegramSectionModule | undefined)?.registerTelegramSection === "function",
189
+ );
190
+ return { ...(status === undefined ? {} : { status }), ...(sections === undefined ? {} : { sections }) };
191
+ }
192
+
193
+ export function createStateFlowTelegramAdapter(options: {
194
+ port: StateFlowTelegramPort;
195
+ load?: StateFlowTelegramLoader;
196
+ }): StateFlowTelegramAdapter {
197
+ const load = options.load ?? loadStateFlowTelegramModules;
198
+ let generation = 0;
199
+ let statusRegistered = false;
200
+ let sectionRegistered = false;
201
+ let registration: Promise<boolean> | undefined;
202
+ const disposers: Array<() => void> = [];
203
+
204
+ const register = async (): Promise<boolean> => {
205
+ const epoch = generation;
206
+ let modules: StateFlowTelegramModules;
207
+ try {
208
+ modules = await load();
209
+ } catch {
210
+ return false;
211
+ }
212
+ // A shutdown during loading must not leave a registration behind.
213
+ if (epoch !== generation) return false;
214
+ if (!statusRegistered && modules.status) {
215
+ try {
216
+ const dispose = modules.status.registerTelegramStatusLineProvider(
217
+ () => formatStateFlowStatusLine(options.port.snapshot()),
218
+ { id: STATE_FLOW_TELEGRAM_ID },
219
+ );
220
+ if (epoch === generation) {
221
+ disposers.push(dispose);
222
+ statusRegistered = true;
223
+ } else {
224
+ dispose();
225
+ }
226
+ } catch {
227
+ // Registry not initialized yet; the next ensure retries.
228
+ }
229
+ }
230
+ if (!sectionRegistered && modules.sections) {
231
+ try {
232
+ const dispose = modules.sections.registerTelegramSection(buildStateFlowTelegramSection(options.port));
233
+ if (epoch === generation) {
234
+ disposers.push(dispose);
235
+ sectionRegistered = true;
236
+ } else {
237
+ dispose();
238
+ }
239
+ } catch {
240
+ // Registry not initialized yet; the next ensure retries.
241
+ }
242
+ }
243
+ return statusRegistered || sectionRegistered;
244
+ };
245
+
246
+ return {
247
+ async ensure(): Promise<boolean> {
248
+ if (statusRegistered && sectionRegistered) return true;
249
+ registration ??= register().finally(() => {
250
+ registration = undefined;
251
+ });
252
+ return registration;
253
+ },
254
+ dispose(): void {
255
+ generation += 1;
256
+ for (const dispose of disposers.splice(0)) {
257
+ try {
258
+ dispose();
259
+ } catch {
260
+ // Disposal is best-effort; pi-telegram owns its registry lifetime.
261
+ }
262
+ }
263
+ statusRegistered = false;
264
+ sectionRegistered = false;
265
+ },
266
+ };
267
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
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.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -43,8 +43,8 @@
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",
46
+ "@llblab/pi-grow-loop": "0.8.0",
47
+ "@llblab/pi-state-flow": "0.9.0",
48
48
  "@llblab/pi-telegram": "0.45.4",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },