@astrofoundry/pi-astro 0.8.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
@@ -1,6 +1,6 @@
1
1
  # @astrofoundry/pi-astro
2
2
 
3
- Personal customizations for the [pi coding agent](https://github.com/badlogic/pi-mono) — registers an `astro_agent` tool, a `grimoire` docs tool, a `claude-globals` rules-injection extension, 8 curated subagents, 3 skills, and an `astro` theme.
3
+ Personal customizations for the [pi coding agent](https://github.com/badlogic/pi-mono) — registers an `astro_agent` tool, a `grimoire` docs tool, a `claude-globals` rules-injection extension, a `caveman` compressed-output toggle, 8 curated subagents, 4 skills, and an `astro` theme.
4
4
 
5
5
  ## Full setup
6
6
 
@@ -62,16 +62,17 @@ 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
69
+ - `caveman` — `/caveman [lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off|status]` toggles a persistent compressed-output mode. No argument toggles between off and the default level (`full`). Active level is shown as a footer badge and survives `/reload`. The skill body at `skills/caveman/SKILL.md` is also available as a one-shot via `/skill:caveman`.
69
70
 
70
71
  **Bundled subagents** (callable via `astro_agent`):
71
72
  - `code-reviewer`, `google-tech-lead`, `spec-writer`, `tester-api`, `tester-ui`, `ui-architect`, `ui-design-system`, `ui-frontend-developer`
72
73
 
73
74
  **Skills** (loadable via `/skill:<name>`):
74
- - `playwright-cli`, `postman-cli`, `raycast-script-creator`
75
+ - `playwright-cli`, `postman-cli`, `raycast-script-creator`, `caveman`
75
76
 
76
77
  **Theme:**
77
78
  - `astro` — dark, gold accent, GitHub-dark-inspired
@@ -0,0 +1,80 @@
1
+ # caveman
2
+
3
+ Persistent compressed-output mode for the [pi coding agent](https://github.com/badlogic/pi-mono). Strips fillers, hedging, and connectives from assistant prose to cut output tokens by ~70-75% while preserving every technical fact. Code, error strings, command lines, paths, identifiers, and version numbers stay untouched.
4
+
5
+ Ships as part of [`@astrofoundry/pi-astro`](../../README.md). No extra install.
6
+
7
+ ## Quick start
8
+
9
+ In any pi session:
10
+
11
+ ```
12
+ /caveman
13
+ ```
14
+
15
+ Caveman is now on at level `full`. Type the same command again to switch it off. The active level is shown as a footer badge and survives `/reload`.
16
+
17
+ ## Commands
18
+
19
+ | Command | Effect |
20
+ |---|---|
21
+ | `/caveman` | Toggle: off → `full`, on → off |
22
+ | `/caveman lite` | Activate at level `lite` |
23
+ | `/caveman full` | Activate at level `full` (default) |
24
+ | `/caveman ultra` | Activate at level `ultra` |
25
+ | `/caveman wenyan-lite` | Activate at level `wenyan-lite` |
26
+ | `/caveman wenyan-full` | Activate at level `wenyan-full` |
27
+ | `/caveman wenyan-ultra` | Activate at level `wenyan-ultra` |
28
+ | `/caveman off` | Deactivate |
29
+ | `/caveman status` | Print the current level |
30
+
31
+ Argument autocomplete is wired — type `/caveman ` and tab-cycle the levels.
32
+
33
+ You can also deactivate by saying "stop caveman" or "normal mode" in plain prose.
34
+
35
+ ## Levels
36
+
37
+ Pick the trade-off between compression and readability:
38
+
39
+ | Level | Behaviour | Example answer to "Why does this React component re-render?" |
40
+ |---|---|---|
41
+ | `lite` | Drop fillers and pleasantries only. Full sentences, articles kept. | "Your component re-renders because every render creates a new object reference for the prop. Wrap it in `useMemo`." |
42
+ | `full` | Drop articles, fragments allowed. Default. | "New object ref each render. Inline obj prop = new ref = re-render. Wrap in `useMemo`." |
43
+ | `ultra` | Aggressive abbreviation, arrows for causality. | "Inline obj prop → new ref → re-render. `useMemo`." |
44
+ | `wenyan-lite` | Classical Chinese register, modern grammar. | "組件每次重繪皆生新對象參照。以 `useMemo` 包之。" |
45
+ | `wenyan-full` | Full 文言文, classical particles. | "每繪生新參照,故重繪。以 `useMemo` 包之。" |
46
+ | `wenyan-ultra` | Maximum classical compression. | "新參照→重繪。`useMemo` 包。" |
47
+
48
+ ## What stays normal
49
+
50
+ Caveman never applies to:
51
+
52
+ - **Source code, commit messages, PR descriptions, code review comments** — written in normal prose.
53
+ - **Documentation files** (`.md`, `.mdx`, `.rst`) — caveman is for chat, not artifacts.
54
+ - **Quoted log lines, stack traces, error messages** — reproduced exactly.
55
+
56
+ Caveman also drops itself temporarily for safety:
57
+
58
+ - Security warnings.
59
+ - Confirmations for destructive or irreversible operations.
60
+ - Multi-step procedures whose order matters.
61
+ - When you ask a clarifying question or repeat yourself.
62
+
63
+ The mode resumes immediately after the clarified step is complete.
64
+
65
+ ## One-shot vs persistent
66
+
67
+ Two ways to use it:
68
+
69
+ - **Persistent** — `/caveman` (or `/caveman <level>`). Stays on until you turn it off. Restored across `/reload` and session resume.
70
+ - **One-shot** — `/skill:caveman`. Loads the skill content for a single response, then the next reply returns to normal. Useful when you want a single terse answer without committing to a mode.
71
+
72
+ ## State and persistence
73
+
74
+ State lives in the pi session itself — no flag files in your home directory, no shell scripts, no symlink risk. Each session keeps its own toggle, so two pi sessions in different projects can run different modes.
75
+
76
+ ## Tips
77
+
78
+ - If you find caveman too clipped while debugging tricky logic, switch to `lite` for the duration: `/caveman lite`.
79
+ - Use `/caveman ultra` for short Q&A and debugging chains. Pair it with thinking levels and small models to keep latency low.
80
+ - The wenyan levels are useful when you genuinely want the classical register — they are not a joke language and require both you and the model to be comfortable reading 文言文.
@@ -0,0 +1,253 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ type Handler = (event: unknown, ctx: unknown) => unknown | Promise<unknown>;
4
+
5
+ interface CommandOptions {
6
+ description?: string;
7
+ handler: (args: string, ctx: unknown) => Promise<void> | void;
8
+ getArgumentCompletions?: (prefix: string) => unknown;
9
+ }
10
+
11
+ interface FakePi {
12
+ handlers: Record<string, Handler>;
13
+ commands: Map<string, CommandOptions>;
14
+ appendEntry: ReturnType<typeof vi.fn>;
15
+ on: (event: string, h: Handler) => void;
16
+ registerCommand: (name: string, opts: CommandOptions) => void;
17
+ }
18
+
19
+ function makePi(): FakePi {
20
+ const handlers: Record<string, Handler> = {};
21
+ const commands = new Map<string, CommandOptions>();
22
+ return {
23
+ handlers,
24
+ commands,
25
+ appendEntry: vi.fn(),
26
+ on(event, h) {
27
+ handlers[event] = h;
28
+ },
29
+ registerCommand(name, opts) {
30
+ commands.set(name, opts);
31
+ },
32
+ };
33
+ }
34
+
35
+ interface FakeCtx {
36
+ ui: {
37
+ notify: ReturnType<typeof vi.fn>;
38
+ setStatus: ReturnType<typeof vi.fn>;
39
+ };
40
+ sessionManager: { getEntries: () => unknown[] };
41
+ }
42
+
43
+ function makeCtx(entries: unknown[] = []): FakeCtx {
44
+ return {
45
+ ui: {
46
+ notify: vi.fn(),
47
+ setStatus: vi.fn(),
48
+ },
49
+ sessionManager: {
50
+ getEntries: () => entries,
51
+ },
52
+ };
53
+ }
54
+
55
+ describe("caveman extension", () => {
56
+ beforeEach(() => {
57
+ vi.resetModules();
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ async function load(): Promise<typeof import("./index.ts")> {
65
+ return await import("./index.ts");
66
+ }
67
+
68
+ async function install(): Promise<{ pi: FakePi; mod: Awaited<ReturnType<typeof load>> }> {
69
+ const mod = await load();
70
+ const pi = makePi();
71
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
72
+ return { pi, mod };
73
+ }
74
+
75
+ it("registers /caveman command and the two lifecycle handlers", async () => {
76
+ const mod = await load();
77
+ const pi = makePi();
78
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
79
+ expect(pi.commands.has("caveman")).toBe(true);
80
+ expect(pi.handlers.session_start).toBeDefined();
81
+ expect(pi.handlers.before_agent_start).toBeDefined();
82
+ });
83
+
84
+ it("/caveman with no args toggles on to default level (full)", async () => {
85
+ const { pi } = await install();
86
+ const ctx = makeCtx();
87
+ await pi.commands.get("caveman")!.handler("", ctx);
88
+ expect(pi.appendEntry).toHaveBeenCalledWith("caveman-mode", { level: "full" });
89
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("caveman", expect.stringContaining("full"));
90
+ expect(ctx.ui.notify).toHaveBeenCalledWith("caveman: full", "info");
91
+ });
92
+
93
+ it("/caveman called twice with no args toggles back off", async () => {
94
+ const { pi } = await install();
95
+ const ctx = makeCtx();
96
+ await pi.commands.get("caveman")!.handler("", ctx);
97
+ await pi.commands.get("caveman")!.handler("", ctx);
98
+ expect(pi.appendEntry).toHaveBeenLastCalledWith("caveman-mode", { level: null });
99
+ expect(ctx.ui.setStatus).toHaveBeenLastCalledWith("caveman", undefined);
100
+ expect(ctx.ui.notify).toHaveBeenLastCalledWith("caveman: off", "info");
101
+ });
102
+
103
+ it.each(["lite", "full", "ultra", "wenyan-lite", "wenyan-full", "wenyan-ultra"])(
104
+ "/caveman %s activates that level",
105
+ async (level) => {
106
+ const { pi } = await install();
107
+ const ctx = makeCtx();
108
+ await pi.commands.get("caveman")!.handler(level, ctx);
109
+ expect(pi.appendEntry).toHaveBeenCalledWith("caveman-mode", { level });
110
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("caveman", `🪨 caveman:${level}`);
111
+ expect(ctx.ui.notify).toHaveBeenCalledWith(`caveman: ${level}`, "info");
112
+ },
113
+ );
114
+
115
+ it("/caveman off clears status and persists null level", async () => {
116
+ const { pi } = await install();
117
+ const ctx = makeCtx();
118
+ await pi.commands.get("caveman")!.handler("ultra", ctx);
119
+ await pi.commands.get("caveman")!.handler("off", ctx);
120
+ expect(pi.appendEntry).toHaveBeenLastCalledWith("caveman-mode", { level: null });
121
+ expect(ctx.ui.setStatus).toHaveBeenLastCalledWith("caveman", undefined);
122
+ expect(ctx.ui.notify).toHaveBeenLastCalledWith("caveman: off", "info");
123
+ });
124
+
125
+ it("/caveman status reports current level without changing state", async () => {
126
+ const { pi } = await install();
127
+ const ctx = makeCtx();
128
+ await pi.commands.get("caveman")!.handler("ultra", ctx);
129
+ const persistedBefore = pi.appendEntry.mock.calls.length;
130
+ await pi.commands.get("caveman")!.handler("status", ctx);
131
+ expect(pi.appendEntry.mock.calls.length).toBe(persistedBefore);
132
+ expect(ctx.ui.notify).toHaveBeenLastCalledWith("caveman: ultra", "info");
133
+ });
134
+
135
+ it("/caveman status reports off when inactive", async () => {
136
+ const { pi } = await install();
137
+ const ctx = makeCtx();
138
+ await pi.commands.get("caveman")!.handler("status", ctx);
139
+ expect(pi.appendEntry).not.toHaveBeenCalled();
140
+ expect(ctx.ui.notify).toHaveBeenCalledWith("caveman: off", "info");
141
+ });
142
+
143
+ it("/caveman with an unknown argument warns and does not change state", async () => {
144
+ const { pi } = await install();
145
+ const ctx = makeCtx();
146
+ await pi.commands.get("caveman")!.handler("nonsense", ctx);
147
+ expect(pi.appendEntry).not.toHaveBeenCalled();
148
+ expect(ctx.ui.setStatus).not.toHaveBeenCalled();
149
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/unknown level/), "warning");
150
+ });
151
+
152
+ it("argument autocomplete returns wenyan completions for 'wen'", async () => {
153
+ const { pi } = await install();
154
+ const completions = pi.commands.get("caveman")!.getArgumentCompletions!("wen");
155
+ expect(completions).toEqual(
156
+ expect.arrayContaining([
157
+ { value: "wenyan-lite", label: "wenyan-lite" },
158
+ { value: "wenyan-full", label: "wenyan-full" },
159
+ { value: "wenyan-ultra", label: "wenyan-ultra" },
160
+ ]),
161
+ );
162
+ });
163
+
164
+ it("argument autocomplete returns null when no candidate matches", async () => {
165
+ const { pi } = await install();
166
+ const completions = pi.commands.get("caveman")!.getArgumentCompletions!("zzz");
167
+ expect(completions).toBeNull();
168
+ });
169
+
170
+ it("argument autocomplete includes off and status", async () => {
171
+ const { pi } = await install();
172
+ const completions = pi.commands.get("caveman")!.getArgumentCompletions!("");
173
+ expect(completions).toEqual(
174
+ expect.arrayContaining([
175
+ { value: "off", label: "off" },
176
+ { value: "status", label: "status" },
177
+ ]),
178
+ );
179
+ });
180
+
181
+ it("session_start restores activeLevel from the latest custom entry", async () => {
182
+ const { pi } = await install();
183
+ const ctx = makeCtx([
184
+ { type: "custom", customType: "caveman-mode", data: { level: "full" } },
185
+ { type: "custom", customType: "caveman-mode", data: { level: "ultra" } },
186
+ ]);
187
+ await pi.handlers.session_start({}, ctx);
188
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("caveman", "🪨 caveman:ultra");
189
+ const result = (await pi.handlers.before_agent_start(
190
+ { systemPrompt: "ORIG" },
191
+ makeCtx(),
192
+ )) as { systemPrompt: string };
193
+ expect(result.systemPrompt).toContain("CAVEMAN MODE ACTIVE — level: ultra");
194
+ });
195
+
196
+ it("session_start clears status when the latest entry persisted off", async () => {
197
+ const { pi } = await install();
198
+ const ctx = makeCtx([
199
+ { type: "custom", customType: "caveman-mode", data: { level: "full" } },
200
+ { type: "custom", customType: "caveman-mode", data: { level: null } },
201
+ ]);
202
+ await pi.handlers.session_start({}, ctx);
203
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("caveman", undefined);
204
+ });
205
+
206
+ it("session_start ignores unrelated custom entries and corrupt level data", async () => {
207
+ const { pi } = await install();
208
+ const ctx = makeCtx([
209
+ { type: "custom", customType: "other-extension", data: { level: "full" } },
210
+ { type: "custom", customType: "caveman-mode", data: { level: "not-a-real-level" } },
211
+ ]);
212
+ await pi.handlers.session_start({}, ctx);
213
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("caveman", undefined);
214
+ });
215
+
216
+ it("before_agent_start returns void when caveman is inactive", async () => {
217
+ const { pi } = await install();
218
+ const result = await pi.handlers.before_agent_start(
219
+ { systemPrompt: "ORIG" },
220
+ makeCtx(),
221
+ );
222
+ expect(result).toBeUndefined();
223
+ });
224
+
225
+ it("before_agent_start appends caveman ruleset filtered to the active level", async () => {
226
+ const { pi } = await install();
227
+ const ctx = makeCtx();
228
+ await pi.commands.get("caveman")!.handler("ultra", ctx);
229
+ const result = (await pi.handlers.before_agent_start(
230
+ { systemPrompt: "ORIG" },
231
+ makeCtx(),
232
+ )) as { systemPrompt: string };
233
+ expect(result.systemPrompt.startsWith("ORIG\n\n")).toBe(true);
234
+ expect(result.systemPrompt).toContain("CAVEMAN MODE ACTIVE — level: ultra");
235
+ expect(result.systemPrompt).toContain("**ultra**");
236
+ expect(result.systemPrompt).not.toContain("**lite**");
237
+ expect(result.systemPrompt).not.toContain("**wenyan-lite**");
238
+ expect(result.systemPrompt).not.toContain("**wenyan-full**");
239
+ });
240
+
241
+ it("before_agent_start keeps only the active level's worked-example bullets", async () => {
242
+ const { pi } = await install();
243
+ const ctx = makeCtx();
244
+ await pi.commands.get("caveman")!.handler("lite", ctx);
245
+ const result = (await pi.handlers.before_agent_start(
246
+ { systemPrompt: "ORIG" },
247
+ makeCtx(),
248
+ )) as { systemPrompt: string };
249
+ expect(result.systemPrompt).toMatch(/^- lite:/m);
250
+ expect(result.systemPrompt).not.toMatch(/^- ultra:/m);
251
+ expect(result.systemPrompt).not.toMatch(/^- wenyan-full:/m);
252
+ });
253
+ });
@@ -0,0 +1,169 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+
6
+ const LEVELS = [
7
+ "lite",
8
+ "full",
9
+ "ultra",
10
+ "wenyan-lite",
11
+ "wenyan-full",
12
+ "wenyan-ultra",
13
+ ] as const;
14
+
15
+ type Level = (typeof LEVELS)[number];
16
+
17
+ const DEFAULT_LEVEL: Level = "full";
18
+ const ENTRY_TYPE = "caveman-mode";
19
+ const STATUS_KEY = "caveman";
20
+
21
+ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
22
+ const skillPath = path.resolve(
23
+ extensionDir,
24
+ "..",
25
+ "..",
26
+ "skills",
27
+ "caveman",
28
+ "SKILL.md",
29
+ );
30
+
31
+ interface PersistedState {
32
+ level: Level | null;
33
+ }
34
+
35
+ function isLevel(value: unknown): value is Level {
36
+ return typeof value === "string" && (LEVELS as readonly string[]).includes(value);
37
+ }
38
+
39
+ function parseLevel(arg: string): Level | null {
40
+ return isLevel(arg) ? arg : null;
41
+ }
42
+
43
+ function readSkillBody(): string {
44
+ const raw = fs.readFileSync(skillPath, "utf8");
45
+ return raw.replace(/^---[\s\S]*?---\s*/, "");
46
+ }
47
+
48
+ function filterForLevel(body: string, level: Level): string {
49
+ return body
50
+ .split("\n")
51
+ .reduce<string[]>((acc, line) => {
52
+ const tableRow = line.match(/^\|\s*\*\*([^*|]+?)\*\*\s*\|/);
53
+ if (tableRow) {
54
+ if (tableRow[1] === level) acc.push(line);
55
+ return acc;
56
+ }
57
+ const exampleBullet = line.match(/^- (\S+?):\s/);
58
+ if (exampleBullet) {
59
+ if (exampleBullet[1] === level) acc.push(line);
60
+ return acc;
61
+ }
62
+ acc.push(line);
63
+ return acc;
64
+ }, [])
65
+ .join("\n");
66
+ }
67
+
68
+ function buildInjection(level: Level, body: string): string {
69
+ return `CAVEMAN MODE ACTIVE — level: ${level}\n\n${filterForLevel(body, level)}`;
70
+ }
71
+
72
+ function statusText(level: Level): string {
73
+ return `🪨 caveman:${level}`;
74
+ }
75
+
76
+ const HELP =
77
+ "Usage: /caveman [lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off|status]. " +
78
+ "No argument toggles between off and the default level (full).";
79
+
80
+ export default function cavemanExtension(pi: ExtensionAPI): void {
81
+ let activeLevel: Level | null = null;
82
+ let cachedBody: string | null = null;
83
+
84
+ function loadBody(): string {
85
+ if (cachedBody === null) {
86
+ cachedBody = readSkillBody();
87
+ }
88
+ return cachedBody;
89
+ }
90
+
91
+ function persist(): void {
92
+ pi.appendEntry<PersistedState>(ENTRY_TYPE, { level: activeLevel });
93
+ }
94
+
95
+ pi.on("session_start", async (_event, ctx) => {
96
+ let restored: Level | null = null;
97
+ for (const entry of ctx.sessionManager.getEntries()) {
98
+ if (entry.type === "custom" && entry.customType === ENTRY_TYPE) {
99
+ const data = entry.data as PersistedState | undefined;
100
+ restored = data && isLevel(data.level) ? data.level : null;
101
+ }
102
+ }
103
+ activeLevel = restored;
104
+ ctx.ui.setStatus(STATUS_KEY, activeLevel ? statusText(activeLevel) : undefined);
105
+ });
106
+
107
+ pi.on("before_agent_start", async (event) => {
108
+ if (!activeLevel) return;
109
+ const body = loadBody();
110
+ return {
111
+ systemPrompt: `${event.systemPrompt}\n\n${buildInjection(activeLevel, body)}`,
112
+ };
113
+ });
114
+
115
+ pi.registerCommand("caveman", {
116
+ description: `Toggle compressed-output mode. ${HELP}`,
117
+ getArgumentCompletions: (prefix) => {
118
+ const items = [...LEVELS, "off", "status"];
119
+ const lower = prefix.toLowerCase();
120
+ const matches = items
121
+ .filter((value) => value.startsWith(lower))
122
+ .map((value) => ({ value, label: value }));
123
+ return matches.length > 0 ? matches : null;
124
+ },
125
+ handler: async (args, ctx) => {
126
+ const arg = args.trim().toLowerCase();
127
+
128
+ if (arg === "status") {
129
+ ctx.ui.notify(
130
+ activeLevel ? `caveman: ${activeLevel}` : "caveman: off",
131
+ "info",
132
+ );
133
+ return;
134
+ }
135
+
136
+ if (arg === "off") {
137
+ activeLevel = null;
138
+ persist();
139
+ ctx.ui.setStatus(STATUS_KEY, undefined);
140
+ ctx.ui.notify("caveman: off", "info");
141
+ return;
142
+ }
143
+
144
+ if (arg === "") {
145
+ activeLevel = activeLevel ? null : DEFAULT_LEVEL;
146
+ } else {
147
+ const level = parseLevel(arg);
148
+ if (!level) {
149
+ ctx.ui.notify(
150
+ `caveman: unknown level "${arg}". ${HELP}`,
151
+ "warning",
152
+ );
153
+ return;
154
+ }
155
+ activeLevel = level;
156
+ }
157
+
158
+ persist();
159
+ ctx.ui.setStatus(
160
+ STATUS_KEY,
161
+ activeLevel ? statusText(activeLevel) : undefined,
162
+ );
163
+ ctx.ui.notify(
164
+ activeLevel ? `caveman: ${activeLevel}` : "caveman: off",
165
+ "info",
166
+ );
167
+ },
168
+ });
169
+ }
@@ -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.8.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"
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: caveman
3
+ description: >
4
+ Persistent compressed-output mode. Strips fillers, hedging, and connectives
5
+ from assistant prose to cut output tokens by ~70-75% while preserving every
6
+ technical fact. Six intensity levels: lite, full (default), ultra, wenyan-lite,
7
+ wenyan-full, wenyan-ultra. Activate with /caveman or /skill:caveman; deactivate
8
+ with /caveman off, "stop caveman", or "normal mode".
9
+ ---
10
+
11
+ # Caveman Mode
12
+
13
+ Output compression via principled prose-stripping. Substance is preserved
14
+ verbatim — only redundancy and social padding are removed. Code, error
15
+ strings, command lines, paths, identifiers, and version numbers are reproduced
16
+ unchanged.
17
+
18
+ ## Persistence
19
+
20
+ While active, **every** assistant response uses caveman compression. Do not
21
+ drift back to verbose prose mid-conversation, even after long context, after
22
+ context compaction, or after style instructions injected by other extensions.
23
+ Resume caveman immediately after any unavoidable break (security warning,
24
+ destructive-action confirmation, multi-step procedure).
25
+
26
+ The level set by `/caveman <level>` persists until changed or session end.
27
+ Switch off only on explicit user request: "stop caveman", "normal mode", or
28
+ `/caveman off`.
29
+
30
+ ## Compression rules
31
+
32
+ Drop:
33
+
34
+ - Articles when grammatically optional (a, an, the).
35
+ - Filler adverbs and intensifiers (just, really, basically, actually, simply, very).
36
+ - Pleasantries (sure, certainly, of course, happy to, let me, I'd be glad to).
37
+ - Hedging phrases (it seems, perhaps, you might want to, I think).
38
+ - Restating the question before answering it.
39
+
40
+ Keep:
41
+
42
+ - Every technical fact, parameter name, file path, command, and version number.
43
+ - Code blocks verbatim. Error messages quoted exactly.
44
+ - Cause-and-effect order — never reorder steps for terseness.
45
+ - Punctuation needed for parsing (commas separating list items, periods between independent claims).
46
+
47
+ Sentence pattern: `<subject> <action> <reason>. <next step>.`
48
+
49
+ Anti-example: "Sure! I'd be happy to help you with that. The issue you're seeing is most likely caused by..."
50
+ Example: "Bug in auth middleware. Token expiry uses `<` not `<=`. Fix:"
51
+
52
+ ## Intensity levels
53
+
54
+ | Level | Behaviour |
55
+ |---|---|
56
+ | **lite** | Drop fillers and pleasantries only. Keep articles + full sentences. Professional but tight. |
57
+ | **full** | Drop articles. Sentence fragments allowed. Short synonyms (fix not "implement a solution for"). Default. |
58
+ | **ultra** | Abbreviate (DB, auth, cfg, req, res, fn, impl). Replace conjunctions with arrows for causality (X → Y). Single word when sufficient. |
59
+ | **wenyan-lite** | Classical Chinese register, modern grammar. Drop fillers and hedging. |
60
+ | **wenyan-full** | Full 文言文 — verb precedes object, omit subject when implied, classical particles (之 / 乃 / 為 / 其). 80–90% character reduction. |
61
+ | **wenyan-ultra** | Maximum classical compression. One classical phrase per thought. |
62
+
63
+ ### Worked example — "Why does this React component re-render on every parent update?"
64
+
65
+ - lite: "Your component re-renders because every render creates a new object reference for the prop. Wrap it in `useMemo`."
66
+ - full: "New object ref each render. Inline obj prop = new ref = re-render. Wrap in `useMemo`."
67
+ - ultra: "Inline obj prop → new ref → re-render. `useMemo`."
68
+ - wenyan-lite: "組件每次重繪皆生新對象參照。以 `useMemo` 包之。"
69
+ - wenyan-full: "每繪生新參照,故重繪。以 `useMemo` 包之。"
70
+ - wenyan-ultra: "新參照→重繪。`useMemo` 包。"
71
+
72
+ ### Worked example — "Explain database connection pooling."
73
+
74
+ - lite: "Connection pooling reuses already-open connections instead of opening a new one per request, avoiding handshake overhead."
75
+ - full: "Pool reuse open connections. No new handshake per request."
76
+ - ultra: "Pool = reuse conn. Skip handshake → fast under load."
77
+ - wenyan-lite: "連接池重用已開連接,免逐請求重握之耗。"
78
+ - wenyan-full: "池存連接而重用,無逐請求重握之耗。"
79
+ - wenyan-ultra: "池連,免握。"
80
+
81
+ ## Auto-clarity (override caveman temporarily)
82
+
83
+ Drop caveman compression when ambiguity would harm correctness:
84
+
85
+ - Security warnings.
86
+ - Confirmations for destructive or irreversible operations.
87
+ - Multi-step procedures whose order matters and where fragments would invite misreading.
88
+ - The user repeats a question or asks for clarification.
89
+
90
+ Use full prose for the duration of the override. Resume caveman immediately
91
+ after the clarified step is complete.
92
+
93
+ Example — destructive operation:
94
+
95
+ > **Warning:** the next command permanently deletes every row in `users` and
96
+ > cannot be undone:
97
+ >
98
+ > ```sql
99
+ > DROP TABLE users;
100
+ > ```
101
+ >
102
+ > Verify a current backup exists before running. Caveman resume.
103
+
104
+ ## Boundaries (caveman never applies)
105
+
106
+ - Source code, commit messages, PR descriptions, code review comments — write in normal prose.
107
+ - Documentation files (`.md`, `.mdx`, `.rst`) — caveman is for chat, not artifacts.
108
+ - Quoted log lines, stack traces, error messages — reproduce exactly.
109
+
110
+ ## Deactivation
111
+
112
+ - `/caveman off` — explicit slash command.
113
+ - "stop caveman" / "normal mode" / "disable caveman" — natural-language requests.
114
+
115
+ After deactivation, return to normal prose immediately on the next response.