ai-notify 0.4.3 → 0.4.5
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.ja.md +23 -1
- package/README.md +24 -1
- package/menubar/AiNotifyMenuBar.swift +61 -1
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +10 -1
- package/src/notify.mjs +9 -4
- package/src/state.mjs +17 -0
- package/src/util.mjs +28 -12
package/README.ja.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) · **日本語**
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
6
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+

|
|
9
|
+
[](https://github.com/unoryota/ai-notify)
|
|
10
|
+
|
|
5
11
|
**ターミナルのAIエージェントが「あなたを必要とした瞬間」を逃さない** — Claude Code・Codex などのエージェントがターンを終えた/入力を求めた瞬間に、音・読み上げ・デスクトップ通知で知らせます。**全エージェント・全ターミナルを1つのスイッチで一括ミュート**。デーモンも常駐プロセスも無し。
|
|
6
12
|
|
|
7
13
|

|
|
@@ -13,6 +19,10 @@ brew install unoryota/tap/ai-notify # macOS(Homebrew)
|
|
|
13
19
|
ai-notify init # インストール済みのエージェントを自動検出して配線
|
|
14
20
|
```
|
|
15
21
|
|
|
22
|
+
セットアップはこれだけ。`init` が Claude Code / Codex / Gemini を見つけてフックを配線します。あとは1つのスイッチで全部を操作できます:
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
16
26
|
## 何が違うか
|
|
17
27
|
|
|
18
28
|
エージェントは数分間も沈黙しがち。ai-notify は適切な瞬間に呼び戻します。とくに **AIを並列でたくさん動かす運用**のために作られています:
|
|
@@ -23,6 +33,10 @@ ai-notify init # インストール済みのエージェントを自動
|
|
|
23
33
|
- 🔕 **1スイッチで全部ミュート。** 全エージェント・全ターミナルが同じフラグを読むので、会議中はワンタップで全部静かに。
|
|
24
34
|
- 🔔 **ネイティブのメニューバーも内蔵。** `ai-notify menubar install` — Hammerspoon/SwiftBar 不要。
|
|
25
35
|
|
|
36
|
+
エージェントの返答を翻訳・VOICEVOX キャラの一覧・ツンデレ口調のクイックツアー:
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
26
40
|
## 対応エージェント
|
|
27
41
|
|
|
28
42
|
| エージェント | 状態 | 配線方法 |
|
|
@@ -68,9 +82,17 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
|
|
|
68
82
|
|
|
69
83
|
モノクロの波形アイコンが**状態を色で**表します(Adobe風):通常はシルエットのみ、入力待ちがあると**黄ドット**、ミュート中は**赤+斜線**。
|
|
70
84
|
|
|
71
|
-
- **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX
|
|
85
|
+
- **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX)、**ペイン別**設定。開いている各ターミナルに、**読み上げ名**(どのペインが終わったか声で分かる)・**声**・**音量**を個別設定でき、各行にそのペインの声が一覧表示されます。
|
|
72
86
|
- **右クリック** → 即ミュート切替。
|
|
73
87
|
|
|
88
|
+
<p>
|
|
89
|
+
<img alt="ai-notify メニュー — 音量・ツンデレ・声" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
|
90
|
+
|
|
91
|
+
<img alt="ペインごとの読み上げ名と声(1ターミナル1行)" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar-panes.png" width="250">
|
|
92
|
+
</p>
|
|
93
|
+
|
|
94
|
+
*左:全体の音量/ツンデレ/声。右:各ペインに名前と声を割り当て(🗣 バックエンド → Kyoko、infra → ずんだもん)。*
|
|
95
|
+
|
|
74
96
|
第三者アプリ不要。別の方法が好みなら、**Hammerspoon**・**SwiftBar/xbar**・**Raycast**・標準の**ショートカット**用レシピが [`recipes/`](recipes/) にあります。`ai-notify status --icon` は `🔔`/`🔕` だけを出力するので、tmux・プロンプト・Claude Code のステータスラインに埋め込めます。
|
|
75
97
|
|
|
76
98
|
> 切替は実行中でも効きます:次にエージェントが発火した時にフラグを読むので、トグルした瞬間に全稼働エージェントへ反映されます。
|
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
**English** · [日本語](README.ja.md)
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
6
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+

|
|
9
|
+
[](https://github.com/unoryota/ai-notify)
|
|
10
|
+
|
|
5
11
|
**Know the moment your terminal AI agent needs you** — a sound, a spoken read-out, and a desktop banner the instant Claude Code, Codex, or another agent finishes a turn or asks for input. One mute switch covers **all of them, across every terminal**. No daemon, no background process.
|
|
6
12
|
|
|
7
13
|

|
|
@@ -13,6 +19,11 @@ brew install unoryota/tap/ai-notify # macOS (Homebrew)
|
|
|
13
19
|
ai-notify init # auto-detects your agents and wires them
|
|
14
20
|
```
|
|
15
21
|
|
|
22
|
+
That's the whole setup — `init` finds Claude Code / Codex / Gemini and wires
|
|
23
|
+
their hooks. From then on you control everything with one switch:
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
16
27
|
## What makes it different
|
|
17
28
|
|
|
18
29
|
Plenty of agents go quiet for minutes. ai-notify pulls you back at the right moment — and is built for **running many agents at once**:
|
|
@@ -23,6 +34,10 @@ Plenty of agents go quiet for minutes. ai-notify pulls you back at the right mom
|
|
|
23
34
|
- 🔕 **One switch mutes everything.** Every agent in every terminal reads the same flag — one tap silences them all for a meeting.
|
|
24
35
|
- 🔔 **A real menu bar bell, built in.** `ai-notify menubar install` — no Hammerspoon/SwiftBar required.
|
|
25
36
|
|
|
37
|
+
A quick tour — translate an agent's reply, list VOICEVOX character voices, and the tsundere persona:
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
26
41
|
## Supported agents
|
|
27
42
|
|
|
28
43
|
| Agent | Status | How it's wired |
|
|
@@ -68,9 +83,17 @@ ai-notify menubar install # native menu bar app, starts at login
|
|
|
68
83
|
|
|
69
84
|
A monochrome waveform icon shows status by color (Adobe-style): plain when idle, a **yellow** dot when an agent is waiting for you, **red + slash** when muted.
|
|
70
85
|
|
|
71
|
-
- **Left-click** → menu: a **volume slider**, a **tsundere** toggle + デレ⇄ツン slider, the **voice list** (system + VOICEVOX), and **per-pane** controls
|
|
86
|
+
- **Left-click** → menu: a **volume slider**, a **tsundere** toggle + デレ⇄ツン slider, the **voice list** (system + VOICEVOX), and **per-pane** controls. Each open terminal gets its own **spoken name** (read out so you know *which* pane finished), **voice**, and **volume** — the row shows each pane's voice at a glance.
|
|
72
87
|
- **Right-click** → instant mute toggle.
|
|
73
88
|
|
|
89
|
+
<p>
|
|
90
|
+
<img alt="ai-notify menu — volume, tsundere, and voice controls" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
|
91
|
+
|
|
92
|
+
<img alt="per-pane spoken name and voice, one row per terminal" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar-panes.png" width="250">
|
|
93
|
+
</p>
|
|
94
|
+
|
|
95
|
+
*Left: global volume / tsundere / voices. Right: each pane named and given its own voice (🗣 バックエンド → Kyoko, infra → ずんだもん).*
|
|
96
|
+
|
|
74
97
|
No third-party app needed. Prefer something else? There are drop-in recipes for **Hammerspoon**, **SwiftBar/xbar**, **Raycast**, and the built-in **macOS Shortcuts** in [`recipes/`](recipes/). `ai-notify status --icon` prints just `🔔`/`🔕` to embed in tmux / your prompt / Claude Code's status line.
|
|
75
98
|
|
|
76
99
|
> Toggling works mid-run: the flag is read the next time an agent fires, so flipping it instantly affects every running agent.
|
|
@@ -113,6 +113,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
113
113
|
}
|
|
114
114
|
render()
|
|
115
115
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.render() }
|
|
116
|
+
|
|
117
|
+
var shotPath = ProcessInfo.processInfo.environment["AI_NOTIFY_SHOT"]
|
|
118
|
+
let args = CommandLine.arguments
|
|
119
|
+
if let i = args.firstIndex(of: "--shot"), i + 1 < args.count { shotPath = args[i + 1] }
|
|
120
|
+
if let shot = shotPath, !shot.isEmpty {
|
|
121
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.captureMenuShot(shot) }
|
|
122
|
+
}
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
// Black/white waveform silhouette (template, auto-adapting) when idle; a
|
|
@@ -283,7 +290,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
283
290
|
return it
|
|
284
291
|
}
|
|
285
292
|
|
|
286
|
-
private func
|
|
293
|
+
private func buildMenu() -> NSMenu {
|
|
287
294
|
let menu = NSMenu()
|
|
288
295
|
|
|
289
296
|
// Parse menu-json once.
|
|
@@ -403,6 +410,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
403
410
|
quitItem.target = self
|
|
404
411
|
menu.addItem(quitItem)
|
|
405
412
|
|
|
413
|
+
return menu
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private func showMenu() {
|
|
417
|
+
let menu = buildMenu()
|
|
418
|
+
if let button = statusItem.button {
|
|
419
|
+
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Screenshot mode (env AI_NOTIFY_SHOT=/path): open the menu, capture just its
|
|
424
|
+
// window via `screencapture`, then quit. Used to regenerate the README image
|
|
425
|
+
// headlessly — never reached in normal operation.
|
|
426
|
+
private var shotMenu: NSMenu?
|
|
427
|
+
func captureMenuShot(_ path: String) {
|
|
428
|
+
let menu = buildMenu()
|
|
429
|
+
shotMenu = menu
|
|
430
|
+
let pid = ProcessInfo.processInfo.processIdentifier
|
|
431
|
+
// An open NSMenu runs the runloop in event-tracking mode, so the timer
|
|
432
|
+
// must be registered in .common mode to fire while the menu is on screen.
|
|
433
|
+
// The menu window can take a few ticks to register with the window
|
|
434
|
+
// server, so poll for it rather than assuming a single fixed delay.
|
|
435
|
+
var ticks = 0
|
|
436
|
+
let t = Timer(timeInterval: 0.25, repeats: true) { [weak self] timer in
|
|
437
|
+
ticks += 1
|
|
438
|
+
let infos = (CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]]) ?? []
|
|
439
|
+
var bestId: CGWindowID = 0
|
|
440
|
+
var bestArea: CGFloat = 0
|
|
441
|
+
for w in infos {
|
|
442
|
+
guard (w[kCGWindowOwnerPID as String] as? pid_t) == pid else { continue }
|
|
443
|
+
guard let b = w[kCGWindowBounds as String] as? [String: CGFloat],
|
|
444
|
+
let wd = b["Width"], let ht = b["Height"],
|
|
445
|
+
let num = w[kCGWindowNumber as String] as? Int else { continue }
|
|
446
|
+
// The menu is far taller than the status-bar button window.
|
|
447
|
+
let area = wd * ht
|
|
448
|
+
if ht > 120 && area > bestArea { bestArea = area; bestId = CGWindowID(num) }
|
|
449
|
+
}
|
|
450
|
+
if bestId == 0 && ticks < 16 { return } // menu window not up yet — keep polling
|
|
451
|
+
if bestId != 0 {
|
|
452
|
+
// -l captures the window at native resolution and crops tight to
|
|
453
|
+
// it; -o drops the drop-shadow for a clean asset.
|
|
454
|
+
let p = Process()
|
|
455
|
+
p.launchPath = "/usr/sbin/screencapture"
|
|
456
|
+
p.arguments = ["-x", "-o", "-l", String(bestId), path]
|
|
457
|
+
try? p.run(); p.waitUntilExit()
|
|
458
|
+
}
|
|
459
|
+
timer.invalidate()
|
|
460
|
+
menu.cancelTracking()
|
|
461
|
+
self?.shotMenu = nil
|
|
462
|
+
NSApp.terminate(nil)
|
|
463
|
+
}
|
|
464
|
+
RunLoop.main.add(t, forMode: .common)
|
|
465
|
+
NSApp.activate(ignoringOtherApps: true) // a popUp only shows for the active app
|
|
406
466
|
if let button = statusItem.button {
|
|
407
467
|
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
|
|
408
468
|
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Desktop, sound, and spoken notifications for terminal AI coding agents (Claude Code, Codex, Gemini, ...) — with one mute switch that covers all of them, across every terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
readPanes,
|
|
33
33
|
readPaneSetting,
|
|
34
34
|
updatePaneSetting,
|
|
35
|
+
firstRunNudge,
|
|
35
36
|
} from './state.mjs';
|
|
36
37
|
|
|
37
38
|
// Single source of truth: read the version from package.json so `--version`
|
|
@@ -138,7 +139,15 @@ const cmds = {
|
|
|
138
139
|
}
|
|
139
140
|
if (!any) log(' No supported agents detected (looked for Claude Code, Codex, Gemini).');
|
|
140
141
|
log(`\nMute toggle: ai-notify toggle Status: ai-notify status`);
|
|
141
|
-
if (!dryRun)
|
|
142
|
+
if (!dryRun) {
|
|
143
|
+
log('Restart already-running Codex sessions to pick up the change.');
|
|
144
|
+
// A quiet, one-time nudge — `init` is a setup command, run once. Shown
|
|
145
|
+
// only on the first successful wiring so it never nags on re-runs.
|
|
146
|
+
if (any && firstRunNudge()) {
|
|
147
|
+
log('\n⭐ Useful? A GitHub star really helps it reach others:');
|
|
148
|
+
log(' https://github.com/unoryota/ai-notify');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
142
151
|
},
|
|
143
152
|
|
|
144
153
|
uninstall() {
|
package/src/notify.mjs
CHANGED
|
@@ -184,10 +184,15 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
184
184
|
setPaneWaiting(tty, event === 'waiting'); // waiting -> yellow menu bar status; done clears it
|
|
185
185
|
const pane = readPaneSetting(tty);
|
|
186
186
|
|
|
187
|
-
// Name this pane in the read-out
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
187
|
+
// Name this pane in the read-out, most-reliable identity first:
|
|
188
|
+
// 1. $AI_NOTIFY_LABEL — set in the pane's shell, inherited by the hook even
|
|
189
|
+
// when the agent runs it detached (no tty). Always spoken: setting it is
|
|
190
|
+
// explicit intent. The reliable way to name a pane for Claude Code.
|
|
191
|
+
// 2. pane.speakName — set from the menu bar, keyed by tty. Works only when
|
|
192
|
+
// the hook resolves to the pane's tty (see controllingTty's tree walk).
|
|
193
|
+
// 3. the auto-derived label — only when speakLabel is on (else slow filler).
|
|
194
|
+
const envName = (process.env.AI_NOTIFY_LABEL || '').trim();
|
|
195
|
+
const spokenName = envName || pane.speakName || (config.speakLabel === true && label ? label : '');
|
|
191
196
|
const speakText = spokenName ? `${spokenName}、${spokenBody}` : spokenBody;
|
|
192
197
|
|
|
193
198
|
// Per-pane voice (precedence: $AI_NOTIFY_* env > this pane's pick > global).
|
package/src/state.mjs
CHANGED
|
@@ -159,6 +159,23 @@ export const nextCounter = (name) => {
|
|
|
159
159
|
return n;
|
|
160
160
|
};
|
|
161
161
|
|
|
162
|
+
// One-time UI nudges (e.g. the post-`init` star hint). Returns true the FIRST
|
|
163
|
+
// time it's called for a given key, then records a marker so it never fires
|
|
164
|
+
// again — so setup hints inform once without ever nagging on re-runs.
|
|
165
|
+
export const firstRunNudge = (key = 'star') => {
|
|
166
|
+
const p = join(stateDir(), `nudged-${key}`);
|
|
167
|
+
if (existsSync(p)) return false;
|
|
168
|
+
try {
|
|
169
|
+
ensureDir(stateDir());
|
|
170
|
+
writeFileSync(p, '');
|
|
171
|
+
} catch {
|
|
172
|
+
/* best-effort: if we can't persist, don't nag repeatedly is preferred, so
|
|
173
|
+
treat a write failure as "already shown". */
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
|
|
162
179
|
// --- Per-pane state --------------------------------------------------------
|
|
163
180
|
// Recently-active terminal panes (so the menu bar can offer per-pane voices),
|
|
164
181
|
// and a per-tty voice override. Both are small JSON files in the state dir.
|
package/src/util.mjs
CHANGED
|
@@ -40,18 +40,34 @@ export const isEphemeralInstall = (cliPath) => /[/\\]_npx[/\\]/.test(cliPath);
|
|
|
40
40
|
|
|
41
41
|
export const MARKER = 'ai-notify'; // substring used to detect our own wiring
|
|
42
42
|
|
|
43
|
-
// The controlling terminal of
|
|
44
|
-
//
|
|
43
|
+
// The controlling terminal of the agent's pane (e.g. "/dev/ttys010"), used to
|
|
44
|
+
// scope per-pane settings. Returns null if none can be found.
|
|
45
|
+
//
|
|
46
|
+
// Agents often run the notify hook detached (Claude Code wires it `async`), so
|
|
47
|
+
// the hook process itself frequently has NO controlling tty — but its parent
|
|
48
|
+
// (the agent, e.g. `claude`) still owns the pane's terminal. So we walk up the
|
|
49
|
+
// process tree until we find a real tty, which makes the hook resolve to the
|
|
50
|
+
// SAME tty the menu bar lists the pane under (it scans the agent process).
|
|
45
51
|
export const controllingTty = () => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
let pid = process.pid;
|
|
53
|
+
for (let depth = 0; depth < 8 && pid > 1; depth++) {
|
|
54
|
+
try {
|
|
55
|
+
const line = execFileSync('ps', ['-o', 'tty=', '-o', 'ppid=', '-p', String(pid)], {
|
|
56
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
57
|
+
})
|
|
58
|
+
.toString()
|
|
59
|
+
.trim();
|
|
60
|
+
if (!line) return null;
|
|
61
|
+
// "ttys010 1234" or "?? 1234" (no controlling tty for this pid)
|
|
62
|
+
const sp = line.lastIndexOf(' ');
|
|
63
|
+
const tty = line.slice(0, sp).trim();
|
|
64
|
+
const ppid = parseInt(line.slice(sp + 1).trim(), 10);
|
|
65
|
+
if (tty && tty !== '??' && tty !== '?') return tty.startsWith('/dev/') ? tty : `/dev/${tty}`;
|
|
66
|
+
if (!Number.isFinite(ppid) || ppid <= 1) return null;
|
|
67
|
+
pid = ppid;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
56
71
|
}
|
|
72
|
+
return null;
|
|
57
73
|
};
|