@llblab/pi-telegram 0.18.3 → 0.18.5
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/AGENTS.md +5 -3
- package/BACKLOG.md +6 -19
- package/CHANGELOG.md +14 -2
- package/README.md +1 -1
- package/docs/multi-instance-bus.md +3 -3
- package/index.ts +30 -1
- package/lib/bus-follower.ts +64 -16
- package/lib/bus-leader.ts +19 -20
- package/lib/bus-transport.ts +227 -0
- package/lib/bus.ts +170 -46
- package/lib/polling.ts +58 -5
- package/lib/preview.ts +20 -39
- package/lib/queue.ts +70 -0
- package/lib/runtime-log.ts +19 -1
- package/lib/status.ts +50 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -74,7 +74,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
74
74
|
- The bridge is session-local, paired to one allowed Telegram user, and owns a local queue aligned with Pi lifecycle hooks
|
|
75
75
|
- The Telegram lock owns only external control/polling. Local extension state and queue runtime are per Pi instance: losing `/telegram-connect` ownership stops live Telegram control here, but must not clear, silence, or stop active/queued preview, final delivery, or dispatch in this instance. Bus leader lock entries carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned Thread Reconciler cleanup/provisioning actions stamp that epoch and skip destructive side effects if current leadership differs.
|
|
76
76
|
- The Telegram lock is always the transport ownership lock. In classic/private-chat mode, transport ownership is the whole bridge ownership and `/telegram-connect` from another live Pi should offer the classic takeover confirmation. In Threaded Mode, transport ownership is the bus leader role and `/telegram-connect` from another live Pi should register as follower instead of offering takeover while the leader is live. If follower registration is not applicable because Threaded Mode is disabled or unavailable, fall back to the classic takeover path.
|
|
77
|
-
- Telegram private-chat Threaded Mode is the public switch for the local multi-instance bus. There is no separate public `telegram.json` bus switch: classic/private-chat singleton DM behavior is the base product mode, and when Telegram reports Threaded Mode available for the bot the bridge enables leader/follower bus behavior. If Telegram reports Threaded Mode unavailable or thread operations prove it unavailable, the bridge returns to classic singleton behavior as a first-class mode. Do not inject slot/thread rename guidance in classic mode. Blocked instances should not silently register as followers unless the effective runtime mode is Threaded Mode.
|
|
77
|
+
- Telegram private-chat Threaded Mode is the public switch for the local multi-instance bus. There is no separate public `telegram.json` bus switch: classic/private-chat singleton DM behavior is the base product mode, and when Telegram reports Threaded Mode available for the bot the bridge enables leader/follower bus behavior. If Telegram reports Threaded Mode unavailable or thread operations prove it unavailable, the bridge returns to classic singleton behavior as a first-class mode. During a live Threaded Mode downgrade, the current transport leader keeps the singleton classic polling role and followers disconnect instead of taking over or offering takeover while active thread bindings prove this is a downgrade from a live bus organism. Do not inject slot/thread rename guidance in classic mode. Blocked instances should not silently register as followers unless the effective runtime mode is Threaded Mode.
|
|
78
78
|
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote itself and force-acquire transport leadership so the organism degrades forward instead of staying split-brain. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for explicit disconnect or confirmed reconciliation cases. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` is a manual thread teardown: a leader deletes its own bound thread before releasing transport ownership, while a follower asks the leader to delete its assigned thread through scoped bus API before unregistering. Status is reactive to the effective runtime role and bot capability: classic/non-thread mode shows ordinary `telegram connected`, active bus leadership shows `telegram leader`, and registered followers show `telegram follower`; stable multi-instance truth belongs in the bus role (`leader`/`follower`), while lifecycle is reserved for transient recovery state such as `electing` and must not duplicate stable roles with labels like `leader-active`. When leadership or Threaded Mode availability changes at runtime, the status line and `/telegram-status` must converge promptly instead of preserving a stale role label.
|
|
79
79
|
- Runtime routing binds to a live `instanceId`; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Compatibility keys may be derived internally, but stale session records must not be treated as live owners. A live instance owns at most one active bound thread; provisioning a new target for the same `instanceId` must remove older current-state bindings and close duplicate Telegram threads when possible. A reload creates a new runtime `instanceId`; if the previous leader slot is still known/alive, keep it occupied in reservations and assign the new leader the next free slot instead of creating a duplicate same-letter tab or blocking startup on Telegram UI convergence.
|
|
80
80
|
- Follower registration readiness should stay on the smallest critical path that makes the follower routable: create or reuse the Telegram target, persist the active binding, mark target-bindings fresh, and return the target to the follower. Telegram-visible connected notices, duplicate/replaced thread cleanup, and other reconciliation work are important but non-critical; run them after registration and record slow/failing background steps as runtime events rather than delaying follower usability.
|
|
@@ -144,6 +144,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
144
144
|
- Name extracted `/lib` modules and mirrored `/tests` suites by bare domain when the repository already supplies the Telegram scope; prefer `queue.ts`, `updates.ts`, and `queue.test.ts` over redundant `telegram-*` filename prefixes. Exception: the concrete Bot API transport domain is named `telegram-api.ts` / `telegram-api.test.ts` to avoid ambiguity with the public `/api/*.ts` package membranes
|
|
145
145
|
- Keep test helpers with the mirrored domain suite by default because test files mirror module-domain boundaries; introduce shared `tests/fixtures` only when multiple domain suites truly reuse the same setup. A `tests/<domain>.test.ts` file should primarily protect `lib/<domain>.ts`; this includes `tests/runtime.test.ts`, which should protect `lib/runtime.ts` rather than act as a general integration bucket. Cross-domain runtime flows belong in the single shared `tests/integration.test.ts` suite. Do not create narrow `*.integration.test.ts` files unless a future repeated setup boundary becomes large enough to justify splitting the one shared integration suite
|
|
146
146
|
- Prefer targeted edits, keeping `index.ts` as the orchestration layer and moving reusable logic into flat `/lib` domain modules when a subsystem becomes large enough to earn extraction
|
|
147
|
+
- Do not atomize cohesive domains just because a file is large. A 1k+ line module can remain healthy when it owns one coherent mechanism and its subparts change together; extract only when there is real pressure to isolate, substitute, independently test, independently mutate, break cycles, or reduce repeated cross-domain coupling.
|
|
147
148
|
- Keep composition wiring DRY with small local adapters or owning-domain contracts when repetition appears, but do not hide live mutable session state behind broad facades just to reduce repeated closures
|
|
148
149
|
- Keep interface contracts consistent for the same runtime entity: prefer the owning domain's exported contract when multiple modules mean the same entity, and use local structural `*Like`/view contracts only for deliberate narrow projections that avoid real coupling without duplicating source-of-truth shapes
|
|
149
150
|
|
|
@@ -161,8 +162,9 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
|
|
|
161
162
|
## 6.5 Entrypoint And Import Boundaries
|
|
162
163
|
|
|
163
164
|
- Keep the preview domain as a thin streaming lifecycle controller only: draft ids, safe-prefix selection for `sendRichMessageDraft`, voice suppression, serialized flushes, diagnostics, and finalization state. Do not reintroduce assistant preview rendering there; keep `rendering.ts` scoped to bridge-owned UI/compat regular-message rendering rather than assistant or guest Markdown delivery. Drafts must only send structurally closed Markdown prefixes; draft failures are not proof that drafts are globally unsupported, so record the failure and skip that preview frame. Do not add raw plain-message fallback previews for assistant Markdown
|
|
164
|
-
- Preview/final delivery ordering is release-critical: finalization
|
|
165
|
-
- Live Rich Draft observation: `sendMessageDraft(..., undefined)` after a persisted final Rich Message can appear in Telegram clients as a separate animated three-dot draft block before dissolving. Do not use post-final draft-clear for assistant finalization; let the persisted final `sendRichMessage` replace/complete the user-visible lifecycle and reset local preview state only.
|
|
165
|
+
- Preview/final delivery ordering is release-critical: finalization waits for active preview flushes, persisted final delivery should not be followed by a post-final draft-clear call that creates transient draft UI, and regressions should cover in-flight draft flush serialization plus final reply ordering.
|
|
166
|
+
- Live Rich Draft observation: `sendMessageDraft(..., undefined)` after a persisted final Rich Message can appear in Telegram clients as a separate animated three-dot draft block before dissolving. Do not use post-final draft-clear or pre-final draft-clear for assistant finalization; let the persisted final `sendRichMessage` replace/complete the user-visible lifecycle and reset local preview state only.
|
|
167
|
+
- Remove unused code immediately when discovered. Do not keep dormant config knobs, disabled policy branches, speculative fallback paths, or "maybe later" implementation hooks in the product path; reintroduce them only with an active caller, tests, and live evidence.
|
|
166
168
|
- Keep Telegram prompt guidance compact and operational. Do not add format-specific steering for native Rich Markdown features unless the model needs a real bridge-specific rule such as formula delimiters or hidden outbound action syntax.
|
|
167
169
|
- Keep direct `node:*` file-operation dependencies out of `index.ts` when an owning domain exists; the entrypoint should compose ports while domains own local filesystem details such as temp-dir preparation, attachment stats, and turn image reads
|
|
168
170
|
- In `index.ts`, prefer namespace imports for local bridge domains so orchestration reads as domain-scoped calls such as `Queue.*`, `Turns.*`, and `Rendering.*` instead of long flat import lists
|
package/BACKLOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Project Backlog
|
|
2
2
|
|
|
3
|
-
_Current deterministic status: Threaded Mode implementation, native typing/activity status, regression coverage, docs/context reconciliation, typecheck, full tests, pack check, audit, Domain DAG validation, context validation,
|
|
3
|
+
_Current deterministic status: Threaded Mode implementation, native typing/activity status, regression coverage, docs/context reconciliation, typecheck, full tests, pack check, audit, Domain DAG validation, context validation, core live Threaded Mode smoke, and native Windows classic↔Threaded Mode upgrade/downgrade smoke are green. This backlog intentionally tracks only release-relevant remaining work: evidence-gated Telegram client/runtime follow-ups and upstream Pi API blockers._
|
|
4
4
|
|
|
5
5
|
## P0 — Live Threaded Mode Regression Sweep
|
|
6
6
|
|
|
@@ -8,19 +8,8 @@ Context: live Linux testing exposed regressions around prompt dispatch readiness
|
|
|
8
8
|
|
|
9
9
|
Open work:
|
|
10
10
|
|
|
11
|
-
- [x] Hide minimal model-menu one-page pagination and keep scope tabs progressive.
|
|
12
|
-
- [x] Add immediate-plus-deferred inbound prompt dispatch so queued prompts do not wait for a later `/reload` or command.
|
|
13
|
-
- [x] Stop automatic leader reclaim/reconciliation paths from visibly calling `editForumTopic` for internal identity restoration.
|
|
14
|
-
- [x] Prevent leader auto-claim of an unknown unbound thread while another live thread target exists, covering same-directory leader/follower smear risk.
|
|
15
|
-
- [x] Prefer local live leader/follower target labels over stale shared thread-store records when building prompt prefixes.
|
|
16
|
-
- [x] Permit follower-safe bot identity reads and own-chat native activity through the leader API proxy without granting cross-thread message/file/topic writes.
|
|
17
|
-
- [x] Preserve leader thread-name fallback in live status state to reduce `Dune`/generic `Telegram` flicker.
|
|
18
11
|
- [ ] Live smoke on Linux with one leader and one follower:
|
|
19
|
-
- [x] clean-state pass after removing `tmp/telegram` so stale diagnostic snapshots do not obscure live behavior;
|
|
20
12
|
- [ ] dirty-state pass with old `state.json`/`logs.jsonl` present to prove live locks, bus registration, target ownership, and reconciliation override stale diagnostics;
|
|
21
|
-
- [x] follower thread receives raw voice/message updates through the leader bus;
|
|
22
|
-
- [x] follower-local handlers from that instance's `telegram.json` process voice independently;
|
|
23
|
-
- [x] same-directory leader/follower sessions keep distinct thread bindings;
|
|
24
13
|
- [ ] leader reload recovers without duplicate visible thread renames;
|
|
25
14
|
- [ ] prompts dispatch without a second command;
|
|
26
15
|
- [ ] status remains stable around thread name and role while active turns start/end.
|
|
@@ -28,18 +17,16 @@ Open work:
|
|
|
28
17
|
|
|
29
18
|
Done when: local Linux live Threaded Mode smoke is stable for leader reload, follower connect, prompt dispatch, voice forwarding, and status naming without visible rename noise.
|
|
30
19
|
|
|
31
|
-
##
|
|
20
|
+
## P1 — Native Windows Threaded Mode Follow-Ups
|
|
32
21
|
|
|
33
|
-
Context:
|
|
22
|
+
Context: Native Windows smoke on the WIP `dev` build now passes for classic mode, classic ownership handoff, hot upgrade to Threaded Mode, leader/follower registration and delivery, and hot downgrade back to classic with follower disconnect. The observed downgrade status convergence can take around 10 seconds, which is acceptable for the current retry-based safety model but should remain evidence-gated if it becomes user-visible friction.
|
|
34
23
|
|
|
35
24
|
Open work:
|
|
36
25
|
|
|
37
|
-
- [ ]
|
|
38
|
-
|
|
39
|
-
- Baseline: deterministic path tests run everywhere, and a Windows-only named-pipe roundtrip regression runs when the suite executes on `win32`. Live Windows smoke remains unavailable in this environment.
|
|
40
|
-
- [ ] If Windows live smoke exposes pipe-specific behavior, add a minimized regression at the bus transport boundary before changing higher-level Threaded Mode logic.
|
|
26
|
+
- [ ] Capture text diagnostics if Windows classic restore/status convergence repeatedly exceeds the intended 5–15 second fallback window.
|
|
27
|
+
- [ ] Add a focused regression or transport/status adjustment only if new Windows evidence shows a repeatable named-pipe, lock, heartbeat, queue, or status-convergence issue.
|
|
41
28
|
|
|
42
|
-
Done when:
|
|
29
|
+
Done when: new Windows-specific runtime issues are either fixed with targeted coverage or left out of the backlog because the native smoke remains green.
|
|
43
30
|
|
|
44
31
|
## P1 — Evidence-Backed Telegram Client Follow-Ups
|
|
45
32
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.18.5: Windows Threaded Mode stabilization hotfix
|
|
4
|
+
|
|
5
|
+
- `[Bus Transport]` Introduced an explicit local bus transport boundary for endpoint derivation, socket-vs-pipe detection, operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, request-scoped server/client transport events, and handler-failure ACKs instead of silent client timeouts. Impact: Unix socket behavior remains the stable baseline while Windows named-pipe readiness and retry behavior are contained in the transport layer instead of leaking into routing.
|
|
6
|
+
- `[Windows IPC]` Leader-side forwarding now tolerates a pruned follower registry entry by using the follower's deterministic receiver endpoint, the default follower prune window is more conservative, and `/telegram-status --debug` identifies local bus endpoints as pipes or sockets. Impact: follower threads that already connected are less likely to fall back to `not connected to the Telegram bus yet` during Windows named-pipe heartbeat jitter, and diagnostics expose the active transport contour directly.
|
|
7
|
+
- `[Capability Switching]` Live Threaded Mode downgrade now blocks follower takeover while active thread bindings prove the bot is degrading from a live leader/follower organism, confirms disabled thread capability after two 2.5-second monitor probes, retries classic polling restore after transient failures, and clears the in-memory follower registry when bus leadership stops. Impact: when BotFather disables private-chat threads, the current bus leader keeps the classic singleton polling role and followers disconnect instead of stealing ownership or lingering as a live bus roster.
|
|
8
|
+
- `[Diagnostics]` Runtime JSONL reset now preserves the previous session log as `logs.previous.jsonl` and records that path in the new reset line. Impact: `/reload` no longer destroys the best evidence for long-running `/start`, menu, polling, queue, or bus stalls immediately before restart.
|
|
9
|
+
- `[Previews]` Native rich Markdown previews no longer send syntax-only prefixes such as a bare opening `**`, while keeping the native draft/final lifecycle otherwise unchanged and removing unused throttle/manual-clear branches. Impact: Telegram avoids malformed early preview fragments without extra delivery policy that can create duplicate, stalled, or placeholder draft artifacts.
|
|
10
|
+
- `[Validation]` Native Windows smoke passed for classic mode, classic ownership handoff, hot upgrade to Threaded Mode, leader/follower registration and delivery, and hot downgrade back to classic with follower disconnect. Impact: the named-pipe and capability-switching fixes have live evidence across both directions, with classic restore/status convergence inside the intended 5–15 second fallback window.
|
|
11
|
+
|
|
12
|
+
## 0.18.4: Windows Threaded Mode hotfix
|
|
13
|
+
|
|
14
|
+
- `[Windows IPC]` Follower registration now retries transient local bus connection failures while the leader named pipe/socket is still coming online. Impact: a same-directory Windows follower is less likely to fail `/telegram-connect` with `connect ENOENT \\.\\pipe\\...` during leader reload or hot Threaded Mode activation.
|
|
15
|
+
- `[Queue]` A session-bound queue dispatch watchdog now retries dispatch while Telegram work remains queued. Impact: if a platform drops the one-shot deferred dispatch wakeup, queued Telegram messages can resume without waiting for a manual `/reload`.
|
|
4
16
|
|
|
5
17
|
## 0.18.3: Threaded Mode live hotfix
|
|
6
18
|
|
|
@@ -10,7 +22,7 @@
|
|
|
10
22
|
- `[Status]` Leader target assignment now carries the live thread name into status fallback state. Impact: the status bar is less likely to flicker from `Dune Leader/Active` to generic `Telegram Leader` when the current active turn or thread-store lookup changes.
|
|
11
23
|
- `[Model Menu]` The Telegram model menu now hides one-page pagination controls and keeps scope tabs hidden unless scoped models exist. Impact: the minimal model menu shows only main-menu navigation and the available models.
|
|
12
24
|
|
|
13
|
-
## 0.18.2:
|
|
25
|
+
## 0.18.2: Setup pairing start hotfix
|
|
14
26
|
|
|
15
27
|
- `[Setup]` `/telegram-setup` now updates the live in-memory config immediately after persisting the validated bot token and before starting polling. Impact: first-time setup no longer shows `Send /start...` followed by `Telegram bot is not configured`, and `/start` can be received without restarting Pi.
|
|
16
28
|
|
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ The agent writes intent; providers or voice handlers own TTS and format conversi
|
|
|
225
225
|
|
|
226
226
|
### Voice reply policies
|
|
227
227
|
|
|
228
|
-
The bridge can automatically convert agent text replies into Telegram voice messages without requiring explicit `<!-- telegram_voice -->` markup in every response. Configure this from Settings →
|
|
228
|
+
The bridge can automatically convert agent text replies into Telegram voice messages without requiring explicit `<!-- telegram_voice -->` markup in every response. Configure this from Settings → `Voice reply` or by setting `voice.replyMode` in `telegram.json`:
|
|
229
229
|
|
|
230
230
|
- `hidden` (default): no `voice.replyMode` is stored. Behavior is manual, but prompt context stays silent.
|
|
231
231
|
- `manual`: agent-authored `<!-- telegram_voice -->` markup is required for voice replies; no automatic conversion. Unlike `hidden`, this explicit mode adds `[voice] reply mode: manual` context.
|
|
@@ -161,7 +161,7 @@ Implemented transport:
|
|
|
161
161
|
|
|
162
162
|
### Local IPC endpoint under agent temp dir
|
|
163
163
|
|
|
164
|
-
Leader opens a local Node `net` endpoint: a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. Followers register, heartbeat, and exchange routed events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
|
|
164
|
+
Leader opens a local Node `net` endpoint: a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. Followers register, heartbeat, and exchange routed events. The transport boundary owns endpoint derivation, socket-vs-pipe detection, bounded operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, and request-scoped transport events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
|
|
165
165
|
|
|
166
166
|
Pros:
|
|
167
167
|
|
|
@@ -190,7 +190,7 @@ Manual smoke checklist:
|
|
|
190
190
|
6. Close the follower terminal; verify heartbeat pruning, disconnected notice, and cleanup behavior match Unix-like behavior.
|
|
191
191
|
7. Reload the leader and verify status/debug output does not expose raw pipe internals except in explicit diagnostics.
|
|
192
192
|
|
|
193
|
-
If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, and `tmp/telegram/logs.jsonl`
|
|
193
|
+
If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs.previous.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs.previous.jsonl` so the evidence that caused the reload is not immediately overwritten.
|
|
194
194
|
|
|
195
195
|
### Native Windows Assumption Audit
|
|
196
196
|
|
|
@@ -311,7 +311,7 @@ Typical config remains just bot identity and authorization:
|
|
|
311
311
|
|
|
312
312
|
Rules:
|
|
313
313
|
|
|
314
|
-
- Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers.
|
|
314
|
+
- Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader.
|
|
315
315
|
- Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar reports `telegram leader` or `telegram follower` so transport role is visible without opening diagnostics.
|
|
316
316
|
- The thread chat is the owner's private bot DM (`allowedUserId`); no `topics.chatId` config is needed. Thread names are assigned by the bridge from a baked compact per-slot palette. There is no agent-facing `telegram_rename_thread` tool and no separate user-facing slash command for manual thread renames.
|
|
317
317
|
- Thread reuse is extension-owned through current live binding identity; there is no separate `topics` config surface in the active private-chat thread model. Manual followers use instance-scoped internal keys by default so multiple terminal processes in the same cwd can receive separate threads.
|
package/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as BusApi from "./lib/bus-api.ts";
|
|
|
9
9
|
import * as Bus from "./lib/bus.ts";
|
|
10
10
|
import * as BusFollower from "./lib/bus-follower.ts";
|
|
11
11
|
import * as BusLeader from "./lib/bus-leader.ts";
|
|
12
|
+
import * as BusTransport from "./lib/bus-transport.ts";
|
|
12
13
|
import * as CommandTemplates from "./lib/command-templates.ts";
|
|
13
14
|
import * as Commands from "./lib/commands.ts";
|
|
14
15
|
import * as Config from "./lib/config.ts";
|
|
@@ -249,6 +250,21 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
249
250
|
records: threadStore.list(),
|
|
250
251
|
});
|
|
251
252
|
},
|
|
253
|
+
getLocalBus() {
|
|
254
|
+
return {
|
|
255
|
+
leaderSocketPath: telegramBusSocketPath,
|
|
256
|
+
leaderTransport:
|
|
257
|
+
BusTransport.getTelegramBusTransportKind(telegramBusSocketPath),
|
|
258
|
+
followerSocketPath: telegramBusFollowerSocketPath,
|
|
259
|
+
followerTransport: BusTransport.getTelegramBusTransportKind(
|
|
260
|
+
telegramBusFollowerSocketPath,
|
|
261
|
+
),
|
|
262
|
+
followerRegistered: telegramBusFollowerRegistrationState.isRegistered(),
|
|
263
|
+
followerTarget: telegramBusFollowerRegistrationState.getTarget(),
|
|
264
|
+
followerSlot: telegramBusFollowerRegistrationState.getSlot(),
|
|
265
|
+
followerThreadName: telegramBusFollowerRegistrationState.getThreadName(),
|
|
266
|
+
};
|
|
267
|
+
},
|
|
252
268
|
getTopicTargets() {
|
|
253
269
|
return Threads.listTelegramThreadStatusTargets(threadStore.list());
|
|
254
270
|
},
|
|
@@ -454,6 +470,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
454
470
|
...promptDispatchRuntime,
|
|
455
471
|
sendUserMessage,
|
|
456
472
|
}).dispatchNext;
|
|
473
|
+
const queueDispatchWatchdogRuntime =
|
|
474
|
+
Queue.createTelegramQueueDispatchWatchdogRuntime({
|
|
475
|
+
hasQueuedItems: telegramQueueStore.hasQueuedItems,
|
|
476
|
+
dispatchNextQueuedTelegramTurn,
|
|
477
|
+
recordRuntimeEvent,
|
|
478
|
+
});
|
|
457
479
|
const nativeMarkdownDraftSender =
|
|
458
480
|
TelegramApi.createTelegramNativeMarkdownDraftSender({
|
|
459
481
|
sendMessageDraft,
|
|
@@ -641,7 +663,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
641
663
|
},
|
|
642
664
|
getMessageOwnership: messageOwnershipStore.get,
|
|
643
665
|
getTargetOwnership(target) {
|
|
644
|
-
return
|
|
666
|
+
return Bus.getTelegramFollowerTargetOwnership({
|
|
667
|
+
target,
|
|
668
|
+
followers: telegramBusFollowerRegistry.list(),
|
|
669
|
+
activeThreadRecords: threadStore.list(),
|
|
670
|
+
currentInstanceId: telegramInstanceId,
|
|
671
|
+
});
|
|
645
672
|
},
|
|
646
673
|
getLiveThreadTargets() {
|
|
647
674
|
return Bus.listTelegramBusLiveThreadTargets({
|
|
@@ -1168,8 +1195,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1168
1195
|
async onSessionStart(event, ctx) {
|
|
1169
1196
|
await lockedPollingRuntime.onSessionStart(event, ctx);
|
|
1170
1197
|
telegramThreadCapabilityMonitor.start(ctx);
|
|
1198
|
+
queueDispatchWatchdogRuntime.start(ctx);
|
|
1171
1199
|
},
|
|
1172
1200
|
async onSessionShutdown() {
|
|
1201
|
+
queueDispatchWatchdogRuntime.stop();
|
|
1173
1202
|
telegramThreadCapabilityMonitor.stop();
|
|
1174
1203
|
},
|
|
1175
1204
|
},
|
package/lib/bus-follower.ts
CHANGED
|
@@ -19,9 +19,17 @@ import {
|
|
|
19
19
|
sendTelegramBusLocalEnvelope,
|
|
20
20
|
type TelegramBusEnvelope,
|
|
21
21
|
} from "./bus.ts";
|
|
22
|
+
import {
|
|
23
|
+
getTelegramBusTransportRetryPolicy,
|
|
24
|
+
TELEGRAM_BUS_REGISTRATION_RETRY,
|
|
25
|
+
} from "./bus-transport.ts";
|
|
22
26
|
|
|
23
27
|
export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
|
|
24
28
|
export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
|
|
29
|
+
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS =
|
|
30
|
+
TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
|
|
31
|
+
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS =
|
|
32
|
+
TELEGRAM_BUS_REGISTRATION_RETRY.delayMs;
|
|
25
33
|
|
|
26
34
|
const TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY =
|
|
27
35
|
"__piTelegramFollowerSessionHandoff";
|
|
@@ -150,6 +158,8 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
|
|
|
150
158
|
getPid?: () => number;
|
|
151
159
|
timeoutMs?: number;
|
|
152
160
|
registrationTimeoutMs?: number;
|
|
161
|
+
registrationRetryAttempts?: number;
|
|
162
|
+
registrationRetryDelayMs?: number;
|
|
153
163
|
heartbeatMs?: number;
|
|
154
164
|
recordRuntimeEvent?: (
|
|
155
165
|
category: string,
|
|
@@ -337,6 +347,10 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
337
347
|
const response = await sendTelegramBusLocalEnvelope({
|
|
338
348
|
socketPath: deps.socketPath,
|
|
339
349
|
timeoutMs,
|
|
350
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
351
|
+
endpoint: deps.socketPath,
|
|
352
|
+
operation: "operation",
|
|
353
|
+
}),
|
|
340
354
|
envelope: {
|
|
341
355
|
kind: "follower.callApi",
|
|
342
356
|
requestId: deps.createRequestId(),
|
|
@@ -588,6 +602,12 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
588
602
|
const heartbeatMs = deps.heartbeatMs ?? 1000;
|
|
589
603
|
const registrationTimeoutMs =
|
|
590
604
|
deps.registrationTimeoutMs ?? deps.timeoutMs ?? 30000;
|
|
605
|
+
const registrationRetryAttempts =
|
|
606
|
+
deps.registrationRetryAttempts ??
|
|
607
|
+
TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS;
|
|
608
|
+
const registrationRetryDelayMs =
|
|
609
|
+
deps.registrationRetryDelayMs ??
|
|
610
|
+
TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS;
|
|
591
611
|
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
|
|
592
612
|
let activeLeaderSocketPath: string | undefined;
|
|
593
613
|
let activeAuthSecret: string | undefined;
|
|
@@ -611,6 +631,10 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
611
631
|
const response = await sendTelegramBusLocalEnvelope({
|
|
612
632
|
socketPath: activeLeaderSocketPath,
|
|
613
633
|
timeoutMs: deps.timeoutMs,
|
|
634
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
635
|
+
endpoint: activeLeaderSocketPath,
|
|
636
|
+
operation: "operation",
|
|
637
|
+
}),
|
|
614
638
|
envelope: {
|
|
615
639
|
kind: "follower.heartbeat",
|
|
616
640
|
requestId: deps.createRequestId(),
|
|
@@ -646,28 +670,46 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
646
670
|
await deps.startReceiving?.();
|
|
647
671
|
activeAuthSecret = deps.getLeaderAuthSecret?.(leader);
|
|
648
672
|
deps.setActiveAuthSecret?.(activeAuthSecret);
|
|
673
|
+
const createRegistrationEnvelope = (): Extract<
|
|
674
|
+
TelegramBusEnvelope,
|
|
675
|
+
{ kind: "follower.register" }
|
|
676
|
+
> => ({
|
|
677
|
+
kind: "follower.register",
|
|
678
|
+
requestId: deps.createRequestId(),
|
|
679
|
+
auth: activeAuthSecret,
|
|
680
|
+
registration: {
|
|
681
|
+
instanceId: deps.instanceId,
|
|
682
|
+
profileKey:
|
|
683
|
+
deps.getProfileKey?.(ctx) ??
|
|
684
|
+
(ctx.cwd ? `cwd:${ctx.cwd}` : undefined),
|
|
685
|
+
threadName:
|
|
686
|
+
deps.getThreadName?.(ctx) ??
|
|
687
|
+
(ctx.cwd ? basename(ctx.cwd) : undefined),
|
|
688
|
+
cwd: ctx.cwd,
|
|
689
|
+
pid: getPid(),
|
|
690
|
+
busSocketPath: deps.followerBusSocketPath,
|
|
691
|
+
connectedAtMs: getNowMs(),
|
|
692
|
+
},
|
|
693
|
+
});
|
|
649
694
|
let response: TelegramBusEnvelope | undefined;
|
|
650
695
|
try {
|
|
651
696
|
response = await sendTelegramBusLocalEnvelope({
|
|
652
697
|
socketPath: leaderSocketPath,
|
|
653
698
|
timeoutMs: registrationTimeoutMs,
|
|
654
|
-
envelope:
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
deps.getProfileKey?.(ctx) ??
|
|
662
|
-
(ctx.cwd ? `cwd:${ctx.cwd}` : undefined),
|
|
663
|
-
threadName:
|
|
664
|
-
deps.getThreadName?.(ctx) ??
|
|
665
|
-
(ctx.cwd ? basename(ctx.cwd) : undefined),
|
|
666
|
-
cwd: ctx.cwd,
|
|
667
|
-
pid: getPid(),
|
|
668
|
-
busSocketPath: deps.followerBusSocketPath,
|
|
669
|
-
connectedAtMs: getNowMs(),
|
|
699
|
+
envelope: createRegistrationEnvelope(),
|
|
700
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
701
|
+
endpoint: leaderSocketPath,
|
|
702
|
+
operation: "registration",
|
|
703
|
+
overrides: {
|
|
704
|
+
attempts: registrationRetryAttempts,
|
|
705
|
+
delayMs: registrationRetryDelayMs,
|
|
670
706
|
},
|
|
707
|
+
}),
|
|
708
|
+
recordTransportEvent(phase, details) {
|
|
709
|
+
deps.recordRuntimeEvent?.("bus", `Telegram bus ${phase}`, {
|
|
710
|
+
phase: `follower-register-${phase}`,
|
|
711
|
+
...details,
|
|
712
|
+
});
|
|
671
713
|
},
|
|
672
714
|
});
|
|
673
715
|
} catch (error) {
|
|
@@ -734,6 +776,12 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<
|
|
|
734
776
|
): TelegramBusForwardedUpdateReceiverRuntime {
|
|
735
777
|
const server = createTelegramBusLocalServer({
|
|
736
778
|
socketPath: deps.socketPath,
|
|
779
|
+
recordTransportEvent(phase, details) {
|
|
780
|
+
deps.recordRuntimeEvent?.("bus", `Telegram bus ${phase}`, {
|
|
781
|
+
phase: `follower-receiver-${phase}`,
|
|
782
|
+
...details,
|
|
783
|
+
});
|
|
784
|
+
},
|
|
737
785
|
async handleEnvelope(envelope) {
|
|
738
786
|
const authSecret = deps.getAuthSecret?.();
|
|
739
787
|
if (deps.getAuthSecret && (!authSecret || envelope.auth !== authSecret)) {
|
package/lib/bus-leader.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type TelegramBusFollowerView,
|
|
22
22
|
type TelegramBusInstanceRegistration,
|
|
23
23
|
} from "./bus.ts";
|
|
24
|
+
import { getTelegramBusTransportRetryPolicy } from "./bus-transport.ts";
|
|
24
25
|
|
|
25
26
|
export interface TelegramBusLeaderRuntime<TContext> {
|
|
26
27
|
startPolling: (ctx: TContext) => Promise<void>;
|
|
@@ -598,31 +599,22 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
598
599
|
>,
|
|
599
600
|
): Promise<TelegramBusEnvelope> => {
|
|
600
601
|
const follower = deps.followerRegistry.get(envelope.recipientInstanceId);
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
ok: false,
|
|
606
|
-
message: "Unknown Telegram bus follower instance.",
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
if (!follower.busSocketPath) {
|
|
610
|
-
return {
|
|
611
|
-
kind: "bus.ack",
|
|
612
|
-
requestId: envelope.requestId,
|
|
613
|
-
ok: false,
|
|
614
|
-
message: "Telegram bus follower does not expose a receiver socket.",
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
|
|
602
|
+
const followerSocketPath =
|
|
603
|
+
follower?.busSocketPath ??
|
|
604
|
+
getTelegramBusFollowerSocketPath(envelope.recipientInstanceId);
|
|
605
|
+
if (follower) deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
|
|
618
606
|
try {
|
|
619
607
|
const response = await sendTelegramBusLocalEnvelope({
|
|
620
|
-
socketPath:
|
|
608
|
+
socketPath: followerSocketPath,
|
|
621
609
|
envelope,
|
|
622
610
|
timeoutMs: deps.timeoutMs,
|
|
611
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
612
|
+
endpoint: followerSocketPath,
|
|
613
|
+
operation: "operation",
|
|
614
|
+
}),
|
|
623
615
|
});
|
|
624
616
|
if (response?.kind === "bus.ack" && response.ok) {
|
|
625
|
-
deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
|
|
617
|
+
if (follower) deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
|
|
626
618
|
return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
|
|
627
619
|
}
|
|
628
620
|
const message =
|
|
@@ -842,7 +834,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
842
834
|
): TelegramBusLeaderRuntime<TContext> {
|
|
843
835
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
844
836
|
const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
|
|
845
|
-
const followerStaleAfterMs = deps.followerStaleAfterMs ??
|
|
837
|
+
const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
|
|
846
838
|
let pruneInterval: ReturnType<typeof setInterval> | undefined;
|
|
847
839
|
const stopPruning = () => {
|
|
848
840
|
if (!pruneInterval) return;
|
|
@@ -878,6 +870,12 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
878
870
|
};
|
|
879
871
|
const localServer = createTelegramBusLocalServer({
|
|
880
872
|
socketPath: deps.socketPath,
|
|
873
|
+
recordTransportEvent(phase, details) {
|
|
874
|
+
deps.recordRuntimeEvent?.("bus", `Telegram bus ${phase}`, {
|
|
875
|
+
phase: `leader-${phase}`,
|
|
876
|
+
...details,
|
|
877
|
+
});
|
|
878
|
+
},
|
|
881
879
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
882
880
|
followerRegistry: deps.followerRegistry,
|
|
883
881
|
authSecret: deps.authSecret,
|
|
@@ -917,6 +915,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
917
915
|
.catch((error) =>
|
|
918
916
|
deps.recordRuntimeEvent?.("bus", error, { phase: "stop" }),
|
|
919
917
|
);
|
|
918
|
+
deps.followerRegistry.clear();
|
|
920
919
|
}
|
|
921
920
|
},
|
|
922
921
|
};
|