@brain0pia/pi-notify 0.1.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 ADDED
@@ -0,0 +1,46 @@
1
+ # `@brain0pia/pi-notify`
2
+
3
+ Pi package that sends a Telegram notification after each completed Pi agent response.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@brain0pia/pi-notify
9
+ ```
10
+
11
+ For local development you can also load the package directly:
12
+
13
+ ```bash
14
+ pi -e /absolute/path/to/pi-notify
15
+ ```
16
+
17
+ ## Configure
18
+
19
+ Create `~/.pi/notify.json`:
20
+
21
+ ```json
22
+ {
23
+ "botToken": "123456:ABCDEF...",
24
+ "chatId": "123456789"
25
+ }
26
+ ```
27
+
28
+ ## Behavior
29
+
30
+ After every `agent_end` event the extension:
31
+
32
+ 1. Finds the latest assistant text from the completed run.
33
+ 2. Shows a 30-second overlay countdown.
34
+ 3. Sends immediately on `Enter`.
35
+ 4. Cancels on any other key.
36
+ 5. Sends automatically when the countdown reaches zero.
37
+
38
+ The Telegram payload includes metadata (`project`, `cwd`, `timestamp`, `model`) plus the full assistant output. Short outputs are sent as a MarkdownV2 message with a plain-text fallback on parse errors. Long outputs are sent as a short header message plus a `.md` attachment containing the full response.
39
+
40
+ ## Development
41
+
42
+ ```bash
43
+ npm install
44
+ npm test
45
+ npm run check
46
+ ```
@@ -0,0 +1,321 @@
1
+ # Pi Telegram Notify — Design
2
+
3
+ Date: 2026-03-13
4
+ Status: Approved
5
+ Package: `@brain0pia/pi-notify`
6
+ Install: `pi install npm:@brain0pia/pi-notify`
7
+
8
+ ## Goal
9
+
10
+ Сделать Pi extension/package, который после каждого завершения работы агента отправляет уведомление в Telegram с markdown output.
11
+
12
+ ## Approved Decisions
13
+
14
+ - Формат поставки: **Pi package**
15
+ - npm package name: **`@brain0pia/pi-notify`**
16
+ - Установка: **`pi install npm:@brain0pia/pi-notify`**
17
+ - Исходный репозиторий: **`~/projects/pi-notify`**
18
+ - Триггер: **каждый `agent_end`**
19
+ - Перед отправкой: **ожидание 30 секунд**
20
+ - UX ожидания: **overlay/countdown widget**
21
+ - Поведение клавиш:
22
+ - **Enter** → скрыть виджет и **отправить** сообщение
23
+ - **любая другая клавиша** → скрыть виджет и **отменить** отправку
24
+ - **timeout** → скрыть виджет и **отправить** сообщение
25
+ - Конфиг: **`~/.pi/notify.json`**
26
+ - Получатель: **один** Telegram chat (`botToken` + `chatId`)
27
+ - Содержимое уведомления: **metadata + полный последний assistant message**
28
+ - Формат сообщения: **MarkdownV2**, при ошибке — **fallback в plain text**
29
+ - Для длинных ответов: **короткий header + `.md` файл**
30
+ - Для полного вложения: **`.md`**, не `.txt`
31
+ - Конкуренция: всегда активен только **последний pending job**
32
+ - Commit и implementation plan: **не делать** по текущему запросу пользователя
33
+
34
+ ## Approaches Considered
35
+
36
+ ### 1. `agent_end` + delay без перехвата клавиш
37
+ Простой и надёжный путь: после `agent_end` запускать таймер и отменять только по новому user input.
38
+
39
+ **Плюсы:** минимальная сложность.
40
+ **Минусы:** не соответствует требованию отмены по любой клавише.
41
+
42
+ ### 2. Countdown overlay widget с фокусом (**selected**)
43
+ После `agent_end` показывается overlay-виджет с таймером. Он получает фокус и перехватывает клавиши.
44
+
45
+ **Плюсы:** точно соответствует нужному UX, хорошо укладывается в extension API Pi.
46
+ **Минусы:** сложнее TUI-часть и управление жизненным циклом overlay.
47
+
48
+ ### 3. Полная временная замена editor/input-компонента
49
+ На 30 секунд заменять обычный editor специальным компонентом для полного контроля над клавишами.
50
+
51
+ **Плюсы:** максимальный контроль ввода.
52
+ **Минусы:** слишком инвазивно, выше риск ломать привычный UX и совместимость.
53
+
54
+ ## Recommended Architecture
55
+
56
+ Пакет состоит из extension и нескольких внутренних модулей.
57
+
58
+ ### 1. Config loader
59
+ Отвечает за чтение и валидацию `~/.pi/notify.json`.
60
+
61
+ Responsibilities:
62
+ - читать JSON-конфиг
63
+ - валидировать `botToken` и `chatId`
64
+ - возвращать нормализованный config
65
+ - при ошибке отключать отправку без падения extension
66
+
67
+ ### 2. Agent event controller
68
+ Подписывается на `agent_end`.
69
+
70
+ Responsibilities:
71
+ - находить последний assistant message текущего прогона
72
+ - собирать metadata
73
+ - создавать pending notification job
74
+ - отменять предыдущий job, если приходит новый `agent_end`
75
+ - координировать overlay, timer и отправку
76
+
77
+ ### 3. Countdown widget / input gate
78
+ UI-слой для 30-секундного ожидания.
79
+
80
+ Responsibilities:
81
+ - отображать countdown overlay
82
+ - обновлять таймер на экране
83
+ - принимать key input
84
+ - интерпретировать `Enter` как send
85
+ - интерпретировать любую другую клавишу как cancel
86
+ - скрывать себя при cancel/send/timeout
87
+
88
+ ### 4. Telegram delivery
89
+ Интеграция с Telegram Bot API.
90
+
91
+ Responsibilities:
92
+ - собирать итоговый payload
93
+ - отправлять short message через `sendMessage`
94
+ - отправлять `.md` файл через `sendDocument`, если output слишком длинный
95
+ - делать fallback с MarkdownV2 на plain text
96
+ - безопасно обрабатывать API errors
97
+
98
+ ### 5. Formatting helpers
99
+ Утилиты для подготовки текста.
100
+
101
+ Responsibilities:
102
+ - сборка metadata header
103
+ - escape для Telegram MarkdownV2
104
+ - определение, помещается ли ответ в одно сообщение
105
+ - сборка markdown-файла для длинного output
106
+
107
+ ## Repository Structure
108
+
109
+ Предлагаемая структура `~/projects/pi-notify`:
110
+
111
+ ```text
112
+ ~/projects/pi-notify/
113
+ package.json
114
+ README.md
115
+ src/
116
+ index.ts
117
+ config.ts
118
+ countdown.ts
119
+ telegram.ts
120
+ format.ts
121
+ docs/
122
+ plans/
123
+ 2026-03-13-pi-telegram-notify-design.md
124
+ ```
125
+
126
+ ## Pi Package Structure
127
+
128
+ `package.json` должен объявлять пакет как Pi package.
129
+
130
+ Expected package metadata:
131
+ - name: `@brain0pia/pi-notify`
132
+ - keywords: include `pi-package`
133
+ - `pi.extensions`: path to extension entrypoint
134
+ - peerDependencies: Pi core packages with `"*"` range
135
+
136
+ ## Runtime Data Flow
137
+
138
+ ### Normal flow
139
+ 1. Пользователь отправляет prompt в Pi.
140
+ 2. Pi завершает ответ, срабатывает `agent_end`.
141
+ 3. Extension находит последний assistant message.
142
+ 4. Extension собирает metadata:
143
+ - cwd / project
144
+ - timestamp
145
+ - model (если доступен)
146
+ 5. Extension создаёт новый notification job.
147
+ 6. Если был предыдущий активный job, он отменяется.
148
+ 7. Показывается countdown overlay на 30 секунд.
149
+ 8. Дальше возможны три исхода:
150
+ - **Enter** → overlay закрывается, сообщение сразу отправляется
151
+ - **другая клавиша** → overlay закрывается, отправка отменяется
152
+ - **timeout** → overlay закрывается, сообщение отправляется автоматически
153
+
154
+ ### Outgoing Telegram content
155
+
156
+ #### Short output
157
+ Отправляется одним сообщением:
158
+ - header с metadata
159
+ - затем полный assistant output
160
+
161
+ Flow:
162
+ 1. try `sendMessage` with MarkdownV2
163
+ 2. if parse error → retry as plain text
164
+
165
+ #### Long output
166
+ Если текст не помещается в лимиты Telegram:
167
+ - сначала отправляется короткий header/message
168
+ - затем отправляется `.md` document с полным output
169
+
170
+ ## Overlay UX
171
+
172
+ Виджет должен:
173
+ - явно показывать, что агент закончил работу
174
+ - показывать countdown от 30 до 0
175
+ - объяснять действия клавиш
176
+ - скрываться в трёх сценариях: cancel, immediate send, timeout send
177
+
178
+ Suggested text shape:
179
+ - title: `Pi finished`
180
+ - body: `Sending to Telegram in 30s`
181
+ - hints:
182
+ - `Enter — send now`
183
+ - `Any other key — cancel`
184
+
185
+ ## State Model
186
+
187
+ In-memory state достаточно.
188
+
189
+ Proposed active state:
190
+ - `activeJobId`
191
+ - `activeTimer`
192
+ - `activeOverlayHandle`
193
+ - `activePayload`
194
+ - `activeStatus`
195
+ - flags: `cancelled`, `sent`
196
+
197
+ Никакое persistent storage для отложенных уведомлений не требуется.
198
+
199
+ ## Concurrency Rules
200
+
201
+ Нужно избегать двойной отправки и гонок.
202
+
203
+ Rules:
204
+ - у каждого job есть уникальный `jobId`
205
+ - любой callback (keypress, timeout, send completion) сначала проверяет, что его `jobId` всё ещё активен
206
+ - новый `agent_end` отменяет предыдущий pending job
207
+ - старые callbacks после отмены ничего не делают
208
+
209
+ ## Error Handling
210
+
211
+ ### Config errors
212
+ Если `~/.pi/notify.json`:
213
+ - не существует
214
+ - битый JSON
215
+ - не содержит `botToken` или `chatId`
216
+
217
+ Then:
218
+ - extension не падает
219
+ - отправка отключается
220
+ - пользователю показывается локальное предупреждение через `ctx.ui.notify(...)`
221
+
222
+ ### Overlay errors
223
+ Если overlay не удалось показать:
224
+ - extension не должен ломать сессию Pi
225
+ - fallback: таймер продолжает жить в фоне
226
+ - по timeout выполняется обычная отправка
227
+ - просто недоступна keyboard cancellation для этого конкретного job
228
+
229
+ ### Telegram API errors
230
+ Cases:
231
+ - network failure
232
+ - 401 / 403 auth or chat access errors
233
+ - 400 markdown parse error
234
+ - 429 rate limit
235
+ - 5xx remote server error
236
+
237
+ Behavior:
238
+ 1. сначала пробовать MarkdownV2
239
+ 2. при markdown parse error → fallback to plain text
240
+ 3. при oversized body → header + `.md` file
241
+ 4. при финальной ошибке → локальное уведомление в Pi + no crash
242
+ 5. бесконечные retry не делать
243
+
244
+ ### Shutdown / reload
245
+ При завершении Pi или reload extension:
246
+ - pending timer отменяется
247
+ - overlay/status очищаются
248
+ - недоотправленные уведомления не восстанавливаются
249
+
250
+ ## Config Shape
251
+
252
+ Планируемый минимальный формат `~/.pi/notify.json`:
253
+
254
+ ```json
255
+ {
256
+ "botToken": "123456:ABCDEF...",
257
+ "chatId": "123456789"
258
+ }
259
+ ```
260
+
261
+ Дополнительные поля не требуются для первой версии.
262
+
263
+ ## Testing Strategy
264
+
265
+ ### 1. Config tests
266
+ Проверить:
267
+ - valid config
268
+ - missing file
269
+ - invalid JSON
270
+ - missing required fields
271
+
272
+ ### 2. Countdown UX tests
273
+ Проверить:
274
+ - overlay появляется после `agent_end`
275
+ - countdown обновляется
276
+ - `Enter` скрывает виджет и отправляет
277
+ - другая клавиша скрывает виджет и отменяет
278
+ - timeout скрывает виджет и отправляет
279
+ - новый `agent_end` отменяет старый countdown
280
+
281
+ ### 3. Formatting tests
282
+ Проверить:
283
+ - metadata header собирается корректно
284
+ - MarkdownV2 escaping корректен
285
+ - fallback в plain text работает
286
+ - длинный output переводится в `.md` document flow
287
+
288
+ ### 4. Telegram integration tests
289
+ Проверить:
290
+ - success path for `sendMessage`
291
+ - success path for `sendDocument`
292
+ - 401/403/429/5xx handled safely
293
+ - ошибки не ломают Pi session
294
+
295
+ ### 5. Packaging smoke tests
296
+ Проверить:
297
+ - package installs as `pi install npm:@brain0pia/pi-notify`
298
+ - Pi discovers extension from package manifest
299
+ - runtime dependencies resolve correctly
300
+
301
+ ## Success Criteria
302
+
303
+ Задача считается успешной, если:
304
+ - пакет устанавливается как `pi install npm:@brain0pia/pi-notify`
305
+ - после каждого ответа Pi появляется countdown-виджет
306
+ - `Enter` отправляет уведомление сразу
307
+ - любая другая клавиша отменяет отправку
308
+ - timeout автоматически отправляет уведомление
309
+ - Telegram получает metadata + output
310
+ - длинные ответы приходят как short message + `.md` file
311
+ - ошибки конфига и Telegram не ломают работу Pi
312
+
313
+ ## Out of Scope
314
+
315
+ В первой версии не включаем:
316
+ - несколько Telegram recipients
317
+ - несколько профилей/config presets
318
+ - retries с backoff и очередями доставки
319
+ - восстановление pending notifications после рестарта Pi
320
+ - commit design doc
321
+ - implementation plan
@@ -0,0 +1,143 @@
1
+ # Build and ship the `@brain0pia/pi-notify` Telegram notifier package
2
+
3
+ This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
4
+
5
+ This document must be maintained in accordance with `/home/bot/.pi/agent/skills/pi-skills-with-self-analysis/execplan/references/PLANS.md`.
6
+
7
+ ## Purpose / Big Picture
8
+
9
+ After this change, Pi users can install `@brain0pia/pi-notify` as a Pi package, configure a Telegram bot in `~/.pi/notify.json`, and automatically receive the final assistant output after each completed agent run. The user-visible behavior is a 30-second countdown overlay: pressing Enter sends immediately, pressing any other key cancels, and letting the countdown expire sends automatically. The way to see it working is to load the package in Pi, trigger an assistant response, watch the overlay appear in interactive mode, and verify that the extension either sends the Telegram notification or cancels it according to the chosen key.
10
+
11
+ ## Progress
12
+
13
+ - [x] (2026-03-13 18:01 UTC+8) Read the approved design in `docs/plans/2026-03-13-pi-telegram-notify-design.md`, read the ExecPlan methodology in `PLANS.md`, and reviewed the Pi package, extension, TUI, and SDK documentation plus relevant extension examples.
14
+ - [x] (2026-03-13 18:04 UTC+8) Created the npm/package scaffold (`package.json`, `README.md`, `.gitignore`, `tsconfig.json`) plus source and test directories.
15
+ - [x] (2026-03-13 18:05 UTC+8) Implemented the config loader, formatting helpers, Telegram transport, countdown overlay, controller, and extension entrypoint in `src/`.
16
+ - [x] (2026-03-13 18:06 UTC+8) Added automated tests for config parsing, formatting, Telegram fallback/document delivery, and controller behavior in `test/`.
17
+ - [x] (2026-03-13 18:07 UTC+8) Installed dependencies, fixed the initial version mismatch in `package.json`, and ran `npm run check`, `npm test`, and the local Pi smoke test successfully.
18
+ - [x] (2026-03-13 18:08 UTC+8) Initialized git, committed the repository, created the public GitHub repository with `gh`, and updated package metadata to the actual remote URL.
19
+ - [x] (2026-03-13 18:08 UTC+8) Updated this ExecPlan with final results, validation evidence, repository URL, and implementation notes.
20
+
21
+ ## Surprises & Discoveries
22
+
23
+ - Observation: the target directory already existed and already contained the approved design document, but it was not yet a git repository.
24
+ Evidence: `/home/bot/projects/pi-notify` initially contained only `docs/` and `git status` returned `fatal: not a git repository`.
25
+ - Observation: the npm versions suggested by nearby example packages were not available for `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui`; the currently published version is `0.57.1`.
26
+ Evidence: `npm install` failed with `No matching version found for @mariozechner/pi-coding-agent@^1.21.1`, and `npm view @mariozechner/pi-coding-agent version` returned `0.57.1`.
27
+ - Observation: the active GitHub account available through `gh` is `brainopia`, not `brain0pia`, so the repository had to be created under `brainopia/pi-notify` even though the npm package scope remains `@brain0pia`.
28
+ Evidence: `gh repo create brain0pia/pi-notify --public ...` returned `HTTP 404`, while `gh repo create brainopia/pi-notify --public ...` succeeded and `gh repo view brainopia/pi-notify` shows a public repository.
29
+
30
+ ## Decision Log
31
+
32
+ - Decision: keep the package as a TypeScript Pi package that ships the extension source directly instead of adding a build step.
33
+ Rationale: Pi loads extensions through `jiti`, the design already targets `src/*.ts`, and skipping a compile step keeps the package simpler for installation and maintenance.
34
+ Date/Author: 2026-03-13 / OpenAI Codex
35
+ - Decision: implement the notification orchestration in a controller module with small helper modules for config, formatting, Telegram transport, and overlay UI.
36
+ Rationale: the behavior includes concurrency, timers, and fallback paths, so a controller boundary makes the code easier to test without depending on a live Pi runtime.
37
+ Date/Author: 2026-03-13 / OpenAI Codex
38
+ - Decision: keep the npm package scope as `@brain0pia/pi-notify` from the approved design, but publish the GitHub repository under the authenticated account `brainopia/pi-notify`.
39
+ Rationale: the design fixes the package name, while GitHub repository ownership must match the available authenticated account in `gh`.
40
+ Date/Author: 2026-03-13 / OpenAI Codex
41
+ - Decision: use conservative local tests with mocked `fetch` and injected timer/countdown dependencies instead of trying to hit the real Telegram API during repository validation.
42
+ Rationale: the repository should validate without secrets, and the mocked tests still prove Markdown fallback, long-output document delivery, and pending-job replacement behavior.
43
+ Date/Author: 2026-03-13 / OpenAI Codex
44
+
45
+ ## Outcomes & Retrospective
46
+
47
+ Implementation complete. The repository now contains a working Pi package with a focused module layout, automated tests, and the approved Telegram-notification behavior. The package reads `~/.pi/notify.json`, extracts the final assistant text on `agent_end`, shows an interactive countdown overlay in UI mode, falls back to background timeout delivery when overlay UI is unavailable, and sends Telegram notifications with MarkdownV2 first and plain text second. Long outputs are routed through a short header message plus a `.md` attachment.
48
+
49
+ The acceptance criteria that could be verified locally are met. `npm run check` passes, `npm test` passes all 18 tests, and `pi -e /home/bot/projects/pi-notify -p "Reply with OK only."` returns `OK`, proving that Pi can load the package from a local path without crashing. The repository is public at `https://github.com/brainopia/pi-notify`. The main remaining work outside this repository is operational rather than implementation-related: a real user still needs to provide a valid `~/.pi/notify.json` and a reachable Telegram bot/chat pair to observe live delivery against the Telegram API.
50
+
51
+ ## Context and Orientation
52
+
53
+ The repository root is `/home/bot/projects/pi-notify`. The approved design source remains `docs/plans/2026-03-13-pi-telegram-notify-design.md`. The package metadata lives in `package.json`; it declares the Pi extension entrypoint through `pi.extensions` and keeps the package name as `@brain0pia/pi-notify`. The extension entrypoint is `src/index.ts`, which registers the runtime hooks and delegates work to `src/controller.ts`.
54
+
55
+ The code is organized so a newcomer can reason about the feature one layer at a time. `src/config.ts` reads and validates `~/.pi/notify.json`. `src/format.ts` builds the metadata header, Telegram-safe MarkdownV2 strings, short-message detection, and `.md` attachment content. `src/telegram.ts` talks to the Telegram Bot API using `sendMessage` and `sendDocument`, including Markdown parse-error fallback and automatic long-output attachment flow. `src/countdown.ts` implements the overlay component and keyboard handling through `ctx.ui.custom(..., { overlay: true })`. `src/controller.ts` owns the in-memory state machine that extracts assistant text, prevents multiple pending jobs, schedules background sends when needed, and reports local warnings without crashing Pi. The tests under `test/` mock the edges of the system so the behavior can be validated without external credentials.
56
+
57
+ ## Plan of Work
58
+
59
+ The implementation was completed in four layers. First, the repository was turned into a standard npm and git project with Pi package metadata, a README, TypeScript settings, and a committed lockfile. Second, the runtime modules were added so each piece of behavior has a narrow responsibility: config parsing, formatting, Telegram transport, countdown overlay, and orchestration. Third, the behavior was covered with isolated tests so the most failure-prone flows—missing config, Markdown parse fallback, oversized outputs, and pending-job replacement—can be revalidated on every change. Fourth, the repository was initialized as a public GitHub repository with `gh` and pushed to `main`.
60
+
61
+ The implementation stays close to the approved design but resolves a few practical details explicitly. The repository owner had to be `brainopia` because that is the authenticated GitHub account. The package still uses the `@brain0pia` npm scope because that was an approved design requirement and is independent of the GitHub owner. The overlay uses Pi’s centered overlay mode with a bordered component so keyboard capture remains local to the countdown UI. Background delivery remains in-memory only, which matches the design’s “no persistence for pending jobs” rule.
62
+
63
+ ## Concrete Steps
64
+
65
+ Work from `/home/bot/projects/pi-notify`.
66
+
67
+ 1. Initialize the repository and scaffold package files.
68
+ 2. Add the source files under `src/` and tests under `test/`.
69
+ 3. Install dependencies and run validation.
70
+ 4. Commit the result and publish the repository with `gh`.
71
+
72
+ Commands executed during implementation:
73
+
74
+ cd /home/bot/projects/pi-notify
75
+ npm install
76
+ npm run check
77
+ npm test
78
+ pi -e /home/bot/projects/pi-notify -p "Reply with OK only."
79
+ git init -b main
80
+ git add .
81
+ git commit -m "Initial pi-notify package"
82
+ gh repo create brainopia/pi-notify --public --source=. --remote=origin --push
83
+
84
+ Important observed outputs:
85
+
86
+ npm run check
87
+ > tsc --noEmit
88
+
89
+ npm test
90
+ ✔ 18 tests passed
91
+
92
+ pi -e /home/bot/projects/pi-notify -p "Reply with OK only."
93
+ OK
94
+
95
+ gh repo view brainopia/pi-notify --json name,visibility,url,owner
96
+ {"name":"pi-notify","owner":{"login":"brainopia"},"url":"https://github.com/brainopia/pi-notify","visibility":"PUBLIC"}
97
+
98
+ ## Validation and Acceptance
99
+
100
+ Acceptance is met for the repository-backed implementation. The package loads in Pi from a local path, the TypeScript check passes, and the automated test suite passes. The implemented behavior matches the approved design in the source and tests: `src/controller.ts` enforces a single active pending job, `src/countdown.ts` maps Enter to immediate send and all other keys to cancel, `src/telegram.ts` retries with plain text on Telegram Markdown parse errors, and `src/format.ts` routes oversized outputs to a `.md` attachment flow.
101
+
102
+ The exact validation commands and results are:
103
+
104
+ - `cd /home/bot/projects/pi-notify && npm run check` → success, no TypeScript errors.
105
+ - `cd /home/bot/projects/pi-notify && npm test` → success, 18 tests passed.
106
+ - `cd /home/bot/projects/pi-notify && pi -e /home/bot/projects/pi-notify -p "Reply with OK only."` → success, output was `OK`.
107
+ - `gh repo view brainopia/pi-notify --json name,visibility,url,owner` → success, repository is public at `https://github.com/brainopia/pi-notify`.
108
+
109
+ A live Telegram end-to-end send was not executed in this repository session because that requires valid external credentials in `~/.pi/notify.json`. The code paths for that behavior are covered by mocked tests and are ready for manual verification once credentials are added.
110
+
111
+ ## Idempotence and Recovery
112
+
113
+ The repository setup is safe to repeat. Re-running `npm install`, `npm run check`, `npm test`, or the local Pi smoke test is idempotent. If `gh repo create` is re-run after the repository already exists, the safe recovery path is to use the existing remote (`origin`) and push normally. The runtime implementation is also idempotent by design: only the latest pending notification stays active, cancelled jobs clear their timers, and `session_shutdown` cancels any in-memory pending work without trying to replay it later.
114
+
115
+ If Telegram delivery fails at runtime, the extension reports a local warning through `ctx.ui.notify(...)` and does not crash Pi. If overlay creation fails, the controller falls back to background timeout delivery and still keeps only one pending job active.
116
+
117
+ ## Artifacts and Notes
118
+
119
+ Important evidence captured during implementation:
120
+
121
+ - `npm run check` passed with no TypeScript errors.
122
+ - `npm test` passed with 18 tests.
123
+ - Local Pi smoke test: `pi -e /home/bot/projects/pi-notify -p "Reply with OK only."` returned `OK`.
124
+ - Public repository: `https://github.com/brainopia/pi-notify`.
125
+ - Initial implementation commit: `ee9bdcb` (`Initial pi-notify package`).
126
+
127
+ The working tree should be clean after committing the final metadata update and ExecPlan revision.
128
+
129
+ ## Interfaces and Dependencies
130
+
131
+ The package exports the default Pi extension factory from `src/index.ts`:
132
+
133
+ export default function registerPiNotify(pi: ExtensionAPI): void
134
+
135
+ `src/config.ts` exposes `loadNotifyConfig()` and `validateNotifyConfig()` to normalize `botToken` and `chatId` from `~/.pi/notify.json`.
136
+
137
+ `src/controller.ts` exports `PiNotifyController`, `extractAssistantText()`, and `createNotificationPayload()`. The controller accepts the `agent_end` message list plus `ExtensionContext`, extracts the latest assistant message, and ensures that only one pending notification job is active at a time.
138
+
139
+ `src/telegram.ts` exports `sendTelegramNotification()` and uses the Telegram Bot API methods `sendMessage` and `sendDocument` over `fetch`. `src/countdown.ts` exports `showCountdownOverlay()` and uses Pi overlay UI plus `@mariozechner/pi-tui` keyboard helpers. `package.json` declares `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` as peer dependencies and development dependencies so the package works inside Pi and can also be type-checked locally.
140
+
141
+ Plan created on 2026-03-13 because the user explicitly requested an ExecPlan-backed implementation based on the approved Telegram notify design.
142
+
143
+ Revision note (2026-03-13): updated after implementation to record the finished source layout, dependency version correction, validation commands/results, GitHub ownership adjustment (`brainopia/pi-notify`), and the public repository URL.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@brain0pia/pi-notify",
3
+ "version": "0.1.0",
4
+ "description": "Pi package that sends Telegram notifications after each completed agent response.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "telegram",
10
+ "notifications"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/brainopia/pi-notify.git"
15
+ },
16
+ "homepage": "https://github.com/brainopia/pi-notify#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/brainopia/pi-notify/issues"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "docs",
27
+ "execplan"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "peerDependencies": {
35
+ "@mariozechner/pi-coding-agent": "*",
36
+ "@mariozechner/pi-tui": "*"
37
+ },
38
+ "devDependencies": {
39
+ "@mariozechner/pi-coding-agent": "^0.57.1",
40
+ "@mariozechner/pi-tui": "^0.57.1",
41
+ "@types/node": "^24.5.2",
42
+ "tsx": "^4.20.5",
43
+ "typescript": "^5.9.2"
44
+ },
45
+ "scripts": {
46
+ "check": "tsc --noEmit",
47
+ "test": "tsx --test test/**/*.test.ts"
48
+ }
49
+ }
package/src/config.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import type { ConfigLoadResult } from "./types.js";
6
+
7
+ export const NOTIFY_CONFIG_PATH = path.join(homedir(), ".pi", "notify.json");
8
+
9
+ export function validateNotifyConfig(value: unknown, configPath = NOTIFY_CONFIG_PATH): ConfigLoadResult {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
11
+ return { ok: false, reason: `pi-notify: expected an object in ${configPath}.` };
12
+ }
13
+
14
+ const record = value as Record<string, unknown>;
15
+ const botToken = typeof record.botToken === "string" ? record.botToken.trim() : "";
16
+ const chatIdValue = record.chatId;
17
+ const chatId =
18
+ typeof chatIdValue === "string"
19
+ ? chatIdValue.trim()
20
+ : typeof chatIdValue === "number" && Number.isFinite(chatIdValue)
21
+ ? String(chatIdValue)
22
+ : "";
23
+
24
+ if (!botToken) {
25
+ return { ok: false, reason: `pi-notify: missing \"botToken\" in ${configPath}.` };
26
+ }
27
+
28
+ if (!botToken.includes(":")) {
29
+ return { ok: false, reason: `pi-notify: botToken in ${configPath} does not look like a Telegram bot token.` };
30
+ }
31
+
32
+ if (!chatId) {
33
+ return { ok: false, reason: `pi-notify: missing \"chatId\" in ${configPath}.` };
34
+ }
35
+
36
+ return {
37
+ ok: true,
38
+ config: {
39
+ botToken,
40
+ chatId,
41
+ },
42
+ };
43
+ }
44
+
45
+ export async function loadNotifyConfig(configPath = NOTIFY_CONFIG_PATH): Promise<ConfigLoadResult> {
46
+ let raw: string;
47
+
48
+ try {
49
+ raw = await readFile(configPath, "utf8");
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
52
+ return { ok: false, reason: `pi-notify: config file not found at ${configPath}.` };
53
+ }
54
+
55
+ throw error;
56
+ }
57
+
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ return { ok: false, reason: `pi-notify: invalid JSON in ${configPath}: ${message}` };
64
+ }
65
+
66
+ return validateNotifyConfig(parsed, configPath);
67
+ }
@@ -0,0 +1,251 @@
1
+ import path from "node:path";
2
+
3
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+
5
+ import { loadNotifyConfig } from "./config.js";
6
+ import { showCountdownOverlay, type CountdownOverlayOptions } from "./countdown.js";
7
+ import { sendTelegramNotification } from "./telegram.js";
8
+ import type { ConfigLoadResult, CountdownDecision, NotificationPayload, NotifyConfig } from "./types.js";
9
+
10
+ interface TextContentLike {
11
+ type?: string;
12
+ text?: string;
13
+ }
14
+
15
+ export interface AgentMessageLike {
16
+ role?: string;
17
+ content?: unknown;
18
+ }
19
+
20
+ export interface PiNotifyControllerDependencies {
21
+ delayMs?: number;
22
+ loadConfig?: () => Promise<ConfigLoadResult>;
23
+ showCountdown?: (ctx: ExtensionContext, options: CountdownOverlayOptions) => Promise<CountdownDecision>;
24
+ sendNotification?: (config: NotifyConfig, payload: NotificationPayload) => Promise<unknown>;
25
+ setTimeoutFn?: typeof setTimeout;
26
+ clearTimeoutFn?: typeof clearTimeout;
27
+ now?: () => Date;
28
+ }
29
+
30
+ interface ActiveJob {
31
+ id: number;
32
+ cancel?: () => void;
33
+ timer?: ReturnType<typeof setTimeout>;
34
+ }
35
+
36
+ function isTextContent(value: unknown): value is TextContentLike {
37
+ return typeof value === "object" && value !== null && (value as { type?: string }).type === "text";
38
+ }
39
+
40
+ export function extractAssistantText(messages: readonly AgentMessageLike[]): string | null {
41
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
42
+ const message = messages[index];
43
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) {
44
+ continue;
45
+ }
46
+
47
+ const text = message.content
48
+ .filter(isTextContent)
49
+ .map((block) => block.text ?? "")
50
+ .join("\n")
51
+ .trim();
52
+
53
+ if (text.length > 0) {
54
+ return text;
55
+ }
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ export function createNotificationPayload(
62
+ messages: readonly AgentMessageLike[],
63
+ ctx: Pick<ExtensionContext, "cwd" | "model">,
64
+ now: Date,
65
+ ): NotificationPayload | null {
66
+ const assistantText = extractAssistantText(messages);
67
+ if (!assistantText) {
68
+ return null;
69
+ }
70
+
71
+ const cwd = ctx.cwd;
72
+ const basename = path.basename(cwd);
73
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
74
+
75
+ return {
76
+ metadata: {
77
+ project: basename.length > 0 ? basename : cwd,
78
+ cwd,
79
+ timestamp: now.toISOString(),
80
+ model,
81
+ },
82
+ assistantText,
83
+ };
84
+ }
85
+
86
+ function describeError(error: unknown): string {
87
+ return error instanceof Error ? error.message : String(error);
88
+ }
89
+
90
+ export class PiNotifyController {
91
+ private readonly delayMs: number;
92
+ private readonly loadConfig: () => Promise<ConfigLoadResult>;
93
+ private readonly showCountdown: (ctx: ExtensionContext, options: CountdownOverlayOptions) => Promise<CountdownDecision>;
94
+ private readonly sendNotification: (config: NotifyConfig, payload: NotificationPayload) => Promise<unknown>;
95
+ private readonly setTimeoutFn: typeof setTimeout;
96
+ private readonly clearTimeoutFn: typeof clearTimeout;
97
+ private readonly now: () => Date;
98
+
99
+ private activeJob: ActiveJob | null = null;
100
+ private nextJobId = 0;
101
+ private lastConfigWarning: string | null = null;
102
+
103
+ constructor(dependencies: PiNotifyControllerDependencies = {}) {
104
+ this.delayMs = dependencies.delayMs ?? 30_000;
105
+ this.loadConfig = dependencies.loadConfig ?? loadNotifyConfig;
106
+ this.showCountdown = dependencies.showCountdown ?? showCountdownOverlay;
107
+ this.sendNotification = dependencies.sendNotification ?? sendTelegramNotification;
108
+ this.setTimeoutFn = dependencies.setTimeoutFn ?? setTimeout;
109
+ this.clearTimeoutFn = dependencies.clearTimeoutFn ?? clearTimeout;
110
+ this.now = dependencies.now ?? (() => new Date());
111
+ }
112
+
113
+ async handleAgentEnd(messages: readonly AgentMessageLike[], ctx: ExtensionContext): Promise<void> {
114
+ const payload = createNotificationPayload(messages, ctx, this.now());
115
+ if (!payload) {
116
+ return;
117
+ }
118
+
119
+ const configResult = await this.loadConfig();
120
+ if (!configResult.ok) {
121
+ this.warnMissingConfig(ctx, configResult.reason);
122
+ return;
123
+ }
124
+
125
+ this.lastConfigWarning = null;
126
+ const job = this.startNewJob();
127
+
128
+ if (!ctx.hasUI) {
129
+ this.scheduleBackgroundSend(job, ctx, configResult.config, payload);
130
+ return;
131
+ }
132
+
133
+ try {
134
+ const decision = await this.showCountdown(ctx, {
135
+ delayMs: this.delayMs,
136
+ registerCancel: (cancel) => {
137
+ job.cancel = cancel;
138
+ },
139
+ });
140
+
141
+ if (!this.isActive(job.id)) {
142
+ return;
143
+ }
144
+
145
+ this.finishJob(job.id);
146
+ if (decision === "send" || decision === "timeout") {
147
+ await this.deliver(configResult.config, payload, ctx);
148
+ }
149
+ } catch (error) {
150
+ if (!this.isActive(job.id)) {
151
+ return;
152
+ }
153
+
154
+ this.notify(ctx, `pi-notify: overlay unavailable, sending automatically in ${Math.ceil(this.delayMs / 1000)}s.`, "warning");
155
+ this.scheduleBackgroundSend(job, ctx, configResult.config, payload);
156
+ const description = describeError(error);
157
+ if (description) {
158
+ this.notify(ctx, `pi-notify: overlay error: ${description}`, "warning");
159
+ }
160
+ }
161
+ }
162
+
163
+ shutdown(): void {
164
+ this.cancelActiveJob();
165
+ }
166
+
167
+ private startNewJob(): ActiveJob {
168
+ this.cancelActiveJob();
169
+ const job: ActiveJob = {
170
+ id: ++this.nextJobId,
171
+ };
172
+ this.activeJob = job;
173
+ return job;
174
+ }
175
+
176
+ private cancelActiveJob(): void {
177
+ const job = this.activeJob;
178
+ this.activeJob = null;
179
+
180
+ if (!job) {
181
+ return;
182
+ }
183
+
184
+ if (job.timer) {
185
+ this.clearTimeoutFn(job.timer);
186
+ }
187
+
188
+ job.cancel?.();
189
+ }
190
+
191
+ private scheduleBackgroundSend(
192
+ job: ActiveJob,
193
+ ctx: ExtensionContext,
194
+ config: NotifyConfig,
195
+ payload: NotificationPayload,
196
+ ): void {
197
+ job.cancel = () => {
198
+ if (job.timer) {
199
+ this.clearTimeoutFn(job.timer);
200
+ }
201
+ };
202
+
203
+ job.timer = this.setTimeoutFn(async () => {
204
+ if (!this.isActive(job.id)) {
205
+ return;
206
+ }
207
+
208
+ this.finishJob(job.id);
209
+ await this.deliver(config, payload, ctx);
210
+ }, this.delayMs);
211
+ }
212
+
213
+ private finishJob(jobId: number): void {
214
+ if (this.activeJob?.id !== jobId) {
215
+ return;
216
+ }
217
+
218
+ const timer = this.activeJob.timer;
219
+ this.activeJob = null;
220
+ if (timer) {
221
+ this.clearTimeoutFn(timer);
222
+ }
223
+ }
224
+
225
+ private isActive(jobId: number): boolean {
226
+ return this.activeJob?.id === jobId;
227
+ }
228
+
229
+ private async deliver(config: NotifyConfig, payload: NotificationPayload, ctx: ExtensionContext): Promise<void> {
230
+ try {
231
+ await this.sendNotification(config, payload);
232
+ } catch (error) {
233
+ this.notify(ctx, `pi-notify: Telegram delivery failed: ${describeError(error)}`, "warning");
234
+ }
235
+ }
236
+
237
+ private warnMissingConfig(ctx: ExtensionContext, reason: string): void {
238
+ if (this.lastConfigWarning === reason) {
239
+ return;
240
+ }
241
+
242
+ this.lastConfigWarning = reason;
243
+ this.notify(ctx, reason, "warning");
244
+ }
245
+
246
+ private notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error"): void {
247
+ if (ctx.hasUI) {
248
+ ctx.ui.notify(message, type);
249
+ }
250
+ }
251
+ }
@@ -0,0 +1,120 @@
1
+ import type { ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
2
+ import type { Component, TUI } from "@mariozechner/pi-tui";
3
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
+
5
+ import type { CountdownDecision } from "./types.js";
6
+
7
+ export interface CountdownOverlayOptions {
8
+ delayMs: number;
9
+ registerCancel?: (cancel: () => void) => void;
10
+ }
11
+
12
+ function clampRemainingSeconds(targetTime: number): number {
13
+ return Math.max(0, Math.ceil((targetTime - Date.now()) / 1000));
14
+ }
15
+
16
+ class CountdownOverlayComponent implements Component {
17
+ private readonly targetTime: number;
18
+ private readonly interval: NodeJS.Timeout;
19
+ private closed = false;
20
+
21
+ constructor(
22
+ private readonly tui: TUI,
23
+ private readonly theme: Theme,
24
+ delayMs: number,
25
+ private readonly finish: (decision: CountdownDecision) => void,
26
+ ) {
27
+ this.targetTime = Date.now() + delayMs;
28
+ this.interval = setInterval(() => {
29
+ if (this.closed) {
30
+ return;
31
+ }
32
+
33
+ if (clampRemainingSeconds(this.targetTime) <= 0) {
34
+ this.finish("timeout");
35
+ return;
36
+ }
37
+
38
+ this.tui.requestRender();
39
+ }, 250);
40
+ }
41
+
42
+ handleInput(data: string): void {
43
+ if (matchesKey(data, Key.enter)) {
44
+ this.finish("send");
45
+ return;
46
+ }
47
+
48
+ this.finish("cancel");
49
+ }
50
+
51
+ render(width: number): string[] {
52
+ const outerWidth = Math.max(28, width);
53
+ const innerWidth = Math.max(26, outerWidth - 2);
54
+ const remaining = clampRemainingSeconds(this.targetTime);
55
+ const title = this.theme.fg("accent", this.theme.bold("Pi finished"));
56
+ const body = this.theme.fg("text", `Sending to Telegram in ${remaining}s`);
57
+ const hintSend = this.theme.fg("success", "Enter — send now");
58
+ const hintCancel = this.theme.fg("warning", "Any other key — cancel");
59
+
60
+ const pad = (value: string): string => {
61
+ const visible = visibleWidth(value);
62
+ return value + " ".repeat(Math.max(0, innerWidth - visible));
63
+ };
64
+
65
+ const row = (value = ""): string => {
66
+ const trimmed = truncateToWidth(value, innerWidth, "");
67
+ return this.theme.fg("borderAccent", "│") + pad(trimmed) + this.theme.fg("borderAccent", "│");
68
+ };
69
+
70
+ return [
71
+ this.theme.fg("borderAccent", `╭${"─".repeat(innerWidth)}╮`),
72
+ row(` ${title}`),
73
+ row(""),
74
+ row(` ${body}`),
75
+ row(""),
76
+ row(` ${hintSend}`),
77
+ row(` ${hintCancel}`),
78
+ this.theme.fg("borderAccent", `╰${"─".repeat(innerWidth)}╯`),
79
+ ];
80
+ }
81
+
82
+ invalidate(): void {}
83
+
84
+ dispose(): void {
85
+ this.closed = true;
86
+ clearInterval(this.interval);
87
+ }
88
+ }
89
+
90
+ export async function showCountdownOverlay(
91
+ ctx: ExtensionContext,
92
+ options: CountdownOverlayOptions,
93
+ ): Promise<CountdownDecision> {
94
+ return ctx.ui.custom<CountdownDecision>(
95
+ (tui, theme, _keybindings, done) => {
96
+ let completed = false;
97
+ const finish = (decision: CountdownDecision) => {
98
+ if (completed) {
99
+ return;
100
+ }
101
+
102
+ completed = true;
103
+ done(decision);
104
+ };
105
+
106
+ options.registerCancel?.(() => finish("replaced"));
107
+
108
+ return new CountdownOverlayComponent(tui, theme, options.delayMs, finish);
109
+ },
110
+ {
111
+ overlay: true,
112
+ overlayOptions: {
113
+ anchor: "center",
114
+ width: 40,
115
+ maxHeight: 10,
116
+ margin: 1,
117
+ },
118
+ },
119
+ );
120
+ }
package/src/format.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { NotificationMetadata, NotificationPayload } from "./types.js";
2
+
3
+ export const TELEGRAM_MESSAGE_LIMIT = 4096;
4
+ const EMPTY_ASSISTANT_MESSAGE = "(assistant message was empty)";
5
+
6
+ const MARKDOWN_V2_SPECIALS = /[\\_*\[\]()~`>#+\-=|{}.!]/g;
7
+
8
+ function normalizeAssistantText(text: string): string {
9
+ const normalized = text.trim();
10
+ return normalized.length > 0 ? normalized : EMPTY_ASSISTANT_MESSAGE;
11
+ }
12
+
13
+ function metadataLines(metadata: NotificationMetadata): string[] {
14
+ return [
15
+ `Project: ${metadata.project}`,
16
+ `CWD: ${metadata.cwd}`,
17
+ `Time: ${metadata.timestamp}`,
18
+ ...(metadata.model ? [`Model: ${metadata.model}`] : []),
19
+ ];
20
+ }
21
+
22
+ export function escapeMarkdownV2(text: string): string {
23
+ return text.replace(MARKDOWN_V2_SPECIALS, (char) => `\\${char}`);
24
+ }
25
+
26
+ export function buildPlainTextMessage(payload: NotificationPayload): string {
27
+ return [
28
+ "Pi finished",
29
+ ...metadataLines(payload.metadata),
30
+ "",
31
+ normalizeAssistantText(payload.assistantText),
32
+ ].join("\n");
33
+ }
34
+
35
+ export function buildMarkdownMessage(payload: NotificationPayload): string {
36
+ const assistantText = escapeMarkdownV2(normalizeAssistantText(payload.assistantText));
37
+ const metadata = [
38
+ "*Pi finished*",
39
+ ...metadataLines(payload.metadata).map((line) => escapeMarkdownV2(line)),
40
+ "",
41
+ assistantText,
42
+ ];
43
+
44
+ return metadata.join("\n");
45
+ }
46
+
47
+ export function buildLongOutputPlainTextNotice(payload: NotificationPayload): string {
48
+ return [
49
+ "Pi finished",
50
+ ...metadataLines(payload.metadata),
51
+ "",
52
+ "Full output attached as pi-notify-output.md",
53
+ ].join("\n");
54
+ }
55
+
56
+ export function buildLongOutputMarkdownNotice(payload: NotificationPayload): string {
57
+ return [
58
+ "*Pi finished*",
59
+ ...metadataLines(payload.metadata).map((line) => escapeMarkdownV2(line)),
60
+ "",
61
+ escapeMarkdownV2("Full output attached as pi-notify-output.md"),
62
+ ].join("\n");
63
+ }
64
+
65
+ export function buildAttachmentFilename(metadata: NotificationMetadata): string {
66
+ const sanitized = metadata.timestamp.replace(/[:]/g, "-");
67
+ return `pi-notify-${sanitized}.md`;
68
+ }
69
+
70
+ export function buildAttachmentMarkdown(payload: NotificationPayload): string {
71
+ const modelLine = payload.metadata.model ? `- Model: ${payload.metadata.model}` : undefined;
72
+
73
+ return [
74
+ "# Pi finished",
75
+ "",
76
+ `- Project: ${payload.metadata.project}`,
77
+ `- CWD: ${payload.metadata.cwd}`,
78
+ `- Time: ${payload.metadata.timestamp}`,
79
+ ...(modelLine ? [modelLine] : []),
80
+ "",
81
+ "## Assistant output",
82
+ "",
83
+ normalizeAssistantText(payload.assistantText),
84
+ "",
85
+ ].join("\n");
86
+ }
87
+
88
+ export function shouldSendAsDocument(payload: NotificationPayload): boolean {
89
+ return (
90
+ buildPlainTextMessage(payload).length > TELEGRAM_MESSAGE_LIMIT ||
91
+ buildMarkdownMessage(payload).length > TELEGRAM_MESSAGE_LIMIT
92
+ );
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ import { PiNotifyController } from "./controller.js";
4
+
5
+ export default function registerPiNotify(pi: ExtensionAPI): void {
6
+ const controller = new PiNotifyController();
7
+
8
+ pi.on("agent_end", async (event, ctx) => {
9
+ await controller.handleAgentEnd(event.messages, ctx);
10
+ });
11
+
12
+ pi.on("session_shutdown", async () => {
13
+ controller.shutdown();
14
+ });
15
+ }
@@ -0,0 +1,139 @@
1
+ import {
2
+ buildAttachmentFilename,
3
+ buildAttachmentMarkdown,
4
+ buildLongOutputMarkdownNotice,
5
+ buildLongOutputPlainTextNotice,
6
+ buildMarkdownMessage,
7
+ buildPlainTextMessage,
8
+ shouldSendAsDocument,
9
+ } from "./format.js";
10
+ import type { NotificationPayload, NotifyConfig } from "./types.js";
11
+
12
+ export interface TelegramSendResult {
13
+ mode: "message" | "document";
14
+ }
15
+
16
+ export class TelegramApiError extends Error {
17
+ constructor(
18
+ public readonly method: string,
19
+ public readonly status: number,
20
+ public readonly description: string,
21
+ public readonly errorCode?: number,
22
+ ) {
23
+ super(`Telegram ${method} failed (${status}): ${description}`);
24
+ this.name = "TelegramApiError";
25
+ }
26
+ }
27
+
28
+ function telegramUrl(config: NotifyConfig, method: string): string {
29
+ return `https://api.telegram.org/bot${config.botToken}/${method}`;
30
+ }
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function isMarkdownParseError(error: TelegramApiError): boolean {
37
+ return /parse entities|can't parse entities/i.test(error.description);
38
+ }
39
+
40
+ function isMessageTooLong(error: TelegramApiError): boolean {
41
+ return /message is too long/i.test(error.description);
42
+ }
43
+
44
+ async function parseTelegramResponse(response: Response, method: string): Promise<unknown> {
45
+ const text = await response.text();
46
+ const payload = text.length > 0 ? JSON.parse(text) : {};
47
+
48
+ if (!response.ok || (isRecord(payload) && payload.ok === false)) {
49
+ const description = isRecord(payload) && typeof payload.description === "string" ? payload.description : response.statusText;
50
+ const errorCode = isRecord(payload) && typeof payload.error_code === "number" ? payload.error_code : undefined;
51
+ throw new TelegramApiError(method, response.status, description || `HTTP ${response.status}`, errorCode);
52
+ }
53
+
54
+ return payload;
55
+ }
56
+
57
+ async function postJson(fetchImpl: typeof fetch, config: NotifyConfig, method: string, body: Record<string, unknown>): Promise<void> {
58
+ const response = await fetchImpl(telegramUrl(config, method), {
59
+ method: "POST",
60
+ headers: {
61
+ "content-type": "application/json",
62
+ },
63
+ body: JSON.stringify(body),
64
+ });
65
+
66
+ await parseTelegramResponse(response, method);
67
+ }
68
+
69
+ async function sendMessage(fetchImpl: typeof fetch, config: NotifyConfig, text: string, parseMode?: "MarkdownV2"): Promise<void> {
70
+ const body: Record<string, unknown> = {
71
+ chat_id: config.chatId,
72
+ text,
73
+ };
74
+
75
+ if (parseMode) {
76
+ body.parse_mode = parseMode;
77
+ }
78
+
79
+ await postJson(fetchImpl, config, "sendMessage", body);
80
+ }
81
+
82
+ async function sendDocument(fetchImpl: typeof fetch, config: NotifyConfig, filename: string, content: string): Promise<void> {
83
+ const form = new FormData();
84
+ form.set("chat_id", config.chatId);
85
+ form.set("document", new Blob([content], { type: "text/markdown" }), filename);
86
+
87
+ const response = await fetchImpl(telegramUrl(config, "sendDocument"), {
88
+ method: "POST",
89
+ body: form,
90
+ });
91
+
92
+ await parseTelegramResponse(response, "sendDocument");
93
+ }
94
+
95
+ async function sendTextWithFallback(fetchImpl: typeof fetch, config: NotifyConfig, markdownText: string, plainText: string): Promise<void> {
96
+ try {
97
+ await sendMessage(fetchImpl, config, markdownText, "MarkdownV2");
98
+ } catch (error) {
99
+ if (error instanceof TelegramApiError && isMarkdownParseError(error)) {
100
+ await sendMessage(fetchImpl, config, plainText);
101
+ return;
102
+ }
103
+
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ async function sendDocumentFlow(fetchImpl: typeof fetch, config: NotifyConfig, payload: NotificationPayload): Promise<void> {
109
+ await sendTextWithFallback(
110
+ fetchImpl,
111
+ config,
112
+ buildLongOutputMarkdownNotice(payload),
113
+ buildLongOutputPlainTextNotice(payload),
114
+ );
115
+ await sendDocument(fetchImpl, config, buildAttachmentFilename(payload.metadata), buildAttachmentMarkdown(payload));
116
+ }
117
+
118
+ export async function sendTelegramNotification(
119
+ config: NotifyConfig,
120
+ payload: NotificationPayload,
121
+ fetchImpl: typeof fetch = fetch,
122
+ ): Promise<TelegramSendResult> {
123
+ if (shouldSendAsDocument(payload)) {
124
+ await sendDocumentFlow(fetchImpl, config, payload);
125
+ return { mode: "document" };
126
+ }
127
+
128
+ try {
129
+ await sendTextWithFallback(fetchImpl, config, buildMarkdownMessage(payload), buildPlainTextMessage(payload));
130
+ return { mode: "message" };
131
+ } catch (error) {
132
+ if (error instanceof TelegramApiError && isMessageTooLong(error)) {
133
+ await sendDocumentFlow(fetchImpl, config, payload);
134
+ return { mode: "document" };
135
+ }
136
+
137
+ throw error;
138
+ }
139
+ }
package/src/types.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface NotifyConfig {
2
+ botToken: string;
3
+ chatId: string;
4
+ }
5
+
6
+ export interface NotificationMetadata {
7
+ project: string;
8
+ cwd: string;
9
+ timestamp: string;
10
+ model?: string;
11
+ }
12
+
13
+ export interface NotificationPayload {
14
+ metadata: NotificationMetadata;
15
+ assistantText: string;
16
+ }
17
+
18
+ export type CountdownDecision = "send" | "cancel" | "timeout" | "replaced";
19
+
20
+ export type ConfigLoadResult =
21
+ | { ok: true; config: NotifyConfig }
22
+ | { ok: false; reason: string };