@llblab/pi-kit 0.36.3 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.37.0: Bundled Package Updates (2026-10-25)
6
+
7
+ - `Exact pins`: `@llblab/pi-telegram` 0.57.11 → 0.58.1.
8
+ - `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
9
+
5
10
  ## 0.36.3: Bundled Package Updates (2026-10-24)
6
11
 
7
12
  - `Exact pins`: `@llblab/pi-telegram` 0.57.7 → 0.57.11.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.2` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
20
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.26.5` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
21
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.11` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
21
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.58.1` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
24
24
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -27,7 +27,7 @@ Versions are exact by design. An upstream release does not change an installed k
27
27
 
28
28
  Requires **Pi 1.0.0+** and **Node.js 22.19.0+**. State Flow requires its canonical checkpoint/tail storage format and does not convert unsupported stores in place. Preserve existing stores and consult the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.26.5/docs/usage.md#moving-a-store-and-supported-formats) before changing installations.
29
29
 
30
- From npm:
30
+ From NPM:
31
31
 
32
32
  ```bash
33
33
  pi install npm:@llblab/pi-kit
@@ -109,7 +109,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
109
109
  - In Threaded Mode, All is service/input-only. New output, previews, typing and routing UI require a concrete Thread; neither implicit fallback nor explicit aggregate delivery may write to All. Unavailable routing authority retains input without an All notice or silent settlement. Classic DM and explicit channel/business destinations remain separate; historical All output is not auto-cleaned.
110
110
  - Complete assistant/guest model answers use Telegram-native Rich Markdown. Harness-owned menus, status, diagnostics, thinking, and tool evidence remain explicit HTML/plain or their documented native surface. Before Telegram preview or final delivery, strip every assistant-authored HTML comment regardless of Markdown position while keeping action activation top-level-only; a comment-only result sends no text message. Preserve literal code outside comments and structurally safe chunking; never split invalid markup.
111
111
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for the captured generation's preceding preview work, never later drafts blocked by that publication, and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Reset/invalidation detaches predecessor dependencies; late settlements cannot mutate replacement state. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
112
- - 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. Compaction may stop only a typing loop it actually started; it must preserve a pre-existing agent-owned loop. Refresh only the assigned Thread on a conservative three-second cadence; do not mirror typing to aggregate `All` and multiply shared-chat flood pressure. Typing is best-effort presence only, never a Thread-existence or message-delivery probe: a failed exact-target action remains a structured diagnostic and must not replace a healthy connected/leader/follower status with `error`. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
112
+ - 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. Compaction may stop only a typing loop it actually started; it must preserve a pre-existing agent-owned loop. Refresh only the assigned Thread on a conservative three-second cadence; do not mirror typing to aggregate `All` and multiply shared-chat flood pressure. One Thread's pending action must not suppress another Thread's activity. Typing is best-effort presence only, never a Thread-existence or message-delivery probe: a failed exact-target action remains a structured diagnostic and must not replace a healthy connected/leader/follower status with `error`. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
113
113
  - 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.
