@llblab/pi-telegram 0.49.0 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -242,6 +242,11 @@ export declare function removeTelegramQueueItemsByMessageIds<TContext = unknown>
242
242
  removedItems: TelegramQueueItem<TContext>[];
243
243
  removedCount: number;
244
244
  };
245
+ export declare function removeTelegramQueuedGuestPromptByOrder<TContext = unknown>(items: TelegramQueueItem<TContext>[], queueOrder: number): {
246
+ items: TelegramQueueItem<TContext>[];
247
+ removedItems: PendingTelegramTurn[];
248
+ removedCount: number;
249
+ };
245
250
  export declare function applyTelegramQueuePromptReactionDisposition<TContext = unknown>(items: TelegramQueueItem<TContext>[], messageId: number, disposition: TelegramQueueReactionDisposition, destinationLaneOrder?: number, scope?: TelegramQueueMessageScope): {
246
251
  items: TelegramQueueItem<TContext>[];
247
252
  changed: boolean;
@@ -624,6 +629,7 @@ export interface TelegramQueueMutationController<TContext> {
624
629
  reorder: (ctx: TContext) => void;
625
630
  clear: (ctx: TContext) => number;
626
631
  removeByMessageIds: (messageIds: number[], ctx: TContext, scope?: TelegramQueueMessageScope) => number;
632
+ removeGuestPromptByQueueOrder?: (queueOrder: number, ctx: TContext) => boolean;
627
633
  applyReactionByMessageId: (messageId: number, disposition: TelegramQueueReactionDisposition, ctx: TContext, scope?: TelegramQueueMessageScope) => boolean;
628
634
  }
629
635
  export interface TelegramControlQueueControllerDeps<TContext> {
@@ -673,6 +679,7 @@ export declare function createTelegramQueueMutationController<TContext>(deps: Te
673
679
  export declare function reorderTelegramQueueItemsRuntime<TContext>(deps: TelegramQueueMutationRuntimeDeps<TContext>): void;
674
680
  export declare function clearTelegramQueueItemsRuntime<TContext>(deps: TelegramQueueMutationRuntimeDeps<TContext>): number;
675
681
  export declare function removeTelegramQueueItemsByMessageIdsRuntime<TContext>(messageIds: number[], deps: TelegramQueueMutationRuntimeDeps<TContext>, scope?: TelegramQueueMessageScope): number;
682
+ export declare function removeTelegramQueuedGuestPromptByOrderRuntime<TContext>(queueOrder: number, deps: TelegramQueueMutationRuntimeDeps<TContext>): boolean;
676
683
  export declare function applyTelegramQueuePromptReactionDispositionRuntime<TContext>(messageId: number, disposition: TelegramQueueReactionDisposition, deps: TelegramQueueMutationRuntimeDeps<TContext>, scope?: TelegramQueueMessageScope): boolean;
677
684
  export declare function enqueueTelegramPromptTurnRuntime<TMessage, TContext = unknown>(messages: TMessage[], deps: TelegramPromptEnqueueRuntimeDeps<TMessage, TContext>): Promise<PendingTelegramTurn>;
678
685
  export declare function createTelegramPromptEnqueueController<TMessage, TContext = unknown>(deps: TelegramPromptEnqueueControllerDeps<TMessage, TContext>): TelegramPromptEnqueueController<TMessage, TContext>;
package/dist/lib/queue.js CHANGED
@@ -491,6 +491,21 @@ export function removeTelegramQueueItemsByMessageIds(items, messageIds, scope) {
491
491
  removedCount: removedItems.length,
492
492
  };
493
493
  }
494
+ export function removeTelegramQueuedGuestPromptByOrder(items, queueOrder) {
495
+ const index = items.findIndex((item) => {
496
+ return isPendingTelegramTurn(item) &&
497
+ item.guestQueryId !== undefined &&
498
+ item.queueOrder === queueOrder;
499
+ });
500
+ if (index < 0)
501
+ return { items, removedItems: [], removedCount: 0 };
502
+ const removedItem = items[index];
503
+ return {
504
+ items: [...items.slice(0, index), ...items.slice(index + 1)],
505
+ removedItems: [removedItem],
506
+ removedCount: 1,
507
+ };
508
+ }
494
509
  export function applyTelegramQueuePromptReactionDisposition(items, messageId, disposition, destinationLaneOrder, scope) {
495
510
  let nextItems = items;
496
511
  for (const [index, item] of items.entries()) {
@@ -1440,6 +1455,7 @@ export function createTelegramQueueMutationController(deps) {
1440
1455
  reorder: (ctx) => reorderTelegramQueueItemsRuntime(buildRuntimeDeps(ctx)),
1441
1456
  clear: (ctx) => clearTelegramQueueItemsRuntime(buildRuntimeDeps(ctx)),
1442
1457
  removeByMessageIds: (messageIds, ctx, scope) => removeTelegramQueueItemsByMessageIdsRuntime(messageIds, buildRuntimeDeps(ctx), scope),
1458
+ removeGuestPromptByQueueOrder: (queueOrder, ctx) => removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, buildRuntimeDeps(ctx)),
1443
1459
  applyReactionByMessageId: (messageId, disposition, ctx, scope) => applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, buildRuntimeDeps(ctx), scope),
1444
1460
  };
1445
1461
  }
@@ -1492,6 +1508,17 @@ export function removeTelegramQueueItemsByMessageIdsRuntime(messageIds, deps, sc
1492
1508
  updateTelegramQueueStatusRuntime(deps);
1493
1509
  return removedCount;
1494
1510
  }
1511
+ export function removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, deps) {
1512
+ const { items, removedItems, removedCount } = removeTelegramQueuedGuestPromptByOrder(deps.getQueuedItems(), queueOrder);
1513
+ if (removedCount === 0)
1514
+ return false;
1515
+ // A menu Skip means permanent discard, so settle durable admission before
1516
+ // removing the live item. A failed settlement keeps the prompt retryable.
1517
+ deps.onItemsDiscarded?.(removedItems, deps.ctx);
1518
+ deps.setQueuedItems(items);
1519
+ updateTelegramQueueStatusRuntime(deps);
1520
+ return true;
1521
+ }
1495
1522
  export function applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, deps, scope) {
1496
1523
  const queuedItems = deps.getQueuedItems();
1497
1524
  const changesLane = queuedItems.some((item) => {
@@ -190,6 +190,7 @@ export declare const TELEGRAM_GUEST_PLACEHOLDER_FRAME_MS = 1000;
190
190
  export declare const TELEGRAM_GUEST_PLACEHOLDER_MIN_MS = 20000;
191
191
  export declare const TELEGRAM_GUEST_PLACEHOLDER_MAX_MS = 26000;
192
192
  export declare const TELEGRAM_GUEST_PLACEHOLDER_FRAMES: readonly ["<b>🌎 Working on it.</b>", "<b>🌍 Working on it.</b>", "<b>🌏 Working on it..</b>", "<b>🌎 Working on it..</b>", "<b>🌍 Working on it...</b>", "<b>🌏 Working on it...</b>"];
193
+ export declare const TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT = "\u2063";
193
194
  export declare function buildTelegramGuestPlaceholderFrame(step: number): string;
194
195
  export interface TelegramGuestPlaceholderRuntimeDeps {
195
196
  editGuestInlineMessage: (inlineMessageId: string, content: {
@@ -211,5 +212,7 @@ export interface TelegramGuestPlaceholderRuntime {
211
212
  stop: (inlineMessageId: string) => Promise<void>;
212
213
  /** Cancels every loop without waiting for in-flight edits (session shutdown). */
213
214
  stopAll: () => void;
215
+ /** Stops rotation and visually clears the inline placeholder. */
216
+ dismiss: (inlineMessageId: string) => Promise<void>;
214
217
  }
215
218
  export declare function createTelegramGuestPlaceholderRuntime(deps: TelegramGuestPlaceholderRuntimeDeps): TelegramGuestPlaceholderRuntime;
@@ -612,6 +612,9 @@ export const TELEGRAM_GUEST_PLACEHOLDER_FRAMES = [
612
612
  "<b>🌍 Working on it...</b>",
613
613
  "<b>🌏 Working on it...</b>",
614
614
  ];
615
+ // Telegram does not expose deletion for inline messages. This non-printing,
616
+ // non-empty edit visually clears a guest placeholder after its query is skipped.
617
+ export const TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT = "\u2063";
615
618
  export function buildTelegramGuestPlaceholderFrame(step) {
616
619
  const frames = TELEGRAM_GUEST_PLACEHOLDER_FRAMES;
617
620
  const index = ((step % frames.length) + frames.length) % frames.length;
@@ -734,5 +737,19 @@ export function createTelegramGuestPlaceholderRuntime(deps) {
734
737
  }
735
738
  sessions.clear();
736
739
  },
740
+ async dismiss(inlineMessageId) {
741
+ await stop(inlineMessageId);
742
+ try {
743
+ await deps.editGuestInlineMessage(inlineMessageId, {
744
+ text: TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT,
745
+ parseMode: "HTML",
746
+ });
747
+ }
748
+ catch (error) {
749
+ deps.recordRuntimeEvent?.("guest", error, {
750
+ phase: "guest-placeholder-dismiss",
751
+ });
752
+ }
753
+ },
737
754
  };
738
755
  }
package/dist/lib/sync.js CHANGED
@@ -381,7 +381,14 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
381
381
  assertLeaderEpoch("before-workspace-persist");
382
382
  await deps.topicTargetStore.persist();
383
383
  assertLeaderEpoch("after-workspace-persist");
384
- return { ...result, ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}) };
384
+ const { target: _target, slot: _slot, threadName: _threadName, displayTitle: _displayTitle, ...rest } = result;
385
+ return {
386
+ ...rest,
387
+ target: { ...committed.target },
388
+ slot: committed.slot ?? result.slot,
389
+ ...(committed.threadName ? { threadName: committed.threadName } : {}),
390
+ ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}),
391
+ };
385
392
  };
