@fyeeme/pi-peon-ping 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +35 -17
- package/index.ts +182 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [1.1.0] - 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- New lifecycle event mappings: `session_before_compact` → `PreCompact` (context compaction) and `tool_call` for `ask_user_question` → `PermissionRequest` (input required)
|
|
10
|
+
- Category-aware event firing — reads peon-ping `config.json` and skips spawning `peon.sh` for events whose category is toggled off. New helpers: `EVENT_CATEGORY`, `resolveConfigPath`, `readPeonConfig`, `isCategoryEnabled`
|
|
11
|
+
- `findPeonCli` / `execPeonCli` utility functions for CLI invocation of peon.sh
|
|
12
|
+
- `peon-ping-toggle` and `peon-ping-use` commands
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Map `UserPromptSubmit` and `Stop` to `before_agent_start` and `agent_end` (once per prompt) instead of `turn_start` / `turn_end` (once per turn), so the completion sound fires once per agent run rather than on every turn
|
|
17
|
+
|
|
3
18
|
## [1.0.0] - 2025-07-25
|
|
4
19
|
|
|
5
20
|
### Added
|
package/README.md
CHANGED
|
@@ -49,13 +49,30 @@ ln -s $(pwd)/packages/extensions/pi-peon-ping ~/.pi/agent/extensions/pi-peon-pin
|
|
|
49
49
|
|
|
50
50
|
## Event Mapping
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
|
57
|
-
|
|
58
|
-
| `
|
|
52
|
+
Each pi event is mapped to a peon-ping event, which peon-ping routes to a sound
|
|
53
|
+
**category** — the keys under `categories` in `~/.openpeon/config.json`. Toggle a
|
|
54
|
+
category off there to silence just that event without touching the others.
|
|
55
|
+
|
|
56
|
+
| pi event | peon-ping event | category | Occurs when |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `session_start` | `SessionStart` | `session.start` | Pi starts or resumes a session |
|
|
59
|
+
| `before_agent_start` | `UserPromptSubmit` | `task.acknowledge` | You submit a prompt (once per agent run) |
|
|
60
|
+
| `agent_end` | `Stop` | `task.complete` | An agent run finishes (once per prompt) |
|
|
61
|
+
| `tool_result` (isError) | `PostToolUseFailure` | `task.error` | A tool call fails |
|
|
62
|
+
| `tool_call` (`ask_user_question`) | `PermissionRequest` | `input.required` | Agent asks you a question |
|
|
63
|
+
| `session_before_compact` | `PreCompact` | `resource.limit` | Context is about to be compacted |
|
|
64
|
+
| `session_shutdown` | `SessionEnd` | — | Session ends (always fires) |
|
|
65
|
+
|
|
66
|
+
> **Why `agent_end` / `before_agent_start` instead of `turn_*`?**
|
|
67
|
+
> A single agent run spans multiple turns (e.g. several tool calls in a row).
|
|
68
|
+
> Mapping `turn_end` → `Stop` would fire the completion sound on every turn;
|
|
69
|
+
> `agent_end` fires once per prompt, which is what the completion sound should
|
|
70
|
+
> mean. Same reasoning for `before_agent_start` vs `turn_start`.
|
|
71
|
+
|
|
72
|
+
> **`input.required` needs the `ask_user_question` tool**, provided by an
|
|
73
|
+
> extension such as [@juicesharp/rpiv-ask-user-question](https://github.com/juicesharp/rpiv-ask-user-question).
|
|
74
|
+
> It listens for the `tool_call` event with `toolName === "ask_user_question"`.
|
|
75
|
+
> Without such a tool installed, this category never fires.
|
|
59
76
|
|
|
60
77
|
## Configuration
|
|
61
78
|
|
|
@@ -67,18 +84,18 @@ ln -s $(pwd)/packages/extensions/pi-peon-ping ~/.pi/agent/extensions/pi-peon-pin
|
|
|
67
84
|
|
|
68
85
|
### peon.sh Discovery
|
|
69
86
|
|
|
70
|
-
pi-peon-ping
|
|
87
|
+
pi-peon-ping auto-discovers the installed `peon.sh` with the following priority (high → low):
|
|
71
88
|
|
|
72
|
-
|
|
|
73
|
-
|
|
74
|
-
| 1 | `$PEON_SH`
|
|
89
|
+
| Priority | Source | Path |
|
|
90
|
+
|----------|--------|------|
|
|
91
|
+
| 1 | `$PEON_SH` env var | User-specified |
|
|
75
92
|
| 2 | Homebrew (macOS) | `$(brew --prefix peon-ping)/libexec/peon.sh` |
|
|
76
|
-
| 3 | curl
|
|
77
|
-
| 4 | `--openpeon`
|
|
78
|
-
| 5 | OpenClaw
|
|
79
|
-
| 6 | Windows/Git Bash WSL2
|
|
93
|
+
| 3 | curl install (Linux/macOS) | `~/.claude/hooks/peon-ping/peon.sh` |
|
|
94
|
+
| 4 | `--openpeon` mode | `~/.openpeon/hooks/peon-ping/peon.sh` |
|
|
95
|
+
| 5 | OpenClaw install | `~/.openclaw/hooks/peon-ping/peon.sh` |
|
|
96
|
+
| 6 | Windows/Git Bash WSL2 fallback | `~/.claude/hooks/peon-ping/peon.ps1` |
|
|
80
97
|
|
|
81
|
-
|
|
98
|
+
When a `.ps1` file is found, it is automatically run with `powershell` instead of `bash`.
|
|
82
99
|
|
|
83
100
|
### peon-ping 配置
|
|
84
101
|
|
|
@@ -89,6 +106,7 @@ peon packs use glados # Switch sound pack
|
|
|
89
106
|
peon packs install --all # Install all packs
|
|
90
107
|
peon trainer on # Enable trainer mode
|
|
91
108
|
peon notifications off # Mute popups but keep sounds
|
|
109
|
+
peon mute # Mute all sounds (toggle back with `peon toggle`)
|
|
92
110
|
peon mobile ntfy my-topic # Push notifications to phone
|
|
93
111
|
peon ssh-audio relay # Route audio for remote sessions
|
|
94
112
|
```
|
|
@@ -103,7 +121,7 @@ rm -rf ~/.pi/agent/extensions/pi-peon-ping
|
|
|
103
121
|
|
|
104
122
|
This is a thin adapter — it does not play sounds or send notifications directly. Instead, it finds `peon.sh` (from the peon-ping install) and spawns it with a JSON payload on each lifecycle event. `peon.sh` handles all sound selection, playback, notifications, and trainer logic.
|
|
105
123
|
|
|
106
|
-
The tab title
|
|
124
|
+
The tab title reflects agent state: `● working...` when an agent run starts, `✓ done` when it finishes, `✗ error` on tool failures.
|
|
107
125
|
|
|
108
126
|
## Credits
|
|
109
127
|
|
package/index.ts
CHANGED
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { spawn, execFileSync } from "node:child_process";
|
|
38
|
-
import { existsSync } from "node:fs";
|
|
38
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
39
39
|
import { homedir, platform } from "node:os";
|
|
40
|
-
import { basename, join } from "node:path";
|
|
40
|
+
import { basename, dirname, join } from "node:path";
|
|
41
41
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
42
42
|
|
|
43
43
|
// ============================================================================
|
|
@@ -153,16 +153,98 @@ export function getSessionId(ctx: ExtensionContext): string {
|
|
|
153
153
|
return `pi-${Date.now()}`;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// ============================================================================
|
|
157
|
+
// Peon-ping config (categories)
|
|
158
|
+
// ============================================================================
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Event → peon-ping category key mapping (mirrors peon.sh's routing).
|
|
162
|
+
* SessionEnd is intentionally unmapped so it always fires regardless of
|
|
163
|
+
* category toggles (peon.sh has no matching category for session teardown).
|
|
164
|
+
*/
|
|
165
|
+
export const EVENT_CATEGORY: Record<string, string> = {
|
|
166
|
+
SessionStart: "session.start",
|
|
167
|
+
UserPromptSubmit: "task.acknowledge",
|
|
168
|
+
Stop: "task.complete",
|
|
169
|
+
PostToolUseFailure: "task.error",
|
|
170
|
+
PreCompact: "resource.limit",
|
|
171
|
+
PermissionRequest: "input.required",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the peon-ping config.json path, following the same priority as
|
|
176
|
+
* peon.sh:
|
|
177
|
+
* 1. $PWD/.claude/hooks/peon-ping/config.json (project-local)
|
|
178
|
+
* 2. ~/.openpeon/config.json (user data dir)
|
|
179
|
+
* 3. $PEON_DIR/config.json (install-level)
|
|
180
|
+
*/
|
|
181
|
+
export function resolveConfigPath(peonPath: string): string | null {
|
|
182
|
+
// 1. Project-local config
|
|
183
|
+
const localCfg = join(process.cwd(), ".claude", "hooks", "peon-ping", "config.json");
|
|
184
|
+
if (existsSync(localCfg)) return localCfg;
|
|
185
|
+
|
|
186
|
+
// 2. ~/.openpeon/config.json
|
|
187
|
+
const openpeonCfg = join(homedir(), ".openpeon", "config.json");
|
|
188
|
+
if (existsSync(openpeonCfg)) return openpeonCfg;
|
|
189
|
+
|
|
190
|
+
// 3. Install-level (same directory as peon.sh)
|
|
191
|
+
const peonDir = dirname(peonPath);
|
|
192
|
+
const installCfg = join(peonDir, "config.json");
|
|
193
|
+
if (existsSync(installCfg)) return installCfg;
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface PeonConfig {
|
|
199
|
+
categories?: Record<string, boolean>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read the peon-ping config.json and return the parsed object,
|
|
204
|
+
* or null if the config file cannot be read.
|
|
205
|
+
*/
|
|
206
|
+
export function readPeonConfig(peonPath: string): PeonConfig | null {
|
|
207
|
+
const cfgPath = resolveConfigPath(peonPath);
|
|
208
|
+
if (!cfgPath) return null;
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(readFileSync(cfgPath, "utf-8")) as PeonConfig;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check whether a peon-ping category is enabled in the config.
|
|
218
|
+
* If no config is found, returns true (allow — mimic peon.sh default).
|
|
219
|
+
*/
|
|
220
|
+
export function isCategoryEnabled(config: PeonConfig | null, category: string): boolean {
|
|
221
|
+
if (!config?.categories) return true;
|
|
222
|
+
const val = config.categories[category];
|
|
223
|
+
// peon.sh defaults: task.acknowledge → false, everything else → true
|
|
224
|
+
if (val === undefined) return category !== "task.acknowledge";
|
|
225
|
+
return val === true;
|
|
226
|
+
}
|
|
227
|
+
|
|
156
228
|
// ============================================================================
|
|
157
229
|
// peon.sh invocation
|
|
158
230
|
// ============================================================================
|
|
159
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Fire a peon-ping event. Spawns peon.sh with the given hook_event_name,
|
|
234
|
+
* but only if the corresponding category is enabled in the peon-ping config.
|
|
235
|
+
* This avoids spawning a process for suppressed events.
|
|
236
|
+
*/
|
|
160
237
|
export function firePeon(
|
|
161
238
|
peonPath: string,
|
|
162
239
|
event: string,
|
|
163
240
|
cwd: string,
|
|
164
241
|
sessionId: string,
|
|
242
|
+
config?: PeonConfig | null,
|
|
165
243
|
): void {
|
|
244
|
+
const category = EVENT_CATEGORY[event];
|
|
245
|
+
const enabled = !category || isCategoryEnabled(config ?? null, category);
|
|
246
|
+
if (!enabled) return;
|
|
247
|
+
|
|
166
248
|
const payload = JSON.stringify({
|
|
167
249
|
hook_event_name: event,
|
|
168
250
|
notification_type: "",
|
|
@@ -185,6 +267,35 @@ export function firePeon(
|
|
|
185
267
|
}
|
|
186
268
|
}
|
|
187
269
|
|
|
270
|
+
// ============================================================================
|
|
271
|
+
// peon CLI invocation (for commands)
|
|
272
|
+
// ============================================================================
|
|
273
|
+
|
|
274
|
+
export function findPeonCli(peonPath?: string | null): string | null {
|
|
275
|
+
// peon.sh accepts CLI args too — just use it directly
|
|
276
|
+
return peonPath ?? findPeonSh();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function execPeonCli(
|
|
280
|
+
cliPath: string,
|
|
281
|
+
args: string[],
|
|
282
|
+
): Promise<{ stdout: string; exitCode: number | null }> {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
const { shell, script } = resolveShellAndScript(cliPath);
|
|
285
|
+
const proc = spawn(shell, [script, ...args], {
|
|
286
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
let stdout = "";
|
|
290
|
+
proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
291
|
+
|
|
292
|
+
proc.on("close", (code) => {
|
|
293
|
+
resolve({ stdout: stdout.trim(), exitCode: code });
|
|
294
|
+
});
|
|
295
|
+
proc.on("error", () => resolve({ stdout: "", exitCode: -1 }));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
188
299
|
// ============================================================================
|
|
189
300
|
// Extension
|
|
190
301
|
// ============================================================================
|
|
@@ -199,36 +310,98 @@ export default function peonPingExtension(pi: ExtensionAPI): void {
|
|
|
199
310
|
return;
|
|
200
311
|
}
|
|
201
312
|
|
|
313
|
+
const cliPath = findPeonCli(peonPath);
|
|
202
314
|
const cwd = process.cwd();
|
|
203
315
|
const projectName = basename(cwd) || "pi";
|
|
204
316
|
|
|
317
|
+
// -----------------------------------------------------------------------
|
|
318
|
+
// Lifecycle event forwarding (respect peon-ping category toggles)
|
|
319
|
+
// Config is re-read on each event so runtime category changes (via
|
|
320
|
+
// `peon notifications`) take effect without a pi restart.
|
|
321
|
+
// -----------------------------------------------------------------------
|
|
322
|
+
|
|
205
323
|
pi.on("session_start", async (_event, ctx) => {
|
|
324
|
+
const config = readPeonConfig(peonPath);
|
|
206
325
|
const sessionId = getSessionId(ctx);
|
|
207
326
|
if (ctx.hasUI) setTabTitle(`● ${projectName}: ready`);
|
|
208
|
-
firePeon(peonPath, "SessionStart", cwd, sessionId);
|
|
327
|
+
firePeon(peonPath, "SessionStart", cwd, sessionId, config);
|
|
209
328
|
});
|
|
210
329
|
|
|
211
|
-
pi.on("
|
|
330
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
331
|
+
const config = readPeonConfig(peonPath);
|
|
212
332
|
const sessionId = getSessionId(ctx);
|
|
213
333
|
if (ctx.hasUI) setTabTitle(`● ${projectName}: working...`);
|
|
214
|
-
firePeon(peonPath, "UserPromptSubmit", cwd, sessionId);
|
|
334
|
+
firePeon(peonPath, "UserPromptSubmit", cwd, sessionId, config);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
338
|
+
const config = readPeonConfig(peonPath);
|
|
339
|
+
const sessionId = getSessionId(ctx);
|
|
340
|
+
firePeon(peonPath, "PreCompact", cwd, sessionId, config);
|
|
215
341
|
});
|
|
216
342
|
|
|
217
|
-
pi.on("
|
|
343
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
344
|
+
const config = readPeonConfig(peonPath);
|
|
218
345
|
const sessionId = getSessionId(ctx);
|
|
219
346
|
if (ctx.hasUI) setTabTitle(`✓ ${projectName}: done`);
|
|
220
|
-
firePeon(peonPath, "Stop", cwd, sessionId);
|
|
347
|
+
firePeon(peonPath, "Stop", cwd, sessionId, config);
|
|
221
348
|
});
|
|
222
349
|
|
|
223
350
|
pi.on("tool_result", async (event, ctx) => {
|
|
224
351
|
if (!event.isError) return;
|
|
352
|
+
const config = readPeonConfig(peonPath);
|
|
225
353
|
const sessionId = getSessionId(ctx);
|
|
226
354
|
if (ctx.hasUI) setTabTitle(`✗ ${projectName}: error`);
|
|
227
|
-
firePeon(peonPath, "PostToolUseFailure", cwd, sessionId);
|
|
355
|
+
firePeon(peonPath, "PostToolUseFailure", cwd, sessionId, config);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Agent is asking the user a question (e.g. ask_user_question tool) →
|
|
359
|
+
// peon-ping "input.required" category. Fires before the tool runs.
|
|
360
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
361
|
+
if (event.toolName !== "ask_user_question") return;
|
|
362
|
+
const config = readPeonConfig(peonPath);
|
|
363
|
+
const sessionId = getSessionId(ctx);
|
|
364
|
+
firePeon(peonPath, "PermissionRequest", cwd, sessionId, config);
|
|
228
365
|
});
|
|
229
366
|
|
|
230
367
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
368
|
+
const config = readPeonConfig(peonPath);
|
|
231
369
|
const sessionId = getSessionId(ctx);
|
|
232
|
-
firePeon(peonPath, "SessionEnd", cwd, sessionId);
|
|
370
|
+
firePeon(peonPath, "SessionEnd", cwd, sessionId, config);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// -----------------------------------------------------------------------
|
|
374
|
+
// Commands
|
|
375
|
+
// -----------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
if (!cliPath) return;
|
|
378
|
+
|
|
379
|
+
pi.registerCommand("peon-ping-toggle", {
|
|
380
|
+
description: "Toggle peon-ping mute on/off",
|
|
381
|
+
handler: async (_args, ctx) => {
|
|
382
|
+
const result = await execPeonCli(cliPath, ["toggle"]);
|
|
383
|
+
if (result.exitCode !== 0) {
|
|
384
|
+
ctx.ui.notify(result.stdout || "toggle failed", "error");
|
|
385
|
+
} else {
|
|
386
|
+
ctx.ui.notify(result.stdout || "toggled", "info");
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
pi.registerCommand("peon-ping-use", {
|
|
392
|
+
description: "Switch sound pack: /peon-ping-use <name>",
|
|
393
|
+
handler: async (args, ctx) => {
|
|
394
|
+
const name = args.trim();
|
|
395
|
+
if (!name) {
|
|
396
|
+
ctx.ui.notify("Usage: /peon-ping-use <pack-name>", "warning");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const result = await execPeonCli(cliPath, ["packs", "use", "--install", name]);
|
|
400
|
+
if (result.exitCode !== 0) {
|
|
401
|
+
ctx.ui.notify(result.stdout || `Failed to switch to ${name}`, "error");
|
|
402
|
+
} else {
|
|
403
|
+
ctx.ui.notify(result.stdout || `Switched to ${name}`, "info");
|
|
404
|
+
}
|
|
405
|
+
},
|
|
233
406
|
});
|
|
234
407
|
}
|
package/package.json
CHANGED