@llblab/pi-kit 0.9.0 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -0
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +5 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +2 -0
  6. package/node_modules/@llblab/pi-grow-loop/index.ts +115 -8
  7. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  8. package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
  9. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +7 -0
  10. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +10 -0
  11. package/node_modules/@llblab/pi-state-flow/README.md +4 -2
  12. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -3
  13. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +2 -2
  14. package/node_modules/@llblab/pi-state-flow/index.ts +22 -1
  15. package/node_modules/@llblab/pi-state-flow/lib/context.ts +1 -1
  16. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +248 -144
  17. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +267 -0
  18. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +1 -1
  19. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  20. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  21. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -1
  22. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +8 -0
  23. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  24. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  25. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +4 -4
  26. package/node_modules/@llblab/pi-telegram/docs/outbound.md +1 -1
  27. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
  28. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  29. package/node_modules/@llblab/pi-telegram/lib/config.ts +6 -4
  30. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -1
  31. package/node_modules/@llblab/pi-telegram/lib/preview.ts +20 -0
  32. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  33. package/package.json +4 -4
@@ -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
+ }
@@ -23,7 +23,7 @@ Use read_state only for a concrete historical or scope-specific gap. It reads on
23
23
 
24
24
  Use patch_state as the sole model-authored semantic mutation mechanism. Supply any combination of global, cwd, and session patches; all supplied scopes are validated and durably accepted as one atomic transition before further reasoning. Call patch_state alone in its assistant response; after its acknowledgement choose the next action from accepted state.
25
25
 
26
- Every enabled iteration starts terminal-ineligible. Set final:true in a successful patch_state call when the iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls, and repeated final:true calls are allowed. Use {"final":true} when no semantic update is needed. If runtime intercepts a terminal draft before eligibility, the draft is not a final answer: follow its instruction, call patch_state with final:true, then provide the final answer normally. A final-only call creates no semantic transition. Never write response through patch_state; runtime records what was actually delivered at turn_end.
26
+ Every enabled iteration starts terminal-ineligible. Set final:true in a successful patch_state call when the iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls, and repeated final:true calls are allowed. Use {"final":true} when no semantic update is needed. If you end a terminal turn without eligibility, runtime preserves that answer as the iteration response and starts bounded fallback turns whose only purpose is the final:true patch: call patch_state with any durable scope changes and final:true, or {"final":true} alone, and never restate or replace the answer. After two fallback turns without final:true the iteration closes with its preserved answer and current state. A final-only call creates no semantic transition. Never write response through patch_state; runtime records what was actually delivered at turn_end.
27
27
 
28
28
  SCOPES: session is branch/run continuation, cwd is project state and Skills, global is cross-project state. Deleting an override affects only its scope and may reveal a parent value.
29
29
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.8.0",
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": [
@@ -99,7 +99,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
99
99
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for active preview flushes and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
100
100
  - Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
101
101
  - Public activity handlers and connected companion delivery are asynchronous, target-bound, generation-fenced surfaces. Connected companion projection has no independent opt-out: disconnect or authority loss is its boundary. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
102
- - Thread display defaults to the profile-scoped Letters strategy, with Directories as the other automatic choice. A durable manual Thread display name retained on its Workspace binding overrides either until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
102
+ - Thread display defaults to the profile-scoped Letters strategy, with Names and Directories as the other automatic choices; Names projects the generated dictionary name for the slot. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
103
103
 
104
104
  ## 5. Domain Ownership Index
105
105
 
@@ -3,7 +3,7 @@
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
5
  - [ ] `Channel multimedia posts` (`0.45.1`, live-acceptance-gated): `telegram_message` channel delivery accepts one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video, uploads it through the multipart transport as `sendPhoto`/`sendVideo` with `text` as the HTML caption, validates kind and size (photo ≤ 10 MiB, video ≤ 50 MiB) plus ≤ 1024 visible caption characters before issuance, and rejects unsupported types and albums instead of downgrading them to links. The channel-post journal binds kind/file name/byte size/SHA-256 and caption, so duplicate requests and lost acknowledgements never re-upload; media-post edits replace the caption through `editMessageCaption`, and Markdown spoilers render as `<tg-spoiler>`. Live image publication passed on `@llb_log`. Regressions cover confirmed publication, duplicate requests, lost ACK, pre-issuance rejection, caption edits, and reconnect replacement. Remaining: operator-authorized disposable-channel acceptance of rejected upload, duplicate request, and caption edit.
