@pi-archimedes/notify 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/package.json +31 -0
- package/src/index.ts +241 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @pi-archimedes/notify
|
|
2
|
+
|
|
3
|
+
Delayed desktop notifications with circuit breaker for the [Pi coding agent](https://github.com/earendil-works/pi).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Delayed notification** — fires only after a configurable period of inactivity (default 30s), so you're not spammed when actively working
|
|
8
|
+
- **Circuit breaker** — any keystroke immediately cancels a pending notification via raw terminal input listening
|
|
9
|
+
- **Terminal-aware dispatch** — auto-detects your terminal and uses the optimal protocol:
|
|
10
|
+
- Kitty → OSC 99 (two-sequence title + body)
|
|
11
|
+
- iTerm2 → OSC 9
|
|
12
|
+
- Windows Terminal → PowerShell toast (ToastText02)
|
|
13
|
+
- Ghostty, WezTerm, others → OSC 777 fallback
|
|
14
|
+
- **tmux passthrough** — all sequences wrapped via DCS for correct rendering inside tmux
|
|
15
|
+
- **Per-trigger toggles** — independently enable/disable notifications for task completion and unanswered questions
|
|
16
|
+
- **Bus-driven** — listens for `agent_end` and `ASK_REQUEST` bus events, so it works with any package that emits them
|
|
17
|
+
|
|
18
|
+
## Screenshots
|
|
19
|
+
|
|
20
|
+
### Kitty notification
|
|
21
|
+
|
|
22
|
+
Notification from Kitty terminal showing title and body via OSC 99:
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install @pi-archimedes/notify
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install the full [pi-archimedes](../..) meta package for the integrated experience:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install pi-archimedes
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Settings
|
|
39
|
+
|
|
40
|
+
| Setting | Type | Default | Description |
|
|
41
|
+
|---------|------|---------|-------------|
|
|
42
|
+
| `enabled` | bool | `true` | Enable desktop notifications |
|
|
43
|
+
| `notifyOnAgentEnd` | bool | `true` | Notify when agent finishes a task |
|
|
44
|
+
| `notifyOnQuestion` | bool | `true` | Notify when a question needs your answer |
|
|
45
|
+
| `delayMs` | number | `30` | Seconds to wait before sending notification (stored as ms) |
|
|
46
|
+
|
|
47
|
+
Settings are stored in `~/.pi/agent/settings.json` under the `archimedes.notify` namespace.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
When the agent finishes a task (`agent_end`) or a question is asked (`ASK_REQUEST`), a timer starts. If you don't interact for the configured delay, a desktop notification fires. Any keystroke — even just pressing a key without submitting — cancels the timer immediately.
|
|
52
|
+
|
|
53
|
+
## Terminal compatibility
|
|
54
|
+
|
|
55
|
+
| Terminal | Protocol | Title + Body |
|
|
56
|
+
|----------|----------|--------------|
|
|
57
|
+
| Kitty | OSC 99 | ✅ |
|
|
58
|
+
| iTerm2 | OSC 9 | Body only |
|
|
59
|
+
| Windows Terminal | PowerShell toast | ✅ |
|
|
60
|
+
| Ghostty | OSC 777 | ✅ |
|
|
61
|
+
| WezTerm | OSC 777 | ✅ |
|
|
62
|
+
| tmux (any above) | DCS passthrough | ✅ |
|
|
63
|
+
|
|
64
|
+
## Integration
|
|
65
|
+
|
|
66
|
+
When installed via `pi-archimedes` (the meta package), the notify package is automatically registered and its settings appear in the `/archimedes` settings panel. Standalone installs work independently — any package emitting `agent_end` or `ASK_REQUEST` bus events will trigger notifications.
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-archimedes/notify",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"description": "Delayed desktop notifications with circuit breaker for pi-archimedes",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@pi-archimedes/core": "1.4.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
21
|
+
"@earendil-works/pi-tui": ">=0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^6.0.0"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./src/index.ts"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
|
|
4
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
// ── Config ──────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface NotifyConfig {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
notifyOnAgentEnd: boolean;
|
|
12
|
+
notifyOnQuestion: boolean;
|
|
13
|
+
delayMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_NOTIFY_CONFIG: NotifyConfig = {
|
|
17
|
+
enabled: true,
|
|
18
|
+
notifyOnAgentEnd: true,
|
|
19
|
+
notifyOnQuestion: true,
|
|
20
|
+
delayMs: 30_000,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const NAMESPACE = "archimedes.notify";
|
|
24
|
+
|
|
25
|
+
export function loadNotifyConfig(): NotifyConfig {
|
|
26
|
+
return loadConfig(NAMESPACE, DEFAULT_NOTIFY_CONFIG);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveNotifyConfig(config: NotifyConfig): void {
|
|
30
|
+
saveConfig(NAMESPACE, config);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Terminal detection helpers ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Wrap an OSC sequence for tmux passthrough. */
|
|
36
|
+
export function wrapForTmux(sequence: string): string {
|
|
37
|
+
if (!process.env.TMUX) {
|
|
38
|
+
return sequence;
|
|
39
|
+
}
|
|
40
|
+
// Escape inner ESC characters so tmux doesn't interpret them
|
|
41
|
+
const escaped = sequence.replace(/\x1b/g, "\x1b\x1b");
|
|
42
|
+
return `\x1bPtmux;${escaped}\x1b\\`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── OSC notification dispatchers ────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** OSC 777 — generic notify (used as fallback) */
|
|
48
|
+
export function notifyOSC777(title: string, body: string): void {
|
|
49
|
+
const seq = `\x1b]777;notify;${title};${body}\x07`;
|
|
50
|
+
process.stderr.write(wrapForTmux(seq));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** OSC 9 — Konsole / generic terminal */
|
|
54
|
+
export function notifyOSC9(message: string): void {
|
|
55
|
+
const seq = `\x1b]9;${message}\x07`;
|
|
56
|
+
process.stderr.write(wrapForTmux(seq));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** OSC 99 — Kitty terminal */
|
|
60
|
+
export function notifyOSC99(title: string, body: string): void {
|
|
61
|
+
const seq1 = `\x1b]99;i=1:d=0;${title}\x1b\\`;
|
|
62
|
+
const seq2 = `\x1b]99;i=1:p=${body}\x1b\\`;
|
|
63
|
+
process.stderr.write(wrapForTmux(seq1));
|
|
64
|
+
process.stderr.write(wrapForTmux(seq2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Windows Terminal — PowerShell toast notification */
|
|
68
|
+
export function notifyWindows(title: string, body: string): void {
|
|
69
|
+
const script = windowsToastScript(title, body);
|
|
70
|
+
execFile(
|
|
71
|
+
"powershell.exe",
|
|
72
|
+
["-NoProfile", "-Command", script],
|
|
73
|
+
{ shell: false },
|
|
74
|
+
() => {
|
|
75
|
+
/* ignore */
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Generate a self-contained PowerShell script for toast notifications */
|
|
81
|
+
export function windowsToastScript(title: string, body: string): string {
|
|
82
|
+
// Escape single quotes for PowerShell string interpolation
|
|
83
|
+
const escapedTitle = title.replace(/'/g, "''");
|
|
84
|
+
const escapedBody = body.replace(/'/g, "''");
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
"Add-Type -AssemblyName System.Runtime.WindowsRuntime",
|
|
88
|
+
"Add-Type -AssemblyName System.Runtime.InteropServices.WindowsRuntime",
|
|
89
|
+
'$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
|
|
90
|
+
'$textNodes = $xml.GetElementsByTagName("text")',
|
|
91
|
+
"$textNodes.Item(0).InnerText = '" + escapedTitle + "'",
|
|
92
|
+
"$textNodes.Item(1).InnerText = '" + escapedBody + "'",
|
|
93
|
+
'$toast = New-Object Windows.UI.Notifications.ToastNotification $xml',
|
|
94
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Pi').Show($toast)",
|
|
95
|
+
].join(";");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Main notify dispatcher ──────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Dispatch a notification to the appropriate terminal handler. */
|
|
101
|
+
export function notify(title: string, body: string): void {
|
|
102
|
+
// 1. Windows Terminal
|
|
103
|
+
if (process.env.WT_SESSION) {
|
|
104
|
+
notifyWindows(title, body);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Kitty
|
|
109
|
+
if (process.env.KITTY_WINDOW_ID) {
|
|
110
|
+
notifyOSC99(title, body);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. iTerm2
|
|
115
|
+
if (process.env.TERM_PROGRAM === "iTerm.app" || process.env.ITERM_SESSION_ID) {
|
|
116
|
+
notifyOSC9(body);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 4. Fallback — OSC 777
|
|
121
|
+
notifyOSC777(title, body);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Circuit breaker state ───────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
let notifyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
127
|
+
let pendingTrigger: "agent_end" | "ask_request" | null = null;
|
|
128
|
+
|
|
129
|
+
// ── Circuit breaker logic ───────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Cancel any pending notification timer. */
|
|
132
|
+
export function cancelPending(): void {
|
|
133
|
+
if (notifyTimer) {
|
|
134
|
+
clearTimeout(notifyTimer);
|
|
135
|
+
notifyTimer = null;
|
|
136
|
+
}
|
|
137
|
+
pendingTrigger = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Schedule a delayed notification (circuit breaker). */
|
|
141
|
+
export function scheduleNotify(trigger: "agent_end" | "ask_request"): void {
|
|
142
|
+
// Cancel any existing timer first
|
|
143
|
+
cancelPending();
|
|
144
|
+
|
|
145
|
+
// Load config fresh on each trigger
|
|
146
|
+
const config = loadNotifyConfig();
|
|
147
|
+
|
|
148
|
+
if (!config.enabled) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (trigger === "agent_end" && !config.notifyOnAgentEnd) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (trigger === "ask_request" && !config.notifyOnQuestion) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
pendingTrigger = trigger;
|
|
161
|
+
notifyTimer = setTimeout(() => {
|
|
162
|
+
fireNotification(pendingTrigger);
|
|
163
|
+
notifyTimer = null;
|
|
164
|
+
pendingTrigger = null;
|
|
165
|
+
}, config.delayMs);
|
|
166
|
+
|
|
167
|
+
// Don't block process exit
|
|
168
|
+
notifyTimer.unref();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Fire the actual notification based on the pending trigger. */
|
|
172
|
+
function fireNotification(trigger: "agent_end" | "ask_request" | null): void {
|
|
173
|
+
if (trigger === "agent_end") {
|
|
174
|
+
notify("Pi", "Task complete — waiting for input");
|
|
175
|
+
} else {
|
|
176
|
+
notify("Pi", "A question needs your answer");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Registration ────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/** Register the notify extension with the Pi agent. */
|
|
183
|
+
export function registerNotify(pi: ExtensionAPI): void {
|
|
184
|
+
pi.on("agent_end", () => scheduleNotify("agent_end"));
|
|
185
|
+
pi.on("input", () => cancelPending());
|
|
186
|
+
pi.on("before_agent_start", () => cancelPending());
|
|
187
|
+
pi.on("agent_start", () => cancelPending());
|
|
188
|
+
|
|
189
|
+
// Listen for ask requests from the bus (ask package emits this)
|
|
190
|
+
const unsubAskRequest = getBus().on(Events.ASK_REQUEST, () =>
|
|
191
|
+
scheduleNotify("ask_request"),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Listen for raw terminal keystrokes — cancel on any key press
|
|
195
|
+
let unsubTerminalInput: (() => void) | null = null;
|
|
196
|
+
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
197
|
+
unsubTerminalInput = ctx.ui.onTerminalInput((_data) => {
|
|
198
|
+
cancelPending();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
pi.on("session_shutdown", () => {
|
|
203
|
+
cancelPending();
|
|
204
|
+
unsubTerminalInput?.();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Settings UI ─────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/** Build settings UI items for the notify package. */
|
|
211
|
+
export function getNotifySettingsItems(config: NotifyConfig): SettingItem[] {
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
id: "enabled",
|
|
215
|
+
label: "Notifications",
|
|
216
|
+
description: "Enable desktop notifications",
|
|
217
|
+
currentValue: config.enabled ? "On" : "Off",
|
|
218
|
+
values: ["On", "Off"],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "notifyOnAgentEnd",
|
|
222
|
+
label: "Notify on task complete",
|
|
223
|
+
description: "Notify when agent finishes a task",
|
|
224
|
+
currentValue: config.notifyOnAgentEnd ? "On" : "Off",
|
|
225
|
+
values: ["On", "Off"],
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "notifyOnQuestion",
|
|
229
|
+
label: "Notify on question",
|
|
230
|
+
description: "Notify when a question needs your answer",
|
|
231
|
+
currentValue: config.notifyOnQuestion ? "On" : "Off",
|
|
232
|
+
values: ["On", "Off"],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "delayMs",
|
|
236
|
+
label: "Delay before notify",
|
|
237
|
+
description: "Seconds to wait before sending notification",
|
|
238
|
+
currentValue: String(config.delayMs / 1000) + "s",
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
}
|