tmux-ide 1.2.1 → 2.0.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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,500 @@
1
+ import { describe, it, beforeEach, afterEach, mock, expect } from "bun:test";
2
+ import {
3
+ _setExecutor,
4
+ _setSpawner,
5
+ TmuxError,
6
+ getSessionState,
7
+ hasSession,
8
+ killSession,
9
+ listPanes,
10
+ createDetachedSession,
11
+ splitPane,
12
+ sendLiteral,
13
+ getSessionVariable,
14
+ setSessionVariable,
15
+ startSessionMonitor,
16
+ stopSessionMonitor,
17
+ setPaneTitle,
18
+ selectPane,
19
+ getPaneCurrentCommand,
20
+ setSessionEnvironment,
21
+ attachSession,
22
+ runSessionCommand,
23
+ } from "./tmux.ts";
24
+
25
+ let mockExec;
26
+ let restoreExec;
27
+
28
+ beforeEach(() => {
29
+ mockExec = mock();
30
+ restoreExec = _setExecutor(mockExec);
31
+ });
32
+
33
+ afterEach(() => {
34
+ restoreExec();
35
+ });
36
+
37
+ // --- Helpers ---
38
+
39
+ function makeExecError(stderr) {
40
+ const err = new Error("Command failed");
41
+ err.stderr = stderr;
42
+ return err;
43
+ }
44
+
45
+ // --- Error classification (via public API) ---
46
+
47
+ describe("getSessionState", () => {
48
+ it("returns running: true when has-session succeeds", () => {
49
+ mockExec.mockImplementation(() => "");
50
+ const result = getSessionState("my-session");
51
+ expect(result).toEqual({ running: true, reason: null });
52
+ expect(mockExec.mock.calls[0][1]).toEqual(["has-session", "-t", "my-session"]);
53
+ });
54
+
55
+ it('returns SESSION_NOT_FOUND for "can\'t find session"', () => {
56
+ mockExec.mockImplementation(() => {
57
+ throw makeExecError("can't find session: my-session");
58
+ });
59
+ const result = getSessionState("my-session");
60
+ expect(result).toEqual({ running: false, reason: "SESSION_NOT_FOUND" });
61
+ });
62
+
63
+ it('returns TMUX_UNAVAILABLE for "connection refused"', () => {
64
+ mockExec.mockImplementation(() => {
65
+ throw makeExecError("error connecting to /tmp/tmux-1000/default (connection refused)");
66
+ });
67
+ const result = getSessionState("my-session");
68
+ expect(result).toEqual({ running: false, reason: "TMUX_UNAVAILABLE" });
69
+ });
70
+
71
+ it('returns TMUX_UNAVAILABLE for "no server running"', () => {
72
+ mockExec.mockImplementation(() => {
73
+ throw makeExecError("no server running on /tmp/tmux-1000/default");
74
+ });
75
+ const result = getSessionState("my-session");
76
+ expect(result).toEqual({ running: false, reason: "TMUX_UNAVAILABLE" });
77
+ });
78
+
79
+ it("throws for unknown errors", () => {
80
+ mockExec.mockImplementation(() => {
81
+ throw makeExecError("something totally unexpected");
82
+ });
83
+ expect(() => getSessionState("my-session")).toThrow(TmuxError);
84
+ });
85
+ });
86
+
87
+ describe("hasSession", () => {
88
+ it("returns true when session exists", () => {
89
+ mockExec.mockImplementation(() => "");
90
+ expect(hasSession("proj")).toBe(true);
91
+ });
92
+
93
+ it("returns false for SESSION_NOT_FOUND", () => {
94
+ mockExec.mockImplementation(() => {
95
+ throw makeExecError("can't find session: proj");
96
+ });
97
+ expect(hasSession("proj")).toBe(false);
98
+ });
99
+
100
+ it("returns false for TMUX_UNAVAILABLE", () => {
101
+ mockExec.mockImplementation(() => {
102
+ throw makeExecError("no server running on /tmp/tmux-1000/default");
103
+ });
104
+ expect(hasSession("proj")).toBe(false);
105
+ });
106
+
107
+ it("throws for unknown errors", () => {
108
+ mockExec.mockImplementation(() => {
109
+ throw makeExecError("unexpected failure");
110
+ });
111
+ expect(() => hasSession("proj")).toThrow(TmuxError);
112
+ });
113
+ });
114
+
115
+ describe("killSession", () => {
116
+ it("returns stopped: true when kill succeeds", () => {
117
+ mockExec.mockImplementation(() => "");
118
+ const result = killSession("proj");
119
+ expect(result).toEqual({ stopped: true, reason: null });
120
+ expect(mockExec.mock.calls[0][1]).toEqual(["kill-session", "-t", "proj"]);
121
+ });
122
+
123
+ it("returns stopped: false for missing session", () => {
124
+ mockExec.mockImplementation(() => {
125
+ throw makeExecError("can't find session: proj");
126
+ });
127
+ const result = killSession("proj");
128
+ expect(result).toEqual({ stopped: false, reason: "SESSION_NOT_FOUND" });
129
+ });
130
+
131
+ it("returns stopped: false for unavailable tmux", () => {
132
+ mockExec.mockImplementation(() => {
133
+ throw makeExecError("failed to connect to server");
134
+ });
135
+ const result = killSession("proj");
136
+ expect(result).toEqual({ stopped: false, reason: "TMUX_UNAVAILABLE" });
137
+ });
138
+
139
+ it("throws for unknown errors", () => {
140
+ mockExec.mockImplementation(() => {
141
+ throw makeExecError("disk error");
142
+ });
143
+ expect(() => killSession("proj")).toThrow(TmuxError);
144
+ });
145
+ });
146
+
147
+ // --- listPanes ---
148
+
149
+ describe("listPanes", () => {
150
+ it("parses multi-pane output correctly", () => {
151
+ mockExec.mockImplementation(() => "0|Editor|120|40|1\n1|Shell|80|40|0\n");
152
+ const panes = listPanes("proj");
153
+ expect(panes).toEqual([
154
+ { index: 0, title: "Editor", width: 120, height: 40, active: true },
155
+ { index: 1, title: "Shell", width: 80, height: 40, active: false },
156
+ ]);
157
+ });
158
+
159
+ it("returns empty array for empty output", () => {
160
+ mockExec.mockImplementation(() => " \n");
161
+ const panes = listPanes("proj");
162
+ expect(panes).toEqual([]);
163
+ });
164
+
165
+ it("handles pane titles containing pipe characters", () => {
166
+ // The split("|") will split on the first pipe in the title, causing misparse.
167
+ // This documents the current behavior — titles with pipes are truncated.
168
+ mockExec.mockImplementation(() => "0|A|B|120|40|0\n");
169
+ const panes = listPanes("proj");
170
+ // With pipe in title, index=0, title="A", width=NaN (from "B"), ...
171
+ expect(panes[0].index).toBe(0);
172
+ expect(panes[0].title).toBe("A");
173
+ });
174
+ });
175
+
176
+ // --- createDetachedSession ---
177
+
178
+ describe("createDetachedSession", () => {
179
+ it("returns trimmed pane ID", () => {
180
+ mockExec.mockImplementation(() => " %0\n");
181
+ const id = createDetachedSession("proj", "/workspace");
182
+ expect(id).toBe("%0");
183
+ });
184
+
185
+ it("uses default dimensions when not specified", () => {
186
+ mockExec.mockImplementation(() => "%0\n");
187
+ createDetachedSession("proj", "/workspace");
188
+ const args = mockExec.mock.calls[0][1];
189
+ expect(args.includes("200")).toBeTruthy(); // default cols
190
+ expect(args.includes("50")).toBeTruthy(); // default lines
191
+ });
192
+
193
+ it("passes custom dimensions", () => {
194
+ mockExec.mockImplementation(() => "%0\n");
195
+ createDetachedSession("proj", "/workspace", { cols: 300, lines: 80 });
196
+ const args = mockExec.mock.calls[0][1];
197
+ expect(args.includes("300")).toBeTruthy();
198
+ expect(args.includes("80")).toBeTruthy();
199
+ });
200
+ });
201
+
202
+ // --- splitPane ---
203
+
204
+ describe("splitPane", () => {
205
+ it("uses -v for vertical direction", () => {
206
+ mockExec.mockImplementation(() => "%1\n");
207
+ splitPane("%0", "vertical", "/workspace", 30);
208
+ const args = mockExec.mock.calls[0][1];
209
+ expect(args.includes("-v")).toBeTruthy();
210
+ expect(!args.includes("-h")).toBeTruthy();
211
+ });
212
+
213
+ it("uses -h for horizontal direction", () => {
214
+ mockExec.mockImplementation(() => "%2\n");
215
+ splitPane("%0", "horizontal", "/workspace", 50);
216
+ const args = mockExec.mock.calls[0][1];
217
+ expect(args.includes("-h")).toBeTruthy();
218
+ expect(!args.includes("-v")).toBeTruthy();
219
+ });
220
+
221
+ it("passes percent correctly", () => {
222
+ mockExec.mockImplementation(() => "%3\n");
223
+ splitPane("%0", "vertical", "/workspace", 42);
224
+ const args = mockExec.mock.calls[0][1];
225
+ const pIdx = args.indexOf("-p");
226
+ expect(args[pIdx + 1]).toBe("42");
227
+ });
228
+
229
+ it("returns trimmed pane ID", () => {
230
+ mockExec.mockImplementation(() => " %5\n");
231
+ const id = splitPane("%0", "vertical", "/workspace", 30);
232
+ expect(id).toBe("%5");
233
+ });
234
+ });
235
+
236
+ // --- sendLiteral ---
237
+
238
+ describe("sendLiteral", () => {
239
+ it("sends text with -l flag then Enter", () => {
240
+ mockExec.mockImplementation(() => "");
241
+ sendLiteral("%0", "echo hello");
242
+ expect(mockExec.mock.calls.length).toBe(2);
243
+ // First call: send-keys with -l and the text
244
+ const firstArgs = mockExec.mock.calls[0][1];
245
+ expect(firstArgs).toEqual(["send-keys", "-t", "%0", "-l", "--", "echo hello"]);
246
+ // Second call: send Enter
247
+ const secondArgs = mockExec.mock.calls[1][1];
248
+ expect(secondArgs).toEqual(["send-keys", "-t", "%0", "Enter"]);
249
+ });
250
+ });
251
+
252
+ // --- getSessionVariable / setSessionVariable ---
253
+
254
+ describe("getSessionVariable", () => {
255
+ it("returns trimmed value when variable exists", () => {
256
+ mockExec.mockImplementation(() => " some-value\n");
257
+ const value = getSessionVariable("proj", "@my_var");
258
+ expect(value).toBe("some-value");
259
+ });
260
+
261
+ it("returns null when variable is empty", () => {
262
+ mockExec.mockImplementation(() => "\n");
263
+ const value = getSessionVariable("proj", "@my_var");
264
+ expect(value).toBe(null);
265
+ });
266
+
267
+ it("returns null when show-option throws", () => {
268
+ mockExec.mockImplementation(() => {
269
+ throw makeExecError("can't find session: proj");
270
+ });
271
+ const value = getSessionVariable("proj", "@my_var");
272
+ expect(value).toBe(null);
273
+ });
274
+ });
275
+
276
+ describe("setSessionVariable", () => {
277
+ it("sets variable with correct args", () => {
278
+ mockExec.mockImplementation(() => "");
279
+ setSessionVariable("proj", "@my_var", "hello");
280
+ expect(mockExec.mock.calls[0][1]).toEqual(["set-option", "-t", "proj", "@my_var", "hello"]);
281
+ });
282
+ });
283
+
284
+ // --- startSessionMonitor / stopSessionMonitor ---
285
+
286
+ describe("startSessionMonitor", () => {
287
+ it("spawns detached process and stores PID", () => {
288
+ const fakeChild = { pid: 12345, unref: mock() };
289
+ const restoreSpawn = _setSpawner(mock(() => fakeChild));
290
+
291
+ mockExec.mockImplementation(() => "");
292
+ startSessionMonitor("proj", "/path/to/monitor.js");
293
+
294
+ // Check spawn was called correctly
295
+ expect(fakeChild.unref.mock.calls.length).toBe(1);
296
+
297
+ // First call checks for existing PID (show-option), then stores new PID (set-option)
298
+ const lastCall = mockExec.mock.calls[mockExec.mock.calls.length - 1];
299
+ const setArgs = lastCall[1];
300
+ expect(setArgs).toEqual(["set-option", "-t", "proj", "@monitor_pid", "12345"]);
301
+
302
+ restoreSpawn();
303
+ });
304
+
305
+ it("spawns bun directly without shell wrapper", () => {
306
+ const fakeChild = { pid: 99, unref: mock() };
307
+ const mockSpawn = mock(() => fakeChild);
308
+ const restoreSpawn = _setSpawner(mockSpawn);
309
+
310
+ mockExec.mockImplementation(() => "");
311
+ startSessionMonitor("proj", "/path/to/monitor.ts");
312
+
313
+ const spawnCall = mockSpawn.mock.calls[0];
314
+ expect(spawnCall[0]).toBe("bun");
315
+ const args = spawnCall[1];
316
+ expect(args).toEqual(["/path/to/monitor.ts", "proj", "0"]);
317
+
318
+ restoreSpawn();
319
+ });
320
+ });
321
+
322
+ describe("stopSessionMonitor", () => {
323
+ it("kills process group by stored PID", () => {
324
+ mockExec.mockImplementation(() => " 42\n");
325
+ const origKill = process.kill;
326
+ const killCalls: Array<{ pid: number; signal?: string }> = [];
327
+ process.kill = ((pid: number, signal?: string) => {
328
+ killCalls.push({ pid, signal });
329
+ }) as typeof process.kill;
330
+ try {
331
+ stopSessionMonitor("proj");
332
+ // First tries process group kill (negative PID)
333
+ expect(killCalls[0]?.pid).toBe(-42);
334
+ expect(killCalls[0]?.signal).toBe("SIGTERM");
335
+ } finally {
336
+ process.kill = origKill;
337
+ }
338
+ });
339
+
340
+ it("handles missing PID gracefully", () => {
341
+ mockExec.mockImplementation(() => {
342
+ throw makeExecError("can't find session: proj");
343
+ });
344
+ // Should not throw
345
+ stopSessionMonitor("proj");
346
+ });
347
+
348
+ it("handles empty PID gracefully", () => {
349
+ mockExec.mockImplementation(() => "\n");
350
+ // Should not throw (no pid to kill)
351
+ stopSessionMonitor("proj");
352
+ });
353
+ });
354
+
355
+ // --- Other public functions ---
356
+
357
+ describe("setPaneTitle", () => {
358
+ it("calls select-pane with -T flag", () => {
359
+ mockExec.mockImplementation(() => "");
360
+ setPaneTitle("%0", "My Pane");
361
+ expect(mockExec.mock.calls[0][1]).toEqual(["select-pane", "-t", "%0", "-T", "My Pane"]);
362
+ });
363
+ });
364
+
365
+ describe("selectPane", () => {
366
+ it("calls select-pane with target", () => {
367
+ mockExec.mockImplementation(() => "");
368
+ selectPane("%2");
369
+ expect(mockExec.mock.calls[0][1]).toEqual(["select-pane", "-t", "%2"]);
370
+ });
371
+ });
372
+
373
+ describe("getPaneCurrentCommand", () => {
374
+ it("returns trimmed command name", () => {
375
+ mockExec.mockImplementation(() => " zsh\n");
376
+ expect(getPaneCurrentCommand("%0")).toBe("zsh");
377
+ });
378
+ });
379
+
380
+ describe("setSessionEnvironment", () => {
381
+ it("calls set-environment with correct args", () => {
382
+ mockExec.mockImplementation(() => "");
383
+ setSessionEnvironment("proj", "PORT", 3000);
384
+ expect(mockExec.mock.calls[0][1]).toEqual(["set-environment", "-t", "proj", "PORT", "3000"]);
385
+ });
386
+ });
387
+
388
+ describe("attachSession", () => {
389
+ it("calls attach with correct args and stdio inherit", () => {
390
+ mockExec.mockImplementation(() => "");
391
+ attachSession("proj");
392
+ expect(mockExec.mock.calls[0][1]).toEqual(["attach", "-t", "proj"]);
393
+ expect(mockExec.mock.calls[0][2].stdio).toBe("inherit");
394
+ });
395
+ });
396
+
397
+ describe("runSessionCommand", () => {
398
+ it("passes args through to tmux", () => {
399
+ mockExec.mockImplementation(() => "");
400
+ runSessionCommand(["resize-pane", "-t", "%0", "-x", "100"]);
401
+ expect(mockExec.mock.calls[0][1]).toEqual(["resize-pane", "-t", "%0", "-x", "100"]);
402
+ });
403
+ });
404
+
405
+ // --- Debug logging ---
406
+
407
+ describe("debug logging", () => {
408
+ it("logs to stderr when globalThis.__tmuxIdeVerbose is true", () => {
409
+ const origVerbose = globalThis.__tmuxIdeVerbose;
410
+ const stderrCalls = [];
411
+ const origConsoleError = console.error;
412
+ console.error = (...args) => stderrCalls.push(args.join(" "));
413
+
414
+ globalThis.__tmuxIdeVerbose = true;
415
+ mockExec.mockImplementation(() => "");
416
+
417
+ try {
418
+ hasSession("test-session");
419
+ expect(stderrCalls.some((msg) => msg.includes("[tmux]"))).toBeTruthy();
420
+ expect(stderrCalls.some((msg) => msg.includes("has-session"))).toBeTruthy();
421
+ } finally {
422
+ globalThis.__tmuxIdeVerbose = origVerbose;
423
+ console.error = origConsoleError;
424
+ }
425
+ });
426
+
427
+ it("does not log when neither DEBUG nor verbose is set", () => {
428
+ const origVerbose = globalThis.__tmuxIdeVerbose;
429
+ const stderrCalls = [];
430
+ const origConsoleError = console.error;
431
+ console.error = (...args) => stderrCalls.push(args.join(" "));
432
+
433
+ globalThis.__tmuxIdeVerbose = false;
434
+ mockExec.mockImplementation(() => "");
435
+
436
+ try {
437
+ hasSession("test-session");
438
+ expect(stderrCalls.length).toBe(0);
439
+ } finally {
440
+ globalThis.__tmuxIdeVerbose = origVerbose;
441
+ console.error = origConsoleError;
442
+ }
443
+ });
444
+ });
445
+
446
+ // --- Error classification edge cases ---
447
+
448
+ describe("error classification", () => {
449
+ it('classifies "can\'t find window" as SESSION_NOT_FOUND', () => {
450
+ mockExec.mockImplementation(() => {
451
+ throw makeExecError("can't find window: proj:0");
452
+ });
453
+ const result = getSessionState("proj");
454
+ expect(result).toEqual({ running: false, reason: "SESSION_NOT_FOUND" });
455
+ });
456
+
457
+ it('classifies "unknown target" as SESSION_NOT_FOUND', () => {
458
+ mockExec.mockImplementation(() => {
459
+ throw makeExecError("unknown target: proj");
460
+ });
461
+ const result = getSessionState("proj");
462
+ expect(result).toEqual({ running: false, reason: "SESSION_NOT_FOUND" });
463
+ });
464
+
465
+ it('classifies "failed to connect to server" as TMUX_UNAVAILABLE', () => {
466
+ mockExec.mockImplementation(() => {
467
+ throw makeExecError("failed to connect to server: /tmp/tmux-1000/default");
468
+ });
469
+ const result = getSessionState("proj");
470
+ expect(result).toEqual({ running: false, reason: "TMUX_UNAVAILABLE" });
471
+ });
472
+
473
+ it('classifies "error connecting to" as TMUX_UNAVAILABLE', () => {
474
+ mockExec.mockImplementation(() => {
475
+ throw makeExecError("error connecting to /tmp/tmux-1000/default");
476
+ });
477
+ const result = getSessionState("proj");
478
+ expect(result).toEqual({ running: false, reason: "TMUX_UNAVAILABLE" });
479
+ });
480
+
481
+ it("handles Buffer stderr in error", () => {
482
+ mockExec.mockImplementation(() => {
483
+ const err = new Error("Command failed");
484
+ err.stderr = Buffer.from("can't find session: proj");
485
+ throw err;
486
+ });
487
+ const result = getSessionState("proj");
488
+ expect(result).toEqual({ running: false, reason: "SESSION_NOT_FOUND" });
489
+ });
490
+
491
+ it("falls back to error.message when stderr is empty", () => {
492
+ mockExec.mockImplementation(() => {
493
+ const err = new Error("can't find session: proj");
494
+ err.stderr = "";
495
+ throw err;
496
+ });
497
+ const result = getSessionState("proj");
498
+ expect(result).toEqual({ running: false, reason: "SESSION_NOT_FOUND" });
499
+ });
500
+ });