tmux-ide 2.7.0 → 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 (101) hide show
  1. package/README.md +22 -5
  2. package/bin/cli.js +3532 -1090
  3. package/bin/cli.ts +368 -71
  4. package/package.json +2 -1
  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/control/client.d.ts +23 -0
  9. package/packages/daemon/dist/control/client.js +105 -0
  10. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  11. package/packages/daemon/dist/control/dispatch.js +83 -0
  12. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  13. package/packages/daemon/dist/control/fanout.js +37 -0
  14. package/packages/daemon/dist/control/frames.d.ts +23 -0
  15. package/packages/daemon/dist/control/frames.js +37 -0
  16. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  17. package/packages/daemon/dist/control/lifecycle.js +114 -0
  18. package/packages/daemon/dist/control/server.d.ts +16 -0
  19. package/packages/daemon/dist/control/server.js +214 -0
  20. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  21. package/packages/daemon/dist/control/verbs.js +91 -0
  22. package/packages/daemon/dist/doctor.d.ts +18 -0
  23. package/packages/daemon/dist/doctor.js +105 -15
  24. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  25. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  26. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  27. package/packages/daemon/dist/lib/app-config.js +104 -5
  28. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  29. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  30. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  31. package/packages/daemon/dist/lib/state-home.js +12 -0
  32. package/packages/daemon/dist/lib/update-check.js +5 -0
  33. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  34. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  35. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  36. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  39. package/packages/daemon/dist/restore.d.ts +35 -8
  40. package/packages/daemon/dist/restore.js +52 -15
  41. package/packages/daemon/dist/send.d.ts +33 -1
  42. package/packages/daemon/dist/send.js +32 -19
  43. package/packages/daemon/src/control/client.ts +128 -0
  44. package/packages/daemon/src/control/dispatch.ts +107 -0
  45. package/packages/daemon/src/control/fanout.ts +44 -0
  46. package/packages/daemon/src/control/frames.ts +40 -0
  47. package/packages/daemon/src/control/lifecycle.ts +151 -0
  48. package/packages/daemon/src/control/server.ts +237 -0
  49. package/packages/daemon/src/control/verbs.ts +118 -0
  50. package/packages/daemon/src/doctor.ts +113 -28
  51. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  52. package/packages/daemon/src/lib/app-config.ts +103 -5
  53. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  54. package/packages/daemon/src/lib/state-home.ts +13 -0
  55. package/packages/daemon/src/lib/update-check.ts +5 -0
  56. package/packages/daemon/src/restore.ts +53 -15
  57. package/packages/daemon/src/send.ts +55 -21
  58. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  59. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  60. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  61. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  62. package/packages/daemon/src/tui/chrome/notify.ts +582 -84
  63. package/packages/daemon/src/tui/chrome/updater.ts +268 -62
  64. package/packages/daemon/src/tui/detect/classify.ts +34 -0
  65. package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
  66. package/packages/daemon/src/tui/detect/manifest.ts +24 -3
  67. package/packages/daemon/src/tui/detect/manifests.ts +240 -6
  68. package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
  69. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  70. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  71. package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
  72. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  73. package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
  74. package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
  75. package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
  76. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  77. package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
  78. package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
  79. package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
  80. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  81. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  82. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  83. package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
  84. package/packages/daemon/src/tui/mirror/palette.ts +299 -9
  85. package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
  86. package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
  87. package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
  88. package/packages/daemon/src/tui/mirror/selection.ts +122 -8
  89. package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
  90. package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
  91. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  92. package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
  93. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  94. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  95. package/packages/daemon/src/tui/team/sessions.ts +85 -7
  96. package/packages/daemon/src/tui/team/wait.ts +144 -0
  97. package/scripts/build-macos-notifier.mjs +160 -0
  98. package/scripts/postinstall.js +8 -1
  99. package/scripts/prepublish-check.mjs +37 -1
  100. package/scripts/publish-tap.sh +55 -0
  101. package/skill/SKILL.md +88 -2
@@ -7,27 +7,54 @@
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
+ * 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
+ *
10
16
  * Split as usual: {@link decideNotifications} + {@link notifyMessage} +
11
17
  * {@link enabledStates} + {@link inQuietHours} + {@link parseNotificationPrefs}
12
18
  * + {@link applyKillSwitch} + {@link parseClients} + {@link terminalNotifierArgs}
