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,276 @@
1
+ import { describe, it, beforeAll, afterAll, expect } from "bun:test";
2
+ import { execSync, execFileSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { tmpdir } from "node:os";
7
+ import { launch } from "./launch.ts";
8
+ import { restart } from "./restart.ts";
9
+
10
+ // Skip entire suite unless tmux is installed and we can create sessions.
11
+ let tmuxAvailable = false;
12
+ try {
13
+ execSync("tmux -V", { stdio: "ignore" });
14
+ const probeSession = `tmux-ide-test-probe-${process.pid}`;
15
+ try {
16
+ execSync(`tmux new-session -d -s "${probeSession}" -x 80 -y 24`, { stdio: "ignore" });
17
+ execSync(`tmux kill-session -t "${probeSession}"`, { stdio: "ignore" });
18
+ tmuxAvailable = true;
19
+ } catch {
20
+ // tmux is installed but session creation is blocked in this environment.
21
+ }
22
+ } catch {
23
+ // tmux is not installed.
24
+ }
25
+
26
+ const describeIntegration = tmuxAvailable ? describe : describe.skip;
27
+
28
+ describeIntegration("integration", () => {
29
+ let tmpDir;
30
+ const session = "tmux-ide-test-integration";
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+ const cli = join(__dirname, "..", "bin", "cli.js");
33
+
34
+ function run(args) {
35
+ return execFileSync("node", [cli, ...args], { cwd: tmpDir, encoding: "utf-8" });
36
+ }
37
+
38
+ function runJSON(args) {
39
+ return JSON.parse(run([...args, "--json"]));
40
+ }
41
+
42
+ function killSession() {
43
+ // Stop the session monitor before killing the tmux session
44
+ // to prevent orphaned monitor processes from accumulating.
45
+ try {
46
+ const pid = execSync(`tmux show-option -gqvt "${session}" @monitor_pid`, {
47
+ encoding: "utf-8",
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ }).trim();
50
+ if (pid) {
51
+ try {
52
+ process.kill(-parseInt(pid, 10), "SIGTERM");
53
+ } catch {
54
+ try {
55
+ process.kill(parseInt(pid, 10), "SIGTERM");
56
+ } catch {
57
+ /* already gone */
58
+ }
59
+ }
60
+ }
61
+ } catch {
62
+ // No monitor PID or session gone
63
+ }
64
+ try {
65
+ execSync(`tmux kill-session -t "${session}"`, { stdio: "ignore" });
66
+ } catch {
67
+ // Session was already absent.
68
+ }
69
+ }
70
+
71
+ function createSession() {
72
+ execSync(`tmux new-session -d -s "${session}" -x 80 -y 24`, { stdio: "ignore" });
73
+ }
74
+
75
+ beforeAll(() => {
76
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-test-"));
77
+ writeFileSync(
78
+ join(tmpDir, "ide.yml"),
79
+ `name: ${session}\nrows:\n - panes:\n - title: Shell\n`,
80
+ );
81
+ });
82
+
83
+ afterAll(() => {
84
+ killSession();
85
+ rmSync(tmpDir, { recursive: true, force: true });
86
+ });
87
+
88
+ it("status --json reports not running when no session exists", () => {
89
+ killSession();
90
+ const result = runJSON(["status"]);
91
+ expect(result.running).toBe(false);
92
+ });
93
+
94
+ it("status --json reports running after session is created", () => {
95
+ killSession();
96
+ createSession();
97
+ const result = runJSON(["status"]);
98
+ expect(result.running).toBe(true);
99
+ killSession();
100
+ });
101
+
102
+ it("inspect --json includes live tmux pane state when the session is running", () => {
103
+ killSession();
104
+ createSession();
105
+ const result = runJSON(["inspect"]);
106
+
107
+ expect(result.session).toBe(session);
108
+ expect(result.tmux.running).toBe(true);
109
+ expect(Array.isArray(result.tmux.panes)).toBeTruthy();
110
+ expect(result.tmux.panes.length >= 1).toBeTruthy();
111
+
112
+ killSession();
113
+ });
114
+
115
+ it("launch creates the configured session without attaching when requested", async () => {
116
+ killSession();
117
+
118
+ await launch(tmpDir, { attach: false });
119
+
120
+ const result = runJSON(["status"]);
121
+ expect(result.running).toBe(true);
122
+ expect(result.panes.length >= 1).toBeTruthy();
123
+
124
+ killSession();
125
+ });
126
+
127
+ it("launch reuses an existing session instead of creating a new layout", async () => {
128
+ killSession();
129
+ await launch(tmpDir, { attach: false });
130
+
131
+ writeFileSync(
132
+ join(tmpDir, "ide.yml"),
133
+ `name: ${session}\nrows:\n - panes:\n - title: Changed\n - title: Shell\n`,
134
+ );
135
+
136
+ await launch(tmpDir, { attach: false });
137
+
138
+ const result = runJSON(["status"]);
139
+ expect(result.running).toBe(true);
140
+ expect(result.panes.map((pane) => pane.title)).toEqual(["Shell"]);
141
+
142
+ killSession();
143
+ });
144
+
145
+ it("launch runs a successful before hook before creating the session", async () => {
146
+ killSession();
147
+ writeFileSync(
148
+ join(tmpDir, "ide.yml"),
149
+ `name: ${session}\nbefore: node -e "require('node:fs').writeFileSync('before.ok','ok')"\nrows:\n - panes:\n - title: Shell\n`,
150
+ );
151
+
152
+ await launch(tmpDir, { attach: false });
153
+
154
+ expect(runJSON(["status"]).running).toBe(true);
155
+ expect(existsSync(join(tmpDir, "before.ok"))).toBe(true);
156
+ killSession();
157
+ });
158
+
159
+ it("launch does not create a session when the before hook fails", async () => {
160
+ killSession();
161
+ writeFileSync(
162
+ join(tmpDir, "ide.yml"),
163
+ `name: ${session}\nbefore: node -e "process.exit(3)"\nrows:\n - panes:\n - title: Shell\n`,
164
+ );
165
+
166
+ await expect(launch(tmpDir, { attach: false })).rejects.toThrow(/before hook failed/i);
167
+ expect(runJSON(["status"]).running).toBe(false);
168
+ });
169
+
170
+ it("restart recreates the session without attaching when requested", async () => {
171
+ killSession();
172
+ writeFileSync(
173
+ join(tmpDir, "ide.yml"),
174
+ `name: ${session}\nrows:\n - panes:\n - title: Shell\n`,
175
+ );
176
+ createSession();
177
+
178
+ await restart(tmpDir, { attach: false });
179
+
180
+ const result = runJSON(["status"]);
181
+ expect(result.running).toBe(true);
182
+ expect(result.panes.length >= 1).toBeTruthy();
183
+
184
+ killSession();
185
+ });
186
+
187
+ it("restart applies the updated layout from ide.yml", async () => {
188
+ killSession();
189
+ writeFileSync(
190
+ join(tmpDir, "ide.yml"),
191
+ `name: ${session}\nrows:\n - panes:\n - title: Shell\n`,
192
+ );
193
+ await launch(tmpDir, { attach: false });
194
+
195
+ writeFileSync(
196
+ join(tmpDir, "ide.yml"),
197
+ `name: ${session}\nrows:\n - panes:\n - title: Claude\n - title: Shell\n`,
198
+ );
199
+
200
+ await restart(tmpDir, { attach: false });
201
+
202
+ const result = runJSON(["status"]);
203
+ expect(result.running).toBe(true);
204
+ expect(result.panes.length).toBe(2);
205
+ expect(result.panes.map((pane) => pane.title)).toEqual(["Claude", "Shell"]);
206
+
207
+ killSession();
208
+ });
209
+
210
+ it("launch applies a team layout without interactive attach", async () => {
211
+ killSession();
212
+ writeFileSync(
213
+ join(tmpDir, "ide.yml"),
214
+ `name: ${session}\nteam:\n name: test-team\nrows:\n - panes:\n - title: Lead\n command: claude\n role: lead\n - title: Worker\n command: claude\n role: teammate\n task: Review changes\n`,
215
+ );
216
+
217
+ await launch(tmpDir, { attach: false });
218
+
219
+ const result = runJSON(["inspect"]);
220
+ expect(result.valid).toBe(true);
221
+ expect(result.team.name).toBe("test-team");
222
+ expect(result.tmux.running).toBe(true);
223
+ expect(result.tmux.panes.map((pane) => pane.title)).toEqual(["Lead", "Worker"]);
224
+
225
+ killSession();
226
+ });
227
+
228
+ it("launch creates the expected pane layout for a team config", async () => {
229
+ killSession();
230
+ writeFileSync(
231
+ join(tmpDir, "ide.yml"),
232
+ `name: ${session}\nteam:\n name: review-team\nrows:\n - panes:\n - title: Lead\n command: claude\n role: lead\n - title: Reviewer\n command: claude\n role: teammate\n task: Review the diff\n`,
233
+ );
234
+
235
+ await launch(tmpDir, { attach: false });
236
+
237
+ const statusResult = runJSON(["status"]);
238
+ const inspectResult = runJSON(["inspect"]);
239
+ expect(statusResult.running).toBe(true);
240
+ expect(statusResult.panes.length).toBe(2);
241
+ expect(inspectResult.team.name).toBe("review-team");
242
+ expect(inspectResult.summary.panes).toBe(2);
243
+
244
+ killSession();
245
+ });
246
+
247
+ it("stop --json kills a running session", () => {
248
+ createSession();
249
+ run(["stop"]);
250
+ // Verify it's gone
251
+ const result = runJSON(["status"]);
252
+ expect(result.running).toBe(false);
253
+ });
254
+
255
+ it("validate --json reports valid for our test config", () => {
256
+ const result = runJSON(["validate"]);
257
+ expect(result.valid).toBe(true);
258
+ expect(result.errors).toEqual([]);
259
+ });
260
+
261
+ it("doctor --json passes checks", () => {
262
+ const result = runJSON(["doctor"]);
263
+ expect(result.ok).toBe(true);
264
+ });
265
+
266
+ it("config --json dumps config", () => {
267
+ const result = runJSON(["config"]);
268
+ expect(result.name).toBe(session);
269
+ expect(Array.isArray(result.rows)).toBeTruthy();
270
+ });
271
+
272
+ it("ls --json returns sessions list", () => {
273
+ const result = runJSON(["ls"]);
274
+ expect(Array.isArray(result.sessions)).toBeTruthy();
275
+ });
276
+ });
@@ -0,0 +1,10 @@
1
+ declare module "js-yaml" {
2
+ function load(input: string): unknown;
3
+ function dump(
4
+ input: unknown,
5
+ options?: { lineWidth?: number; noRefs?: boolean; quotingType?: string },
6
+ ): string;
7
+ const _default: { load: typeof load; dump: typeof dump };
8
+ export default _default;
9
+ export { load, dump };
10
+ }
@@ -0,0 +1,222 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ buildPaneMap,
7
+ waitForPaneCommand,
8
+ ensureTaskDocs,
9
+ resolveDashboardDir,
10
+ startDashboard,
11
+ } from "./launch.ts";
12
+
13
+ describe("buildPaneMap", () => {
14
+ it("uses returned pane ids instead of assuming sequential numbering", () => {
15
+ const rows = [
16
+ {
17
+ size: "60%",
18
+ panes: [{ title: "A" }, { title: "B" }],
19
+ },
20
+ {
21
+ panes: [{ title: "C" }, { title: "D", dir: "apps/api" }],
22
+ },
23
+ ];
24
+ const splitCalls = [];
25
+ const returnedPaneIds = ["%42", "%99", "%7"];
26
+
27
+ const { paneMap, firstPanesOfRows } = buildPaneMap(
28
+ rows,
29
+ "/workspace",
30
+ "%1",
31
+ ({ targetPane, direction, cwd, percent }) => {
32
+ splitCalls.push({ targetPane, direction, cwd, percent });
33
+ return returnedPaneIds.shift();
34
+ },
35
+ );
36
+
37
+ expect(splitCalls).toEqual([
38
+ { targetPane: "%1", direction: "vertical", cwd: "/workspace", percent: 40 },
39
+ { targetPane: "%1", direction: "horizontal", cwd: "/workspace", percent: 50 },
40
+ { targetPane: "%42", direction: "horizontal", cwd: "/workspace/apps/api", percent: 50 },
41
+ ]);
42
+
43
+ expect(paneMap).toEqual([
44
+ ["%1", "%99"],
45
+ ["%42", "%7"],
46
+ ]);
47
+ expect([...firstPanesOfRows]).toEqual(["%1", "%42"]);
48
+ });
49
+
50
+ it("chains additional row splits from the returned row pane ids", () => {
51
+ const rows = [
52
+ { size: "50%", panes: [{ title: "Lead" }] },
53
+ { size: "30%", panes: [{ title: "Worker" }] },
54
+ { panes: [{ title: "Shell" }] },
55
+ ];
56
+ const splitCalls = [];
57
+ const returnedPaneIds = ["%21", "%34"];
58
+
59
+ const { paneMap, firstPanesOfRows } = buildPaneMap(
60
+ rows,
61
+ "/workspace",
62
+ "%5",
63
+ ({ targetPane, direction, cwd, percent }) => {
64
+ splitCalls.push({ targetPane, direction, cwd, percent });
65
+ return returnedPaneIds.shift();
66
+ },
67
+ );
68
+
69
+ expect(splitCalls).toEqual([
70
+ { targetPane: "%5", direction: "vertical", cwd: "/workspace", percent: 50 },
71
+ { targetPane: "%21", direction: "vertical", cwd: "/workspace", percent: 40 },
72
+ ]);
73
+ expect(paneMap).toEqual([["%5"], ["%21"], ["%34"]]);
74
+ expect([...firstPanesOfRows]).toEqual(["%5", "%21", "%34"]);
75
+ });
76
+ });
77
+
78
+ describe("waitForPaneCommand", () => {
79
+ it("returns true once the pane reports an expected command", () => {
80
+ const seenSleeps = [];
81
+ const commands = ["zsh", "zsh", "claude"];
82
+
83
+ const result = waitForPaneCommand("%1", ["claude"], {
84
+ attempts: 5,
85
+ delayMs: 25,
86
+ getCurrentCommand: () => commands.shift(),
87
+ sleep: (ms) => seenSleeps.push(ms),
88
+ });
89
+
90
+ expect(result).toBe(true);
91
+ expect(seenSleeps).toEqual([25, 25]);
92
+ });
93
+
94
+ it("returns false after exhausting retries", () => {
95
+ const seenSleeps = [];
96
+
97
+ const result = waitForPaneCommand("%1", ["claude"], {
98
+ attempts: 3,
99
+ delayMs: 10,
100
+ getCurrentCommand: () => "zsh",
101
+ sleep: (ms) => seenSleeps.push(ms),
102
+ });
103
+
104
+ expect(result).toBe(false);
105
+ expect(seenSleeps).toEqual([10, 10]);
106
+ });
107
+ });
108
+
109
+ describe("ensureTaskDocs", () => {
110
+ let tmpDir: string;
111
+
112
+ beforeEach(() => {
113
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-launch-test-"));
114
+ });
115
+
116
+ afterEach(() => {
117
+ rmSync(tmpDir, { recursive: true, force: true });
118
+ });
119
+
120
+ it("creates CLAUDE.md with task docs when file does not exist", () => {
121
+ ensureTaskDocs(tmpDir);
122
+ const content = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
123
+ expect(content.includes("## Task Management")).toBeTruthy();
124
+ expect(content.includes("tmux-ide mission set")).toBeTruthy();
125
+ expect(content.includes("tmux-ide task create")).toBeTruthy();
126
+ expect(content.includes("--proof")).toBeTruthy();
127
+ expect(content.includes("--depends")).toBeTruthy();
128
+ });
129
+
130
+ it("appends task docs to existing CLAUDE.md", () => {
131
+ writeFileSync(join(tmpDir, "CLAUDE.md"), "# My Project\n\nExisting content.\n");
132
+ ensureTaskDocs(tmpDir);
133
+ const content = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
134
+ expect(content.startsWith("# My Project\n\nExisting content.\n")).toBeTruthy();
135
+ expect(content.includes("## Task Management")).toBeTruthy();
136
+ });
137
+
138
+ it("does not duplicate if section already exists", () => {
139
+ writeFileSync(join(tmpDir, "CLAUDE.md"), "# Project\n\n## Task Management\n\nAlready here.\n");
140
+ ensureTaskDocs(tmpDir);
141
+ const content = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
142
+ const count = content.split("## Task Management").length - 1;
143
+ expect(count).toBe(1);
144
+ expect(content.includes("Already here.")).toBeTruthy();
145
+ expect(!content.includes("tmux-ide mission set")).toBeTruthy();
146
+ });
147
+
148
+ it("is idempotent when called twice", () => {
149
+ ensureTaskDocs(tmpDir);
150
+ const first = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
151
+ ensureTaskDocs(tmpDir);
152
+ const second = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
153
+ expect(first).toBe(second);
154
+ });
155
+ });
156
+
157
+ describe("dashboard helpers", () => {
158
+ it("detects a dashboard directory from the package root", () => {
159
+ const root = join(import.meta.dir, "..");
160
+ expect(resolveDashboardDir(root)).toBe(join(root, "dashboard"));
161
+ });
162
+
163
+ it("returns null when the dashboard directory is missing", () => {
164
+ expect(resolveDashboardDir("/definitely/missing-root")).toBe(null);
165
+ });
166
+
167
+ it("starts the dashboard and stores pid and url", () => {
168
+ const sessionVars: Array<{ name: string; value: string }> = [];
169
+ let spawned: {
170
+ command: string;
171
+ args: readonly string[];
172
+ envValue: string | undefined;
173
+ cwd?: string;
174
+ } | null = null;
175
+
176
+ const url = startDashboard("proj", 6060, {
177
+ dashboardPort: 6061,
178
+ dashboardDir: "/workspace/dashboard",
179
+ spawnFn: (command, args, options) => {
180
+ spawned = {
181
+ command,
182
+ args,
183
+ envValue: options.env?.NEXT_PUBLIC_API_URL,
184
+ cwd: options.cwd,
185
+ };
186
+ return { pid: 4321, unref: () => {} };
187
+ },
188
+ setVar: (_session, name, value) => {
189
+ sessionVars.push({ name, value });
190
+ },
191
+ });
192
+
193
+ expect(url).toBe("http://localhost:6061");
194
+ expect(spawned).toEqual({
195
+ command: "pnpm",
196
+ args: ["dev", "--port", "6061"],
197
+ envValue: "http://localhost:6060",
198
+ cwd: "/workspace/dashboard",
199
+ });
200
+ expect(sessionVars).toEqual([
201
+ { name: "@dashboard_pid", value: "4321" },
202
+ { name: "@dashboard_url", value: "http://localhost:6061" },
203
+ ]);
204
+ });
205
+
206
+ it("supports overriding the dashboard port", () => {
207
+ let args: readonly string[] | null = null;
208
+
209
+ const url = startDashboard("proj", 6060, {
210
+ dashboardPort: 7777,
211
+ dashboardDir: "/workspace/dashboard",
212
+ spawnFn: (_command, spawnArgs) => {
213
+ args = spawnArgs;
214
+ return { pid: 99, unref: () => {} };
215
+ },
216
+ setVar: () => {},
217
+ });
218
+
219
+ expect(url).toBe("http://localhost:7777");
220
+ expect(args).toEqual(["dev", "--port", "7777"]);
221
+ });
222
+ });