tmux-ide 2.6.1 → 2.8.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.
Files changed (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -7,29 +7,86 @@
7
7
  * signals out to the human: a tmux toast on each attached client
8
8
  * (`display-message -c`), and optionally a macOS notification.
9
9
  *
10
- * Split as usual: {@link decideNotifications} + {@link notificationPrefs} +
11
- * {@link applyKillSwitch} + {@link parseClients} are PURE (unit-tested without a
10
+ * Since M25.2 the fan-out spans FIVE channels: the in-app toast, the system
11
+ * banner (native macOS helper, Linux `notify-send`), the
12
+ * terminal-native OSC 9/99 escape written straight to each eligible client's
13
+ * tty, a ping sound, and a BEL on blocked. The OS-level channels (everything
14
+ * but the toast) are quiet-hours gated and delay/re-verified by the updater.
15
+ *
16
+ * Split as usual: {@link decideNotifications} + {@link notifyMessage} +
17
+ * {@link enabledStates} + {@link inQuietHours} + {@link parseNotificationPrefs}
18
+ * + {@link applyKillSwitch} + {@link parseClients} + {@link terminalNotifierArgs}
19
+ * + {@link terminalNotifyEscape} + {@link decideTtyWrites} + {@link notifySendArgs}
20
+ * + {@link soundEligible} + {@link soundArgv} are PURE (unit-tested without a
12
21
  * live tmux / filesystem); {@link sendToasts}, {@link sendSystemNotification},
13
- * {@link listAttachedClients} and {@link readNotificationPrefs} are the thin io
22
+ * {@link resolveNativeMacosNotifierPath}, {@link listAttachedClients}, {@link writeTtys},
23
+ * {@link playPingSound} and {@link readNotificationPrefs} are the thin io
14
24
  * wrappers. Every io path is best-effort — a failed ping must never break the
15
25
  * updater loop.
16
26
  */
17
- import { execFileSync } from "node:child_process";
27
+ import { execFileSync, spawn } from "node:child_process";
28
+ import { dirname, resolve } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import {
31
+ closeSync,
32
+ constants as fsConstants,
33
+ existsSync,
34
+ openSync,
35
+ readFileSync,
36
+ writeSync,
37
+ } from "node:fs";
18
38
  import { runTmux } from "@tmux-ide/tmux-bridge";
19
- import { appConfigPath, loadAppConfig, parseAppConfig } from "../../lib/app-config.ts";
39
+ import { appConfigPath, parseAppConfig, type NotificationSound } from "../../lib/app-config.ts";
40
+ import { APP_HOST_SESSION } from "../mirror/hosted.ts";
41
+ import { tmuxPassthrough } from "../mirror/selection.ts";
20
42
  import type { AgentStatus } from "../detect/classify.ts";
43
+ // The pure prefs shapes + parsing live in a pure module (notify-prefs.ts) so
44
+ // pure surfaces (settings-model) can import them without dragging this file's
45
+ // node/tmux io behind them. Re-exported here: existing importers are unchanged.
46
+ import { parseHHMM, type NotificationPrefs, type QuietHours } from "./notify-prefs.ts";
47
+ export { parseHHMM };
48
+ export type { NotificationPrefs, QuietHours };
21
49
 
22
- /** A fleet transition this tick — the shape {@link ./events.ts diffFleet} emits. */
50
+ /**
51
+ * A fleet transition this tick — since M25.1 a PANE-level transition (the
52
+ * updater diffs per-pane states, not the session rollup, so a second agent
53
+ * blocking in an already-blocked session still pings), ENRICHED with the
54
+ * pane's resolved `agent` id and human `location` (`session:window.pane`) so
55
+ * the ping can name who needs the user. The enrichments are optional: a caller
56
+ * that can't resolve them falls back to a generic `agent` label and the bare
57
+ * session name (see {@link notifyMessage}).
58
+ */
23
59
  export interface NotifyEvent {
24
60
  session: string;
25
61
  from: AgentStatus | null;
26
62
  to: AgentStatus;
63
+ /** Resolved agent id (e.g. `claude`), or null/absent when unknown. */
64
+ agent?: string | null;
65
+ /** Human location `session:window.pane` (e.g. `myproj:1.2`); falls back to `session`. */
66
+ location?: string;
67
+ /** The pane behind the transition — the debounce key and the visibility
68
+ * test both want the pane, not the session. Absent for a caller that only
69
+ * has session granularity (falls back to session-keyed behavior). */
70
+ paneId?: string | null;
71
+ /** The pane's `window_index` — window-granular toast suppression needs it.
72
+ * Absent/null degrades that check to session granularity. */
73
+ windowIndex?: number | null;
27
74
  }
28
75
 
29
- /** An attached tmux client and the session it's currently viewing. */
76
+ /** An attached tmux client, the session it's viewing, and that session's
77
+ * CURRENT window (null when the caller couldn't resolve it — suppression then
78
+ * degrades to session granularity for that client). `tty`/`termname` (M25.2)
79
+ * are what the terminal-escape channel needs — the device to write and which
80
+ * escape form the terminal behind it understands; null/absent means the
81
+ * caller couldn't resolve them and that client just gets no escapes. */
30
82
  export interface AttachedClient {
31
83
  client: string;
32
84
  session: string;
85
+ windowIndex?: number | null;
86
+ /** `#{client_tty}` — the device the escape/BEL bytes are written to. */
87
+ tty?: string | null;
88
+ /** `#{client_termname}` — picks OSC 99 (kitty) / passthrough (nested tmux). */
89
+ termname?: string | null;
33
90
  }
34
91
 
35
92
  /** A toast destined for one client's status line. */
@@ -38,9 +95,23 @@ export interface ToastTarget {
38
95
  message: string;
39
96
  }
40
97
 
41
- /** A macOS-notification payload (title is fixed by {@link sendSystemNotification}). */
98
+ /**
99
+ * One OS-level ping. `session` rides along so the click-through path
100
+ * ({@link terminalNotifierArgs}) can focus the right session on click; since
101
+ * M25.2 the payload also carries the transition's `state` (urgency + sound
102
+ * routing) and its pane identity (`paneId`/`windowIndex`) so the delayed
103
+ * re-verify can confirm the pane is STILL in that state and the terminal-escape
104
+ * channel can re-apply per-client suppression at fire time.
105
+ */
42
106
  export interface SystemNotification {
43
107
  message: string;
108
+ session: string;
109
+ /** The state that was pinged — must still hold when a delayed ping fires. */
110
+ state: AgentStatus;
111
+ /** The pane behind the ping (null: session-granular caller — unverifiable). */
112
+ paneId: string | null;
113
+ /** The pane's window — per-client escape suppression wants it. */
114
+ windowIndex: number | null;
44
115
  }
45
116
 
46
117
  /** The verdict of {@link decideNotifications}. */
@@ -53,25 +124,86 @@ export interface NotifyDecision {
53
124
 
54
125
  /** The only two states worth pinging the user over. */
55
126
  const NOTIFY_STATES: ReadonlySet<AgentStatus> = new Set<AgentStatus>(["blocked", "done"]);
56
- /** Don't re-ping the same session+state more than once inside this window. */
127
+ /**
128
+ * Don't re-ping the same session+state more than once inside this window. This
129
+ * is what tames a FLAPPING agent (working↔blocked every few seconds): the first
130
+ * `blocked` fires, and every subsequent `blocked` inside the window is dropped,
131
+ * so a flap notifies at most once per 30s instead of on every bounce.
132
+ */
57
133
  export const NOTIFY_DEBOUNCE_MS = 30_000;
134
+ /** Cap the ping text so a macOS banner never truncates mid-word (banners clip ~200+). */
135
+ export const NOTIFY_MAX_LEN = 120;
136
+
137
+ /** PURE — the trailing clause for a notifiable state. */
138
+ function statusPhrase(to: AgentStatus): string {
139
+ return to === "blocked" ? "needs input" : "finished";
140
+ }
141
+
142
+ /**
143
+ * PURE — the human-readable ping for an (enriched) transition, e.g.
144
+ * `claude blocked · myproj:1.2 — needs input`. Falls back to a generic `agent`
145
+ * label and the bare session name when the updater couldn't resolve the pane's
146
+ * agent/location. Clamped to {@link NOTIFY_MAX_LEN} so it fits a macOS banner.
147
+ */
148
+ export function notifyMessage(ev: NotifyEvent): string {
149
+ const agent = ev.agent && ev.agent.length > 0 ? ev.agent : "agent";
150
+ const where = ev.location && ev.location.length > 0 ? ev.location : ev.session;
151
+ const text = `${agent} ${ev.to} · ${where} — ${statusPhrase(ev.to)}`;
152
+ return text.length > NOTIFY_MAX_LEN ? `${text.slice(0, NOTIFY_MAX_LEN - 1)}…` : text;
153
+ }
154
+
155
+ /**
156
+ * PURE — which states this user has opted into pinging on, from the
157
+ * `onBlocked`/`onDone` prefs. Empty when both are off (→ no notifications).
158
+ */
159
+ export function enabledStates(prefs: NotificationPrefs): ReadonlySet<AgentStatus> {
160
+ const states = new Set<AgentStatus>();
161
+ if (prefs.onBlocked) states.add("blocked");
162
+ if (prefs.onDone) states.add("done");
163
+ return states;
164
+ }
165
+
166
+ /** PURE — the debounce-map key for an event: the PANE when known (so a second
167
+ * agent blocking in the same session still pings — per-pane debounce), else
168
+ * the session (session-granular callers keep the old behavior). */
169
+ export function notifyDebounceKey(ev: Pick<NotifyEvent, "session" | "to" | "paneId">): string {
170
+ return `${ev.paneId ?? ev.session}:${ev.to}`;
171
+ }
58
172
 
59
- /** PURE — the human-readable ping text for a notifiable state. */
60
- function notifyMessage(session: string, to: AgentStatus): string {
61
- return to === "blocked" ? `⚠ ${session} needs you (blocked)` : `✓ ${session} finished (done)`;
173
+ /** PURE — should this client's toast be suppressed for this event? A client
174
+ * gets no toast when it is already LOOKING at the transition: same session
175
+ * AND same window (window-granular since M25.1 — an agent in window 2 while
176
+ * the client views window 1 IS toast-worthy). Unknown window info on either
177
+ * side degrades to the old session-granular suppression. Clients viewing the
178
+ * hosted app are always suppressed: the app has its own in-app surfacing, and
179
+ * a raw tmux message over the app's renderer is noise. */
180
+ export function suppressToastFor(client: AttachedClient, ev: NotifyEvent): boolean {
181
+ if (client.session === APP_HOST_SESSION) return true;
182
+ if (client.session !== ev.session) return false;
183
+ if (ev.windowIndex === undefined || ev.windowIndex === null) return true;
184
+ if (client.windowIndex === undefined || client.windowIndex === null) return true;
185
+ return client.windowIndex === ev.windowIndex;
62
186
  }
63
187
 
64
188
  /**
65
189
  * PURE — decide who to ping from this tick's transitions.
66
190
  *
67
191
  * Rules:
68
- * - only `blocked` / `done` transitions qualify (working/idle etc. are noise);
69
- * - DEBOUNCE: skip a session+state that fired within {@link NOTIFY_DEBOUNCE_MS};
70
- * - SUPPRESS the toast for any client already viewing that session (they can
71
- * see the bar flip themselves) — other clients still get toasted;
192
+ * - a FIRST-SIGHT event (`from: null`) never notifies — the updater's first
193
+ * tick (or a restart, or a session appearing) sees every pane as new, and
194
+ * re-pinging an agent that has been blocked for an hour is noise;
195
+ * - only states in `states` qualify (default {@link NOTIFY_STATES}; the caller
196
+ * narrows it via {@link enabledStates} to honor `onBlocked`/`onDone`);
197
+ * - DEBOUNCE: skip a pane+state (see {@link notifyDebounceKey}) that fired
198
+ * within {@link NOTIFY_DEBOUNCE_MS} — the flap guard;
199
+ * - APP FOCUS: when the unified app is attached and the event's pane is on
200
+ * its screen ({@link AppFocus}), the user is LOOKING at it — no toast, no
201
+ * banner (and no debounce stamp: nothing fired);
202
+ * - SUPPRESS the toast for any client already viewing that pane's window
203
+ * ({@link suppressToastFor}) — other clients still get toasted;
72
204
  * - a `system` entry is produced per qualifying, non-debounced event regardless
73
205
  * of clients (so the macOS path fires even with nothing attached — the
74
- * caller gates it on prefs).
206
+ * caller gates it on prefs / quiet hours).
75
207
  *
76
208
  * Returns the toasts/system to dispatch plus `nextLastNotified` (a copy of the
77
209
  * input with fresh timestamps for the events we acted on) to thread onward.
@@ -81,40 +213,66 @@ export function decideNotifications(
81
213
  clients: AttachedClient[],
82
214
  lastNotified: Map<string, number>,
83
215
  nowMs: number,
216
+ states: ReadonlySet<AgentStatus> = NOTIFY_STATES,
217
+ appFocus: AppFocus | null = null,
84
218
  ): NotifyDecision {
85
219
  const nextLastNotified = new Map(lastNotified);
86
220
  const toasts: ToastTarget[] = [];
87
221
  const system: SystemNotification[] = [];
88
222
  for (const ev of events) {
89
- if (!NOTIFY_STATES.has(ev.to)) continue;
90
- const key = `${ev.session}:${ev.to}`;
223
+ if (ev.from === null) continue; // first sight — not a transition worth pinging
224
+ if (!states.has(ev.to)) continue;
225
+ const key = notifyDebounceKey(ev);
91
226
  const last = nextLastNotified.get(key);
92
227
  if (last !== undefined && nowMs - last < NOTIFY_DEBOUNCE_MS) continue;
228
+ if (appFocus?.attached && ev.paneId && appFocus.panes.includes(ev.paneId)) continue;
93
229
  nextLastNotified.set(key, nowMs);
94
- const message = notifyMessage(ev.session, ev.to);
230
+ const message = notifyMessage(ev);
95
231
  for (const c of clients) {
96
- if (c.session === ev.session) continue; // they're already looking at it
232
+ if (suppressToastFor(c, ev)) continue;
97
233
  toasts.push({ client: c.client, message });
98
234
  }
99
- system.push({ message });
235
+ system.push({
236
+ message,
237
+ session: ev.session,
238
+ state: ev.to,
239
+ paneId: ev.paneId ?? null,
240
+ windowIndex: ev.windowIndex ?? null,
241
+ });
100
242
  }
101
243
  return { toasts, system, nextLastNotified };
102
244
  }
103
245
 
104
- /** PURE — parse `list-clients -F '#{client_name}\t#{session_name}'` output. */
246
+ /** PURE — parse `list-clients -F '#{client_name}\t#{session_name}\t#{window_index}
247
+ * \t#{client_tty}\t#{client_termname}'` output (fields three-plus are optional;
248
+ * missing/non-numeric window parses as null so shorter-format callers degrade
249
+ * gracefully, and a missing tty/termname just disables that client's escapes). */
105
250
  export function parseClients(lines: string[]): AttachedClient[] {
106
251
  const out: AttachedClient[] = [];
107
252
  for (const line of lines) {
108
- const [client = "", session = ""] = line.split("\t");
109
- if (client && session) out.push({ client, session });
253
+ const [client = "", session = "", win = "", tty = "", termname = ""] = line.split("\t");
254
+ if (!client || !session) continue;
255
+ const n = Number.parseInt(win, 10);
256
+ out.push({
257
+ client,
258
+ session,
259
+ windowIndex: Number.isInteger(n) ? n : null,
260
+ tty: tty || null,
261
+ termname: termname || null,
262
+ });
110
263
  }
111
264
  return out;
112
265
  }
113
266
 
114
- /** io — enumerate attached clients from the live tmux server. Never throws. */
267
+ /** io — enumerate attached clients (+ their current window, tty, termname) from
268
+ * the live tmux server. Never throws. */
115
269
  export function listAttachedClients(): AttachedClient[] {
116
270
  try {
117
- const raw = runTmux(["list-clients", "-F", "#{client_name}\t#{session_name}"])
271
+ const raw = runTmux([
272
+ "list-clients",
273
+ "-F",
274
+ "#{client_name}\t#{session_name}\t#{window_index}\t#{client_tty}\t#{client_termname}",
275
+ ])
118
276
  .toString()
119
277
  .trim();
120
278
  return raw ? parseClients(raw.split("\n")) : [];
@@ -123,6 +281,78 @@ export function listAttachedClients(): AttachedClient[] {
123
281
  }
124
282
  }
125
283
 
284
+ // ---------------------------------------------------------------------------
285
+ // The app focus handshake (M25.1)
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * What the unified app publishes about its own screen, so the updater can
290
+ * suppress pings for panes the user is literally looking at. Lives as a tmux
291
+ * SERVER option ({@link APP_FOCUS_OPTION}) rather than a file: the record is
292
+ * scoped to exactly the server whose panes are being watched (no cross-server
293
+ * or cross-`TMUX_IDE_HOME` leakage), it is readable in the same cheap tmux
294
+ * calls the updater already makes, and it DIES WITH THE SERVER — a crashed
295
+ * server can't leave a stale record behind. A crashed APP can, which is what
296
+ * the `ts` staleness guard covers ({@link APP_FOCUS_STALE_MS}).
297
+ */
298
+ export interface AppFocus {
299
+ /** Epoch ms the app last refreshed the record (each fleet poll, ~3s). */
300
+ ts: number;
301
+ /** Whether a client is attached to the app (hosted: probed; plain: true). */
302
+ attached: boolean;
303
+ /** The session the app's Terminal tab mirrors ("" on Home with no context). */
304
+ session: string;
305
+ /** Pane ids VISIBLE on the app's screen right now — the mirrored window's
306
+ * panes while the Terminal tab is active, [] on Files/Diff/Home. */
307
+ panes: string[];
308
+ }
309
+
310
+ /** The tmux server option carrying the app's JSON {@link AppFocus} record. */
311
+ export const APP_FOCUS_OPTION = "@tmux_ide_app_focus";
312
+ /** Ignore a focus record older than this — covers an app that died without
313
+ * cleanup (the app refreshes every ~3s fleet poll). */
314
+ export const APP_FOCUS_STALE_MS = 15_000;
315
+
316
+ /** PURE — serialize an {@link AppFocus} for the option value. */
317
+ export function buildAppFocusValue(focus: AppFocus): string {
318
+ return JSON.stringify(focus);
319
+ }
320
+
321
+ /** PURE — parse a raw option value into a live {@link AppFocus}, or null for
322
+ * anything missing, malformed, or STALE (`ts` older than
323
+ * {@link APP_FOCUS_STALE_MS} relative to `nowMs`). Never throws. */
324
+ export function parseAppFocus(raw: string | null | undefined, nowMs: number): AppFocus | null {
325
+ if (!raw) return null;
326
+ try {
327
+ const o = JSON.parse(raw) as Record<string, unknown>;
328
+ if (!o || typeof o !== "object") return null;
329
+ const ts = typeof o.ts === "number" ? o.ts : null;
330
+ if (ts === null || nowMs - ts > APP_FOCUS_STALE_MS) return null;
331
+ return {
332
+ ts,
333
+ attached: o.attached === true,
334
+ session: typeof o.session === "string" ? o.session : "",
335
+ panes: Array.isArray(o.panes)
336
+ ? o.panes.filter((p): p is string => typeof p === "string")
337
+ : [],
338
+ };
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+
344
+ /** io — read the app's focus record off the live tmux server (null when unset,
345
+ * malformed, or stale). Never throws. */
346
+ export function readAppFocus(nowMs: number = Date.now()): AppFocus | null {
347
+ try {
348
+ const raw = runTmux(["show-option", "-s", "-v", APP_FOCUS_OPTION]).toString().trim();
349
+ return parseAppFocus(raw, nowMs);
350
+ } catch {
351
+ // option never set (unset server user-options error out) — no app around
352
+ return null;
353
+ }
354
+ }
355
+
126
356
  /**
127
357
  * io — flash each toast on its client's status line (`-d 3000` = 3s; needs tmux
128
358
  * ≥3.2). Best-effort per toast: a gone client / failed call just drops that one.
@@ -137,47 +367,491 @@ export function sendToasts(toasts: ToastTarget[]): void {
137
367
  }
138
368
  }
139
369
 
370
+ // ---------------------------------------------------------------------------
371
+ // The terminal-escape + sound channels (M25.2)
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /**
375
+ * PURE — the OSC 9 desktop-notification escape (iTerm2 / Ghostty / WezTerm and
376
+ * friends; BEL-terminated). Terminals without support ignore it entirely.
377
+ */
378
+ export function osc9Notification(text: string): string {
379
+ return `\x1b]9;${text}\x07`;
380
+ }
381
+
382
+ /**
383
+ * PURE — kitty's richer OSC 99 form (ST-terminated; the empty-metadata payload
384
+ * is the notification title). Blocked pings carry `u=2` (critical urgency) —
385
+ * kitty ignores metadata keys it doesn't know, so this is safe on older kitties.
386
+ */
387
+ export function osc99Notification(text: string, urgent: boolean): string {
388
+ return `\x1b]99;${urgent ? "u=2" : ""};${text}\x1b\\`;
389
+ }
390
+
391
+ /**
392
+ * PURE — the escape a client's terminal actually understands, by
393
+ * `#{client_termname}` (conservative, MEASURED — see {@link writeClientTty}):
394
+ *
395
+ * - `*kitty*` → OSC 99 (kitty ignores OSC 9's text form);
396
+ * - `tmux-*` / `screen*` → the OSC 9 wrapped in the tmux passthrough envelope
397
+ * ({@link tmuxPassthrough}): that termname means the client is itself INSIDE
398
+ * another tmux/screen, so our bytes land as pane OUTPUT of the outer mux and
399
+ * only the envelope (plus the outer mux's `allow-passthrough`, which is the
400
+ * user's to set — we can't reach a foreign server) carries them further;
401
+ * - anything else → raw OSC 9. A direct client-tty write BYPASSES our own
402
+ * tmux server entirely (measured: bytes arrive on the tty stream verbatim),
403
+ * so for a directly-attached terminal the RAW escape is the correct form —
404
+ * an envelope there would be ignored by the terminal, not unwrapped.
405
+ */
406
+ export function terminalNotifyEscape(
407
+ termname: string | null | undefined,
408
+ text: string,
409
+ urgent: boolean,
410
+ ): string {
411
+ const t = termname ?? "";
412
+ if (t.includes("kitty")) return osc99Notification(text, urgent);
413
+ if (t.startsWith("tmux") || t.startsWith("screen")) {
414
+ return tmuxPassthrough(osc9Notification(text));
415
+ }
416
+ return osc9Notification(text);
417
+ }
418
+
419
+ /** One pending write to a client's tty device. */
420
+ export interface TtyWrite {
421
+ tty: string;
422
+ data: string;
423
+ }
424
+
425
+ /** PURE — should the sound channel fire for this state under this pref? */
426
+ export function soundEligible(state: AgentStatus, sound: NotificationSound): boolean {
427
+ if (sound === "none") return false;
428
+ if (sound === "all") return state === "blocked" || state === "done";
429
+ return state === "blocked";
430
+ }
431
+
432
+ /** BEL rings the terminal's attention marker — `blocked` only, and it rides the
433
+ * sound pref (a user who turned sound off asked for silence). */
434
+ function belEligible(state: AgentStatus, sound: NotificationSound): boolean {
435
+ return state === "blocked" && sound !== "none";
436
+ }
437
+
438
+ /**
439
+ * PURE — the tty writes for one OS-level ping: for every attached client that
440
+ * is NOT already looking at the transition (the SAME suppression rule as toasts,
441
+ * {@link suppressToastFor}) and whose tty we know, the terminal-notification
442
+ * escape (when `prefs.terminal`) plus a BEL on blocked (when the sound pref
443
+ * allows). Clients contribute nothing when both channels are off for them.
444
+ */
445
+ export function decideTtyWrites(
446
+ n: SystemNotification,
447
+ clients: AttachedClient[],
448
+ prefs: Pick<NotificationPrefs, "terminal" | "sound">,
449
+ ): TtyWrite[] {
450
+ const asEvent: NotifyEvent = {
451
+ session: n.session,
452
+ from: null,
453
+ to: n.state,
454
+ paneId: n.paneId,
455
+ windowIndex: n.windowIndex,
456
+ };
457
+ const bel = belEligible(n.state, prefs.sound) ? "\x07" : "";
458
+ const out: TtyWrite[] = [];
459
+ for (const c of clients) {
460
+ if (!c.tty) continue;
461
+ if (suppressToastFor(c, asEvent)) continue;
462
+ const escape = prefs.terminal
463
+ ? terminalNotifyEscape(c.termname, n.message, n.state === "blocked")
464
+ : "";
465
+ const data = escape + bel;
466
+ if (data) out.push({ tty: c.tty, data });
467
+ }
468
+ return out;
469
+ }
470
+
140
471
  /**
141
- * io — fire a macOS notification via `osascript`. macOS-only (guarded), and
142
- * quotes/backslashes are escaped for the AppleScript string literal. Best-effort.
472
+ * io — write escape/BEL bytes straight to a client's tty device. This is the
473
+ * delivery mechanism (MEASURED against a recorded client tty): the write goes
474
+ * to the pty the tmux client sits on, so the bytes reach the outer terminal
475
+ * verbatim without our tmux server ever seeing them — no `allow-passthrough`
476
+ * needed on this server, no `run-shell`/`display-message` indirection.
477
+ * Non-blocking open so a flow-stopped tty (^S) can never stall the updater
478
+ * tick; any failure just drops that client's ping.
143
479
  */
144
- export function sendSystemNotification(message: string): void {
145
- if (process.platform !== "darwin") return;
146
- const escaped = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
480
+ export function writeClientTty(write: TtyWrite): void {
481
+ let fd: number | null = null;
147
482
  try {
148
- execFileSync("osascript", ["-e", `display notification "${escaped}" with title "tmux-ide"`], {
149
- stdio: "ignore",
150
- });
483
+ fd = openSync(write.tty, fsConstants.O_WRONLY | fsConstants.O_NOCTTY | fsConstants.O_NONBLOCK);
484
+ writeSync(fd, write.data);
485
+ } catch {
486
+ // client gone / tty unwritable — never fatal
487
+ } finally {
488
+ if (fd !== null) {
489
+ try {
490
+ closeSync(fd);
491
+ } catch {
492
+ // best-effort
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ /** io — dispatch a batch of tty writes, each best-effort. */
499
+ export function writeTtys(writes: TtyWrite[]): void {
500
+ for (const w of writes) writeClientTty(w);
501
+ }
502
+
503
+ /** The calm default ping sounds — a short macOS system chime and the standard
504
+ * freedesktop completion sound (skipped silently when absent). */
505
+ export const DARWIN_SOUND_FILE = "/System/Library/Sounds/Tink.aiff";
506
+ export const LINUX_SOUND_FILE = "/usr/share/sounds/freedesktop/stereo/complete.oga";
507
+
508
+ /** PURE — the sound-player argv for a platform, or null when it has none. */
509
+ export function soundArgv(platform: NodeJS.Platform): string[] | null {
510
+ if (platform === "darwin") return ["afplay", DARWIN_SOUND_FILE];
511
+ if (platform === "linux") return ["paplay", LINUX_SOUND_FILE];
512
+ return null;
513
+ }
514
+
515
+ /**
516
+ * io — play the platform ping sound, fire-and-forget (a sync wait would stall
517
+ * the updater tick for the clip's duration). Missing sound file or player →
518
+ * silent skip.
519
+ */
520
+ export function playPingSound(platform: NodeJS.Platform = process.platform): void {
521
+ const argv = soundArgv(platform);
522
+ if (!argv || !existsSync(argv[1]!)) return;
523
+ try {
524
+ const child = spawn(argv[0]!, argv.slice(1), { stdio: "ignore", detached: true });
525
+ child.on("error", () => {});
526
+ child.unref();
527
+ } catch {
528
+ // no player — never fatal
529
+ }
530
+ }
531
+
532
+ /** io — whether `terminal-notifier` is on PATH (enables click-through banners). */
533
+ export function hasTerminalNotifier(): boolean {
534
+ return hasBinary("terminal-notifier");
535
+ }
536
+
537
+ /** io — resolve a binary to the absolute path a GUI-launched helper can retain. */
538
+ function binaryPath(name: string): string | null {
539
+ try {
540
+ const path = execFileSync("which", [name], { encoding: "utf8" }).trim();
541
+ return path.startsWith("/") ? path : null;
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ /** io — whether a binary resolves on PATH. */
548
+ function hasBinary(name: string): boolean {
549
+ return binaryPath(name) !== null;
550
+ }
551
+
552
+ /** PURE — single-quote a string for safe interpolation into a `/bin/sh -c` command. */
553
+ function shellSingleQuote(value: string): string {
554
+ return `'${value.replace(/'/g, `'\\''`)}'`;
555
+ }
556
+
557
+ /** The session option a banner click stamps on the hosted app: "open THIS
558
+ * session when you next look". The app consumes it on its fleet poll. */
559
+ export const APP_JUMP_OPTION = "@tmux_ide_app_jump";
560
+
561
+ /**
562
+ * PURE — the shell command a banner click runs (M25.1 click-to-jump v2).
563
+ * When the hosted app exists, the click routes THROUGH the cockpit: stamp
564
+ * {@link APP_JUMP_OPTION} on the host session (the running app consumes it and
565
+ * opens that workspace — so even a DETACHED cockpit shows the right session on
566
+ * the next attach), then switch the user's most-recent client to the cockpit.
567
+ * Without a hosted app, fall back to switching the client straight to the
568
+ * session. (`switch-client` without `-c` targets the last-active client — the
569
+ * best we can do without knowing which terminal the click came from; with no
570
+ * client attached at all it fails silently, and the jump stamp still lands.)
571
+ *
572
+ * NOTE the target spellings: has-session/switch-client take the `=` exact-match
573
+ * prefix, but set-option REJECTS it ("no such session", measured on 3.7b — see
574
+ * {@link ../mirror/hosted.ts hostSetupArgvs}), so the stamp uses the plain name.
575
+ */
576
+ export function notifierExecuteCommand(session: string): string {
577
+ const target = shellSingleQuote(session);
578
+ const host = shellSingleQuote(`=${APP_HOST_SESSION}`);
579
+ return (
580
+ `if tmux has-session -t ${host} 2>/dev/null; then ` +
581
+ `tmux set-option -t ${shellSingleQuote(APP_HOST_SESSION)} ${APP_JUMP_OPTION} ${target}; ` +
582
+ `tmux switch-client -t ${host}; ` +
583
+ `else tmux switch-client -t ${target}; fi`
584
+ );
585
+ }
586
+
587
+ /** The native sender injected into the npm/Homebrew payload at release. */
588
+ export const NATIVE_MACOS_NOTIFIER_RELATIVE_PATH =
589
+ "packages/daemon/dist/native/TmuxIdeNotifier.app";
590
+ const NATIVE_MACOS_NOTIFIER_EXECUTABLE = "Contents/MacOS/tmux-ide-notifier";
591
+
592
+ export interface NativeMacosNotifierPathIo {
593
+ /** The real node CLI path forwarded to compiled TUI surfaces. */
594
+ cliPath?: string | null;
595
+ /** Injectable module path keeps the ancestor walk deterministic in tests. */
596
+ modulePath?: string;
597
+ exists?: (path: string) => boolean;
598
+ }
599
+
600
+ /**
601
+ * io — find the packaged native notifier from either runtime shape:
602
+ *
603
+ * - the bundled CLI lives at `bin/cli.js`, one level below the package root;
604
+ * - checkout source lives below `packages/daemon/src/…`;
605
+ * - the standalone Bun TUI has a virtual module URL, but the CLI forwards its
606
+ * real path through `TMUX_IDE_CLI`.
607
+ *
608
+ * Walking ancestors handles all three without baking an install prefix into
609
+ * the binary. A missing helper is a silent null so older installs can fall back.
610
+ */
611
+ export function resolveNativeMacosNotifierPath(io: NativeMacosNotifierPathIo = {}): string | null {
612
+ const exists = io.exists ?? existsSync;
613
+ const cliPath = io.cliPath === undefined ? process.env.TMUX_IDE_CLI : io.cliPath;
614
+ const modulePath = io.modulePath ?? fileURLToPath(import.meta.url);
615
+ const anchors = [cliPath, modulePath]
616
+ .filter((path): path is string => Boolean(path))
617
+ .map((path) => dirname(resolve(path)));
618
+ const visited = new Set<string>();
619
+
620
+ for (const anchor of anchors) {
621
+ let directory = anchor;
622
+ while (!visited.has(directory)) {
623
+ visited.add(directory);
624
+ const candidate = resolve(directory, NATIVE_MACOS_NOTIFIER_RELATIVE_PATH);
625
+ if (exists(resolve(candidate, NATIVE_MACOS_NOTIFIER_EXECUTABLE))) return candidate;
626
+ const parent = dirname(directory);
627
+ if (parent === directory) break;
628
+ directory = parent;
629
+ }
630
+ }
631
+ return null;
632
+ }
633
+
634
+ /** PURE — recover tmux's exact socket from `$TMUX` (`path,server-pid,pane-id`). */
635
+ export function parseTmuxSocketPath(value: string | undefined): string | null {
636
+ if (!value) return null;
637
+ const normalized = value.trim();
638
+ // Match the two numeric TMUX metadata fields from the RIGHT: socket paths
639
+ // themselves are allowed to contain commas.
640
+ const path = (/^(.*),\d+,\d+$/.exec(normalized)?.[1] ?? normalized).trim();
641
+ return path.startsWith("/") ? path : null;
642
+ }
643
+
644
+ /**
645
+ * PURE — launch the branded LSUIElement app through LaunchServices. The helper
646
+ * stores structured tmux coordinates in the system notification payload so a
647
+ * later click can jump without inheriting a shell, PATH, or TMUX environment.
648
+ */
649
+ export function nativeMacosNotifierArgs(
650
+ appPath: string,
651
+ n: SystemNotification,
652
+ tmuxPath: string | null,
653
+ socketPath: string | null,
654
+ ): string[] {
655
+ const args = [
656
+ "-g",
657
+ "-n",
658
+ appPath,
659
+ "--args",
660
+ "--title",
661
+ "tmux-ide",
662
+ "--message",
663
+ n.message,
664
+ "--session",
665
+ n.session,
666
+ "--host-session",
667
+ APP_HOST_SESSION,
668
+ "--jump-option",
669
+ APP_JUMP_OPTION,
670
+ ];
671
+ if (tmuxPath) args.push("--tmux-path", tmuxPath);
672
+ if (socketPath) args.push("--socket-path", socketPath);
673
+ return args;
674
+ }
675
+
676
+ /**
677
+ * PURE — the legacy `terminal-notifier` argv retained for older installations
678
+ * whose package predates the native helper.
679
+ */
680
+ export function terminalNotifierArgs(n: SystemNotification): string[] {
681
+ return [
682
+ "-title",
683
+ "tmux-ide",
684
+ "-message",
685
+ n.message,
686
+ "-execute",
687
+ notifierExecuteCommand(n.session),
688
+ ];
689
+ }
690
+
691
+ /**
692
+ * PURE — the `notify-send` argv for a Linux desktop banner: app-named, blocked
693
+ * pings marked critical so they persist until dismissed (that's the "an agent
694
+ * is stuck waiting on you" contract).
695
+ */
696
+ export function notifySendArgs(n: SystemNotification): string[] {
697
+ const args = ["--app-name=tmux-ide"];
698
+ if (n.state === "blocked") args.push("--urgency=critical");
699
+ args.push("tmux-ide", n.message);
700
+ return args;
701
+ }
702
+
703
+ /** The io {@link sendSystemNotification} needs — injectable so the per-platform
704
+ * routing is unit-tested without firing real banners. */
705
+ export interface SystemNotifyIo {
706
+ platform?: NodeJS.Platform;
707
+ env?: Record<string, string | undefined>;
708
+ exec?: (cmd: string, args: string[]) => void;
709
+ hasBinary?: (name: string) => boolean;
710
+ /** undefined auto-resolves the shipped bundle; null tests/old installs skip it. */
711
+ nativeNotifierPath?: string | null;
712
+ /** Absolute executable retained for a click relaunch with no terminal PATH. */
713
+ tmuxPath?: string | null;
714
+ }
715
+
716
+ /**
717
+ * io — fire a system notification, best-effort. macOS: the bundled native app
718
+ * first (branded, appearance-aware, click-through), then `terminal-notifier`
719
+ * for an older install, then `osascript` as the last unbranded fallback. Linux:
720
+ * under a
721
+ * desktop session (DISPLAY / WAYLAND_DISPLAY) with `notify-send` on PATH, the
722
+ * same title/body (+ critical urgency for blocked — {@link notifySendArgs});
723
+ * anything missing → silent skip. Other platforms: no-op.
724
+ */
725
+ export function sendSystemNotification(n: SystemNotification, io: SystemNotifyIo = {}): void {
726
+ const platform = io.platform ?? process.platform;
727
+ const exec =
728
+ io.exec ?? ((cmd: string, args: string[]) => execFileSync(cmd, args, { stdio: "ignore" }));
729
+ const has = io.hasBinary ?? hasBinary;
730
+ try {
731
+ if (platform === "darwin") {
732
+ const appPath =
733
+ io.nativeNotifierPath === undefined
734
+ ? resolveNativeMacosNotifierPath()
735
+ : io.nativeNotifierPath;
736
+ if (appPath) {
737
+ const env = io.env ?? process.env;
738
+ const tmuxPath = io.tmuxPath === undefined ? binaryPath("tmux") : io.tmuxPath;
739
+ try {
740
+ exec(
741
+ "/usr/bin/open",
742
+ nativeMacosNotifierArgs(appPath, n, tmuxPath, parseTmuxSocketPath(env.TMUX)),
743
+ );
744
+ return;
745
+ } catch {
746
+ // Corrupt/blocked helper: retain the two compatibility fallbacks.
747
+ }
748
+ }
749
+ if (has("terminal-notifier")) {
750
+ exec("terminal-notifier", terminalNotifierArgs(n));
751
+ return;
752
+ }
753
+ const escaped = n.message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
754
+ exec("osascript", ["-e", `display notification "${escaped}" with title "tmux-ide"`]);
755
+ return;
756
+ }
757
+ if (platform === "linux") {
758
+ const env = io.env ?? process.env;
759
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return; // headless — nowhere to banner
760
+ if (!has("notify-send")) return;
761
+ exec("notify-send", notifySendArgs(n));
762
+ }
151
763
  } catch {
152
- // osascript missing / notification blocked — never fatal
764
+ // notifier missing or notification blocked — never fatal
153
765
  }
154
766
  }
155
767
 
156
- /** User notification preferences (minimal, pre-M14). */
157
- export interface NotificationPrefs {
158
- toast: boolean;
159
- macos: boolean;
768
+ /** A "quiet hours" window — banners are suppressed while the wall clock is inside it. */
769
+ /** User notification preferences. */
770
+
771
+ /** Defaults: enabled, tmux toasts + terminal escapes on, system banners off,
772
+ * sound on blocked, a 2s re-verify delay, both states pinged, no quiet window. */
773
+ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
774
+ enabled: true,
775
+ toast: true,
776
+ macos: false,
777
+ terminal: true,
778
+ delaySeconds: 2,
779
+ sound: "blocked",
780
+ onBlocked: true,
781
+ onDone: true,
782
+ quietHours: null,
783
+ };
784
+
785
+ /** A plain object, or `{}` for anything that isn't one (arrays included). */
786
+ function asObject(value: unknown): Record<string, unknown> {
787
+ return value && typeof value === "object" && !Array.isArray(value)
788
+ ? (value as Record<string, unknown>)
789
+ : {};
160
790
  }
161
791
 
162
- /** Defaults: tmux toasts on, macOS notifications off. */
163
- export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { toast: true, macos: false };
792
+ /** A boolean, else the default. */
793
+ function pickBool(value: unknown, fallback: boolean): boolean {
794
+ return typeof value === "boolean" ? value : fallback;
795
+ }
164
796
 
165
797
  /**
166
- * PURE — read `{ notifications: { toast, macos } }` out of a parsed config,
167
- * falling back to {@link DEFAULT_NOTIFICATION_PREFS} for anything missing or of
168
- * the wrong type. Delegates to the shared {@link parseAppConfig} so notification
169
- * parsing can't drift from the rest of the config.
798
+ * PURE — is `now` (local time) inside the quiet window? Handles a window that
799
+ * WRAPS midnight (`22:00`–`08:00`). A null window, or a malformed / zero-width
800
+ * (`start === end`) one, is never quiet.
170
801
  */
171
- export function notificationPrefs(parsedConfig: unknown): NotificationPrefs {
172
- return parseAppConfig(parsedConfig).notifications;
802
+ export function inQuietHours(now: Date, quiet: QuietHours | null): boolean {
803
+ if (!quiet) return false;
804
+ const start = parseHHMM(quiet.start);
805
+ const end = parseHHMM(quiet.end);
806
+ if (start === null || end === null || start === end) return false;
807
+ const nowMin = now.getHours() * 60 + now.getMinutes();
808
+ return start < end ? nowMin >= start && nowMin < end : nowMin >= start || nowMin < end;
173
809
  }
174
810
 
175
- /** PURE — the `TMUX_IDE_NOTIFY=0` kill-switch: disables both channels. */
811
+ /** PURE — parse a `{ start, end }` quiet-hours block, or null when absent / malformed. */
812
+ function parseQuietHours(value: unknown): QuietHours | null {
813
+ const o = asObject(value);
814
+ const start = typeof o.start === "string" ? o.start : null;
815
+ const end = typeof o.end === "string" ? o.end : null;
816
+ if (start === null || end === null) return null;
817
+ if (parseHHMM(start) === null || parseHHMM(end) === null) return null;
818
+ return { start, end };
819
+ }
820
+
821
+ /**
822
+ * PURE — resolve full {@link NotificationPrefs} from a parsed config object.
823
+ *
824
+ * `toast`/`macos` delegate to the shared {@link parseAppConfig} (so those two
825
+ * can't drift from the rest of the config); the polish-era fields
826
+ * (`enabled`/`onBlocked`/`onDone`/`quietHours`) are read straight off the raw
827
+ * `notifications` block here — `parseAppConfig` doesn't model them, so reading
828
+ * them locally keeps this change inside the chrome layer. Anything missing or
829
+ * mistyped falls back to {@link DEFAULT_NOTIFICATION_PREFS}.
830
+ */
831
+ export function parseNotificationPrefs(rawConfig: unknown): NotificationPrefs {
832
+ const base = parseAppConfig(rawConfig).notifications;
833
+ const n = asObject(asObject(rawConfig).notifications);
834
+ return {
835
+ enabled: pickBool(n.enabled, DEFAULT_NOTIFICATION_PREFS.enabled),
836
+ toast: base.toast,
837
+ macos: base.macos,
838
+ terminal: base.terminal,
839
+ delaySeconds: base.delaySeconds,
840
+ sound: base.sound,
841
+ onBlocked: pickBool(n.onBlocked, DEFAULT_NOTIFICATION_PREFS.onBlocked),
842
+ onDone: pickBool(n.onDone, DEFAULT_NOTIFICATION_PREFS.onDone),
843
+ quietHours: parseQuietHours(n.quietHours),
844
+ };
845
+ }
846
+
847
+ /** PURE — the `TMUX_IDE_NOTIFY=0` kill-switch: disables everything. */
176
848
  export function applyKillSwitch(
177
849
  prefs: NotificationPrefs,
178
850
  envValue: string | undefined,
179
851
  ): NotificationPrefs {
180
- return envValue === "0" ? { toast: false, macos: false } : prefs;
852
+ return envValue === "0"
853
+ ? { ...prefs, enabled: false, toast: false, macos: false, terminal: false, sound: "none" }
854
+ : prefs;
181
855
  }
182
856
 
183
857
  /** Absolute path to the shared config (honors `TMUX_IDE_CONFIG`). */
@@ -185,11 +859,22 @@ export function notifyConfigPath(): string {
185
859
  return appConfigPath();
186
860
  }
187
861
 
862
+ /** io — read + JSON-parse the raw config file; undefined when missing / malformed. */
863
+ function readRawConfig(): unknown {
864
+ const path = appConfigPath();
865
+ if (!existsSync(path)) return undefined;
866
+ try {
867
+ return JSON.parse(readFileSync(path, "utf-8"));
868
+ } catch {
869
+ return undefined;
870
+ }
871
+ }
872
+
188
873
  /**
189
874
  * io — resolve effective prefs from the shared app config (defaults for a
190
875
  * missing/invalid file), then apply the `TMUX_IDE_NOTIFY=0` kill-switch. Reads
191
- * fresh so the env kill-switch is honored each call.
876
+ * fresh so the env kill-switch and config edits are honored each call.
192
877
  */
193
878
  export function readNotificationPrefs(): NotificationPrefs {
194
- return applyKillSwitch(loadAppConfig().notifications, process.env.TMUX_IDE_NOTIFY);
879
+ return applyKillSwitch(parseNotificationPrefs(readRawConfig()), process.env.TMUX_IDE_NOTIFY);
195
880
  }