tmux-ide 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +22 -5
  2. package/bin/cli.js +3532 -1090
  3. package/bin/cli.ts +368 -71
  4. package/package.json +2 -1
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/control/client.d.ts +23 -0
  9. package/packages/daemon/dist/control/client.js +105 -0
  10. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  11. package/packages/daemon/dist/control/dispatch.js +83 -0
  12. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  13. package/packages/daemon/dist/control/fanout.js +37 -0
  14. package/packages/daemon/dist/control/frames.d.ts +23 -0
  15. package/packages/daemon/dist/control/frames.js +37 -0
  16. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  17. package/packages/daemon/dist/control/lifecycle.js +114 -0
  18. package/packages/daemon/dist/control/server.d.ts +16 -0
  19. package/packages/daemon/dist/control/server.js +214 -0
  20. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  21. package/packages/daemon/dist/control/verbs.js +91 -0
  22. package/packages/daemon/dist/doctor.d.ts +18 -0
  23. package/packages/daemon/dist/doctor.js +105 -15
  24. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  25. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  26. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  27. package/packages/daemon/dist/lib/app-config.js +104 -5
  28. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  29. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  30. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  31. package/packages/daemon/dist/lib/state-home.js +12 -0
  32. package/packages/daemon/dist/lib/update-check.js +5 -0
  33. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  34. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  35. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  36. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  39. package/packages/daemon/dist/restore.d.ts +35 -8
  40. package/packages/daemon/dist/restore.js +52 -15
  41. package/packages/daemon/dist/send.d.ts +33 -1
  42. package/packages/daemon/dist/send.js +32 -19
  43. package/packages/daemon/src/control/client.ts +128 -0
  44. package/packages/daemon/src/control/dispatch.ts +107 -0
  45. package/packages/daemon/src/control/fanout.ts +44 -0
  46. package/packages/daemon/src/control/frames.ts +40 -0
  47. package/packages/daemon/src/control/lifecycle.ts +151 -0
  48. package/packages/daemon/src/control/server.ts +237 -0
  49. package/packages/daemon/src/control/verbs.ts +118 -0
  50. package/packages/daemon/src/doctor.ts +113 -28
  51. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  52. package/packages/daemon/src/lib/app-config.ts +103 -5
  53. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  54. package/packages/daemon/src/lib/state-home.ts +13 -0
  55. package/packages/daemon/src/lib/update-check.ts +5 -0
  56. package/packages/daemon/src/restore.ts +53 -15
  57. package/packages/daemon/src/send.ts +55 -21
  58. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  59. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  60. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  61. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  62. package/packages/daemon/src/tui/chrome/notify.ts +582 -84
  63. package/packages/daemon/src/tui/chrome/updater.ts +268 -62
  64. package/packages/daemon/src/tui/detect/classify.ts +34 -0
  65. package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
  66. package/packages/daemon/src/tui/detect/manifest.ts +24 -3
  67. package/packages/daemon/src/tui/detect/manifests.ts +240 -6
  68. package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
  69. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  70. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  71. package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
  72. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  73. package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
  74. package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
  75. package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
  76. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  77. package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
  78. package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
  79. package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
  80. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  81. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  82. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  83. package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
  84. package/packages/daemon/src/tui/mirror/palette.ts +299 -9
  85. package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
  86. package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
  87. package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
  88. package/packages/daemon/src/tui/mirror/selection.ts +122 -8
  89. package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
  90. package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
  91. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  92. package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
  93. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  94. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  95. package/packages/daemon/src/tui/team/sessions.ts +85 -7
  96. package/packages/daemon/src/tui/team/wait.ts +144 -0
  97. package/scripts/build-macos-notifier.mjs +160 -0
  98. package/scripts/postinstall.js +8 -1
  99. package/scripts/prepublish-check.mjs +37 -1
  100. package/scripts/publish-tap.sh +55 -0
  101. package/skill/SKILL.md +88 -2
@@ -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,24 +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,
37
40
  enabledStates,
38
41
  inQuietHours,
39
42
  listAttachedClients,
43
+ playPingSound,
44
+ readAppFocus,
40
45
  readNotificationPrefs,
41
46
  sendSystemNotification,
42
47
  sendToasts,
48
+ soundEligible,
49
+ writeTtys,
50
+ type AppFocus,
43
51
  type AttachedClient,
44
52
  type NotificationPrefs,
45
53
  type NotifyEvent,
46
54
  type SystemNotification,
47
55
  type ToastTarget,
