ai-notify 0.4.2 → 0.4.4

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  [English](README.md) · **日本語**
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/ai-notify?color=cb3837&logo=npm)](https://www.npmjs.com/package/ai-notify)
6
+ [![downloads](https://img.shields.io/npm/dw/ai-notify?color=cb3837)](https://www.npmjs.com/package/ai-notify)
7
+ [![license](https://img.shields.io/npm/l/ai-notify?color=blue)](./LICENSE)
8
+ ![platform](https://img.shields.io/badge/macOS%20%C2%B7%20Linux-zero--dep-success)
9
+ [![stars](https://img.shields.io/github/stars/unoryota/ai-notify?style=social)](https://github.com/unoryota/ai-notify)
10
+
5
11
  **ターミナルのAIエージェントが「あなたを必要とした瞬間」を逃さない** — Claude Code・Codex などのエージェントがターンを終えた/入力を求めた瞬間に、音・読み上げ・デスクトップ通知で知らせます。**全エージェント・全ターミナルを1つのスイッチで一括ミュート**。デーモンも常駐プロセスも無し。
6
12
 
7
13
  ![ai-notify demo](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/hero-ja.gif)
@@ -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
+ ![ai-notify の使い方: init・status・一括ミュート・声の切替](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/usage.gif)
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
+ ![ai-notify の機能: 翻訳・VOICEVOXボイス・ツンデレ](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/features.gif)
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
+ &nbsp;&nbsp;
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
+ [![npm](https://img.shields.io/npm/v/ai-notify?color=cb3837&logo=npm)](https://www.npmjs.com/package/ai-notify)
6
+ [![downloads](https://img.shields.io/npm/dw/ai-notify?color=cb3837)](https://www.npmjs.com/package/ai-notify)
7
+ [![license](https://img.shields.io/npm/l/ai-notify?color=blue)](./LICENSE)
8
+ ![platform](https://img.shields.io/badge/macOS%20%C2%B7%20Linux-zero--dep-success)
9
+ [![stars](https://img.shields.io/github/stars/unoryota/ai-notify?style=social)](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
  ![ai-notify demo](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/hero-en.gif)
@@ -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
+ ![ai-notify usage: init, status, one-switch mute, voices](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/usage.gif)
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
+ ![ai-notify features: translate, VOICEVOX voices, tsundere](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/features.gif)
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 each open terminal gets its own voice *and* volume.
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
+ &nbsp;&nbsp;
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
@@ -175,6 +182,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
175
182
  @objc private func paneVolumeChanged(_ s: NSSlider) {
176
183
  if let tty = s.identifier?.rawValue { State.cli(["volume-pane", tty, String(format: "%.2f", s.doubleValue)]) }
177
184
  }
185
+ // Editing a text field *inside* an NSMenu is unreliable — the menu's tracking
186
+ // loop swallows the keystrokes. So naming a pane opens a normal modal dialog
187
+ // (NSAlert with a text field), which takes keyboard focus properly. Empty =>
188
+ // clear (the pane falls back to its label / the speakLabel default).
189
+ @objc private func promptPaneName(_ sender: NSMenuItem) {
190
+ guard let info = sender.representedObject as? [String], let tty = info.first else { return }
191
+ let current = info.count > 1 ? info[1] : ""
192
+ let alert = NSAlert()
193
+ alert.messageText = "読み上げ名"
194
+ alert.informativeText = "このペインを通知で読み上げる名前(空欄で解除)"
195
+ let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
196
+ field.stringValue = current
197
+ field.placeholderString = "例: バックエンド"
198
+ alert.accessoryView = field
199
+ alert.addButton(withTitle: "保存")
200
+ alert.addButton(withTitle: "キャンセル")
201
+ NSApp.activate(ignoringOtherApps: true)
202
+ alert.window.initialFirstResponder = field
203
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
204
+ let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
205
+ State.cli(["name-pane", tty, name.isEmpty ? "clear" : name])
206
+ }
178
207
  // identifier carries the prosody key (speed | pitch | intonation).
179
208
  @objc private func prosodyChanged(_ s: NSSlider) {
180
209
  if let key = s.identifier?.rawValue { State.cli(["voice-prosody", key, String(format: "%.3f", s.doubleValue)]) }
@@ -261,7 +290,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
261
290
  return it
262
291
  }
263
292
 
264
- private func showMenu() {
293
+ private func buildMenu() -> NSMenu {
265
294
  let menu = NSMenu()
266
295
 
267
296
  // Parse menu-json once.
@@ -323,8 +352,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
323
352
  guard let tty = p["tty"] as? String else { continue }
324
353
  let label = p["label"] as? String ?? tty
325
354
  let cur = p["current"] as? String
326
- let item = NSMenuItem(title: cur != nil ? "\(label) — \(cur!)" : label, action: nil, keyEquivalent: "")
355
+ let pname = p["speakName"] as? String ?? ""
356
+ // Parent row shows at a glance WHO the pane is (its custom 🗣 name,
357
+ // or its label/tty) AND which 🔊 voice it uses (omitted when it just
358
+ // follows the global voice). Naming a pane must not hide the voice —
359
+ // surfacing the per-pane voice is what this list is for.
360
+ let who = pname.isEmpty ? label : "🗣 \(pname)"
361
+ let voiceTag = cur != nil ? " — 🔊 \(cur!)" : ""
362
+ let item = NSMenuItem(title: who + voiceTag, action: nil, keyEquivalent: "")
327
363
  let sub = NSMenu()
364
+ // Per-pane spoken name — opens a dialog (menu fields can't type).
365
+ sub.addItem(disabledHeader("読み上げ名"))
366
+ let nameItem = NSMenuItem(
367
+ title: pname.isEmpty ? "(クリックして設定…)" : "「\(pname)」を変更…",
368
+ action: #selector(promptPaneName(_:)), keyEquivalent: ""
369
+ )
370
+ nameItem.target = self
371
+ nameItem.representedObject = [tty, pname]
372
+ sub.addItem(nameItem)
373
+ if !pname.isEmpty {
374
+ let clr = NSMenuItem(title: "読み上げ名を解除", action: #selector(runItem(_:)), keyEquivalent: "")
375
+ clr.target = self; clr.representedObject = ["name-pane", tty, "clear"]
376
+ sub.addItem(clr)
377
+ }
378
+ sub.addItem(.separator())
328
379
  // Per-pane volume.
329
380
  let pv = (p["volume"] as? Double) ?? State.volume
330
381
  sub.addItem(disabledHeader("音量"))
@@ -359,6 +410,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
359
410
  quitItem.target = self
360
411
  menu.addItem(quitItem)
361
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
362
466
  if let button = statusItem.button {
363
467
  menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
364
468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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) log('Restart already-running Codex sessions to pick up the change.');
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() {
@@ -479,6 +488,24 @@ const cmds = {
479
488
  log(`pane ${tty}: tsundere level ${v}`);
480
489
  },
481
490
 
491
+ // Name a specific pane in the spoken read-out (set from the menu bar), or
492
+ // `clear` to fall back to the label / speakLabel default.
493
+ // name-pane <tty> <name|clear>
494
+ 'name-pane'() {
495
+ const [tty, ...rest] = positionals;
496
+ const arg = rest.join(' ').trim(); // a name may contain spaces
497
+ if (!tty || arg === '') {
498
+ console.error('usage: name-pane <tty> <name|clear>');
499
+ process.exit(1);
500
+ }
501
+ if (arg === 'clear') {
502
+ updatePaneSetting(tty, { speakName: null });
503
+ return log(`pane ${tty}: name cleared`);
504
+ }
505
+ updatePaneSetting(tty, { speakName: arg });
506
+ log(`pane ${tty}: name ${arg}`);
507
+ },
508
+
482
509
  // Get/set the VOICEVOX base prosody (the normal-tone scales the menu bar
483
510
  // sliders drive). With no args, prints the current values as JSON.
484
511
  // voice-prosody [speed|pitch|intonation <value> | reset]
@@ -534,6 +561,7 @@ const cmds = {
534
561
  tty,
535
562
  label: recorded.get(tty) || tty.replace('/dev/', ''),
536
563
  current: labelFor(s.tts ? s : null),
564
+ speakName: typeof s.speakName === 'string' ? s.speakName : '',
537
565
  volume: typeof s.volume === 'number' ? s.volume : globalVol,
538
566
  volumeSet: typeof s.volume === 'number',
539
567
  tsundere: typeof s.tsundere === 'number' ? s.tsundere : tsLevel,
package/src/notify.mjs CHANGED
@@ -176,19 +176,21 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
176
176
  if (!message) spokenBody = fromTemplate || fallback;
177
177
  else if (config.speakAgentMessage) spokenBody = fullBody;
178
178
  else spokenBody = shortenForSpeech(fullBody, config.speakMaxChars || 40);
179
- // The task gist already tells you which pane; the label (often the working
180
- // dir) is just slow filler. Prefix it only if explicitly enabled.
181
- const speakText = config.speakLabel === true && label ? `${label}、${spokenBody}` : spokenBody;
182
-
183
- // Per-pane voice: remember this pane (so the menu bar can list it) and apply
184
- // any voice assigned to it. Precedence (most specific first):
185
- // $AI_NOTIFY_* env — set in the pane's shell
186
- // this pane's pick — assigned from the menu bar (keyed by tty)
187
- // provider / global — config defaults
179
+ // Per-pane settings (voice / volume / tsundere / name), keyed by tty. Read
180
+ // here — before the read-out is assembled so the spoken text can use this
181
+ // pane's assigned name. Also remember the pane so the menu bar can list it.
188
182
  const tty = controllingTty();
189
183
  recordPane(tty, label);
190
184
  setPaneWaiting(tty, event === 'waiting'); // waiting -> yellow menu bar status; done clears it
191
185
  const pane = readPaneSetting(tty);
186
+
187
+ // Name this pane in the read-out. An explicit per-pane name (set from the menu
188
+ // bar) is ALWAYS spoken; the auto-derived label (often just the working dir)
189
+ // is prefixed only when speakLabel is on — it's slow filler otherwise.
190
+ const spokenName = pane.speakName || (config.speakLabel === true && label ? label : '');
191
+ const speakText = spokenName ? `${spokenName}、${spokenBody}` : spokenBody;
192
+
193
+ // Per-pane voice (precedence: $AI_NOTIFY_* env > this pane's pick > global).
192
194
  const tts = pane.tts || config.tts;
193
195
  const voice = process.env.AI_NOTIFY_VOICE || pane.voice || p.voice || config.voice;
194
196
  const speaker = process.env.AI_NOTIFY_VOICEVOX_SPEAKER || pane.speaker || config.voicevox?.speaker;
@@ -231,7 +233,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
231
233
  speakTone = tsundere.axisFor(eff);
232
234
  outVol = Math.min(2, Math.max(0, vol * tsundere.volumeMul(tier, ts.volumeBoost !== false)));
233
235
  outText = tsundere.wrap(spokenBody, eff, tier, ts.lang || 'ja', nextCounter('tsundere'));
234
- if (config.speakLabel === true && label) outText = `${label}、${outText}`;
236
+ if (spokenName) outText = `${spokenName}、${outText}`;
235
237
  if (tts === 'voicevox') {
236
238
  const sm = ts.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
237
239
  const axis = tsundere.axisFor(eff);
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.