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