@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 +46 -0
- package/docs/plans/2026-03-13-pi-telegram-notify-design.md +321 -0
- package/execplan/2026-03-13-pi-telegram-notify-implementation.md +143 -0
- package/package.json +49 -0
- package/src/config.ts +67 -0
- package/src/controller.ts +251 -0
- package/src/countdown.ts +120 -0
- package/src/format.ts +93 -0
- package/src/index.ts +15 -0
- package/src/telegram.ts +139 -0
- package/src/types.ts +22 -0
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
|
+
}
|
package/src/countdown.ts
ADDED
|
@@ -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
|
+
}
|
package/src/telegram.ts
ADDED
|
@@ -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 };
|