13
- * are PURE (unit-tested without a live tmux / filesystem); {@link sendToasts},
14
- * {@link sendSystemNotification}, {@link hasTerminalNotifier},
15
- * {@link listAttachedClients} and {@link readNotificationPrefs} are the thin io
19
+ * + {@link terminalNotifyEscape} + {@link decideTtyWrites} + {@link notifySendArgs}
20
+ * + {@link soundEligible} + {@link soundArgv} are PURE (unit-tested without a
21
+ * live tmux / filesystem); {@link sendToasts}, {@link sendSystemNotification},
22
+ * {@link resolveNativeMacosNotifierPath}, {@link listAttachedClients}, {@link writeTtys},
23
+ * {@link playPingSound} and {@link readNotificationPrefs} are the thin io
16
24
  * wrappers. Every io path is best-effort — a failed ping must never break the
17
25
  * updater loop.
18
26
  */
19
- import { execFileSync } from "node:child_process";
20
- import { existsSync, readFileSync } from "node:fs";
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";
21
38
  import { runTmux } from "@tmux-ide/tmux-bridge";
22
- import { appConfigPath, 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";
23
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 };
24
49
 
25
50
  /**
26
- * A fleet transition this tick — the shape {@link ./events.ts diffFleet} emits,
27
- * ENRICHED by the updater with the pane's resolved `agent` id and human
28
- * `location` (`session:window.pane`) so the ping can name who needs the user.
29
- * Both are optional: a caller that can't resolve them falls back to a generic
30
- * `agent` label and the bare session name (see {@link notifyMessage}).
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}).
31
58
  */
32
59
  export interface NotifyEvent {
33
60
  session: string;
@@ -37,12 +64,29 @@ export interface NotifyEvent {
37
64
  agent?: string | null;
38
65
  /** Human location `session:window.pane` (e.g. `myproj:1.2`); falls back to `session`. */
39
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;
40
74
  }
41
75
 
42
- /** 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. */
43
82
  export interface AttachedClient {
44
83
  client: string;
45
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;
46
90
  }
47
91
 
48
92
  /** A toast destined for one client's status line. */
@@ -52,12 +96,22 @@ export interface ToastTarget {
52
96
  }
53
97
 
54
98
  /**
55
- * A macOS-notification payload. `session` rides along so the click-through path
56
- * ({@link terminalNotifierArgs}) can focus the right session on click.
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.
57
105
  */
58
106
  export interface SystemNotification {
59
107
  message: string;
60
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;
61
115
  }
62
116
 
63
117
  /** The verdict of {@link decideNotifications}. */
@@ -109,16 +163,44 @@ export function enabledStates(prefs: NotificationPrefs): ReadonlySet<AgentStatus
109
163
  return states;
110
164
  }
111
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
+ }
172
+
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;
186
+ }
187
+
112
188
  /**
113
189
  * PURE — decide who to ping from this tick's transitions.
114
190
  *
115
191
  * Rules:
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;
116
195
  * - only states in `states` qualify (default {@link NOTIFY_STATES}; the caller
117
196
  * narrows it via {@link enabledStates} to honor `onBlocked`/`onDone`);
118
- * - DEBOUNCE: skip a session+state that fired within {@link NOTIFY_DEBOUNCE_MS}
119
- * — this is the flap guard (see {@link NOTIFY_DEBOUNCE_MS});
120
- * - SUPPRESS the toast for any client already viewing that session (they can
121
- * see the bar flip themselves) — other clients still get toasted;
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;
122
204
  * - a `system` entry is produced per qualifying, non-debounced event regardless
123
205
  * of clients (so the macOS path fires even with nothing attached — the
124
206
  * caller gates it on prefs / quiet hours).
@@ -132,40 +214,65 @@ export function decideNotifications(
132
214
  lastNotified: Map<string, number>,
133
215
  nowMs: number,
134
216
  states: ReadonlySet<AgentStatus> = NOTIFY_STATES,
217
+ appFocus: AppFocus | null = null,
135
218
  ): NotifyDecision {
136
219
  const nextLastNotified = new Map(lastNotified);
137
220
  const toasts: ToastTarget[] = [];
138
221
  const system: SystemNotification[] = [];
139
222
  for (const ev of events) {
223
+ if (ev.from === null) continue; // first sight — not a transition worth pinging
140
224
  if (!states.has(ev.to)) continue;
141
- const key = `${ev.session}:${ev.to}`;
225
+ const key = notifyDebounceKey(ev);
142
226
  const last = nextLastNotified.get(key);
143
227
  if (last !== undefined && nowMs - last < NOTIFY_DEBOUNCE_MS) continue;
228
+ if (appFocus?.attached && ev.paneId && appFocus.panes.includes(ev.paneId)) continue;
144
229
  nextLastNotified.set(key, nowMs);
145
230
  const message = notifyMessage(ev);
146
231
  for (const c of clients) {
147
- if (c.session === ev.session) continue; // they're already looking at it
232
+ if (suppressToastFor(c, ev)) continue;
148
233
  toasts.push({ client: c.client, message });
149
234
  }
150
- system.push({ message, session: ev.session });
235
+ system.push({
236
+ message,
237
+ session: ev.session,
238
+ state: ev.to,
239
+ paneId: ev.paneId ?? null,
240
+ windowIndex: ev.windowIndex ?? null,
241
+ });
151
242
  }
152
243
  return { toasts, system, nextLastNotified };
153
244
  }
154
245
 
155
- /** 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). */
156
250
  export function parseClients(lines: string[]): AttachedClient[] {
157
251
  const out: AttachedClient[] = [];
158
252
  for (const line of lines) {
159
- const [client = "", session = ""] = line.split("\t");
160
- 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
+ });
161
263
  }
