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
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The control-socket server — `tmux-ide serve` (M23.3).
3
+ *
4
+ * NDJSON frames over a local Unix socket (default `~/.tmux-ide/control.sock`,
5
+ * mode 0600). Agent loops connect once and drive the fleet — request/response
6
+ * verbs plus PUSHED agent-status events after `subscribe` — instead of
7
+ * spawning a CLI process per call. Protocol schemas live in
8
+ * `@tmux-ide/contracts` (control.ts); verb handlers in `./verbs.ts` call the
9
+ * SAME data layer the CLI cases do.
10
+ *
11
+ * HOST DECISION (the tradeoff, considered before building):
12
+ * (a) `tmux-ide serve` — an EXPLICIT foreground process. Costs the user a
13
+ * deliberate start, dies with them, trivially restartable after a code
14
+ * change, and its lifetime states its purpose.
15
+ * (b) piggyback on the `_tmux-ide-chrome` updater tick — always running for
16
+ * adopted fleets, but chrome-lifecycle-coupled: unadopting the last
17
+ * session would kill the API mid-conversation, `adopt` would grow a
18
+ * network-ish responsibility, and the updater's contract ("never let a
19
+ * bad tick break the bars") is the wrong place for a protocol surface.
20
+ * (c) socket-activated per-connection handlers — zero resident cost, but
21
+ * every connection pays a full node boot (the latency the socket exists
22
+ * to remove) and no resident process means no push events and no
23
+ * persistent status tracker (the cross-tick `done` needs history).
24
+ * CHOSEN: (a), deliberately WITHOUT auto-start. The old command-center HTTP
25
+ * server is the cautionary tale: an implicitly-running daemon accretes
26
+ * scope. This surface stays minimal — session-control verbs only, no feed,
27
+ * no chat, no network listener — and the CLI only uses the socket
28
+ * OPPORTUNISTICALLY (`--socket` fast-paths fall back to polling when no
29
+ * server is up). An agent that wants the socket runs `tmux-ide serve`
30
+ * itself; the process it spawned is the process it owns.
31
+ *
32
+ * SECURITY: local user only. The socket is chmod 0600, there is no network
33
+ * transport, no tokens (filesystem permissions ARE the auth). The #90 bridge
34
+ * that exposes this to a native app layers a WS server ON TOP later — each
35
+ * NDJSON frame maps 1:1 to a WS text message, so that bridge is mechanical
36
+ * and this file never grows remote scope.
37
+ *
38
+ * EVENTS: while at least one connection is subscribed, a detection tick
39
+ * (same cadence and diff as the chrome updater, one persistent tracker)
40
+ * computes the fleet and pushes session-level status transitions. No
41
+ * subscribers → no tick → an idle server does nothing. The tick does NOT
42
+ * write events.jsonl — the chrome updater owns the log; this stream is
43
+ * transport, not history.
44
+ */
45
+ import { chmodSync, existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
46
+ import { createServer, connect, type Server, type Socket } from "node:net";
47
+ import { dirname, join } from "node:path";
48
+ import { CONTROL_PROTOCOL_VERSION, type AgentStatusEvent } from "@tmux-ide/contracts";
49
+ import { IdeError } from "../lib/errors.ts";
50
+ import { tuiStateHome } from "../lib/tui-binary.ts";
51
+ import { createStatusTracker, type AgentStatus } from "../tui/detect/classify.ts";
52
+ import { diffFleet } from "../tui/chrome/events.ts";
53
+ import { fleetStatuses, TICK_MS } from "../tui/chrome/updater.ts";
54
+ import { listTeamProjects } from "../tui/team/projects.ts";
55
+ import { dispatchLine } from "./dispatch.ts";
56
+ import { createFanout } from "./fanout.ts";
57
+ import { createFrameSplitter, encodeFrame } from "./frames.ts";
58
+ import { createVerbHandlers } from "./verbs.ts";
59
+
60
+ /** The default socket path — under the state home so `TMUX_IDE_HOME` scopes
61
+ * tests away from the real user socket. */
62
+ export function defaultControlSocketPath(): string {
63
+ return join(tuiStateHome(), "control.sock");
64
+ }
65
+
66
+ export interface ControlServerOptions {
67
+ socketPath?: string;
68
+ /** Diagnostics sink (the CLI passes stderr). Default: silent. */
69
+ log?: (message: string) => void;
70
+ /** Event-tick cadence override (tests); defaults to the updater's TICK_MS. */
71
+ tickMs?: number;
72
+ }
73
+
74
+ export interface ControlServer {
75
+ socketPath: string;
76
+ close(): Promise<void>;
77
+ }
78
+
79
+ /**
80
+ * Claim `path`: refuse anything that exists and is not a socket (NEVER
81
+ * unlink a foreign file), refuse a socket another live server answers on,
82
+ * and unlink a stale socket left by a dead server.
83
+ */
84
+ async function claimSocketPath(path: string): Promise<void> {
85
+ if (!existsSync(path)) return;
86
+ if (!statSync(path).isSocket()) {
87
+ throw new IdeError(
88
+ `${path} exists and is not a socket — refusing to remove it. ` +
89
+ `Pass a different --socket path.`,
90
+ { code: "USAGE", exitCode: 1 },
91
+ );
92
+ }
93
+ const alive = await new Promise<boolean>((resolve) => {
94
+ const probe: Socket = connect(path);
95
+ const done = (result: boolean) => {
96
+ probe.destroy();
97
+ resolve(result);
98
+ };
99
+ probe.once("connect", () => done(true));
100
+ probe.once("error", () => done(false));
101
+ probe.setTimeout(500, () => done(false));
102
+ });
103
+ if (alive) {
104
+ throw new IdeError(`another server is already listening on ${path}`, {
105
+ code: "USAGE",
106
+ exitCode: 1,
107
+ });
108
+ }
109
+ unlinkSync(path); // stale socket from a dead server — safe to rebind
110
+ }
111
+
112
+ /** Start the server. Resolves once the socket is listening (mode 0600). */
113
+ export async function startControlServer(opts: ControlServerOptions = {}): Promise<ControlServer> {
114
+ const socketPath = opts.socketPath ?? defaultControlSocketPath();
115
+ const log = opts.log ?? (() => {});
116
+ const tickMs = opts.tickMs ?? TICK_MS;
117
+
118
+ mkdirSync(dirname(socketPath), { recursive: true });
119
+ await claimSocketPath(socketPath);
120
+
121
+ // ONE tracker for the server's lifetime, shared by the verbs and the event
122
+ // tick — cross-tick `done` (working→idle) is only observable with history.
123
+ const tracker = createStatusTracker();
124
+ const handlers = createVerbHandlers({ tracker });
125
+
126
+ // The event tick: runs ONLY while subscribers exist (fanout edges), diffs
127
+ // the fleet exactly like the chrome updater, pushes transitions.
128
+ const prevState = new Map<string, AgentStatus>();
129
+ let timer: ReturnType<typeof setInterval> | null = null;
130
+ const tick = (): void => {
131
+ try {
132
+ const { events, state } = diffFleet(prevState, fleetStatuses(listTeamProjects(tracker)));
133
+ prevState.clear();
134
+ for (const [name, status] of state) prevState.set(name, status);
135
+ const ts = new Date().toISOString();
136
+ for (const ev of events) fanout.emit({ ts, ...ev });
137
+ } catch (err) {
138
+ log(`event tick failed: ${(err as Error).message}`);
139
+ }
140
+ };
141
+ const fanout = createFanout<AgentStatusEvent>({
142
+ onFirst: () => {
143
+ tick(); // seed immediately — the first events carry the current fleet (from: null)
144
+ timer = setInterval(tick, tickMs);
145
+ },
146
+ onLast: () => {
147
+ if (timer) clearInterval(timer);
148
+ timer = null;
149
+ prevState.clear();
150
+ },
151
+ });
152
+
153
+ const connections = new Set<Socket>();
154
+ const server: Server = createServer((conn) => {
155
+ connections.add(conn);
156
+ conn.setEncoding("utf8");
157
+ const split = createFrameSplitter();
158
+ let unsubscribe: (() => void) | null = null;
159
+
160
+ const push = (ev: AgentStatusEvent): void => {
161
+ // One write per frame — JSON.stringify never contains a raw newline,
162
+ // so frames from concurrent requests can never interleave mid-message.
163
+ conn.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, event: "agent-status", data: ev }));
164
+ };
165
+ const ctx = {
166
+ subscribe: () => {
167
+ unsubscribe ??= fanout.add(push);
168
+ },
169
+ };
170
+
171
+ conn.on("data", (chunk: string) => {
172
+ let lines: string[];
173
+ try {
174
+ lines = split(chunk);
175
+ } catch {
176
+ conn.destroy(); // frame overflow — a broken client, not a slow one
177
+ return;
178
+ }
179
+ for (const line of lines) {
180
+ // Handled CONCURRENTLY: a long `wait` must not block this
181
+ // connection's other requests (responses correlate by id).
182
+ void dispatchLine(line, handlers, ctx).then((response) => {
183
+ if (!conn.destroyed) conn.write(encodeFrame(response));
184
+ });
185
+ }
186
+ });
187
+ conn.on("close", () => {
188
+ unsubscribe?.();
189
+ connections.delete(conn);
190
+ });
191
+ conn.on("error", () => {
192
+ // close follows; nothing to do — never let a client error kill serve
193
+ });
194
+ });
195
+
196
+ await new Promise<void>((resolve, reject) => {
197
+ server.once("error", (err: NodeJS.ErrnoException) => {
198
+ // Unix sockets cap the path at ~104 bytes (macOS sun_path) — surfaced
199
+ // as a bare EINVAL. Say what actually went wrong and how to fix it.
200
+ if ((err.code === "EINVAL" || err.code === "ENAMETOOLONG") && socketPath.length > 100) {
201
+ reject(
202
+ new IdeError(
203
+ `socket path is too long for a Unix socket (${socketPath.length} chars; the OS caps it around 104): ${socketPath}\n` +
204
+ `Pass a shorter path: tmux-ide serve --socket /tmp/tmux-ide-control.sock`,
205
+ { code: "USAGE", exitCode: 1 },
206
+ ),
207
+ );
208
+ return;
209
+ }
210
+ reject(err);
211
+ });
212
+ server.listen(socketPath, () => {
213
+ server.removeAllListeners("error");
214
+ resolve();
215
+ });
216
+ });
217
+ chmodSync(socketPath, 0o600);
218
+ log(`listening on ${socketPath}`);
219
+
220
+ return {
221
+ socketPath,
222
+ close: () =>
223
+ new Promise<void>((resolve) => {
224
+ if (timer) clearInterval(timer);
225
+ timer = null;
226
+ for (const conn of connections) conn.destroy(); // clients see EOF
227
+ server.close(() => {
228
+ try {
229
+ unlinkSync(socketPath);
230
+ } catch {
231
+ // already gone
232
+ }
233
+ resolve();
234
+ });
235
+ }),
236
+ };
237
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The v1 verb handlers — each one a THIN adapter from validated params to
3
+ * the SAME data-layer functions the CLI cases call (report/sessions/wait/
4
+ * send/agent-explain/lifecycle). No fleet logic lives here; if a verb needs
5
+ * logic a CLI case has inline, the logic moves to the data layer and both
6
+ * point at it (that's how `wait` and `send` got their shared cores).
7
+ */
8
+ import {
9
+ agentsParamsSchema,
10
+ explainParamsSchema,
11
+ restartAgentParamsSchema,
12
+ sendParamsSchema,
13
+ spawnParamsSchema,
14
+ stopAgentParamsSchema,
15
+ waitParamsSchema,
16
+ } from "@tmux-ide/contracts";
17
+ import type { ZodType } from "zod";
18
+ import { buildReport } from "../agent-explain.ts";
19
+ import { deliverMessage } from "../send.ts";
20
+ import type { StatusTracker } from "../tui/detect/classify.ts";
21
+ import { toFleetJson } from "../tui/team/report.ts";
22
+ import { listTeamProjects } from "../tui/team/projects.ts";
23
+ import { listTeamSessions } from "../tui/team/sessions.ts";
24
+ import { waitForAgentStatus, waitForOutputMatch } from "../tui/team/wait.ts";
25
+ import { ControlVerbError, type VerbHandler } from "./dispatch.ts";
26
+ import { resolveLaunchCommand, restartAgent, spawnAgent, stopAgent } from "./lifecycle.ts";
27
+
28
+ /** Validate `params` against a verb's schema, or answer `bad-request`. */
29
+ function parse<T>(schema: ZodType<T>, params: unknown): T {
30
+ const result = schema.safeParse(params);
31
+ if (!result.success) {
32
+ const issue = result.error.issues[0];
33
+ const at = issue?.path?.length ? ` at ${issue.path.join(".")}` : "";
34
+ throw new ControlVerbError("bad-request", `invalid params${at}: ${issue?.message ?? "?"}`);
35
+ }
36
+ return result.data;
37
+ }
38
+
39
+ /**
40
+ * Build the handler map. `tracker` is the server's ONE persistent status
41
+ * tracker (shared with the event tick) so `fleet`/`agents` see the
42
+ * cross-tick `done` transition exactly like the chrome updater does —
43
+ * a fresh tracker per call could never observe working→idle.
44
+ */
45
+ export function createVerbHandlers(ctx: { tracker: StatusTracker }): Record<string, VerbHandler> {
46
+ return {
47
+ fleet: () => toFleetJson(listTeamProjects(ctx.tracker)),
48
+
49
+ agents: (params) => {
50
+ const p = parse(agentsParamsSchema, params);
51
+ const sessions = listTeamSessions(ctx.tracker);
52
+ const scoped = p.session ? sessions.filter((s) => s.name === p.session) : sessions;
53
+ if (p.session && scoped.length === 0) {
54
+ throw new ControlVerbError("not-found", `no session "${p.session}"`);
55
+ }
56
+ return { agents: scoped.flatMap((s) => s.agents ?? []) };
57
+ },
58
+
59
+ send: (params) => {
60
+ const p = parse(sendParamsSchema, params);
61
+ return deliverMessage(p);
62
+ },
63
+
64
+ wait: async (params) => {
65
+ const p = parse(waitParamsSchema, params);
66
+ if (p.kind === "output") {
67
+ try {
68
+ new RegExp(p.match);
69
+ } catch (err) {
70
+ throw new ControlVerbError(
71
+ "bad-request",
72
+ `invalid match regex: ${(err as Error).message}`,
73
+ );
74
+ }
75
+ }
76
+ const result =
77
+ p.kind === "agent-status"
78
+ ? await waitForAgentStatus(p.session, p.status, { timeoutMs: p.timeoutMs })
79
+ : await waitForOutputMatch(p.target, p.match, { timeoutMs: p.timeoutMs });
80
+ if (!result.ok) {
81
+ const what =
82
+ p.kind === "agent-status"
83
+ ? `"${p.session}" to reach status "${p.status}"`
84
+ : `${p.target} output to match /${p.match}/`;
85
+ throw new ControlVerbError(
86
+ "timeout",
87
+ `timed out after ${result.timedOutAfterMs}ms waiting for ${what}`,
88
+ );
89
+ }
90
+ return result;
91
+ },
92
+
93
+ spawn: (params) => {
94
+ const p = parse(spawnParamsSchema, params);
95
+ return spawnAgent({ ...p, command: resolveLaunchCommand(p) });
96
+ },
97
+
98
+ "restart-agent": (params) => {
99
+ const p = parse(restartAgentParamsSchema, params);
100
+ return restartAgent(p.paneId, resolveLaunchCommand(p));
101
+ },
102
+
103
+ "stop-agent": (params) => {
104
+ const p = parse(stopAgentParamsSchema, params);
105
+ return stopAgent(p.paneId);
106
+ },
107
+
108
+ explain: (params) => {
109
+ const p = parse(explainParamsSchema, params);
110
+ return buildReport(p.target);
111
+ },
112
+
113
+ subscribe: (_params, verbCtx) => {
114
+ verbCtx.subscribe();
115
+ return { subscribed: true, events: ["agent-status"] };
116
+ },
117
+ };
118
+ }
@@ -1,11 +1,13 @@
1
1
  import { execSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { accessSync, constants, existsSync } from "node:fs";
3
3
  import { resolve, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { getCurrentVersion, getUpdateStatus } from "./lib/update-check.ts";
6
6
  import { installedSkillVersion } from "./lib/skill-sync.ts";
7
7
  import { discoverAgents, presentAgents, type DiscoveredAgent } from "./lib/agent-discovery.ts";
8
8
  import { findCompiledTui, isBunAvailable } from "./tui/compiled.ts";
9
+ import { claudeSettingsPath } from "./tui/integrations/claude.ts";
10
+ import { readNotificationPrefs, resolveNativeMacosNotifierPath } from "./tui/chrome/notify.ts";
9
11
 
10
12
  interface CheckResult {
11
13
  label: string;
@@ -27,12 +29,19 @@ export function agentIntegrationRows(agents: DiscoveredAgent[]): CheckResult[] {
27
29
  return presentAgents(agents).map((agent) => {
28
30
  const label = `agent: ${agent.id}`;
29
31
  if (agent.integration) {
32
+ // What installing actually buys, honestly per mechanism: claude's hooks
33
+ // give ground-truth status (and record resume ids); opencode's plugin
34
+ // records resume ids only.
35
+ const benefit =
36
+ agent.capture === "hooks"
37
+ ? "for ground-truth status"
38
+ : "to record session ids for restore --resume-agents";
30
39
  return agent.installed
31
40
  ? { label, pass: true, detail: "integration installed ✓", optional: true }
32
41
  : {
33
42
  label,
34
43
  pass: false,
35
- detail: `found on PATH — run \`tmux-ide integration install ${agent.id}\` for ground-truth status`,
44
+ detail: `found on PATH — run \`tmux-ide integration install ${agent.id}\` ${benefit}`,
36
45
  optional: true,
37
46
  };
38
47
  }
@@ -45,6 +54,59 @@ export function agentIntegrationRows(agents: DiscoveredAgent[]): CheckResult[] {
45
54
  });
46
55
  }
47
56
 
57
+ /**
58
+ * PURE — the "Claude hooks target writable" row from observed facts. Both
59
+ * `integration install claude` and the npm postinstall write the settings
60
+ * file; surface a permissions problem BEFORE an install fails halfway.
61
+ * Optional: a machine without Claude Code shouldn't fail doctor over this.
62
+ */
63
+ export function hooksTargetRow(facts: {
64
+ settingsPath: string;
65
+ fileExists: boolean;
66
+ writable: boolean;
67
+ }): CheckResult {
68
+ const label = "Claude hooks target writable";
69
+ if (facts.writable) {
70
+ return {
71
+ label,
72
+ pass: true,
73
+ detail: facts.fileExists ? facts.settingsPath : `${facts.settingsPath} (will be created)`,
74
+ optional: true,
75
+ };
76
+ }
77
+ return {
78
+ label,
79
+ pass: false,
80
+ detail: `cannot write ${facts.settingsPath} — fix its permissions (chown/chmod), or point TMUX_IDE_CLAUDE_SETTINGS at a writable path`,
81
+ optional: true,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * PURE — native notifier health, shown only when macOS notifications are ON.
87
+ * Release packages always include the helper; absence means an incomplete or
88
+ * old installation, which can still fall back to the unbranded AppleScript path.
89
+ * Optional — informational, never fails doctor.
90
+ */
91
+ export function notifierRow(present: boolean): CheckResult {
92
+ const label = "native macOS notifications";
93
+ if (present) {
94
+ return {
95
+ label,
96
+ pass: true,
97
+ detail: "bundled — branded banners and click-to-jump are ready",
98
+ optional: true,
99
+ };
100
+ }
101
+ return {
102
+ label,
103
+ pass: false,
104
+ detail:
105
+ "native helper missing — reinstall tmux-ide; unbranded AppleScript banners remain available",
106
+ optional: true,
107
+ };
108
+ }
109
+
48
110
  function check(
49
111
  label: string,
50
112
  fn: () => string,
@@ -67,7 +129,13 @@ export async function doctor({
67
129
 
68
130
  checks.push(
69
131
  check("tmux installed", () => {
70
- execSync("which tmux", { stdio: "ignore" });
132
+ try {
133
+ execSync("which tmux", { stdio: "ignore" });
134
+ } catch {
135
+ throw new Error(
136
+ "not found on PATH — install it (macOS: `brew install tmux`; Debian/Ubuntu: `sudo apt install tmux`)",
137
+ );
138
+ }
71
139
  return "found";
72
140
  }),
73
141
  );
@@ -153,37 +221,49 @@ export async function doctor({
153
221
  ),
154
222
  );
155
223
 
156
- checks.push(
224
+ // Optional tunnel CLIs: probe quietly (a missing binary must not leak
225
+ // "command not found" stderr into doctor's own output) and fail with a
226
+ // plain hint rather than execSync's raw error.
227
+ const tunnelCli = (label: string, cmd: string): CheckResult =>
157
228
  check(
158
- "tailscale CLI",
159
- () => {
160
- const version = execSync("tailscale version", { encoding: "utf-8" }).trim().split("\n")[0]!;
161
- return version;
162
- },
163
- { optional: true },
164
- ),
165
- );
166
-
167
- checks.push(
168
- check(
169
- "ngrok CLI",
229
+ label,
170
230
  () => {
171
- const version = execSync("ngrok version", { encoding: "utf-8" }).trim();
172
- return version;
231
+ try {
232
+ return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] })
233
+ .trim()
234
+ .split("\n")[0]!;
235
+ } catch {
236
+ throw new Error("not found (optional — used for remote access tunnels)");
237
+ }
173
238
  },
174
239
  { optional: true },
175
- ),
176
- );
240
+ );
241
+ checks.push(tunnelCli("tailscale CLI", "tailscale version"));
242
+ checks.push(tunnelCli("ngrok CLI", "ngrok version"));
243
+ checks.push(tunnelCli("cloudflared CLI", "cloudflared --version"));
177
244
 
178
245
  checks.push(
179
- check(
180
- "cloudflared CLI",
181
- () => {
182
- const version = execSync("cloudflared --version", { encoding: "utf-8" }).trim();
183
- return version;
184
- },
185
- { optional: true },
186
- ),
246
+ (() => {
247
+ // Claude hooks target: the settings file `integration install claude`
248
+ // (and the npm postinstall) will write. Writability of the file itself
249
+ // when it exists, else of its nearest existing ancestor directory.
250
+ const settingsPath = claudeSettingsPath();
251
+ const fileExists = existsSync(settingsPath);
252
+ let probe = fileExists ? settingsPath : dirname(settingsPath);
253
+ while (!existsSync(probe)) {
254
+ const parent = dirname(probe);
255
+ if (parent === probe) break;
256
+ probe = parent;
257
+ }
258
+ let writable = false;
259
+ try {
260
+ accessSync(probe, constants.W_OK);
261
+ writable = true;
262
+ } catch {
263
+ // leave writable=false — the row explains the fix
264
+ }
265
+ return hooksTargetRow({ settingsPath, fileExists, writable });
266
+ })(),
187
267
  );
188
268
 
189
269
  checks.push(
@@ -222,6 +302,11 @@ export async function doctor({
222
302
  ),
223
303
  );
224
304
 
305
+ // Native branded sender: only when the macOS channel is actually on.
306
+ if (process.platform === "darwin" && readNotificationPrefs().macos) {
307
+ checks.push(notifierRow(resolveNativeMacosNotifierPath() !== null));
308
+ }
309
+
225
310
  // Agent integrations: one row per agent discovered on PATH (absent → no row).
226
311
  checks.push(...agentIntegrationRows(discoverAgents()));
227
312
 
@@ -17,6 +17,21 @@
17
17
  */
18
18
  import { execFileSync } from "node:child_process";
19
19
  import { claudeIntegrationStatus } from "../tui/integrations/claude.ts";
20
+ import { opencodeIntegrationStatus } from "../tui/integrations/opencode.ts";
21
+
22
+ /**
23
+ * How a kind's `@agent_session_id` (the `restore --resume-agents` key) gets
24
+ * captured:
25
+ * - `"hooks"` — the agent's own lifecycle hooks stamp it (claude; needs
26
+ * `integration install`).
27
+ * - `"plugin"` — an in-process plugin stamps it (opencode; needs
28
+ * `integration install`).
29
+ * - `"probe"` — the chrome updater discovers it from the agent's own on-disk
30
+ * session state (codex, cursor; automatic, nothing to install).
31
+ * - `null` — no defensible capture surface (see
32
+ * {@link ../tui/detect/session-id.ts} for the per-kind evidence).
33
+ */
34
+ export type CaptureMechanism = "hooks" | "plugin" | "probe" | null;
20
35
 
21
36
  /** A coding agent tmux-ide knows how to detect. */
22
37
  export interface KnownAgent {
@@ -26,19 +41,24 @@ export interface KnownAgent {
26
41
  bin: string;
27
42
  /** True → tmux-ide ships a lifecycle-integration installer for this agent. */
28
43
  integration: boolean;
44
+ /** How this kind's session id is captured for `restore --resume-agents`. */
45
+ capture: CaptureMechanism;
29
46
  }
30
47
 
31
48
  /**
32
49
  * The agents tmux-ide recognizes. `integration: true` means we HAVE an installer
33
- * (a real lifecycle hook → ground-truth pane state); the rest are detected via
34
- * screen-manifest scraping only, with no lifecycle hook yet.
50
+ * (claude's lifecycle hooks, opencode's plugin); the rest are detected via
51
+ * screen-manifest scraping only. `capture` records each kind's session-id
52
+ * story ({@link CaptureMechanism}).
35
53
  */
36
54
  export const KNOWN_AGENTS: readonly KnownAgent[] = [
37
- { id: "claude", bin: "claude", integration: true },
38
- { id: "codex", bin: "codex", integration: false },
39
- { id: "opencode", bin: "opencode", integration: false },
40
- { id: "gemini", bin: "gemini", integration: false },
41
- { id: "aider", bin: "aider", integration: false },
55
+ { id: "claude", bin: "claude", integration: true, capture: "hooks" },
56
+ { id: "codex", bin: "codex", integration: false, capture: "probe" },
57
+ { id: "opencode", bin: "opencode", integration: true, capture: "plugin" },
58
+ { id: "gemini", bin: "gemini", integration: false, capture: null },
59
+ { id: "aider", bin: "aider", integration: false, capture: null },
60
+ { id: "cursor", bin: "cursor-agent", integration: false, capture: "probe" },
61
+ { id: "copilot", bin: "copilot", integration: false, capture: null },
42
62
  ];
43
63
 
44
64
  /** One probed agent: its registry facts plus what the PATH/integration probe found. */
@@ -55,6 +75,15 @@ export interface DiscoveredAgent {
55
75
  * integrate (there's nothing to install) and for any agent absent from PATH.
56
76
  */
57
77
  installed: boolean;
78
+ /** How this kind's session id is captured (copied from the registry). */
79
+ capture: CaptureMechanism;
80
+ /**
81
+ * Whether session-id capture is LIVE for this kind on this machine:
82
+ * `"probe"` capture is automatic whenever the binary is present; hook/plugin
83
+ * capture requires the integration to be installed; `null` capture is never
84
+ * active.
85
+ */
86
+ captureActive: boolean;
58
87
  }
59
88
 
60
89
  /** Resolve a binary to its absolute path, or null. Must never throw. */
@@ -84,14 +113,15 @@ const defaultWhich: WhichRunner = (bin) => {
84
113
  };
85
114
 
86
115
  /**
87
- * Default integration probe: only `claude` has an installer, so only it can be
88
- * "installed". Reads the real Claude settings; any failure degrades to false so
89
- * discovery never throws.
116
+ * Default integration probe: claude's hooks and opencode's plugin are the two
117
+ * shipped installers. Reads the real settings/plugin file; any failure degrades
118
+ * to false so discovery never throws.
90
119
  */
91
120
  const defaultIntegrationProbe: IntegrationProbe = (agentId) => {
92
- if (agentId !== "claude") return false;
93
121
  try {
94
- return claudeIntegrationStatus().installed;
122
+ if (agentId === "claude") return claudeIntegrationStatus().installed;
123
+ if (agentId === "opencode") return opencodeIntegrationStatus().installed;
124
+ return false;
95
125
  } catch {
96
126
  return false;
97
127
  }
@@ -111,7 +141,17 @@ export function discoverAgents(
111
141
  const path = which(agent.bin);
112
142
  const present = path !== null;
113
143
  const installed = present && agent.integration ? isInstalled(agent.id) : false;
114
- return { id: agent.id, bin: agent.bin, integration: agent.integration, path, installed };
144
+ const captureActive =
145
+ agent.capture === "probe" ? present : agent.capture !== null ? installed : false;
146
+ return {
147
+ id: agent.id,
148
+ bin: agent.bin,
149
+ integration: agent.integration,
150
+ path,
151
+ installed,
152
+ capture: agent.capture,
153
+ captureActive,
154
+ };
115
155
  });
116
156
  }
117
157