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
@@ -15,6 +15,7 @@ import {
15
15
  classifyInstant,
16
16
  parseAuthority,
17
17
  parseAuthorityEpoch,
18
+ sanitizeAgentText,
18
19
  type AgentStatus,
19
20
  type StatusTracker,
20
21
  } from "../detect/classify.ts";
@@ -78,6 +79,19 @@ export interface PaneAgentEntry {
78
79
  command: string;
79
80
  /** `pane_current_path` — the pane's working directory. */
80
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;
81
95
  }
82
96
 
83
97
  /**
@@ -93,6 +107,9 @@ export function buildAgentEntry(input: {
93
107
  manifest: AgentManifest | undefined;
94
108
  state: AgentStatus;
95
109
  since: number | null;
110
+ /** Already-sanitized display metadata (undefined when absent/stale). */
111
+ statusText?: string;
112
+ displayName?: string;
96
113
  }): PaneAgentEntry | null {
97
114
  const { manifest, pane } = input;
98
115
  if (!manifest || manifest.id === "shell") return null;
@@ -107,6 +124,29 @@ export function buildAgentEntry(input: {
107
124
  title: pane.title,
108
125
  command: pane.cmd,
109
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 } : {}),
110
150
  };
111
151
  }
112
152
 
@@ -138,6 +178,15 @@ export interface PaneDetail {
138
178
  paneId: string;
139
179
  agent: string | null;
140
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;
141
190
  }
142
191
 
143
192
  interface PaneRecord {
@@ -155,6 +204,12 @@ interface PaneRecord {
155
204
  authority: string;
156
205
  /** Raw `@agent_hint` pane option — forces a manifest when set. */
157
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;
158
213
  /** `window_index` — the window (tab) this pane lives in. */
159
214
  windowIndex: number;
160
215
  /** `window_name`. */
@@ -182,12 +237,13 @@ export function excludeSidebarPanes<T extends { sidebar: boolean }>(panes: T[]):
182
237
  const SEVERITY: AgentStatus[] = ["blocked", "working", "done", "idle", "unknown"];
183
238
 
184
239
  /**
185
- * Whether a session should appear in the switcher. Any `_`-prefixed session is
186
- * internal plumbing (the `_tmux-ide-chrome` updater, scratch sessions, …) and
187
- * 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.
188
244
  */
189
245
  export function isListableSession(name: string): boolean {
190
- return !name.startsWith("_");
246
+ return !name.startsWith("_") && !name.startsWith("zz-");
191
247
  }
192
248
 
193
249
  function tmux(args: string[]): string {
@@ -300,10 +356,23 @@ export function listTeamSessions(
300
356
  paneId: pane.id,
301
357
  agent: manifest && manifest.id !== "shell" ? manifest.id : null,
302
358
  status,
359
+ windowIndex: pane.windowIndex,
360
+ pid: pane.pid,
361
+ dir: pane.dir,
362
+ sessionId: pane.sessionId.length > 0 ? pane.sessionId : null,
303
363
  });
304
364
  // Surface per-pane agent detail (same resolved manifest/status — nothing
305
- // re-derived). Non-agent panes yield null and are skipped.
306
- const entry = buildAgentEntry({ sessionName: name, pane, manifest, state: status, since });
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),
375
+ });
307
376
  if (entry) agents.push(entry);
308
377
  return status;
309
378
  });
