@fyeeme/pi-peon-ping 1.0.0 → 1.1.1

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 +22 -0
  2. package/README.md +37 -19
  3. package/index.ts +188 -19
  4. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.1.1] - 2026-08-08
6
+
7
+ ### Changed
8
+
9
+ - Tab 标题改用 provided API:删除手写 ANSI `setTabTitle`(`process.stdout.write(\x1b]0;...\x07)`),四处调用点(`session_start`/`before_agent_start`/`agent_settled`/`tool_result`,均在 `ctx.hasUI` 守卫下)改为 `ctx.ui.setTitle(...)`——mode 感知,print/json 模式自动 no-op,不再向 stdout 写裸 ANSI。
10
+ - `Stop` 事件映射从 `agent_end` 改为 `agent_settled`(与 ExtensionAPI 实际事件名一致)。
11
+
12
+ ## [1.1.0] - 2026-07-26
13
+
14
+ ### Added
15
+
16
+ - New lifecycle event mappings: `session_before_compact` → `PreCompact` (context compaction) and `tool_call` for `ask_user_question` → `PermissionRequest` (input required)
17
+ - 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`
18
+ - `findPeonCli` / `execPeonCli` utility functions for CLI invocation of peon.sh
19
+ - `peon-ping-toggle` and `peon-ping-use` commands
20
+
21
+ ### Changed
22
+
23
+ - 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
24
+
3
25
  ## [1.0.0] - 2025-07-25
4
26
 
5
27
  ### 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,28 +84,29 @@ 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
- ### peon-ping 配置
100
+ ### peon-ping configuration
84
101
 
85
- 所有 peon-ping 的功能配置通过 `peon` CLI 进行:
102
+ All peon-ping feature configuration is done through the `peon` CLI:
86
103
 
87
104
  ```bash
88
105
  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
@@ -14,7 +14,7 @@
14
14
  * Event mapping (pi ExtensionAPI → peon.sh hook_event_name):
15
15
  * session_start → SessionStart
16
16
  * turn_start → UserPromptSubmit
17
- * turn_end → Stop
17
+ * agent_settled → Stop
18
18
  * tool_result (event.isError === true) → PostToolUseFailure
19
19
  * session_shutdown → SessionEnd
20
20
  *
@@ -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
  // ============================================================================
@@ -131,13 +131,9 @@ export function resolveShellAndScript(
131
131
  }
132
132
 
133
133
  // ============================================================================
134
- // Terminal tab title helpers
134
+ // Terminal tab title (via pi's mode-aware ctx.ui.setTitle)
135
135
  // ============================================================================
136
136
 
137
- export function setTabTitle(title: string): void {
138
- process.stdout.write(`\x1b]0;${title}\x07`);
139
- }
140
-
141
137
  // ============================================================================
142
138
  // Session ID helpers
143
139
  // ============================================================================
@@ -153,16 +149,98 @@ export function getSessionId(ctx: ExtensionContext): string {
153
149
  return `pi-${Date.now()}`;
154
150
  }
155
151
 
