agent-noti 1.3.0 → 1.4.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.
package/README.md CHANGED
@@ -37,16 +37,18 @@ Each theme includes a separate idle and input sound.
37
37
  ## Commands
38
38
 
39
39
  ```sh
40
- agent-noti install # Add hooks + pick theme (i)
40
+ agent-noti install # Add hooks + pick theme (i)
41
41
  agent-noti uninstall # Remove hooks
42
- agent-noti test # Play current sounds (t)
43
- agent-noti sounds # List available themes (s)
44
- agent-noti pick # Interactive sound picker (p)
45
- agent-noti add-custom # Use your own sound files (ac)
46
- agent-noti volume <1-10> # Set volume level (v)
47
- agent-noti mute # Mute notifications (m)
48
- agent-noti unmute # Unmute notifications (u)
49
- agent-noti reset # Reset everything (r)
42
+ agent-noti test # Play current sounds (t)
43
+ agent-noti sounds # List available themes (s)
44
+ agent-noti pick # Interactive sound picker (p)
45
+ agent-noti add-custom # Use your own sound files (ac)
46
+ agent-noti volume <1-10> # Set volume level (v)
47
+ agent-noti mute # Mute notifications (m)
48
+ agent-noti unmute # Unmute notifications (u)
49
+ agent-noti ntfy # Configure ntfy.sh push alerts (n)
50
+ agent-noti ntfy-test # Send a test push notification (nt)
51
+ agent-noti reset # Reset everything (r)
50
52
  ```
51
53
 
52
54
  Every command has a short alias shown in parentheses — e.g. `agent-noti v 5` instead of `agent-noti volume 5`.
@@ -87,6 +89,46 @@ agent-noti unmute # Re-enable notifications
87
89
 
88
90
  Setting volume while muted auto-unmutes. Volume works across all platforms.
89
91
 
92
+ ## Push notifications (ntfy.sh)
93
+
94
+ Get push notifications on your phone or desktop via [ntfy.sh](https://ntfy.sh) — even when you're away from the terminal. ntfy works independently from audio: you can mute sounds and still receive push alerts.
95
+
96
+ ### Setup
97
+
98
+ ```sh
99
+ agent-noti ntfy
100
+ ```
101
+
102
+ This opens an interactive TUI where you can:
103
+ - Set your ntfy server and topic
104
+ - Toggle which events send push notifications (task complete / approval needed)
105
+ - Set priority level and time threshold
106
+ - Send a test notification
107
+
108
+ The **threshold** setting (in minutes) skips push notifications for quick tasks. For example, set it to `5` and you'll only get pinged if the agent ran for at least 5 minutes. Set to `0` to always notify.
109
+
110
+ On first run you'll be prompted for a topic name. Subscribe to the same topic in the [ntfy app](https://ntfy.sh) (Android/iOS/web) to receive notifications.
111
+
112
+ ### Quick test
113
+
114
+ ```sh
115
+ agent-noti ntfy-test
116
+ ```
117
+
118
+ Sends a test notification to your configured topic without opening the interactive TUI.
119
+
120
+ ### Config fields
121
+
122
+ | Field | Default | Description |
123
+ |---|---|---|
124
+ | `ntfy.enabled` | `false` | Master on/off |
125
+ | `ntfy.server` | `https://ntfy.sh` | ntfy server URL |
126
+ | `ntfy.topic` | — | Your topic name (required) |
127
+ | `ntfy.priority` | `default` | `min`, `low`, `default`, `high`, `urgent` |
128
+ | `ntfy.threshold` | `0` | Min minutes before notifying (0 = always) |
129
+ | `ntfy.idle` | `true` | Notify on task complete |
130
+ | `ntfy.input` | `true` | Notify on approval needed |
131
+
90
132
  ## Config
91
133
 
92
134
  All settings are stored in `~/.agent-noti/config.json`:
@@ -96,7 +138,16 @@ All settings are stored in `~/.agent-noti/config.json`:
96
138
  "idle": "cow",
97
139
  "input": "cow",
98
140
  "volume": 10,
99
- "muted": false
141
+ "muted": false,
142
+ "ntfy": {
143
+ "enabled": true,
144
+ "server": "https://ntfy.sh",
145
+ "topic": "my-agent-alerts",
146
+ "priority": "default",
147
+ "threshold": 5,
148
+ "idle": true,
149
+ "input": true
150
+ }
100
151
  }
