pi-notifi 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,217 @@
1
+ # pi-notifi
2
+
3
+ A [pi](https://pi.dev/) package that sends desktop notifications when an
4
+ interactive pi task finishes, unless the tmux window containing that pi agent is
5
+ already visible in Hyprland.
6
+
7
+ Notifications include a `Focus` action that jumps back to the originating pi
8
+ agent by focusing the saved Hyprland/Ghostty/tmux target.
9
+
10
+ This package is intentionally developed for an Arch + Hyprland + Ghostty +
11
+ tmux + dunst workflow. If your setup differs, copy/fork it and adapt the small
12
+ focus script.
13
+
14
+ ## Requirements
15
+
16
+ - Linux with Hyprland
17
+ - Ghostty
18
+ - tmux
19
+ - dunst or another notification daemon compatible with `notify-send` actions
20
+ - `notify-send` from `libnotify`
21
+ - `dunstctl` if using the example keybinds
22
+ - `jq`
23
+
24
+ On Arch:
25
+
26
+ ```bash
27
+ sudo pacman -S libnotify dunst jq
28
+ ```
29
+
30
+ Your pi shell should be running inside tmux, and that tmux client should be
31
+ inside a Hyprland-managed Ghostty window.
32
+
33
+ ## Install
34
+
35
+ From npm, once published:
36
+
37
+ ```bash
38
+ pi install npm:pi-notifi
39
+ ```
40
+
41
+ From git:
42
+
43
+ ```bash
44
+ pi install git:github.com/<you>/pi-notifi
45
+ ```
46
+
47
+ For a one-off test without installing:
48
+
49
+ ```bash
50
+ pi -e git:github.com/<you>/pi-notifi
51
+ ```
52
+
53
+ For local development from this checkout:
54
+
55
+ ```bash
56
+ pi -e /absolute/path/to/pi-notifi
57
+ ```
58
+
59
+ The package manifest loads:
60
+
61
+ ```text
62
+ extensions/notifi.ts
63
+ ```
64
+
65
+ No manual symlink is required. The extension invokes the packaged focus helper
66
+ at:
67
+
68
+ ```text
69
+ scripts/notifi-focus
70
+ ```
71
+
72
+ ## My dunst / Hyprland setup
73
+
74
+ Hyprland binds:
75
+
76
+ ```ini
77
+ bind = SUPER, X, exec, dunstctl close
78
+ bind = SUPER, N, exec, dunstctl action 0 && dunstctl close
79
+ ```
80
+
81
+ Dunst mouse behavior:
82
+
83
+ ```ini
84
+ mouse_left_click = do_action, close_current
85
+ mouse_right_click = close_current
86
+ ```
87
+
88
+ Behavior:
89
+
90
+ - `SUPER X` / right click: close the current notification without action.
91
+ - `SUPER N` / left click: invoke the notification action, then close it.
92
+ - For notifi notifications, the action jumps to the Ghostty/tmux location that
93
+ produced that specific notification.
94
+
95
+ ## Commands
96
+
97
+ Inside pi:
98
+
99
+ ```text
100
+ /notifi status
101
+ /notifi test
102
+ /notifi on|enable
103
+ /notifi off|disable
104
+ ```
105
+
106
+ `on` / `off` are persisted into the current pi session.
107
+
108
+ ## Configuration
109
+
110
+ Configuration is read from the first valid JSON file that exists:
111
+
112
+ 1. `<project>/.pi/notifi.json`
113
+ 2. `~/.pi/agent/notifi.json`
114
+
115
+ Example:
116
+
117
+ ```json
118
+ {
119
+ "disabled": false,
120
+ "urgency": "normal",
121
+ "expireTime": 0,
122
+ "notifyOnError": true,
123
+ "notifyOnAbort": false
124
+ }
125
+ ```
126
+
127
+ An example file is included at:
128
+
129
+ ```text
130
+ examples/notifi.json
131
+ ```
132
+
133
+ Available JSON fields:
134
+
135
+ | Field | Default | Description |
136
+ | --------------- | ------------------------------------------------ | ------------------------------------------------------------------- |
137
+ | `disabled` | `false` | Start disabled |
138
+ | `title` | `<tmux-session>:<window-index>` or `pi` | Notification title |
139
+ | `body` | `Task Finished` / `Task Failed` / `Task Aborted` | Notification body |
140
+ | `urgency` | `normal` / `critical` | notify-send urgency |
141
+ | `expireTime` | `0` | notify-send expire time in ms; `0` requests persist until dismissed |
142
+ | `notifyOnError` | `true` | Notify when a task fails |
143
+ | `notifyOnAbort` | `false` | Notify when a task is aborted |
144
+
145
+ Environment variables with the old `PI_NOTIFI_*` names override JSON for quick
146
+ one-off changes. Invalid JSON config files are ignored so a bad config does not
147
+ break notification delivery.
148
+
149
+ ## Behavior
150
+
151
+ Notification is suppressed only when all of these are true:
152
+
153
+ 1. pi is running inside tmux.
154
+ 2. notifi identifies the tmux session/window containing the pi pane.
155
+ 3. an attached tmux client is currently viewing that same tmux window.
156
+ 4. that tmux client maps through its process tree to a Hyprland window.
157
+ 5. that Hyprland window is on a workspace visible on a monitor.
158
+
159
+ Pane focus does not matter for notification suppression. If the pi pane is
160
+ anywhere in the visible tmux window, no notification is sent. Notification
161
+ actions still try to focus the original pi pane after switching to the saved
162
+ tmux session/window.
163
+
164
+ If the tmux window is not visible, notifi sends a persistent notification with a
165
+ `Focus` action:
166
+
167
+ ```text
168
+ <title: tmux-session:window-index>
169
+ <body: Task Finished | Task Failed>
170
+ <action: Focus>
171
+ ```
172
+
173
+ Aborted tasks do not notify by default. Headless/print-mode pi runs do not
174
+ notify.
175
+
176
+ ## Action target cache
177
+
178
+ Each notification gets a unique UUID target id. The extension writes that
179
+ notification's jump target to:
180
+
181
+ ```text
182
+ ${XDG_CACHE_HOME:-~/.cache}/notifi/targets/<target-id>.json
183
+ ```
184
+
185
+ Each notification action captures its own target id, so multiple concurrent pi
186
+ agents and long-lived notifications do not overwrite each other.
187
+
188
+ `scripts/notifi-focus <target-id>`:
189
+
190
+ 1. validates the target id
191
+ 2. reads the target file
192
+ 3. deletes the consumed target file
193
+ 4. prunes target files older than 24 hours
194
+ 5. switches Hyprland to the saved Ghostty workspace/window when possible
195
+ 6. switches tmux to the saved session/window
196
+ 7. focuses the original pi pane if it still exists
197
+
198
+ If the original Ghostty window no longer exists but the tmux session/window
199
+ still exists, the action opens Ghostty attached to that tmux session/window. If
200
+ the tmux session/window no longer exists, it exits successfully and does
201
+ nothing.
202
+
203
+ Target files for notifications dismissed without action are cleaned up the next
204
+ time a notifi action is consumed.
205
+
206
+ ## Edge cases
207
+
208
+ - If multiple Ghostty windows are attached to the same tmux session, notifi
209
+ picks the first usable tmux client it can map back to Hyprland.
210
+ - If the saved Ghostty/Hyprland window is stale, notifi falls back to opening
211
+ Ghostty attached to the saved tmux session/window.
212
+ - If the saved tmux session or window no longer exists, the action exits
213
+ successfully and does nothing.
214
+ - If the saved tmux pane no longer exists, the action still switches to the
215
+ saved tmux window and skips pane focus.
216
+ - If the target file is missing, malformed, or older than 24 hours when pruning
217
+ runs, the action exits successfully and/or removes stale metadata.
@@ -0,0 +1,7 @@
1
+ {
2
+ "disabled": false,
3
+ "urgency": "normal",
4
+ "expireTime": 0,
5
+ "notifyOnError": true,
6
+ "notifyOnAbort": false
7
+ }
@@ -0,0 +1,475 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
3
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+
7
+ type NotifiState = {
8
+ enabled: boolean;
9
+ };
10
+
11
+ type TaskStatus = "finished" | "error" | "aborted";
12
+
13
+ type NotifiConfig = {
14
+ disabled: boolean;
15
+ title?: string;
16
+ body?: string;
17
+ urgency?: string;
18
+ expireTime?: string;
19
+ notifyOnError: boolean;
20
+ notifyOnAbort: boolean;
21
+ };
22
+
23
+ type NotifiFileConfig = Partial<{
24
+ disabled: boolean;
25
+ title: string;
26
+ body: string;
27
+ urgency: string;
28
+ expireTime: string | number;
29
+ notifyOnError: boolean;
30
+ notifyOnAbort: boolean;
31
+ }>;
32
+
33
+ type TmuxLocation = {
34
+ sessionId: string;
35
+ windowId: string;
36
+ paneId: string;
37
+ sessionName?: string;
38
+ windowIndex?: string;
39
+ };
40
+
41
+ type TmuxClient = {
42
+ pid: number;
43
+ tty?: string;
44
+ sessionId: string;
45
+ windowId: string;
46
+ };
47
+
48
+ type HyprClient = {
49
+ address?: string;
50
+ pid?: number;
51
+ mapped?: boolean;
52
+ hidden?: boolean;
53
+ visible?: boolean;
54
+ workspace?: {
55
+ id?: number;
56
+ };
57
+ };
58
+
59
+ type NotifiTarget = {
60
+ id: string;
61
+ workspaceId?: number;
62
+ hyprWindowAddress?: string;
63
+ tmuxSessionId: string;
64
+ tmuxWindowId: string;
65
+ tmuxPaneId: string;
66
+ tmuxClientTty?: string;
67
+ timestamp: number;
68
+ };
69
+
70
+ type HyprMonitor = {
71
+ activeWorkspace?: {
72
+ id?: number;
73
+ };
74
+ specialWorkspace?: {
75
+ id?: number;
76
+ };
77
+ };
78
+
79
+ const truthy = (value: string | undefined): boolean => {
80
+ if (!value) return false;
81
+ return ["1", "true", "yes", "on"].includes(value.toLowerCase());
82
+ };
83
+
84
+ const env = (name: string): string | undefined => {
85
+ const value = process.env[name];
86
+ return value && value.trim().length > 0 ? value : undefined;
87
+ };
88
+
89
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
90
+ const packageRoot = dirname(extensionDir);
91
+ const focusScriptPath = join(packageRoot, "scripts", "notifi-focus");
92
+
93
+ const fileExists = async (path: string): Promise<boolean> => {
94
+ try {
95
+ await access(path);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ };
101
+
102
+ const cacheHome = (): string => process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? "/tmp", ".cache");
103
+
104
+ const targetFile = (targetId: string): string => join(cacheHome(), "notifi", "targets", `${targetId}.json`);
105
+
106
+ const readConfigFile = async (cwd: string): Promise<NotifiFileConfig> => {
107
+ const paths = [join(cwd, ".pi", "notifi.json"), join(process.env.HOME ?? "", ".pi", "agent", "notifi.json")];
108
+
109
+ for (const path of paths) {
110
+ if (!path || !(await fileExists(path))) continue;
111
+
112
+ try {
113
+ const raw = await readFile(path, "utf8");
114
+ return JSON.parse(raw) as NotifiFileConfig;
115
+ } catch {
116
+ continue;
117
+ }
118
+ }
119
+
120
+ return {};
121
+ };
122
+
123
+ const configString = (value: unknown): string | undefined => {
124
+ if (typeof value === "string" && value.trim().length > 0) return value;
125
+ if (typeof value === "number") return String(value);
126
+ return undefined;
127
+ };
128
+
129
+ const configBoolean = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
130
+
131
+ const statusBody = (status: TaskStatus): string => {
132
+ if (status === "finished") return "Task Finished";
133
+ if (status === "aborted") return "Task Aborted";
134
+ return "Task Failed";
135
+ };
136
+
137
+ const getTmuxSessionTitle = async (pi: ExtensionAPI): Promise<string | undefined> => {
138
+ const pane = process.env.TMUX_PANE;
139
+ if (!process.env.TMUX || !pane) return undefined;
140
+
141
+ try {
142
+ const result = await pi.exec("tmux", ["display-message", "-p", "-t", pane, "#S:#{window_index}"], {
143
+ timeout: 2000,
144
+ });
145
+ const title = result.stdout.trim();
146
+ return title || undefined;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ };
151
+
152
+ const getConfig = async (pi: ExtensionAPI, ctx: ExtensionContext, status: TaskStatus): Promise<NotifiConfig> => {
153
+ const [tmuxTitle, fileConfig] = await Promise.all([getTmuxSessionTitle(pi), readConfigFile(ctx.cwd)]);
154
+
155
+ return {
156
+ disabled: truthy(process.env.PI_NOTIFI_DISABLED) || configBoolean(fileConfig.disabled) === true,
157
+ title: env("PI_NOTIFI_TITLE") ?? configString(fileConfig.title) ?? tmuxTitle ?? "pi",
158
+ body: env("PI_NOTIFI_BODY") ?? configString(fileConfig.body) ?? statusBody(status),
159
+ urgency: env("PI_NOTIFI_URGENCY") ?? configString(fileConfig.urgency) ?? (status === "finished" ? "normal" : "critical"),
160
+ expireTime: env("PI_NOTIFI_EXPIRE_TIME") ?? configString(fileConfig.expireTime) ?? "0",
161
+ notifyOnError: !truthy(process.env.PI_NOTIFI_NOTIFY_ON_ERROR_DISABLED) && configBoolean(fileConfig.notifyOnError) !== false,
162
+ notifyOnAbort: truthy(process.env.PI_NOTIFI_NOTIFY_ON_ABORT) || configBoolean(fileConfig.notifyOnAbort) === true,
163
+ };
164
+ };
165
+
166
+ const parseJsonArray = <T>(text: string): T[] | undefined => {
167
+ try {
168
+ const value = JSON.parse(text) as unknown;
169
+ return Array.isArray(value) ? (value as T[]) : undefined;
170
+ } catch {
171
+ return undefined;
172
+ }
173
+ };
174
+
175
+ const getTmuxLocation = async (pi: ExtensionAPI): Promise<TmuxLocation | undefined> => {
176
+ const pane = process.env.TMUX_PANE;
177
+ if (!process.env.TMUX || !pane) return undefined;
178
+
179
+ try {
180
+ const result = await pi.exec("tmux", ["display-message", "-p", "-t", pane, "#{session_id}\t#{window_id}\t#{pane_id}\t#S\t#{window_index}"], {
181
+ timeout: 2000,
182
+ });
183
+ const [sessionId, windowId, paneId, sessionName, windowIndex] = result.stdout.trim().split("\t");
184
+ if (!sessionId || !windowId || !paneId) return undefined;
185
+ return { sessionId, windowId, paneId, sessionName, windowIndex };
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ };
190
+
191
+ const getTmuxClients = async (pi: ExtensionAPI): Promise<TmuxClient[]> => {
192
+ try {
193
+ const result = await pi.exec(
194
+ "tmux",
195
+ ["list-clients", "-F", "#{client_pid}\t#{client_tty}\t#{session_id}\t#{window_id}"],
196
+ { timeout: 2000 },
197
+ );
198
+
199
+ return result.stdout
200
+ .split("\n")
201
+ .map((line) => line.trim())
202
+ .filter(Boolean)
203
+ .map((line) => {
204
+ const [pidText, tty, sessionId, windowId] = line.split("\t");
205
+ return { pid: Number(pidText), tty, sessionId, windowId };
206
+ })
207
+ .filter((client) => Number.isInteger(client.pid) && client.pid > 0 && !!client.sessionId && !!client.windowId);
208
+ } catch {
209
+ return [];
210
+ }
211
+ };
212
+
213
+ const getTmuxClientsForWindow = async (pi: ExtensionAPI, location: TmuxLocation): Promise<TmuxClient[]> => {
214
+ const clients = await getTmuxClients(pi);
215
+ return clients.filter((client) => client.sessionId === location.sessionId && client.windowId === location.windowId);
216
+ };
217
+
218
+ const getTmuxClientsForSession = async (pi: ExtensionAPI, location: TmuxLocation): Promise<TmuxClient[]> => {
219
+ const clients = await getTmuxClients(pi);
220
+ return clients.filter((client) => client.sessionId === location.sessionId);
221
+ };
222
+
223
+ const getAncestorPids = async (pid: number): Promise<number[]> => {
224
+ const pids: number[] = [];
225
+ let current = pid;
226
+
227
+ for (let depth = 0; depth < 64 && current > 1; depth++) {
228
+ pids.push(current);
229
+
230
+ try {
231
+ const status = await readFile(`/proc/${current}/status`, "utf8");
232
+ const parent = /^PPid:\s+(\d+)$/m.exec(status)?.[1];
233
+ if (!parent) break;
234
+ current = Number(parent);
235
+ } catch {
236
+ break;
237
+ }
238
+ }
239
+
240
+ return pids;
241
+ };
242
+
243
+ const getVisibleHyprWorkspaceIds = (monitors: HyprMonitor[]): Set<number> => {
244
+ const ids = new Set<number>();
245
+ for (const monitor of monitors) {
246
+ if (typeof monitor.activeWorkspace?.id === "number") ids.add(monitor.activeWorkspace.id);
247
+ if (typeof monitor.specialWorkspace?.id === "number" && monitor.specialWorkspace.id !== 0) {
248
+ ids.add(monitor.specialWorkspace.id);
249
+ }
250
+ }
251
+ return ids;
252
+ };
253
+
254
+ const hyprClientIsUsable = (client: HyprClient): boolean => {
255
+ return (
256
+ typeof client.address === "string" &&
257
+ client.address.length > 0 &&
258
+ typeof client.workspace?.id === "number" &&
259
+ client.mapped !== false &&
260
+ client.hidden !== true
261
+ );
262
+ };
263
+
264
+ const hyprClientIsVisible = (client: HyprClient, visibleWorkspaceIds: Set<number>): boolean => {
265
+ const workspaceId = client.workspace?.id;
266
+ return hyprClientIsUsable(client) && typeof workspaceId === "number" && visibleWorkspaceIds.has(workspaceId) && client.visible !== false;
267
+ };
268
+
269
+ const getHyprState = async (pi: ExtensionAPI): Promise<{ clients: HyprClient[]; visibleWorkspaceIds: Set<number> } | undefined> => {
270
+ try {
271
+ const [clientsResult, monitorsResult] = await Promise.all([
272
+ pi.exec("hyprctl", ["clients", "-j"], { timeout: 3000 }),
273
+ pi.exec("hyprctl", ["monitors", "-j"], { timeout: 3000 }),
274
+ ]);
275
+
276
+ const clients = parseJsonArray<HyprClient>(clientsResult.stdout);
277
+ const monitors = parseJsonArray<HyprMonitor>(monitorsResult.stdout);
278
+ if (!clients || !monitors) return undefined;
279
+
280
+ return { clients, visibleWorkspaceIds: getVisibleHyprWorkspaceIds(monitors) };
281
+ } catch {
282
+ return undefined;
283
+ }
284
+ };
285
+
286
+ const findHyprWindowForTmuxClient = async (
287
+ tmuxClientPid: number,
288
+ hyprClients: HyprClient[],
289
+ ): Promise<HyprClient | undefined> => {
290
+ const ancestorPids = new Set(await getAncestorPids(tmuxClientPid));
291
+ return hyprClients.find((client) => typeof client.pid === "number" && ancestorPids.has(client.pid) && hyprClientIsUsable(client));
292
+ };
293
+
294
+ const getPiTmuxWindowTarget = async (pi: ExtensionAPI, targetId: string): Promise<NotifiTarget | undefined> => {
295
+ const location = await getTmuxLocation(pi);
296
+ if (!location) return undefined;
297
+
298
+ const baseTarget: NotifiTarget = {
299
+ id: targetId,
300
+ tmuxSessionId: location.sessionId,
301
+ tmuxWindowId: location.windowId,
302
+ tmuxPaneId: location.paneId,
303
+ timestamp: Date.now(),
304
+ };
305
+
306
+ const [sessionClients, hyprState] = await Promise.all([getTmuxClientsForSession(pi, location), getHyprState(pi)]);
307
+ if (sessionClients.length === 0 || !hyprState) return baseTarget;
308
+
309
+ // Prefer a client already viewing the target window. If none exists, use any
310
+ // attached client for the same session, focus its Ghostty, then switch it to
311
+ // the target tmux window. This avoids opening a new Ghostty when the session
312
+ // is already visible but currently on a different tmux window.
313
+ const tmuxClients = [
314
+ ...sessionClients.filter((client) => client.windowId === location.windowId),
315
+ ...sessionClients.filter((client) => client.windowId !== location.windowId),
316
+ ];
317
+
318
+ for (const tmuxClient of tmuxClients) {
319
+ const hyprWindow = await findHyprWindowForTmuxClient(tmuxClient.pid, hyprState.clients);
320
+ if (!hyprWindow || typeof hyprWindow.workspace?.id !== "number" || !hyprWindow.address) continue;
321
+
322
+ return {
323
+ ...baseTarget,
324
+ workspaceId: hyprWindow.workspace.id,
325
+ hyprWindowAddress: hyprWindow.address,
326
+ tmuxClientTty: tmuxClient.tty,
327
+ };
328
+ }
329
+
330
+ return baseTarget;
331
+ };
332
+
333
+ const piTmuxWindowIsVisible = async (pi: ExtensionAPI): Promise<boolean> => {
334
+ const location = await getTmuxLocation(pi);
335
+ if (!location) return false;
336
+
337
+ const [tmuxClients, hyprState] = await Promise.all([getTmuxClientsForWindow(pi, location), getHyprState(pi)]);
338
+ if (tmuxClients.length === 0 || !hyprState) return false;
339
+
340
+ for (const tmuxClient of tmuxClients) {
341
+ const hyprWindow = await findHyprWindowForTmuxClient(tmuxClient.pid, hyprState.clients);
342
+ if (hyprWindow && hyprClientIsVisible(hyprWindow, hyprState.visibleWorkspaceIds)) return true;
343
+ }
344
+
345
+ return false;
346
+ };
347
+
348
+ const writeTarget = async (target: NotifiTarget | undefined): Promise<void> => {
349
+ if (!target) return;
350
+ const path = targetFile(target.id);
351
+ await mkdir(dirname(path), { recursive: true });
352
+ await writeFile(path, `${JSON.stringify(target, null, 2)}\n`, "utf8");
353
+ };
354
+
355
+ const getStatus = (messages: unknown[]): TaskStatus => {
356
+ for (let i = messages.length - 1; i >= 0; i--) {
357
+ const message = messages[i] as { role?: string; stopReason?: string };
358
+ if (message?.role !== "assistant") continue;
359
+ if (message.stopReason === "error") return "error";
360
+ if (message.stopReason === "aborted") return "aborted";
361
+ return "finished";
362
+ }
363
+ return "finished";
364
+ };
365
+
366
+ const sendNotification = async (pi: ExtensionAPI, config: NotifiConfig, targetId: string | undefined): Promise<void> => {
367
+ await pi.exec(
368
+ "bash",
369
+ [
370
+ "-c",
371
+ [
372
+ "set -euo pipefail",
373
+ "title=$1",
374
+ "body=$2",
375
+ "urgency=$3",
376
+ "expire_time=$4",
377
+ "target_id=$5",
378
+ "focus_script=$6",
379
+ "args=(--app-name pi --urgency \"$urgency\" --expire-time \"$expire_time\")",
380
+ "if [[ -z \"$target_id\" ]]; then",
381
+ " notify-send \"${args[@]}\" \"$title\" \"$body\"",
382
+ "else",
383
+ " args=(--wait --action=focus=Focus \"${args[@]}\")",
384
+ " (",
385
+ " action=$(notify-send \"${args[@]}\" \"$title\" \"$body\" || true)",
386
+ " if [[ \"$action\" == \"focus\" ]]; then",
387
+ " \"$focus_script\" \"$target_id\" >/dev/null 2>&1 || true",
388
+ " fi",
389
+ " ) >/dev/null 2>&1 &",
390
+ "fi",
391
+ ].join("\n"),
392
+ "notifi-send",
393
+ config.title ?? "pi",
394
+ config.body ?? "Task Finished",
395
+ config.urgency ?? "normal",
396
+ config.expireTime ?? "0",
397
+ targetId ?? "",
398
+ focusScriptPath,
399
+ ],
400
+ { timeout: 5000 },
401
+ );
402
+ };
403
+
404
+ const notify = async (pi: ExtensionAPI, ctx: ExtensionContext, status: TaskStatus) => {
405
+ const config = await getConfig(pi, ctx, status);
406
+ if (config.disabled) return;
407
+ if (status === "aborted" && !config.notifyOnAbort) return;
408
+ if (status === "error" && !config.notifyOnError) return;
409
+ if (await piTmuxWindowIsVisible(pi)) return;
410
+
411
+ try {
412
+ const targetId = randomUUID();
413
+ const target = await getPiTmuxWindowTarget(pi, targetId);
414
+ await writeTarget(target);
415
+ await sendNotification(pi, config, target?.id);
416
+ } catch (error) {
417
+ if (ctx.hasUI) {
418
+ ctx.ui.notify(
419
+ `notifi: notify-send failed: ${error instanceof Error ? error.message : String(error)}`,
420
+ "warning",
421
+ );
422
+ }
423
+ }
424
+ };
425
+
426
+ export default function (pi: ExtensionAPI) {
427
+ let state: NotifiState = {
428
+ enabled: true,
429
+ };
430
+
431
+ pi.on("session_start", async (_event, ctx) => {
432
+ for (const entry of ctx.sessionManager.getEntries()) {
433
+ const custom = entry as { type?: string; customType?: string; data?: Partial<NotifiState> };
434
+ if (custom.type === "custom" && custom.customType === "notifi-state" && typeof custom.data?.enabled === "boolean") {
435
+ state.enabled = custom.data.enabled;
436
+ }
437
+ }
438
+ });
439
+
440
+ pi.on("agent_end", async (event, ctx) => {
441
+ if (!ctx.hasUI) return;
442
+ if (!state.enabled) return;
443
+ // If queued steering/follow-up messages remain, this is not the final idle point yet.
444
+ if (ctx.hasPendingMessages()) return;
445
+ await notify(pi, ctx, getStatus(event.messages as unknown[]));
446
+ });
447
+
448
+ pi.registerCommand("notifi", {
449
+ description: "Manage desktop notifications when pi finishes a task: status | test | on/enable | off/disable",
450
+ handler: async (args, ctx) => {
451
+ const subcommand = args.trim().toLowerCase() || "status";
452
+ if (subcommand === "on" || subcommand === "enable") {
453
+ state.enabled = true;
454
+ pi.appendEntry("notifi-state", { enabled: true });
455
+ ctx.ui.notify("notifi enabled", "info");
456
+ return;
457
+ }
458
+
459
+ if (subcommand === "off" || subcommand === "disable") {
460
+ state.enabled = false;
461
+ pi.appendEntry("notifi-state", { enabled: false });
462
+ ctx.ui.notify("notifi disabled", "info");
463
+ return;
464
+ }
465
+
466
+ if (subcommand === "test") {
467
+ await notify(pi, ctx, "finished");
468
+ ctx.ui.notify("notifi test sent", "info");
469
+ return;
470
+ }
471
+
472
+ ctx.ui.notify(`notifi is ${state.enabled ? "enabled" : "disabled"}`, "info");
473
+ },
474
+ });
475
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "pi-notifi",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that sends focus-aware desktop notifications when pi finishes a task.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "notifications",
10
+ "notification",
11
+ "notify",
12
+ "notice",
13
+ "warning",
14
+ "ping",
15
+ "notify-send",
16
+ "hyprland",
17
+ "tmux",
18
+ "ghostty",
19
+ "dunst"
20
+ ],
21
+ "type": "module",
22
+ "files": [
23
+ "extensions/",
24
+ "scripts/",
25
+ "examples/",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit",
30
+ "check": "tsc --noEmit && bash -n scripts/notifi-focus"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./extensions/notifi.ts"
35
+ ]
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-coding-agent": "latest",
42
+ "@types/node": "latest",
43
+ "typescript": "latest"
44
+ }
45
+ }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ target_id="${1:-}"
5
+ targets_dir="${XDG_CACHE_HOME:-$HOME/.cache}/notifi/targets"
6
+
7
+ prune_targets() {
8
+ [[ -d "$targets_dir" ]] || return 0
9
+ find "$targets_dir" -maxdepth 1 -type f -name '*.json' -mmin +1440 -delete 2>/dev/null || true
10
+ }
11
+
12
+ if [[ -z "$target_id" ]]; then
13
+ prune_targets
14
+ exit 0
15
+ fi
16
+
17
+ # notifi currently generates UUIDs. Reject anything path-like so this helper
18
+ # cannot be used to read/delete files outside the target cache directory.
19
+ if [[ ! "$target_id" =~ ^[0-9a-fA-F-]{36}$ ]]; then
20
+ prune_targets
21
+ exit 0
22
+ fi
23
+
24
+ state_file="$targets_dir/${target_id}.json"
25
+
26
+ if [[ ! -f "$state_file" ]]; then
27
+ prune_targets
28
+ exit 0
29
+ fi
30
+
31
+ consume_bad_target() {
32
+ rm -f -- "$state_file"
33
+ prune_targets
34
+ exit 0
35
+ }
36
+
37
+ workspace_id="$(jq -r '.workspaceId // empty' "$state_file" 2>/dev/null)" || consume_bad_target
38
+ hypr_window_address="$(jq -r '.hyprWindowAddress // empty' "$state_file" 2>/dev/null)" || consume_bad_target
39
+ tmux_session_id="$(jq -r '.tmuxSessionId // empty' "$state_file" 2>/dev/null)" || consume_bad_target
40
+ tmux_window_id="$(jq -r '.tmuxWindowId // empty' "$state_file" 2>/dev/null)" || consume_bad_target
41
+ tmux_pane_id="$(jq -r '.tmuxPaneId // empty' "$state_file" 2>/dev/null)" || consume_bad_target
42
+ tmux_client_tty="$(jq -r '.tmuxClientTty // empty' "$state_file" 2>/dev/null)" || consume_bad_target
43
+
44
+ rm -f -- "$state_file"
45
+ prune_targets
46
+
47
+ if [[ -z "$tmux_session_id" || -z "$tmux_window_id" ]]; then
48
+ exit 0
49
+ fi
50
+
51
+ if ! tmux has-session -t "$tmux_session_id" 2>/dev/null; then
52
+ exit 0
53
+ fi
54
+
55
+ if ! tmux list-windows -t "$tmux_session_id" -F '#{window_id}' | grep -Fxq "$tmux_window_id"; then
56
+ exit 0
57
+ fi
58
+
59
+ hypr_window_exists() {
60
+ [[ -n "$hypr_window_address" ]] && \
61
+ hyprctl clients -j | jq -e --arg address "$hypr_window_address" '.[] | select(.address == $address)' >/dev/null
62
+ }
63
+
64
+ select_saved_pane() {
65
+ [[ -n "$tmux_pane_id" ]] || return 0
66
+ tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -Fxq "$tmux_pane_id" || return 0
67
+ tmux select-pane -t "$tmux_pane_id" 2>/dev/null || true
68
+ }
69
+
70
+ if [[ -n "$workspace_id" ]]; then
71
+ hyprctl dispatch workspace "$workspace_id" >/dev/null || true
72
+ fi
73
+
74
+ if hypr_window_exists; then
75
+ hyprctl dispatch focuswindow "address:$hypr_window_address" >/dev/null || true
76
+
77
+ if [[ -n "$tmux_client_tty" ]]; then
78
+ tmux switch-client -c "$tmux_client_tty" -t "$tmux_window_id" 2>/dev/null || tmux select-window -t "$tmux_window_id"
79
+ else
80
+ tmux select-window -t "$tmux_window_id"
81
+ fi
82
+ select_saved_pane
83
+ else
84
+ tmux select-window -t "$tmux_window_id"
85
+ select_saved_pane
86
+ ghostty -e tmux attach-session -t "$tmux_session_id" >/dev/null 2>&1 &
87
+ fi