@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.
Files changed (4) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +35 -17
  3. package/index.ts +182 -9
  4. 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
- | pi event | peon-ping event | Occurs when |
53
- |------------------------------|------------------------|---------------------------------|
54
- | `session_start` | `SessionStart` | Pi starts or resumes a session |
55
- | `turn_start` | `UserPromptSubmit` | You submit a prompt |
56
- | `turn_end` | `Stop` | Agent finishes responding |
57
- | `tool_result` (isError) | `PostToolUseFailure` | A tool call fails |
58
- | `session_shutdown` | `SessionEnd` | Session ends |
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 自动查找已安装的 `peon.sh`,优先级如下(高 → 低):
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 安装 (Linux/macOS) | `~/.claude/hooks/peon-ping/peon.sh` |
77
- | 4 | `--openpeon` 模式 | `~/.openpeon/hooks/peon-ping/peon.sh` |
78
- | 5 | OpenClaw 安装 | `~/.openclaw/hooks/peon-ping/peon.sh` |
79
- | 6 | Windows/Git Bash WSL2 回退 | `~/.claude/hooks/peon-ping/peon.ps1` |
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
- 当找到 `.ps1` 文件时,自动使用 `powershell` 而非 `bash` 执行。
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 is updated to reflect current agent state: `● working...` during a turn, `✓ done` on completion, `✗ error` on tool failures.
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("turn_start", async (_event, ctx) => {
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("turn_end", async (_event, ctx) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-peon-ping",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "peon-ping adapter for pi — routes lifecycle events through peon.sh for sound packs, notifications, and trainer reminders.",
5
5
  "type": "module",
6
6
  "license": "MIT",