56
+ type TtyWrite,
48
57
  } from "./notify.ts";
58
+ import { loadLastNotified, saveLastNotified } from "./notify-state.ts";
49
59
  import {
50
60
  collectFleetSnapshot,
51
61
  createSnapshotter,
@@ -58,10 +68,15 @@ import { buildStatusline } from "./statusline.ts";
58
68
  export const STATUS_OPTION = "@tmux_ide_status";
59
69
  /** Per-PANE user option holding the pre-rendered agent chip (read by pane-border-format). */
60
70
  export const CHIP_OPTION = "@tmux_ide_chip";
61
- /** Per-session marker option set on adopt so the updater can enumerate adopted sessions. */
62
- export const ADOPTED_OPTION = "@tmux_ide_adopted";
63
- /** The hidden internal session that hosts the updater loop. */
64
- 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";
65
80
  /** Server option holding the running updater's pid (a lightweight single-owner guard). */
66
81
  export const UPDATER_PID_OPTION = "@tmux_ide_updater_pid";
67
82
  /** Default tick cadence — overridable via `updater.tickMs` in the app config. */
@@ -140,18 +155,49 @@ export interface UpdaterTickDeps {
140
155
  prevState?: Map<string, AgentStatus>;
141
156
  appendEvents?: (events: AgentEventInit[]) => void;
142
157
  /**
143
- * Notification dispatch (optional). When wired alongside `prevState`, the tick
144
- * turns THIS tick's transitions into user pings — toasts on attached clients
145
- * and/or a macOS notification — via {@link decideNotifications}, gated on
146
- * `prefs`. `lastNotified` is the persistent debounce map, mutated in place.
147
- * 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.
148
167
  */
168
+ prevPaneState?: Map<string, AgentStatus>;
149
169
  listClients?: () => AttachedClient[];
150
170
  lastNotified?: Map<string, number>;
151
171
  now?: () => number;
152
172
  prefs?: NotificationPrefs;
153
173
  sendToasts?: (toasts: ToastTarget[]) => void;
154
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;
155
201
  /**
156
202
  * Resolve a pane id to its human `session:window.pane` location for the ping
157
203
  * text (optional). Wired to the live tmux {@link paneLocation}; tests inject a
@@ -168,6 +214,14 @@ export interface UpdaterTickDeps {
168
214
  */
169
215
  maybeCheckForUpdate?: () => UpdateStatus;
170
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;
171
225
  }
172
226
 
173
227
  /**
@@ -181,8 +235,11 @@ export function updateSegment(status: UpdateStatus, theme: AppTheme): string {
181
235
  return `#[range=user|update]#[fg=${theme.accent}]⬆ v${status.latest}#[default]#[norange]`;
182
236
  }
183
237
 
184
- /** Flatten the project view to a flat per-session status list for {@link diffFleet}. */
185
- 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 }> {
186
243
  return projects.flatMap((p) => p.sessions.map((s) => ({ name: s.name, status: s.status })));
187
244
  }
188
245
 
@@ -213,15 +270,23 @@ export function runUpdaterTick(deps: UpdaterTickDeps): void {
213
270
  deps.writeStatus(session, buildStatusline(projects, session, 12, theme, extra));
214
271
  }
215
272
  writeChips(deps, adopted, panes, theme);
273
+ deps.captureSessionIds?.(panes);
216
274
  if (update?.updateAvailable && update.latest) dispatchUpdateToast(deps, update.latest);
217
275
  if (deps.prevState && deps.appendEvents) {
218
276
  const { events, state } = diffFleet(deps.prevState, fleetStatuses(projects));
219
277
  deps.prevState.clear();
220
278
  for (const [name, status] of state) deps.prevState.set(name, status);
221
- if (events.length > 0) {
222
- deps.appendEvents(events);
223
- dispatchNotifications(deps, enrichEvents(events, panes, deps.locatePane));
224
- }
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);
225
290
  }
226
291
  }
227
292
 
@@ -251,58 +316,72 @@ function writeChips(
251
316
  }
252
317
 