6
- - [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes Letters/Directories, reset is leader/follower generation- and target-fenced, Letters is the default, and legacy Names resolves to Letters without rewriting recovery identity. Local review findings are remediated, including Bot-API-wait target-replacement regressions for leader/follower rename and reset. Remaining: disposable live acceptance for command-menu ordering, dialog, invalid input, duplicate callbacks, leader/follower rename and reset.
6
+ - [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes every automatic display mode, reset is leader/follower generation- and target-fenced, and Letters remains the default without rewriting recovery identity. Local review findings are remediated, including Bot-API-wait target-replacement regressions for leader/follower rename and reset. Remaining: disposable live acceptance for command-menu ordering, dialog, invalid input, duplicate callbacks, leader/follower rename and reset.
7
7
  - [ ] `OMP schema acceptance` ([#267](https://github.com/llblab/pi-telegram/issues/267), `human-/environment-gated`): Local emitted-schema, Pi process, and llama.cpp source checks now prove explicit recursive JSON values, root `$defs`, only supported local `#/...` references, and no bare boolean schema. Confirm one connected `telegram_bind` request through the reporter's OMP + llama-server build before closing interoperability acceptance; do not treat this progressive external check as a Pi release blocker.
8
8
  - [ ] [`Workspace operator gates`](./docs/multi-instance-bus.md#approved-next-contract-directory-names-and-reclaimable-slots): Complete the remaining external evidence for display modes, unique slots, and durable recovery. Local recovery and snapshot-equality reviews are closed; do not repeat them without changed relevant inputs. No live deletion, commit, publication, restart, or automatic retirement activation belongs to local implementation authority.
9
9
  - [ ] `Bus performance escalation` (`research`, deferred): The [isolated registry/store baselines](./docs/architecture.md#persistence-io-baseline) do not justify secondary indexes, shared mutable views, or IPC multiplexing at 26 slots. Resume full IPC/authenticated registration/routing and allocation-cost measurement only after attributable latency, event-loop blocking, or growing work establishes a concrete claim. Existing local counts are not throughput or end-to-end evidence. Preserve owner/generation fences, journal authority, and unknown-ACK behavior; do not repeat unchanged synthetic measurements merely to sustain the loop.
@@ -4,6 +4,14 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.45.4: Draft Cadence Hotfix
8
+
9
+ - `Draft Cadence`: Each preview segment now holds its first frame for one full two-second interval from its first visible text, so the opening draft is an accumulated passage instead of a single streamed word. Later frames keep the trailing cadence, message/turn rollover preserves the remaining interval and reopens the window, and sealing or final publication still cancels the pending timer; first frames no longer ship immediately.
10
+
11
+ ## 0.45.3: Thread Display Names Hotfix
12
+
13
+ - `Thread Display Names`: Settings again offers the dictionary naming mode as the second chooser between Letters and Directories. Names shows each Workspace's generated slot-letter palette word, such as `Anchor` for slot `A`; switching renames live tabs and fresh tabs start under the active projection. `profiles.<name>.threadDisplayMode` persists all three values, absent or invalid ones resolve to Letters, and Names works with legacy followers that predate `thread-display-mode-v1`.
14
+
7
15
  ## 0.45.2: Provider-Compatible Bind Schema Hotfix
8
16
 
9
17
  - `Provider-Compatible Bind Argument`: Serializes the `telegram_bind` `argument` schema as an inline builder-made JSON-value union bounded to four container levels, with no `$ref`/`$defs` recursion or raw TypeBox marker leakage; OpenAI no longer rejects every request with "Recursive JSON schemas are not currently supported" (#273) and Gemini no longer rejects the unknown `~optional` field (#269) while the tool is registered.
@@ -240,7 +240,7 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
240
240
  - Unknown threads are preserved and offered explicit reroute/restore choices.
241
241
  - Telegram never launches hidden Pi processes.
242
242
 
243
- In Threaded Mode, open Settings → **🧵 Thread display** to choose **Letters** (default) or **Directories** for this bot profile. Fresh tabs are created with the active mode's title instead of being visibly renamed afterward. Telegram tab titles, Pi terminal status, live Thread choosers/notices, prompt attribution, and named `telegram_message` targeting use the same acknowledged display name; target IDs and live registrations still own routing. Directory mode adds persistent global-letter suffixes when a Workspace has multiple instances, such as `extensions_a` and `extensions_c`. `/name` sets a manual Thread display name; **Reset to automatic** restores the selected automatic projection. Legacy persisted `names` values resolve to Letters. Switching preserves Thread IDs, slots, generated recovery identity, and queue ownership. Partial application reports an error and can be retried without recreating Threads.
243
+ In Threaded Mode, open Settings → **🧵 Thread display** to choose **Letters** (default), **Names**, or **Directories** for this bot profile. Fresh tabs are created with the active mode's title instead of being visibly renamed afterward. Telegram tab titles, Pi terminal status, live Thread choosers/notices, prompt attribution, and named `telegram_message` targeting use the same acknowledged display name; target IDs and live registrations still own routing. Names shows the generated dictionary name chosen for the slot, such as `Anchor` for slot `A`. Directory mode adds persistent global-letter suffixes when a Workspace has multiple instances, such as `extensions_a` and `extensions_c`. `/name` sets a manual Thread display name; **Reset to automatic** restores the selected automatic projection. Switching preserves Thread IDs, slots, generated recovery identity, and queue ownership. Partial application reports an error and can be retried without recreating Threads.
244
244
 
245
245
  | Mode | Best for | Runtime shape |
246
246
  | --- | --- | --- |
@@ -371,7 +371,7 @@ Profile reality follows three explicit storage classes. `telegram.json` shared s
371
371
 
372
372
  When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Followers heartbeat every `1s`; the leader uses a `15s` stale grace and a `1s` prune loop so transient IPC stalls do not create false routing gaps while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, `workspaceBindings` stores profile-scoped normalized exact-`cwd` target/name/slot reuse hints, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
373
373
 
374
- Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over either automatic display mode; bare `/name` opens five-minute exact-target input whose next valid text is consumed before agent dispatch. Name input, cancel, and reset are consume-once; stale scope, target, message ID, expiry, and duplicate callbacks cannot mutate. Reset clears only the override and restores the current automatic projection. Leader command routing reuses its already-held profile admission for the rename body rather than recursively entering the non-reentrant Workspace gate; standalone leader renames acquire their own admission. Followers send an authenticated exact-generation `workspace-thread-rename-v1` request, and the leader owns any Bot API mutation plus durable binding persistence. Concurrent processes from one directory receive deterministic Workspace suffixes, while leader/follower roles remain transient projections over that durable identity. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection. A confirmed runtime transition from Threaded to Singleton stops threaded transport and suspends the process-local leader target before classic polling can accept new work; durable Workspace slot, generated name, manual display name, and binding evidence remain retained. Re-enabling Threaded Mode restores or replaces that logical binding before publishing one new live target. Already-admitted turns keep their captured destination and are never silently retargeted or duplicated.
374
+ Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over the active automatic display projection; bare `/name` opens five-minute exact-target input whose next valid text is consumed before agent dispatch. Name input, cancel, and reset are consume-once; stale scope, target, message ID, expiry, and duplicate callbacks cannot mutate. Reset clears only the override and restores the current automatic projection. Leader command routing reuses its already-held profile admission for the rename body rather than recursively entering the non-reentrant Workspace gate; standalone leader renames acquire their own admission. Followers send an authenticated exact-generation `workspace-thread-rename-v1` request, and the leader owns any Bot API mutation plus durable binding persistence. Concurrent processes from one directory receive deterministic Workspace suffixes, while leader/follower roles remain transient projections over that durable identity. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection. A confirmed runtime transition from Threaded to Singleton stops threaded transport and suspends the process-local leader target before classic polling can accept new work; durable Workspace slot, generated name, manual display name, and binding evidence remain retained. Re-enabling Threaded Mode restores or replaces that logical binding before publishing one new live target. Already-admitted turns keep their captured destination and are never silently retargeted or duplicated.
375
375
 
376
376
  ### Unbound Thread Detection
377
377
 
@@ -145,11 +145,11 @@ A registered instance exposes:
145
145
  }
146
146
  ```
147
147
 
148
- `instanceId` is liveness identity. `owner` is explicit current binding identity (`leader`, `manual-follower`, or `pending-topic`). Internal compatibility keys may be derived, but `state.json` should not hide ownership direction inside legacy string keys. `threadName` is the stable named-mode and restoration identity; `displayTitle` is the separately acknowledged user-facing projection. Fresh threads receive a compact palette name from the assigned slot, while Letters and Directories project other titles without replacing the saved name.
148
+ `instanceId` is liveness identity. `owner` is explicit current binding identity (`leader`, `manual-follower`, or `pending-topic`). Internal compatibility keys may be derived, but `state.json` should not hide ownership direction inside legacy string keys. `threadName` is the stable named-mode and restoration identity; `displayTitle` is the separately acknowledged user-facing projection. Fresh threads receive a compact palette name from the assigned slot; Names projects that saved name, while Letters and Directories project other titles without replacing it.
149
149
 
150
150
  ## Approved Next Contract: Directory Names And Reclaimable Slots
151
151
 
152
- Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters, Names (default), and Directories; follower changes use the capability-gated leader-owned setting path. Live bot chooser/notice labels and cross-instance agent-target resolution use acknowledged titles without granting routing authority. Confirmed owner cleanup now persists the first proven `inactiveSinceMs` transition atomically with target invalidation; successful active provisioning clears it. Pressure selection, intents, mocked execution, and recovery are implemented. A 2/2 same-model independent post-fix quorum cleared the admission-composition blocker at 0.96 confidence per reviewer, but production deletion remains disconnected by this release scope; `BACKLOG.md` owns operator smoke and release readiness.
152
+ Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters (default), Names, and Directories; follower changes use the capability-gated leader-owned setting path. Live bot chooser/notice labels and cross-instance agent-target resolution use acknowledged titles without granting routing authority. Confirmed owner cleanup now persists the first proven `inactiveSinceMs` transition atomically with target invalidation; successful active provisioning clears it. Pressure selection, intents, mocked execution, and recovery are implemented. A 2/2 same-model independent post-fix quorum cleared the admission-composition blocker at 0.96 confidence per reviewer, but production deletion remains disconnected by this release scope; `BACKLOG.md` owns operator smoke and release readiness.
153
153
 
154
154
  The pure policy distinguishes a free letter, a proposed pressure-reclamation victim, and protected/invalid capacity. Its caller must supply a validated profile-wide snapshot, reservations, proven inactivity start, and explicit protection classification; duplicate legacy letters block selection. The policy performs no filesystem or Telegram operations and does not establish liveness or deletion authority. It proposes a victim only when every profile-wide letter is occupied or reserved; elapsed time alone never triggers retirement.
155
155
 
@@ -167,7 +167,7 @@ Production retirement requires a durable profile-scoped reader/writer ledger own
167
167
  - Retirement intent preparation/adoption/execution owns its exact gate-and-ledger protocol but remains absent from production composition. Status projection and polling/routing bot-mode writes change only diagnostic or capability metadata; they neither create nor remove Thread/Workspace authority and serialize through the store's local persistence queue.
168
168
  - Production callers of reservation, provision/cleanup intent, target-record, Workspace-binding, and display mutators are contained by the owners above. The generic store remains policy-free for isolated tests and domain composition; calling a primitive directly is not production retirement authority.
169
169
  - Workspace identity remains the selected bot profile plus normalized exact full `cwd`; directory basenames are presentation, never routing keys. Each concurrent binding receives one profile-wide unique lowercase slot from `a` through `z`, persisted on the wire/store as its uppercase equivalent, independent of directory and leader/follower role. This replaces the two competing displayed allocation identities; immutable legacy `instanceSlot` and `bindingKey` remain recovery keys, not another displayed pool.
170
- - Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters` then `directories`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; directories use the directory basename. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides either automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Legacy persisted `names` configuration is read compatibly but resolves to Letters; it is no longer an effective or offered automatic choice. Store the automatic preference at `profiles.<name>.threadDisplayMode`, not as a process-local choice or a setting shared by unrelated bots.
170
+ - Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters`, `names`, then `directories`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; Names shows the generated dictionary name for the slot, such as `Anchor` for `A`; directories use the directory basename. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides any automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Store the automatic preference at `profiles.<name>.threadDisplayMode`, not as a process-local choice or a setting shared by unrelated bots.
171
171
  - In directory mode, a singleton may hide its suffix; once another retained binding for that Workspace exists, all its labels expose their globally assigned suffixes (for example `extensions_a`, `skills_b`, `extensions_c`). Persist the decision to show suffixes so later closure does not make names oscillate. Equal basenames from different paths require a deterministic parent-path qualifier. Preserve the existing `threadName` as generated/recovery identity while automatic modes are selected. New manual names live only in `manualThreadName`; do not guess manual provenance from a legacy name or palette membership. `showSlotSuffix: true` is sticky binding metadata; sibling creation and legacy multi-binding loads expose it, and later upserts that omit it cannot reset it. Telegram `/name Name` changes the manual override and displayed title only for its exact originating target. Leader and follower requests carry that target through final generation/binding checks, so replacement cannot redirect a stale dialog mutation. Reset uses the same negotiated `workspace-thread-rename-v1` capability and exact follower generation; the leader computes the current automatic projection, edits the exact target, clears only `manualThreadName`, and persists before acknowledging. Follower metadata refresh preserves an acknowledged display title only while target and registration generation stay unchanged. Named-profile setup preserves the latest saved automatic preference even if another instance changes it while the token form is open.
172
172
  - Fresh provisioning projects the candidate together with retained bindings and sends the active mode's title in `createForumTopic`. The exact targeted provision retains creation-title evidence until the Workspace commit publishes the binding and consumes that evidence together. Recovery preserves it even when a starting record already exists; an untargeted or unknown creation never authorizes a title commit. Proven deletion removes exact-target pending creation evidence, including when no current record was committed. Older contradictory pending/deleted snapshots settle that evidence durably before replacement; closed targets and pending cleanup block recovery until reconciliation, rather than becoming active again. The same exact-target check protects follower reconnect/carried-target shortcuts and the final Workspace commit, before creation evidence can be consumed. A matching carried pending target resumes through the provisioner that owns its reserved slot and acknowledged title instead of allocating that slot again. The shared provision-commit helper first commits the claim, then applies the acknowledged title with exact-binding comparison, preserving generic stale-title rejection on target replacement. Switching display mode changes projection only: preserve Thread ID, binding identity, slot, queue ownership, and routing. `displayTitle` records a successful Telegram edit independently of `threadName`, survives same-target registration updates, and is cleared on target replacement. The title reconciler captures profile, mode, leader epoch, and exact live-binding authority before each edit, rechecks after ACK and persistence, and skips dormant bindings. Failed persistence retains acknowledged dirty metadata for a later persist without repeating that API edit; a late or unknown ACK never commits a title to a replacement binding. Keep the stable palette/manual name separate from the current display title so switching back does not generate a different name. The leader owns Telegram title edits and acknowledged follower/TUI convergence, with generation/profile fencing and truthful partial-failure recovery. Successful registration ACKs optionally carry the acknowledged `displayTitle` with the exact target and registration generation, making it available before the initial status refresh. Heartbeats carry later title changes; stale generations cannot update display state. Connected notices use acknowledged titles while runtime `threadName` remains the stable restoration identity. Live bot chooser/notice labels, prompt attribution, and cross-instance agent-target name selection use the same acknowledged projection, but candidate liveness and the captured numeric `{chatId, threadId}` remain authoritative. Ambiguous projected names fail closed. Older peers can ignore the optional field, and no separate polling connection or follower snapshot-read loop is needed; do not expose a setting control that merely stores a preference without updating its promised surfaces.
173
173
  - Reopening a retained inactive binding restores its slot and name without taking leadership. Explicit connection from the same directory may allocate a second binding. Startup restore remains restore-only: it must not evict another Workspace or allocate a fresh binding merely because all remembered bindings are owned. Explicit connection already skips a live peer's migrated binding instead of attempting to adopt it; that admission rule is independent of the display redesign.
@@ -236,7 +236,7 @@ Run this only with an operator-approved disposable bot/profile and disposable Th
236
236
  1. Start one Pi in directory A, enable private-chat Threaded Mode, and run `/telegram-connect`. Confirm leader ownership, one Thread created directly with the selected display-mode title, the same title in the connected notice and initial Pi status, and no generated-name flash or second polling owner.
237
237
  2. Start a Pi in directory B and connect it. Confirm follower registration rather than takeover, a distinct globally ordered slot/name, the selected title in creation/notice/initial status without waiting for a heartbeat, exact prompt/reply routing, and no traffic in the leader Thread.
238
238
  3. Explicitly connect a second Pi from directory A. Confirm it receives a separate binding/slot without copying the first target. Restart each follower independently and confirm restore-only startup reuses its remembered exact-directory binding without allocating a new Thread.
239
- 4. Rename the leader and a follower from their respective Telegram Threads with `/name Name`. Confirm each manual override converges in Telegram, Pi status, choosers, notices, and agent-target labels under both Letters and Directories. Reset each override to the current automatic projection; confirm target IDs never change and same-basename directory suffixes remain sticky after a sibling disconnects.
239
+ 4. Rename the leader and a follower from their respective Telegram Threads with `/name Name`. Confirm each manual override converges in Telegram, Pi status, choosers, notices, and agent-target labels under all three display modes. Reset each override to the current automatic projection; confirm target IDs never change and same-basename directory suffixes remain sticky after a sibling disconnects.
240
240
  5. From leader and follower Threads, exercise ordinary prompts, callback buttons, one file, and one voice response. Confirm each result remains reply-anchored to the originating numeric Thread and no upload, notice, or final is duplicated.
241
241
  6. From `All`, create an unbound disposable Thread. Test forward and Replace/restore separately. Confirm accepted content reaches only the selected live instance, Restore carries the selected identity onto the source target, and only the confirmed old/chooser targets are deleted.
242
242
  7. Replace a follower session, then stop the leader and allow follower promotion. Confirm profile, target, slot, saved name, acknowledged title, accepted queue work, and routing survive without a new Thread or competing poller.
@@ -16,7 +16,7 @@ Projected blocks use `assistant.rendering` independently of voice policy. Rich m
16
16
 
17
17
  Assistant-message completion seals its preview state: queued follow-up drafts and late updates are suppressed. Native final delivery still waits for the already-issued draft request before sending the permanent answer, so an older draft is not deliberately allowed to overtake the final. Intermediate publication seals and drains its captured preview before sending permanent text. Preview rollover itself sends no permanent message: it carries the preceding delivery boundary and draft identity into the next state without holding the Pi message-start hook. The next draft waits for that publication to settle, including failure or cancellation. Active-turn final delivery captures its preview operations before entering the background queue: it drains only the originating draft, leaves a successor's preview untouched, and cannot wait for a successor publication queued behind itself. Delivery authority is rechecked after the captured flush; if the original preview has been replaced, ordinary final sending remains the queue's responsibility. This does not promise instant delivery or eliminate Telegram/client latency.
18
18
 
19
- Assistant previews use a two-second leading/trailing throttle per preview controller, with at most one request in flight. The first eligible snapshot sends immediately; changes inside the window replace pending text, and one trailing timer sends the latest safe snapshot without moving its deadline on every delta. Message/turn rollover preserves the remaining interval. Sealing, clearing, or replacing preview state cancels its timer; final publication drains only an already-issued request and never waits for the throttle deadline.
19
+ Assistant previews use a two-second trailing throttle per preview controller, with at most one request in flight. Each preview segment holds its first frame for one full interval from its first visible text, so the opening frame is an accumulated passage instead of the first streamed word even when the previous cadence boundary has already passed (fresh turn, slow first token, or rollover after tool work); changes inside the window replace pending text, and one trailing timer sends the latest safe snapshot without moving its deadline on every delta. Message/turn rollover preserves the remaining interval and reopens the initial window for the next segment. Sealing, clearing, or replacing preview state cancels its timer; final publication drains only an already-issued request and never waits for the throttle deadline.
20
20
 
21
21
  The Bot API client does not replay draft snapshots through API retry backoff. After a retryable draft HTTP failure (`429` or `5xx`), it defers new draft requests for that bot/chat/thread using the existing `Retry-After` or default backoff delay; it stores a deadline, not a body or a background retry. A deferred update does not advance the preview's last-delivered text. Fresh updates can send after the deadline, while a sealed preview cannot resume. Different bots and thread targets have independent cooldowns; credential rotation for the same bot does not bypass its deadline. Permanent replies retain their existing retry policy. Already-issued requests and Telegram/client rendering can still delay visible completion.
22
22
 
@@ -46,7 +46,7 @@ Stable commands inside Pi:
46
46
 
47
47
  ### Telegram commands
48
48
 
49
- - `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title under either automatic mode. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
49
+ - `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title over the active automatic projection. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
50
50
 
51
51
  Stable commands inside the paired Telegram DM:
52
52
 
@@ -120,7 +120,7 @@ Bot/session identity always persists under `profiles.<name>`. The ordinary setup
120
120
 
121
121
  The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
122
122
 
123
- Threaded Mode Settings exposes **Thread display** as Letters (default) or Directories. `profiles.<name>.threadDisplayMode` is profile-scoped; absent, invalid, and legacy `names` values resolve to `letters`. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1` and its exact registration generation. Directories requires compatible connected followers and rechecks compatibility before live publication. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
123
+ Threaded Mode Settings exposes **Thread display** as Letters (default), Names, or Directories. `profiles.<name>.threadDisplayMode` is profile-scoped; absent and invalid values resolve to `letters`, and Names projects the generated dictionary name for the slot. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1` and its exact registration generation. Letters and Directories require compatible connected followers and recheck compatibility before live publication; Names remains usable with legacy peers because it is their generated-name behavior. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
124
124
 
125
125
  Hidden/default semantics are represented by absence:
126
126
 
@@ -232,7 +232,7 @@ Rules:
232
232
  - Explain what the setting does and what the options mean only as much as needed.
233
233
  - Order setting value descriptions exactly like the chooser: rows top-to-bottom and values in a shared row left-to-right. Keep `(default)` on the actual default wherever it falls; default status never changes order.
234
234
  - Keep descriptions short and clear.
235
- - Automatic Thread display uses the same setting card: current value in `<code>`, then descriptions ordered `letters`, `directories`, with `(default)` only on `letters`. Its vertical chooser marks only the current option. A manual `/name Name` sets the current Thread display name and supersedes either automatic projection until reset; switching automatic mode preserves the slot and override.
235
+ - Automatic Thread display uses the same setting card: current value in `<code>`, then descriptions ordered `letters`, `names`, `directories`, with `(default)` only on `letters`. Its vertical chooser marks only the current option. A manual `/name Name` sets the current Thread display name and supersedes any automatic projection until reset; switching automatic mode preserves the slot and override.
236
236
 
237
237
  Examples:
238
238
 
@@ -141,9 +141,11 @@ export type TelegramThreadDisplayMode = "letters" | "names" | "directories";
141
141
  export function resolveTelegramThreadDisplayMode(
142
142
  config: Pick<TelegramConfig, "threadDisplayMode">,
143
143
  ): TelegramThreadDisplayMode {
144
- return config.threadDisplayMode === "directories"
145
- ? "directories"
146
- : "letters";
144
+ return config.threadDisplayMode === "names"
145
+ ? "names"
146
+ : config.threadDisplayMode === "directories"
147
+ ? "directories"
148
+ : "letters";
147
149
  }
148
150
 
149
151
  export async function setTelegramThreadDisplayMode(
@@ -151,7 +153,7 @@ export async function setTelegramThreadDisplayMode(
151
153
  mode: TelegramThreadDisplayMode,
152
154
  isCurrent: () => boolean,
153
155
  ): Promise<void> {
154
- if (!["letters", "directories"].includes(mode)) {
156
+ if (!["letters", "names", "directories"].includes(mode)) {
155
157
  throw new Error("Invalid Telegram Thread display mode.");
156
158
  }
157
159
  const profile = store.getActiveProfileName();
@@ -182,6 +182,7 @@ export function buildThreadDisplaySettingsText(mode: TelegramThreadDisplayMode):
182
182
  "Choose how this bot profile labels Telegram tabs and Pi terminal status. Each slot is unique across this bot profile.",
183
183
  "",
184
184
  "<code>-</code> <code>letters</code> (default): show the unique slot, such as <b><i>A</i></b> or <b><i>B</i></b>.",
185
+ "<code>-</code> <code>names</code>: show the generated dictionary name for the slot, such as <b><i>Anchor</i></b> or <b><i>Briar</i></b>.",
185
186
  "<code>-</code> <code>directories</code>: show the directory, such as <b><i>extensions</i></b>; shared Workspaces keep slot suffixes, such as <b><i>extensions_a</i></b> and <b><i>extensions_c</i></b>.",
186
187
  "A manual <code>/name Name</code> overrides this Thread display name until reset.",
187
188
  ].join("\n");
@@ -414,7 +415,7 @@ export async function openTelegramSettingsMenu<
414
415
  export function buildThreadDisplaySettingsReplyMarkup(mode: TelegramThreadDisplayMode): TelegramSettingsMenuReplyMarkup {
415
416
  return { inline_keyboard: [
416
417
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
417
- ...(["letters", "directories"] as const).map((value) => [{
418
+ ...(["letters", "names", "directories"] as const).map((value) => [{
418
419
  text: `${mode === value ? "🟢 " : ""}${value}`,
419
420
  callback_data: `settings:set:thread-display:${value}`,
420
421
  }]),
@@ -15,6 +15,9 @@ import { shouldSuppressPreviewForVoice } from "./voice.ts";
15
15
 
16
16
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
17
17
  const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
18
+ // Native draft cadence: at most one frame per interval, and a fresh preview
19
+ // segment holds its first frame for one full interval so the opening frame is
20
+ // an accumulated passage rather than a single streamed word.
18
21
  const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
19
22
 
20
23
  export type TelegramDraftSupport = "unknown" | "supported";
@@ -90,6 +93,7 @@ export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
90
93
  createPreviewState: () => TelegramPreviewRuntimeState;
91
94
  canSend?: () => boolean;
92
95
  getMessageText: (message: TMessage) => string;
96
+ minDraftIntervalMs?: number;
93
97
  schedulePreviewFlush: (
94
98
  chatId: number,
95
99
  options?: { target?: TelegramTarget },
@@ -305,6 +309,7 @@ export function createTelegramAssistantPreviewRuntime<
305
309
  createPreviewState: controller.createState,
306
310
  canSend: deps.canSend,
307
311
  getMessageText: deps.getMessageText,
312
+ minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
308
313
  schedulePreviewFlush: controller.scheduleFlush,
309
314
  }),
310
315
  };
@@ -466,9 +471,24 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
466
471
  deps.setState(state);
467
472
  }
468
473
  if (state.sealed) return;
474
+ const hadVisibleText = state.pendingText.length > 0;
469
475
  state.pendingText = stripTelegramCommentMarkupForPreview(
470
476
  deps.getMessageText(message),
471
477
  );
478
+ // The first visible text of a preview segment opens an initial accumulation
479
+ // window, so the segment's first frame cannot ship as a single word even
480
+ // when the previous cadence boundary has already passed (fresh turn, slow
481
+ // first token, or message rollover after tool work). Later deltas keep the
482
+ // trailing deadline instead of sliding it on every update.
483
+ const interval = deps.minDraftIntervalMs ?? 0;
484
+ if (
485
+ interval > 0 &&
486
+ !hadVisibleText &&
487
+ !state.lastSentText &&
488
+ state.pendingText.length > 0
489
+ ) {
490
+ state.nextDraftAt = Math.max(state.nextDraftAt ?? 0, Date.now() + interval);
491
+ }
472
492
  deps.schedulePreviewFlush(turn.chatId, { target: turn.target });
473
493
  }
474
494
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.45.2",
3
+ "version": "0.45.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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.0",
48
- "@llblab/pi-telegram": "0.45.2",
46
+ "@llblab/pi-grow-loop": "0.8.0",
47
+ "@llblab/pi-state-flow": "0.9.0",
48
+ "@llblab/pi-telegram": "0.45.4",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [