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.
- package/README.md +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +103 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- 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
|
-
*
|
|
14
|
-
* {@link
|
|
15
|
-
* {@link
|
|
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 {
|
|
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 —
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
119
|
-
*
|
|
120
|
-
* -
|
|
121
|
-
*
|
|
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 =
|
|
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
|
|
232
|
+
if (suppressToastFor(c, ev)) continue;
|
|
148
233
|
toasts.push({ client: c.client, message });
|
|
149
234
|
}
|
|
150
|
-
system.push({
|
|
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}
|
|
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
|
|
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
|
|
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([
|
|
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", [
|
|
195
|
-
return
|
|
540
|
+
const path = execFileSync("which", [name], { encoding: "utf8" }).trim();
|
|
541
|
+
return path.startsWith("/") ? path : null;
|
|
196
542
|
} catch {
|
|
197
|
-
return
|
|
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
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
-
|
|
687
|
+
notifierExecuteCommand(n.session),
|
|
221
688
|
];
|
|
222
689
|
}
|
|
223
690
|
|
|
224
691
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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 (
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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"
|
|
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`). */
|