@astrofoundry/pi-astro 0.9.0 → 0.10.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
CHANGED
|
@@ -62,7 +62,7 @@ pi # launch; confirm [Extensions] lists astro-agents, claude-glob
|
|
|
62
62
|
- `multi-edit` — registers the enhanced `edit` tool
|
|
63
63
|
- `gemini-image` — registers `gemini_image` tool (requires a Gemini API key; prompts and saves on first use)
|
|
64
64
|
- `security-guard` — blocks/prompts destructive bash commands and sensitive file access; configure at `~/.pi/agent/security-guard.json` (example written on first run); `/security-guard status|reload|test`
|
|
65
|
-
- `notify-on-stop` — runs a shell command when the agent finishes a turn (sound, voice, desktop notification). macOS default: plays the Glass system sound **and** speaks "Agent done" via `say` using the **`Samantha (Enhanced)`** voice. Linux default: `notify-send "pi" "Agent done"`. Override the voice with `PI_STOP_NOTIFY_VOICE=<voice-name>` (macOS only; e.g. `Alex`, `Karen`, `Daniel (Enhanced)`). Replace the full command with `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`.
|
|
65
|
+
- `notify-on-stop` — runs a shell command when the agent finishes a turn (sound, voice, desktop notification). **Default: off.** Enable with `/notify on` (state persists in `~/.pi/agent/notify-on-stop.json`); disable with `/notify off`. macOS default command: plays the Glass system sound **and** speaks "Agent done" via `say` using the **`Samantha (Enhanced)`** voice. Linux default: `notify-send "pi" "Agent done"`. Override the voice with `PI_STOP_NOTIFY_VOICE=<voice-name>` (macOS only; e.g. `Alex`, `Karen`, `Daniel (Enhanced)`). Replace the full command with `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`. Hard-kill (overrides `/notify on`) with `PI_STOP_NOTIFY_OFF=1`. Commands: `/notify [on|off|status|test]`. See [extensions/notify-on-stop/README.md](extensions/notify-on-stop/README.md) for full details.
|
|
66
66
|
- **macOS voice install (required once for the default):** open **System Settings → Accessibility → Spoken Content → System Voice → Manage Voices…**, expand **English**, check **Samantha (Enhanced)**, click **Done** to download (~500 MB – 1 GB). Verify with `say -v "Samantha (Enhanced)" hi`. If the voice is missing, `say` errors silently and you'll only hear the Glass sound.
|
|
67
67
|
- `vscode-image` — only active inside VS Code's integrated terminal (`TERM_PROGRAM=vscode`). Switches pi-tui's image output to the **Kitty graphics protocol** so images returned by tools like `gemini_image` render as real pixels instead of the `[Image: …]` text fallback. **Requires enabling `Terminal › Integrated: Enable Images` in VS Code settings** (off by default); restart the integrated terminal after flipping it. Disable with `PI_VSCODE_IMAGE_OFF=1`. `/vscode-image` prints current status.
|
|
68
68
|
- `claude-globals` — auto-injects `~/.claude/CLAUDE.md` into every pi session's system prompt
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# notify-on-stop
|
|
2
|
+
|
|
3
|
+
Plays a sound, speaks a message, or fires a desktop notification every time the pi agent finishes a turn (`agent_end` event). Cuts the "is it done yet?" tab-switching tax.
|
|
4
|
+
|
|
5
|
+
Ships as part of [`@astrofoundry/pi-astro`](../../README.md). No extra install.
|
|
6
|
+
|
|
7
|
+
## Default: off
|
|
8
|
+
|
|
9
|
+
The extension does **nothing** by default. Enable it once with:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
/notify on
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
State is persisted to `~/.pi/agent/notify-on-stop.json` and survives session restarts and `chezmoi apply`. To turn it back off:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/notify off
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
| Command | Effect |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `/notify on` | Enable for this and future sessions. Persists. |
|
|
26
|
+
| `/notify off` | Disable for this and future sessions. Persists. |
|
|
27
|
+
| `/notify` or `/notify status` | Print current state and the resolved command |
|
|
28
|
+
| `/notify test` | Run the resolved command immediately, regardless of on/off state |
|
|
29
|
+
|
|
30
|
+
Argument autocomplete is wired — type `/notify ` and tab-cycle.
|
|
31
|
+
|
|
32
|
+
## Defaults per platform
|
|
33
|
+
|
|
34
|
+
When enabled, the extension runs:
|
|
35
|
+
|
|
36
|
+
| Platform | Command |
|
|
37
|
+
|---|---|
|
|
38
|
+
| macOS | `afplay /System/Library/Sounds/Glass.aiff && say -v "Samantha (Enhanced)" "Agent done"` |
|
|
39
|
+
| Linux | `notify-send "pi" "Agent done"` (only if `notify-send` exists on `PATH`) |
|
|
40
|
+
| Windows / other | Nothing — wire your own command via `PI_STOP_NOTIFY` |
|
|
41
|
+
|
|
42
|
+
Errors from the notify command are swallowed — a broken sound or missing voice will never surface in pi output.
|
|
43
|
+
|
|
44
|
+
## macOS voice setup (one-time)
|
|
45
|
+
|
|
46
|
+
The default speaks via the `Samantha (Enhanced)` voice. If it isn't installed, the `say` command silently fails and you only hear the Glass sound. Install it once:
|
|
47
|
+
|
|
48
|
+
1. **System Settings → Accessibility → Spoken Content → System Voice → Manage Voices…**
|
|
49
|
+
2. Expand **English**, check **Samantha (Enhanced)**, click **Done** (downloads ~500 MB – 1 GB).
|
|
50
|
+
3. Verify: `say -v "Samantha (Enhanced)" hi`.
|
|
51
|
+
|
|
52
|
+
If you'd rather skip the install, override with `PI_STOP_NOTIFY_VOICE` or replace the whole command via `PI_STOP_NOTIFY` — see below.
|
|
53
|
+
|
|
54
|
+
## Configuration (env vars)
|
|
55
|
+
|
|
56
|
+
Env vars control **what** runs, not **whether**. The on/off toggle is `/notify on|off`.
|
|
57
|
+
|
|
58
|
+
Set in your shell rc (`.zshrc`/`.bashrc`) so they apply to every pi session.
|
|
59
|
+
|
|
60
|
+
### `PI_STOP_NOTIFY` — replace the full command
|
|
61
|
+
|
|
62
|
+
Wins over the platform default. Wins over `PI_STOP_NOTIFY_VOICE`.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Just play the sound, no speech
|
|
66
|
+
export PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff'
|
|
67
|
+
|
|
68
|
+
# Speak something custom
|
|
69
|
+
export PI_STOP_NOTIFY='say "Pi just finished, get back here"'
|
|
70
|
+
|
|
71
|
+
# Linux: dunst / libnotify
|
|
72
|
+
export PI_STOP_NOTIFY='notify-send "pi" "Done" -u low'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `PI_STOP_NOTIFY_VOICE` — change only the macOS voice
|
|
76
|
+
|
|
77
|
+
Used when you keep the default Glass+say behaviour but prefer a different voice. Ignored on Linux/Windows. Ignored when `PI_STOP_NOTIFY` is set.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
export PI_STOP_NOTIFY_VOICE="Alex"
|
|
81
|
+
export PI_STOP_NOTIFY_VOICE="Daniel (Enhanced)"
|
|
82
|
+
export PI_STOP_NOTIFY_VOICE="Karen"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
List available voices: `say -v ?`.
|
|
86
|
+
|
|
87
|
+
### `PI_STOP_NOTIFY_OFF=1` — hard kill
|
|
88
|
+
|
|
89
|
+
Overrides the persisted on/off state. Useful for CI / headless runs where you want the extension globally muted regardless of the state file.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
export PI_STOP_NOTIFY_OFF=1
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Quick recipes
|
|
96
|
+
|
|
97
|
+
| Goal | What to do |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Enable | `/notify on` |
|
|
100
|
+
| Disable | `/notify off` |
|
|
101
|
+
| Test the command without changing state | `/notify test` |
|
|
102
|
+
| Sound only, no voice | `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff'` |
|
|
103
|
+
| Voice only, no sound | `PI_STOP_NOTIFY='say "Done"'` |
|
|
104
|
+
| Different macOS voice | `PI_STOP_NOTIFY_VOICE="Alex"` |
|
|
105
|
+
| Custom desktop notification | `PI_STOP_NOTIFY='terminal-notifier -title pi -message Done -sound Glass'` |
|
|
106
|
+
| Hard-mute everywhere | `export PI_STOP_NOTIFY_OFF=1` |
|
|
107
|
+
|
|
108
|
+
## Troubleshooting
|
|
109
|
+
|
|
110
|
+
- **`/notify on` says "no command resolves on this platform"** — Windows or unknown platform. Set `PI_STOP_NOTIFY` to your own command.
|
|
111
|
+
- **Nothing happens on macOS even though `/notify status` shows `on`** — the Samantha (Enhanced) voice is probably missing. Either install it (above) or set `PI_STOP_NOTIFY_VOICE` to a voice you have. Run `/notify test` to see whether the command itself runs.
|
|
112
|
+
- **Nothing happens on Linux** — install `libnotify` (`apt install libnotify-bin`) or override `PI_STOP_NOTIFY`.
|
|
113
|
+
- **`/notify status` shows the right command but nothing happens** — the command is launched via `/bin/sh` with a 10-second timeout, and all errors are silenced. Run the command directly in a shell to see the real error.
|
|
114
|
+
- **Env-var changes don't take effect** — env vars are read once when the extension loads. Restart pi after changing them. The on/off state, by contrast, applies immediately.
|
|
@@ -5,7 +5,6 @@ const execMock = vi.fn();
|
|
|
5
5
|
vi.mock("node:child_process", () => ({
|
|
6
6
|
exec: (cmd: string, opts: unknown, cb?: unknown) => {
|
|
7
7
|
execMock(cmd, opts, cb);
|
|
8
|
-
// Return a chainable fake child with on/stdout/stderr
|
|
9
8
|
const fake = {
|
|
10
9
|
on: vi.fn(),
|
|
11
10
|
stdout: { on: vi.fn() },
|
|
@@ -15,15 +14,39 @@ vi.mock("node:child_process", () => ({
|
|
|
15
14
|
},
|
|
16
15
|
}));
|
|
17
16
|
|
|
17
|
+
const fakeFiles = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
vi.mock("node:fs", () => ({
|
|
20
|
+
existsSync: (p: string) => fakeFiles.has(p),
|
|
21
|
+
readFileSync: (p: string) => {
|
|
22
|
+
const value = fakeFiles.get(p);
|
|
23
|
+
if (value === undefined) throw new Error(`ENOENT: ${p}`);
|
|
24
|
+
return value;
|
|
25
|
+
},
|
|
26
|
+
writeFileSync: (p: string, data: string) => {
|
|
27
|
+
fakeFiles.set(p, data);
|
|
28
|
+
},
|
|
29
|
+
mkdirSync: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
18
32
|
type Handler = (event: unknown, ctx: unknown) => void | Promise<void>;
|
|
19
33
|
type CommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
|
|
20
34
|
|
|
21
|
-
|
|
35
|
+
interface FakePi {
|
|
22
36
|
handlers: Record<string, Handler>;
|
|
23
|
-
commands: Map<string, { handler: CommandHandler }>;
|
|
37
|
+
commands: Map<string, { handler: CommandHandler; getArgumentCompletions?: (p: string) => unknown }>;
|
|
24
38
|
on: (event: string, h: Handler) => void;
|
|
25
|
-
registerCommand: (
|
|
26
|
-
|
|
39
|
+
registerCommand: (
|
|
40
|
+
name: string,
|
|
41
|
+
opts: {
|
|
42
|
+
description?: string;
|
|
43
|
+
handler: CommandHandler;
|
|
44
|
+
getArgumentCompletions?: (p: string) => unknown;
|
|
45
|
+
},
|
|
46
|
+
) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makePi(): FakePi {
|
|
27
50
|
const handlers: Record<string, Handler> = {};
|
|
28
51
|
const commands = new Map();
|
|
29
52
|
return {
|
|
@@ -33,7 +56,10 @@ function makePi(): {
|
|
|
33
56
|
handlers[event] = h;
|
|
34
57
|
},
|
|
35
58
|
registerCommand: (name, opts) => {
|
|
36
|
-
commands.set(name, {
|
|
59
|
+
commands.set(name, {
|
|
60
|
+
handler: opts.handler,
|
|
61
|
+
getArgumentCompletions: opts.getArgumentCompletions,
|
|
62
|
+
});
|
|
37
63
|
},
|
|
38
64
|
};
|
|
39
65
|
}
|
|
@@ -48,6 +74,11 @@ function setPlatform(p: NodeJS.Platform): void {
|
|
|
48
74
|
Object.defineProperty(process, "platform", { value: p, configurable: true });
|
|
49
75
|
}
|
|
50
76
|
|
|
77
|
+
function seedEnabled(enabled: boolean): void {
|
|
78
|
+
const stateFile = `${process.env.HOME}/.pi/agent/notify-on-stop.json`;
|
|
79
|
+
fakeFiles.set(stateFile, JSON.stringify({ enabled }));
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
describe("notify-on-stop", () => {
|
|
52
83
|
const origCmd = process.env.PI_STOP_NOTIFY;
|
|
53
84
|
const origOff = process.env.PI_STOP_NOTIFY_OFF;
|
|
@@ -56,6 +87,7 @@ describe("notify-on-stop", () => {
|
|
|
56
87
|
beforeEach(() => {
|
|
57
88
|
vi.resetModules();
|
|
58
89
|
execMock.mockReset();
|
|
90
|
+
fakeFiles.clear();
|
|
59
91
|
delete process.env.PI_STOP_NOTIFY;
|
|
60
92
|
delete process.env.PI_STOP_NOTIFY_OFF;
|
|
61
93
|
delete process.env.PI_STOP_NOTIFY_VOICE;
|
|
@@ -71,174 +103,215 @@ describe("notify-on-stop", () => {
|
|
|
71
103
|
else process.env.PI_STOP_NOTIFY_VOICE = origVoice;
|
|
72
104
|
});
|
|
73
105
|
|
|
74
|
-
it("
|
|
106
|
+
it("defaults to off — agent_end runs nothing on a fresh install", async () => {
|
|
75
107
|
setPlatform("darwin");
|
|
76
108
|
const mod = await import("./index.ts");
|
|
77
109
|
const pi = makePi();
|
|
78
110
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
79
111
|
pi.handlers.agent_end({}, makeCtx());
|
|
80
|
-
expect(execMock).toHaveBeenCalled();
|
|
81
|
-
const cmd = execMock.mock.calls[0][0] as string;
|
|
82
|
-
expect(cmd).toMatch(/^afplay \/System\/Library\/Sounds\/Glass\.aiff && say -v /);
|
|
83
|
-
expect(cmd).toContain("'Samantha (Enhanced)'");
|
|
84
|
-
expect(cmd).toContain("'Agent done'");
|
|
112
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
85
113
|
});
|
|
86
114
|
|
|
87
|
-
it("
|
|
115
|
+
it("/notify on persists enabled=true and starts firing on agent_end (macOS default)", async () => {
|
|
88
116
|
setPlatform("darwin");
|
|
89
|
-
process.env.PI_STOP_NOTIFY_VOICE = "Alex";
|
|
90
117
|
const mod = await import("./index.ts");
|
|
91
118
|
const pi = makePi();
|
|
92
119
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
120
|
+
const ctx = makeCtx();
|
|
121
|
+
await pi.commands.get("notify")!.handler("on", ctx);
|
|
122
|
+
expect(fakeFiles.get(`${process.env.HOME}/.pi/agent/notify-on-stop.json`)).toContain('"enabled": true');
|
|
93
123
|
pi.handlers.agent_end({}, makeCtx());
|
|
94
124
|
const cmd = execMock.mock.calls[0][0] as string;
|
|
95
|
-
expect(cmd).
|
|
96
|
-
expect(cmd).
|
|
125
|
+
expect(cmd).toMatch(/^afplay \/System\/Library\/Sounds\/Glass\.aiff && say -v /);
|
|
126
|
+
expect(cmd).toContain("'Samantha (Enhanced)'");
|
|
97
127
|
});
|
|
98
128
|
|
|
99
|
-
it("
|
|
129
|
+
it("/notify off persists enabled=false and stops firing on agent_end", async () => {
|
|
100
130
|
setPlatform("darwin");
|
|
101
|
-
|
|
131
|
+
seedEnabled(true);
|
|
102
132
|
const mod = await import("./index.ts");
|
|
103
133
|
const pi = makePi();
|
|
104
134
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
135
|
+
const ctx = makeCtx();
|
|
136
|
+
await pi.commands.get("notify")!.handler("off", ctx);
|
|
137
|
+
expect(fakeFiles.get(`${process.env.HOME}/.pi/agent/notify-on-stop.json`)).toContain('"enabled": false');
|
|
105
138
|
pi.handlers.agent_end({}, makeCtx());
|
|
106
|
-
|
|
107
|
-
expect(
|
|
139
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
140
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith("notify: off (persisted)", "info");
|
|
108
141
|
});
|
|
109
142
|
|
|
110
|
-
it("
|
|
143
|
+
it("state survives across extension reloads", async () => {
|
|
111
144
|
setPlatform("darwin");
|
|
112
|
-
|
|
145
|
+
seedEnabled(true);
|
|
113
146
|
const mod = await import("./index.ts");
|
|
114
147
|
const pi = makePi();
|
|
115
148
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
116
149
|
pi.handlers.agent_end({}, makeCtx());
|
|
117
|
-
|
|
118
|
-
expect(cmd).toContain("'Samantha (Enhanced)'");
|
|
150
|
+
expect(execMock).toHaveBeenCalled();
|
|
119
151
|
});
|
|
120
152
|
|
|
121
|
-
it("
|
|
153
|
+
it("PI_STOP_NOTIFY_OFF=1 hard-kills even when enabled is true", async () => {
|
|
122
154
|
setPlatform("darwin");
|
|
123
|
-
|
|
124
|
-
process.env.
|
|
155
|
+
seedEnabled(true);
|
|
156
|
+
process.env.PI_STOP_NOTIFY_OFF = "1";
|
|
125
157
|
const mod = await import("./index.ts");
|
|
126
158
|
const pi = makePi();
|
|
127
159
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
128
160
|
pi.handlers.agent_end({}, makeCtx());
|
|
129
|
-
expect(execMock.
|
|
161
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
130
162
|
});
|
|
131
163
|
|
|
132
|
-
it("
|
|
133
|
-
setPlatform("
|
|
134
|
-
process.env.PI_STOP_NOTIFY_VOICE = "Alex";
|
|
164
|
+
it("/notify status reflects on/off", async () => {
|
|
165
|
+
setPlatform("darwin");
|
|
135
166
|
const mod = await import("./index.ts");
|
|
136
167
|
const pi = makePi();
|
|
137
168
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
const ctx = makeCtx();
|
|
170
|
+
await pi.commands.get("notify")!.handler("status", ctx);
|
|
171
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/notify: off/), "info");
|
|
172
|
+
await pi.commands.get("notify")!.handler("on", ctx);
|
|
173
|
+
await pi.commands.get("notify")!.handler("status", ctx);
|
|
174
|
+
expect(ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringMatching(/notify: on .* will run /), "info");
|
|
143
175
|
});
|
|
144
176
|
|
|
145
|
-
it("
|
|
177
|
+
it("/notify status reports hard-kill via env var", async () => {
|
|
146
178
|
setPlatform("darwin");
|
|
147
|
-
|
|
179
|
+
seedEnabled(true);
|
|
180
|
+
process.env.PI_STOP_NOTIFY_OFF = "1";
|
|
148
181
|
const mod = await import("./index.ts");
|
|
149
182
|
const pi = makePi();
|
|
150
183
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
const ctx = makeCtx();
|
|
185
|
+
await pi.commands.get("notify")!.handler("status", ctx);
|
|
186
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/PI_STOP_NOTIFY_OFF=1/), "info");
|
|
153
187
|
});
|
|
154
188
|
|
|
155
|
-
it("
|
|
189
|
+
it("/notify test runs the command immediately even when disabled", async () => {
|
|
156
190
|
setPlatform("darwin");
|
|
157
|
-
process.env.
|
|
191
|
+
process.env.PI_STOP_NOTIFY = "test-cmd";
|
|
158
192
|
const mod = await import("./index.ts");
|
|
159
193
|
const pi = makePi();
|
|
160
194
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
161
|
-
|
|
162
|
-
|
|
195
|
+
const ctx = makeCtx();
|
|
196
|
+
await pi.commands.get("notify")!.handler("test", ctx);
|
|
197
|
+
expect(execMock.mock.calls[0][0]).toBe("test-cmd");
|
|
163
198
|
});
|
|
164
199
|
|
|
165
|
-
it("
|
|
200
|
+
it("/notify test warns when nothing configured (unsupported platform)", async () => {
|
|
166
201
|
setPlatform("win32");
|
|
167
202
|
const mod = await import("./index.ts");
|
|
168
203
|
const pi = makePi();
|
|
169
204
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
const ctx = makeCtx();
|
|
206
|
+
await pi.commands.get("notify")!.handler("test", ctx);
|
|
207
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/nothing configured/), "warning");
|
|
172
208
|
});
|
|
173
209
|
|
|
174
|
-
it("
|
|
175
|
-
setPlatform("
|
|
210
|
+
it("/notify on warns if no command resolves on this platform", async () => {
|
|
211
|
+
setPlatform("win32");
|
|
212
|
+
const mod = await import("./index.ts");
|
|
213
|
+
const pi = makePi();
|
|
214
|
+
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
215
|
+
const ctx = makeCtx();
|
|
216
|
+
await pi.commands.get("notify")!.handler("on", ctx);
|
|
217
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
218
|
+
expect.stringMatching(/notify: on, but no command resolves/),
|
|
219
|
+
"warning",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("PI_STOP_NOTIFY overrides the default command", async () => {
|
|
224
|
+
setPlatform("darwin");
|
|
225
|
+
seedEnabled(true);
|
|
226
|
+
process.env.PI_STOP_NOTIFY = "my custom --cmd";
|
|
176
227
|
const mod = await import("./index.ts");
|
|
177
228
|
const pi = makePi();
|
|
178
229
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
179
230
|
pi.handlers.agent_end({}, makeCtx());
|
|
180
|
-
expect(execMock.mock.calls[0][0]).
|
|
231
|
+
expect(execMock.mock.calls[0][0]).toBe("my custom --cmd");
|
|
181
232
|
});
|
|
182
233
|
|
|
183
|
-
it("
|
|
234
|
+
it("PI_STOP_NOTIFY_VOICE overrides the macOS default voice", async () => {
|
|
184
235
|
setPlatform("darwin");
|
|
185
|
-
|
|
236
|
+
seedEnabled(true);
|
|
237
|
+
process.env.PI_STOP_NOTIFY_VOICE = "Alex";
|
|
186
238
|
const mod = await import("./index.ts");
|
|
187
239
|
const pi = makePi();
|
|
188
240
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
189
241
|
pi.handlers.agent_end({}, makeCtx());
|
|
190
|
-
|
|
242
|
+
const cmd = execMock.mock.calls[0][0] as string;
|
|
243
|
+
expect(cmd).toContain("say -v 'Alex'");
|
|
244
|
+
expect(cmd).not.toContain("Samantha");
|
|
191
245
|
});
|
|
192
246
|
|
|
193
|
-
it("
|
|
247
|
+
it("PI_STOP_NOTIFY_VOICE with apostrophes is POSIX-safely quoted", async () => {
|
|
194
248
|
setPlatform("darwin");
|
|
249
|
+
seedEnabled(true);
|
|
250
|
+
process.env.PI_STOP_NOTIFY_VOICE = "O'Neill";
|
|
195
251
|
const mod = await import("./index.ts");
|
|
196
252
|
const pi = makePi();
|
|
197
253
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
expect(
|
|
254
|
+
pi.handlers.agent_end({}, makeCtx());
|
|
255
|
+
const cmd = execMock.mock.calls[0][0] as string;
|
|
256
|
+
expect(cmd).toContain("say -v 'O'\\''Neill'");
|
|
201
257
|
});
|
|
202
258
|
|
|
203
|
-
it("
|
|
259
|
+
it("PI_STOP_NOTIFY_VOICE with only whitespace falls back to default voice", async () => {
|
|
204
260
|
setPlatform("darwin");
|
|
205
|
-
|
|
261
|
+
seedEnabled(true);
|
|
262
|
+
process.env.PI_STOP_NOTIFY_VOICE = " ";
|
|
206
263
|
const mod = await import("./index.ts");
|
|
207
264
|
const pi = makePi();
|
|
208
265
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
expect(
|
|
266
|
+
pi.handlers.agent_end({}, makeCtx());
|
|
267
|
+
const cmd = execMock.mock.calls[0][0] as string;
|
|
268
|
+
expect(cmd).toContain("'Samantha (Enhanced)'");
|
|
212
269
|
});
|
|
213
270
|
|
|
214
|
-
it("
|
|
271
|
+
it("PI_STOP_NOTIFY_VOICE is ignored when PI_STOP_NOTIFY overrides the full command", async () => {
|
|
215
272
|
setPlatform("darwin");
|
|
216
|
-
|
|
273
|
+
seedEnabled(true);
|
|
274
|
+
process.env.PI_STOP_NOTIFY = "my custom --cmd";
|
|
275
|
+
process.env.PI_STOP_NOTIFY_VOICE = "Alex";
|
|
217
276
|
const mod = await import("./index.ts");
|
|
218
277
|
const pi = makePi();
|
|
219
278
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
expect(execMock.mock.calls[0][0]).toBe("test-cmd");
|
|
279
|
+
pi.handlers.agent_end({}, makeCtx());
|
|
280
|
+
expect(execMock.mock.calls[0][0]).toBe("my custom --cmd");
|
|
223
281
|
});
|
|
224
282
|
|
|
225
|
-
it("
|
|
226
|
-
setPlatform("
|
|
283
|
+
it("Linux default uses notify-send", async () => {
|
|
284
|
+
setPlatform("linux");
|
|
285
|
+
seedEnabled(true);
|
|
227
286
|
const mod = await import("./index.ts");
|
|
228
287
|
const pi = makePi();
|
|
229
288
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/nothing configured/), "warning");
|
|
289
|
+
pi.handlers.agent_end({}, makeCtx());
|
|
290
|
+
expect(execMock.mock.calls[0][0]).toMatch(/notify-send/);
|
|
233
291
|
});
|
|
234
292
|
|
|
235
|
-
it("
|
|
293
|
+
it("argument autocomplete returns on/off/status/test", async () => {
|
|
236
294
|
setPlatform("darwin");
|
|
237
295
|
const mod = await import("./index.ts");
|
|
238
296
|
const pi = makePi();
|
|
239
297
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
298
|
+
const completions = pi.commands.get("notify")!.getArgumentCompletions!("");
|
|
299
|
+
expect(completions).toEqual(
|
|
300
|
+
expect.arrayContaining([
|
|
301
|
+
{ value: "on", label: "on" },
|
|
302
|
+
{ value: "off", label: "off" },
|
|
303
|
+
{ value: "status", label: "status" },
|
|
304
|
+
{ value: "test", label: "test" },
|
|
305
|
+
]),
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("argument autocomplete returns null when no candidate matches", async () => {
|
|
310
|
+
setPlatform("darwin");
|
|
311
|
+
const mod = await import("./index.ts");
|
|
312
|
+
const pi = makePi();
|
|
313
|
+
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
314
|
+
const completions = pi.commands.get("notify")!.getArgumentCompletions!("zzz");
|
|
315
|
+
expect(completions).toBeNull();
|
|
243
316
|
});
|
|
244
317
|
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
2
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
6
|
|
|
4
7
|
const ENV_CMD = "PI_STOP_NOTIFY";
|
|
@@ -8,6 +11,25 @@ const TIMEOUT_MS = 10_000;
|
|
|
8
11
|
const DEFAULT_VOICE = "Samantha (Enhanced)";
|
|
9
12
|
const MACOS_SPOKEN_MESSAGE = "Agent done";
|
|
10
13
|
|
|
14
|
+
const STATE_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
15
|
+
const STATE_FILE = path.join(STATE_DIR, "notify-on-stop.json");
|
|
16
|
+
|
|
17
|
+
interface PersistedState {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readEnabled(): boolean {
|
|
22
|
+
if (!fs.existsSync(STATE_FILE)) return false;
|
|
23
|
+
const raw = fs.readFileSync(STATE_FILE, "utf8");
|
|
24
|
+
const parsed = JSON.parse(raw) as PersistedState;
|
|
25
|
+
return parsed.enabled === true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeEnabled(enabled: boolean): void {
|
|
29
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
30
|
+
fs.writeFileSync(STATE_FILE, `${JSON.stringify({ enabled }, null, 2)}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
11
33
|
function shellQuote(value: string): string {
|
|
12
34
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
13
35
|
}
|
|
@@ -34,14 +56,16 @@ function resolveCommand(): string | undefined {
|
|
|
34
56
|
|
|
35
57
|
function runNotify(cmd: string): void {
|
|
36
58
|
const child = exec(cmd, { shell: "/bin/sh", timeout: TIMEOUT_MS });
|
|
37
|
-
// Swallow errors so a broken notify command never surfaces into pi output.
|
|
38
59
|
child.on("error", () => {});
|
|
39
60
|
child.stderr?.on("data", () => {});
|
|
40
61
|
child.stdout?.on("data", () => {});
|
|
41
62
|
}
|
|
42
63
|
|
|
43
64
|
export default function notifyOnStopExtension(pi: ExtensionAPI): void {
|
|
65
|
+
let enabled = readEnabled();
|
|
66
|
+
|
|
44
67
|
pi.on("agent_end", () => {
|
|
68
|
+
if (!enabled) return;
|
|
45
69
|
const cmd = resolveCommand();
|
|
46
70
|
if (!cmd) return;
|
|
47
71
|
runNotify(cmd);
|
|
@@ -49,31 +73,60 @@ export default function notifyOnStopExtension(pi: ExtensionAPI): void {
|
|
|
49
73
|
|
|
50
74
|
pi.registerCommand("notify", {
|
|
51
75
|
description:
|
|
52
|
-
"Stop-notify
|
|
76
|
+
"Stop-notify control. Usage: /notify [on|off|status|test]. State persists in ~/.pi/agent/notify-on-stop.json. Defaults to off; PI_STOP_NOTIFY_OFF=1 hard-kills regardless.",
|
|
77
|
+
getArgumentCompletions: (prefix) => {
|
|
78
|
+
const items = ["on", "off", "status", "test"];
|
|
79
|
+
const lower = prefix.toLowerCase();
|
|
80
|
+
const matches = items
|
|
81
|
+
.filter((value) => value.startsWith(lower))
|
|
82
|
+
.map((value) => ({ value, label: value }));
|
|
83
|
+
return matches.length > 0 ? matches : null;
|
|
84
|
+
},
|
|
53
85
|
handler: async (args, ctx) => {
|
|
54
86
|
const sub = args.trim().toLowerCase() || "status";
|
|
87
|
+
|
|
55
88
|
if (sub === "test") {
|
|
56
89
|
const cmd = resolveCommand();
|
|
57
90
|
if (!cmd) {
|
|
58
|
-
ctx.ui.notify("notify: nothing configured
|
|
91
|
+
ctx.ui.notify("notify: nothing configured (no platform default and no PI_STOP_NOTIFY)", "warning");
|
|
59
92
|
return;
|
|
60
93
|
}
|
|
61
94
|
runNotify(cmd);
|
|
62
95
|
ctx.ui.notify(`notify: ran "${cmd}"`, "info");
|
|
63
96
|
return;
|
|
64
97
|
}
|
|
65
|
-
|
|
98
|
+
|
|
99
|
+
if (sub === "on") {
|
|
100
|
+
enabled = true;
|
|
101
|
+
writeEnabled(true);
|
|
102
|
+
const cmd = resolveCommand();
|
|
66
103
|
ctx.ui.notify(
|
|
67
|
-
|
|
68
|
-
|
|
104
|
+
cmd
|
|
105
|
+
? `notify: on — will run "${cmd}" on agent_end`
|
|
106
|
+
: `notify: on, but no command resolves on this platform (set PI_STOP_NOTIFY)`,
|
|
107
|
+
cmd ? "info" : "warning",
|
|
69
108
|
);
|
|
70
109
|
return;
|
|
71
110
|
}
|
|
111
|
+
|
|
112
|
+
if (sub === "off") {
|
|
113
|
+
enabled = false;
|
|
114
|
+
writeEnabled(false);
|
|
115
|
+
ctx.ui.notify("notify: off (persisted)", "info");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
const cmd = resolveCommand();
|
|
120
|
+
const hardKilled = process.env[ENV_OFF] === "1";
|
|
121
|
+
const stateLabel = hardKilled
|
|
122
|
+
? `off (PI_STOP_NOTIFY_OFF=1)`
|
|
123
|
+
: enabled
|
|
124
|
+
? "on"
|
|
125
|
+
: "off";
|
|
73
126
|
ctx.ui.notify(
|
|
74
|
-
cmd
|
|
75
|
-
? `notify: will run "${cmd}" on agent_end`
|
|
76
|
-
: `notify:
|
|
127
|
+
cmd && enabled && !hardKilled
|
|
128
|
+
? `notify: ${stateLabel} — will run "${cmd}" on agent_end`
|
|
129
|
+
: `notify: ${stateLabel}${cmd ? "" : " (no command resolves on this platform)"}`,
|
|
77
130
|
"info",
|
|
78
131
|
);
|
|
79
132
|
},
|