253
318
  /**
254
- * PURE — pick the pane to name in a session-level ping. A session rolls up many
255
- * panes; the ping should point at ONE. We take a pane whose status matches the
256
- * transition (`to`), preferring one that resolved to a real agent (so the ping
257
- * reads `claude blocked …`, not a bare shell). Null when no pane matches — the
258
- * updater then falls back to the session name / a generic label.
259
- */
260
- export function pickRepresentativePane(
261
- session: string,
262
- to: AgentStatus,
263
- panes: PaneDetail[],
264
- ): PaneDetail | null {
265
- const matching = panes.filter((p) => p.sessionName === session && p.status === to);
266
- if (matching.length === 0) return null;
267
- return matching.find((p) => p.agent !== null) ?? matching[0]!;
268
- }
269
-
270
- /**
271
- * PURE — enrich session-level transitions with the pane's `agent` id and human
272
- * `location` so {@link notifyMessage} can name who needs the user. Only the
273
- * notifiable states (blocked/done) get resolved — everything else keeps the bare
274
- * session as its location and is filtered out downstream anyway, so `locate`
275
- * (a live tmux call) only fires for a real ping.
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.
276
329
  */
277
- export function enrichEvents(
278
- events: AgentEventInit[],
330
+ export function diffPaneTransitions(
331
+ prev: Map<string, AgentStatus>,
279
332
  panes: PaneDetail[],
280
333
  locate?: (paneId: string) => string,
281
334
  ): NotifyEvent[] {
282
- return events.map((ev) => {
283
- const notifiable = ev.to === "blocked" || ev.to === "done";
284
- const rep = notifiable ? pickRepresentativePane(ev.session, ev.to, panes) : null;
285
- return {
286
- ...ev,
287
- agent: rep?.agent ?? null,
288
- location: rep && locate ? locate(rep.paneId) : ev.session,
289
- };
290
- });
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";
291
367
  }
292
368
 
293
369
  /**
294
370
  * Ping the user about who needs them from this tick's transitions. Only runs
295
371
  * when the notification deps are wired, notifications are `enabled`, AND at
296
372
  * least one channel is on. `lastNotified` is mutated in place so the debounce
297
- * (the flap guard) persists across ticks. The macOS banner is additionally
298
- * gated on QUIET HOURS — inside the window the banner is skipped, but the event
299
- * has already been recorded to the log by the caller, so history stays honest.
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.
300
379
  */
301
380
  function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): void {
302
- const { listClients, lastNotified, now, prefs, sendToasts: toast, sendSystem } = deps;
381
+ const { listClients, lastNotified, now, prefs, sendToasts: toast } = deps;
303
382
  if (!listClients || !lastNotified || !now || !prefs) return;
304
383
  if (!prefs.enabled) return;
305
- if (!prefs.toast && !prefs.macos) return;
384
+ if (!anyChannelOn(prefs)) return;
306
385
  const nowMs = now();
307
386
  const decision = decideNotifications(
308
387
  events,
@@ -310,12 +389,79 @@ function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): vo
310
389
  lastNotified,
311
390
  nowMs,
312
391
  enabledStates(prefs),
392
+ deps.appFocus?.() ?? null,
313
393
  );
314
394
  lastNotified.clear();
315
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);
316
399
  if (prefs.toast && toast) toast(decision.toasts);
317
- if (prefs.macos && sendSystem && !inQuietHours(new Date(nowMs), prefs.quietHours)) {
318
- for (const n of decision.system) sendSystem(n);
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();
319
465
  }
320
466
  }
321
467
 
