pi-ding 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # pi-ding
2
+
3
+ A [pi](https://github.com/badlogic/pi-mono) extension that plays a sound when the agent finishes processing a prompt (when it’s ready for the next input).
4
+
5
+ ## Install
6
+
7
+ ### Install from npm
8
+
9
+ ```bash
10
+ pi install npm:pi-ding
11
+ ```
12
+
13
+ ### Install from git
14
+
15
+ ```bash
16
+ pi install git:github.com/ninlds/pi-ding@v0.1.0
17
+ ```
18
+
19
+ ### Try without installing
20
+
21
+ ```bash
22
+ pi -e npm:pi-ding
23
+ # or
24
+ pi -e git:github.com/ninlds/pi-ding@v0.1.0
25
+ ```
26
+
27
+ ## Configure (recommended)
28
+
29
+ pi-ding stores its config inside pi’s settings files, so you can configure once and then just run `pi`.
30
+
31
+ Settings locations (project overrides global):
32
+
33
+ - Global: `~/.pi/agent/settings.json`
34
+ - Project: `<project>/.pi/settings.json`
35
+
36
+ Add a `ding` key:
37
+
38
+ ```json
39
+ {
40
+ "ding": {
41
+ "enabled": true,
42
+ "player": "mpv",
43
+ "args": ["--no-video", "--really-quiet"],
44
+ "path": "~/sounds/done.mkv"
45
+ }
46
+ }
47
+ ```
48
+
49
+ Notes:
50
+ - `args` is optional.
51
+ - If `player` is omitted, pi-ding tries common players automatically (`mpv`, `ffplay`, `paplay`, `afplay`, `aplay`).
52
+ - Supported audio formats depend on the chosen player.
53
+
54
+ ### Easiest way: edit from inside pi
55
+
56
+ Inside pi:
57
+ - `/ding edit` → edits **project** config (`.pi/settings.json`)
58
+ - `/ding edit global` → edits **global** config (`~/.pi/agent/settings.json`)
59
+
60
+ ## Commands (inside pi)
61
+
62
+ - `/ding` — toggle enabled (saved to **project** settings by default)
63
+ - `/ding on` / `/ding off` — set enabled
64
+ - `/ding test` — play sound immediately
65
+ - `/ding info` — show debug info (effective config, resolved file, players)
66
+ - `/ding reload` — reload settings from disk
67
+
68
+ ## Optional CLI overrides
69
+
70
+ You typically won’t need these once config exists, but they can override settings for a single run:
71
+
72
+ ```bash
73
+ pi --no-ding
74
+ pi --ding-player mpv --ding-path "./done.mkv"
75
+ pi --ding-args '["--no-video","--really-quiet"]'
76
+ ```
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,412 @@
1
+ /**
2
+ * pi-ding
3
+ *
4
+ * Play a sound (or terminal bell) when pi finishes a prompt.
5
+ *
6
+ * Configuration is stored in pi settings files (project overrides global):
7
+ * - Global: ~/.pi/agent/settings.json
8
+ * - Project: <project>/.pi/settings.json
9
+ *
10
+ * Add a `ding` object to either settings file:
11
+ *
12
+ * {
13
+ * "ding": {
14
+ * "enabled": true,
15
+ * "player": "mpv",
16
+ * "args": ["--no-video", "--really-quiet"],
17
+ * "path": "~/sounds/done.mkv"
18
+ * }
19
+ * }
20
+ *
21
+ * Notes:
22
+ * - `args` is optional.
23
+ * - If `player` is omitted, the extension will try common players automatically.
24
+ * - If `path` is omitted or does not exist, it falls back to the terminal bell.
25
+ * - Supported formats depend on the chosen player.
26
+ *
27
+ * CLI overrides (optional):
28
+ * --no-ding
29
+ * --ding-player <player>
30
+ * --ding-path <path>
31
+ * --ding-args <json-array>
32
+ *
33
+ * Commands:
34
+ * /ding Toggle enabled (writes to project settings by default)
35
+ * /ding on|off Set enabled (writes to project settings by default)
36
+ * /ding test Play now
37
+ * /ding info Show config/debug info
38
+ * /ding edit [project|global] Edit ding config JSON in editor
39
+ * /ding reload Reload settings from disk
40
+ */
41
+
42
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
43
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
44
+ import { spawn } from "node:child_process";
45
+ import * as fs from "node:fs";
46
+ import * as path from "node:path";
47
+
48
+ type DingConfig = {
49
+ enabled?: boolean;
50
+ player?: string;
51
+ args?: string[];
52
+ path?: string;
53
+ };
54
+
55
+ type ConfigSource = "project" | "global" | "default";
56
+
57
+ const SETTINGS_KEY = "ding";
58
+
59
+ const DEFAULT_CONFIG: Required<Pick<DingConfig, "enabled">> = {
60
+ enabled: true,
61
+ };
62
+
63
+ function terminalBell() {
64
+ process.stdout.write("\x07");
65
+ }
66
+
67
+ function trySpawnDetached(command: string, args: string[], onError: () => void) {
68
+ try {
69
+ const child = spawn(command, args, {
70
+ detached: true,
71
+ stdio: "ignore",
72
+ });
73
+ child.on("error", onError);
74
+ child.unref();
75
+ } catch {
76
+ onError();
77
+ }
78
+ }
79
+
80
+ function expandTilde(p: string): string {
81
+ if (p.startsWith("~/") && process.env.HOME) return path.join(process.env.HOME, p.slice(2));
82
+ return p;
83
+ }
84
+
85
+ function resolveMaybeRelative(ctx: ExtensionContext, p: string): string {
86
+ const expanded = expandTilde(p.trim());
87
+ return path.isAbsolute(expanded) ? expanded : path.join(ctx.cwd, expanded);
88
+ }
89
+
90
+ function isStringArray(v: unknown): v is string[] {
91
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
92
+ }
93
+
94
+ function sanitizeConfig(input: unknown): DingConfig {
95
+ if (!input || typeof input !== "object") return { ...DEFAULT_CONFIG };
96
+ const o = input as any;
97
+ return {
98
+ ...DEFAULT_CONFIG,
99
+ ...(typeof o.enabled === "boolean" ? { enabled: o.enabled } : {}),
100
+ ...(typeof o.player === "string" ? { player: o.player } : {}),
101
+ ...(isStringArray(o.args) ? { args: o.args } : {}),
102
+ ...(typeof o.path === "string" ? { path: o.path } : {}),
103
+ };
104
+ }
105
+
106
+ function mergeConfig(base: DingConfig, override: DingConfig): DingConfig {
107
+ // shallow merge is enough for this shape; arrays should override.
108
+ return {
109
+ ...base,
110
+ ...Object.fromEntries(Object.entries(override).filter(([, v]) => v !== undefined)),
111
+ };
112
+ }
113
+
114
+ function readJsonFile(filePath: string): { ok: true; data: any } | { ok: false; error: string } {
115
+ try {
116
+ if (!fs.existsSync(filePath)) return { ok: true, data: {} };
117
+ const raw = fs.readFileSync(filePath, "utf8");
118
+ return { ok: true, data: JSON.parse(raw) };
119
+ } catch (e) {
120
+ const msg = e instanceof Error ? e.message : String(e);
121
+ return { ok: false, error: msg };
122
+ }
123
+ }
124
+
125
+ function writeJsonFile(filePath: string, data: any) {
126
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
127
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
128
+ }
129
+
130
+ function getProjectSettingsPath(ctx: ExtensionContext): string {
131
+ // pi config dir is .pi by default
132
+ return path.join(ctx.cwd, ".pi", "settings.json");
133
+ }
134
+
135
+ function getGlobalSettingsPath(): string {
136
+ return path.join(getAgentDir(), "settings.json");
137
+ }
138
+
139
+ function extractDingConfigFromSettings(settings: any): DingConfig {
140
+ if (!settings || typeof settings !== "object" || !(SETTINGS_KEY in settings)) return {};
141
+ return sanitizeConfig(settings[SETTINGS_KEY]);
142
+ }
143
+
144
+ function findInPath(cmd: string): string | undefined {
145
+ const PATH = process.env.PATH || "";
146
+ for (const p of PATH.split(path.delimiter)) {
147
+ if (!p) continue;
148
+ const full = path.join(p, cmd);
149
+ try {
150
+ fs.accessSync(full, fs.constants.X_OK);
151
+ return full;
152
+ } catch {
153
+ // ignore
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ function playAuto(filePath: string) {
160
+ if (!fs.existsSync(filePath)) {
161
+ terminalBell();
162
+ return;
163
+ }
164
+
165
+ const candidates: Array<{ cmd: string; args: string[] }> = [
166
+ { cmd: "mpv", args: ["--no-video", "--really-quiet", filePath] },
167
+ { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath] },
168
+ { cmd: "paplay", args: [filePath] },
169
+ { cmd: "afplay", args: [filePath] },
170
+ { cmd: "aplay", args: [filePath] },
171
+ ];
172
+
173
+ const runAt = (i: number) => {
174
+ if (i >= candidates.length) {
175
+ terminalBell();
176
+ return;
177
+ }
178
+ const { cmd, args } = candidates[i];
179
+ trySpawnDetached(cmd, args, () => runAt(i + 1));
180
+ };
181
+
182
+ runAt(0);
183
+ }
184
+
185
+ export default function (pi: ExtensionAPI) {
186
+ // Per-run overrides. The point of settings-based config is that most users will not need these.
187
+ pi.registerFlag("ding", {
188
+ description: "Enable pi-ding (disable with --no-ding)",
189
+ type: "boolean",
190
+ default: true,
191
+ });
192
+ pi.registerFlag("ding-player", { description: "Override player executable", type: "string" });
193
+ pi.registerFlag("ding-path", { description: "Override sound file path", type: "string" });
194
+ pi.registerFlag("ding-args", {
195
+ description: "Override args as JSON array string, e.g. --ding-args '[\"--no-video\"]'",
196
+ type: "string",
197
+ });
198
+
199
+ let globalPath: string | undefined;
200
+ let projectPath: string | undefined;
201
+ let loadedFrom: ConfigSource = "default";
202
+ let lastLoadError: string | undefined;
203
+ let config: DingConfig = { ...DEFAULT_CONFIG };
204
+ let globalConfig: DingConfig = {};
205
+ let projectConfig: DingConfig = {};
206
+
207
+ function parseArgsFlag(): string[] | undefined {
208
+ const raw = pi.getFlag("ding-args");
209
+ if (typeof raw !== "string" || raw.trim().length === 0) return undefined;
210
+ try {
211
+ const parsed = JSON.parse(raw);
212
+ return isStringArray(parsed) ? parsed : undefined;
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ function loadSettings(ctx: ExtensionContext) {
219
+ lastLoadError = undefined;
220
+ globalPath = getGlobalSettingsPath();
221
+ projectPath = getProjectSettingsPath(ctx);
222
+
223
+ const g = readJsonFile(globalPath);
224
+ const p = readJsonFile(projectPath);
225
+
226
+ if (!g.ok) lastLoadError = `Invalid JSON in ${globalPath}: ${g.error}`;
227
+ if (!p.ok) lastLoadError = lastLoadError ?? `Invalid JSON in ${projectPath}: ${p.error}`;
228
+
229
+ const globalCfg = g.ok ? extractDingConfigFromSettings(g.data) : { ...DEFAULT_CONFIG };
230
+ const projectCfg = p.ok ? extractDingConfigFromSettings(p.data) : {};
231
+ globalConfig = globalCfg;
232
+ projectConfig = projectCfg;
233
+
234
+ config = mergeConfig(globalCfg, projectCfg);
235
+
236
+ // Determine source used (best-effort)
237
+ const projectHasKey = p.ok && p.data && typeof p.data === "object" && SETTINGS_KEY in p.data;
238
+ const globalHasKey = g.ok && g.data && typeof g.data === "object" && SETTINGS_KEY in g.data;
239
+ loadedFrom = projectHasKey ? "project" : globalHasKey ? "global" : "default";
240
+ }
241
+
242
+ function getEffectiveConfig(): DingConfig {
243
+ const player = pi.getFlag("ding-player");
244
+ const p = pi.getFlag("ding-path");
245
+ const args = parseArgsFlag();
246
+ return {
247
+ ...config,
248
+ ...(typeof player === "string" && player.trim().length > 0 ? { player } : {}),
249
+ ...(typeof p === "string" && p.trim().length > 0 ? { path: p } : {}),
250
+ ...(args ? { args } : {}),
251
+ };
252
+ }
253
+
254
+ function isEnabled(cfg: DingConfig): boolean {
255
+ const runFlag = pi.getFlag("ding");
256
+ const runEnabled = typeof runFlag === "boolean" ? runFlag : true;
257
+ const cfgEnabled = cfg.enabled ?? true;
258
+ return runEnabled && cfgEnabled;
259
+ }
260
+
261
+ function play(ctx: ExtensionContext, options?: { force?: boolean }) {
262
+ const cfg = getEffectiveConfig();
263
+ if (!options?.force && !isEnabled(cfg)) return;
264
+
265
+ if (!cfg.path || cfg.path.trim().length === 0) {
266
+ terminalBell();
267
+ return;
268
+ }
269
+
270
+ const resolvedPath = resolveMaybeRelative(ctx, cfg.path);
271
+ if (!fs.existsSync(resolvedPath)) {
272
+ terminalBell();
273
+ return;
274
+ }
275
+
276
+ const player = typeof cfg.player === "string" ? cfg.player.trim() : "";
277
+ const args = isStringArray(cfg.args) ? cfg.args : [];
278
+
279
+ if (player.length > 0) {
280
+ trySpawnDetached(player, [...args, resolvedPath], () => terminalBell());
281
+ return;
282
+ }
283
+
284
+ playAuto(resolvedPath);
285
+ }
286
+
287
+ function updateSettingsDing(ctx: ExtensionContext, target: "project" | "global", newDing: DingConfig) {
288
+ const settingsPath = target === "project" ? getProjectSettingsPath(ctx) : getGlobalSettingsPath();
289
+ const current = readJsonFile(settingsPath);
290
+ if (!current.ok) {
291
+ throw new Error(`Can't write ${settingsPath}: invalid JSON (${current.error})`);
292
+ }
293
+ const root = current.data && typeof current.data === "object" ? current.data : {};
294
+ root[SETTINGS_KEY] = sanitizeConfig(newDing);
295
+ writeJsonFile(settingsPath, root);
296
+ // Reload to sync in-memory
297
+ loadSettings(ctx);
298
+ }
299
+
300
+ function defaultWriteTarget(): "project" | "global" {
301
+ // Prefer project so teams can commit `.pi/settings.json`.
302
+ return "project";
303
+ }
304
+
305
+ pi.on("session_start", async (_event, ctx) => {
306
+ loadSettings(ctx);
307
+ if (ctx.hasUI && lastLoadError) ctx.ui.notify(lastLoadError, "warning");
308
+ });
309
+
310
+ pi.on("agent_end", async (_event, ctx) => {
311
+ if (!ctx.hasUI) return;
312
+ play(ctx);
313
+ });
314
+
315
+ pi.registerCommand("ding", {
316
+ description: "Configure and test pi-ding (usage: /ding [on|off|test|info|edit|reload])",
317
+ handler: async (args, ctx) => {
318
+ const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
319
+ const sub = (tokens[0] || "").toLowerCase();
320
+
321
+ if (sub === "test") {
322
+ play(ctx, { force: true });
323
+ ctx.ui.notify("Played ding", "info");
324
+ return;
325
+ }
326
+
327
+ if (sub === "reload") {
328
+ loadSettings(ctx);
329
+ ctx.ui.notify("Reloaded ding settings", "info");
330
+ if (lastLoadError) ctx.ui.notify(lastLoadError, "warning");
331
+ return;
332
+ }
333
+
334
+ if (sub === "edit") {
335
+ const scope = (tokens[1] || "").toLowerCase();
336
+ const target: "project" | "global" = scope === "global" ? "global" : "project";
337
+
338
+ const rootPath = target === "project" ? getProjectSettingsPath(ctx) : getGlobalSettingsPath();
339
+ const current = readJsonFile(rootPath);
340
+ if (!current.ok) {
341
+ ctx.ui.notify(`Invalid JSON in ${rootPath}: ${current.error}`, "error");
342
+ return;
343
+ }
344
+
345
+ const existingRoot =
346
+ current.data && typeof current.data === "object" ? (current.data as any) : {};
347
+ const existingDing = sanitizeConfig(existingRoot[SETTINGS_KEY]);
348
+
349
+ const prefill = JSON.stringify(existingDing, null, 2) + "\n";
350
+ const edited = await ctx.ui.editor(`Edit ding config (${target})`, prefill);
351
+ if (edited === undefined) return;
352
+
353
+ try {
354
+ const parsed = JSON.parse(edited);
355
+ updateSettingsDing(ctx, target, sanitizeConfig(parsed));
356
+ ctx.ui.notify(`Saved ding config to ${rootPath}`, "info");
357
+ } catch (e) {
358
+ const msg = e instanceof Error ? e.message : String(e);
359
+ ctx.ui.notify(`Invalid JSON: ${msg}`, "error");
360
+ }
361
+ return;
362
+ }
363
+
364
+ if (sub === "info") {
365
+ const effective = getEffectiveConfig();
366
+ const resolved = effective.path ? resolveMaybeRelative(ctx, effective.path) : undefined;
367
+ const exists = resolved ? fs.existsSync(resolved) : undefined;
368
+ const players = ["mpv", "ffplay", "paplay", "afplay", "aplay"].map((c) => {
369
+ const found = findInPath(c);
370
+ return `${c}: ${found ?? "not found"}`;
371
+ });
372
+
373
+ const lines = [
374
+ `loadedFrom: ${loadedFrom}`,
375
+ `projectSettings: ${projectPath ?? "(unknown)"}`,
376
+ `globalSettings: ${globalPath ?? "(unknown)"}`,
377
+ lastLoadError ? `loadError: ${lastLoadError}` : undefined,
378
+ "",
379
+ `effective.enabled: ${isEnabled(effective)}`,
380
+ `effective.player: ${effective.player ?? "(auto)"}`,
381
+ `effective.args: ${isStringArray(effective.args) ? JSON.stringify(effective.args) : "(none)"}`,
382
+ `effective.path: ${effective.path ?? "(none)"}`,
383
+ `resolved.path: ${resolved ?? "(n/a)"}`,
384
+ `resolved.exists: ${typeof exists === "boolean" ? exists : "(n/a)"}`,
385
+ "",
386
+ "Players in PATH:",
387
+ ...players,
388
+ ].filter((l): l is string => typeof l === "string");
389
+
390
+ ctx.ui.notify(lines.join("\n"), "info");
391
+ return;
392
+ }
393
+
394
+ // Default: toggle enabled, persist
395
+ const target = defaultWriteTarget();
396
+ const current = config.enabled ?? true;
397
+ let next: boolean;
398
+ if (sub === "on") next = true;
399
+ else if (sub === "off") next = false;
400
+ else next = !current;
401
+
402
+ try {
403
+ const base = target === "project" ? projectConfig : globalConfig;
404
+ updateSettingsDing(ctx, target, mergeConfig(base, { enabled: next }));
405
+ ctx.ui.notify(`Ding ${next ? "enabled" : "disabled"} (saved to ${target})`, "info");
406
+ } catch (e) {
407
+ const msg = e instanceof Error ? e.message : String(e);
408
+ ctx.ui.notify(msg, "error");
409
+ }
410
+ },
411
+ });
412
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "pi-ding",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: play a sound when the agent finishes a prompt",
5
+ "type": "module",
6
+ "keywords": ["pi-package", "pi-extension", "pi"],
7
+ "license": "MIT",
8
+ "author": "ninlds",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ninlds/pi-ding.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ninlds/pi-ding/issues"
15
+ },
16
+ "homepage": "https://github.com/ninlds/pi-ding#readme",
17
+ "pi": {
18
+ "extensions": ["./extensions"]
19
+ },
20
+ "peerDependencies": {
21
+ "@mariozechner/pi-coding-agent": "*"
22
+ },
23
+ "files": [
24
+ "extensions",
25
+ "README.md",
26
+ "LICENSE",
27
+ "package.json"
28
+ ]
29
+ }