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 +217 -0
- package/examples/notifi.json +7 -0
- package/extensions/notifi.ts +475 -0
- package/package.json +45 -0
- package/scripts/notifi-focus +87 -0
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,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
|