386
393
  try {
387
394
  const unavailableTargetKeys = new Set([
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.49.0",
3
+ "version": "0.50.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -480,6 +480,8 @@ Priority and Skip may coexist, including `👍 + 💩`. The prompt remains at it
480
480
 
481
481
  Menu and reaction controls share the same canonical queue state. A menu Keep can clear internal Skip without changing Priority or queue position, but Telegram's Bot API cannot remove a reaction created by the user; the visible user reaction can therefore remain until that user removes it. Once Pi has consumed or dropped a prompt, later reactions cannot retract or restore it.
482
482
 
483
+ Guest Mode is the deliberate exception to deferred Keep/Skip semantics. A guest prompt has no source message reaction surface and may share zero-valued chat/message placeholders with other guest prompts, so queue-menu callbacks address it by immutable `queueOrder` and expose an immediate `Skip`. The leader settles its durable admission, removes it while other agent work may continue, stops placeholder animation, and replaces the inline globe placeholder with non-printing content. Bot API inline messages have no delete method, so this visual clear is the supported deletion equivalent. The guest prompt never reaches a model turn.
484
+
483
485
  Admission and planning validate lane contracts. Invalid lane/kind pairings fail predictably instead of being silently coerced.
484
486
 
485
487
  Prompt preparation completes downloads, inbound handlers, and binary image reads before synchronous final assembly and queue commit. Enqueue captures only intended abort-history identities before preparation; afterward it reads the current queue, folds surviving selected prompts using their current text and receipts, allocates the new turn's order, and appends without another asynchronous boundary. The handed-off head remains in place until `agent_start` consumes it, even if preparation finishes first. Concurrent arrivals, removals, and lane/reaction edits remain intact; preparation failure or stale-generation completion leaves intervening queue changes untouched.
@@ -610,7 +612,7 @@ Queue reaction behavior, lane-tail transitions, Keep/Skip independence, multi-re
610
612
 
611
613
  Complete intermediate assistant text blocks from Telegram-originated activity are sent once to the immutable originating target before active-turn final delivery; final and terminal-partial segments stay with settlement so replies are not duplicated. While this instance has exact direct or follower transport authority, completed public blocks from local/autonomous work are always sent once and in source order to the instance's authorized target. Connected companion projection is not configurable; disconnect or authority loss is its boundary. Both paths use the configured Rich or HTML renderer and exclude reasoning, tool traffic, token deltas, local prompt text, unknown sources, and stale generations. Each admitted block remains fenced to its exact target, profile/token stamp, leader epoch or follower registration generation, and session generation; non-idempotent acknowledgement ambiguity never authorizes replay.
612
614
 
613
- `assistant.activity` is an independent bridge-owned projection over normalized Activity events. Each process reloads the shared file-backed setting at `agent-start` before activity admission, so multi-instance mode cannot continue projecting a stale broader process-local selection. Omitted values resolve to `verbose`, while invalid values fail closed to `quiet`; `thinking` and `tools` select one technical class, while `verbose` enables both. Provider-exposed thinking uses persistent ordinary HTML containing only a standard expandable blockquote with a bounded redacted latest-text window and inline Markdown rendered as Telegram HTML. Completed executed tools use native Rich Messages: each closed `<Tool>: <status>` root details node renders snake-case names as title words while preserving an uppercase two- or three-letter repeated prefix per word, then the native disclosure chevron reveals an open-by-default `arguments` child plus closed retained `update N` and `result`/`error` child details with lowercase monospaced, marker-free summaries and JSON pre blocks; known-safe Rich rejections fall back to the previous HTML disclosure. The projection captures the exact target and transport stamp at activity admission, serializes updates, preserves tool-start order, closes coalescing across assistant/thinking boundaries, bounds retained text/update memory plus edit frames and message/tool size, disables previews and HTTP(S) auto-link recognition inside technical evidence, and never replays a possibly committed send. Session generations own independent queues, so replacement drops queued old work without waiting on an old call. Bridge-owned assistant output and activity projection share one activity-publication sequence admitted synchronously from the activity bus, so later tool disclosures cannot overtake earlier assistant blocks. Activity targets and transport authority are captured before queued work starts. Active Telegram-turn final replies/artifacts and automatic compaction notices use that same publication owner before the existing direct/follower transport split, without mutually waiting on separate output tails. Final settlement captures the exact active turn and assistant result and reserves its publication position before config loading. Empty outcomes without a publication candidate reserve no position. Replacement, preparation failure, or an unused reservation releases the position without resetting or replying for a replacement turn. Final errors also publish through this owner. Reservations accept one task; cancellation cannot undo a published task, and session reset releases unresolved reservations while fencing old queued tasks. Terminal assistant messages with a publication candidate reserve the final position synchronously; settlement consumes that reservation only for the exact originating turn. Compaction notices enter the same queue immediately, behind the reserved final rather than through a separate notice buffer. Settlement, a new agent run, or session replacement cancels an unconsumed reservation. Notice target and authority are captured when the event is observed. Pi lifecycle completion does not wait for network publication. This order is process-local and does not serialize unrelated instances or independent registered activity handlers. Settlement, replacement, disconnect, failure, or stale authority clears only local ownership; already-sent activity messages remain in chat.
615
+ `assistant.activity` is an independent bridge-owned projection over normalized Activity events. Each process reloads the shared file-backed setting at `agent-start` before activity admission, so multi-instance mode cannot continue projecting a stale broader process-local selection. Omitted values resolve to `verbose`, while invalid values fail closed to `quiet`; `thinking` and `tools` select one technical class, while `verbose` enables both. Provider-exposed thinking uses persistent ordinary HTML containing only a standard expandable blockquote with a bounded redacted latest-text window and inline Markdown rendered as Telegram HTML. Like answer drafts, it accumulates for two seconds before the first frame, publishes at most one update every two seconds, and flushes remaining buffered reasoning on terminal completion. Completed executed tools use native Rich Messages: each closed `<Tool>: <status>` root details node renders snake-case names as title words while preserving an uppercase two- or three-letter repeated prefix per word, then the native disclosure chevron reveals an open-by-default `arguments` child plus closed retained `update N` and `result`/`error` child details with lowercase monospaced, marker-free summaries and JSON pre blocks; known-safe Rich rejections fall back to the previous HTML disclosure. The projection captures the exact target and transport stamp at activity admission, serializes updates, preserves tool-start order, closes coalescing across assistant/thinking boundaries, bounds retained text/update memory plus edit frames and message/tool size, disables previews and HTTP(S) auto-link recognition inside technical evidence, and never replays a possibly committed send. Session generations own independent queues, so replacement drops queued old work without waiting on an old call. Bridge-owned assistant output and activity projection share one activity-publication sequence admitted synchronously from the activity bus, so later tool disclosures cannot overtake earlier assistant blocks. Activity targets and transport authority are captured before queued work starts. Active Telegram-turn final replies/artifacts and automatic compaction notices use that same publication owner before the existing direct/follower transport split, without mutually waiting on separate output tails. Final settlement captures the exact active turn and assistant result and reserves its publication position before config loading. Empty outcomes without a publication candidate reserve no position. Replacement, preparation failure, or an unused reservation releases the position without resetting or replying for a replacement turn. Final errors also publish through this owner. Reservations accept one task; cancellation cannot undo a published task, and session reset releases unresolved reservations while fencing old queued tasks. Terminal assistant messages with a publication candidate reserve the final position synchronously; settlement consumes that reservation only for the exact originating turn. Compaction notices enter the same queue immediately, behind the reserved final rather than through a separate notice buffer. Settlement, a new agent run, or session replacement cancels an unconsumed reservation. Notice target and authority are captured when the event is observed. Pi lifecycle completion does not wait for network publication. This order is process-local and does not serialize unrelated instances or independent registered activity handlers. Settlement, replacement, disconnect, failure, or stale authority clears only local ownership; already-sent activity messages remain in chat.
614
616
 
615
617
  Telegram prompt guidance is context- and authority-aware. The package and source-checkout extension contribute `telegram-bridge`, optional `generated-control-surface`, and `generative-apps` Skills through Pi resource discovery. Generated Control Surface treats `interface = f(state, capabilities, intent)` as a renderer-neutral primitive, compiling transient evidence-backed controls over domain-owned workflows, systems, navigation, supervision, and decisions without creating parallel application state. It composes an ordered ragged sequence of independently sized semantic rows rather than filling a rectangular grid: compact rows contain genuine peers, singleton rows isolate structurally independent actions, and rectangular layouts remain reserved for genuinely spatial state. Text-bearing controls use at most two columns and flow into additional rows, while denser rows are reserved for short position-bearing glyphs or codes and never exceed the eight-column phone-width UX maximum. Vertical extent is independent: a true spatial surface may retain substantially more rows, while non-spatial button walls route to grouping, disclosure, or pagination. Symmetry is treated as an evidence claim about equal relationships or real spatial topology; an abstract layout catalog supplies adaptable singleton, peer, staged, navigational, repeated-pair, and rectangular shapes without forcing tasks into preset grids. Repeated controls carry the smallest sufficient action delta when visible conversation is unambiguous; larger or error-prone state moves to a deterministic task-owned Markdown artifact, correctness-sensitive transitions move to a small domain-owned transition implementation, and repeated clicks are adjudicated against current state rather than stale button appearance. Its filesystem adapter reserves the first full-width row for parent traversal outside root, places available Previous/Next controls together in one compact row immediately afterward, orders visible directories, hidden directories, visible files, and hidden files alphabetically within each category before fixed ten-entry pagination, renders path/range metadata as stacked status-style key-value rows instead of middle-dot prose, emits the complete Telegram control set through one JSON-matrix action, suppresses duplicate plain/monospaced listings and default Refresh unless user preference overrides presentation, and retains an ordinary numbered fallback when buttons are unavailable. Only an exact direct owner or live registered follower exposes the two pi-telegram delivery tools, their active-tool metadata, and the compact routing suffix. Disconnect or authority loss removes those tool surfaces for subsequent requests without touching foreign tools; reconnect/recovery restores only the pi-telegram subset that was active before suspension, including across same-process reload. Repeated stable interactions may graduate from that model-mediated surface into a reviewed Generative App whose deterministic bound methods bypass Pi queue admission; the `generative-apps` Skill owns this compilation and operating workflow while the underlying capability retains domain authority. Telegram-originated turns route to the stable Skill contracts and retain dynamic blocks such as `[voice] delivery: automatic voice`; the Skills and public documentation own syntax, target routing, Threaded Mode behavior, Generative App operation, and diagnostics.
616
618
 
@@ -186,7 +186,7 @@ Status: locally implemented and validated for the next minor release; final comp
186
186
 
187
187
  A durable Workspace binding is owned by the selected bot profile plus `{ cwd, sessionId? }`. `cwd` is the normalized exact full path. When present, `sessionId` is the exact non-empty bounded value returned by the public `ctx.sessionManager.getSessionId()` API. Session display names, selector positions, session file paths, process instance IDs, and lifecycle generations are metadata or transient fences, never substitutes for the stable session ID. Absence is a real identity value `{ cwd, ∅ }`, not a wildcard; malformed present values still fail closed.
188
188
 
189
- The persisted binding stores the exact session ID and derives its new binding key from the existing collision-verified Workspace directory key plus a full SHA-256 digest of the session ID. Exact persisted values remain the collision check; the digest is an index component, not independent authority. Reopening, `/resume`, `/continue`/continue-recent, or process replacement with the same `{ cwd, sessionId }` reclaims the same retained target and profile-wide letter when current ownership, accepted-work, admission, and retirement evidence allow it. `/new` and `/fork` produce distinct session IDs and therefore distinct bindings even in the same directory. `/reload` changes neither component.
189
+ The persisted binding stores the exact session ID and derives its new binding key from the existing collision-verified Workspace directory key plus a full SHA-256 digest of the session ID. Exact persisted values remain the collision check; the digest is an index component, not independent authority. Reopening, `/resume`, `/continue`/continue-recent, or process replacement with the same `{ cwd, sessionId }` reclaims the same retained target and profile-wide letter when current ownership, accepted-work, admission, and retirement evidence allow it. When target deduplication resolves a provisional claim to an already retained binding, the committed binding's canonical target, slot, and generated name must drive the live record and reported result; provisional metadata must not consume a second letter. `/new` and `/fork` produce distinct session IDs and therefore distinct bindings even in the same directory. `/reload` changes neither component.
190
190
 
191
191
  Legacy cwd-only bindings remain exact compatibility identities whose absent session component is itself a unique key. They are neither wildcard session bindings nor adoption candidates: session-aware claims never stamp, migrate, consume, or restore them, and instead allocate their own `{ cwd, sessionId }` binding subject to the same global slot and protection rules. `{ cwd, ∅ }`, `{ cwd, session-a }`, and `{ cwd, session-b }` may coexist as majestic degradation for older state and peers. The 0.46 runtime reads strict legacy records as inert independent `{ cwd, ∅ }` bindings but never migrates, adopts, or routes a session through them. Protocol v2 is session-native and intentionally incompatible with protocol-v1 0.45.x peers. Upgrade requires stopping all Telegram instances first, updating them together, and then starting them again; the first v2 process becomes leader with its own session-qualified binding.
192
192
 
@@ -40,7 +40,7 @@ import {
40
40
  Stable commands inside Pi:
41
41
 
42
42
  - `/telegram-setup` — configure/update the bot token.
43
- - `/telegram-connect` — start polling here and acquire external Telegram control ownership. `/telegram-connect [profile] as=Name` assigns an optional unique capitalized Latin-word identity only when provisioning a fresh Workspace Thread. Accepted queue/reply state stays local if ownership later moves elsewhere. A successful command queues a hidden connection-state note for delivery with the agent's next turn without triggering one; it says Telegram is connected and that connectivity alone is not user intent.
43
+ - `/telegram-connect` — start polling here and acquire external Telegram control ownership, optionally for a named profile. Thread names are configured from Telegram rather than Pi commands. Accepted queue/reply state stays local if ownership later moves elsewhere. A successful command queues a hidden connection-state note for delivery with the agent's next turn without triggering one; it says Telegram is connected and that connectivity alone is not user intent.
44
44
  - `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. A successful command queues the corresponding hidden, non-triggering disconnected context note; cancelled or failed disconnects do not publish a false state transition. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while `reload`, `new`, `resume`, and `fork` preserve same-process handoff.
45
45
  - `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics; debug output separates poller and worker progress, durable automatic-retry state, exact foreign queued-owner identity, and negotiated protocol/build/capabilities.
46
46
 
@@ -51,7 +51,7 @@ Stable commands inside Pi:
51
51
  Stable commands inside the paired Telegram DM:
52
52
 
53
53
  - `/start` — pair when needed and open the main application menu.
54
- - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches an internal Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Its real `ExtensionCommandContext` calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
54
+ - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
55
55
  - `/compact` — open confirmation and compact when idle.
56
56
  - `/next` — abort active work first when needed, let the interrupted prompt receive its abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. The command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
57
  - `/continue` — enqueue a priority `continue` prompt.
@@ -24,7 +24,9 @@ export const TELEGRAM_ACTIVITY_MESSAGE_MAX_CHARS = 3_900;
24
24
  export const TELEGRAM_ACTIVITY_MESSAGE_MAX_TOOLS = 6;
25
25
  export const TELEGRAM_REASONING_MESSAGE_MAX_FRAMES = 24;
26
26
  export const TELEGRAM_REASONING_BUFFER_MAX_CHARS = 1_200;
27
- export const TELEGRAM_REASONING_MIN_INTERVAL_MS = 1_200;
27
+ // Match native answer drafts: accumulate the opening frame for one full
28
+ // interval, then publish at most one updated frame per interval.
29
+ export const TELEGRAM_REASONING_MIN_INTERVAL_MS = 2_000;
28
30
  export const TELEGRAM_TOOL_UPDATE_MAX_ENTRIES = 4;
29
31
 
30
32
  interface ToolActivity {
@@ -325,6 +327,11 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
325
327
  getActivityMode: () => "quiet" | "thinking" | "tools" | "verbose";
326
328
  refreshActivityMode?: () => Promise<void>;
327
329
  getNowMs?: () => number;
330
+ setReasoningTimeout?: (
331
+ callback: () => void,
332
+ delayMs: number,
333
+ ) => ReturnType<typeof setTimeout>;
334
+ clearReasoningTimeout?: (timer: ReturnType<typeof setTimeout>) => void;
328
335
  resolveTarget: (event: TelegramActivityEvent) => TelegramTarget | undefined;
329
336
  captureAuthority: () => TAuthority;
330
337
  isAuthorityActive: (authority: TAuthority) => boolean;
@@ -360,11 +367,18 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
360
367
  let reasoningMessage: ReasoningMessage | undefined;
361
368
  let reasoningBlocked = false;
362
369
  let lastReasoningPublishMs = 0;
370
+ let reasoningFlushTimer: ReturnType<typeof setTimeout> | undefined;
363
371
  let toolMessage: ToolMessage | undefined;
364
372
  const tools = new Map<string, ToolActivity>();
365
373
  const toolOrder: string[] = [];
366
374
 
375
+ const clearReasoningFlushTimer = () => {
376
+ if (!reasoningFlushTimer) return;
377
+ (deps.clearReasoningTimeout ?? clearTimeout)(reasoningFlushTimer);
378
+ reasoningFlushTimer = undefined;
379
+ };
367
380
  const clearActivity = () => {
381
+ clearReasoningFlushTimer();
368
382
  activityId = undefined;
369
383
  authority = undefined;
370
384
  target = undefined;
@@ -466,6 +480,33 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
466
480
  );
467
481
  }
468
482
  };
483
+ const scheduleReasoningPublish = (
484
+ event: TelegramActivityEvent,
485
+ acceptedGeneration: number,
486
+ ) => {
487
+ if (reasoningFlushTimer || reasoningBlocked) return;
488
+ const elapsed = reasoningMessageFrames === 0
489
+ ? 0
490
+ : getNowMs() - lastReasoningPublishMs;
491
+ const delayMs = Math.max(0, TELEGRAM_REASONING_MIN_INTERVAL_MS - elapsed);
492
+ const schedule = deps.setReasoningTimeout ?? setTimeout;
493
+ reasoningFlushTimer = schedule(() => {
494
+ reasoningFlushTimer = undefined;
495
+ const admittedAuthority = authority;
496
+ const task = async () => {
497
+ if (
498
+ !isCurrent(acceptedGeneration, admittedAuthority) ||
499
+ reasoningChars <= lastReasoningMessageChars
500
+ ) return;
501
+ await publishReasoning(event, acceptedGeneration);
502
+ };
503
+ const enqueue = deps.enqueue ?? ((next: () => Promise<void>) => tail.then(next));
504
+ tail = enqueue(task).catch((error) => {
505
+ deps.recordFailure?.("reasoning-send", event, error);
506
+ });
507
+ }, delayMs) as ReturnType<typeof setTimeout>;
508
+ reasoningFlushTimer?.unref?.();
509
+ };
469
510
  const publishTool = async (
470
511
  event: TelegramActivityEvent,
471
512
  tool: ToolActivity,
@@ -602,13 +643,8 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
602
643
  reasoningBuffer = `${reasoningBuffer}${event.delta}`.slice(
603
644
  -TELEGRAM_REASONING_BUFFER_MAX_CHARS,
604
645
  );
605
- if (reasoningMessageFrames < TELEGRAM_REASONING_MESSAGE_MAX_FRAMES &&
606
- (reasoningMessageFrames === 0 ||
607
- (getNowMs() - lastReasoningPublishMs >=
608
- TELEGRAM_REASONING_MIN_INTERVAL_MS &&
609
- reasoningChars - lastReasoningMessageChars >= 160))
610
- ) {
611
- await publishReasoning(event, acceptedGeneration);
646
+ if (reasoningMessageFrames < TELEGRAM_REASONING_MESSAGE_MAX_FRAMES) {
647
+ scheduleReasoningPublish(event, acceptedGeneration);
612
648
  }
613
649
  return;
614
650
  }
@@ -620,6 +656,7 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
620
656
  -TELEGRAM_REASONING_BUFFER_MAX_CHARS,
621
657
  );
622
658
  }
659
+ clearReasoningFlushTimer();
623
660
  if (
624
661
  reasoningChars > 0 &&
625
662
  reasoningChars > lastReasoningMessageChars &&
@@ -687,8 +724,8 @@ export function createTelegramActivityVerbosityRuntime<TAuthority>(deps: {
687
724
  return;
688
725
  }
689
726
  if (event.type === "agent-end" || event.type === "agent-settled") {
727
+ clearReasoningFlushTimer();
690
728
  if (
691
- reasoningMessage &&
692
729
  reasoningChars > lastReasoningMessageChars &&
693
730
  !reasoningBlocked
694
731
  ) {
package/lib/bindings.ts CHANGED
@@ -563,10 +563,6 @@ interface TelegramCommandsAndToolsBindingDeps {
563
563
  stopPolling?: () => Promise<void | string>;
564
564
  recoverPollingStart?: Commands.TelegramBridgeCommandRegistrationDeps["recoverPollingStart"];
565
565
  getDisconnectThreadName?: () => string | undefined;
566
- setRequestedThreadNameForPollingStart?: (
567
- threadName: string | undefined,
568
- ) => void;
569
- validateThreadName?: Commands.TelegramBridgeCommandRegistrationDeps["validateThreadName"];
570
566
  onTransportChanged?: () => Promise<void> | void;
571
567
  getStatusLines: (
572
568
  options?: Status.TelegramBridgeStatusLineOptions,
@@ -615,8 +611,6 @@ export function registerTelegramCommandsAndTools({
615
611
  stopPolling,
616
612
  recoverPollingStart,
617
613
  getDisconnectThreadName,
618
- setRequestedThreadNameForPollingStart,
619
- validateThreadName,
620
614
  onTransportChanged,
621
615
  getStatusLines,
622
616
  buttonActionStore,
@@ -785,20 +779,16 @@ export function registerTelegramCommandsAndTools({
785
779
  hasBotToken: configStore.hasBotToken,
786
780
  getBotTokenDiagnostic: configStore.getBotTokenDiagnostic,
787
781
  startPolling: async (ctx, options) => {
788
- setRequestedThreadNameForPollingStart?.(options?.requestedThreadName);
789
782
  try {
790
783
  return await lockedPollingRuntime.start(ctx, options);
791
784
  } catch (error) {
792
785
  recordRuntimeEvent("recovery", error, { phase: "polling-start" });
793
786
  throw error;
794
- } finally {
795
- setRequestedThreadNameForPollingStart?.(undefined);
796
787
  }
797
788
  },
798
789
  stopPolling: stopPolling ?? lockedPollingRuntime.stop,
799
790
  recoverPollingStart,
800
791
  getDisconnectThreadName,
801
- validateThreadName,
802
792
  queueAgentConnectionContext,
803
793
  updateStatus,
804
794
  getProfileNames: () =>
package/lib/commands.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Owns Telegram slash-command normalization, bot command metadata, pi-side command registration, and command-initiated session replacement orchestration behind runtime ports
5
5
  */
6
6
 
7
+ import { randomUUID } from "node:crypto";
8
+
7
9
  import {
8
10
  pairTelegramUserIfNeeded,
9
11
  type TelegramConfigStore,
@@ -366,7 +368,6 @@ export function createTelegramBotCommandRegistrar(
366
368
  export interface TelegramBridgeCommandStartPollingOptions {
367
369
  force?: boolean;
368
370
  forceFreshLeaderThread?: boolean;
369
- requestedThreadName?: string;
370
371
  }
371
372
 
372
373
  export interface TelegramBridgeCommandStartPollingResult {
@@ -407,7 +408,6 @@ export interface TelegramBridgeCommandRegistrationDeps {
407
408
  ctx: ExtensionCommandContext,
408
409
  profileName: string,
409
410
  ) => Promise<boolean>;
410
- validateThreadName?: (threadName: string) => string | undefined;
411
411
  }
412
412
 
413
413
  export type TelegramThreadDisplayNameRenamePort = (
@@ -466,17 +466,6 @@ function parseTelegramProfileArg(args: string): string | undefined {
466
466
  return word === TELEGRAM_DEFAULT_PROFILE_NAME ? undefined : word;
467
467
  }
468
468
 
469
- export function parseTelegramRequestedThreadName(
470
- args: string,
471
- ): string | undefined {
472
- const token = args
473
- .trim()
474
- .split(/\s+/)
475
- .find((word) => /^as=/i.test(word));
476
- const value = token?.slice(3).trim();
477
- return value || undefined;
478
- }
479
-
480
469
  function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
481
470
  return ctx.ui.theme.fg("accent", "pi-telegram");
482
471
  }
@@ -498,7 +487,7 @@ export function registerTelegramBridgeCommands(
498
487
  deps: TelegramBridgeCommandRegistrationDeps,
499
488
  ): void {
500
489
  pi.registerCommand("telegram-setup", {
501
- description: "Configure Telegram bot token. Use /telegram-setup <name> for named profiles.",
490
+ description: "<profile> — Configure Telegram bot token",
502
491
  handler: async (args, ctx) => {
503
492
  await deps.promptForConfig(ctx, parseTelegramProfileArg(args));
504
493
  },
@@ -513,28 +502,17 @@ export function registerTelegramBridgeCommands(
513
502
  },
514
503
  });
515
504
  pi.registerCommand("telegram-connect", {
516
- description:
517
- "Start the Telegram bridge. Use /telegram-connect <profile> and optional as=Name for a fresh Workspace Thread.",
505
+ description: "<profile> — Start Telegram bridge",
518
506
  handler: async (args, ctx) => {
519
- const profileName = parseTelegramProfileArg(args);
520
- const requestedNameTokens = args
521
- .trim()
522
- .split(/\s+/)
523
- .filter((word) => /^as=/i.test(word));
524
- const requestedThreadName = parseTelegramRequestedThreadName(args);
525
- const requestedNameError =
526
- requestedNameTokens.length > 1
527
- ? "Specify at most one as=Name Workspace Thread name."
528
- : requestedNameTokens.length === 1 && !requestedThreadName
529
- ? "Usage: /telegram-connect [profile] as=Flightprice"
530
- : requestedThreadName
531
- ? deps.validateThreadName?.(requestedThreadName)
532
- : undefined;
533
- if (requestedNameError) {
534
- ctx.ui.notify(requestedNameError, "warning");
507
+ if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
508
+ ctx.ui.notify(
509
+ "Thread names are configured from Telegram, not from Pi commands.",
510
+ "warning",
511
+ );
535
512
  deps.updateStatus(ctx);
536
513
  return;
537
514
  }
515
+ const profileName = parseTelegramProfileArg(args);
538
516
  if (profileName && deps.activateProfileConfig) {
539
517
  const ok = await deps.activateProfileConfig(ctx, profileName);
540
518
  if (!ok) {
@@ -597,7 +575,6 @@ export function registerTelegramBridgeCommands(
597
575
  };
598
576
  let result = await startWithRecovery({
599
577
  forceFreshLeaderThread: true,
600
- ...(requestedThreadName ? { requestedThreadName } : {}),
601
578
  });
602
579
  if (result && !result.ok && result.canTakeover) {
603
580
  const confirmed = await ctx.ui.confirm(
@@ -612,7 +589,6 @@ export function registerTelegramBridgeCommands(
612
589
  result = await startWithRecovery({
613
590
  force: true,
614
591
  forceFreshLeaderThread: true,
615
- ...(requestedThreadName ? { requestedThreadName } : {}),
616
592
  });
617
593
  }
618
594
  if (result?.message) {
@@ -625,8 +601,7 @@ export function registerTelegramBridgeCommands(
625
601
  },
626
602
  });
627
603
  pi.registerCommand("telegram-disconnect", {
628
- description:
629
- "Stop Telegram; in Threaded Mode, delete this instance's current thread",
604
+ description: "Stop Telegram and delete current thread in Threaded Mode",
630
605
  handler: async (_args, ctx) => {
631
606
  const threadName = deps.getDisconnectThreadName?.();
632
607
  if (threadName) {
@@ -2335,9 +2310,11 @@ async function handleTelegramCommandRuntime<
2335
2310
  );
2336
2311
  }
2337
2312
 
2338
- export const TELEGRAM_SESSION_ACTION_COMMAND_NAME = "telegram-session-action";
2339
- export const TELEGRAM_SESSION_ACTION_COMMAND_DESCRIPTION =
2340
- "(internal) replace the current Pi session after Telegram settlement";
2313
+ export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
2314
+ export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION =
2315
+ "Internal Telegram command cannot be run manually";
2316
+ export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE =
2317
+ "This internal Telegram command cannot be run manually.";
2341
2318
 
2342
2319
  export function delayTelegramSessionAction(delayMs: number): Promise<void> {
2343
2320
  return new Promise<void>((resolve) => setTimeout(resolve, delayMs));
@@ -2538,6 +2515,13 @@ export function createTelegramSessionActionAssembly(
2538
2515
  return { action, settlement };
2539
2516
  }
2540
2517
 
2518
+ type TelegramPendingInternalAction = {
2519
+ kind: "replace-session";
2520
+ token: string;
2521
+ updateId: number;
2522
+ target: { chatId: number; threadId?: number; messageId: number };
2523
+ };
2524
+
2541
2525
  export interface TelegramSessionActionRuntime {
2542
2526
  register: () => void;
2543
2527
  scheduleAfterUpdate: (
@@ -2557,8 +2541,7 @@ export function createTelegramSessionActionRuntime(
2557
2541
  threadId?: number;
2558
2542
  messageId: number;
2559
2543
  } | undefined;
2560
- let commandPending = false;
2561
- let commandUpdateId: number | undefined;
2544
+ let pendingAction: TelegramPendingInternalAction | undefined;
2562
2545
  let registered = false;
2563
2546
 
2564
2547
  const reportFailure = (error: unknown): void => {
@@ -2573,29 +2556,32 @@ export function createTelegramSessionActionRuntime(
2573
2556
  register() {
2574
2557
  if (registered) return;
2575
2558
  registered = true;
2576
- deps.registerCommand(TELEGRAM_SESSION_ACTION_COMMAND_NAME, {
2577
- description: TELEGRAM_SESSION_ACTION_COMMAND_DESCRIPTION,
2578
- handler: async (_args, ctx) => {
2579
- if (!commandPending) return;
2580
- commandPending = false;
2581
- const target = pendingTarget;
2582
- const updateId = commandUpdateId;
2583
- pendingTarget = undefined;
2584
- commandUpdateId = undefined;
2585
- if (!target || updateId === undefined) return;
2586
- try {
2587
- await deps.prepareReplacement?.(ctx, updateId, target);
2588
- const result = await ctx.newSession();
2589
- if (result.cancelled) await deps.notifyResult(target, "cancelled");
2590
- } catch (error) {
2591
- reportFailure(error);
2592
- await deps.notifyResult(target, "failure");
2559
+ deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
2560
+ description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
2561
+ handler: async (args, ctx) => {
2562
+ const action = pendingAction;
2563
+ if (!action || args.trim() !== action.token) {
2564
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
2565
+ return;
2566
+ }
2567
+ pendingAction = undefined;
2568
+ switch (action.kind) {
2569
+ case "replace-session":
2570
+ try {
2571
+ await deps.prepareReplacement?.(ctx, action.updateId, action.target);
2572
+ const result = await ctx.newSession();
2573
+ if (result.cancelled) await deps.notifyResult(action.target, "cancelled");
2574
+ } catch (error) {
2575
+ reportFailure(error);
2576
+ await deps.notifyResult(action.target, "failure");
2577
+ }
2578
+ return;
2593
2579
  }
2594
2580
  },
2595
2581
  });
2596
2582
  },
2597
2583
  scheduleAfterUpdate(updateId, target) {
2598
- if (pendingUpdateId !== undefined || commandPending) return false;
2584
+ if (pendingUpdateId !== undefined || pendingAction !== undefined) return false;
2599
2585
  pendingUpdateId = updateId;
2600
2586
  pendingTarget = { ...target };
2601
2587
  return true;
@@ -2603,23 +2589,24 @@ export function createTelegramSessionActionRuntime(
2603
2589
  onUpdateCompleted(updateId) {
2604
2590
  if (pendingUpdateId !== updateId) return;
2605
2591
  pendingUpdateId = undefined;
2606
- commandUpdateId = updateId;
2607
- commandPending = true;
2592
+ const target = pendingTarget;
2593
+ pendingTarget = undefined;
2594
+ if (!target) return;
2595
+ const token = randomUUID();
2596
+ pendingAction = { kind: "replace-session", token, updateId, target };
2608
2597
  void Promise.resolve()
2609
2598
  .then(() =>
2610
- deps.sendUserMessage(`/${TELEGRAM_SESSION_ACTION_COMMAND_NAME}`, {
2599
+ deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME} ${token}`, {
2611
2600
  expandPromptTemplates: true,
2612
2601
  }),
2613
2602
  )
2614
2603
  .catch((error) => {
2615
- commandPending = false;
2616
- commandUpdateId = undefined;
2617
- pendingTarget = undefined;
2604
+ pendingAction = undefined;
2618
2605
  reportFailure(error);
2619
2606
  });
2620
2607
  },
2621
2608
  hasPending() {
2622
- return pendingUpdateId !== undefined || commandPending;
2609
+ return pendingUpdateId !== undefined || pendingAction !== undefined;
2623
2610
  },
2624
2611
  };
2625
2612
  }
package/lib/extension.ts CHANGED
@@ -871,6 +871,7 @@ export default function (pi: Pi.ExtensionAPI) {
871
871
  storeModelMenuState: modelMenuRuntime.storeState,
872
872
  updateStatusMessage: menuActions.updateStatusMessage,
873
873
  updateStatus,
874
+ dismissGuestPlaceholder: guestPlaceholderRuntime.dismiss,
874
875
  });
875
876
  const threadDisplaySettingsRuntime =
876
877
  ThreadDisplay.createTelegramThreadDisplaySettingsRuntime({
@@ -1731,14 +1732,6 @@ export default function (pi: Pi.ExtensionAPI) {
1731
1732
  if (!record?.target.threadId) return undefined;
1732
1733
  return record.threadName ?? "current Telegram thread";
1733
1734
  },
1734
- setRequestedThreadNameForPollingStart:
1735
- telegramThreadCapabilityState.setRequestedThreadName,
1736
- validateThreadName(threadName) {
1737
- return Threads.getTelegramTopicThreadNameValidationError(
1738
- threadName,
1739
- undefined,
1740
- );
1741
- },
1742
1735
  onTransportChanged() {
1743
1736
  deliveryLifecycleRuntime.onSessionStart();
1744
1737
  activityVerbosityRuntime.reset();