@@ -381,15 +527,14 @@ export function updaterRunning(): boolean {
381
527
 
382
528
  /**
383
529
  * Ensure the background updater is running: if the `_tmux-ide-chrome` session
384
- * isn't up, start it detached running `tmux-ide chrome-updater`. `exec` replaces
385
- * 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
386
531
  * so it's hidden from the bar/switcher. Best-effort — a chrome failure must
387
532
  * never break adopt/launch.
388
533
  */
389
534
  export function startUpdaterIfNeeded(): void {
390
535
  try {
391
536
  if (updaterRunning()) return;
392
- runTmux(["new-session", "-d", "-s", UPDATER_SESSION, "exec tmux-ide chrome-updater"]);
537
+ runTmux(updaterSpawnArgv());
393
538
  } catch {
394
539
  // best-effort — the bar still works via the last-written var
395
540
  }
@@ -441,11 +586,43 @@ function releaseUpdater(): void {
441
586
  }
442
587
  }
443
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
+
444
618
  /**
445
619
  * Run the updater loop forever (the body of `tmux-ide chrome-updater`). Claims
446
620
  * single-ownership, then rewrites every adopted session's bar immediately and
447
621
  * every {@link TICK_MS} thereafter behind ONE persistent tracker (so `done`
448
- * 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.
449
626
  */
450
627
  export function runUpdaterLoop(): void {
451
628
  if (!claimUpdater()) return;
@@ -456,10 +633,24 @@ export function runUpdaterLoop(): void {
456
633
  const tracker = createStatusTracker();
457
634
  // Persistent across ticks so `diffFleet` can spot working→done etc.
458
635
  const prevState = new Map<string, AgentStatus>();
459
- // Persistent so the notification debounce survives across ticks.
460
- 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[] = [];
461
644
  // Persistent per-pane chip cache so we only rewrite a chip when it changed.
462
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
+ });
463
654
  // The fleet snapshotter — pulsed each tick, self-throttled, writes only on a
464
655
  // structural change so the fleet can be rebuilt after a tmux-server death.
465
656
  const snapshotter = createSnapshotter({
@@ -468,6 +659,7 @@ export function runUpdaterLoop(): void {
468
659
  write: writeSnapshot,
469
660
  every: config.updater.snapshotEvery,
470
661
  });
662
+ const shouldGiveUp = createUnreachableCounter();
471
663
  const tick = () => {
472
664
  try {
473
665
  runUpdaterTick({
@@ -479,15 +671,22 @@ export function runUpdaterLoop(): void {
479
671
  chipCache,
480
672
  prevState,
481
673
  appendEvents,
674
+ prevPaneState,
482
675
  listClients: listAttachedClients,
483
676
  lastNotified,
484
677
  now: () => Date.now(),
485
678
  prefs: readNotificationPrefs(),
486
679
  sendToasts,
487
680
  sendSystem: sendSystemNotification,
681
+ sendTerminal: writeTtys,
682
+ playSound: playPingSound,
683
+ pendingPings,
488
684
  locatePane: paneLocation,
685
+ appFocus: () => readAppFocus(),
686
+ persistNotified: (map) => saveLastNotified(map),
489
687
  maybeCheckForUpdate: () => maybeCheckForUpdate({ enabled: config.updates.check }),
490
688
  markUpdateNotified,
689
+ captureSessionIds: (panes) => capturer.onTick(panes),
491
690
  });
492
691
  } catch {
493
692
  // never let one bad tick kill the loop
@@ -497,8 +696,14 @@ export function runUpdaterLoop(): void {
497
696
  } catch {
498
697
  // a failed snapshot just means staler disaster-recovery state
499
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
+ }
500
706
  };
501
- tick();
502
707
  const timer = setInterval(tick, config.updater.tickMs);
503
708
  const shutdown = () => {
504
709
  clearInterval(timer);
@@ -507,4 +712,5 @@ export function runUpdaterLoop(): void {
507
712
  };
508
713
  process.on("SIGTERM", shutdown);
509
714
  process.on("SIGINT", shutdown);
715
+ tick();
510
716
  }
@@ -49,6 +49,40 @@ 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
+
52
86
  /**
53
87
  * Extract the epoch STAMP from an `@agent_state` value (`"<state>:<epoch>"`) —
54
88
  * the "since" timestamp of the reported state. PURE — returns the epoch, or
@@ -30,9 +30,20 @@ import { join } from "node:path";
30
30
  import type { AgentManifest, Rule, StateRules } from "./manifest.ts";
31
31
  import { BUNDLED_MANIFESTS } from "./manifests.ts";
32
32
 
33
- /** Directory scanned for user override manifests. */
33
+ /** Directory scanned for user override manifests. Honors `TMUX_IDE_HOME` (the
34
+ * state-home override every other `~/.tmux-ide` consumer respects) so tests
35
+ * never read a real user's overrides. */
34
36
  export function overrideDir(): string {
35
- return join(homedir(), ".tmux-ide", "agent-detection");
37
+ const home = process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
38
+ return join(home, "agent-detection");
39
+ }
40
+
41
+ /** The fetched manifest-pack file (`tmux-ide update --manifests` installs it;
42
+ * see `lib/manifest-pack.ts` for the format + publish contract). Lives in a
43
+ * SUBDIRECTORY so {@link readOverrideManifests}'s top-level `*.json` scan
44
+ * never sees it — that is what keeps user files ABOVE the pack. */
45
+ export function packFile(dir = overrideDir()): string {
46
+ return join(dir, "pack", "manifest-pack.json");
36
47
  }
37
48
 
38
49
  /**
@@ -167,11 +178,49 @@ function warnOnce(path: string, reason: string): void {
167
178
  }
168
179
 
169
180
  /**
170
- * Load the full manifest set: bundled + user overrides, merged. Does io on
171
- * every call — prefer {@link getManifests} for hot paths.
181
+ * Read the installed manifest PACK (fetched by `tmux-ide update --manifests`),
182
+ * or `[]` when none is installed. Thin io — a malformed pack file is skipped
183
+ * with a one-time stderr warning, never a throw (the pack was schema-validated
184
+ * at install time; this guards a hand-edited/corrupted file). Each entry gets
185
+ * the same structural validation + normalization as a user override file.
186
+ */
187
+ export function readPackManifests(dir = overrideDir()): AgentManifest[] {
188
+ const path = packFile(dir);
189
+ let raw: string;
190
+ try {
191
+ raw = readFileSync(path, "utf8");
192
+ } catch {
193
+ // No pack installed (the common case).
194
+ return [];
195
+ }
196
+ try {
197
+ const parsed: unknown = JSON.parse(raw);
198
+ const manifests = (parsed as { manifests?: unknown })?.manifests;
199
+ if (!Array.isArray(manifests)) {
200
+ warnOnce(path, "not a manifest pack (missing manifests[])");
201
+ return [];
202
+ }
203
+ const valid: AgentManifest[] = [];
204
+ for (const entry of manifests) {
205
+ if (validateManifestShape(entry)) valid.push(normalizeStates(entry));
206
+ else warnOnce(path, "pack entry is not a valid AgentManifest — entry skipped");
207
+ }
208
+ return valid;
209
+ } catch (err) {
210
+ warnOnce(path, err instanceof Error ? err.message : String(err));
211
+ return [];
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Load the full manifest set: bundled + pack + user overrides, merged in that
217
+ * PRECEDENCE order — a fetched pack refines the bundled set, and a user's own
218
+ * `agent-detection/*.json` file always beats both (it is merged LAST). Does io
219
+ * on every call — prefer {@link getManifests} for hot paths.
172
220
  */
173
221
  export function loadManifests(): AgentManifest[] {
174
- return mergeManifests(BUNDLED_MANIFESTS, readOverrideManifests());
222
+ const withPack = mergeManifests(BUNDLED_MANIFESTS, readPackManifests());
223
+ return mergeManifests(withPack, readOverrideManifests());
175
224
  }
176
225
 
177
226
  /** Process-lifetime cache for the merged manifest set. */
@@ -175,10 +175,31 @@ export function explain(
175
175
  return { state: winner ? winner.state : null, checked };
176
176
  }
177
177
 
178
+ /** Escape a string for literal use inside a RegExp source. */
179
+ function escapeRegex(s: string): string {
180
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
181
+ }
182
+
183
+ /**
184
+ * Does `haystack` contain `needle` as a WHOLE alphanumeric segment (delimited
185
+ * by start/end or any non-alphanumeric)? `grok-build` contains the segment
186
+ * `grok`; `pip` does NOT contain the segment `pi`. This is what keeps short
187
+ * manifest command tokens (pi, devin, kimi — M25.4) from substring-matching
188
+ * unrelated commands (`pip`, `vi`, `api-server`).
189
+ */
190
+ function containsSegment(haystack: string, needle: string): boolean {
191
+ if (!haystack.includes(needle)) return false;
192
+ return new RegExp(`(^|[^a-z0-9])${escapeRegex(needle)}([^a-z0-9]|$)`).test(haystack);
193
+ }
194
+
178
195
  /**
179
196
  * Pick the manifest whose `commands` best match a pane's current command.
180
- * Prefers an exact (case-insensitive) match, then a substring match in either
181
- * direction. Returns undefined when nothing applies.
197
+ * Prefers an exact (case-insensitive) match, then a SEGMENT match in either
198
+ * direction — the command contains a manifest token as a whole segment
199
+ * (`grok-build` → `grok`, `.kilo` → `kilo`) or vice versa (`cursor` →
200
+ * `cursor-agent`). Raw substring matching was retired in M25.4: it made every
201
+ * short token a false-positive machine (`pi` ⊂ `pip`, `vi` ⊂ `devin`).
202
+ * Returns undefined when nothing applies.
182
203
  */
183
204
  export function pickManifest(
184
205
  command: string,
@@ -193,7 +214,7 @@ export function pickManifest(
193
214
  return manifests.find((m) =>
194
215
  m.commands.some((c) => {
195
216
  const name = c.toLowerCase();
196
- return cmd.includes(name) || name.includes(cmd);
217
+ return containsSegment(cmd, name) || containsSegment(name, cmd);
197
218
  }),
198
219
  );
199
220
  }