@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"'`. Disable everything with `PI_STOP_NOTIFY_OFF=1`. `/notify status|test|off`.
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
- function makePi(): {
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: (name: string, opts: { description?: string; handler: CommandHandler }) => void;
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, { handler: opts.handler });
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("runs default command on agent_end (macOS): afplay + say with Samantha (Enhanced)", async () => {
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("PI_STOP_NOTIFY_VOICE overrides the default voice (macOS)", async () => {
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).toContain("say -v 'Alex'");
96
- expect(cmd).not.toContain("Samantha");
125
+ expect(cmd).toMatch(/^afplay \/System\/Library\/Sounds\/Glass\.aiff && say -v /);
126
+ expect(cmd).toContain("'Samantha (Enhanced)'");
97
127
  });
98
128
 
99
- it("PI_STOP_NOTIFY_VOICE with apostrophes is POSIX-safely quoted", async () => {
129
+ it("/notify off persists enabled=false and stops firing on agent_end", async () => {
100
130
  setPlatform("darwin");
101
- process.env.PI_STOP_NOTIFY_VOICE = "O'Neill";
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
- const cmd = execMock.mock.calls[0][0] as string;
107
- expect(cmd).toContain("say -v 'O'\\''Neill'");
139
+ expect(execMock).not.toHaveBeenCalled();
140
+ expect(ctx.ui.notify).toHaveBeenCalledWith("notify: off (persisted)", "info");
108
141
  });
109
142
 
110
- it("PI_STOP_NOTIFY_VOICE with only whitespace falls back to default voice (macOS)", async () => {
143
+ it("state survives across extension reloads", async () => {
111
144
  setPlatform("darwin");
112
- process.env.PI_STOP_NOTIFY_VOICE = " ";
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
- const cmd = execMock.mock.calls[0][0] as string;
118
- expect(cmd).toContain("'Samantha (Enhanced)'");
150
+ expect(execMock).toHaveBeenCalled();
119
151
  });
120
152
 
121
- it("PI_STOP_NOTIFY_VOICE is ignored when PI_STOP_NOTIFY overrides the full command", async () => {
153
+ it("PI_STOP_NOTIFY_OFF=1 hard-kills even when enabled is true", async () => {
122
154
  setPlatform("darwin");
123
- process.env.PI_STOP_NOTIFY = "my custom --cmd";
124
- process.env.PI_STOP_NOTIFY_VOICE = "Alex";
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.mock.calls[0][0]).toBe("my custom --cmd");
161
+ expect(execMock).not.toHaveBeenCalled();
130
162
  });
131
163
 
132
- it("PI_STOP_NOTIFY_VOICE has no effect on Linux default", async () => {
133
- setPlatform("linux");
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
- pi.handlers.agent_end({}, makeCtx());
139
- const cmd = execMock.mock.calls[0][0] as string;
140
- expect(cmd).toMatch(/notify-send/);
141
- expect(cmd).not.toContain("say");
142
- expect(cmd).not.toContain("Alex");
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("runs custom PI_STOP_NOTIFY over default", async () => {
177
+ it("/notify status reports hard-kill via env var", async () => {
146
178
  setPlatform("darwin");
147
- process.env.PI_STOP_NOTIFY = "my custom --cmd";
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
- pi.handlers.agent_end({}, makeCtx());
152
- expect(execMock.mock.calls[0][0]).toBe("my custom --cmd");
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("does NOT run anything when PI_STOP_NOTIFY_OFF=1", async () => {
189
+ it("/notify test runs the command immediately even when disabled", async () => {
156
190
  setPlatform("darwin");
157
- process.env.PI_STOP_NOTIFY_OFF = "1";
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
- pi.handlers.agent_end({}, makeCtx());
162
- expect(execMock).not.toHaveBeenCalled();
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("does nothing on an unsupported platform with no env", async () => {
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
- pi.handlers.agent_end({}, makeCtx());
171
- expect(execMock).not.toHaveBeenCalled();
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("Linux default uses notify-send", async () => {
175
- setPlatform("linux");
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]).toMatch(/notify-send/);
231
+ expect(execMock.mock.calls[0][0]).toBe("my custom --cmd");
181
232
  });
182
233
 
183
- it("trims whitespace on PI_STOP_NOTIFY and ignores empty strings", async () => {
234
+ it("PI_STOP_NOTIFY_VOICE overrides the macOS default voice", async () => {
184
235
  setPlatform("darwin");
185
- process.env.PI_STOP_NOTIFY = " ";
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
- expect(execMock.mock.calls[0][0]).toMatch(/afplay/);
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("/notify status reports current command", async () => {
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
- const ctx = makeCtx();
199
- await pi.commands.get("notify")!.handler("status", ctx);
200
- expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/afplay/), "info");
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("/notify status reports disabled when PI_STOP_NOTIFY_OFF", async () => {
259
+ it("PI_STOP_NOTIFY_VOICE with only whitespace falls back to default voice", async () => {
204
260
  setPlatform("darwin");
205
- process.env.PI_STOP_NOTIFY_OFF = "1";
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
- const ctx = makeCtx();
210
- await pi.commands.get("notify")!.handler("", ctx);
211
- expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/disabled/), "info");
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("/notify test runs the command immediately", async () => {
271
+ it("PI_STOP_NOTIFY_VOICE is ignored when PI_STOP_NOTIFY overrides the full command", async () => {
215
272
  setPlatform("darwin");
216
- process.env.PI_STOP_NOTIFY = "test-cmd";
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
- const ctx = makeCtx();
221
- await pi.commands.get("notify")!.handler("test", ctx);
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("/notify test warns when nothing configured", async () => {
226
- setPlatform("win32");
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
- const ctx = makeCtx();
231
- await pi.commands.get("notify")!.handler("test", ctx);
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("/notify off gives persistence guidance", async () => {
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 ctx = makeCtx();
241
- await pi.commands.get("notify")!.handler("off", ctx);
242
- expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/PI_STOP_NOTIFY_OFF/), "info");
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 status/test. Usage: /notify [status|test|off]. Configure via env PI_STOP_NOTIFY (full override) or PI_STOP_NOTIFY_VOICE (macOS default voice); disable with PI_STOP_NOTIFY_OFF=1.",
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 / disabled", "warning");
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
- if (sub === "off") {
98
+
99
+ if (sub === "on") {
100
+ enabled = true;
101
+ writeEnabled(true);
102
+ const cmd = resolveCommand();
66
103
  ctx.ui.notify(
67
- `notify: to disable persistently, set ${ENV_OFF}=1 in your shell rc; this session's behavior is driven by env at startup.`,
68
- "info",
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: disabled (no default for this platform or ${ENV_OFF}=1)`,
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"