152
+ // ============================================================================
153
+ // Peon-ping config (categories)
154
+ // ============================================================================
155
+
156
+ /**
157
+ * Event → peon-ping category key mapping (mirrors peon.sh's routing).
158
+ * SessionEnd is intentionally unmapped so it always fires regardless of
159
+ * category toggles (peon.sh has no matching category for session teardown).
160
+ */
161
+ export const EVENT_CATEGORY: Record<string, string> = {
162
+ SessionStart: "session.start",
163
+ UserPromptSubmit: "task.acknowledge",
164
+ Stop: "task.complete",
165
+ PostToolUseFailure: "task.error",
166
+ PreCompact: "resource.limit",
167
+ PermissionRequest: "input.required",
168
+ };
169
+
170
+ /**
171
+ * Resolve the peon-ping config.json path, following the same priority as
172
+ * peon.sh:
173
+ * 1. $PWD/.claude/hooks/peon-ping/config.json (project-local)
174
+ * 2. ~/.openpeon/config.json (user data dir)
175
+ * 3. $PEON_DIR/config.json (install-level)
176
+ */
177
+ export function resolveConfigPath(peonPath: string): string | null {
178
+ // 1. Project-local config
179
+ const localCfg = join(process.cwd(), ".claude", "hooks", "peon-ping", "config.json");
180
+ if (existsSync(localCfg)) return localCfg;
181
+
182
+ // 2. ~/.openpeon/config.json
183
+ const openpeonCfg = join(homedir(), ".openpeon", "config.json");
184
+ if (existsSync(openpeonCfg)) return openpeonCfg;
185
+
186
+ // 3. Install-level (same directory as peon.sh)
187
+ const peonDir = dirname(peonPath);
188
+ const installCfg = join(peonDir, "config.json");
189
+ if (existsSync(installCfg)) return installCfg;
190
+
191
+ return null;
192
+ }
193
+
194
+ export interface PeonConfig {
195
+ categories?: Record<string, boolean>;
196
+ }
197
+
198
+ /**
199
+ * Read the peon-ping config.json and return the parsed object,
200
+ * or null if the config file cannot be read.
201
+ */
202
+ export function readPeonConfig(peonPath: string): PeonConfig | null {
203
+ const cfgPath = resolveConfigPath(peonPath);
204
+ if (!cfgPath) return null;
205
+ try {
206
+ return JSON.parse(readFileSync(cfgPath, "utf-8")) as PeonConfig;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Check whether a peon-ping category is enabled in the config.
214
+ * If no config is found, returns true (allow — mimic peon.sh default).
215
+ */
216
+ export function isCategoryEnabled(config: PeonConfig | null, category: string): boolean {
217
+ if (!config?.categories) return true;
218
+ const val = config.categories[category];
219
+ // peon.sh defaults: task.acknowledge → false, everything else → true
220
+ if (val === undefined) return category !== "task.acknowledge";
221
+ return val === true;
222
+ }
223
+
156
224
  // ============================================================================
157
225
  // peon.sh invocation
158
226
  // ============================================================================
159
227
 
228
+ /**
229
+ * Fire a peon-ping event. Spawns peon.sh with the given hook_event_name,
230
+ * but only if the corresponding category is enabled in the peon-ping config.
231
+ * This avoids spawning a process for suppressed events.
232
+ */
160
233
  export function firePeon(
161
234
  peonPath: string,
162
235
  event: string,
163
236
  cwd: string,
164
237
  sessionId: string,
238
+ config?: PeonConfig | null,
165
239
  ): void {
240
+ const category = EVENT_CATEGORY[event];
241
+ const enabled = !category || isCategoryEnabled(config ?? null, category);
242
+ if (!enabled) return;
243
+
166
244
  const payload = JSON.stringify({
167
245
  hook_event_name: event,
168
246
  notification_type: "",
@@ -185,6 +263,35 @@ export function firePeon(
185
263
  }
186
264
  }
187
265
 
266
+ // ============================================================================
267
+ // peon CLI invocation (for commands)
268
+ // ============================================================================
269
+
270
+ export function findPeonCli(peonPath?: string | null): string | null {
271
+ // peon.sh accepts CLI args too — just use it directly
272
+ return peonPath ?? findPeonSh();
273
+ }
274
+
275
+ export async function execPeonCli(
276
+ cliPath: string,
277
+ args: string[],
278
+ ): Promise<{ stdout: string; exitCode: number | null }> {
279
+ return new Promise((resolve) => {
280
+ const { shell, script } = resolveShellAndScript(cliPath);
281
+ const proc = spawn(shell, [script, ...args], {
282
+ stdio: ["ignore", "pipe", "pipe"],
283
+ });
284
+
285
+ let stdout = "";
286
+ proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
287
+
288
+ proc.on("close", (code) => {
289
+ resolve({ stdout: stdout.trim(), exitCode: code });
290
+ });
291
+ proc.on("error", () => resolve({ stdout: "", exitCode: -1 }));
292
+ });
293
+ }
294
+
188
295
  // ============================================================================
189
296
  // Extension
190
297
  // ============================================================================
@@ -199,36 +306,98 @@ export default function peonPingExtension(pi: ExtensionAPI): void {
199
306
  return;
200
307
  }
201
308
 
309
+ const cliPath = findPeonCli(peonPath);
202
310
  const cwd = process.cwd();
203
311
  const projectName = basename(cwd) || "pi";
204
312
 
313
+ // -----------------------------------------------------------------------
314
+ // Lifecycle event forwarding (respect peon-ping category toggles)
315
+ // Config is re-read on each event so runtime category changes (via
316
+ // `peon notifications`) take effect without a pi restart.
317
+ // -----------------------------------------------------------------------
318
+
205
319
  pi.on("session_start", async (_event, ctx) => {
320
+ const config = readPeonConfig(peonPath);
206
321
  const sessionId = getSessionId(ctx);
207
- if (ctx.hasUI) setTabTitle(`● ${projectName}: ready`);
208
- firePeon(peonPath, "SessionStart", cwd, sessionId);
322
+ if (ctx.hasUI) ctx.ui.setTitle(`● ${projectName}: ready`);
323
+ firePeon(peonPath, "SessionStart", cwd, sessionId, config);
209
324
  });
210
325
 
211
- pi.on("turn_start", async (_event, ctx) => {
326
+ pi.on("before_agent_start", async (_event, ctx) => {
327
+ const config = readPeonConfig(peonPath);
212
328
  const sessionId = getSessionId(ctx);
213
- if (ctx.hasUI) setTabTitle(`● ${projectName}: working...`);
214
- firePeon(peonPath, "UserPromptSubmit", cwd, sessionId);
329
+ if (ctx.hasUI) ctx.ui.setTitle(`● ${projectName}: working...`);
330
+ firePeon(peonPath, "UserPromptSubmit", cwd, sessionId, config);
215
331
  });
216
332
 
217
- pi.on("turn_end", async (_event, ctx) => {
333
+ pi.on("session_before_compact", async (_event, ctx) => {
334
+ const config = readPeonConfig(peonPath);
218
335
  const sessionId = getSessionId(ctx);
219
- if (ctx.hasUI) setTabTitle(`✓ ${projectName}: done`);
220
- firePeon(peonPath, "Stop", cwd, sessionId);
336
+ firePeon(peonPath, "PreCompact", cwd, sessionId, config);
337
+ });
338
+
339
+ pi.on("agent_settled", async (_event, ctx) => {
340
+ const config = readPeonConfig(peonPath);
341
+ const sessionId = getSessionId(ctx);
342
+ if (ctx.hasUI) ctx.ui.setTitle(`✓ ${projectName}: done`);
343
+ firePeon(peonPath, "Stop", cwd, sessionId, config);
221
344
  });
222
345
 
223
346
  pi.on("tool_result", async (event, ctx) => {
224
347
  if (!event.isError) return;
348
+ const config = readPeonConfig(peonPath);
349
+ const sessionId = getSessionId(ctx);
350
+ if (ctx.hasUI) ctx.ui.setTitle(`✗ ${projectName}: error`);
351
+ firePeon(peonPath, "PostToolUseFailure", cwd, sessionId, config);
352
+ });
353
+
354
+ // Agent is asking the user a question (e.g. ask_user_question tool) →
355
+ // peon-ping "input.required" category. Fires before the tool runs.
356
+ pi.on("tool_call", async (event, ctx) => {
357
+ if (event.toolName !== "ask_user_question") return;
358
+ const config = readPeonConfig(peonPath);
225
359
  const sessionId = getSessionId(ctx);
226
- if (ctx.hasUI) setTabTitle(`✗ ${projectName}: error`);
227
- firePeon(peonPath, "PostToolUseFailure", cwd, sessionId);
360
+ firePeon(peonPath, "PermissionRequest", cwd, sessionId, config);
228
361
  });
229
362
 
230
363
  pi.on("session_shutdown", async (_event, ctx) => {
364
+ const config = readPeonConfig(peonPath);
231
365
  const sessionId = getSessionId(ctx);
232
- firePeon(peonPath, "SessionEnd", cwd, sessionId);
366
+ firePeon(peonPath, "SessionEnd", cwd, sessionId, config);
367
+ });
368
+
369
+ // -----------------------------------------------------------------------
370
+ // Commands
371
+ // -----------------------------------------------------------------------
372
+
373
+ if (!cliPath) return;
374
+
375
+ pi.registerCommand("peon-ping-toggle", {
376
+ description: "Toggle peon-ping mute on/off",
377
+ handler: async (_args, ctx) => {
378
+ const result = await execPeonCli(cliPath, ["toggle"]);
379
+ if (result.exitCode !== 0) {
380
+ ctx.ui.notify(result.stdout || "toggle failed", "error");
381
+ } else {
382
+ ctx.ui.notify(result.stdout || "toggled", "info");
383
+ }
384
+ },
385
+ });
386
+
387
+ pi.registerCommand("peon-ping-use", {
388
+ description: "Switch sound pack: /peon-ping-use <name>",
389
+ handler: async (args, ctx) => {
390
+ const name = args.trim();
391
+ if (!name) {
392
+ ctx.ui.notify("Usage: /peon-ping-use <pack-name>", "warning");
393
+ return;
394
+ }
395
+ const result = await execPeonCli(cliPath, ["packs", "use", "--install", name]);
396
+ if (result.exitCode !== 0) {
397
+ ctx.ui.notify(result.stdout || `Failed to switch to ${name}`, "error");
398
+ } else {
399
+ ctx.ui.notify(result.stdout || `Switched to ${name}`, "info");
400
+ }
401
+ },
233
402
  });
234
403
  }
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.1",
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",
@@ -44,10 +44,10 @@
44
44
  "typecheck": "tsc"
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": ">=0.77.0"
47
+ "@earendil-works/pi-coding-agent": ">=0.84.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@earendil-works/pi-coding-agent": "0.77.0",
50
+ "@earendil-works/pi-coding-agent": "0.84.1",
51
51
  "@types/node": "22.19.19",
52
52
  "typescript": "5.9.3"
53
53
  }