162
264
  return out;
163
265
  }
164
266
 
165
- /** 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. */
166
269
  export function listAttachedClients(): AttachedClient[] {
167
270
  try {
168
- 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
+ ])
169
276
  .toString()
170
277
  .trim();
171
278
  return raw ? parseClients(raw.split("\n")) : [];
@@ -174,6 +281,78 @@ export function listAttachedClients(): AttachedClient[] {
174
281
  }
175
282
  }
176
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
+
177
356
  /**
178
357
  * io — flash each toast on its client's status line (`-d 3000` = 3s; needs tmux
179
358
  * ≥3.2). Best-effort per toast: a gone client / failed call just drops that one.
@@ -188,27 +367,315 @@ export function sendToasts(toasts: ToastTarget[]): void {
188
367
  }
189
368
  }
190
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
+
471
+ /**
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.
479
+ */
480
+ export function writeClientTty(write: TtyWrite): void {
481
+ let fd: number | null = null;
482
+ try {
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
+
191
532
  /** io — whether `terminal-notifier` is on PATH (enables click-through banners). */
192
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 {
193
539
  try {
194
- execFileSync("which", ["terminal-notifier"], { stdio: "ignore" });
195
- return true;
540
+ const path = execFileSync("which", [name], { encoding: "utf8" }).trim();
541
+ return path.startsWith("/") ? path : null;
196
542
  } catch {
197
- return false;
543
+ return null;
198
544
  }
199
545
  }
200
546
 
547
+ /** io — whether a binary resolves on PATH. */
548
+ function hasBinary(name: string): boolean {
549
+ return binaryPath(name) !== null;
550
+ }
551
+
201
552
  /** PURE — single-quote a string for safe interpolation into a `/bin/sh -c` command. */
202
553
  function shellSingleQuote(value: string): string {
203
554
  return `'${value.replace(/'/g, `'\\''`)}'`;
204
555
  }
205
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
+
206
561
  /**
207
- * PURE — the `terminal-notifier` argv for a click-through banner: clicking it
208
- * runs `tmux switch-client -t <session>`, jumping the user's most-recent client
209
- * straight to the session that needs them. (`switch-client` without `-c` targets
210
- * the last-active client — the best we can do without knowing which terminal the
211
- * click came from.)
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.
212
679
  */
213
680
  export function terminalNotifierArgs(n: SystemNotification): string[] {
214
681
  return [
@@ -217,62 +684,99 @@ export function terminalNotifierArgs(n: SystemNotification): string[] {
217
684
  "-message",
218
685
  n.message,
219
686
  "-execute",
220
- `tmux switch-client -t ${shellSingleQuote(n.session)}`,
687
+ notifierExecuteCommand(n.session),
221
688
  ];
222
689
  }
223
690
 
224
691
  /**
225
- * io — fire a macOS notification. macOS-only (guarded), best-effort. When
226
- * `terminal-notifier` is available we use it for a CLICK-THROUGH banner
227
- * ({@link terminalNotifierArgs}) that focuses the session on click; otherwise we
228
- * fall back to `osascript`, whose `display notification` has NO click action —
229
- * so on a stock machine the banner informs but can't be clicked to jump.
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.
230
724
  */
231
- export function sendSystemNotification(n: SystemNotification): void {
232
- if (process.platform !== "darwin") return;
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;
233
730
  try {
234
- if (hasTerminalNotifier()) {
235
- execFileSync("terminal-notifier", terminalNotifierArgs(n), { stdio: "ignore" });
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"`]);
236
755
  return;
237
756
  }