101
152
  ```
102
153
 
package/bin/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import { homedir, platform } from "os";
9
9
 
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  const PLAY_SCRIPT = join(__dirname, "play.mjs");
12
+ const STAMP_SCRIPT = join(__dirname, "stamp.mjs");
12
13
  const CODEX_NOTIFY_SCRIPT = join(__dirname, "codex-notify.mjs");
13
14
  const SOUNDS_DIR = join(__dirname, "..", "sounds");
14
15
 
@@ -98,9 +99,11 @@ function writeConfig(config) {
98
99
  function buildClaudeHooks() {
99
100
  const idle = { type: "command", command: `node "${PLAY_SCRIPT}" idle` };
100
101
  const input = { type: "command", command: `node "${PLAY_SCRIPT}" input` };
102
+ const stamp = { type: "command", command: `node "${STAMP_SCRIPT}"` };
101
103
  return {
102
104
  Stop: [{ hooks: [idle], metadata: { id: HOOK_ID } }],
103
105
  PermissionRequest: [{ hooks: [input], metadata: { id: HOOK_ID } }],
106
+ PromptSubmit: [{ hooks: [stamp], metadata: { id: HOOK_ID } }],
104
107
  };
105
108
  }
106
109
 
@@ -421,6 +424,14 @@ function sounds() {
421
424
  console.log(" Theme: idle=%s, input=%s", idleLabel, inputLabel);
422
425
  const volBar = "#".repeat(vol) + "-".repeat(10 - vol);
423
426
  console.log(` Volume: [${volBar}] ${vol}/10${muted ? " (MUTED)" : ""}`);
427
+
428
+ if (config.ntfy && config.ntfy.topic) {
429
+ const n = config.ntfy;
430
+ const server = (n.server || "https://ntfy.sh").replace(/^https?:\/\//, "").replace(/\/+$/, "");
431
+ const status = n.enabled ? "enabled" : "disabled";
432
+ console.log(` ntfy: ${status} (${server}/${n.topic})`);
433
+ }
434
+
424
435
  console.log("");
425
436
  }
426
437
 
@@ -564,6 +575,244 @@ async function addCustom() {
564
575
  console.log(" Custom sounds applied.\n");
565
576
  }
566
577
 
578
+ // --- ntfy.sh push notifications ---
579
+
580
+ const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"];
581
+
582
+ function ntfyPrompt(label) {
583
+ return new Promise((resolve) => {
584
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
585
+ rl.question(` ${label}`, (answer) => {
586
+ rl.close();
587
+ resolve(answer.trim());
588
+ });
589
+ });
590
+ }
591
+
592
+ async function ntfyInitialSetup() {
593
+ console.log("\n First-time ntfy.sh setup\n");
594
+ const topic = await ntfyPrompt("Topic (required): ");
595
+ if (!topic) {
596
+ console.log(" Topic is required. Aborting.\n");
597
+ return null;
598
+ }
599
+ const server = await ntfyPrompt("Server (enter for https://ntfy.sh): ");
600
+ return { topic, server: server || "https://ntfy.sh" };
601
+ }
602
+
603
+ async function ntfySendTest(ntfyConfig) {
604
+ const server = (ntfyConfig.server || "https://ntfy.sh").replace(/\/+$/, "");
605
+ const url = `${server}/${ntfyConfig.topic}`;
606
+ const res = await fetch(url, {
607
+ method: "POST",
608
+ headers: {
609
+ Title: "Test Notification",
610
+ Priority: ntfyConfig.priority || "default",
611
+ Tags: "bell",
612
+ },
613
+ body: "agent-noti test notification",
614
+ });
615
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
616
+ }
617
+
618
+ function ntfy() {
619
+ return new Promise(async (resolve) => {
620
+ if (!process.stdin.isTTY) {
621
+ console.log("\n This command requires an interactive terminal.\n");
622
+ resolve();
623
+ return;
624
+ }
625
+
626
+ const config = readConfig();
627
+
628
+ // First-time setup: prompt for topic
629
+ if (!config.ntfy || !config.ntfy.topic) {
630
+ const setup = await ntfyInitialSetup();
631
+ if (!setup) { resolve(); return; }
632
+ config.ntfy = {
633
+ enabled: true,
634
+ server: setup.server,
635
+ topic: setup.topic,
636
+ priority: "default",
637
+ threshold: 0,
638
+ idle: true,
639
+ input: true,
640
+ };
641
+ writeConfig(config);
642
+ }
643
+
644
+ const ntfyConf = config.ntfy;
645
+ const rows = ["idle", "input"];
646
+ let selected = 0;
647
+ let statusMsg = "";
648
+ const totalLines = 13;
649
+
650
+ function render(firstTime) {
651
+ if (!firstTime) process.stdout.write(`\x1b[${totalLines}A`);
652
+
653
+ const thresh = ntfyConf.threshold ?? 0;
654
+ const threshLabel = thresh === 0 ? "off (always notify)" : `${thresh} min`;
655
+
656
+ process.stdout.write("\x1b[2K\n");
657
+ process.stdout.write(`\x1b[2K \x1b[1mntfy.sh push notifications\x1b[0m\n`);
658
+ process.stdout.write("\x1b[2K\n");
659
+ process.stdout.write(`\x1b[2K Server: \x1b[36m${ntfyConf.server || "https://ntfy.sh"}\x1b[0m\n`);
660
+ process.stdout.write(`\x1b[2K Topic: \x1b[36m${ntfyConf.topic}\x1b[0m\n`);
661
+ process.stdout.write(`\x1b[2K Priority: \x1b[36m${ntfyConf.priority || "default"}\x1b[0m\n`);
662
+ process.stdout.write(`\x1b[2K Threshold: \x1b[36m${threshLabel}\x1b[0m\n`);
663
+ process.stdout.write("\x1b[2K\n");
664
+
665
+ for (let i = 0; i < rows.length; i++) {
666
+ const checked = ntfyConf[rows[i]] ? "x" : " ";
667
+ const label = rows[i] === "idle" ? "Notify on task complete (idle)" : "Notify on approval needed (input)";
668
+ const arrow = i === selected ? "\x1b[36m> " : " ";
669
+ const color = i === selected ? "\x1b[36m" : "\x1b[0m";
670
+ process.stdout.write(`\x1b[2K ${arrow}${color}[${checked}] ${label}\x1b[0m\n`);
671
+ }
672
+
673
+ process.stdout.write("\x1b[2K\n");
674
+ const status = statusMsg ? ` ${statusMsg}` : "";
675
+ process.stdout.write(`\x1b[2K \x1b[90m[space] Toggle [up/down] Navigate [e] Edit [t] Test [q] Save & quit\x1b[0m${status}\n`);
676
+ process.stdout.write(`\x1b[2K\n`);
677
+ }
678
+
679
+ const stdin = process.stdin;
680
+ stdin.setRawMode(true);
681
+ stdin.resume();
682
+ stdin.setEncoding("utf8");
683
+
684
+ render(true);
685
+
686
+ function cleanup() {
687
+ stdin.removeListener("data", onKey);
688
+ stdin.setRawMode(false);
689
+ stdin.pause();
690
+ }
691
+
692
+ function save() {
693
+ ntfyConf.enabled = ntfyConf.idle || ntfyConf.input;
694
+ config.ntfy = ntfyConf;
695
+ writeConfig(config);
696
+ }
697
+
698
+ async function editFields() {
699
+ cleanup();
700
+ console.log("");
701
+
702
+ const newServer = await ntfyPrompt(`Server (${ntfyConf.server || "https://ntfy.sh"}): `);
703
+ if (newServer) ntfyConf.server = newServer;
704
+
705
+ const newTopic = await ntfyPrompt(`Topic (${ntfyConf.topic}): `);
706
+ if (newTopic) ntfyConf.topic = newTopic;
707
+
708
+ const priIdx = NTFY_PRIORITIES.indexOf(ntfyConf.priority || "default");
709
+ const newPri = await ntfyPrompt(`Priority [${NTFY_PRIORITIES.join("/")}] (${NTFY_PRIORITIES[priIdx]}): `);
710
+ if (newPri && NTFY_PRIORITIES.includes(newPri)) ntfyConf.priority = newPri;
711
+
712
+ const curThresh = ntfyConf.threshold ?? 0;
713
+ const newThresh = await ntfyPrompt(`Threshold in minutes, 0=off (${curThresh}): `);
714
+ if (newThresh !== "") {
715
+ const val = parseInt(newThresh, 10);
716
+ if (!isNaN(val) && val >= 0) ntfyConf.threshold = val;
717
+ }
718
+
719
+ // Re-enter raw mode and re-render
720
+ stdin.setRawMode(true);
721
+ stdin.resume();
722
+ stdin.setEncoding("utf8");
723
+ stdin.on("data", onKey);
724
+ render(true);
725
+ }
726
+
727
+ async function testNotification() {
728
+ cleanup();
729
+ statusMsg = "\x1b[33mSending...\x1b[0m";
730
+ // Re-enter raw mode to render
731
+ stdin.setRawMode(true);
732
+ stdin.resume();
733
+ stdin.setEncoding("utf8");
734
+ render(true);
735
+
736
+ try {
737
+ await ntfySendTest(ntfyConf);
738
+ statusMsg = "\x1b[32mSent!\x1b[0m";
739
+ } catch (e) {
740
+ statusMsg = `\x1b[31mFailed: ${e.message}\x1b[0m`;
741
+ }
742
+
743
+ stdin.on("data", onKey);
744
+ render();
745
+ setTimeout(() => { statusMsg = ""; render(); }, 3000);
746
+ }
747
+
748
+ async function onKey(key) {
749
+ if (key === "\x03") {
750
+ cleanup();
751
+ save();
752
+ console.log("");
753
+ process.exit(0);
754
+ }
755
+
756
+ if (key === "q" || key === "Q" || key === "\r" || key === "\n") {
757
+ cleanup();
758
+ save();
759
+ console.log(`\n ntfy config saved (${ntfyConf.enabled ? "enabled" : "disabled"}).\n`);
760
+ resolve();
761
+ return;
762
+ }
763
+
764
+ if (key === " ") {
765
+ ntfyConf[rows[selected]] = !ntfyConf[rows[selected]];
766
+ render();
767
+ return;
768
+ }
769
+
770
+ if (key === "\x1b[A" || key === "k") {
771
+ selected = (selected - 1 + rows.length) % rows.length;
772
+ render();
773
+ return;
774
+ }
775
+
776
+ if (key === "\x1b[B" || key === "j") {
777
+ selected = (selected + 1) % rows.length;
778
+ render();
779
+ return;
780
+ }
781
+
782
+ if (key === "e" || key === "E") {
783
+ await editFields();
784
+ return;
785
+ }
786
+
787
+ if (key === "t" || key === "T") {
788
+ stdin.removeListener("data", onKey);
789
+ await testNotification();
790
+ return;
791
+ }
792
+ }
793
+
794
+ stdin.on("data", onKey);
795
+ });
796
+ }
797
+
798
+ async function ntfyTest() {
799
+ const config = readConfig();
800
+ const ntfyConf = config.ntfy;
801
+
802
+ if (!ntfyConf || !ntfyConf.topic) {
803
+ console.log("\n ntfy not configured. Run 'agent-noti ntfy' first.\n");
804
+ return;
805
+ }
806
+
807
+ process.stdout.write("\n Sending test notification...");
808
+ try {
809
+ await ntfySendTest(ntfyConf);
810
+ console.log(" sent!\n");
811
+ } catch (e) {
812
+ console.log(` failed: ${e.message}\n`);
813
+ }
814
+ }
815
+
567
816
  function mute() {
568
817
  const config = readConfig();
569
818
  config.muted = true;
@@ -604,7 +853,7 @@ function volume(args) {
604
853
 
605
854
  function reset() {
606
855
  writeConfig({ idle: "default", input: "default", volume: 10, muted: false });
607
- console.log("\n Reset to defaults (theme=default, volume=10, unmuted).\n");
856
+ console.log("\n Reset to defaults (theme=default, volume=10, unmuted, ntfy cleared).\n");
608
857
  }
609
858
 
610
859
  function applyPickerChoice(choice) {
@@ -651,6 +900,8 @@ async function main() {
651
900
  case "mute": case "m": mute(); break;
652
901
  case "unmute": case "u": unmute(); break;
653
902
  case "reset": case "r": reset(); break;
903
+ case "ntfy": case "n": await ntfy(); break;
904
+ case "ntfy-test": case "nt": await ntfyTest(); break;
654
905
  default:
655
906
  console.log("");
656
907
  console.log(" agent-noti install (i) Add hooks + pick theme");
@@ -662,6 +913,8 @@ async function main() {
662
913
  console.log(" agent-noti volume (v) Set volume <1-10>");
663
914
  console.log(" agent-noti mute (m) Mute notifications");
664
915
  console.log(" agent-noti unmute (u) Unmute notifications");
916
+ console.log(" agent-noti ntfy (n) Configure ntfy.sh push notifications");
917
+ console.log(" agent-noti ntfy-test (nt) Send a test push notification");
665
918
  console.log(" agent-noti reset (r) Reset everything");
666
919
  console.log("");
667
920
  }
package/bin/play.mjs CHANGED
@@ -6,17 +6,21 @@
6
6
  * node play.mjs --file <path> — plays a file directly
7
7
  *
8
8
  * Respects ~/.agent-noti/config.json for mute and volume (1-10).
9
+ * Also sends ntfy.sh push notifications when configured.
9
10
  */
10
11
 
11
12
  import { execFile, exec } from "child_process";
12
13
  import { join, dirname } from "path";
13
14
  import { fileURLToPath } from "url";
14
15
  import { platform, homedir } from "os";
15
- import { existsSync, readFileSync } from "fs";
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
19
  const SOUNDS_DIR = join(__dirname, "..", "sounds");
19
- const CONFIG_PATH = join(homedir(), ".agent-noti", "config.json");
20
+ const CONFIG_DIR = join(homedir(), ".agent-noti");
21
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
22
+ const LAST_PROMPT_PATH = join(CONFIG_DIR, "last-prompt");
23
+ const LAST_EVENT_PATH = join(CONFIG_DIR, "last-event");
20
24
 
21
25
  const EVENTS = ["idle", "input"];
22
26
 
@@ -69,36 +73,98 @@ function resolveSound(arg) {
69
73
  return findFile(`${arg}-idle`) || findFile(arg) || null;
70
74
  }
71
75
 
72
- const arg = process.argv[2];
73
- if (!arg) process.exit(0);
76
+ const NTFY_MESSAGES = {
77
+ idle: { title: "Task Complete", tags: "white_check_mark", body: "Agent finished task" },
78
+ input: { title: "Approval Needed", tags: "warning", body: "Agent needs your approval" },
79
+ };
74
80
 
75
- const config = readConfig();
81
+ async function sendNtfy(event, config) {
82
+ try {
83
+ const ntfy = config.ntfy;
84
+ if (!ntfy || !ntfy.enabled || !ntfy.topic) return;
85
+ if (!ntfy[event]) return;
86
+
87
+ const msg = NTFY_MESSAGES[event];
88
+ if (!msg) return;
89
+
90
+ const server = (ntfy.server || "https://ntfy.sh").replace(/\/+$/, "");
91
+ const url = `${server}/${ntfy.topic}`;
92
+
93
+ await fetch(url, {
94
+ method: "POST",
95
+ headers: {
96
+ Title: msg.title,
97
+ Priority: ntfy.priority || "default",
98
+ Tags: msg.tags,
99
+ },
100
+ body: msg.body,
101
+ });
102
+ } catch {}
103
+ }
104
+
105
+ (async () => {
106
+ const arg = process.argv[2];
107
+ if (!arg) process.exit(0);
76
108
 
77
- // Mute check (skip for --file, which is used by picker previews)
78
- if (arg !== "--file" && config.muted) process.exit(0);
109
+ const config = readConfig();
79
110
 
80
- const file = resolveSound(arg);
81
- if (!file) process.exit(1);
111
+ // Send ntfy push notification for actual events (not --file previews)
112
+ if (EVENTS.includes(arg)) {
113
+ const threshold = config.ntfy?.threshold ?? 0; // minutes, 0 = always
114
+ let shouldSend = true;
115
+
116
+ if (threshold > 0) {
117
+ // Claude Code: PromptSubmit hook writes last-prompt (exact task start)
118
+ // Codex fallback: use last-event (time since previous event)
119
+ const tsFile = existsSync(LAST_PROMPT_PATH) ? LAST_PROMPT_PATH : LAST_EVENT_PATH;
120
+ try {
121
+ if (existsSync(tsFile)) {
122
+ const started = parseInt(readFileSync(tsFile, "utf-8"), 10);
123
+ if (!isNaN(started)) {
124
+ const elapsed = (Date.now() - started) / 60000;
125
+ shouldSend = elapsed >= threshold;
126
+ }
127
+ }
128
+ } catch {}
129
+ }
82
130
 
83
- // Volume: 1-10 config → 0.0-1.0 native scale
84
- const vol = Math.max(1, Math.min(10, config.volume ?? 10));
85
- const volFloat = vol / 10; // 0.1 – 1.0 (macOS, Windows)
86
- const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
87
- const volPulse = Math.round(volFloat * 65536); // paplay scale
131
+ if (shouldSend) {
132
+ await sendNtfy(arg, config);
133
+ }
88
134
 
89
- const os = platform();
135
+ // Write last-event timestamp (Codex fallback for threshold)
136
+ try {
137
+ mkdirSync(CONFIG_DIR, { recursive: true });
138
+ writeFileSync(LAST_EVENT_PATH, String(Date.now()));
139
+ } catch {}
140
+ }
90
141
 
91
- if (os === "darwin") {
92
- execFile("afplay", ["-v", String(volFloat), file], () => {});
93
- } else if (os === "win32") {
94
- exec(
95
- `powershell -NoProfile -Command "Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Volume = ${volFloat}; $p.Play(); Start-Sleep -Seconds 3"`,
96
- () => {}
97
- );
98
- } else {
99
- execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], (err) => {
100
- if (err) execFile("paplay", ["--volume", String(volPulse), file], (err2) => {
101
- if (err2) execFile("mpv", ["--no-video", `--volume=${volPct}`, file], () => {});
142
+ // Mute check (skip for --file, which is used by picker previews)
143
+ if (arg !== "--file" && config.muted) process.exit(0);
144
+
145
+ const file = resolveSound(arg);
146
+ if (!file) process.exit(1);
147
+
148
+ // Volume: 1-10 config → 0.0-1.0 native scale
149
+ const vol = Math.max(1, Math.min(10, config.volume ?? 10));
150
+ const volFloat = vol / 10; // 0.1 – 1.0 (macOS, Windows)
151
+ const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
152
+ const volPulse = Math.round(volFloat * 65536); // paplay scale
153
+
154
+ const os = platform();
155
+
156
+ if (os === "darwin") {
157
+ execFile("afplay", ["-v", String(volFloat), file], () => {});
158
+ } else if (os === "win32") {
159
+ exec(
160
+ `powershell -NoProfile -Command "Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Volume = ${volFloat}; $p.Play(); Start-Sleep -Seconds 3"`,
161
+ () => {}
162
+ );
163
+ } else {
164
+ execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], (err) => {
165
+ if (err) execFile("paplay", ["--volume", String(volPulse), file], (err2) => {
166
+ if (err2) execFile("mpv", ["--no-video", `--volume=${volPct}`, file], () => {});
167
+ });
102
168
  });
103
- });
104
- }
169
+ }
170
+ })();
package/bin/stamp.mjs ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Records the current timestamp to ~/.agent-noti/last-prompt.
4
+ * Called by the PromptSubmit hook so play.mjs can measure task duration.
5
+ */
6
+
7
+ import { writeFileSync, mkdirSync } from "fs";
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+
11
+ const dir = join(homedir(), ".agent-noti");
12
+ mkdirSync(dir, { recursive: true });
13
+ writeFileSync(join(dir, "last-prompt"), String(Date.now()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-noti",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Audio notifications for Claude Code & Codex — customizable sound themes",
5
5
  "bin": {
6
6
  "agent-noti": "./bin/cli.mjs"