@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.
Files changed (3) hide show
  1. package/README.md +66 -0
  2. package/package.json +31 -0
  3. 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
+ ![notify kitty](../../docs/images/notify-kitty.png)
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
+ }