238
- const escaped = n.message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
239
- execFileSync("osascript", ["-e", `display notification "${escaped}" with title "tmux-ide"`], {
240
- stdio: "ignore",
241
- });
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
+ }
242
763
  } catch {
243
- // osascript / terminal-notifier missing or notification blocked — never fatal
764
+ // notifier missing or notification blocked — never fatal
244
765
  }
245
766
  }
246
767
 
247
768
  /** A "quiet hours" window — banners are suppressed while the wall clock is inside it. */
248
- export interface QuietHours {
249
- /** Local `HH:MM` the window opens (e.g. `22:00`). */
250
- start: string;
251
- /** Local `HH:MM` the window closes (e.g. `08:00`). */
252
- end: string;
253
- }
254
-
255
769
  /** User notification preferences. */
256
- export interface NotificationPrefs {
257
- /** Master switch — false silences every channel. */
258
- enabled: boolean;
259
- /** In-terminal status-line toasts. */
260
- toast: boolean;
261
- /** macOS system banners. */
262
- macos: boolean;
263
- /** Ping when an agent goes `blocked`. */
264
- onBlocked: boolean;
265
- /** Ping when an agent goes `done`. */
266
- onDone: boolean;
267
- /** Optional local-time window that suppresses macOS banners (events still record). */
268
- quietHours: QuietHours | null;
269
- }
270
-
271
- /** Defaults: enabled, tmux toasts on, macOS banners off, both states pinged, no quiet window. */
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. */
272
773
  export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
273
774
  enabled: true,
274
775
  toast: true,
275
776
  macos: false,
777
+ terminal: true,
778
+ delaySeconds: 2,
779
+ sound: "blocked",
276
780
  onBlocked: true,
277
781
  onDone: true,
278
782
  quietHours: null,
@@ -290,17 +794,6 @@ function pickBool(value: unknown, fallback: boolean): boolean {
290
794
  return typeof value === "boolean" ? value : fallback;
291
795
  }
292
796
 
293
- /** PURE — parse `HH:MM` to minutes-since-midnight, or null when malformed / out of range. */
294
- export function parseHHMM(value: unknown): number | null {
295
- if (typeof value !== "string") return null;
296
- const m = /^(\d{2}):(\d{2})$/.exec(value.trim());
297
- if (!m) return null;
298
- const hours = Number(m[1]);
299
- const minutes = Number(m[2]);
300
- if (hours > 23 || minutes > 59) return null;
301
- return hours * 60 + minutes;
302
- }
303
-
304
797
  /**
305
798
  * PURE — is `now` (local time) inside the quiet window? Handles a window that
306
799
  * WRAPS midnight (`22:00`–`08:00`). A null window, or a malformed / zero-width
@@ -342,6 +835,9 @@ export function parseNotificationPrefs(rawConfig: unknown): NotificationPrefs {
342
835
  enabled: pickBool(n.enabled, DEFAULT_NOTIFICATION_PREFS.enabled),
343
836
  toast: base.toast,
344
837
  macos: base.macos,
838
+ terminal: base.terminal,
839
+ delaySeconds: base.delaySeconds,
840
+ sound: base.sound,
345
841
  onBlocked: pickBool(n.onBlocked, DEFAULT_NOTIFICATION_PREFS.onBlocked),
346
842
  onDone: pickBool(n.onDone, DEFAULT_NOTIFICATION_PREFS.onDone),
347
843
  quietHours: parseQuietHours(n.quietHours),
@@ -353,7 +849,9 @@ export function applyKillSwitch(
353
849
  prefs: NotificationPrefs,
354
850
  envValue: string | undefined,
355
851
  ): NotificationPrefs {
356
- return envValue === "0" ? { ...prefs, enabled: false, toast: false, macos: false } : prefs;
852
+ return envValue === "0"
853
+ ? { ...prefs, enabled: false, toast: false, macos: false, terminal: false, sound: "none" }
854
+ : prefs;
357
855
  }
358
856
 
359
857
  /** Absolute path to the shared config (honors `TMUX_IDE_CONFIG`). */