tmux-ide 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -21,6 +21,7 @@
21
21
  * without a live tmux; `adoptedSessionsFrom` is a pure parser.
22
22
  */
23
23
  import { hasSession, isProcessAlive, runTmux } from "@tmux-ide/tmux-bridge";
24
+ import { ADOPTED_OPTION, UPDATER_SESSION, updaterSpawnArgv } from "./front-door.ts";
24
25
  import { DEFAULT_THEME, getAppConfig, type AppTheme } from "../../lib/app-config.ts";
25
26
  import {
26
27
  maybeCheckForUpdate,
@@ -28,21 +29,33 @@ import {
28
29
  type UpdateStatus,
29
30
  } from "../../lib/update-check.ts";
30
31
  import { createStatusTracker, type AgentStatus } from "../detect/classify.ts";
32
+ import { createSessionIdCapturer } from "../detect/session-id.ts";
31
33
  import { listTeamProjects, type TeamProject } from "../team/projects.ts";
32
34
  import type { PaneDetail } from "../team/sessions.ts";
33
35
  import { paneChip } from "./chip.ts";
34
36
  import { appendEvents, diffFleet, type AgentEventInit } from "./events.ts";
35
37
  import {
36
38
  decideNotifications,
39
+ decideTtyWrites,
40
+ enabledStates,
41
+ inQuietHours,
37
42
  listAttachedClients,
43
+ playPingSound,
44
+ readAppFocus,
38
45
  readNotificationPrefs,
39
46
  sendSystemNotification,
40
47
  sendToasts,
48
+ soundEligible,
49
+ writeTtys,
50
+ type AppFocus,
41
51
  type AttachedClient,
42
52
  type NotificationPrefs,
43
53
  type NotifyEvent,
54
+ type SystemNotification,
44
55
  type ToastTarget,
56
+ type TtyWrite,
45
57
  } from "./notify.ts";
58
+ import { loadLastNotified, saveLastNotified } from "./notify-state.ts";
46
59
  import {
47
60
  collectFleetSnapshot,
48
61
  createSnapshotter,
@@ -55,10 +68,15 @@ import { buildStatusline } from "./statusline.ts";
55
68
  export const STATUS_OPTION = "@tmux_ide_status";
56
69
  /** Per-PANE user option holding the pre-rendered agent chip (read by pane-border-format). */
57
70
  export const CHIP_OPTION = "@tmux_ide_chip";
58
- /** Per-session marker option set on adopt so the updater can enumerate adopted sessions. */
59
- export const ADOPTED_OPTION = "@tmux_ide_adopted";
60
- /** The hidden internal session that hosts the updater loop. */
61
- export const UPDATER_SESSION = "_tmux-ide-chrome";
71
+ // The adopted marker, the updater session name, and their argv builders live
72
+ // in the LEAF module front-door.ts (the unified app imports them without
73
+ // pulling this module's fleet-scan graph); re-exported here for the chrome.
74
+ export {
75
+ ADOPTED_OPTION,
76
+ UPDATER_SESSION,
77
+ updaterProbeArgv,
78
+ updaterSpawnArgv,
79
+ } from "./front-door.ts";
62
80
  /** Server option holding the running updater's pid (a lightweight single-owner guard). */
63
81
  export const UPDATER_PID_OPTION = "@tmux_ide_updater_pid";
64
82
  /** Default tick cadence — overridable via `updater.tickMs` in the app config. */
@@ -137,18 +155,55 @@ export interface UpdaterTickDeps {
137
155
  prevState?: Map<string, AgentStatus>;
138
156
  appendEvents?: (events: AgentEventInit[]) => void;
139
157
  /**
140
- * Notification dispatch (optional). When wired alongside `prevState`, the tick
141
- * turns THIS tick's transitions into user pings — toasts on attached clients
142
- * and/or a macOS notification — via {@link decideNotifications}, gated on
143
- * `prefs`. `lastNotified` is the persistent debounce map, mutated in place.
144
- * All deps-injected so the routing is unit-tested without a live tmux.
158
+ * Notification dispatch (optional). When wired alongside `prevPaneState`
159
+ * (M25.1 — notifications are PANE-granular so a second agent blocking in an
160
+ * already-blocked session still pings), the tick turns THIS tick's pane
161
+ * transitions into user pings — toasts on attached clients and/or a macOS
162
+ * notification — via {@link decideNotifications}, gated on `prefs`.
163
+ * `lastNotified` is the persistent debounce map, mutated in place (and
164
+ * persisted via `persistNotified` when a ping fired, so a restart can't
165
+ * re-ping inside the window). All deps-injected so the routing is
166
+ * unit-tested without a live tmux.
145
167
  */
168
+ prevPaneState?: Map<string, AgentStatus>;
146
169
  listClients?: () => AttachedClient[];
147
170
  lastNotified?: Map<string, number>;
148
171
  now?: () => number;
149
172
  prefs?: NotificationPrefs;
150
173
  sendToasts?: (toasts: ToastTarget[]) => void;
151
- sendSystem?: (message: string) => void;
174
+ sendSystem?: (n: SystemNotification) => void;
175
+ /**
176
+ * The OS-level channel io (M25.2), both optional and deps-injected like the
177
+ * rest: `sendTerminal` writes OSC 9/99 + BEL bytes to client ttys
178
+ * ({@link writeTtys}); `playSound` fires the platform ping sound
179
+ * ({@link playPingSound}). Which clients/states qualify is decided purely
180
+ * ({@link decideTtyWrites} / {@link soundEligible}).
181
+ */
182
+ sendTerminal?: (writes: TtyWrite[]) => void;
183
+ playSound?: () => void;
184
+ /**
185
+ * The DELAYED RE-VERIFY queue (M25.2), loop-owned and mutated in place like
186
+ * `prevPaneState`. With `notifications.delaySeconds > 0`, a notify-eligible
187
+ * transition queues its OS-LEVEL channels here instead of firing them; each
188
+ * tick {@link firePendingOsPings} fires the due entries whose pane is STILL
189
+ * in the notified state (this tick's scan is the re-verify — no setTimeout
190
+ * racing the tick), so a flappy blocked→working inside the window fires
191
+ * nothing OS-level. Toasts never queue — the in-app note stays immediate.
192
+ * Omitted (tests, delay 0) → OS channels fire immediately.
193
+ */
194
+ pendingPings?: PendingOsPing[];
195
+ /** The unified app's focus record (see {@link AppFocus}) — pings for panes
196
+ * on the app's screen are suppressed. Wired to {@link readAppFocus}. */
197
+ appFocus?: () => AppFocus | null;
198
+ /** Persist the debounce map after a tick that fired a ping. Wired to
199
+ * {@link saveLastNotified}. */
200
+ persistNotified?: (map: ReadonlyMap<string, number>) => void;
201
+ /**
202
+ * Resolve a pane id to its human `session:window.pane` location for the ping
203
+ * text (optional). Wired to the live tmux {@link paneLocation}; tests inject a
204
+ * pure stub. Only ever called for the pane behind a blocked/done transition.
205
+ */
206
+ locatePane?: (paneId: string) => string;
152
207
  /**
153
208
  * Update-flow surfacing (optional). When wired, the tick calls this cheap,
154
209
  * cache-backed check each tick (throttled internally to 24h; it kicks off a
@@ -159,6 +214,14 @@ export interface UpdaterTickDeps {
159
214
  */
160
215
  maybeCheckForUpdate?: () => UpdateStatus;
161
216
  markUpdateNotified?: (version: string) => boolean;
217
+ /**
218
+ * Session-id capture (optional). When wired, receives this tick's per-pane
219
+ * detail so the capturer ({@link ../detect/session-id.ts}) can stamp
220
+ * `@agent_session_id` on codex/cursor panes that lack one — the key
221
+ * `restore --resume-agents` resumes from. Self-throttled and stamp-once, so
222
+ * a fleet with no unstamped capturable panes costs nothing.
223
+ */
224
+ captureSessionIds?: (panes: PaneDetail[]) => void;
162
225
  }
163
226
 
164
227
  /**
@@ -172,8 +235,11 @@ export function updateSegment(status: UpdateStatus, theme: AppTheme): string {
172
235
  return `#[range=user|update]#[fg=${theme.accent}]⬆ v${status.latest}#[default]#[norange]`;
173
236
  }
174
237
 
175
- /** Flatten the project view to a flat per-session status list for {@link diffFleet}. */
176
- function fleetStatuses(projects: TeamProject[]): Array<{ name: string; status: AgentStatus }> {
238
+ /** Flatten the project view to a flat per-session status list for {@link diffFleet}.
239
+ * Exported: the control server's event tick diffs the same projection. */
240
+ export function fleetStatuses(
241
+ projects: TeamProject[],
242
+ ): Array<{ name: string; status: AgentStatus }> {
177
243
  return projects.flatMap((p) => p.sessions.map((s) => ({ name: s.name, status: s.status })));
178
244
  }
179
245
 
@@ -204,15 +270,23 @@ export function runUpdaterTick(deps: UpdaterTickDeps): void {
204
270
  deps.writeStatus(session, buildStatusline(projects, session, 12, theme, extra));
205
271
  }
206
272
  writeChips(deps, adopted, panes, theme);
273
+ deps.captureSessionIds?.(panes);
207
274
  if (update?.updateAvailable && update.latest) dispatchUpdateToast(deps, update.latest);
208
275
  if (deps.prevState && deps.appendEvents) {
209
276
  const { events, state } = diffFleet(deps.prevState, fleetStatuses(projects));
210
277
  deps.prevState.clear();
211
278
  for (const [name, status] of state) deps.prevState.set(name, status);
212
- if (events.length > 0) {
213
- deps.appendEvents(events);
214
- dispatchNotifications(deps, events);
215
- }
279
+ if (events.length > 0) deps.appendEvents(events);
280
+ }
281
+ // Notifications ride PANE transitions (M25.1), not the session rollup — a
282
+ // second agent blocking in an already-blocked session is invisible at the
283
+ // session level but is exactly who the user needs to hear about. Due delayed
284
+ // pings re-verify against THIS tick's pane states before the diff mutates
285
+ // anything (M25.2).
286
+ if (deps.prevPaneState) {
287
+ firePendingOsPings(deps, panes);
288
+ const events = diffPaneTransitions(deps.prevPaneState, panes, deps.locatePane);
289
+ if (events.length > 0) dispatchNotifications(deps, events);
216
290
  }
217
291
  }
218
292
 
@@ -241,21 +315,174 @@ function writeChips(
241
315
  }
242
316
  }
243
317
 
318
+ /**
319
+ * PURE (given `locate`) — diff the previous per-pane states against this
320
+ * tick's panes into ready-to-ping {@link NotifyEvent}s: each carries its
321
+ * `paneId` (the debounce/visibility key), `windowIndex` (window-granular toast
322
+ * suppression), the pane's resolved `agent`, and — only for a notifiable
323
+ * blocked/done transition that isn't first-sight — the human `location`
324
+ * (`locate` is a live tmux call, so it only fires for a potential ping).
325
+ * `prev` is mutated in place to the fresh state, like the session-level
326
+ * `prevState`; a pane that vanished simply drops out. First sight of a pane
327
+ * emits `from: null`, which {@link decideNotifications} ignores — that's the
328
+ * restart/first-tick grace.
329
+ */
330
+ export function diffPaneTransitions(
331
+ prev: Map<string, AgentStatus>,
332
+ panes: PaneDetail[],
333
+ locate?: (paneId: string) => string,
334
+ ): NotifyEvent[] {
335
+ const events: NotifyEvent[] = [];
336
+ const next = new Map<string, AgentStatus>();
337
+ for (const pane of panes) {
338
+ const before = prev.has(pane.paneId) ? prev.get(pane.paneId)! : null;
339
+ next.set(pane.paneId, pane.status);
340
+ if (before === pane.status) continue;
341
+ const notifiable = before !== null && (pane.status === "blocked" || pane.status === "done");
342
+ events.push({
343
+ session: pane.sessionName,
344
+ from: before,
345
+ to: pane.status,
346
+ paneId: pane.paneId,
347
+ windowIndex: pane.windowIndex,
348
+ agent: pane.agent,
349
+ location: notifiable && locate ? locate(pane.paneId) : pane.sessionName,
350
+ });
351
+ }
352
+ prev.clear();
353
+ for (const [paneId, status] of next) prev.set(paneId, status);
354
+ return events;
355
+ }
356
+
357
+ /** A queued OS-level ping: the {@link SystemNotification} payload plus when it
358
+ * becomes due. Fires only if its pane is STILL in `state` at fire time. */
359
+ export interface PendingOsPing extends SystemNotification {
360
+ dueAtMs: number;
361
+ }
362
+
363
+ /** PURE — whether ANY channel beyond the master switch is on; an all-off
364
+ * config costs the tick nothing. */
365
+ function anyChannelOn(prefs: NotificationPrefs): boolean {
366
+ return prefs.toast || prefs.macos || prefs.terminal || prefs.sound !== "none";
367
+ }
368
+
244
369
  /**
245
370
  * Ping the user about who needs them from this tick's transitions. Only runs
246
- * when the notification deps are wired AND at least one channel is enabled;
247
- * `lastNotified` is mutated in place so the debounce persists across ticks.
371
+ * when the notification deps are wired, notifications are `enabled`, AND at
372
+ * least one channel is on. `lastNotified` is mutated in place so the debounce
373
+ * (the flap guard) persists across ticks. The in-app toast fires IMMEDIATELY
374
+ * (cheap, low-annoyance); the OS-LEVEL channels (banner / terminal escape /
375
+ * sound / BEL) either fire now (`delaySeconds` 0 or no queue wired) or queue
376
+ * onto `pendingPings` for the delayed re-verify (M25.2). The debounce stamp
377
+ * lands at DECIDE time either way — a flap that later cancels its OS ping
378
+ * already toasted, so it spent its debounce slot honestly.
248
379
  */
249
380
  function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): void {
250
- const { listClients, lastNotified, now, prefs, sendToasts: toast, sendSystem } = deps;
381
+ const { listClients, lastNotified, now, prefs, sendToasts: toast } = deps;
251
382
  if (!listClients || !lastNotified || !now || !prefs) return;
252
- if (!prefs.toast && !prefs.macos) return;
253
- const decision = decideNotifications(events, listClients(), lastNotified, now());
383
+ if (!prefs.enabled) return;
384
+ if (!anyChannelOn(prefs)) return;
385
+ const nowMs = now();
386
+ const decision = decideNotifications(
387
+ events,
388
+ listClients(),
389
+ lastNotified,
390
+ nowMs,
391
+ enabledStates(prefs),
392
+ deps.appFocus?.() ?? null,
393
+ );
254
394
  lastNotified.clear();
255
395
  for (const [key, ts] of decision.nextLastNotified) lastNotified.set(key, ts);
396
+ // A system entry exists for every event that actually fired (stamped the
397
+ // debounce map), so this is exactly "the map changed — persist it".
398
+ if (decision.system.length > 0) deps.persistNotified?.(lastNotified);
256
399
  if (prefs.toast && toast) toast(decision.toasts);
257
- if (prefs.macos && sendSystem) {
258
- for (const { message } of decision.system) sendSystem(message);
400
+ if (decision.system.length === 0) return;
401
+ const delayMs = prefs.delaySeconds * 1000;
402
+ if (delayMs > 0 && deps.pendingPings) {
403
+ for (const n of decision.system) deps.pendingPings.push({ ...n, dueAtMs: nowMs + delayMs });
404
+ } else {
405
+ fireOsChannels(deps, prefs, decision.system, nowMs);
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Fire the DUE queued OS pings whose transition still holds (M25.2). The
411
+ * re-verify is tick-native: this tick's fresh pane scan is the source of truth
412
+ * — a pane that flapped out of the notified state, vanished, or is now on the
413
+ * unified app's screen ({@link AppFocus} — the user got there on their own)
414
+ * fires nothing and is dropped. Prefs are the CURRENT tick's fresh read, so a
415
+ * config change during the delay is honored. Runs before this tick's diff so
416
+ * "the next tick's pane states" is literally what confirms each ping.
417
+ */
418
+ function firePendingOsPings(deps: UpdaterTickDeps, panes: PaneDetail[]): void {
419
+ const { pendingPings: pending, now, prefs } = deps;
420
+ if (!pending || pending.length === 0 || !now || !prefs) return;
421
+ const nowMs = now();
422
+ const due: PendingOsPing[] = [];
423
+ const keep: PendingOsPing[] = [];
424
+ for (const p of pending) (p.dueAtMs <= nowMs ? due : keep).push(p);
425
+ if (due.length === 0) return;
426
+ pending.length = 0;
427
+ for (const p of keep) pending.push(p);
428
+ if (!prefs.enabled) return;
429
+ const statusByPane = new Map(panes.map((p) => [p.paneId, p.status]));
430
+ const focus = deps.appFocus?.() ?? null;
431
+ const confirmed = due.filter((p) => {
432
+ // A pane-less ping (session-granular caller) can't be re-verified — fire it.
433
+ if (p.paneId !== null && statusByPane.get(p.paneId) !== p.state) return false;
434
+ if (focus?.attached && p.paneId !== null && focus.panes.includes(p.paneId)) return false;
435
+ return true;
436
+ });
437
+ fireOsChannels(deps, prefs, confirmed, nowMs);
438
+ }
439
+
440
+ /**
441
+ * The OS-LEVEL fan-out (M25.2): system banner, terminal escapes + BEL to
442
+ * eligible client ttys, and the ping sound (at most ONE sound per batch — a
443
+ * fleet-wide flap must not ring a carillon). ALL of it is gated on quiet hours
444
+ * — inside the window nothing OS-level fires (the in-app toast and the event
445
+ * log have already surfaced the transition, so history stays honest).
446
+ */
447
+ function fireOsChannels(
448
+ deps: UpdaterTickDeps,
449
+ prefs: NotificationPrefs,
450
+ entries: SystemNotification[],
451
+ nowMs: number,
452
+ ): void {
453
+ if (entries.length === 0) return;
454
+ if (inQuietHours(new Date(nowMs), prefs.quietHours)) return;
455
+ if (prefs.macos && deps.sendSystem) {
456
+ for (const n of entries) deps.sendSystem(n);
457
+ }
458
+ if ((prefs.terminal || prefs.sound !== "none") && deps.sendTerminal && deps.listClients) {
459
+ const clients = deps.listClients();
460
+ const writes = entries.flatMap((n) => decideTtyWrites(n, clients, prefs));
461
+ if (writes.length > 0) deps.sendTerminal(writes);
462
+ }
463
+ if (deps.playSound && entries.some((n) => soundEligible(n.state, prefs.sound))) {
464
+ deps.playSound();
465
+ }
466
+ }
467
+
468
+ /**
469
+ * io — resolve a pane id to `session:window.pane` (e.g. `myproj:1.2`) for the
470
+ * ping text. Best-effort: a gone pane / failed call degrades to the raw pane id.
471
+ */
472
+ export function paneLocation(paneId: string): string {
473
+ try {
474
+ const raw = runTmux([
475
+ "display-message",
476
+ "-p",
477
+ "-t",
478
+ paneId,
479
+ "#{session_name}:#{window_index}.#{pane_index}",
480
+ ])
481
+ .toString()
482
+ .trim();
483
+ return raw || paneId;
484
+ } catch {
485
+ return paneId;
259
486
  }
260
487
  }
261
488
 
@@ -300,15 +527,14 @@ export function updaterRunning(): boolean {
300
527
 
301
528
  /**
302
529
  * Ensure the background updater is running: if the `_tmux-ide-chrome` session
303
- * isn't up, start it detached running `tmux-ide chrome-updater`. `exec` replaces
304
- * the shell so the pane IS the loop; killing the session stops it. `_`-internal
530
+ * isn't up, start it detached running `tmux-ide chrome-updater`. `_`-internal
305
531
  * so it's hidden from the bar/switcher. Best-effort — a chrome failure must
306
532
  * never break adopt/launch.
307
533
  */
308
534
  export function startUpdaterIfNeeded(): void {
309
535
  try {
310
536
  if (updaterRunning()) return;
311
- runTmux(["new-session", "-d", "-s", UPDATER_SESSION, "exec tmux-ide chrome-updater"]);
537
+ runTmux(updaterSpawnArgv());
312
538
  } catch {
313
539
  // best-effort — the bar still works via the last-written var
314
540
  }
@@ -360,11 +586,43 @@ function releaseUpdater(): void {
360
586
  }
361
587
  }
362
588
 
589
+ /** Exit the loop after this many CONSECUTIVE ticks with the tmux server gone —
590
+ * a dead server means nothing to watch and nobody to ping, and an immortal
591
+ * interval would zombie (two were found live before M25.1). */
592
+ export const UPDATER_UNREACHABLE_EXIT_TICKS = 5;
593
+
594
+ /** PURE — a consecutive-failure counter: feed it each tick's reachability;
595
+ * it answers "give up now?" after `threshold` misses in a row (any success
596
+ * resets the run). */
597
+ export function createUnreachableCounter(
598
+ threshold: number = UPDATER_UNREACHABLE_EXIT_TICKS,
599
+ ): (reachable: boolean) => boolean {
600
+ let consecutive = 0;
601
+ return (reachable: boolean): boolean => {
602
+ consecutive = reachable ? 0 : consecutive + 1;
603
+ return consecutive >= threshold;
604
+ };
605
+ }
606
+
607
+ /** io — can we still talk to the tmux server? (A server with zero sessions
608
+ * exits, so `list-sessions` failing means the server itself is gone.) */
609
+ function isServerReachable(): boolean {
610
+ try {
611
+ runTmux(["list-sessions", "-F", "#{session_name}"]);
612
+ return true;
613
+ } catch {
614
+ return false;
615
+ }
616
+ }
617
+
363
618
  /**
364
619
  * Run the updater loop forever (the body of `tmux-ide chrome-updater`). Claims
365
620
  * single-ownership, then rewrites every adopted session's bar immediately and
366
621
  * every {@link TICK_MS} thereafter behind ONE persistent tracker (so `done`
367
- * transitions surface). Blocks — the interval keeps the event loop alive.
622
+ * transitions surface). Blocks — the interval keeps the event loop alive —
623
+ * until the tmux server has been unreachable for
624
+ * {@link UPDATER_UNREACHABLE_EXIT_TICKS} consecutive ticks, at which point the
625
+ * loop logs once and exits instead of running headless forever.
368
626
  */
369
627
  export function runUpdaterLoop(): void {
370
628
  if (!claimUpdater()) return;
@@ -375,10 +633,24 @@ export function runUpdaterLoop(): void {
375
633
  const tracker = createStatusTracker();
376
634
  // Persistent across ticks so `diffFleet` can spot working→done etc.
377
635
  const prevState = new Map<string, AgentStatus>();
378
- // Persistent so the notification debounce survives across ticks.
379
- const lastNotified = new Map<string, number>();
636
+ // Per-PANE states for the notification path (M25.1 — pane-granular pings).
637
+ const prevPaneState = new Map<string, AgentStatus>();
638
+ // The notification debounce map — restored from disk so a restart can't
639
+ // re-ping inside the window, persisted back on every ping.
640
+ const lastNotified = loadLastNotified();
641
+ // Queued OS-level pings awaiting their delayed re-verify (M25.2). In-memory
642
+ // only: the window is seconds, so a restart just drops the queue.
643
+ const pendingPings: PendingOsPing[] = [];
380
644
  // Persistent per-pane chip cache so we only rewrite a chip when it changed.
381
645
  const chipCache = new Map<string, string>();
646
+ // Session-id capture for kinds without a hook integration (codex/cursor):
647
+ // stamps @agent_session_id so restore --resume-agents has its key. Throttled
648
+ // internally; probes only unstamped panes of capturable kinds.
649
+ const capturer = createSessionIdCapturer({
650
+ // Throwing is fine here — the capturer treats a failed stamp as "retry on
651
+ // the next capture window".
652
+ stamp: (paneId, id) => runTmux(["set-option", "-p", "-t", paneId, "@agent_session_id", id]),
653
+ });
382
654
  // The fleet snapshotter — pulsed each tick, self-throttled, writes only on a
383
655
  // structural change so the fleet can be rebuilt after a tmux-server death.
384
656
  const snapshotter = createSnapshotter({
@@ -387,6 +659,7 @@ export function runUpdaterLoop(): void {
387
659
  write: writeSnapshot,
388
660
  every: config.updater.snapshotEvery,
389
661
  });
662
+ const shouldGiveUp = createUnreachableCounter();
390
663
  const tick = () => {
391
664
  try {
392
665
  runUpdaterTick({
@@ -398,14 +671,22 @@ export function runUpdaterLoop(): void {
398
671
  chipCache,
399
672
  prevState,
400
673
  appendEvents,
674
+ prevPaneState,
401
675
  listClients: listAttachedClients,
402
676
  lastNotified,
403
677
  now: () => Date.now(),
404
678
  prefs: readNotificationPrefs(),
405
679
  sendToasts,
406
680
  sendSystem: sendSystemNotification,
681
+ sendTerminal: writeTtys,
682
+ playSound: playPingSound,
683
+ pendingPings,
684
+ locatePane: paneLocation,
685
+ appFocus: () => readAppFocus(),
686
+ persistNotified: (map) => saveLastNotified(map),
407
687
  maybeCheckForUpdate: () => maybeCheckForUpdate({ enabled: config.updates.check }),
408
688
  markUpdateNotified,
689
+ captureSessionIds: (panes) => capturer.onTick(panes),
409
690
  });
410
691
  } catch {
411
692
  // never let one bad tick kill the loop
@@ -415,8 +696,14 @@ export function runUpdaterLoop(): void {
415
696
  } catch {
416
697
  // a failed snapshot just means staler disaster-recovery state
417
698
  }
699
+ // Self-exit when the server we exist to watch is gone (logged ONCE).
700
+ if (shouldGiveUp(isServerReachable())) {
701
+ console.error(
702
+ `tmux-ide chrome-updater: tmux server unreachable for ${UPDATER_UNREACHABLE_EXIT_TICKS} consecutive ticks — exiting`,
703
+ );
704
+ shutdown();
705
+ }
418
706
  };
419
- tick();
420
707
  const timer = setInterval(tick, config.updater.tickMs);
421
708
  const shutdown = () => {
422
709
  clearInterval(timer);
@@ -425,4 +712,5 @@ export function runUpdaterLoop(): void {
425
712
  };
426
713
  process.on("SIGTERM", shutdown);
427
714
  process.on("SIGINT", shutdown);
715
+ tick();
428
716
  }
@@ -9,7 +9,9 @@
9
9
  *
10
10
  * Order is "checkout first, binary second": a dev machine that happens to have
11
11
  * built the binary still uses its live `.tsx` sources. The binary is consulted
12
- * only when the checkout sources or `bun` are missing.
12
+ * only when the checkout sources or `bun` are missing. {@link findCompiledTui}
13
+ * probes the shipped/local compiled binary first, then a per-platform binary
14
+ * downloaded at runtime into `~/.tmux-ide/bin/` (see lib/tui-binary.ts).
13
15
  *
14
16
  * {@link resolveTuiLaunch} is a PURE decision (unit-tested); {@link findCompiledTui}
15
17
  * and {@link isBunAvailable} are the thin io probes that feed it.
@@ -18,6 +20,7 @@ import { existsSync } from "node:fs";
18
20
  import { dirname, resolve } from "node:path";
19
21
  import { fileURLToPath } from "node:url";
20
22
  import { execFileSync } from "node:child_process";
23
+ import { findDownloadedTui } from "../lib/tui-binary.ts";
21
24
 
22
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
26
 
@@ -63,7 +66,7 @@ export function resolveTuiLaunch(input: TuiResolveInput): TuiLaunch {
63
66
  reasons.push("the `bun` runtime is not installed (https://bun.sh)");
64
67
  }
65
68
  reasons.push(
66
- "no compiled `tmux-ide-tui` binary was found (build one with `pnpm build:tui`, or reinstall a release that ships it)",
69
+ "no compiled `tmux-ide-tui` binary was found (build one with `pnpm build:tui`, download it with `tmux-ide update --tui-binary`, or reinstall a release that ships it)",
67
70
  );
68
71
  return { mode: "unavailable", reasons };
69
72
  }
@@ -99,7 +102,12 @@ export function findCompiledTui(): string | null {
99
102
  if (existsSync(candidate)) return candidate;
100
103
  }
101
104
  }
102
- return null;
105
+
106
+ // Last: a per-platform binary fetched at runtime by `tmux-ide update
107
+ // --tui-binary` into `~/.tmux-ide/bin/` — the fallback for an npm install with
108
+ // no bun and no shipped compiled binary. Version-stamped, so it only matches
109
+ // the running version (see lib/tui-binary.ts).
110
+ return findDownloadedTui();
103
111
  }
104
112
 
105
113
  /** io — is the `bun` runtime callable? */
@@ -49,6 +49,55 @@ export function parseAuthority(raw: string | undefined, nowSec: number): AgentSt
49
49
  return state as AgentStatus;
50
50
  }
51
51
 
52
+ /** Display-metadata budget — `@agent_status_text` / `@agent_display_name`
53
+ * values are clamped to this many characters (ellipsis included). */
54
+ export const AGENT_TEXT_MAX = 32;
55
+
56
+ /**
57
+ * Sanitize a self-reported display-metadata pane option
58
+ * (`@agent_status_text` / `@agent_display_name`) — PURE, never throws.
59
+ *
60
+ * ANSI escape sequences are stripped, remaining control characters become
61
+ * spaces, runs of whitespace collapse to one space, and the result is clamped
62
+ * to {@link AGENT_TEXT_MAX} characters (with a trailing ellipsis when cut).
63
+ * Returns undefined for an absent or effectively-empty value, so callers can
64
+ * spread the field additively (`...(text ? { statusText: text } : {})`).
65
+ *
66
+ * Freshness is NOT judged here: the metadata options carry no epoch of their
67
+ * own — they ride the pane's `@agent_state` stamp, and the caller surfaces
68
+ * them only while {@link parseAuthority} reports that stamp fresh.
69
+ */
70
+ export function sanitizeAgentText(raw: string | undefined): string | undefined {
71
+ if (!raw) return undefined;
72
+ const cleaned = raw
73
+ // CSI escape sequences (colors, cursor moves) — drop entirely.
74
+ // eslint-disable-next-line no-control-regex
75
+ .replace(/\x1b\[[0-9;?]*[ -/]*[\x40-\x7e]/g, "")
76
+ // Any remaining C0 control chars (incl. bare ESC, tabs, newlines) + DEL.
77
+ // eslint-disable-next-line no-control-regex
78
+ .replace(/[\x00-\x1f\x7f]/g, " ")
79
+ .replace(/\s+/g, " ")
80
+ .trim();
81
+ if (cleaned.length === 0) return undefined;
82
+ if (cleaned.length <= AGENT_TEXT_MAX) return cleaned;
83
+ return cleaned.slice(0, AGENT_TEXT_MAX - 1) + "…";
84
+ }
85
+
86
+ /**
87
+ * Extract the epoch STAMP from an `@agent_state` value (`"<state>:<epoch>"`) —
88
+ * the "since" timestamp of the reported state. PURE — returns the epoch, or
89
+ * null when the value is absent or its stamp isn't a finite number. Staleness
90
+ * is NOT considered here (that's {@link parseAuthority}'s job); callers pair
91
+ * this with a fresh `parseAuthority` result to surface a state's `since`.
92
+ */
93
+ export function parseAuthorityEpoch(raw: string | undefined): number | null {
94
+ if (!raw) return null;
95
+ const sep = raw.lastIndexOf(":");
96
+ if (sep === -1) return null;
97
+ const epoch = Number(raw.slice(sep + 1));
98
+ return Number.isFinite(epoch) ? epoch : null;
99
+ }
100
+
52
101
  /**
53
102
  * Classify a single snapshot against a manifest — PURE, never throws.
54
103
  *