@llblab/pi-telegram 0.23.2 → 0.24.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/AGENTS.md +3 -3
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +4 -5
- package/docs/README.md +0 -1
- package/docs/architecture.md +14 -15
- package/docs/multi-instance-bus.md +15 -9
- package/docs/public-api.md +8 -2
- package/docs/sections.md +1 -1
- package/docs/updates.md +1 -1
- package/index.ts +5 -7
- package/lib/bindings.ts +1 -9
- package/lib/commands.ts +5 -2
- package/lib/config.ts +163 -43
- package/lib/locks.ts +13 -14
- package/lib/menu-settings.ts +11 -2
- package/lib/paths.ts +9 -6
- package/lib/status.ts +12 -5
- package/package.json +1 -1
- package/docs/locks.md +0 -156
package/AGENTS.md
CHANGED
|
@@ -77,9 +77,9 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
77
77
|
|
|
78
78
|
- The bridge is session-local, paired to one allowed Telegram user, and owns a local queue aligned with Pi lifecycle hooks
|
|
79
79
|
- The Telegram lock owns external Telegram transport, not local Pi execution. Local extension state and queue runtime are per Pi instance: losing exact `/telegram-connect` ownership must not clear accepted queue state or stop local Pi dispatch, but preview, final delivery, attachments, menus, and other Bot API mutations fail closed once another runtime owns transport. A replacement owner must never receive stale direct mutations merely to preserve old-runtime Telegram output. 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.
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
- Telegram profiles are parallel bot/runtime realities, not merely alternate credentials.
|
|
80
|
+
- Telegram transport ownership lives only in the extension-local `tmp/telegram/owners.json` store under `default` or named profile slots. Every mutation must serialize the complete cross-process read/check/write transaction through `owners.json.transaction` and fail closed on contention; atomic payload replacement alone does not make ownership linearizable. Refresh, release, takeover, and irreversible leader work additionally fence against the exact acquired owner and leader epoch. In classic/private-chat mode, transport ownership is the whole bridge ownership and `/telegram-connect` from another live Pi offers classic takeover confirmation. In Threaded Mode, transport ownership is the bus leader role and another live Pi registers as follower instead of offering takeover while the leader is live. If follower registration is unavailable, fall back to classic takeover.
|
|
81
|
+
- Ownership transactions publish a fully initialized private guard directory containing one exact generation-specific `owner.<generation>.json` through same-parent atomic rename. The owner filename and payload generation must match so stale observation cannot rename replacement metadata. Release verifies the owner and atomically renames the stable guard away before cleanup. Directory recovery first claims owner metadata inside the still-occupied guard so a crashed recoverer can itself be replaced without exposing the stable path; transient guard moves and rollback both retry, while inactive process-global claim generations remain recoverable after failed restoration. If secondary recovery cleanup fails after publishing a replacement main guard, release that exact replacement before propagating the failure. Guards must not depend on hard links, direct file rename-overwrite, incomplete create-then-write state, or age-only recovery; malformed or unsupported guards fail closed.
|
|
82
|
+
- Telegram profiles are parallel bot/runtime realities, not merely alternate credentials. Persist every bot/session field (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) under `telegram.json` `profiles.<name>`, including `profiles.default`; keep shared handlers and assistant/voice/time settings top-level. Bare and explicit `default` setup/connect commands address the same profile. Legacy root identity may normalize atomically only when unambiguous; conflicts fail closed without mutation. Any owner slot, IPC endpoint, state snapshot, diagnostics log, thread/bus binding, runtime cache, ownership map, or operator-visible status surface that could mix bot identities must be deliberately classified as global, session-local, or profile-scoped. Use the `default` owner slot for the default profile and validated profile names for named slots; give named profiles isolated observable surfaces unless there is an explicit top-level shared-config reason not to.
|
|
83
83
|
- 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.
|
|
84
84
|
- 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. Heartbeat acknowledgements carry the authenticated live follower-slot roster: on leader loss, the lowest observed live slot attempts the atomic lock first, while higher slots defer for one bounded grace and then re-check so a missing lower follower cannot block recovery indefinitely. 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 only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. 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 sends an authenticated exact-registration-generation disconnect envelope and waits for the leader to delete its assigned thread, mark the durable binding offline, and remove live routing authority before unregistering. Cleanup must be confirmed by successful deletion or explicit already-gone evidence; incomplete cleanup preserves binding/routing state, keeps the session open, and returns actionable retry guidance. A promoted leader authorizes inherited follower-thread cleanup by its current leader epoch rather than the historical owner label. 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.
|
|
85
85
|
- Runtime routing binds to a live `instanceId` plus its exact follower registration generation; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Persisted manual-follower records may act as stable restart hints, but only an authenticated live registry entry authorizes inbound forwarding or outbound transport; 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.
|
package/BACKLOG.md
CHANGED
|
@@ -10,7 +10,7 @@ Open work:
|
|
|
10
10
|
|
|
11
11
|
- [ ] Capture text diagnostics if Windows classic restore/status convergence repeatedly exceeds the intended 5–15 second fallback window.
|
|
12
12
|
- [ ] 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.
|
|
13
|
-
- [ ] For every Windows connect/runtime crash report, classify the failing boundary (`
|
|
13
|
+
- [ ] For every Windows connect/runtime crash report, classify the failing boundary (`owners.json` atomic write, named pipe, heartbeat, polling, queue, or status), ensure `logs.jsonl` captures enough redacted evidence before shutdown, and add a minimized regression when the failure can be simulated deterministically.
|
|
14
14
|
|
|
15
15
|
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.
|
|
16
16
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.24.0: Canonical Profiles And Extension-Local Ownership
|
|
6
|
+
|
|
7
|
+
- `Canonical Default Profile`: Persisted bot/session identity under `profiles.default` and named siblings while keeping shared settings top-level. Legacy root identity migrates atomically when unambiguous and conflicts fail closed; bare and explicit `default` setup/connect commands now behave identically without changing default runtime paths. Live reload preserved token, pairing, offset, ownership, and Telegram delivery.
|
|
8
|
+
- `Extension-Local Transport Ownership`: Replaced shared agent-level `locks.json` ownership with private profile-scoped `tmp/telegram/owners.json`, serialized through `owners.json.transaction`; followers remain outside owner-slot writes. Breaking: `0.24.0` intentionally does not migrate legacy ownership, so upgrading resets the transport owner and may require `/telegram-connect`. Live reload confirmed isolated default ownership while leaving the legacy registry untouched.
|
|
9
|
+
|
|
10
|
+
## 0.23.3: Thread-Scoped Settings Hotfix
|
|
11
|
+
|
|
12
|
+
- `Thread-Scoped Settings Rehydration`: Preserved `message_thread_id` when rebuilding full Settings menu state after session reload or TTL expiry. Impact: a stale Settings message in Threaded Mode retains its exact Telegram target when later callbacks reopen menus or cross into model/status controls.
|
|
13
|
+
- `Composition Root Hygiene`: Sorted the local domain imports in `index.ts` and normalized adjacent wrapping without changing runtime composition. Impact: the entrypoint remains easier to scan and review while preserving the existing dependency graph and behavior.
|
|
14
|
+
|
|
3
15
|
## 0.23.2: Voice Policy And Turn Delivery Hotfix
|
|
4
16
|
|
|
5
17
|
- `Settings Persistence Race`: Rebuilt expired Settings callback state from the live model-menu context before applying mutations. Polling now persists only its monotonic `lastUpdateId` into the current config-store snapshot instead of later submitting the detached full config object captured when polling started. Impact: a subsequent Telegram update can no longer erase freshly persisted `voice.replyMode`, `assistant.proactivePush`, or other Settings values and return the menu to stale defaults.
|
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ Run this inside Pi:
|
|
|
45
45
|
/telegram-setup
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved token, setup offers it as the default. If no saved token exists, setup can prefill from `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, or `TELEGRAM_KEY`.
|
|
48
|
+
Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved token, setup offers it as the default. If no saved token exists, setup can prefill from `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, or `TELEGRAM_KEY`. Bot/session identity persists under `profiles.default`; shared handlers and assistant/voice/time settings remain top-level. `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Use `/telegram-setup <name>` only when you want an additional bot profile. Cancelling or failing named-profile token validation leaves the currently active profile and polling runtime unchanged; setup reports the profile as saved and connected only after polling startup succeeds.
|
|
49
49
|
|
|
50
50
|
### 3. Connect this Pi instance and its active session
|
|
51
51
|
|
|
@@ -53,7 +53,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
|
|
|
53
53
|
/telegram-connect
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the
|
|
56
|
+
The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers.
|
|
57
57
|
|
|
58
58
|
### 4. Pair your Telegram account
|
|
59
59
|
|
|
@@ -148,9 +148,9 @@ Run these inside Pi.
|
|
|
148
148
|
|
|
149
149
|
| Command | Purpose |
|
|
150
150
|
| --- | --- |
|
|
151
|
-
| `/telegram-setup` | Save or update
|
|
151
|
+
| `/telegram-setup` / `/telegram-setup default` | Save or update `profiles.default` |
|
|
152
152
|
| `/telegram-setup <profile>` | Save or update a named-profile bot token |
|
|
153
|
-
| `/telegram-connect` | Activate
|
|
153
|
+
| `/telegram-connect` / `/telegram-connect default` | Activate `profiles.default` and acquire its transport ownership |
|
|
154
154
|
| `/telegram-connect <profile>` | Activate a named profile and acquire its transport ownership |
|
|
155
155
|
| `/telegram-disconnect` | Stop polling and release ownership; in Threaded Mode, confirm deletion of this instance's current Telegram thread |
|
|
156
156
|
| `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
|
|
@@ -257,7 +257,6 @@ A Telegram prompt is a normal model turn in the active Pi session and therefore
|
|
|
257
257
|
- [Extension Sections](./docs/sections.md) — Telegram-native companion UI surfaces.
|
|
258
258
|
- [Updates](./docs/updates.md) — update handler registry and callback interop.
|
|
259
259
|
- [Multi-Instance Bus](./docs/multi-instance-bus.md) — leader/follower routing in Threaded Mode.
|
|
260
|
-
- [Locks](./docs/locks.md) — singleton ownership and shared lock conventions.
|
|
261
260
|
- [UI Style](./docs/ui-style.md) — menu, emoji, labels, dialogs, and inline keyboard standards.
|
|
262
261
|
- [Callback Namespaces](./docs/callback-namespaces.md) — callback ownership and routing.
|
|
263
262
|
- [Command Templates](./docs/command-templates.md) — handler command-template conventions.
|
package/docs/README.md
CHANGED
|
@@ -20,5 +20,4 @@ Living index of project documentation in `/docs`.
|
|
|
20
20
|
- [multi-instance-bus.md](./multi-instance-bus.md) — Optional multi-instance Telegram bus architecture: profile-scoped transport, leader/follower routing, thread targets, instance slots, manual follower registration, and recovery semantics
|
|
21
21
|
- [sections.md](./sections.md) — Telegram Extension Sections Standard: registration contract, context ports, callback routing, navigation hierarchy, and demo reference for pi extensions that want Telegram UI surfaces
|
|
22
22
|
- [voice.md](./voice.md) — Voice integration guide: detection, reply policy, STT/TTS provider registration, provider-owned conversion, and transparent interception
|
|
23
|
-
- [locks.md](./locks.md) — Shared `locks.json` standard for singleton extension ownership
|
|
24
23
|
- [ui-style.md](./ui-style.md) — Inline UI style guide for buttons, toggles, tabs, option lists, cards, and dialogs
|
package/docs/architecture.md
CHANGED
|
@@ -63,7 +63,7 @@ The repository uses a **Flat Domain DAG**:
|
|
|
63
63
|
- `index.ts`: composition root for live ports, session-state ports, transport adapters, and lifecycle registration. It exposes cross-domain wiring but does not own mutable domain state or reusable adapters.
|
|
64
64
|
- `api`: Bot API helpers, retries, uploads/downloads, temp cleanup, byte limits, chat actions, lazy token clients, and API error recording.
|
|
65
65
|
- `config` / `setup`: `telegram.json`, bot token setup, named bot/session profiles, first-user pairing, authorization, env fallback, atomic persistence, effective config views, and live config accessors.
|
|
66
|
-
- `locks` / `polling`:
|
|
66
|
+
- `locks` / `polling`: extension-local transport owner storage, exact-owner epoch exposure, process-global reload generations, owner-aware polling lifecycle/takeover/follower registration, and the cohesive classic-vs-Threaded capability state/monitor/observation/polling orchestration. Polling also owns long-poll controller state, offset admission/persistence, and poll-loop wiring.
|
|
67
67
|
- `bus` / `bus-api` / `bus-leader` / `bus-follower` / `ownership` / `target`: Threaded Mode multi-instance bus contracts, profile-scoped process/endpoint identity, local leader/follower IPC, leader-only orchestration, follower-side manual registration/session runtime, follower-routed Bot API calls, live message ownership, and `{ chatId, threadId? }` target identity. `bus` owns shared protocol, process identity, profile-aware local endpoints, and IPC primitives; `bus-leader` owns leader runtime, leader envelope handling, activation scheduling, and leader polling/server/prune orchestration; `bus-follower` owns process-stable manual-follower keys plus this Pi instance's follower-side registration, heartbeat, one-sequence authenticated client assembly, forwarded-update adaptation/receiving, recovery retry defaults, and routed API caller without any process spawning.
|
|
68
68
|
- `sync`: demand-driven Telegram reconciliation, mutable sync-slice state, nested provisioning activity, and local assumption policy. It does not own a complete Telegram bot read-model; Bot API lacks a complete topic/thread listing surface. It owns sync slices, invalidation triggers, config-persist invalidation sequencing, stale-topic API recovery adaptation, observation intake, status/debug freshness, and reconciliation scheduling across bot identity, pairing assumptions, live target bindings, reservations, and transport health after meaningful observable signals. It should call narrower domain primitives rather than letting `index.ts`, `threads`, or `status` accumulate cross-cutting reconciliation policy.
|
|
69
69
|
- `thread-reconciler`: Threaded Mode control-plane planning for Telegram thread/tab lifecycle. It owns the reconciliation state machine (`stable`, `provisioning`, `sync-required`, `cleanup-required`), pure plans, proof-before-delete rules, pending-provision protection, fresh-creation grace windows, leader-epoch checks, and the single policy authority for destructive thread cleanup actions. It excludes live Telegram API calls, inbound routing, menu rendering, and direct persistence.
|
|
@@ -103,7 +103,7 @@ Mirrored domain regressions live in `/tests/*.test.ts`. Shared test fixtures sho
|
|
|
103
103
|
|
|
104
104
|
## Configuration And Ownership
|
|
105
105
|
|
|
106
|
-
Telegram configuration lives in `~/.pi/agent/telegram.json`.
|
|
106
|
+
Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
|
|
107
107
|
|
|
108
108
|
### Setup Flow
|
|
109
109
|
|
|
@@ -113,29 +113,28 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership l
|
|
|
113
113
|
2. Otherwise use the first supported Telegram token environment variable.
|
|
114
114
|
3. Otherwise show the example placeholder.
|
|
115
115
|
|
|
116
|
-
`ctx.ui.input()` only supports placeholder text, so setup uses `ctx.ui.editor()` when a real default must appear already filled in. Persisted config is written through a private temp file plus atomic rename and left with `0600` permissions.
|
|
116
|
+
`ctx.ui.input()` only supports placeholder text, so setup uses `ctx.ui.editor()` when a real default must appear already filled in. Bare and explicit `default` setup/connect commands address the same `profiles.default` entry. Persisted config is written through a private temp file plus atomic rename and left with `0600` permissions. On first load, legacy root identity moves into `profiles.default` in that same serialized atomic transaction when no conflicting canonical value exists; identical duplicates collapse, complementary fields merge, and conflicts reject the load without modifying the file.
|
|
117
117
|
|
|
118
118
|
### Runtime Ownership
|
|
119
119
|
|
|
120
|
-
- `/telegram-connect` acquires or moves
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
128
|
-
- `locks.json` owns only external Telegram control/polling. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and other delayed work stop until exact direct or follower authority becomes valid again; ownership loss never permits delivery through replacement transport.
|
|
120
|
+
- `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` stops polling and releases only that exact slot. In Threaded Mode disconnect first names the current thread in a destructive confirmation, then tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope and wait for confirmed leader cleanup before unregistering. Unconfirmed cleanup keeps binding/routing state available for an explicit retry.
|
|
121
|
+
- Session start schedules polling resume asynchronously only when the owner slot already points at the current `pid`/`cwd`, or when a stale same-`cwd` owner can be safely replaced after process restart. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
|
|
122
|
+
- Pi `print`/`json` run modes stay passive. Inherited child sessions that share `telegram.json` but do not own the exact `pid`/`cwd` slot must not poll or call `getUpdates` unless the operator force-takes ownership.
|
|
123
|
+
- Session replacement suspends polling/watchers without releasing ownership so the next session in the same process can resume. A registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and re-registers through the live leader without marking or replacing its Telegram thread.
|
|
124
|
+
- Live external owners require explicit takeover confirmation. Long-lived timers compare against snapshotted owner identity and stop local transport work when the slot no longer matches.
|
|
125
|
+
- `owners.json` owns only Telegram transport control. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and delayed Bot API work fail closed until exact direct or follower authority becomes valid again.
|
|
126
|
+
- Every acquisition, refresh, release, takeover, and stale recovery serializes through the sibling `owners.json.transaction` guard. The guard publishes one private generation-named owner record atomically, validates filename/payload generation agreement, fences stale recovery and delayed release against replacement-owner ABA, and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. The JSON store publishes through a private same-directory temporary file and atomic rename; atomic payload replacement does not replace transaction serialization.
|
|
127
|
+
- `owners.json` is authoritative and private. `state.json` remains an observable snapshot, `logs.jsonl` remains diagnostics, and followers remain authenticated bus registrations rather than ownership-file writers.
|
|
129
128
|
|
|
130
|
-
|
|
129
|
+
Version `0.24.0` intentionally does not read or migrate the former agent-level `locks.json`; upgrading resets Telegram ownership. Run `/telegram-connect` when a fresh owner is not elected automatically. Delete `~/.pi/agent/tmp/telegram/owners.json` and its stale transaction guard, when no owner process is live, to reset only pi-telegram transport ownership without deleting configuration, diagnostics, or other extensions' state.
|
|
131
130
|
|
|
132
131
|
### Threaded Mode Multi-Instance Bus
|
|
133
132
|
|
|
134
133
|
Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode.
|
|
135
134
|
|
|
136
|
-
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes
|
|
135
|
+
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
|
|
137
136
|
|
|
138
|
-
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration;
|
|
137
|
+
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
|
|
139
138
|
|
|
140
139
|
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
|
|
141
140
|
|
|
@@ -71,16 +71,16 @@ The operator experience:
|
|
|
71
71
|
|
|
72
72
|
In Threaded Mode, the lock means "this instance is the current Telegram bus leader" rather than "this instance is the only usable Telegram extension".
|
|
73
73
|
|
|
74
|
-
Classic
|
|
74
|
+
Classic ownership meaning:
|
|
75
75
|
|
|
76
76
|
```text
|
|
77
|
-
|
|
77
|
+
tmp/telegram/owners.json / <profile-slot> -> polling/control owner
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
Threaded Mode meaning:
|
|
81
81
|
|
|
82
82
|
```text
|
|
83
|
-
|
|
83
|
+
tmp/telegram/owners.json / <profile-slot> -> bus leader identity + heartbeat
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Pi session replacement (`new`) changes follower agent context, not bus membership: a registered follower preserves its registration and refreshes the live context instead of disconnecting. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
|
|
@@ -200,7 +200,7 @@ Current portability audit:
|
|
|
200
200
|
|
|
201
201
|
- Local bus transport: adapted. Unix-like platforms use filesystem socket paths; native Windows uses named pipes so no POSIX socket pathname is required.
|
|
202
202
|
- Bus endpoint permissions: Unix sockets/directories use `chmod`; Windows named-pipe endpoints skip POSIX chmod/unlink path handling because the pipe is not a filesystem node.
|
|
203
|
-
-
|
|
203
|
+
- Ownership/config/state/temp files: path construction uses `path.join`/`path.resolve` under the Pi agent directory. File permission calls remain best-effort private-mode hardening; native Windows may emulate POSIX modes, so broad Windows ACL auditing is outside this extension's current local-bus baseline.
|
|
204
204
|
- Process liveness: lock ownership uses `process.kill(pid, 0)`, which Node supports on Windows for existence checks. Cross-user permission failures are treated as alive, matching Unix semantics.
|
|
205
205
|
- Shell/provider commands: outbound handler command templates remain operator-configured and platform-dependent; Threaded Mode bus portability does not guarantee every configured STT/TTS/shell provider is Windows-native.
|
|
206
206
|
- Manual follower identity: process ids are used as local liveness/profile hints only, not cross-machine identifiers.
|
|
@@ -323,15 +323,21 @@ Target-scoped state requirements:
|
|
|
323
323
|
|
|
324
324
|
There is no public `telegram.json` switch for the bus. Telegram private-chat Threaded Mode is the runtime switch: when Telegram exposes threads for the bot, the bridge enables the local bus; when Telegram runs as an ordinary private DM, the bridge uses classic private-chat flow as the base mode.
|
|
325
325
|
|
|
326
|
-
Typical config remains just bot identity and authorization:
|
|
326
|
+
Typical config remains just bot identity and authorization, stored in the canonical default profile:
|
|
327
327
|
|
|
328
328
|
```json
|
|
329
329
|
{
|
|
330
|
-
"
|
|
331
|
-
|
|
330
|
+
"profiles": {
|
|
331
|
+
"default": {
|
|
332
|
+
"botToken": "...",
|
|
333
|
+
"allowedUserId": 123456789
|
|
334
|
+
}
|
|
335
|
+
}
|
|
332
336
|
}
|
|
333
337
|
```
|
|
334
338
|
|
|
339
|
+
Named bots use sibling `profiles.<name>` entries. Shared bridge settings remain top-level.
|
|
340
|
+
|
|
335
341
|
Rules:
|
|
336
342
|
|
|
337
343
|
- 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.
|
|
@@ -345,8 +351,8 @@ Rules:
|
|
|
345
351
|
|
|
346
352
|
Current state under the agent dir:
|
|
347
353
|
|
|
348
|
-
- `
|
|
349
|
-
- `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. Every process on one Telegram profile reads this shared path, but only the active transport
|
|
354
|
+
- `tmp/telegram/owners.json`: authoritative extension-local transport owners keyed by `default` or named profile. Each owner contains the bus leader identity, capability secret, heartbeat, generation, and cleanup fencing epoch. Mutations serialize through `owners.json.transaction`; followers never write owner slots. The local bus endpoint is derived from the agent directory by default; legacy `busSocketPath` entry fields are tolerated inside current owner records but are not required.
|
|
355
|
+
- `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. Every process on one Telegram profile reads this shared path, but only the active transport owner may persist it; followers become writers only after promotion. Status-only persistence refreshes disk-backed bindings before serialization so an already-loaded stale view cannot erase newer leader records. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
|
|
350
356
|
- Local bus endpoints: Unix-like platforms expose stable `tmp/telegram/bus.sock` and `tmp/telegram/followers/*` symlinks backed by private generation sockets; native Windows uses deterministic named pipes under `\\.\pipe\pi-telegram-...`. These are transient IPC endpoints, not durable routing state.
|
|
351
357
|
|
|
352
358
|
The bridge must not keep a separate durable `telegram-targets.json` history. `state.json` retains current stable manual-follower bindings as restart hints, but they never authorize routing without a matching authenticated live registration. Stale/offline/failed observations are not reusable delivery authority. `sync` remains event-driven assumption reconciliation rather than a full Telegram bot-state mirror because Bot API exposes no complete thread listing surface. Non-current routeable thread bindings are pruned during load/persist; old session records must not be retained just to compute the next slot because `bot.lastSlot` is the only durable cursor. Previous-process leader bindings are treated as occupied TTL-bounded reservations until Telegram confirms deletion: reload/startup may close/delete/probe the old thread, known reservations are retried proactively on leader startup, and if Telegram still accepts the old thread id, the new leader should provision the next free slot (`B`, `C`, …) rather than creating a duplicate same-letter tab or blocking startup on Telegram UI convergence. Routing must use live current threads/follower registry, never reservations. The bus leader provisions its own thread during bus startup/connect and provisions follower threads on `follower.register`; registered followers also live in the leader's in-memory registry and communicate over the local bus socket. The live follower registry can resolve a follower by exact `{ chatId, threadId? }`; the leader uses that target ownership to forward message and edited-message updates to followers, and the follower receiver accepts those updates in addition to callbacks and reactions. Terminal status and `[telegram|thread:name]` resolve the matching current-instance identity through the same target-aware path, preferring registered local metadata over stale shared bindings. Media album grouping and split-text coalescing keys include the thread target, queue reaction mutations can scope by chat/thread to avoid cross-target message-id collisions, active-turn target is exposed for lifecycle cleanup and local direct-tool defaults, transport reply dedup is chat/thread-scoped, stored menu state is keyed by chat/message so callback state lookup cannot collide across chats, and generated button turns plus section prompt/open actions preserve the callback thread target. `telegram_message` and immediate `telegram_attach` delivery can also carry an explicit `thread_id` with `chat_id`; when a follower is registered, their default direct-tool target is the assigned thread target and the bus-aware API runtime routes the send through the leader instead of calling Bot API transport locally.
|
package/docs/public-api.md
CHANGED
|
@@ -78,12 +78,16 @@ Configuration lives in `~/.pi/agent/telegram.json` unless `PI_CODING_AGENT_DIR`
|
|
|
78
78
|
Stable config keys:
|
|
79
79
|
|
|
80
80
|
```ts
|
|
81
|
-
interface
|
|
82
|
-
botToken
|
|
81
|
+
interface TelegramBotProfile {
|
|
82
|
+
botToken: string;
|
|
83
83
|
botUsername?: string; // runtime-managed
|
|
84
84
|
botId?: number; // runtime-managed
|
|
85
85
|
allowedUserId?: number;
|
|
86
86
|
lastUpdateId?: number; // runtime-managed
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface TelegramConfig {
|
|
90
|
+
profiles?: Record<string, TelegramBotProfile>; // includes `default`
|
|
87
91
|
inboundHandlers?: TelegramInboundHandlerConfig[];
|
|
88
92
|
attachmentHandlers?: TelegramInboundHandlerConfig[]; // compatibility alias
|
|
89
93
|
outboundHandlers?: TelegramOutboundHandlerConfig[];
|
|
@@ -103,6 +107,8 @@ interface TelegramConfig {
|
|
|
103
107
|
}
|
|
104
108
|
```
|
|
105
109
|
|
|
110
|
+
Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, and `time` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
|
|
111
|
+
|
|
106
112
|
Hidden/default semantics are represented by absence:
|
|
107
113
|
|
|
108
114
|
- `assistant.proactivePush` defaults to `true`; omit it to keep projection enabled, or set it explicitly to `false` to disable it. When enabled, each completed public assistant text block from local or autonomous work is projected to the authorized Telegram target once and in source order. This includes visible intermediate commentary/checkpoints and the final block. It excludes token deltas, hidden reasoning, tool calls/arguments/results, Telegram-owned turns, empty blocks, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
|
package/docs/sections.md
CHANGED
|
@@ -436,7 +436,7 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Main-menu/sett
|
|
|
436
436
|
|
|
437
437
|
- [Callback Namespaces](./callback-namespaces.md): defines `section:` as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them
|
|
438
438
|
- [Updates](./updates.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
|
|
439
|
-
- [
|
|
439
|
+
- [Architecture](./architecture.md#configuration-and-ownership): pi-telegram transport ownership is extension-local and independent from section registration identity
|
|
440
440
|
- [Command Templates](./command-templates.md): sections do not execute command templates by default. UI registration + callback routing, not shell execution
|
|
441
441
|
|
|
442
442
|
## 15. Demo Extension
|
package/docs/updates.md
CHANGED
|
@@ -136,7 +136,7 @@ This means:
|
|
|
136
136
|
|
|
137
137
|
## Ownership semantics
|
|
138
138
|
|
|
139
|
-
The handler registry is ownership-agnostic and does not interact with the
|
|
139
|
+
The handler registry is ownership-agnostic and does not interact with the extension-local transport owner slots documented in [Architecture](./architecture.md#configuration-and-ownership). When the polling runtime loses its `owners.json` slot and stops `getUpdates`, handlers stop receiving updates because no updates are being fetched; they are not unregistered.
|
|
140
140
|
|
|
141
141
|
If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the handler registry.
|
|
142
142
|
|
package/index.ts
CHANGED
|
@@ -7,18 +7,18 @@
|
|
|
7
7
|
import * as Activity from "./lib/activity.ts";
|
|
8
8
|
import * as Bindings from "./lib/bindings.ts";
|
|
9
9
|
import * as BusApi from "./lib/bus-api.ts";
|
|
10
|
-
import * as Bus from "./lib/bus.ts";
|
|
11
10
|
import * as BusFollower from "./lib/bus-follower.ts";
|
|
12
11
|
import * as BusLeader from "./lib/bus-leader.ts";
|
|
13
12
|
import * as BusTransport from "./lib/bus-transport.ts";
|
|
13
|
+
import * as Bus from "./lib/bus.ts";
|
|
14
14
|
import * as CommandTemplates from "./lib/command-templates.ts";
|
|
15
15
|
import * as Commands from "./lib/commands.ts";
|
|
16
16
|
import * as Config from "./lib/config.ts";
|
|
17
17
|
import * as Delivery from "./lib/delivery.ts";
|
|
18
|
-
import * as Threads from "./lib/threads.ts";
|
|
19
18
|
import * as Inbound from "./lib/inbound.ts";
|
|
20
19
|
import * as Lifecycle from "./lib/lifecycle.ts";
|
|
21
20
|
import * as Locks from "./lib/locks.ts";
|
|
21
|
+
import * as Logs from "./lib/logs.ts";
|
|
22
22
|
import * as Media from "./lib/media.ts";
|
|
23
23
|
import * as MenuQueue from "./lib/menu-queue.ts";
|
|
24
24
|
import * as MenuSettings from "./lib/menu-settings.ts";
|
|
@@ -35,13 +35,13 @@ import * as Queue from "./lib/queue.ts";
|
|
|
35
35
|
import * as Replies from "./lib/replies.ts";
|
|
36
36
|
import * as Routing from "./lib/routing.ts";
|
|
37
37
|
import * as Runtime from "./lib/runtime.ts";
|
|
38
|
-
import * as Logs from "./lib/logs.ts";
|
|
39
38
|
import * as Sections from "./lib/sections.ts";
|
|
40
39
|
import * as Status from "./lib/status.ts";
|
|
41
40
|
import * as Sync from "./lib/sync.ts";
|
|
42
41
|
import * as TelegramApi from "./lib/telegram-api.ts";
|
|
43
42
|
import * as TextGroups from "./lib/text-groups.ts";
|
|
44
43
|
import * as ThreadReconciler from "./lib/thread-reconciler.ts";
|
|
44
|
+
import * as Threads from "./lib/threads.ts";
|
|
45
45
|
import * as TimeInjection from "./lib/time-injection.ts";
|
|
46
46
|
import * as Updates from "./lib/updates.ts";
|
|
47
47
|
import * as Voice from "./lib/voice.ts";
|
|
@@ -465,8 +465,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
465
465
|
isTransportStampActive: telegramTransportStampRuntime.isActive,
|
|
466
466
|
ownsDirect: lockRuntime.owns,
|
|
467
467
|
getDirectEpoch: lockRuntime.getOwnedLeaderEpoch,
|
|
468
|
-
isFollowerRegistered:
|
|
469
|
-
telegramBusFollowerRegistrationState.isRegistered,
|
|
468
|
+
isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
|
|
470
469
|
getFollowerGeneration:
|
|
471
470
|
telegramBusFollowerRegistrationState.getGeneration,
|
|
472
471
|
},
|
|
@@ -776,8 +775,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
776
775
|
return telegramActiveBusAuthSecret;
|
|
777
776
|
},
|
|
778
777
|
...forwardedRouteHandlers,
|
|
779
|
-
prepareForwardedMessage:
|
|
780
|
-
textGroupRuntime.prepareForwardedMessage,
|
|
778
|
+
prepareForwardedMessage: textGroupRuntime.prepareForwardedMessage,
|
|
781
779
|
recordRuntimeEvent,
|
|
782
780
|
},
|
|
783
781
|
targetReplacement: {
|
package/lib/bindings.ts
CHANGED
|
@@ -204,15 +204,7 @@ export function registerTelegramCommandsAndTools({
|
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
206
|
await configStore.load();
|
|
207
|
-
|
|
208
|
-
configStore.activateProfile(undefined);
|
|
209
|
-
configStore.set({
|
|
210
|
-
...latestConfig,
|
|
211
|
-
profiles: {
|
|
212
|
-
...(latestConfig.profiles ?? {}),
|
|
213
|
-
[profileName]: profile,
|
|
214
|
-
},
|
|
215
|
-
});
|
|
207
|
+
configStore.setProfile(profileName, profile);
|
|
216
208
|
configStore.activateProfile(profileName);
|
|
217
209
|
await onTransportChanged?.();
|
|
218
210
|
await persistConfig(configStore.get());
|
package/lib/commands.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* Owns Telegram slash-command normalization, bot command metadata, and pi-side command registration behind runtime ports
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
pairTelegramUserIfNeeded,
|
|
9
|
+
TELEGRAM_DEFAULT_PROFILE_NAME,
|
|
10
|
+
} from "./config.ts";
|
|
8
11
|
import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
|
|
9
12
|
import type { TelegramBridgeStatusLineOptions } from "./status.ts";
|
|
10
13
|
import {
|
|
@@ -321,7 +324,7 @@ function parseTelegramProfileArg(args: string): string | undefined {
|
|
|
321
324
|
const word = args.trim().split(/\s+/)[0];
|
|
322
325
|
if (!word || word.length === 0) return undefined;
|
|
323
326
|
if (word.startsWith("-")) return undefined;
|
|
324
|
-
return word;
|
|
327
|
+
return word === TELEGRAM_DEFAULT_PROFILE_NAME ? undefined : word;
|
|
325
328
|
}
|
|
326
329
|
|
|
327
330
|
function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
|
package/lib/config.ts
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
writeFileSync,
|
|
16
16
|
} from "node:fs";
|
|
17
17
|
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
resolveAgentDir,
|
|
20
|
+
resolveTelegramConfigPath,
|
|
21
|
+
TELEGRAM_DEFAULT_PROFILE_NAME,
|
|
22
|
+
} from "./paths.ts";
|
|
23
|
+
export { TELEGRAM_DEFAULT_PROFILE_NAME } from "./paths.ts";
|
|
19
24
|
|
|
20
25
|
import type { CommandTemplateObjectConfig } from "./command-templates.ts";
|
|
21
26
|
import type { TelegramInboundHandlerConfig } from "./inbound.ts";
|
|
@@ -52,10 +57,15 @@ export interface ResolvedTelegramTimeConfig {
|
|
|
52
57
|
export type TelegramAssistantRenderingMode = "rich" | "html";
|
|
53
58
|
|
|
54
59
|
export interface TelegramConfig {
|
|
60
|
+
/** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
|
|
55
61
|
botToken?: string;
|
|
62
|
+
/** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
|
|
56
63
|
botUsername?: string;
|
|
64
|
+
/** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
|
|
57
65
|
botId?: number;
|
|
66
|
+
/** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
|
|
58
67
|
allowedUserId?: number;
|
|
68
|
+
/** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
|
|
59
69
|
lastUpdateId?: number;
|
|
60
70
|
inboundHandlers?: TelegramInboundHandlerConfig[];
|
|
61
71
|
attachmentHandlers?: TelegramInboundHandlerConfig[];
|
|
@@ -77,7 +87,7 @@ export interface TelegramConfig {
|
|
|
77
87
|
sendTranscript?: boolean;
|
|
78
88
|
};
|
|
79
89
|
time?: TelegramTimeConfig;
|
|
80
|
-
/**
|
|
90
|
+
/** Canonical bot/session profiles, including profiles.default. */
|
|
81
91
|
profiles?: Record<string, TelegramBotProfile>;
|
|
82
92
|
}
|
|
83
93
|
|
|
@@ -98,7 +108,6 @@ export interface TelegramBotProfile {
|
|
|
98
108
|
/** Profile names must contain only lowercase ASCII letters and digits; max 32 chars. */
|
|
99
109
|
const TELEGRAM_PROFILE_NAME_PATTERN = /^[a-z0-9]{1,32}$/;
|
|
100
110
|
const TELEGRAM_RESERVED_PROFILE_NAMES: ReadonlySet<string> = new Set([
|
|
101
|
-
"default",
|
|
102
111
|
"main",
|
|
103
112
|
"active",
|
|
104
113
|
]);
|
|
@@ -111,10 +120,8 @@ export function isValidTelegramProfileName(name: string): boolean {
|
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
/**
|
|
114
|
-
* Resolve
|
|
115
|
-
*
|
|
116
|
-
* top-level fields for the default profile. Shared bridge settings
|
|
117
|
-
* always come from the top level.
|
|
123
|
+
* Resolve bot/session identity from the canonical default or named profile.
|
|
124
|
+
* Shared bridge settings always remain top-level.
|
|
118
125
|
*/
|
|
119
126
|
export function resolveTelegramActiveProfile(
|
|
120
127
|
config: TelegramConfig,
|
|
@@ -126,16 +133,9 @@ export function resolveTelegramActiveProfile(
|
|
|
126
133
|
allowedUserId?: number;
|
|
127
134
|
lastUpdateId?: number;
|
|
128
135
|
} {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
botUsername: config.botUsername,
|
|
133
|
-
botId: config.botId,
|
|
134
|
-
allowedUserId: config.allowedUserId,
|
|
135
|
-
lastUpdateId: config.lastUpdateId,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
const profile = config.profiles[profileName];
|
|
136
|
+
const effectiveProfileName = profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
137
|
+
const profile = config.profiles?.[effectiveProfileName];
|
|
138
|
+
if (!profile) return {};
|
|
139
139
|
return {
|
|
140
140
|
botToken: profile.botToken,
|
|
141
141
|
botUsername: profile.botUsername,
|
|
@@ -154,6 +154,7 @@ export interface TelegramConfigStore {
|
|
|
154
154
|
get: () => TelegramConfig;
|
|
155
155
|
getStoredConfig: () => TelegramConfig;
|
|
156
156
|
set: (config: TelegramConfig) => void;
|
|
157
|
+
setProfile: (profileName: string, profile: TelegramBotProfile) => void;
|
|
157
158
|
update: (mutate: (config: TelegramConfig) => void) => void;
|
|
158
159
|
activateProfile: (profileName: string | undefined) => boolean;
|
|
159
160
|
getActiveProfileName: () => string | undefined;
|
|
@@ -396,10 +397,91 @@ export function getTelegramProfileFields(
|
|
|
396
397
|
if (!token) return undefined;
|
|
397
398
|
return {
|
|
398
399
|
botToken: token,
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
400
|
+
...(config.botUsername !== undefined
|
|
401
|
+
? { botUsername: config.botUsername }
|
|
402
|
+
: {}),
|
|
403
|
+
...(config.botId !== undefined ? { botId: config.botId } : {}),
|
|
404
|
+
...(config.allowedUserId !== undefined
|
|
405
|
+
? { allowedUserId: config.allowedUserId }
|
|
406
|
+
: {}),
|
|
407
|
+
...(config.lastUpdateId !== undefined
|
|
408
|
+
? { lastUpdateId: config.lastUpdateId }
|
|
409
|
+
: {}),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
|
|
414
|
+
const {
|
|
415
|
+
botToken: _botToken,
|
|
416
|
+
botUsername: _botUsername,
|
|
417
|
+
botId: _botId,
|
|
418
|
+
allowedUserId: _allowedUserId,
|
|
419
|
+
lastUpdateId: _lastUpdateId,
|
|
420
|
+
...sharedConfig
|
|
421
|
+
} = config;
|
|
422
|
+
return sharedConfig;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
|
|
426
|
+
config: TelegramConfig;
|
|
427
|
+
changed: boolean;
|
|
428
|
+
} {
|
|
429
|
+
const hasLegacyRootProfile = [
|
|
430
|
+
"botToken",
|
|
431
|
+
"botUsername",
|
|
432
|
+
"botId",
|
|
433
|
+
"allowedUserId",
|
|
434
|
+
"lastUpdateId",
|
|
435
|
+
].some((field) => Object.hasOwn(config, field));
|
|
436
|
+
if (!hasLegacyRootProfile) return { config, changed: false };
|
|
437
|
+
const canonicalProfile = config.profiles?.[TELEGRAM_DEFAULT_PROFILE_NAME];
|
|
438
|
+
const legacyToken = config.botToken?.trim();
|
|
439
|
+
if (Object.hasOwn(config, "botToken") && !legacyToken) {
|
|
440
|
+
throw new Error("Legacy Telegram default profile has no bot token");
|
|
441
|
+
}
|
|
442
|
+
const legacyProfile: Partial<TelegramBotProfile> = {
|
|
443
|
+
...(legacyToken ? { botToken: legacyToken } : {}),
|
|
444
|
+
...(config.botUsername !== undefined
|
|
445
|
+
? { botUsername: config.botUsername }
|
|
446
|
+
: {}),
|
|
447
|
+
...(config.botId !== undefined ? { botId: config.botId } : {}),
|
|
448
|
+
...(config.allowedUserId !== undefined
|
|
449
|
+
? { allowedUserId: config.allowedUserId }
|
|
450
|
+
: {}),
|
|
451
|
+
...(config.lastUpdateId !== undefined
|
|
452
|
+
? { lastUpdateId: config.lastUpdateId }
|
|
453
|
+
: {}),
|
|
454
|
+
};
|
|
455
|
+
if (!canonicalProfile && !legacyToken) {
|
|
456
|
+
throw new Error("Legacy Telegram default profile has no bot token");
|
|
457
|
+
}
|
|
458
|
+
const hasConflict = canonicalProfile
|
|
459
|
+
? Object.entries(legacyProfile).some(
|
|
460
|
+
([field, value]) =>
|
|
461
|
+
Object.hasOwn(canonicalProfile, field) &&
|
|
462
|
+
!configValuesEqual(
|
|
463
|
+
canonicalProfile[field as keyof TelegramBotProfile],
|
|
464
|
+
value,
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
: false;
|
|
468
|
+
if (hasConflict) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
"Conflicting Telegram default profile identity at root and profiles.default",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const normalizedProfile: TelegramBotProfile = canonicalProfile
|
|
474
|
+
? { ...legacyProfile, ...canonicalProfile }
|
|
475
|
+
: (legacyProfile as TelegramBotProfile);
|
|
476
|
+
return {
|
|
477
|
+
config: {
|
|
478
|
+
...omitTelegramRootProfileFields(config),
|
|
479
|
+
profiles: {
|
|
480
|
+
...(config.profiles ?? {}),
|
|
481
|
+
[TELEGRAM_DEFAULT_PROFILE_NAME]: normalizedProfile,
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
changed: true,
|
|
403
485
|
};
|
|
404
486
|
}
|
|
405
487
|
|
|
@@ -407,16 +489,12 @@ function applyTelegramProfile(
|
|
|
407
489
|
config: TelegramConfig,
|
|
408
490
|
profileName: string | undefined,
|
|
409
491
|
): TelegramConfig {
|
|
410
|
-
|
|
411
|
-
const profile = config.profiles?.[
|
|
412
|
-
if (!profile) return config;
|
|
492
|
+
const effectiveProfileName = profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
493
|
+
const profile = config.profiles?.[effectiveProfileName];
|
|
494
|
+
if (!profile) return omitTelegramRootProfileFields(config);
|
|
413
495
|
return {
|
|
414
|
-
...config,
|
|
415
|
-
|
|
416
|
-
botUsername: profile.botUsername,
|
|
417
|
-
botId: profile.botId,
|
|
418
|
-
allowedUserId: profile.allowedUserId,
|
|
419
|
-
lastUpdateId: profile.lastUpdateId,
|
|
496
|
+
...omitTelegramRootProfileFields(config),
|
|
497
|
+
...profile,
|
|
420
498
|
};
|
|
421
499
|
}
|
|
422
500
|
|
|
@@ -425,18 +503,13 @@ function storeTelegramEffectiveConfig(
|
|
|
425
503
|
nextConfig: TelegramConfig,
|
|
426
504
|
profileName: string | undefined,
|
|
427
505
|
): TelegramConfig {
|
|
428
|
-
|
|
506
|
+
const effectiveProfileName = profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
|
|
429
507
|
const profile = getTelegramProfileFields(nextConfig);
|
|
430
508
|
const profiles = { ...(baseConfig.profiles ?? {}) };
|
|
431
|
-
if (profile) profiles[
|
|
432
|
-
else delete profiles[
|
|
509
|
+
if (profile) profiles[effectiveProfileName] = profile;
|
|
510
|
+
else delete profiles[effectiveProfileName];
|
|
433
511
|
return {
|
|
434
|
-
...nextConfig,
|
|
435
|
-
botToken: baseConfig.botToken,
|
|
436
|
-
botUsername: baseConfig.botUsername,
|
|
437
|
-
botId: baseConfig.botId,
|
|
438
|
-
allowedUserId: baseConfig.allowedUserId,
|
|
439
|
-
lastUpdateId: baseConfig.lastUpdateId,
|
|
512
|
+
...omitTelegramRootProfileFields(nextConfig),
|
|
440
513
|
profiles: Object.keys(profiles).length > 0 ? profiles : undefined,
|
|
441
514
|
};
|
|
442
515
|
}
|
|
@@ -444,7 +517,9 @@ function storeTelegramEffectiveConfig(
|
|
|
444
517
|
export function createTelegramConfigStore(
|
|
445
518
|
options: TelegramConfigStoreOptions = {},
|
|
446
519
|
): TelegramConfigStore {
|
|
447
|
-
let config: TelegramConfig =
|
|
520
|
+
let config: TelegramConfig = normalizeTelegramDefaultProfileConfig(
|
|
521
|
+
cloneTelegramConfig(options.initialConfig ?? {}),
|
|
522
|
+
).config;
|
|
448
523
|
let persistedConfig: TelegramConfig = {};
|
|
449
524
|
let mutationVersion = 0;
|
|
450
525
|
let persistQueue: Promise<void> = Promise.resolve();
|
|
@@ -465,14 +540,33 @@ export function createTelegramConfigStore(
|
|
|
465
540
|
get: getEffectiveConfig,
|
|
466
541
|
getStoredConfig: () => config,
|
|
467
542
|
set: setEffectiveConfig,
|
|
543
|
+
setProfile: (profileName, profile) => {
|
|
544
|
+
config = {
|
|
545
|
+
...omitTelegramRootProfileFields(config),
|
|
546
|
+
profiles: {
|
|
547
|
+
...(config.profiles ?? {}),
|
|
548
|
+
[profileName]: cloneTelegramConfig(profile),
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
mutationVersion += 1;
|
|
552
|
+
},
|
|
468
553
|
update: (mutate) => {
|
|
469
554
|
const nextConfig = getEffectiveConfig();
|
|
470
555
|
mutate(nextConfig);
|
|
471
556
|
setEffectiveConfig(nextConfig);
|
|
472
557
|
},
|
|
473
558
|
activateProfile: (profileName) => {
|
|
474
|
-
|
|
475
|
-
|
|
559
|
+
const normalizedProfileName =
|
|
560
|
+
!profileName || profileName === TELEGRAM_DEFAULT_PROFILE_NAME
|
|
561
|
+
? undefined
|
|
562
|
+
: profileName;
|
|
563
|
+
if (
|
|
564
|
+
normalizedProfileName &&
|
|
565
|
+
!config.profiles?.[normalizedProfileName]
|
|
566
|
+
) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
activeProfileName = normalizedProfileName;
|
|
476
570
|
return true;
|
|
477
571
|
},
|
|
478
572
|
getActiveProfileName: () => activeProfileName,
|
|
@@ -491,7 +585,7 @@ export function createTelegramConfigStore(
|
|
|
491
585
|
setEffectiveConfig(nextConfig);
|
|
492
586
|
},
|
|
493
587
|
load: async () => {
|
|
494
|
-
|
|
588
|
+
const loadedConfig = await readTelegramConfig(configPath, {
|
|
495
589
|
onInvalidConfig: (recovery) => {
|
|
496
590
|
options.recordRuntimeEvent?.("config", recovery.error, {
|
|
497
591
|
phase: "load",
|
|
@@ -500,6 +594,32 @@ export function createTelegramConfigStore(
|
|
|
500
594
|
});
|
|
501
595
|
},
|
|
502
596
|
});
|
|
597
|
+
let normalized: ReturnType<typeof normalizeTelegramDefaultProfileConfig>;
|
|
598
|
+
try {
|
|
599
|
+
normalized = normalizeTelegramDefaultProfileConfig(loadedConfig);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
options.recordRuntimeEvent?.("config", error, {
|
|
602
|
+
phase: "default-profile-normalize",
|
|
603
|
+
configPath,
|
|
604
|
+
});
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
config = normalized.changed
|
|
608
|
+
? withTelegramFileTransaction(`${configPath}.transaction`, () => {
|
|
609
|
+
const latestConfig = readTelegramConfigForTransaction(configPath);
|
|
610
|
+
const latestNormalized = normalizeTelegramDefaultProfileConfig(
|
|
611
|
+
latestConfig,
|
|
612
|
+
);
|
|
613
|
+
if (latestNormalized.changed) {
|
|
614
|
+
writeTelegramConfigInTransaction(
|
|
615
|
+
agentDir,
|
|
616
|
+
configPath,
|
|
617
|
+
latestNormalized.config,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return latestNormalized.config;
|
|
621
|
+
})
|
|
622
|
+
: normalized.config;
|
|
503
623
|
persistedConfig = cloneTelegramConfig(config);
|
|
504
624
|
mutationVersion += 1;
|
|
505
625
|
},
|
package/lib/locks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram singleton lock helpers
|
|
3
|
-
* Zones:
|
|
4
|
-
* Owns
|
|
3
|
+
* Zones: telegram ownership, filesystem, transport authority
|
|
4
|
+
* Owns extension-local owners.json access and Telegram bridge ownership semantics
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
@@ -19,9 +19,9 @@ import {
|
|
|
19
19
|
} from "node:fs";
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
21
|
import { basename, dirname, join } from "node:path";
|
|
22
|
-
import {
|
|
22
|
+
import { resolveTelegramOwnersPath } from "./paths.ts";
|
|
23
23
|
|
|
24
|
-
export const TELEGRAM_LOCK_KEY = "
|
|
24
|
+
export const TELEGRAM_LOCK_KEY = "default";
|
|
25
25
|
export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 5_000;
|
|
26
26
|
const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
|
|
27
27
|
const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
|
|
@@ -42,18 +42,17 @@ function allocateTelegramLockRuntimeGeneration(): number {
|
|
|
42
42
|
return generation;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function
|
|
46
|
-
return
|
|
45
|
+
function getOwnersPath(): string {
|
|
46
|
+
return resolveTelegramOwnersPath();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Resolve the
|
|
51
|
-
* Default profile →
|
|
52
|
-
* Named profile →
|
|
50
|
+
* Resolve the extension-local owner slot for the active Telegram profile.
|
|
51
|
+
* Default profile → default
|
|
52
|
+
* Named profile → the validated profile name
|
|
53
53
|
*/
|
|
54
54
|
export function resolveTelegramLockKey(activeProfile?: string): string {
|
|
55
|
-
|
|
56
|
-
return TELEGRAM_LOCK_KEY;
|
|
55
|
+
return activeProfile || TELEGRAM_LOCK_KEY;
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
export interface TelegramActiveProfileGetter {
|
|
@@ -139,7 +138,7 @@ export interface TelegramLockRuntimeOptions {
|
|
|
139
138
|
staleHeartbeatMs?: number;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
export function readLocks(path =
|
|
141
|
+
export function readLocks(path = getOwnersPath()): Record<string, unknown> {
|
|
143
142
|
if (!existsSync(path)) return {};
|
|
144
143
|
try {
|
|
145
144
|
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -161,7 +160,7 @@ function readLocksForTransaction(path: string): Record<string, unknown> {
|
|
|
161
160
|
}
|
|
162
161
|
const value: unknown = JSON.parse(source);
|
|
163
162
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
164
|
-
throw new Error(`Invalid Telegram
|
|
163
|
+
throw new Error(`Invalid Telegram owner store: ${path}`);
|
|
165
164
|
}
|
|
166
165
|
return value as Record<string, unknown>;
|
|
167
166
|
}
|
|
@@ -856,7 +855,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
856
855
|
options: TelegramLockRuntimeOptions = {},
|
|
857
856
|
): TelegramLockRuntime<TContext> {
|
|
858
857
|
const key = options.key ?? TELEGRAM_LOCK_KEY;
|
|
859
|
-
const locksPath = options.locksPath ??
|
|
858
|
+
const locksPath = options.locksPath ?? getOwnersPath();
|
|
860
859
|
const pid = options.pid ?? process.pid;
|
|
861
860
|
const isAlive = options.isProcessAlive ?? isProcessAlive;
|
|
862
861
|
const getNowMs = options.getNowMs ?? Date.now;
|
package/lib/menu-settings.ts
CHANGED
|
@@ -74,7 +74,11 @@ export interface TelegramSettingsMenuRuntime<TContext> {
|
|
|
74
74
|
query: {
|
|
75
75
|
id: string;
|
|
76
76
|
data?: string;
|
|
77
|
-
message?: {
|
|
77
|
+
message?: {
|
|
78
|
+
message_id?: number;
|
|
79
|
+
message_thread_id?: number;
|
|
80
|
+
chat?: { id?: number };
|
|
81
|
+
};
|
|
78
82
|
},
|
|
79
83
|
ctx: TContext,
|
|
80
84
|
) => Promise<boolean>;
|
|
@@ -98,6 +102,7 @@ export interface TelegramSettingsMenuRuntimeDeps<
|
|
|
98
102
|
getModelMenuState: (
|
|
99
103
|
chatId: number,
|
|
100
104
|
ctx: TContext,
|
|
105
|
+
threadId?: number,
|
|
101
106
|
) => Promise<TelegramModelMenuState<TModel>>;
|
|
102
107
|
getStoredModelMenuState: (
|
|
103
108
|
messageId: number | undefined,
|
|
@@ -672,7 +677,11 @@ export function createTelegramSettingsMenuRuntime<
|
|
|
672
677
|
);
|
|
673
678
|
return true;
|
|
674
679
|
}
|
|
675
|
-
state = await deps.getModelMenuState(
|
|
680
|
+
state = await deps.getModelMenuState(
|
|
681
|
+
chatId,
|
|
682
|
+
ctx,
|
|
683
|
+
query.message?.message_thread_id,
|
|
684
|
+
);
|
|
676
685
|
state.messageId = messageId;
|
|
677
686
|
state.mode = "settings";
|
|
678
687
|
deps.storeModelMenuState(state);
|
package/lib/paths.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join, resolve } from "node:path";
|
|
12
12
|
|
|
13
|
+
export const TELEGRAM_DEFAULT_PROFILE_NAME = "default";
|
|
14
|
+
|
|
13
15
|
export interface TelegramAgentDirResolutionInput {
|
|
14
16
|
env?: Partial<Pick<NodeJS.ProcessEnv, "PI_CODING_AGENT_DIR">>;
|
|
15
17
|
execPath?: string;
|
|
@@ -45,18 +47,19 @@ export function resolveTelegramConfigPath(): string {
|
|
|
45
47
|
return join(resolveAgentDir(), "telegram.json");
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
/** Telegram singleton lock file (<agentDir>/locks.json). */
|
|
49
|
-
export function resolveTelegramLocksPath(): string {
|
|
50
|
-
return join(resolveAgentDir(), "locks.json");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
50
|
/** Telegram bridge temporary directory (<agentDir>/tmp/telegram). */
|
|
54
51
|
export function resolveTelegramTempDir(agentDir = resolveAgentDir()): string {
|
|
55
52
|
return join(agentDir, "tmp", "telegram");
|
|
56
53
|
}
|
|
57
54
|
|
|
55
|
+
/** Telegram transport ownership store (<agentDir>/tmp/telegram/owners.json). */
|
|
56
|
+
export function resolveTelegramOwnersPath(): string {
|
|
57
|
+
return join(resolveTelegramTempDir(), "owners.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
58
60
|
export function getTelegramProfilePathSuffix(profileName?: string): string {
|
|
59
|
-
|
|
61
|
+
if (!profileName || profileName === TELEGRAM_DEFAULT_PROFILE_NAME) return "";
|
|
62
|
+
return `.${profileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
export function resolveTelegramProfileTempFilePath(
|
package/lib/status.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Builds usage, cost, and context summaries for the interactive Telegram status view
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
|
|
8
|
+
|
|
7
9
|
export type TelegramStatusQueueLane = "control" | "priority" | "default";
|
|
8
10
|
|
|
9
11
|
export interface TelegramUsageStats {
|
|
@@ -624,7 +626,9 @@ export function createTelegramBridgeStatusRuntime<
|
|
|
624
626
|
getBridgeStatusLineState: () => {
|
|
625
627
|
const config = deps.getConfig();
|
|
626
628
|
const botThreadMode = deps.getBotThreadMode?.();
|
|
627
|
-
const activeProfileName = deps.getActiveProfileName
|
|
629
|
+
const activeProfileName = deps.getActiveProfileName
|
|
630
|
+
? (deps.getActiveProfileName() ?? TELEGRAM_STATUS_DEFAULT_PROFILE_NAME)
|
|
631
|
+
: undefined;
|
|
628
632
|
return {
|
|
629
633
|
hasBotToken: Boolean(config.botToken),
|
|
630
634
|
botUsername: config.botUsername,
|
|
@@ -1066,13 +1070,16 @@ function buildTelegramBridgeCompactStatusLines(
|
|
|
1066
1070
|
: state.activeSourceMessageIds?.length
|
|
1067
1071
|
? "active"
|
|
1068
1072
|
: "idle";
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1073
|
+
const diagnosticsProfileName =
|
|
1074
|
+
state.activeProfileName === TELEGRAM_STATUS_DEFAULT_PROFILE_NAME
|
|
1075
|
+
? undefined
|
|
1076
|
+
: state.activeProfileName;
|
|
1077
|
+
const profileSuffix = diagnosticsProfileName
|
|
1078
|
+
? `.${diagnosticsProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
|
|
1071
1079
|
: "";
|
|
1072
|
-
const profileSlug = profileSuffix.slice(1);
|
|
1073
1080
|
const diagnosticsPaths = state.diagnosticPaths ?? {
|
|
1074
1081
|
state: `~/.pi/agent/tmp/telegram/state${profileSuffix}.json`,
|
|
1075
|
-
logs: `~/.pi/agent/tmp/telegram/logs${
|
|
1082
|
+
logs: `~/.pi/agent/tmp/telegram/logs${profileSuffix}.jsonl`,
|
|
1076
1083
|
};
|
|
1077
1084
|
return [
|
|
1078
1085
|
"connection:",
|
package/package.json
CHANGED
package/docs/locks.md
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
# Extension Locks Standard
|
|
2
|
-
|
|
3
|
-
**Meta-contract:** transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
`locks.json` is a shared registry for singleton pi extensions.
|
|
8
|
-
|
|
9
|
-
Path:
|
|
10
|
-
|
|
11
|
-
```text
|
|
12
|
-
~/.pi/agent/locks.json
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Shape
|
|
16
|
-
|
|
17
|
-
```json
|
|
18
|
-
{
|
|
19
|
-
"@scope/pi-singleton": {
|
|
20
|
-
"pid": 2590864,
|
|
21
|
-
"cwd": "/home/user/project"
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Top-level keys are extension identities. Values are JSON objects owned by that extension.
|
|
27
|
-
|
|
28
|
-
## Identity key
|
|
29
|
-
|
|
30
|
-
Use the most stable available identity:
|
|
31
|
-
|
|
32
|
-
1. `package.json/name` for npm-style pi packages
|
|
33
|
-
2. Directory name when the extension entrypoint is `index.ts` but there is no package name
|
|
34
|
-
3. File basename when the extension is a single file
|
|
35
|
-
|
|
36
|
-
For npm-style package extensions, the canonical value is the `package.json` `name`. Implementations may keep that value as a small local constant when it is clearer than runtime package introspection. The fallback rules are only for unpackaged extensions.
|
|
37
|
-
|
|
38
|
-
Examples:
|
|
39
|
-
|
|
40
|
-
```text
|
|
41
|
-
extensions/pi-singleton/package.json name=@scope/pi-singleton -> @scope/pi-singleton
|
|
42
|
-
extensions/pi-singleton/index.ts without package.json -> pi-singleton
|
|
43
|
-
extensions/pi-singleton.ts -> pi-singleton
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## Required fields
|
|
47
|
-
|
|
48
|
-
```json
|
|
49
|
-
{
|
|
50
|
-
"pid": 2590864
|
|
51
|
-
}
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
`pid` is the process that currently owns the singleton runtime. `cwd` should be stored when ownership is tied to a pi session directory.
|
|
55
|
-
|
|
56
|
-
During a user-initiated start/connect event, an extension should:
|
|
57
|
-
|
|
58
|
-
1. Read its lock entry
|
|
59
|
-
2. If `pid` is stale, replace the entry
|
|
60
|
-
3. If `pid` and `cwd` match the current pi instance, refresh or keep the entry
|
|
61
|
-
4. If a live external owner exists, ask interactively whether to move singleton ownership here
|
|
62
|
-
|
|
63
|
-
## Acquisition timing
|
|
64
|
-
|
|
65
|
-
Lock writes must be caused by an explicit user-initiated runtime event, such as a start/connect command or a confirmed takeover prompt.
|
|
66
|
-
|
|
67
|
-
Extension initialization and session-start hooks may read `locks.json`, update local status, install ownership watchers, and resume local work when the existing lock already points at the current `pid`/`cwd`. After a full process restart, a session-start hook may replace a stale lock from the same `cwd` to restore explicitly requested ownership. They must not create ownership from an inactive lock, take over a live external owner, or replace a stale lock from another directory by themselves. Such locks should stay visible as state until the user runs the start/connect command. Session replacement should suspend local runtime work and ownership watchers without releasing the lock, so the next session in the same `pid`/`cwd` can resume from explicit ownership.
|
|
68
|
-
|
|
69
|
-
## Optional fields
|
|
70
|
-
|
|
71
|
-
Extensions may add compact fields when useful:
|
|
72
|
-
|
|
73
|
-
```json
|
|
74
|
-
{
|
|
75
|
-
"pid": 2590864,
|
|
76
|
-
"cwd": "/repo/project",
|
|
77
|
-
"mode": "connected",
|
|
78
|
-
"updatedAt": "2026-04-28T00:00:00.000Z"
|
|
79
|
-
}
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Do not print optional fields in normal UI unless they help the user act.
|
|
83
|
-
|
|
84
|
-
## Ownership rules
|
|
85
|
-
|
|
86
|
-
- One top-level key per singleton extension
|
|
87
|
-
- An extension may only mutate its own key
|
|
88
|
-
- Other keys must be preserved exactly
|
|
89
|
-
- If `cwd` is present, active-here ownership means both `pid` and `cwd` match the current pi instance
|
|
90
|
-
- Human-readable diagnostics should say `active here`, `active elsewhere`, or `stale`
|
|
91
|
-
- Debug data belongs in `locks.json`, not in normal status output
|
|
92
|
-
|
|
93
|
-
## Runtime status
|
|
94
|
-
|
|
95
|
-
Singleton extensions with footer/status presence should expose quiet but explicit local state:
|
|
96
|
-
|
|
97
|
-
- `off` when this pi instance does not own the singleton runtime
|
|
98
|
-
- `on` when this pi instance owns the runtime but has no pending runtime detail to show
|
|
99
|
-
- `[16:32:39]` when the runtime owns scheduled work and can show the next countdown
|
|
100
|
-
|
|
101
|
-
Extensions may prefix active states with their own compact name, such as `telegram on` or `wakeup [00:10:00]`. Quiet idle states may be hidden when status-line width is more valuable than an explicit off marker.
|
|
102
|
-
|
|
103
|
-
## Interactive takeover
|
|
104
|
-
|
|
105
|
-
Start/connect commands should make singleton moves easy:
|
|
106
|
-
|
|
107
|
-
1. If no live owner exists, take ownership without an extra prompt
|
|
108
|
-
2. If a live external owner exists, ask whether to move singleton ownership to this pi instance
|
|
109
|
-
3. On confirmation, write the current `{ "pid": ..., "cwd": ... }` to this extension's key in `locks.json`
|
|
110
|
-
4. The previous owner must notice that `locks.json` no longer points at its own `pid`/`cwd` and stop local runtime work without deleting the new lock
|
|
111
|
-
|
|
112
|
-
Takeover prompts should use the extension name as the dialog title, then the question, a blank line, and source/target lines:
|
|
113
|
-
|
|
114
|
-
```text
|
|
115
|
-
pi-singleton
|
|
116
|
-
move singleton lock here?
|
|
117
|
-
|
|
118
|
-
from: pid 2590864, cwd /old
|
|
119
|
-
to: /new
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Avoid repeating the extension name in the body. Color is encouraged: extension title/name accent, question warning, `from:`/`to:` muted.
|
|
123
|
-
|
|
124
|
-
The previous owner may use `fs.watch`, mtime polling, or an existing status/timer tick. Long-lived watchers should compare against a snapshotted `pid`/`cwd` identity rather than a live pi context object, because session replacement such as `/new` makes captured contexts stale. The important contract is graceful local shutdown after ownership mismatch.
|
|
125
|
-
|
|
126
|
-
## Reset
|
|
127
|
-
|
|
128
|
-
Delete `~/.pi/agent/locks.json` to reset singleton runtime ownership for all participating extensions without deleting their configuration files.
|
|
129
|
-
|
|
130
|
-
## Atomicity
|
|
131
|
-
|
|
132
|
-
`locks.json` is one shared registry, so preserving unrelated keys in memory is not sufficient. Every writer must serialize the complete cross-process read/check/write transaction through the same guard. Otherwise two extensions can read the same snapshot, update different keys, and publish snapshots that erase one another.
|
|
133
|
-
|
|
134
|
-
The canonical guard path is:
|
|
135
|
-
|
|
136
|
-
```text
|
|
137
|
-
~/.pi/agent/locks.json.transaction
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
All participating extensions must follow one compatible protocol:
|
|
141
|
-
|
|
142
|
-
- Acquire the guard before every ownership acquisition, refresh, release, takeover, or other registry mutation.
|
|
143
|
-
- Publish fully initialized private owner metadata atomically. A portable implementation may stage a non-empty directory containing `owner.<generation>.json`, require filename/payload generation agreement, and rename that directory into the stable guard path.
|
|
144
|
-
- Do not depend on hard links or platform-specific advisory locks; the protocol must work on Linux, macOS, native Windows, and Android/Termux filesystems supported by Pi.
|
|
145
|
-
- Read and validate the latest complete registry only after guard acquisition, change only the owned extension key, and preserve every unrelated key from that guarded snapshot.
|
|
146
|
-
- Publish the JSON payload through a same-directory temporary file and atomic rename. Atomic payload replacement prevents torn JSON but does not replace transaction serialization.
|
|
147
|
-
- Release only the exact acquired owner by atomically renaming the stable guard away before cleanup. Stale recovery must prove the observed owner process is dead and must fence delayed recovery against replacement-owner ABA races.
|
|
148
|
-
- Fail closed on malformed owner metadata, malformed registry state, unverifiable generations, contention timeout, or unsupported atomic filesystem behavior.
|
|
149
|
-
|
|
150
|
-
Lock-free reads remain appropriate for status display when readers tolerate an old-or-new complete snapshot. Any decision that mutates shared ownership must re-read and validate under the transaction.
|
|
151
|
-
|
|
152
|
-
Cross-writer safety is compositional: every writer targeting the same registry must participate in the protocol. One compliant writer cannot guarantee lost-update safety against another writer that bypasses the shared transaction.
|
|
153
|
-
|
|
154
|
-
## Migration
|
|
155
|
-
|
|
156
|
-
Migrations from legacy lock files or legacy keys should be one-off cleanup work. Runtime ownership should read and write only `locks.json` under the canonical identity key.
|