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
@@ -14,10 +14,12 @@ import { execFileSync } from "node:child_process";
14
14
  import {
15
15
  classifyInstant,
16
16
  parseAuthority,
17
+ parseAuthorityEpoch,
18
+ sanitizeAgentText,
17
19
  type AgentStatus,
18
20
  type StatusTracker,
19
21
  } from "../detect/classify.ts";
20
- import type { AgentManifest } from "../detect/manifest.ts";
22
+ import type { AgentManifest, ManifestConfidence } from "../detect/manifest.ts";
21
23
  import { readProcessTable, resolveAgentCommand } from "../detect/process-tree.ts";
22
24
  import { readPaneSnapshot } from "../detect/snapshot.ts";
23
25
 
@@ -33,6 +35,119 @@ export interface TeamSession {
33
35
  * this is the richer breakdown the switcher/cockpit navigate.
34
36
  */
35
37
  windowList: TeamWindow[];
38
+ /**
39
+ * Per-pane agent detail — one entry for every pane the two-layer detection
40
+ * classifies as an agent (a manifest resolved and it isn't the `shell`
41
+ * catch-all). A FLAT list across the session's windows; each entry carries
42
+ * its own `windowIndex`, so a consumer that wants a per-window view groups by
43
+ * it. ADDITIVE: the rollup fields above (`status`, `windowList`, `panes`) are
44
+ * unchanged — this only SURFACES per-pane truth the rollup already computes.
45
+ * Optional so pre-existing constructors stay valid; {@link listTeamSessions}
46
+ * always populates it (possibly empty).
47
+ */
48
+ agents?: PaneAgentEntry[];
49
+ }
50
+
51
+ /**
52
+ * Per-pane agent detail surfaced alongside a session's rollup. Emitted only for
53
+ * panes that resolve to a real agent (a manifest, excluding the `shell`
54
+ * catch-all). Everything here is already computed during the status rollup —
55
+ * this record just keeps it instead of discarding it.
56
+ */
57
+ export interface PaneAgentEntry {
58
+ /** tmux pane id, e.g. `%5`. */
59
+ paneId: string;
60
+ /** `window_index` of the window (tab) this pane lives in. */
61
+ windowIndex: number;
62
+ /** Owning session name (repeated per entry so a flattened list stays keyed). */
63
+ session: string;
64
+ /** The resolved agent kind — the manifest id (`claude`, `codex`, …). */
65
+ kind: string;
66
+ /** Final per-pane status (authority when fresh, else scraped/tracked). */
67
+ state: AgentStatus;
68
+ /** The manifest's evidence confidence (`conservative` when the manifest omits it). */
69
+ confidence: ManifestConfidence;
70
+ /**
71
+ * The authority state's epoch stamp when the AUTHORITY layer provided the
72
+ * state (`@agent_state` = `"<state>:<epoch>"`); null for a scraped/tracked
73
+ * pane, which has no authoritative timestamp.
74
+ */
75
+ since: number | null;
76
+ /** `pane_title`. */
77
+ title: string;
78
+ /** `pane_current_command` — the pane's immediate process (often node/bun/sh). */
79
+ command: string;
80
+ /** `pane_current_path` — the pane's working directory. */
81
+ dir: string;
82
+ /**
83
+ * Self-reported one-liner (`@agent_status_text`, sanitized + clamped to 32
84
+ * chars) — what the agent says it is doing ("refactoring auth"). ADDITIVE and
85
+ * present only while the pane's `@agent_state` stamp is FRESH (the metadata
86
+ * follows the same authority-staleness rules; stale/absent authority drops it).
87
+ */
88
+ statusText?: string;
89
+ /**
90
+ * Self-reported display name (`@agent_display_name`, sanitized) — label
91
+ * precedence over `kind` in the sidebar/chips. Same freshness gate as
92
+ * {@link statusText}. ADDITIVE.
93
+ */
94
+ displayName?: string;
95
+ }
96
+
97
+ /**
98
+ * Build a {@link PaneAgentEntry} from a classified pane — PURE. Returns null
99
+ * for a NON-agent pane (no manifest resolved, or the `shell` catch-all), which
100
+ * gets no entry. `since` is threaded from the caller (the authority epoch when
101
+ * the authority layer supplied the state, else null). No tmux/`ps` access —
102
+ * every input is already resolved by the rollup.
103
+ */
104
+ export function buildAgentEntry(input: {
105
+ sessionName: string;
106
+ pane: Pick<PaneRecord, "id" | "windowIndex" | "title" | "cmd" | "dir">;
107
+ manifest: AgentManifest | undefined;
108
+ state: AgentStatus;
109
+ since: number | null;
110
+ /** Already-sanitized display metadata (undefined when absent/stale). */
111
+ statusText?: string;
112
+ displayName?: string;
113
+ }): PaneAgentEntry | null {
114
+ const { manifest, pane } = input;
115
+ if (!manifest || manifest.id === "shell") return null;
116
+ return {
117
+ paneId: pane.id,
118
+ windowIndex: pane.windowIndex,
119
+ session: input.sessionName,
120
+ kind: manifest.id,
121
+ state: input.state,
122
+ confidence: manifest.confidence ?? "conservative",
123
+ since: input.since,
124
+ title: pane.title,
125
+ command: pane.cmd,
126
+ dir: pane.dir,
127
+ ...(input.statusText !== undefined ? { statusText: input.statusText } : {}),
128
+ ...(input.displayName !== undefined ? { displayName: input.displayName } : {}),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * PURE — the display metadata a pane's agent entry surfaces: the sanitized
134
+ * `@agent_status_text` / `@agent_display_name` options, gated on the SAME
135
+ * authority-freshness verdict as the state itself. `authorityFresh` is
136
+ * `parseAuthority(...) !== null` — when the pane's `@agent_state` stamp is
137
+ * absent or stale (a dead hook mid-turn), the metadata is dropped with it
138
+ * rather than lying alongside a scraped state.
139
+ */
140
+ export function agentMetadataFor(
141
+ pane: Pick<PaneRecord, "statusTextRaw" | "displayNameRaw">,
142
+ authorityFresh: boolean,
143
+ ): { statusText?: string; displayName?: string } {
144
+ if (!authorityFresh) return {};
145
+ const statusText = sanitizeAgentText(pane.statusTextRaw);
146
+ const displayName = sanitizeAgentText(pane.displayNameRaw);
147
+ return {
148
+ ...(statusText !== undefined ? { statusText } : {}),
149
+ ...(displayName !== undefined ? { displayName } : {}),
150
+ };
36
151
  }
37
152
 
38
153
  /**
@@ -63,6 +178,15 @@ export interface PaneDetail {
63
178
  paneId: string;
64
179
  agent: string | null;
65
180
  status: AgentStatus;
181
+ /** `window_index` of the pane's window — the notification path suppresses
182
+ * toasts window-granularly, so the transition must know its window. */
183
+ windowIndex: number;
184
+ /** `pane_pid` — root of the pane's process tree (session-id capture probes from it). */
185
+ pid: number;
186
+ /** `pane_current_path` — the pane's working directory. */
187
+ dir: string;
188
+ /** Existing `@agent_session_id` stamp, or null (capture only fills empty ones). */
189
+ sessionId: string | null;
66
190
  }
67
191
 
68
192
  interface PaneRecord {
@@ -74,10 +198,18 @@ interface PaneRecord {
74
198
  cmd: string;
75
199
  /** `pane_title`. */
76
200
  title: string;
201
+ /** `pane_current_path` — the pane's working directory. */
202
+ dir: string;
77
203
  /** Raw `@agent_state` pane option (authority layer), if set. */
78
204
  authority: string;
79
205
  /** Raw `@agent_hint` pane option — forces a manifest when set. */
80
206
  hint: string;
207
+ /** Raw `@agent_session_id` pane option — the agent's own session id, if recorded. */
208
+ sessionId: string;
209
+ /** Raw `@agent_status_text` pane option — sanitized by {@link agentMetadataFor}. */
210
+ statusTextRaw: string;
211
+ /** Raw `@agent_display_name` pane option — sanitized by {@link agentMetadataFor}. */
212
+ displayNameRaw: string;
81
213
  /** `window_index` — the window (tab) this pane lives in. */
82
214
  windowIndex: number;
83
215
  /** `window_name`. */
@@ -105,12 +237,13 @@ export function excludeSidebarPanes<T extends { sidebar: boolean }>(panes: T[]):
105
237
  const SEVERITY: AgentStatus[] = ["blocked", "working", "done", "idle", "unknown"];
106
238
 
107
239
  /**
108
- * Whether a session should appear in the switcher. Any `_`-prefixed session is
109
- * internal plumbing (the `_tmux-ide-chrome` updater, scratch sessions, …) and
110
- * is filtered out so the cockpit never lists — or navigates into — infrastructure.
240
+ * Whether a session should appear in the switcher. `_`-prefixed sessions are
241
+ * internal plumbing (the `_tmux-ide-chrome` updater, the `_tmux-ide-app` host)
242
+ * and `zz-`-prefixed sessions are development scratch sessions — both are
243
+ * filtered out so the cockpit never lists — or navigates into — infrastructure.
111
244
  */
112
245
  export function isListableSession(name: string): boolean {
113
- return !name.startsWith("_");
246
+ return !name.startsWith("_") && !name.startsWith("zz-");
114
247
  }
115
248
 
116
249
  function tmux(args: string[]): string {
@@ -177,16 +310,21 @@ export function listTeamSessions(
177
310
  const seen = opts.viewed === name;
178
311
 
179
312
  const nowSec = Math.floor(Date.now() / 1000);
180
- const wantPane = typeof opts.onPane === "function";
313
+ const agents: PaneAgentEntry[] = [];
181
314
  const statuses = panes.map((pane) => {
182
315
  // AUTHORITY first: a fresh hook-reported state outranks scraping.
183
316
  const authority = parseAuthority(pane.authority, nowSec);
184
317
  let status: AgentStatus;
185
- // The manifest is resolved in the fallback anyway; for authority panes
186
- // we only resolve it (cheaply — the process table is already loaded, no
187
- // capture) when a pane sink wants the agent name for a chip.
318
+ // The agent kind is needed for BOTH the chip (onPane) and the per-pane
319
+ // agent entry, so the manifest is resolved for every pane. It's cheap —
320
+ // the process table is already loaded, and authority panes take NO
321
+ // capture-pane round-trip.
188
322
  let manifest: AgentManifest | undefined;
323
+ // The authority state's own timestamp (its "since"); only fresh
324
+ // authority states carry one — scraped panes have no stamp.
325
+ let since: number | null = null;
189
326
  if (authority !== null) {
327
+ since = parseAuthorityEpoch(pane.authority);
190
328
  if (authority === "done" && seen) {
191
329
  // Viewing acknowledges a finished agent — persist the ack so the
192
330
  // pane doesn't flip back to done on the next tick.
@@ -195,11 +333,9 @@ export function listTeamSessions(
195
333
  } else {
196
334
  status = authority;
197
335
  }
198
- if (wantPane) {
199
- manifest = resolveAgentCommand(pane.cmd, pane.pid, processTable, {
200
- hint: pane.hint,
201
- }).manifest;
202
- }
336
+ manifest = resolveAgentCommand(pane.cmd, pane.pid, processTable, {
337
+ hint: pane.hint,
338
+ }).manifest;
203
339
  } else {
204
340
  // FALLBACK: snapshot scraping. Resolve the real agent from the pane's
205
341
  // process tree (pane_current_command alone is usually just node/bun/sh).
@@ -220,7 +356,24 @@ export function listTeamSessions(
220
356
  paneId: pane.id,
221
357
  agent: manifest && manifest.id !== "shell" ? manifest.id : null,
222
358
  status,
359
+ windowIndex: pane.windowIndex,
360
+ pid: pane.pid,
361
+ dir: pane.dir,
362
+ sessionId: pane.sessionId.length > 0 ? pane.sessionId : null,
363
+ });
364
+ // Surface per-pane agent detail (same resolved manifest/status — nothing
365
+ // re-derived). Non-agent panes yield null and are skipped. Display
366
+ // metadata (@agent_status_text/@agent_display_name) is gated on the SAME
367
+ // freshness verdict as the authority state — stale/absent stamp drops it.
368
+ const entry = buildAgentEntry({
369
+ sessionName: name,
370
+ pane,
371
+ manifest,
372
+ state: status,
373
+ since,
374
+ ...agentMetadataFor(pane, authority !== null),
223
375
  });
376
+ if (entry) agents.push(entry);
224
377
  return status;
225
378
  });
226
379
 
@@ -233,6 +386,7 @@ export function listTeamSessions(
233
386
  // `panes` and `statuses` are parallel (statuses = panes.map(...)), so
234
387
  // the pure rollup can group each pane's window with its resolved status.
235
388
  windowList: rollupWindows(panes, statuses),
389
+ agents,
236
390
  };
237
391
  });
238
392
  }
@@ -243,9 +397,14 @@ function collectPanes(): Map<string, PaneRecord[]> {
243
397
  "list-panes",
244
398
  "-a",
245
399
  "-F",
246
- // Window fields sit before pane_title so the (tab-safe) title stays the
247
- // trailing catch-all — window names don't contain tabs in practice.
248
- `#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{@agent_state}\t#{@agent_hint}\t#{${SIDEBAR_PANE_OPTION}}\t#{window_index}\t#{window_name}\t#{window_active}\t#{pane_title}`,
400
+ // Window fields + pane_current_path sit before pane_title so the (tab-safe)
401
+ // title stays the trailing catch-all — window names/paths don't contain tabs
402
+ // in practice. pane_current_path rides this SAME list-panes call (no extra
403
+ // tmux round-trip) so per-pane agent entries can carry a working dir.
404
+ // @agent_status_text/@agent_display_name ride the same caveat: the contract
405
+ // (skill/SKILL.md) says plain text; a stamped tab would shift this one
406
+ // pane's fields (sanitizeAgentText strips control chars AFTER the split).
407
+ `#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{@agent_state}\t#{@agent_hint}\t#{@agent_session_id}\t#{@agent_status_text}\t#{@agent_display_name}\t#{${SIDEBAR_PANE_OPTION}}\t#{window_index}\t#{window_name}\t#{window_active}\t#{pane_current_path}\t#{pane_title}`,
249
408
  ]);
250
409
  const bySession = new Map<string, PaneRecord[]>();
251
410
  for (const line of raw.split("\n").filter(Boolean)) {
@@ -256,10 +415,14 @@ function collectPanes(): Map<string, PaneRecord[]> {
256
415
  cmd = "",
257
416
  authority = "",
258
417
  hint = "",
418
+ sessionId = "",
419
+ statusTextRaw = "",
420
+ displayNameRaw = "",
259
421
  sidebar = "",
260
422
  windowIndex = "0",
261
423
  windowName = "",
262
424
  windowActive = "0",
425
+ dir = "",
263
426
  ...titleParts
264
427
  ] = line.split("\t");
265
428
  if (!session) continue;
@@ -270,10 +433,14 @@ function collectPanes(): Map<string, PaneRecord[]> {
270
433
  cmd,
271
434
  authority,
272
435
  hint,
436
+ sessionId,
437
+ statusTextRaw,
438
+ displayNameRaw,
273
439
  sidebar: sidebar === "1",
274
440
  windowIndex: Number(windowIndex) || 0,
275
441
  windowName,
276
442
  windowActive: windowActive === "1",
443
+ dir,
277
444
  title: titleParts.join("\t"),
278
445
  });
279
446
  bySession.set(session, list);
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The `wait` conditions — the SHARED implementation behind `tmux-ide wait
3
+ * agent-status` / `tmux-ide wait output` (bin/cli.ts) and the control
4
+ * socket's `wait` verb (src/control/). Extracted from the CLI case so the
5
+ * socket is a transport over the same logic, not a second implementation.
6
+ *
7
+ * Both loops are deps-injected (fleet lister / pane capture, clock, sleep)
8
+ * so the polling logic unit-tests without tmux; the exported defaults wire
9
+ * the real io.
10
+ */
11
+ import { capturePane } from "@tmux-ide/tmux-bridge";
12
+ import type { AgentStatus, StatusTracker } from "../detect/classify.ts";
13
+ import { createStatusTracker } from "../detect/classify.ts";
14
+ import { findSessionStatus } from "./report.ts";
15
+ import { listTeamSessions, type TeamSession } from "./sessions.ts";
16
+
17
+ /** Default overall timeout (matches the CLI's historical default). */
18
+ export const WAIT_DEFAULT_TIMEOUT_MS = 60_000;
19
+ /** Poll cadence for the agent-status wait. */
20
+ export const WAIT_STATUS_POLL_MS = 750;
21
+ /** Poll cadence for the output-match wait. */
22
+ export const WAIT_OUTPUT_POLL_MS = 500;
23
+
24
+ const sleepMs = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
25
+
26
+ /**
27
+ * PURE — test `text` (a pane capture) against `pattern`, line by line, and
28
+ * report the first matching LINE; falls back to whole-text matching (with the
29
+ * last line reported) so multi-line patterns still hit. A fresh RegExp per
30
+ * test so a user-supplied /g flag can't carry `lastIndex` between calls.
31
+ * Returns null when nothing matches.
32
+ */
33
+ export function matchOutput(text: string, pattern: string): string | null {
34
+ const lines = text.split("\n");
35
+ for (const line of lines) {
36
+ if (new RegExp(pattern).test(line)) return line;
37
+ }
38
+ if (new RegExp(pattern).test(text)) return lines[lines.length - 1] ?? "";
39
+ return null;
40
+ }
41
+
42
+ export interface WaitAgentStatusResult {
43
+ ok: boolean;
44
+ session: string;
45
+ want: AgentStatus;
46
+ /** The session's last observed status (null = session absent). */
47
+ status: AgentStatus | null;
48
+ /** Set when `ok` is false: how long we waited. */
49
+ timedOutAfterMs?: number;
50
+ }
51
+
52
+ export interface WaitAgentStatusOpts {
53
+ timeoutMs?: number;
54
+ pollMs?: number;
55
+ /**
56
+ * The tracker threaded across polls (one PERSISTS per wait so the
57
+ * cross-tick working→idle `done` transition can be observed). Injected by
58
+ * tests; defaults to a fresh tracker.
59
+ */
60
+ tracker?: StatusTracker;
61
+ listSessions?: (tracker: StatusTracker) => TeamSession[];
62
+ now?: () => number;
63
+ sleep?: (ms: number) => Promise<void>;
64
+ }
65
+
66
+ /** Block until `session` reaches `want`, or time out. Never throws. */
67
+ export async function waitForAgentStatus(
68
+ session: string,
69
+ want: AgentStatus,
70
+ opts: WaitAgentStatusOpts = {},
71
+ ): Promise<WaitAgentStatusResult> {
72
+ const timeoutMs = opts.timeoutMs ?? WAIT_DEFAULT_TIMEOUT_MS;
73
+ const pollMs = opts.pollMs ?? WAIT_STATUS_POLL_MS;
74
+ const tracker = opts.tracker ?? createStatusTracker();
75
+ const list = opts.listSessions ?? listTeamSessions;
76
+ const now = opts.now ?? Date.now;
77
+ const sleep = opts.sleep ?? sleepMs;
78
+ const started = now();
79
+
80
+ for (;;) {
81
+ const status = findSessionStatus(list(tracker), session);
82
+ if (status === want) return { ok: true, session, want, status };
83
+ if (now() - started >= timeoutMs) {
84
+ return { ok: false, session, want, status, timedOutAfterMs: timeoutMs };
85
+ }
86
+ await sleep(pollMs);
87
+ }
88
+ }
89
+
90
+ export interface WaitOutputResult {
91
+ ok: boolean;
92
+ target: string;
93
+ pattern: string;
94
+ /** The matching line when `ok`; null on timeout. */
95
+ matched: string | null;
96
+ timedOutAfterMs?: number;
97
+ }
98
+
99
+ export interface WaitOutputOpts {
100
+ timeoutMs?: number;
101
+ pollMs?: number;
102
+ /** Pane capture (defaults to tmux-bridge's `capturePane`, 200 lines). */
103
+ capture?: (target: string) => string;
104
+ now?: () => number;
105
+ sleep?: (ms: number) => Promise<void>;
106
+ }
107
+
108
+ /**
109
+ * Block until `target`'s captured output matches `pattern`, or time out.
110
+ * A capture failure (pane/session not (yet) available) keeps polling until
111
+ * the timeout. Throws only on an invalid regex — validate before looping.
112
+ */
113
+ export async function waitForOutputMatch(
114
+ target: string,
115
+ pattern: string,
116
+ opts: WaitOutputOpts = {},
117
+ ): Promise<WaitOutputResult> {
118
+ new RegExp(pattern); // an invalid pattern is a usage error, surfaced up front
119
+ const timeoutMs = opts.timeoutMs ?? WAIT_DEFAULT_TIMEOUT_MS;
120
+ const pollMs = opts.pollMs ?? WAIT_OUTPUT_POLL_MS;
121
+ const capture = opts.capture ?? defaultCapture;
122
+ const now = opts.now ?? Date.now;
123
+ const sleep = opts.sleep ?? sleepMs;
124
+ const started = now();
125
+
126
+ for (;;) {
127
+ let text = "";
128
+ try {
129
+ text = capture(target);
130
+ } catch {
131
+ // not capturable yet — keep polling
132
+ }
133
+ const matched = matchOutput(text, pattern);
134
+ if (matched !== null) return { ok: true, target, pattern, matched };
135
+ if (now() - started >= timeoutMs) {
136
+ return { ok: false, target, pattern, matched: null, timedOutAfterMs: timeoutMs };
137
+ }
138
+ await sleep(pollMs);
139
+ }
140
+ }
141
+
142
+ function defaultCapture(target: string): string {
143
+ return capturePane(target, { lines: 200 });
144
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /** Build the universal, appearance-aware native macOS notification helper. */
3
+ import { execFileSync } from "node:child_process";
4
+ import {
5
+ chmodSync,
6
+ cpSync,
7
+ mkdtempSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ if (process.platform !== "darwin") {
18
+ throw new Error("build-macos-notifier must run on macOS with Xcode 26 or newer");
19
+ }
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ const root = resolve(here, "..");
23
+ const sourceDir = join(root, "native", "macos", "notifier");
24
+ const outputFlag = process.argv.indexOf("--output");
25
+ const versionFlag = process.argv.indexOf("--version");
26
+ const appPath = resolve(
27
+ outputFlag === -1
28
+ ? join(root, "packages", "daemon", "dist", "native", "TmuxIdeNotifier.app")
29
+ : process.argv[outputFlag + 1],
30
+ );
31
+ const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
32
+ const version = String(
33
+ versionFlag === -1 ? packageJson.version || "1.0.0" : process.argv[versionFlag + 1],
34
+ );
35
+ const buildVersion = /^\d+(?:\.\d+){0,2}/.exec(version)?.[0] ?? "1";
36
+ const signingIdentity = process.env.TMUX_IDE_CODESIGN_IDENTITY?.trim() || "-";
37
+ const scratch = mkdtempSync(join(tmpdir(), "tmux-ide-notifier-"));
38
+
39
+ function run(command, args, options = {}) {
40
+ execFileSync(command, args, { stdio: "inherit", ...options });
41
+ }
42
+
43
+ function output(command, args) {
44
+ return execFileSync(command, args, { encoding: "utf8" }).trim();
45
+ }
46
+
47
+ try {
48
+ const contents = join(appPath, "Contents");
49
+ const macosDir = join(contents, "MacOS");
50
+ const resourcesDir = join(contents, "Resources");
51
+ const stagedIcon = join(scratch, "AppIcon.icon");
52
+ const armBinary = join(scratch, "tmux-ide-notifier-arm64");
53
+ const x64Binary = join(scratch, "tmux-ide-notifier-x86_64");
54
+ const universalBinary = join(macosDir, "tmux-ide-notifier");
55
+
56
+ rmSync(appPath, { recursive: true, force: true });
57
+ mkdirSync(macosDir, { recursive: true });
58
+ mkdirSync(resourcesDir, { recursive: true });
59
+ cpSync(join(sourceDir, "AppIcon.icon"), stagedIcon, { recursive: true });
60
+
61
+ const swiftArgs = (target, output) => [
62
+ "swiftc",
63
+ "-O",
64
+ "-parse-as-library",
65
+ "-swift-version",
66
+ "5",
67
+ "-target",
68
+ target,
69
+ "-framework",
70
+ "AppKit",
71
+ "-framework",
72
+ "UserNotifications",
73
+ "-framework",
74
+ "Security",
75
+ join(sourceDir, "TmuxIdeNotifier.swift"),
76
+ "-o",
77
+ output,
78
+ ];
79
+ run("xcrun", swiftArgs("arm64-apple-macos11.0", armBinary));
80
+ run("xcrun", swiftArgs("x86_64-apple-macos11.0", x64Binary));
81
+ run("xcrun", ["lipo", "-create", armBinary, x64Binary, "-output", universalBinary]);
82
+ chmodSync(universalBinary, 0o755);
83
+
84
+ run("xcrun", [
85
+ "actool",
86
+ stagedIcon,
87
+ "--compile",
88
+ resourcesDir,
89
+ "--platform",
90
+ "macosx",
91
+ "--minimum-deployment-target",
92
+ "11.0",
93
+ "--target-device",
94
+ "mac",
95
+ "--app-icon",
96
+ "AppIcon",
97
+ "--standalone-icon-behavior",
98
+ "all",
99
+ "--output-partial-info-plist",
100
+ join(scratch, "icon-partial.plist"),
101
+ "--warnings",
102
+ "--notices",
103
+ "--errors",
104
+ "--output-format",
105
+ "human-readable-text",
106
+ ]);
107
+
108
+ const plist = readFileSync(join(sourceDir, "Info.plist"), "utf8")
109
+ // Apple bundle versions are numeric even when the npm release carries a
110
+ // prerelease suffix (for example 2.8.0-beta.1).
111
+ .replaceAll("__VERSION__", buildVersion)
112
+ .replaceAll("__BUILD_VERSION__", buildVersion);
113
+ writeFileSync(join(contents, "Info.plist"), plist);
114
+ writeFileSync(join(contents, "PkgInfo"), "APPL????");
115
+
116
+ // Portable release artifacts use an ad-hoc signature plus the helper's
117
+ // native compatibility delivery. A release environment can provide a
118
+ // Developer ID identity as part of a signed + notarized distribution; that
119
+ // makes the modern UNUserNotificationCenter path eligible at runtime.
120
+ const signArgs =
121
+ signingIdentity === "-"
122
+ ? ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath]
123
+ : [
124
+ "--force",
125
+ "--deep",
126
+ "--options",
127
+ "runtime",
128
+ "--sign",
129
+ signingIdentity,
130
+ "--timestamp",
131
+ appPath,
132
+ ];
133
+ run("codesign", signArgs);
134
+ run("codesign", ["--verify", "--deep", "--strict", appPath]);
135
+
136
+ const architectures = new Set(output("xcrun", ["lipo", "-archs", universalBinary]).split(/\s+/));
137
+ for (const architecture of ["arm64", "x86_64"]) {
138
+ if (!architectures.has(architecture)) {
139
+ throw new Error(`native notifier is missing ${architecture}`);
140
+ }
141
+ }
142
+
143
+ const assetInfo = JSON.parse(
144
+ output("xcrun", ["assetutil", "--info", join(resourcesDir, "Assets.car")]),
145
+ );
146
+ const iconAppearances = new Set(
147
+ assetInfo.filter((entry) => entry.AssetType === "IconGroup").map((entry) => entry.Appearance),
148
+ );
149
+ for (const appearance of ["NSAppearanceNameAqua", "NSAppearanceNameDarkAqua"]) {
150
+ if (!iconAppearances.has(appearance)) {
151
+ throw new Error(`native notifier icon is missing ${appearance}`);
152
+ }
153
+ }
154
+
155
+ console.log(
156
+ `[build-macos-notifier] wrote ${appPath} (${[...architectures].join("+")}; Aqua+DarkAqua)`,
157
+ );
158
+ } finally {
159
+ rmSync(scratch, { recursive: true, force: true });
160
+ }
@@ -16,7 +16,9 @@
16
16
  * node-only for CI) — run `pnpm build:tui` on a machine with bun.
17
17
  *
18
18
  * Cross-compile: pass `--target bun-<os>-<arch>` (e.g. bun-linux-x64) to build
19
- * for another platform. Defaults to the host target.
19
+ * for another platform. Defaults to the host target. Pass `--outfile <path>` to
20
+ * write somewhere other than the default dist path (the release workflow uses
21
+ * this to emit per-platform artifacts side by side).
20
22
  */
21
23
 
22
24
  import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin";
@@ -27,8 +29,7 @@ import { fileURLToPath } from "node:url";
27
29
  const here = dirname(fileURLToPath(import.meta.url));
28
30
  const repoRoot = resolve(here, "..");
29
31
  const entry = resolve(repoRoot, "packages/daemon/src/tui/main.ts");
30
- const outDir = resolve(repoRoot, "packages/daemon/dist/tui");
31
- const outfile = resolve(outDir, "tmux-ide-tui");
32
+ const defaultOutDir = resolve(repoRoot, "packages/daemon/dist/tui");
32
33
 
33
34
  if (!existsSync(entry)) {
34
35
  throw new Error(`[build-tui] entry not found: ${entry}`);
@@ -38,7 +39,13 @@ const targetArg = process.argv.indexOf("--target");
38
39
  const target =
39
40
  targetArg !== -1 ? process.argv[targetArg + 1] : `bun-${process.platform}-${process.arch}`;
40
41
 
41
- mkdirSync(outDir, { recursive: true });
42
+ const outfileArg = process.argv.indexOf("--outfile");
43
+ const outfile =
44
+ outfileArg !== -1
45
+ ? resolve(process.argv[outfileArg + 1])
46
+ : resolve(defaultOutDir, "tmux-ide-tui");
47
+
48
+ mkdirSync(dirname(outfile), { recursive: true });
42
49
 
43
50
  const start = Date.now();
44
51
  const result = await Bun.build({