@llblab/pi-telegram 0.3.0 → 0.4.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/README.md CHANGED
@@ -18,7 +18,7 @@ This repository is an actively maintained fork of [`badlogic/pi-telegram`](https
18
18
  - **In-flight Model Switching**: Change the active model mid-generation. The agent gracefully pauses, applies the new model, and restarts its response without losing context.
19
19
  - **Smart Message Queue**: Messages sent while the agent is busy are queued and previewed in the pi status bar, and queued turns can be reprioritized or removed with Telegram reactions.
20
20
  - **Mobile-Optimized Rendering**: Tables and lists are formatted for narrow screens, table padding accounts for emoji grapheme and wide Unicode display width, and Telegram-originated runs prompt the assistant to prefer narrow table columns for phone readability. Markdown is correctly parsed and split to fit Telegram's limits without breaking HTML structures or code blocks, block spacing stays faithful to the original Markdown with readable heading separation, supported absolute links stay clickable, and unsupported link forms degrade safely.
21
- - **File Handling & Attachments**: Send images and files to the agent, or ask it to generate and return artifacts. Inbound downloads and outbound attachments are size-limited by default, and outbound files are delivered automatically via the `telegram_attach` tool.
21
+ - **File Handling & Attachments**: Send images and files to the agent, transcribe or transform inbound files with configured attachment handlers, or ask pi to generate and return artifacts. Inbound downloads and outbound attachments are size-limited by default, and outbound files are delivered automatically via the `telegram_attach` tool.
22
22
  - **Streaming Responses**: Closed Markdown blocks stream back as rich Telegram HTML while pi is generating, and the still-growing tail stays readable until the final fully rendered reply lands.
23
23
 
24
24
  ## Install
@@ -60,7 +60,7 @@ Paste your bot token when prompted. If a bot token is already saved in `~/.pi/ag
60
60
  /telegram-connect
61
61
  ```
62
62
 
63
- The bridge is session-local. Only one pi session should be connected to the bot at a time.
63
+ The bridge is session-local: only one pi instance polls Telegram at a time. `/telegram-connect` records polling ownership in `~/.pi/agent/locks.json`; live ownership moves require confirmation, while `/new` and same-`cwd` process restarts resume automatically.
64
64
 
65
65
  ### 4. Pair your account from Telegram
66
66
 
@@ -92,8 +92,8 @@ Run these inside pi, not Telegram:
92
92
 
93
93
  - **`/telegram-setup`**: Configure or update the Telegram bot token.
94
94
  - **`/telegram-status`**: Check bridge status, connection, polling, execution, queue, and recent redacted runtime/API failure events.
95
- - **`/telegram-connect`**: Start polling Telegram updates in the current pi session.
96
- - **`/telegram-disconnect`**: Stop polling in the current pi session.
95
+ - **`/telegram-connect`**: Start polling Telegram updates in the current pi session, acquire the singleton lock, or interactively move ownership here from another live instance.
96
+ - **`/telegram-disconnect`**: Stop polling in the current pi session and release the singleton lock.
97
97
 
98
98
  ### Queue, Reactions, and Media
99
99
 
@@ -102,9 +102,31 @@ Run these inside pi, not Telegram:
102
102
  - `👎` removes a waiting turn from the queue. Telegram Bot API does not expose ordinary DM message-deletion events through the polling path used here, so queue removal is bound to the dislike reaction.
103
103
  - For media groups, a reaction on any message in the group applies to the whole queued turn.
104
104
  - If you edit a Telegram message while it is still waiting in the queue, the queued turn is updated instead of creating a duplicate prompt. Edits after a turn has already started may not affect the active run.
105
- - Inbound images, albums, and files are saved to `~/.pi/agent/tmp/telegram`, local file paths are included in the prompt, and inbound images are forwarded to pi as image inputs. Inbound downloads default to a 50 MiB limit and can be adjusted with `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES` or `TELEGRAM_MAX_FILE_SIZE_BYTES`.
105
+ - Inbound images, albums, and files are saved to `~/.pi/agent/tmp/telegram`. Unhandled local file paths are included in the prompt, handled attachment output is injected into the prompt text, and inbound images are forwarded to pi as image inputs. Inbound downloads default to a 50 MiB limit and can be adjusted with `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES` or `TELEGRAM_MAX_FILE_SIZE_BYTES`.
106
106
  - Queue reactions depend on Telegram delivering `message_reaction` updates for your bot and chat type.
107
107
 
108
+ ### Inbound Attachment Handlers
109
+
110
+ `telegram.json` can define `attachmentHandlers` for common preprocessing such as voice transcription. The first matching handler runs after download and before the Telegram turn enters the pi queue.
111
+
112
+ ```json
113
+ {
114
+ "attachmentHandlers": [
115
+ {
116
+ "mime": "audio/*",
117
+ "command": "/home/me/bin/transcribe {filename} ru"
118
+ },
119
+ {
120
+ "type": "voice",
121
+ "tool": "transcribe_groq",
122
+ "args": { "lang": "ru" }
123
+ }
124
+ ]
125
+ }
126
+ ```
127
+
128
+ Matching supports `mime`, `type`, or `match`; wildcards like `audio/*` are accepted. Command placeholders are substituted as argv, not shell text: `{filename}` and `{path}` are the downloaded file path, `{basename}` is the display filename, `{mime}` is the MIME type, and `{type}` is the Telegram attachment type. Tool handlers invoke a locally available tool by name, for example `transcribe_groq`; how that tool is provided is outside pi-telegram. Local attachments stay in the prompt under `[attachments] <directory>` with relative file entries; successful handler stdout is added under `[outputs]`; failed or empty handlers simply produce no output block.
129
+
108
130
  ### Requesting Files
109
131
 
110
132
  If you ask pi for a file or generated artifact (e.g., _"generate a shell script and attach it"_), pi will call the `telegram_attach` tool, and the extension will send the file alongside its next Telegram reply. Outbound attachments default to a 50 MiB limit and can be adjusted with `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES` or `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES`.
package/docs/README.md CHANGED
@@ -4,6 +4,5 @@ Living index of project documentation in `/docs`.
4
4
 
5
5
  ## Documents
6
6
 
7
- | Document | Description |
8
- | --- | --- |
9
- | [architecture.md](./architecture.md) | Overview of the Telegram bridge runtime, queueing model, rendering pipeline, and interactive controls |
7
+ - [architecture.md](./architecture.md) Overview of the Telegram bridge runtime, queueing model, rendering pipeline, and interactive controls
8
+ - [locks.md](./locks.md) Shared `locks.json` standard for singleton extension ownership
@@ -26,7 +26,7 @@ Current runtime areas include:
26
26
  - Telegram Bot API concrete transport shapes live with Telegram API helpers in `/lib/api.ts`, while persisted bot/session pairing state lives in `/lib/config.ts`; domain-owned runtime state types stay with their owners, such as queued/active turn state in `/lib/queue.ts` and preview state in `/lib/preview.ts`, while domain helpers prefer local structural `*Like` contracts instead of importing concrete wire DTOs
27
27
  - Direct pi SDK imports are centralized in `/lib/pi.ts`, which exposes concrete pi SDK type exports, bound extension API runtime ports, and narrow bridge-facing helpers such as settings-manager creation plus context model/idle/pending-message/compaction adapters; `index.ts` uses this adapter namespace instead of importing `@mariozechner/pi-coding-agent` directly
28
28
  - Session-local runtime primitives such as queue/control/priority ordering counters, lifecycle/dispatch flags, setup guard state, abort-handler storage/binding, typing-loop timer lifecycle, typing-loop starter binding, prompt-dispatch lifecycle/runtime adapters, and agent-end reset sequencing in `/lib/runtime.ts`; the runtime domain's essence is mutable cross-domain session coordination rather than business behavior. It exposes a grouped bridge runtime facade with named queue/lifecycle/setup/abort/typing ports that bind those primitives to one session state while remaining a cohesive state/runtime boundary, and `index.ts` still wires live Telegram API calls and status updates into those helpers. Preview-specific state, draft-support detection, and draft-id allocation live in `/lib/preview.ts`.
29
- - Constants live in their owning domains instead of a shared constants module: API paths/inbound limits and inbound file-size env parsing in `/lib/api.ts`, outbound attachment limits and outbound attachment-size env parsing in `/lib/attachments.ts`, media-group debounce in `/lib/media.ts`, menu cache/state bounds in `/lib/menu.ts`, preview throttle/draft bounds in `/lib/preview.ts`, typing cadence in `/lib/runtime.ts`, diagnostic ring limits in `/lib/status.ts`, Telegram prompt prefix in `/lib/turns.ts`, and system-prompt guidance in `/lib/registration.ts`.
29
+ - Constants live in their owning domains instead of a shared constants module: API paths/inbound limits and inbound file-size env parsing in `/lib/api.ts`, outbound attachment limits and outbound attachment-size env parsing in `/lib/attachments.ts`, media-group debounce in `/lib/media.ts`, attachment-handler timeout and local tool lookup defaults in `/lib/handlers.ts`, menu cache/state bounds in `/lib/menu.ts`, preview throttle/draft bounds in `/lib/preview.ts`, typing cadence in `/lib/runtime.ts`, diagnostic ring limits in `/lib/status.ts`, Telegram prompt prefix in `/lib/turns.ts`, and system-prompt guidance in `/lib/registration.ts`.
30
30
  - Queueing, narrow Telegram prompt content contracts, queue-store contracts/state helpers, active-turn state helpers, dispatch-readiness adapters, queue append/mutation runtime/controller adapters, control enqueue controllers, queue dispatch readiness/controller/runtime adapters, prompt enqueue/history planning/runtime/controllers, queue-runtime, session state appliers plus lifecycle/runtime sequencing, session start/shutdown sequencing plus hook binding, agent-start/agent-end lifecycle handling plus hook/runtime binding, and tool lifecycle handling plus tool-execution hook/runtime binding in `/lib/queue.ts`
31
31
  - Model identity/thinking-level contracts, scoped model-pattern parsing/resolution/sorting, current-model store/update/runtime helpers, in-flight model-switch state helpers, restart eligibility, delayed abort decisions, Telegram-prefix defaulted continuation prompt construction, continuation queue adapters, and model-switch controller/runtime binding over queue-owned turns in `/lib/model.ts`
32
32
  - Preview transport-selection, assistant-message preview lifecycle hook binding/handling, preview-finalization, preview controller state/reset helpers, preview Bot API message/rendered-chunk transport adapters, preview-controller/assistant-preview runtime binding, reply-metadata defaulting through the replies-domain helper, and preview-runtime helpers in `/lib/preview.ts`
@@ -35,17 +35,19 @@ Current runtime areas include:
35
35
  - Polling request, start/stop controller state orchestration, polling activity readers, stop-condition, structural config contract, long-poll loop helpers, and poll-loop/controller runtime wiring over Telegram transport ports in `/lib/polling.ts`
36
36
  - Telegram persisted config shape, config-path defaults, config file read/write helpers, mutable config-store accessors, single-user authorization, and first-user pairing side effects/runtime adapters in `/lib/config.ts`
37
37
  - Telegram API helpers, concrete Bot API transport shapes including reply parameters and send/edit message bodies, typed/default Bot API runtime helpers, bot identity fetch transport, chat-action sender adapters/runtime-bound typing action, lazy bot-token client wrappers, API runtime error-recording wrappers, temp paths, inbound file-size limits, and runtime-bound temp-directory preparation/default cleanup in `/lib/api.ts`
38
- - Telegram turn-building helpers, runtime turn-builder wiring over media download ports and media-owned downloaded-file metadata contracts, queued-prompt edit runtime binding, and Node-backed image-file reads for pi image inputs in `/lib/turns.ts`
38
+ - Telegram turn-building helpers, runtime turn-builder wiring over media download ports and media-owned downloaded-file metadata contracts, inbound attachment handler processing, queued-prompt edit runtime binding, and Node-backed image-file reads for pi image inputs in `/lib/turns.ts`
39
39
  - Telegram media/text extraction, file-info normalization, downloaded-message-file metadata contracts, inbound file download assembly, media-group debounce helpers, media-group controller state, and media-group-aware authorized-message dispatch adapter wiring in `/lib/media.ts`
40
+ - Telegram inbound attachment handler matching, command placeholder substitution, local tool invocation, handler-output collection, and quiet failure handling in `/lib/handlers.ts`
40
41
  - Telegram slash-command parsing, command-message target helpers/adapters, command control-enqueue adapters/runtime binding, command-action routing, command-handler/target-runtime and command-or-prompt dispatch binding, command runtime port orchestration, shared command-runtime reply/status/control adapter closures, stop/compact/status/model/help command side-effect branching, bound Bot API command registration, and Bot API command metadata helpers in `/lib/commands.ts`
41
- - Telegram updates extraction, paired update-runtime binding, flow, execution-planning, authorized reaction priority/removal handling, direct execute-from-update routing, update runtime adapters over queue/media/menu ports, and runtime helpers in `/lib/updates.ts`
42
+ - Telegram updates extraction, authorization classification, execution-planning, authorized reaction priority/removal handling, direct execute-from-update routing, and runtime helpers in `/lib/updates.ts`
43
+ - Inbound route composition across paired update execution, callback menus, command-or-prompt dispatch, media grouping, prompt enqueueing, queued edits, and attachment-handler turn building in `/lib/routing.ts`
42
44
  - Telegram attachment queueing, narrow structural attachment turn targets, queued-attachment sender runtime binding, delivery helpers, Node-backed file stat checks, outbound photo-vs-document classification, and outbound attachment limits/env parsing in `/lib/attachments.ts`
43
45
  - Telegram tool, command, before-agent prompt, and lifecycle-hook registration helpers in `/lib/registration.ts`
44
46
  - Setup/token prompt, environment fallback, guarded setup runtime adapter wiring, structural setup config contract, token validation, config persistence orchestration, and setup notification helpers in `/lib/setup.ts`
45
47
  - Markdown block scanning/rendering, inline-token/style rendering, text-piece rendering, stable-preview block scanning, final rendered-block chunk balancing, preview-snapshot derivation, HTML escaping, raw HTML tag-preserving chunking, and Telegram message rendering helpers in `/lib/rendering.ts`
46
48
  - Status-bar rendering/runtime adapters, bridge status state adapters, status-message rendering and status-HTML binding, structural queue-lane status view contracts, structured redacted runtime-event recording, recent-event recorder state, recent-event line formatting, and grouped pi-side diagnostics helpers in `/lib/status.ts`
47
49
  - Menu settings/model-registry access through structural ports, menu-state construction, menu runtime state/cache controller, menu-state storage pruning/refresh helpers, command open-flow branching, action runtime/state-builder adapters, menu callback handler adapters, stored callback entry/runtime routing, model-menu input-cache/state-building resolution, pure menu-page derivation, pure menu render-payload builders, menu-message runtime, callback parsing, callback mutation helpers, full model-callback planning and execution, interface-polished callback effect ports, status-thinking callback handling, and UI helpers in `/lib/menu.ts`
48
- - Telegram API-bound transport adapters and broader event-side orchestration in `index.ts`; direct Node file-operation imports stay in the owning domains rather than the entrypoint
50
+ - Telegram API-bound transport adapters and top-level runtime registration stay in `index.ts`; broader inbound event-side orchestration lives in `/lib/routing.ts`; direct Node file-operation imports stay in the owning domains rather than the entrypoint
49
51
  - Remaining `index.ts` wiring is intentionally cross-domain adapter code that closes over live extension state, pi callbacks, Telegram API ports, and status updates; keep repeated wiring DRY through small local adapter helpers or owning-domain contracts when that reduces duplication without obscuring live state, and extract more only when a boundary can move cohesive behavior into an owning domain instead of relocating one-off closures
50
52
  - Additional domains can be extracted into `/lib/*.ts` as the bridge grows, while keeping `index.ts` as the single entrypoint
51
53
  - `index.ts` uses namespace imports for local bridge domains so orchestration reads as domain-scoped calls such as `Queue.*`, `Turns.*`, and `Rendering.*` instead of long flat import lists
@@ -61,6 +63,10 @@ Current runtime areas include:
61
63
 
62
64
  Because `ctx.ui.input()` only exposes placeholder text, the bridge uses `ctx.ui.editor()` whenever a real default value must appear already filled in. The persisted `telegram.json` config is written with private `0600` permissions because it contains the bot token.
63
65
 
66
+ ## Runtime Ownership
67
+
68
+ Telegram bot configuration stays in `~/.pi/agent/telegram.json`; singleton runtime ownership lives separately in `~/.pi/agent/locks.json` under `@llblab/pi-telegram`. `/telegram-connect` acquires or moves that lock before polling starts, and `/telegram-disconnect` stops polling and releases it. Session start may read the existing lock and resume polling when the lock already points at the current `pid`/`cwd`; after a full pi process restart, it may also replace a stale lock from the same `cwd` and resume polling automatically. Session start does not create new ownership from an inactive lock, a live external lock, or a stale lock from another directory. Session replacement suspends polling and ownership watchers without releasing the lock, allowing the next session-start hook in the same `pid`/`cwd` to resume from the existing explicit ownership. When a live external owner exists, `/telegram-connect` asks whether to move singleton ownership to the current pi instance. Active owners poll the lock while running through a snapshotted ownership context, so long-lived timers do not touch stale pi contexts after `/new`; they stop local polling when `locks.json` no longer points at their own `pid`/`cwd`, without deleting the new owner lock. Deleting `locks.json` resets runtime ownership without deleting Telegram configuration.
69
+
64
70
  ## Message And Queue Flow
65
71
 
66
72
  ### Inbound Path
@@ -70,9 +76,11 @@ Because `ctx.ui.input()` only exposes placeholder text, the bridge uses `ctx.ui.
70
76
  3. The bridge filters to the paired private user
71
77
  4. Media groups are coalesced into a single Telegram turn when needed
72
78
  5. Files are streamed into `~/.pi/agent/tmp/telegram` with a default 50 MiB size limit, partial-download cleanup on failures, and stale temp cleanup on session start; operators can tune the limit with `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES` or `TELEGRAM_MAX_FILE_SIZE_BYTES`
73
- 6. A `PendingTelegramTurn` is created and queued locally
74
- 7. Telegram `edited_message` updates are routed separately and update a matching queued turn when the original message has not been dispatched yet
75
- 8. The queue dispatcher sends the turn into pi only when dispatch is safe
79
+ 6. Configured inbound attachment handlers may run on downloaded files by MIME wildcard, Telegram attachment type, or generic match selector; command handlers receive safe argv substitution for `{filename}`/`{path}`/`{basename}`/`{mime}`/`{type}`, and tool handlers invoke locally available tools by name
80
+ 7. Local attachments stay visible under `[attachments] <directory>` with relative file entries, and handler stdout is appended under `[outputs]` before the agent sees the turn; failed or empty handlers simply omit handler output while keeping the attachment entry
81
+ 8. A `PendingTelegramTurn` is created and queued locally
82
+ 9. Telegram `edited_message` updates are routed separately and update a matching queued turn when the original message has not been dispatched yet
83
+ 10. The queue dispatcher sends the turn into pi only when dispatch is safe
76
84
 
77
85
  ### Queue Safety Model
78
86
 
package/docs/locks.md ADDED
@@ -0,0 +1,136 @@
1
+ # Extension Locks Standard
2
+
3
+ `locks.json` is a shared registry for singleton pi extensions.
4
+
5
+ Path:
6
+
7
+ ```text
8
+ ~/.pi/agent/locks.json
9
+ ```
10
+
11
+ ## Shape
12
+
13
+ ```json
14
+ {
15
+ "@llblab/pi-telegram": {
16
+ "pid": 2590864,
17
+ "cwd": "/home/user/project"
18
+ }
19
+ }
20
+ ```
21
+
22
+ Top-level keys are extension identities. Values are JSON objects owned by that extension.
23
+
24
+ ## Identity key
25
+
26
+ Use the most stable available identity:
27
+
28
+ 1. `package.json/name` for npm-style pi packages
29
+ 2. Directory name when the extension entrypoint is `index.ts` but there is no package name
30
+ 3. File basename when the extension is a single file
31
+
32
+ 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.
33
+
34
+ Examples:
35
+
36
+ ```text
37
+ extensions/pi-telegram/package.json name=@llblab/pi-telegram -> @llblab/pi-telegram
38
+ extensions/pi-telegram/index.ts without package.json -> pi-telegram
39
+ extensions/pi-telegram.ts -> pi-telegram
40
+ ```
41
+
42
+ ## Required fields
43
+
44
+ ```json
45
+ {
46
+ "pid": 2590864
47
+ }
48
+ ```
49
+
50
+ `pid` is the process that currently owns the singleton runtime. `cwd` should be stored when ownership is tied to a pi session directory.
51
+
52
+ During a user-initiated start/connect event, an extension should:
53
+
54
+ 1. Read its lock entry
55
+ 2. If `pid` is stale, replace the entry
56
+ 3. If `pid` and `cwd` match the current pi instance, refresh or keep the entry
57
+ 4. If a live external owner exists, ask interactively whether to move singleton ownership here
58
+
59
+ ## Acquisition timing
60
+
61
+ Lock writes must be caused by an explicit user-initiated runtime event, such as `/wakeup-start`, `/telegram-connect`, or a confirmed takeover prompt.
62
+
63
+ 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.
64
+
65
+ ## Optional fields
66
+
67
+ Extensions may add compact fields when useful:
68
+
69
+ ```json
70
+ {
71
+ "pid": 2590864,
72
+ "cwd": "/repo/project",
73
+ "mode": "connected",
74
+ "updatedAt": "2026-04-28T00:00:00.000Z"
75
+ }
76
+ ```
77
+
78
+ Do not print optional fields in normal UI unless they help the user act.
79
+
80
+ ## Ownership rules
81
+
82
+ - One top-level key per singleton extension
83
+ - An extension may only mutate its own key
84
+ - Other keys must be preserved exactly
85
+ - If `cwd` is present, active-here ownership means both `pid` and `cwd` match the current pi instance
86
+ - Human-readable diagnostics should say `active here`, `active elsewhere`, or `stale`
87
+ - Debug data belongs in `locks.json`, not in normal status output
88
+
89
+ ## Runtime status
90
+
91
+ Singleton extensions with footer/status presence should expose quiet but explicit local state. For example, pi-wakeup uses:
92
+
93
+ - `wakeup off` when this pi instance does not own the singleton runtime
94
+ - `wakeup on` when this pi instance owns the runtime but has no pending wake-up detail to show
95
+ - `wakeup [16:32:39]` when the runtime owns scheduled work and can show the next countdown
96
+
97
+ ## Interactive takeover
98
+
99
+ Start/connect commands should make singleton moves easy:
100
+
101
+ 1. If no live owner exists, take ownership without an extra prompt
102
+ 2. If a live external owner exists, ask whether to move singleton ownership to this pi instance
103
+ 3. On confirmation, write the current `{ "pid": ..., "cwd": ... }` to this extension's key in `locks.json`
104
+ 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
105
+
106
+ Takeover prompts should use the extension name as the dialog title, then the question, a blank line, and source/target lines:
107
+
108
+ ```text
109
+ pi-telegram
110
+ move singleton lock here?
111
+
112
+ from: pid 2590864, cwd /old
113
+ to: /new
114
+ ```
115
+
116
+ Avoid repeating the extension name in the body. Color is encouraged: extension title/name accent, question warning, `from:`/`to:` muted.
117
+
118
+ 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.
119
+
120
+ ## Reset
121
+
122
+ Delete `~/.pi/agent/locks.json` to reset singleton runtime ownership for all participating extensions without deleting their configuration files such as `telegram.json`.
123
+
124
+ ## Atomicity
125
+
126
+ Current baseline is read-modify-write JSON. This is enough for interactive pi singleton starts.
127
+
128
+ If multiple instances may start concurrently, use an atomic helper later:
129
+
130
+ - Lock file around `locks.json`, or
131
+ - Temp file + rename with conflict checks, or
132
+ - OS-level exclusive open for a short critical section
133
+
134
+ ## Migration
135
+
136
+ 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.
package/index.ts CHANGED
@@ -5,8 +5,9 @@
5
5
 
6
6
  import * as Api from "./lib/api.ts";
7
7
  import * as Attachments from "./lib/attachments.ts";
8
- import * as Commands from "./lib/commands.ts";
9
8
  import * as Config from "./lib/config.ts";
9
+ import * as Handlers from "./lib/handlers.ts";
10
+ import * as Locks from "./lib/locks.ts";
10
11
  import * as Media from "./lib/media.ts";
11
12
  import * as Menu from "./lib/menu.ts";
12
13
  import * as Model from "./lib/model.ts";
@@ -17,10 +18,9 @@ import * as Queue from "./lib/queue.ts";
17
18
  import * as Registration from "./lib/registration.ts";
18
19
  import * as Replies from "./lib/replies.ts";
19
20
  import * as Runtime from "./lib/runtime.ts";
21
+ import * as Routing from "./lib/routing.ts";
20
22
  import * as Setup from "./lib/setup.ts";
21
23
  import * as Status from "./lib/status.ts";
22
- import * as Turns from "./lib/turns.ts";
23
- import * as Updates from "./lib/updates.ts";
24
24
 
25
25
  type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
26
26
  type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
@@ -31,6 +31,7 @@ export default function (pi: Pi.ExtensionAPI) {
31
31
  const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
32
32
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
33
33
  const configStore = Config.createTelegramConfigStore();
34
+ const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
34
35
  const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
35
36
  const pendingModelSwitchStore =
36
37
  Model.createPendingModelSwitchStore<
@@ -63,6 +64,7 @@ export default function (pi: Pi.ExtensionAPI) {
63
64
  getQueuedItems: telegramQueueStore.getQueuedItems,
64
65
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
65
66
  getRecentRuntimeEvents: runtimeEvents.getEvents,
67
+ getRuntimeLockState: lockRuntime.getStatusLabel,
66
68
  });
67
69
  const currentModelRuntime = Model.createCurrentModelRuntime<
68
70
  Pi.ExtensionContext,
@@ -80,6 +82,13 @@ export default function (pi: Pi.ExtensionAPI) {
80
82
  bridgeRuntime.queue.incrementNextPriorityReactionOrder,
81
83
  updateStatus,
82
84
  });
85
+ const attachmentHandlerRuntime =
86
+ Handlers.createTelegramAttachmentHandlerRuntime<Pi.ExtensionContext>({
87
+ getHandlers: configStore.getAttachmentHandlers,
88
+ execCommand: piRuntime.exec,
89
+ getCwd: Pi.getExtensionContextCwd,
90
+ recordRuntimeEvent: runtimeEvents.record,
91
+ });
83
92
 
84
93
  // --- Telegram API ---
85
94
 
@@ -202,127 +211,76 @@ export default function (pi: Pi.ExtensionAPI) {
202
211
  deleteWebhook,
203
212
  getUpdates,
204
213
  persistConfig: configStore.persist,
205
- handleUpdate: Updates.createTelegramPairedUpdateRuntime<
214
+ handleUpdate: Routing.createTelegramInboundRouteRuntime<
215
+ Api.TelegramUpdate,
216
+ Api.TelegramMessage,
217
+ Api.TelegramCallbackQuery,
206
218
  Pi.ExtensionContext,
207
- Api.TelegramUpdate
219
+ ActivePiModel
208
220
  >({
209
- getAllowedUserId: configStore.getAllowedUserId,
210
- setAllowedUserId: configStore.setAllowedUserId,
211
- persistConfig: configStore.persist,
221
+ configStore,
222
+ bridgeRuntime,
223
+ activeTurnRuntime,
224
+ mediaGroupRuntime,
225
+ telegramQueueStore,
226
+ queueMutationRuntime,
227
+ modelMenuRuntime,
228
+ currentModelRuntime,
229
+ modelSwitchController,
230
+ menuActions,
231
+ attachmentHandlerRuntime,
212
232
  updateStatus,
213
- removePendingMediaGroupMessages: mediaGroupRuntime.removeMessages,
214
- removeQueuedTelegramTurnsByMessageIds:
215
- queueMutationRuntime.removeByMessageIds,
216
- clearQueuedTelegramTurnPriorityByMessageId:
217
- queueMutationRuntime.clearPriorityByMessageId,
218
- prioritizeQueuedTelegramTurnByMessageId:
219
- queueMutationRuntime.prioritizeByMessageId,
233
+ dispatchNextQueuedTelegramTurn,
220
234
  answerCallbackQuery,
221
- handleAuthorizedTelegramCallbackQuery:
222
- Menu.createTelegramMenuCallbackHandlerForContext<
223
- Api.TelegramCallbackQuery,
224
- Pi.ExtensionContext,
225
- ActivePiModel
226
- >({
227
- getStoredModelMenuState: modelMenuRuntime.getState,
228
- getActiveModel: currentModelRuntime.get,
229
- getThinkingLevel: piRuntime.getThinkingLevel,
230
- setThinkingLevel: piRuntime.setThinkingLevel,
231
- updateStatus,
232
- updateModelMenuMessage: menuActions.updateModelMenuMessage,
233
- updateThinkingMenuMessage: menuActions.updateThinkingMenuMessage,
234
- updateStatusMessage: menuActions.updateStatusMessage,
235
- answerCallbackQuery,
236
- isIdle: Pi.isExtensionContextIdle,
237
- hasActiveTelegramTurn: activeTurnRuntime.has,
238
- hasAbortHandler: bridgeRuntime.abort.hasHandler,
239
- getActiveToolExecutions:
240
- bridgeRuntime.lifecycle.getActiveToolExecutions,
241
- setModel: piRuntime.setModel,
242
- setCurrentModel: currentModelRuntime.setCurrentModel,
243
- stagePendingModelSwitch: modelSwitchController.stagePendingSwitch,
244
- restartInterruptedTelegramTurn:
245
- modelSwitchController.restartInterruptedTurn,
246
- }),
247
235
  sendTextReply,
248
- handleAuthorizedTelegramMessage:
249
- Media.createTelegramMediaGroupDispatchRuntime<
250
- Api.TelegramMessage,
251
- Pi.ExtensionContext
252
- >({
253
- mediaGroups: mediaGroupRuntime,
254
- dispatchMessages: Commands.createTelegramCommandOrPromptRuntime<
255
- Api.TelegramMessage,
256
- Pi.ExtensionContext
257
- >({
258
- extractRawText: Media.extractFirstTelegramMessageText,
259
- handleCommand: Commands.createTelegramCommandHandlerTargetRuntime<
260
- Api.TelegramMessage,
261
- Pi.ExtensionContext
262
- >({
263
- hasAbortHandler: bridgeRuntime.abort.hasHandler,
264
- clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
265
- hasQueuedTelegramItems: telegramQueueStore.hasQueuedItems,
266
- setPreserveQueuedTurnsAsHistory:
267
- bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory,
268
- abortCurrentTurn: bridgeRuntime.abort.abortTurn,
269
- isIdle: Pi.isExtensionContextIdle,
270
- hasPendingMessages: Pi.hasExtensionContextPendingMessages,
271
- hasActiveTelegramTurn: activeTurnRuntime.has,
272
- hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
273
- isCompactionInProgress:
274
- bridgeRuntime.lifecycle.isCompactionInProgress,
275
- setCompactionInProgress:
276
- bridgeRuntime.lifecycle.setCompactionInProgress,
277
- updateStatus,
278
- dispatchNextQueuedTelegramTurn,
279
- compact: Pi.compactExtensionContext,
280
- allocateItemOrder: bridgeRuntime.queue.allocateItemOrder,
281
- allocateControlOrder: bridgeRuntime.queue.allocateControlOrder,
282
- appendControlItem: queueMutationRuntime.append,
283
- showStatus: menuActions.sendStatusMessage,
284
- openModelMenu: menuActions.openModelMenu,
285
- getAllowedUserId: configStore.getAllowedUserId,
286
- setAllowedUserId: configStore.setAllowedUserId,
287
- setMyCommands,
288
- persistConfig: configStore.persist,
289
- sendTextReply,
290
- recordRuntimeEvent: runtimeEvents.record,
291
- }),
292
- enqueueTurn: Queue.createTelegramPromptEnqueueController<
293
- Api.TelegramMessage,
294
- Pi.ExtensionContext
295
- >({
296
- ...telegramQueueStore,
297
- getPreserveQueuedTurnsAsHistory:
298
- bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory,
299
- setPreserveQueuedTurnsAsHistory:
300
- bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory,
301
- createTurn:
302
- Turns.createTelegramPromptTurnRuntimeBuilder<Api.TelegramMessage>(
303
- {
304
- allocateQueueOrder: bridgeRuntime.queue.allocateItemOrder,
305
- downloadFile: downloadTelegramBridgeFile,
306
- },
307
- ),
308
- updateStatus,
309
- dispatchNextQueuedTelegramTurn,
310
- }).enqueue,
311
- }).dispatchMessages,
312
- }).handleMessage,
313
- handleAuthorizedTelegramEditedMessage:
314
- Turns.createTelegramQueuedPromptEditRuntime<
315
- Api.TelegramMessage,
316
- Pi.ExtensionContext
317
- >({
318
- ...telegramQueueStore,
319
- updateStatus,
320
- }).updateFromEditedMessage,
236
+ setMyCommands,
237
+ downloadFile: downloadTelegramBridgeFile,
238
+ getThinkingLevel: piRuntime.getThinkingLevel,
239
+ setThinkingLevel: piRuntime.setThinkingLevel,
240
+ setModel: piRuntime.setModel,
241
+ isIdle: Pi.isExtensionContextIdle,
242
+ hasPendingMessages: Pi.hasExtensionContextPendingMessages,
243
+ compact: Pi.compactExtensionContext,
244
+ recordRuntimeEvent: runtimeEvents.record,
321
245
  }).handleUpdate,
322
246
  stopTypingLoop: bridgeRuntime.typing.stop,
323
247
  updateStatus,
324
248
  recordRuntimeEvent: runtimeEvents.record,
325
249
  });
250
+ const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
251
+ lock: lockRuntime,
252
+ hasBotToken: configStore.hasBotToken,
253
+ startPolling: pollingRuntime.start,
254
+ stopPolling: pollingRuntime.stop,
255
+ updateStatus,
256
+ recordRuntimeEvent: runtimeEvents.record,
257
+ });
258
+ const sessionLifecycleRuntime = Registration.appendTelegramLifecycleHooks(
259
+ Queue.createTelegramSessionLifecycleRuntime<
260
+ Pi.ExtensionContext,
261
+ RuntimeTelegramQueueItem,
262
+ ActivePiModel
263
+ >({
264
+ getCurrentModel: Pi.getExtensionContextModel,
265
+ loadConfig: configStore.load,
266
+ setQueuedItems: telegramQueueStore.setQueuedItems,
267
+ setCurrentModel: currentModelRuntime.set,
268
+ setPendingModelSwitch: pendingModelSwitchStore.set,
269
+ syncCounters: bridgeRuntime.queue.syncCounters,
270
+ syncFlags: bridgeRuntime.lifecycle.syncFlags,
271
+ prepareTempDir,
272
+ updateStatus,
273
+ clearPendingMediaGroups: mediaGroupRuntime.clear,
274
+ clearModelMenuState: modelMenuRuntime.clear,
275
+ getActiveTurnChatId: activeTurnRuntime.getChatId,
276
+ clearPreview: previewRuntime.clear,
277
+ clearActiveTurn: activeTurnRuntime.clear,
278
+ clearAbort: bridgeRuntime.abort.clearHandler,
279
+ stopPolling: lockedPollingRuntime.suspend,
280
+ recordRuntimeEvent: runtimeEvents.record,
281
+ }),
282
+ { onSessionStart: lockedPollingRuntime.onSessionStart },
283
+ );
326
284
 
327
285
  // --- Extension Registration ---
328
286
 
@@ -338,44 +296,22 @@ export default function (pi: Pi.ExtensionAPI) {
338
296
  setupGuard: bridgeRuntime.setup,
339
297
  getMe: Api.fetchTelegramBotIdentity,
340
298
  persistConfig: configStore.persist,
341
- startPolling: pollingRuntime.start,
299
+ startPolling: lockedPollingRuntime.start,
342
300
  updateStatus,
343
301
  recordRuntimeEvent: runtimeEvents.record,
344
302
  }),
345
303
  getStatusLines,
346
304
  reloadConfig: configStore.load,
347
305
  hasBotToken: configStore.hasBotToken,
348
- startPolling: pollingRuntime.start,
349
- stopPolling: pollingRuntime.stop,
306
+ startPolling: lockedPollingRuntime.start,
307
+ stopPolling: lockedPollingRuntime.stop,
350
308
  updateStatus,
351
309
  });
352
310
 
353
311
  // --- Lifecycle Hooks ---
354
312
 
355
313
  Registration.registerTelegramLifecycleHooks(pi, {
356
- ...Queue.createTelegramSessionLifecycleRuntime<
357
- Pi.ExtensionContext,
358
- RuntimeTelegramQueueItem,
359
- ActivePiModel
360
- >({
361
- getCurrentModel: Pi.getExtensionContextModel,
362
- loadConfig: configStore.load,
363
- setQueuedItems: telegramQueueStore.setQueuedItems,
364
- setCurrentModel: currentModelRuntime.set,
365
- setPendingModelSwitch: pendingModelSwitchStore.set,
366
- syncCounters: bridgeRuntime.queue.syncCounters,
367
- syncFlags: bridgeRuntime.lifecycle.syncFlags,
368
- prepareTempDir,
369
- updateStatus,
370
- clearPendingMediaGroups: mediaGroupRuntime.clear,
371
- clearModelMenuState: modelMenuRuntime.clear,
372
- getActiveTurnChatId: activeTurnRuntime.getChatId,
373
- clearPreview: previewRuntime.clear,
374
- clearActiveTurn: activeTurnRuntime.clear,
375
- clearAbort: bridgeRuntime.abort.clearHandler,
376
- stopPolling: pollingRuntime.stop,
377
- recordRuntimeEvent: runtimeEvents.record,
378
- }),
314
+ ...sessionLifecycleRuntime,
379
315
  onBeforeAgentStart: Registration.createTelegramBeforeAgentStartHook(),
380
316
  onModelSelect: currentModelRuntime.onModelSelect,
381
317
  ...Queue.createTelegramAgentLifecycleHooks<
package/lib/config.ts CHANGED
@@ -5,10 +5,19 @@
5
5
 
6
6
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
- import { join } from "node:path";
8
+ import { join, resolve } from "node:path";
9
9
 
10
- const AGENT_DIR = join(homedir(), ".pi", "agent");
11
- const CONFIG_PATH = join(AGENT_DIR, "telegram.json");
10
+ import type { TelegramAttachmentHandlerConfig } from "./handlers.ts";
11
+
12
+ function getAgentDir(): string {
13
+ return process.env.PI_CODING_AGENT_DIR
14
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
15
+ : join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function getConfigPath(): string {
19
+ return join(getAgentDir(), "telegram.json");
20
+ }
12
21
 
13
22
  export interface TelegramConfig {
14
23
  botToken?: string;
@@ -16,6 +25,7 @@ export interface TelegramConfig {
16
25
  botId?: number;
17
26
  allowedUserId?: number;
18
27
  lastUpdateId?: number;
28
+ attachmentHandlers?: TelegramAttachmentHandlerConfig[];
19
29
  }
20
30
 
21
31
  export interface TelegramConfigStore {
@@ -25,6 +35,7 @@ export interface TelegramConfigStore {
25
35
  getBotToken: () => string | undefined;
26
36
  hasBotToken: () => boolean;
27
37
  getAllowedUserId: () => number | undefined;
38
+ getAttachmentHandlers: () => TelegramAttachmentHandlerConfig[] | undefined;
28
39
  setAllowedUserId: (userId: number) => void;
29
40
  load: () => Promise<void>;
30
41
  persist: (config?: TelegramConfig) => Promise<void>;
@@ -64,8 +75,8 @@ export function createTelegramConfigStore(
64
75
  options: TelegramConfigStoreOptions = {},
65
76
  ): TelegramConfigStore {
66
77
  let config: TelegramConfig = options.initialConfig ?? {};
67
- const agentDir = options.agentDir ?? AGENT_DIR;
68
- const configPath = options.configPath ?? CONFIG_PATH;
78
+ const agentDir = options.agentDir ?? getAgentDir();
79
+ const configPath = options.configPath ?? getConfigPath();
69
80
  return {
70
81
  get: () => config,
71
82
  set: (nextConfig) => {
@@ -77,6 +88,7 @@ export function createTelegramConfigStore(
77
88
  getBotToken: () => config.botToken,
78
89
  hasBotToken: () => !!config.botToken,
79
90
  getAllowedUserId: () => config.allowedUserId,
91
+ getAttachmentHandlers: () => config.attachmentHandlers,
80
92
  setAllowedUserId: (userId) => {
81
93
  config.allowedUserId = userId;
82
94
  },