114
114
  - Profiles hold only bot identity and the paired owner; Settings-menu preferences live in shared top-level config. Thread display (`threads.displayMode`) defaults to the Letters strategy, with Names, Directory Snake, and Directory Title as the other automatic choices; Names projects the generated dictionary name for the slot. Unsupported retained display keys resolve to Letters without rewriting persisted configuration. 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. After leader startup, automatic display contraction waits one follower-staleness window so election and follower re-registration cannot briefly remove and restore an acknowledged same-cwd suffix; stop/start generation cancels stale reconciliation. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable action labels use `emoji + space + text`; emoji-free action text is only a reasoned no-semantic-marker fallback. State/value controls follow the [button control hierarchy](./docs/ui-style.md#button-control-hierarchy), not the action-label grammar. 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.
115
115
 
@@ -134,7 +134,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
134
134
  - Command templates remain compact and shell-free. Use string leaves or ordered `template` arrays; shell operators are not an execution contract. Examples use portable executable placeholders, never machine-local paths.
135
135
  - `telegram_attach` is the canonical file path and `telegram_message` the direct Markdown text/buttons path. Both require current direct or registered-follower authority and must not replace the normal active-turn reply.
136
136
  - Inbound handlers transform text/media before queueing; outbound handlers precede programmatic/provider fallbacks. Public contracts and ordering live in `docs/inbound.md`, `docs/outbound.md`, and `docs/public-api.md`.
137
- - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. A registered follower cannot persist leader-owned state: its Workspace Thread intent is published and claimed by the leader over capability-gated exact-generation bus RPC after leader-side binding validation, and records its source runtime instance so only that follower lineage may re-key or claim it. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
137
+ - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the `/telegram-connect --internal new <token>` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; the internal branch never enters profile activation or connection/recovery, only a runtime-armed one-use typed action may execute, manual invocation reports that the action cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. A registered follower cannot persist leader-owned state: its Workspace Thread intent is published and claimed by the leader over capability-gated exact-generation bus RPC after leader-side binding validation, and records its source runtime instance so only that follower lineage may re-key or claim it. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
138
138
 
139
139
  ## 7. Engineering Conventions
140
140
 
@@ -145,7 +145,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
145
145
  - Treat every meaningful `lib/extension.ts` edit as a composition-pressure check, but keep one-off live adapter wiring there when extraction would only hide cross-domain state.
146
146
  - Follow [`docs/ui-style.md`](./docs/ui-style.md) for interface copy, emoji, buttons, menus, and dialogs. Update the registry before assigning a new UI emoji meaning. Standalone notices use one fully bold emoji-led sentence with a terminal period; menu or chooser headings use the same hierarchy with a terminal colon. Material names may add nested italic emphasis without breaking the outer bold span. Callback toasts are fleeting plain text with no emoji and no terminal sentence period (question/exclamation marks and ellipses remain), written in that final form at their call site; no delivery boundary rewrites punctuation, and navigation taps answer silently. In-chat notices and explicit raw API payloads keep their punctuation.
147
147
  - Markdown lists never contain blank lines between adjacent items; list items are not paragraphs. Use blank lines only between paragraphs or independently separated blocks. Markdown tables use compact source formatting with `---` separator cells and one surrounding space per cell. Preserve vendored references unchanged.
148
- - Treat Windows filesystem, named-pipe, lock, heartbeat, and atomic-rename reports as high-signal evidence; reduce them to regressions or explicit platform caveats.
148
+ - Treat Windows filesystem, named-pipe, lock, heartbeat, and atomic-rename reports as high-signal evidence; reduce them to regressions or explicit platform caveats. Process fixtures must distinguish bounded startup from post-readiness natural exit: arm the exit guard only after the declared checkpoint, retain a separate total cap, and ensure extra startup allowance cannot hide retained post-readiness handles.
149
149
  - Route significant runtime failures through the redacted recent-event recorder. Keep the compact TUI status at generic `error`; details belong in diagnostics.
150
150
 
151
151
  ## 8. Work Protocol
@@ -2,10 +2,10 @@
2
2
 
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
- ## 0.57.11 publication checkpoint
5
+ ## 0.58.1 publication checkpoint
6
6
 
7
- - [ ] Validate and publish only the pre-publication follower reply-target fix through the protected dev-to-main release path. Require fresh Ubuntu, Windows and both macOS shard checks before merge, exact candidate/merged-tree equality, one immutable version tag and the repository-owned release workflow.
8
- - [ ] Confirm the published GitHub Release and public npm version, tag/gitHead identity, release notes and packed runtime/skills before closing this checkpoint. Keep post-refusal recovery and disposable-tab live acceptance open; do not reload the running session.
7
+ - [ ] Publish the independent Thread chat-action fix through the protected dev-to-main path, requiring fresh Ubuntu, Windows and both macOS shard checks at the exact candidate head and candidate/merged-tree equality. Keep the immutable Connect-only minor unchanged and verify its publication before issuing the hotfix tag.
8
+ - [ ] Verify the hotfix GitHub Release and public npm latest, exact tag/gitHead identity, canonical notes, packed runtime/skills and provenance before closing this checkpoint. No live session reload or Telegram probes; controlled transport tests do not prove live client presence.
9
9
 
10
10
  ## Disposable Thread live acceptance
11
11
 
@@ -38,10 +38,10 @@ A target-scoped reply refusal cannot prove that a command made no prior effects.
38
38
  - [ ] Route an input that arrives in a tab bound to nothing through a fresh temporary routing tab (the All flow) instead of publishing the chooser in the orphan, deleting the orphan with it once nothing unspent remains. Needs its own durable-metadata design.
39
39
  - [ ] Live acceptance on disposable tabs only: Cancel on an orphan tab deletes it; a second unspent input keeps it until the last Cancel; a plain `std::map` option button is delivered as an ordinary prompt.
40
40
 
41
- ## macOS process-exit test flake
41
+ ## Passive-child cold-import follow-up
42
42
 
43
- The 0.57.8 tag validation failed `Child process sharing the agent dir does not poll while parent owns Telegram lock` even when that file ran alone: `Child process did not exit. stdout= stderr=` at its existing child deadline. The other seven process tests and the identical pre-merge tree passed. That historical failure has no child-stage trace, so cold import, startup and a retained runtime handle remain competing explanations. Bounded diagnostic sampling is exhausted; passing traces showed imports up to 4.37 seconds against the five-second non-Windows child budget and natural exit within 8–14 ms after the event-loop checkpoint. These traces do not identify the original cause.
44
- - [ ] Retain bounded child-stage/resource diagnostics for the next genuinely failing passive-child trace; the original timeout is still unexplained. Do not spend more unchanged-head diagnostic samples. The parent records explicit shutdown separately; do not add a shutdown call to the passive child to make it pass. Correct a fixture/runtime cause only when the failing stage evidence supports it, and require fresh platform CI before resuming release recovery. Do not widen deadlines or describe the runner as the proven cause without measurement.
43
+ The 0.57.8 tag validation failed `Child process sharing the agent dir does not poll while parent owns Telegram lock` even when that file ran alone: `Child process did not exit. stdout= stderr=` at its existing child deadline. The other seven process tests and the identical pre-merge tree passed. That historical failure has no child-stage trace, so cold import, startup and a retained runtime handle remain competing explanations. Bounded diagnostic sampling is exhausted; passing traces showed imports up to 4.37 seconds against the five-second non-Windows child budget and natural exit within 8–14 ms after the event-loop checkpoint. These traces do not identify the original cause. A later main-validation recurrence stopped after `eval` and `import-start`, with parent-clock `elapsedMs=5009` and no import-complete checkpoint. That trace locates this recurrence before import completion, not in natural exit or a retained post-checkpoint handle; the slow dependency/startup cause remains unknown. The independent tag-owned Release validation passed the same committed tree, which does not erase the failed main check. The initial hotfix candidate then showed the same pre-import-completion state on Ubuntu at `elapsedMs=5026`; the recurrence is not macOS-only. Slow dependency/startup cost remains unexplained, separate from the fixture's corrected startup/exit observation contract.
44
+ - [ ] Validate the corrected phase-owned passive-child fixture in fresh complete platform CI before publication: bounded total startup, a strict post-readiness exit guard, unchanged no-poll/natural-exit assertions and no forced shutdown or retry. Controlled delayed-start/retained-work regressions and local native passes are not platform proof. For any genuine recurrence, preserve deadline and stage evidence before correcting runtime imports or budgets. Do not spend unchanged-head diagnostic samples or claim runner contention or the historical cause is proven.
45
45
 
46
46
  ## Windows Workspace-gate fixture timeout
47
47
 
@@ -4,6 +4,15 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.58.1: Independent Thread Typing
8
+
9
+ - `Typing`: A pending activity request in one Thread no longer silently suppresses another Thread's typing. Exact chat/Thread/action calls still coalesce with their own minimum interval, while real Telegram 429 cooldowns remain shared across the chat, including late failures after a faster peer completes. Authority checks, one-attempt typing and bounded gate storage remain intact.
10
+ - `Process validation`: Passive-child checks distinguish cold startup from natural exit: a 15-second total cap retains the existing Windows bound, while a separate five-second guard starts after the event-loop checkpoint on every platform. Regressions reject both a missing checkpoint and retained post-checkpoint work; no polling assertions, natural-exit requirement or job budgets are weakened.
11
+
12
+ ## 0.58.0: Unified Connect Gateway
13
+
14
+ - `Pi commands`: Removes the standalone `/telegram-internal` gateway. Runtime-armed session replacement now dispatches `/telegram-connect --internal new <token>` after exact durable update completion. Internal calls bypass profile activation and connection/recovery; malformed, unarmed and replayed calls fail closed. `internal` remains a valid profile name.
15
+
7
16
  ## 0.57.11: Follower Reply Target
8
17
 
9
18
  - `Follower command replies`: Status/model/settings and text replies from an admitted follower command select its acknowledged Thread before publication, not the old input Thread. A changed Thread drops the reply anchor; delivery, menu state and ownership use one destination. The journaled original and explicit selected-menu grants stay intact; no refused-send recovery, handler replay or proxy widening is added.
@@ -201,6 +201,7 @@ export declare function createTelegramLiveTargetWorkObserver<TContext>(deps: {
201
201
  }, ctx: TContext) => TelegramBusLiveRebindWorkState;
202
202
  interface TelegramCommandsAndToolsBindingDeps {
203
203
  pi: Pi.ExtensionAPI;
204
+ handleInternalAction: Commands.TelegramSessionActionRuntime["handleInternalAction"];
204
205
  agentDir: string;
205
206
  configStore: Config.TelegramConfigStore;
206
207
  persistConfig: (config?: Config.TelegramConfig) => Promise<void>;
@@ -241,7 +242,7 @@ interface TelegramCommandsAndToolsBindingDeps {
241
242
  connectionIntent: NonNullable<Commands.TelegramBridgeCommandRegistrationDeps["connectionIntent"]>;
242
243
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
243
244
  }
244
- export declare function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }: TelegramCommandsAndToolsBindingDeps): void;
245
+ export declare function registerTelegramCommandsAndTools({ pi, handleInternalAction, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }: TelegramCommandsAndToolsBindingDeps): void;
245
246
  interface TelegramLifecycleBindingDeps {
246
247
  pi: Pi.ExtensionAPI;
247
248
  publicationRuntime: TelegramBridgePublicationRuntime;
@@ -497,7 +497,7 @@ export function createTelegramLiveTargetWorkObserver(deps) {
497
497
  };
498
498
  };
