@llblab/pi-telegram 0.23.3 → 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 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
- - The Telegram lock is always the transport ownership lock. Every shared `locks.json` mutation must serialize the complete cross-process read/check/write transaction, preserve unrelated registry keys, and fail closed on transaction contention; atomic payload replacement alone does not make ownership linearizable. Refresh, release, takeover, and irreversible leader work must 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 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.
81
- - Shared filesystem 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. Legacy regular-file recovery remains separately serialized through recoverable directory guards. New guards must not depend on hard links, direct file rename-overwrite, incomplete create-then-write state, or age-only recovery. Legacy regular-file guards may be read only for bounded upgrade recovery; malformed or unsupported guards fail closed.
82
- - Telegram profiles are parallel bot/runtime realities, not merely alternate credentials. Shared bridge settings remain global, but any lock, 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. Preserve default-profile legacy paths/keys for compatibility, and give named profiles isolated observable surfaces unless there is an explicit top-level shared-config reason not to.
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 (`locks.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.
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,12 @@
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
+
3
10
  ## 0.23.3: Thread-Scoped Settings Hotfix
4
11
 
5
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.
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`. Named profiles are optional; the ordinary `/telegram-setup` and `/telegram-connect` flow keeps using the default profile. 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.
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 unnamed default profile keeps legacy 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.
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 the default bot token |
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 the default profile and acquire its transport ownership |
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
@@ -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`: serialized singleton lock storage, exact-owner epoch exposure, process-global reload generations, lock-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.
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`. Polling ownership lives separately in `~/.pi/agent/locks.json` under `@llblab/pi-telegram`.
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 singleton polling ownership before polling starts.
121
- - `/telegram-disconnect` stops polling and releases ownership. In Threaded Mode it 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.
122
- - Session start schedules Telegram polling resume asynchronously only when the existing lock already points at the current `pid`/`cwd`, or when a stale same-`cwd` lock can be safely replaced after process restart. Startup and `/resume` should not wait on Telegram leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
123
- - Pi `print`/`json` run modes stay passive: they do not start or resume Telegram polling even if a lock is present. Older Pi runtimes without `ctx.mode` keep the previous compatibility behavior.
124
- - Inherited child sessions that see the same `telegram.json` but do not own the `pid`/`cwd` lock must not auto-start polling or call `getUpdates` unless the operator force-takes ownership.
125
- - Session replacement suspends polling/watchers without releasing ownership so the next session-start hook 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 automatically re-registers the new session context through the live leader without marking or replacing its Telegram thread.
126
- - Live polling owners require explicit takeover confirmation.
127
- - Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
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
- Deleting `locks.json` resets runtime ownership without deleting Telegram configuration.
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 singleton locks, diagnostics logs, state files, thread/bus owner keys, 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 profile. A different selected profile is a parallel bot runtime: its locks, `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 from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
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; profile bot/session fields and 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.
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 lock meaning:
74
+ Classic ownership meaning:
75
75
 
76
76
  ```text
77
- locks.json / @llblab/pi-telegram -> polling/control owner
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
- locks.json / @llblab/pi-telegram -> bus leader identity + heartbeat
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
- - Shared lock/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.
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
- "botToken": "...",
331
- "allowedUserId": 123456789
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
- - `locks.json`: current bus leader identity, capability secret, heartbeat, and cleanup fencing epoch. The local bus endpoint is derived from the agent directory by default; legacy `busSocketPath` entries are tolerated but are not required.
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 lock 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.
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.
@@ -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 TelegramConfig {
82
- botToken?: string;
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
- - [Extension Locks](../docs/locks.md) (polling): same identity key rules (`package.json/name` canonical id)
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 `locks.json` singleton lock documented in [Locks](./locks.md). When the locked polling runtime stops `pi-telegram`'s `getUpdates` loop, for example after ownership is moved to another pi process, handlers stop receiving updates because no updates are being fetched. They are not unregistered.
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/lib/bindings.ts CHANGED
@@ -204,15 +204,7 @@ export function registerTelegramCommandsAndTools({
204
204
  );
205
205
  }
206
206
  await configStore.load();
207
- const latestConfig = configStore.getStoredConfig();
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 { pairTelegramUserIfNeeded } from "./config.ts";
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 { resolveAgentDir, resolveTelegramConfigPath } from "./paths.ts";
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
- /** Named bot/session profiles (e.g. "work", "omp"). */
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 the effective config for a named (or default) profile.
115
- * Returns bot/session fields from the named profile, falling back to
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
- if (!profileName || !config.profiles?.[profileName]) {
130
- return {
131
- botToken: config.botToken,
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
- botUsername: config.botUsername,
400
- botId: config.botId,
401
- allowedUserId: config.allowedUserId,
402
- lastUpdateId: config.lastUpdateId,
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
- if (!profileName) return config;
411
- const profile = config.profiles?.[profileName];
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
- botToken: profile.botToken,
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
- if (!profileName) return nextConfig;
506
+ const effectiveProfileName = profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
429
507
  const profile = getTelegramProfileFields(nextConfig);
430
508
  const profiles = { ...(baseConfig.profiles ?? {}) };
431
- if (profile) profiles[profileName] = profile;
432
- else delete profiles[profileName];
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 = cloneTelegramConfig(options.initialConfig ?? {});
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
- if (profileName && !config.profiles?.[profileName]) return false;
475
- activeProfileName = profileName;
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
- config = await readTelegramConfig(configPath, {
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: shared singleton, filesystem, telegram runtime ownership
4
- * Owns shared locks.json access and Telegram bridge ownership semantics
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 { resolveTelegramLocksPath } from "./paths.ts";
22
+ import { resolveTelegramOwnersPath } from "./paths.ts";
23
23
 
24
- export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
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 getLocksPath(): string {
46
- return resolveTelegramLocksPath();
45
+ function getOwnersPath(): string {
46
+ return resolveTelegramOwnersPath();
47
47
  }
48
48
 
49
49
  /**
50
- * Resolve the scoped lock key for the active Telegram profile.
51
- * Default profile → @llblab/pi-telegram
52
- * Named profile → @llblab/pi-telegram:<name>
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
- if (activeProfile) return `${TELEGRAM_LOCK_KEY}:${activeProfile}`;
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 = getLocksPath()): Record<string, unknown> {
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 lock registry: ${path}`);
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 ?? getLocksPath();
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/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
- return profileName ? `.${profileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}` : "";
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 profileSuffix = state.activeProfileName
1070
- ? `.${state.activeProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
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${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
1082
+ logs: `~/.pi/agent/tmp/telegram/logs${profileSuffix}.jsonl`,
1076
1083
  };
1077
1084
  return [
1078
1085
  "connection:",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.23.3",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
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.