@@ -332,7 +401,10 @@ function collectPanes(): Map<string, PaneRecord[]> {
332
401
  // title stays the trailing catch-all — window names/paths don't contain tabs
333
402
  // in practice. pane_current_path rides this SAME list-panes call (no extra
334
403
  // tmux round-trip) so per-pane agent entries can carry a working dir.
335
- `#{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_current_path}\t#{pane_title}`,
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}`,
336
408
  ]);
337
409
  const bySession = new Map<string, PaneRecord[]>();
338
410
  for (const line of raw.split("\n").filter(Boolean)) {
@@ -343,6 +415,9 @@ function collectPanes(): Map<string, PaneRecord[]> {
343
415
  cmd = "",
344
416
  authority = "",
345
417
  hint = "",
418
+ sessionId = "",
419
+ statusTextRaw = "",
420
+ displayNameRaw = "",
346
421
  sidebar = "",
347
422
  windowIndex = "0",
348
423
  windowName = "",
@@ -358,6 +433,9 @@ function collectPanes(): Map<string, PaneRecord[]> {
358
433
  cmd,
359
434
  authority,
360
435
  hint,
436
+ sessionId,
437
+ statusTextRaw,
438
+ displayNameRaw,
361
439
  sidebar: sidebar === "1",
362
440
  windowIndex: Number(windowIndex) || 0,
363
441
  windowName,
@@ -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
+ }
@@ -91,7 +91,14 @@ const nextSettings = {
91
91
  },
92
92
  };
93
93
 
94
- writeFileSync(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`);
94
+ try {
95
+ writeFileSync(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`);
96
+ } catch (error) {
97
+ // Best-effort like everything above: sandboxed installs (e.g. a package
98
+ // manager building in a $HOME-restricted sandbox) may deny this write —
99
+ // that must never fail the install itself.
100
+ console.warn(`[tmux-ide] Skipping Claude settings update: ${error.message}`);
101
+ }
95
102
 
96
103
  function shouldInstallClaudeIntegration() {
97
104
  return process.env.npm_config_global === "true";
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { execFileSync } from "node:child_process";
13
- import { existsSync, statSync } from "node:fs";
13
+ import { accessSync, constants, existsSync, statSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
 
16
16
  function run(command, args) {
@@ -42,3 +42,39 @@ if (cliJsMtime < cliTsMtime) {
42
42
  "bin/cli.js is older than bin/cli.ts — run: pnpm build:cli && git add bin/cli.js",
43
43
  );
44
44
  }
45
+
46
+ // ---------------------------------------------------------------
47
+ // Native macOS notification sender — release.yml builds this on a macOS 26
48
+ // runner and injects it into packages/daemon/dist before npm publish. npm and
49
+ // Homebrew consume the same tarball, so a missing bundle would silently put
50
+ // users back on an unbranded AppleScript fallback.
51
+ // ---------------------------------------------------------------
52
+ const notifierRoot = join(
53
+ process.cwd(),
54
+ "packages",
55
+ "daemon",
56
+ "dist",
57
+ "native",
58
+ "TmuxIdeNotifier.app",
59
+ "Contents",
60
+ );
61
+ for (const relative of [
62
+ "Info.plist",
63
+ join("Resources", "Assets.car"),
64
+ join("Resources", "AppIcon.icns"),
65
+ ]) {
66
+ const path = join(notifierRoot, relative);
67
+ if (!existsSync(path)) {
68
+ throw new Error(
69
+ `native macOS notifier is incomplete (${relative} missing) — run pnpm build:macos-notifier on macOS`,
70
+ );
71
+ }
72
+ }
73
+ const notifierExecutable = join(notifierRoot, "MacOS", "tmux-ide-notifier");
74
+ try {
75
+ accessSync(notifierExecutable, constants.X_OK);
76
+ } catch {
77
+ throw new Error(
78
+ "native macOS notifier executable is missing or not executable — run pnpm build:macos-notifier on macOS",
79
+ );
80
+ }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # Publish packaging/homebrew/Formula/tmux-ide.rb to the Homebrew tap repo
3
+ # (wavyrai/homebrew-tap), i.e. the one-time seeding the CI `bump_tap` job
4
+ # assumes. After this, every release keeps the tap current automatically
5
+ # (given the TAP_PUSH_TOKEN secret — see .github/workflows/release.yml).
6
+ #
7
+ # Usage:
8
+ # scripts/publish-tap.sh [path-to-tap-checkout]
9
+ #
10
+ # With no argument, clones git@github.com:wavyrai/homebrew-tap.git into a
11
+ # temp dir. The tap repo must already exist on GitHub (create it empty —
12
+ # the name MUST be exactly `homebrew-tap` for `brew install
13
+ # wavyrai/tap/tmux-ide` to resolve).
14
+ set -euo pipefail
15
+
16
+ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
17
+ formula="$repo_root/packaging/homebrew/Formula/tmux-ide.rb"
18
+ [[ -f "$formula" ]] || {
19
+ echo "formula not found: $formula" >&2
20
+ exit 1
21
+ }
22
+
23
+ tap_dir="${1:-}"
24
+ cleanup=""
25
+ if [[ -z "$tap_dir" ]]; then
26
+ tap_dir="$(mktemp -d)/homebrew-tap"
27
+ cleanup="$(dirname "$tap_dir")"
28
+ git clone git@github.com:wavyrai/homebrew-tap.git "$tap_dir"
29
+ fi
30
+ [[ -d "$tap_dir/.git" ]] || {
31
+ echo "not a git checkout: $tap_dir" >&2
32
+ exit 1
33
+ }
34
+
35
+ version="$(sed -nE 's#^ url ".*/tmux-ide-([0-9][^"]*)\.tgz"$#\1#p' "$formula")"
36
+ [[ -n "$version" ]] || {
37
+ echo "could not read the version from the formula url" >&2
38
+ exit 1
39
+ }
40
+
41
+ mkdir -p "$tap_dir/Formula"
42
+ cp "$formula" "$tap_dir/Formula/tmux-ide.rb"
43
+
44
+ cd "$tap_dir"
45
+ git add Formula/tmux-ide.rb
46
+ if git diff --cached --quiet; then
47
+ echo "tap already has this formula version (v$version) — nothing to push."
48
+ else
49
+ git commit -m "tmux-ide $version"
50
+ git push origin HEAD
51
+ echo "pushed tmux-ide v$version to $(git remote get-url origin)"
52
+ fi
53
+
54
+ [[ -n "$cleanup" ]] && rm -rf "$cleanup"
55
+ echo "install with: brew install wavyrai/tap/tmux-ide"