499
499
  }
500
- export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }) {
500
+ export function registerTelegramCommandsAndTools({ pi, handleInternalAction, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }) {
501
501
  GenerativeApps.registerTelegramBindTool(pi, {
502
502
  agentDir,
503
503
  getActiveProfileName: configStore.getActiveProfileName,
@@ -551,6 +551,7 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
551
551
  }, { deliverAs: "nextTurn" });
552
552
  };
553
553
  Commands.registerTelegramBridgeCommands(pi, {
554
+ handleInternalAction,
554
555
  promptForConfig: async (ctx, profileName) => {
555
556
  const nextProfileName = profileName ?? undefined;
556
557
  if (profileName && !Config.isValidTelegramProfileName(profileName)) {
@@ -166,6 +166,7 @@ export interface TelegramBridgeCommandStartPollingResult {
166
166
  owner?: string;
167
167
  }
168
168
  export interface TelegramBridgeCommandRegistrationDeps {
169
+ handleInternalAction?: TelegramSessionActionRuntime["handleInternalAction"];
169
170
  promptForConfig: (ctx: ExtensionCommandContext, profileName?: string) => Promise<void>;
170
171
  getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
171
172
  reloadConfig: () => Promise<void>;
@@ -661,11 +662,10 @@ export declare function createTelegramCommandHandler<TMessage extends TelegramCo
661
662
  prepareSelectedMenuCommand(command: ParsedTelegramCommand, messages: readonly TMessage[], ctx: TContext, admission: TelegramSelectedCommandAdmission): (() => Promise<boolean>) | undefined;
662
663
  };
663
664
  export declare function createTelegramCommandOrPromptDispatcher<TMessage, TContext>(deps: TelegramCommandOrPromptDispatcherDeps<TMessage, TContext>): (messages: TMessage[], ctx: TContext) => Promise<void>;
664
- export declare const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
665
- export declare const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION = "Internal Telegram command cannot be run manually";
666
- export declare const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE = "This internal Telegram command cannot be run manually.";
665
+ export declare const TELEGRAM_CONNECT_COMMAND_NAME = "telegram-connect";
666
+ export declare const TELEGRAM_INTERNAL_COMMAND_FLAG = "--internal";
667
+ export declare const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE = "This internal Telegram action cannot be run manually.";
667
668
  export interface TelegramSessionActionRuntimeDeps {
668
- registerCommand: Pi.ExtensionAPI["registerCommand"];
669
669
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
670
670
  notifyResult: (target: {
671
671
  chatId: number;
@@ -697,7 +697,6 @@ export interface TelegramSessionReplacementSettlementDeps {
697
697
  }
698
698
  export declare function settleTelegramSessionReplacement(deps: TelegramSessionReplacementSettlementDeps): Promise<"none" | "settled" | "expired" | "failed" | "stale">;
699
699
  export interface TelegramSessionActionAssemblyDeps {
700
- registerCommand: Pi.ExtensionAPI["registerCommand"];
701
700
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
702
701
  store: {
703
702
  load: () => Promise<void>;
@@ -753,7 +752,7 @@ export declare function createTelegramSessionActionAssembly(deps: TelegramSessio
753
752
  };
754
753
  };
755
754
  export interface TelegramSessionActionRuntime {
756
- register: () => void;
755
+ handleInternalAction: (args: string, ctx: Pi.ExtensionCommandContext) => Promise<void>;
757
756
  scheduleAfterUpdate: (updateId: number, target: {
758
757
  chatId: number;
759
758
  threadId?: number;
@@ -405,6 +405,7 @@ function formatTelegramTakeoverPrompt(ctx, owner) {
405
405
  return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
406
406
  }
407
407
  export function registerTelegramBridgeCommands(pi, deps) {
408
+ const handleInternalAction = deps.handleInternalAction;
408
409
  pi.registerCommand("telegram-setup", {
409
410
  description: "<profile> — Configure Telegram bot token",
410
411
  handler: async (args, ctx) => {
@@ -418,9 +419,19 @@ export function registerTelegramBridgeCommands(pi, deps) {
418
419
  ctx.ui.notify(deps.getStatusLines({ verbose }).join("\n"), "info");
419
420
  },
420
421
  });
421
- pi.registerCommand("telegram-connect", {
422
+ pi.registerCommand(TELEGRAM_CONNECT_COMMAND_NAME, {
422
423
  description: "<profile> — Start Telegram bridge",
423
424
  handler: async (args, ctx) => {
425
+ const words = args.trim().split(/\s+/);
426
+ // Service dispatch never inherits profile activation or explicit-connect recovery authority.
427
+ if (words.some(word => word.toLowerCase().startsWith(TELEGRAM_INTERNAL_COMMAND_FLAG))) {
428
+ if (words[0] !== TELEGRAM_INTERNAL_COMMAND_FLAG || !handleInternalAction) {
429
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
430
+ return;
431
+ }
432
+ await handleInternalAction(words.slice(1).join(" "), ctx);
433
+ return;
434
+ }
424
435
  const sessionGeneration = deps.getSessionGeneration?.();
425
436
  let intentId;
426
437
  // Pi context getters throw after replacement; check plain intent/generation first.
@@ -2415,9 +2426,9 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
2415
2426
  },
2416
2427
  }, commandArgs);
2417
2428
  }
2418
- export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
2419
- export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION = "Internal Telegram command cannot be run manually";
2420
- export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE = "This internal Telegram command cannot be run manually.";
2429
+ export const TELEGRAM_CONNECT_COMMAND_NAME = "telegram-connect";
2430
+ export const TELEGRAM_INTERNAL_COMMAND_FLAG = "--internal";
2431
+ export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE = "This internal Telegram action cannot be run manually.";
2421
2432
  function delayTelegramSessionAction(delayMs) {
2422
2433
  return new Promise((resolve) => setTimeout(resolve, delayMs));
2423
2434
  }
@@ -2487,7 +2498,6 @@ export function createTelegramSessionActionAssembly(deps) {
2487
2498
  report(new Error("Telegram new-session result delivery failed."));
2488
2499
  };
2489
2500
  const action = createTelegramSessionActionRuntime({
2490
- registerCommand: deps.registerCommand,
2491
2501
  sendUserMessage: deps.sendUserMessage,
2492
2502
  notifyResult(target, result) {
2493
2503
  return sendTerminalResult(target, result);
@@ -2585,7 +2595,6 @@ export function createTelegramSessionActionRuntime(deps) {
2585
2595
  let pendingUpdateId;
2586
2596
  let pendingTarget;
2587
2597
  let pendingAction;
2588
- let registered = false;
2589
2598
  const reportFailure = (error) => {
2590
2599
  try {
2591
2600
  deps.recordRuntimeEvent?.("new-session", error);
@@ -2595,35 +2604,29 @@ export function createTelegramSessionActionRuntime(deps) {
2595
2604
  }
2596
2605
  };
2597
2606
  return {
2598
- register() {
2599
- if (registered)
2607
+ async handleInternalAction(args, ctx) {
2608
+ const action = pendingAction;
2609
+ const [operation, token, ...extra] = args.trim().split(/\s+/);
2610
+ if (!action || operation !== "new" ||
2611
+ token !== action.token || extra.length !== 0) {
2612
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
2600
2613
  return;
2601
- registered = true;
2602
- deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
2603
- description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
2604
- handler: async (args, ctx) => {
2605
- const action = pendingAction;
2606
- if (!action || args.trim() !== action.token) {
2607
- ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
2608
- return;
2614
+ }
2615
+ pendingAction = undefined;
2616
+ switch (action.kind) {
2617
+ case "replace-session":
2618
+ try {
2619
+ await deps.prepareReplacement?.(ctx, action.updateId, action.target);
2620
+ const result = await ctx.newSession();
2621
+ if (result.cancelled)
2622
+ await deps.notifyResult(action.target, "cancelled");
2609
2623
  }
2610
- pendingAction = undefined;
2611
- switch (action.kind) {
2612
- case "replace-session":
2613
- try {
2614
- await deps.prepareReplacement?.(ctx, action.updateId, action.target);
2615
- const result = await ctx.newSession();
2616
- if (result.cancelled)
2617
- await deps.notifyResult(action.target, "cancelled");
2618
- }
2619
- catch (error) {
2620
- reportFailure(error);
2621
- await deps.notifyResult(action.target, "failure");
2622
- }
2623
- return;
2624
+ catch (error) {
2625
+ reportFailure(error);
2626
+ await deps.notifyResult(action.target, "failure");
2624
2627
  }
2625
- },
2626
- });
2628
+ return;
2629
+ }
2627
2630
  },
2628
2631
  scheduleAfterUpdate(updateId, target) {
2629
2632
  if (pendingUpdateId !== undefined || pendingAction !== undefined)
@@ -2643,9 +2646,7 @@ export function createTelegramSessionActionRuntime(deps) {
2643
2646
  const token = randomUUID();
2644
2647
  pendingAction = { kind: "replace-session", token, updateId, target };
2645
2648
  void Promise.resolve()
2646
- .then(() => deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME} ${token}`, {
2647
- expandPromptTemplates: true,
2648
- }))
2649
+ .then(() => deps.sendUserMessage(`/${TELEGRAM_CONNECT_COMMAND_NAME} ${TELEGRAM_INTERNAL_COMMAND_FLAG} new ${token}`, { expandPromptTemplates: true }))
2649
2650
  .catch((error) => {
2650
2651
  pendingAction = undefined;
2651
2652
  reportFailure(error);
@@ -80,7 +80,7 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
80
80
  export default function (pi) {
81
81
  Skills.registerTelegramSkillDiscovery(pi);
82
82
  const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
83
- const { getActiveTools, getCommands, getThinkingLevel, sendUserMessage, registerCommand, setActiveTools, setModel, setThinkingLevel, } = piRuntime;
83
+ const { getActiveTools, getCommands, getThinkingLevel, sendUserMessage, setActiveTools, setModel, setThinkingLevel, } = piRuntime;
84
84
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
85
85
  const runtimeDiagnostics = Logging.createTelegramRuntimeDiagnosticsRuntime({
86
86
  sharedFile: true,
@@ -242,7 +242,6 @@ export default function (pi) {
242
242
  captureAuthority: captureTelegramStateAuthority,
243
243
  });
244
244
  const sessionActionAssembly = Commands.createTelegramSessionActionAssembly({
245
- registerCommand,
246
245
  sendUserMessage,
247
246
  store: threadStore,
248
247
  getProfileName: configStore.getActiveProfileName,
@@ -2293,9 +2292,9 @@ export default function (pi) {
2293
2292
  }
2294
2293
  },
2295
2294
  }.reset);
2296
- sessionActionsRuntime.register();
2297
2295
  Bindings.registerTelegramCommandsAndTools({
2298
2296
  pi,
2297
+ handleInternalAction: sessionActionsRuntime.handleInternalAction,
2299
2298
  agentDir: Paths.resolveAgentDir(),
2300
2299
  configStore,
2301
2300
  persistConfig: persistTelegramConfigWithSync,
@@ -1039,6 +1039,7 @@ export function createTelegramBridgeApiRuntime(deps) {
1039
1039
  const chatActionMinIntervalMs = Math.max(0, deps.chatActionMinIntervalMs ?? 2_000);
1040
1040
  const chatActionMaxGates = Math.max(1, deps.chatActionMaxGates ?? 256);
1041
1041
  const chatActionGates = new Map();
1042
+ // Pin shared cooldowns until every concurrent Thread settles; this state never owns their sends.
1042
1043
  const chatActionChatGates = new Map();
1043
1044
  const getChatActionKeys = (method, body) => {
1044
1045
  if (method !== "sendChatAction")
@@ -1069,7 +1070,7 @@ export function createTelegramBridgeApiRuntime(deps) {
1069
1070
  }
1070
1071
  }
1071
1072
  for (const [key, candidate] of chatActionChatGates) {
1072
- if (!candidate.inFlight && nowMs >= candidate.notBeforeMs) {
1073
+ if (candidate.pendingRequests === 0 && nowMs >= candidate.notBeforeMs) {
1073
1074
  chatActionChatGates.delete(key);
1074
1075
  }
1075
1076
  }
@@ -1085,13 +1086,11 @@ export function createTelegramBridgeApiRuntime(deps) {
1085
1086
  if (chatActionChatGates.size >= chatActionMaxGates) {
1086
1087
  return true;
1087
1088
  }
1088
- chatGate = { notBeforeMs: 0 };
1089
+ chatGate = { pendingRequests: 0, notBeforeMs: 0 };
1089
1090
  chatActionChatGates.set(chatActionKeys.chat, chatGate);
1090
1091
  }
1091
1092
  if (gate.inFlight)
1092
1093
  return (await gate.inFlight);
1093
- if (chatGate.inFlight)
1094
- return true;
1095
1094
  if (nowMs < gate.notBeforeMs || nowMs < chatGate.notBeforeMs) {
1096
1095
  return true;
1097
1096
  }
@@ -1127,11 +1126,10 @@ export function createTelegramBridgeApiRuntime(deps) {
1127
1126
  .finally(() => {
1128
1127
  if (gate.inFlight === request)
1129
1128
  gate.inFlight = undefined;
1130
- if (chatGate.inFlight === request)
1131
- chatGate.inFlight = undefined;
1129
+ chatGate.pendingRequests--;
1132
1130
  });
1133
1131
  gate.inFlight = request;
1134
- chatGate.inFlight = request;
1132
+ chatGate.pendingRequests++;
1135
1133
  return request;
1136
1134
  }
1137
1135
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.11",
3
+ "version": "0.58.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -42,7 +42,7 @@ Keep this boundary explicit:
42
42
 
43
43
  ### Instance, Session, And Context Cost
44
44
 
45
- Live Telegram routing belongs to the active authenticated Pi instance, while a durable Workspace Thread belongs to the exact profile/CWD/session identity. Ordinary Telegram prompts enter the session currently bound to that target. Local `/resume` replaces session-scoped runtime state and selects the destination session's binding; it never inherits the source Thread merely because the process is unchanged. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches an internal registered Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })` so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
45
+ Live Telegram routing belongs to the active authenticated Pi instance, while a durable Workspace Thread belongs to the exact profile/CWD/session identity. Ordinary Telegram prompts enter the session currently bound to that target. Local `/resume` replaces session-scoped runtime state and selects the destination session's binding; it never inherits the source Thread merely because the process is unchanged. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches the runtime-armed `/telegram-connect --internal new <token>` branch through `pi.sendUserMessage(..., { expandPromptTemplates: true })`, without entering ordinary Connect profile or transport startup, so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
46
46
 
47
47
  `/telegram-connect` never launches a hidden or headless Pi process. A long-lived background Pi process can own Telegram only when something else explicitly launched that process and it satisfies the normal lock/runtime rules. Pi `print` and `json` modes stay passive and exit rather than becoming hidden polling owners.
48
48
 
@@ -685,7 +685,7 @@ Native typing during compaction follows connected-instance activity rather than
685
685
  - Thread-targeted typing is sent only to the concrete thread. Aggregate `All` mirroring is intentionally omitted because duplicating every keepalive multiplies shared-chat flood pressure. Compaction completion, failure, or timeout stops only a loop actually started by the compaction observer; a pre-existing agent-owned loop remains active. Ownership is sticky across a repeated compaction start, whose shared-loop start reports `false` for the observer's own running loop. Authority loss and shutdown still stop the keyed loop.
686
686
  - Pi `ui_prompt_start` pauses typing while an extension-owned local prompt waits for the operator; `ui_prompt_end` emits the matching Activity boundary and resumes typing whenever agent or compaction work remains unsettled.
687
687
 
688
- At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Streaming keepalives reuse a valid typing owner with the same session generation, transport and target rather than revoking its pending HTTP/IPC action. Fresh context wrappers may reuse that owner within a proven generation; without generation evidence, context identity must match. Explicit queue dispatch and Telegram prompt handoff still replace the owner. Queue dispatch carries the queued turn's exact target into typing before `agent_start`, rather than reducing it to a chat ID. In Threaded Mode, missing or invalid Thread targets are refused before starting a loop or invoking the API; mode changes also invalidate a captured Classic chat-only loop. A rejected start never replaces an existing valid Thread loop. The starter also requires the captured Pi context and session generation to remain current at start, queued send, tick and late failure boundaries, even if the transport epoch is unchanged; replacing a context object in place cannot revive old activity. Message activity hooks capture generation across awaited preview work, and agent-start bindings recheck it before post-await activity/typing, so an obsolete finally or continuation cannot acquire replacement ownership. The typing sender carries its captured loop/context/generation/transport assertion as process-local call authority: direct API issuance rechecks it after queued work, and the follower caller rechecks it after registration waits and immediately before socket issuance without putting callbacks in IPC data. Automatic typing uses one HTTP attempt with no transport-family fallback or delayed replay, including forwarded calls; a future keepalive is a fresh owner-checked attempt. An already issued HTTP/RPC cannot be retroactively unsent. Each loop refreshes its exact target every three seconds and keeps one action in flight. The leader API runtime coalesces identical chat/thread/action calls for two seconds, permits at most one concurrent chat action per chat, and shares a Telegram 429 `retry_after` fence across every Thread key in that chat; expired gates prune opportunistically and each gate family retains at most 256 active keys. Suppression never schedules a delayed retry. A failed typing action remains a structured diagnostic but cannot project `error` onto an otherwise healthy connected/leader/follower status. Assistant message start/update hooks still re-arm typing during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
688
+ At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Streaming keepalives reuse a valid typing owner with the same session generation, transport and target rather than revoking its pending HTTP/IPC action. Fresh context wrappers may reuse that owner within a proven generation; without generation evidence, context identity must match. Explicit queue dispatch and Telegram prompt handoff still replace the owner. Queue dispatch carries the queued turn's exact target into typing before `agent_start`, rather than reducing it to a chat ID. In Threaded Mode, missing or invalid Thread targets are refused before starting a loop or invoking the API; mode changes also invalidate a captured Classic chat-only loop. A rejected start never replaces an existing valid Thread loop. The starter also requires the captured Pi context and session generation to remain current at start, queued send, tick and late failure boundaries, even if the transport epoch is unchanged; replacing a context object in place cannot revive old activity. Message activity hooks capture generation across awaited preview work, and agent-start bindings recheck it before post-await activity/typing, so an obsolete finally or continuation cannot acquire replacement ownership. The typing sender carries its captured loop/context/generation/transport assertion as process-local call authority: direct API issuance rechecks it after queued work, and the follower caller rechecks it after registration waits and immediately before socket issuance without putting callbacks in IPC data. Automatic typing uses one HTTP attempt with no transport-family fallback or delayed replay, including forwarded calls; a future keepalive is a fresh owner-checked attempt. An already issued HTTP/RPC cannot be retroactively unsent. Each loop refreshes its exact target every three seconds and keeps one action in flight. The leader API runtime coalesces identical chat/thread/action calls for two seconds, admits different Threads independently, and shares a real Telegram 429 `retry_after` fence across every Thread key in that chat. A pending-request count retains that shared state until every concurrent action settles, so completion of a faster peer cannot orphan a late 429; expired gates prune opportunistically and each gate family retains at most 256 active keys. Suppression never schedules a delayed retry. A failed typing action remains a structured diagnostic but cannot project `error` onto an otherwise healthy connected/leader/follower status. Assistant message start/update hooks still re-arm typing during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
689
689
 
690
690
  ### Rendering And Delivery
691
691
 
@@ -485,7 +485,7 @@ Threaded Mode should make follower threads behave like normal Telegram instance
485
485
  | Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
486
486
  | Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
487
487
  | Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
488
- | Native activity status | `sendChatAction(typing)` refreshes Telegram's native `…typing` indicator only in the assigned Thread for every agent run, including local and autonomous work; aggregate `All` is not mirrored, terminal identity remains `connected`, and active work stays included in the green Queue count | Followers route the exact Thread action through leader transport while retaining their stable terminal `follower` identity and green activity count; the leader admits one concurrent action per chat and shares Telegram 429 cooldown across that chat's Thread keys | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
488
+ | Native activity status | `sendChatAction(typing)` refreshes Telegram's native `…typing` indicator only in the assigned Thread for every agent run, including local and autonomous work; aggregate `All` is not mirrored, terminal identity remains `connected`, and active work stays included in the green Queue count | Followers route the exact Thread action through leader transport while retaining their stable terminal `follower` identity and green activity count; the leader admits different Threads independently, coalesces exact chat/Thread/action calls and shares real Telegram 429 cooldown across that chat's Thread keys | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
489
489
  | Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
490
490
  | `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
491
491
  | Follower reconnect | Existing leader binding is reused only when still usable | Same-process `/new` or `/reload` suspends the old follower socket/context and automatically re-registers the new session to the exact prior target; explicit reconnect to a genuinely closed/stale Telegram tab still recreates a visible thread before success | A short-lived handoff carries the assigned target across session replacement, and the leader transfers that binding to the new runtime instance id by stable manual-follower identity; stale Bot API errors remain the proof for fresh provisioning | Session handoff/refresh, leader binding-transfer, persisted leader-reload reuse, and stale-target replacement regressions |
@@ -44,6 +44,8 @@ Stable commands inside Pi:
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 session replacement suspends transport without disconnect teardown. Local `/resume` carries connection intent rather than the source Thread; other supported replacements retain their existing continuity rules.
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
 
47
+ The `/telegram-connect --internal new <token>` form is a runtime-only dispatch, not a profile connection or a manual new-session shortcut. It requires the exact prepared one-use action and bypasses profile activation, connection intents and startup/recovery. Missing, forged, replayed, unsupported or malformed internal calls fail closed; invalid calls do not consume a valid pending action. The flag is reserved, but `internal` without the leading dashes remains a valid profile name. The standalone `/telegram-internal` command is no longer registered.
48
+
47
49
  ### Telegram commands
48
50
 
49
51
  - `/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.
@@ -51,7 +53,7 @@ Stable commands inside Pi:
51
53
  Stable commands inside the paired Telegram DM:
52
54
 
53
55
  - `/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 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. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
56
+ - `/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-connect --internal new <token>` 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. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
55
57
  - `/compact` — open confirmation and compact when idle.
56
58
  - `/next` — abort active work first when needed, attempt the interrupted prompt's abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. That dispatch notice owns the turn's one reply header; later answer messages do not reply to the same prompt again. Abort-notice failure is diagnostic and cannot block dispatch. A later `/abort` or `/stop` cancels both pending transition notices before taking ownership. The `/next` command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
59
  - `/continue` — enqueue a priority `continue` prompt.
@@ -869,6 +869,8 @@ export function createTelegramLiveTargetWorkObserver<TContext>(deps: {
869
869
 
870
870
  interface TelegramCommandsAndToolsBindingDeps {
871
871
  pi: Pi.ExtensionAPI;
872
+ handleInternalAction:
873
+ Commands.TelegramSessionActionRuntime["handleInternalAction"];
872
874
  agentDir: string;
873
875
  configStore: Config.TelegramConfigStore;
874
876
  persistConfig: (config?: Config.TelegramConfig) => Promise<void>;
@@ -926,6 +928,7 @@ interface TelegramCommandsAndToolsBindingDeps {
926
928
 
927
929
  export function registerTelegramCommandsAndTools({
928
930
  pi,
931
+ handleInternalAction,
929
932
  agentDir,
930
933
  configStore,
931
934
  persistConfig,
@@ -1023,6 +1026,7 @@ export function registerTelegramCommandsAndTools({
1023
1026
  );
1024
1027
  };
1025
1028
  Commands.registerTelegramBridgeCommands(pi, {
1029
+ handleInternalAction,
1026
1030
  promptForConfig: async (ctx, profileName) => {
1027
1031
  const nextProfileName = profileName ?? undefined;
1028
1032
  if (profileName && !Config.isValidTelegramProfileName(profileName)) {
@@ -625,6 +625,7 @@ export interface TelegramBridgeCommandStartPollingResult {
625
625
  }
626
626
 
627
627
  export interface TelegramBridgeCommandRegistrationDeps {
628
+ handleInternalAction?: TelegramSessionActionRuntime["handleInternalAction"];
628
629
  promptForConfig: (
629
630
  ctx: ExtensionCommandContext,
630
631
  profileName?: string,
@@ -790,6 +791,7 @@ export function registerTelegramBridgeCommands(
790
791
  pi: ExtensionAPI,
791
792
  deps: TelegramBridgeCommandRegistrationDeps,
792
793
  ): void {
794
+ const handleInternalAction = deps.handleInternalAction;
793
795
  pi.registerCommand("telegram-setup", {
794
796
  description: "<profile> — Configure Telegram bot token",
795
797
  handler: async (args, ctx) => {
@@ -805,9 +807,21 @@ export function registerTelegramBridgeCommands(
805
807
  ctx.ui.notify(deps.getStatusLines({ verbose }).join("\n"), "info");
806
808
  },
807
809
  });
808
- pi.registerCommand("telegram-connect", {
810
+ pi.registerCommand(TELEGRAM_CONNECT_COMMAND_NAME, {
809
811
  description: "<profile> — Start Telegram bridge",
810
812
  handler: async (args, ctx) => {
813
+ const words = args.trim().split(/\s+/);
814
+ // Service dispatch never inherits profile activation or explicit-connect recovery authority.
815
+ if (words.some(word =>
816
+ word.toLowerCase().startsWith(TELEGRAM_INTERNAL_COMMAND_FLAG),
817
+ )) {
818
+ if (words[0] !== TELEGRAM_INTERNAL_COMMAND_FLAG || !handleInternalAction) {
819
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
820
+ return;
821
+ }
822
+ await handleInternalAction(words.slice(1).join(" "), ctx);
823
+ return;
824
+ }
811
825
  const sessionGeneration = deps.getSessionGeneration?.();
812
826
  let intentId: string | undefined;
813
827
  // Pi context getters throw after replacement; check plain intent/generation first.
@@ -4183,18 +4197,16 @@ async function handleTelegramCommandRuntime<
4183
4197
  );
4184
4198
  }
4185
4199
 
4186
- export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
4187
- export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION =
4188
- "Internal Telegram command cannot be run manually";
4200
+ export const TELEGRAM_CONNECT_COMMAND_NAME = "telegram-connect";
4201
+ export const TELEGRAM_INTERNAL_COMMAND_FLAG = "--internal";
4189
4202
  export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE =
4190
- "This internal Telegram command cannot be run manually.";
4203
+ "This internal Telegram action cannot be run manually.";
4191
4204
 
4192
4205
  function delayTelegramSessionAction(delayMs: number): Promise<void> {
4193
4206
  return new Promise<void>((resolve) => setTimeout(resolve, delayMs));
4194
4207
  }
4195
4208
 
4196
4209
  export interface TelegramSessionActionRuntimeDeps {
4197
- registerCommand: Pi.ExtensionAPI["registerCommand"];
4198
4210
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
4199
4211
  notifyResult: (
4200
4212
  target: { chatId: number; threadId?: number; messageId: number },
@@ -4277,7 +4289,6 @@ function createTelegramSessionReplacementSettlementRuntime<TContext>(deps: {
4277
4289
  }
4278
4290
 
4279
4291
  export interface TelegramSessionActionAssemblyDeps {
4280
- registerCommand: Pi.ExtensionAPI["registerCommand"];
4281
4292
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
4282
4293
  store: {
4283
4294
  load: () => Promise<void>;
@@ -4359,7 +4370,6 @@ export function createTelegramSessionActionAssembly(
4359
4370
  report(new Error("Telegram new-session result delivery failed."));
4360
4371
  };
4361
4372
  const action = createTelegramSessionActionRuntime({
4362
- registerCommand: deps.registerCommand,
4363
4373
  sendUserMessage: deps.sendUserMessage,
4364
4374
  notifyResult(target, result) {
4365
4375
  return sendTerminalResult(target, result);
@@ -4491,7 +4501,10 @@ type TelegramPendingInternalAction = {
4491
4501
  };
4492
4502
 
4493
4503
  export interface TelegramSessionActionRuntime {
4494
- register: () => void;
4504
+ handleInternalAction: (
4505
+ args: string,
4506
+ ctx: Pi.ExtensionCommandContext,
4507
+ ) => Promise<void>;
4495
4508
  scheduleAfterUpdate: (
4496
4509
  updateId: number,
4497
4510
  target: { chatId: number; threadId?: number; messageId: number },
@@ -4512,7 +4525,6 @@ export function createTelegramSessionActionRuntime(
4512
4525
  }
4513
4526
  | undefined;
4514
4527
  let pendingAction: TelegramPendingInternalAction | undefined;
4515
- let registered = false;
4516
4528
 
4517
4529
  const reportFailure = (error: unknown): void => {
4518
4530
  try {
@@ -4523,37 +4535,34 @@ export function createTelegramSessionActionRuntime(
4523
4535
  };
4524
4536
 
4525
4537
  return {
4526
- register() {
4527
- if (registered) return;
4528
- registered = true;
4529
- deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
4530
- description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
4531
- handler: async (args, ctx) => {
4532
- const action = pendingAction;
4533
- if (!action || args.trim() !== action.token) {
4534
- ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
4535
- return;
4536
- }
4537
- pendingAction = undefined;
4538
- switch (action.kind) {
4539
- case "replace-session":
4540
- try {
4541
- await deps.prepareReplacement?.(
4542
- ctx,
4543
- action.updateId,
4544
- action.target,
4545
- );
4546
- const result = await ctx.newSession();
4547
- if (result.cancelled)
4548
- await deps.notifyResult(action.target, "cancelled");
4549
- } catch (error) {
4550
- reportFailure(error);
4551
- await deps.notifyResult(action.target, "failure");
4552
- }
4553
- return;
4538
+ async handleInternalAction(args, ctx) {
4539
+ const action = pendingAction;
4540
+ const [operation, token, ...extra] = args.trim().split(/\s+/);
4541
+ if (
4542
+ !action || operation !== "new" ||
4543
+ token !== action.token || extra.length !== 0
4544
+ ) {
4545
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
4546
+ return;
4547
+ }
4548
+ pendingAction = undefined;
4549
+ switch (action.kind) {
4550
+ case "replace-session":
4551
+ try {
4552
+ await deps.prepareReplacement?.(
4553
+ ctx,
4554
+ action.updateId,
4555
+ action.target,
4556
+ );
4557
+ const result = await ctx.newSession();
4558
+ if (result.cancelled)
4559
+ await deps.notifyResult(action.target, "cancelled");
4560
+ } catch (error) {
4561
+ reportFailure(error);
4562
+ await deps.notifyResult(action.target, "failure");
4554
4563
  }
4555
- },
4556
- });
4564
+ return;
4565
+ }
4557
4566
  },
4558
4567
  scheduleAfterUpdate(updateId, target) {
4559
4568
  if (pendingUpdateId !== undefined || pendingAction !== undefined)
@@ -4572,9 +4581,10 @@ export function createTelegramSessionActionRuntime(
4572
4581
  pendingAction = { kind: "replace-session", token, updateId, target };
4573
4582
  void Promise.resolve()
4574
4583
  .then(() =>
4575
- deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME} ${token}`, {
4576
- expandPromptTemplates: true,
4577
- }),
4584
+ deps.sendUserMessage(
4585
+ `/${TELEGRAM_CONNECT_COMMAND_NAME} ${TELEGRAM_INTERNAL_COMMAND_FLAG} new ${token}`,
4586
+ { expandPromptTemplates: true },
4587
+ ),
4578
4588
  )
4579
4589
  .catch((error) => {
4580
4590
  pendingAction = undefined;
@@ -92,7 +92,6 @@ export default function (pi: Pi.ExtensionAPI) {
92
92
  getCommands,
93
93
  getThinkingLevel,
94
94
  sendUserMessage,
95
- registerCommand,
96
95
  setActiveTools,
97
96
  setModel,
98
97
  setThinkingLevel,
@@ -310,7 +309,6 @@ export default function (pi: Pi.ExtensionAPI) {
310
309
  captureAuthority: captureTelegramStateAuthority,
311
310
  });
312
311
  const sessionActionAssembly = Commands.createTelegramSessionActionAssembly({
313
- registerCommand,
314
312
  sendUserMessage,
315
313
  store: threadStore,
316
314
  getProfileName: configStore.getActiveProfileName,
@@ -2833,9 +2831,9 @@ export default function (pi: Pi.ExtensionAPI) {
2833
2831
  },
2834
2832
  }.reset,
2835
2833
  );
2836
- sessionActionsRuntime.register();
2837
2834
  Bindings.registerTelegramCommandsAndTools({
2838
2835
  pi,
2836
+ handleInternalAction: sessionActionsRuntime.handleInternalAction,
2839
2837
  agentDir: Paths.resolveAgentDir(),
2840
2838
  configStore,
2841
2839
  persistConfig: persistTelegramConfigWithSync,
@@ -2090,7 +2090,8 @@ export function createTelegramBridgeApiRuntime(
2090
2090
  notBeforeMs: number;
2091
2091
  };
2092
2092
  const chatActionGates = new Map<string, ChatActionGate>();
2093
- const chatActionChatGates = new Map<string, ChatActionGate>();
2093
+ // Pin shared cooldowns until every concurrent Thread settles; this state never owns their sends.
2094
+ const chatActionChatGates = new Map<string, { pendingRequests: number; notBeforeMs: number }>();
2094
2095
  const getChatActionKeys = (
2095
2096
  method: string,
2096
2097
  body: Record<string, unknown>,
@@ -2130,7 +2131,7 @@ export function createTelegramBridgeApiRuntime(
2130
2131
  }
2131
2132
  }
2132
2133
  for (const [key, candidate] of chatActionChatGates) {
2133
- if (!candidate.inFlight && nowMs >= candidate.notBeforeMs) {
2134
+ if (candidate.pendingRequests === 0 && nowMs >= candidate.notBeforeMs) {
2134
2135
  chatActionChatGates.delete(key);
2135
2136
  }
2136
2137
  }
@@ -2146,11 +2147,10 @@ export function createTelegramBridgeApiRuntime(
2146
2147
  if (chatActionChatGates.size >= chatActionMaxGates) {
2147
2148
  return true as TResponse;
2148
2149
  }
2149
- chatGate = { notBeforeMs: 0 };
2150
+ chatGate = { pendingRequests: 0, notBeforeMs: 0 };
2150
2151
  chatActionChatGates.set(chatActionKeys.chat, chatGate);
2151
2152
  }
2152
2153
  if (gate.inFlight) return (await gate.inFlight) as TResponse;
2153
- if (chatGate.inFlight) return true as TResponse;
2154
2154
  if (nowMs < gate.notBeforeMs || nowMs < chatGate.notBeforeMs) {
2155
2155
  return true as TResponse;
2156
2156
  }
@@ -2198,10 +2198,10 @@ export function createTelegramBridgeApiRuntime(
2198
2198
  })
2199
2199
  .finally(() => {
2200
2200
  if (gate.inFlight === request) gate.inFlight = undefined;
2201
- if (chatGate.inFlight === request) chatGate.inFlight = undefined;
2201
+ chatGate.pendingRequests--;
2202
2202
  });
2203
2203
  gate.inFlight = request;
2204
- chatGate.inFlight = request;
2204
+ chatGate.pendingRequests++;
2205
2205
  return request;
2206
2206
  }
2207
2207
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.11",
3
+ "version": "0.58.1",
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.36.3",
3
+ "version": "0.37.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -50,7 +50,7 @@
50
50
  "@llblab/pi-codex-usage": "0.13.2",
51
51
  "@llblab/pi-grow-loop": "0.9.0",
52
52
  "@llblab/pi-state-flow": "0.26.5",
53
- "@llblab/pi-telegram": "0.57.11",
53
+ "@llblab/pi-telegram": "0.58.1",
54
54
  "@llblab/skills": "1.15.0"
55
55
  },
56
56
  "bundledDependencies": [