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,818 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { agentIdentifier } from "../lib/orchestrator.ts";
6
+ import {
7
+ ensureTasksDir,
8
+ saveMission,
9
+ saveTask,
10
+ loadTask,
11
+ loadMission,
12
+ type Task,
13
+ } from "../lib/task-store.ts";
14
+ import { saveValidationState } from "../lib/validation.ts";
15
+ import { appendEvent } from "../lib/event-log.ts";
16
+ import { loadResearchState, saveResearchState } from "../lib/research.ts";
17
+ import { _setExecutor, type PaneInfo } from "../widgets/lib/pane-comms.ts";
18
+ import { _setTmuxRunner } from "./discovery.ts";
19
+ import { createApp } from "./server.ts";
20
+ import { makeTask, makePane } from "../__tests__/support.ts";
21
+
22
+ let tmpDir: string;
23
+ let restoreTmux: () => void;
24
+ let restoreDiscoveryTmux: () => void;
25
+ let mockPanes: PaneInfo[];
26
+
27
+ beforeEach(() => {
28
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-cc-srv-test-"));
29
+ ensureTasksDir(tmpDir);
30
+ mockPanes = [];
31
+
32
+ restoreTmux = _setExecutor((_cmd: string, args: string[]) => {
33
+ if (args[0] === "list-panes") {
34
+ return mockPanes
35
+ .map(
36
+ (p) =>
37
+ `${p.id}\t${p.index}\t${p.title}\t${p.currentCommand}\t${p.width}\t${p.height}\t${p.active ? "1" : "0"}\t${p.role ?? ""}\t${p.name ?? ""}\t${p.type ?? ""}`,
38
+ )
39
+ .join("\n");
40
+ }
41
+ return "";
42
+ });
43
+
44
+ restoreDiscoveryTmux = _setTmuxRunner((args: string[]) => {
45
+ if (args[0] === "list-sessions") return "test-project";
46
+ if (args[0] === "display-message") return tmpDir;
47
+ return "";
48
+ });
49
+ });
50
+
51
+ afterEach(() => {
52
+ restoreTmux();
53
+ restoreDiscoveryTmux();
54
+ rmSync(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ describe("GET /api/sessions", () => {
58
+ it("returns discovered sessions", async () => {
59
+ saveMission(tmpDir, {
60
+ title: "Test mission",
61
+ description: "",
62
+ status: "active",
63
+ branch: null,
64
+ milestones: [],
65
+ created: "2026-01-01T00:00:00Z",
66
+ updated: "2026-01-01T00:00:00Z",
67
+ });
68
+ saveTask(tmpDir, makeTask({ id: "001", status: "done" }));
69
+ saveTask(tmpDir, makeTask({ id: "002", status: "todo" }));
70
+
71
+ const app = createApp();
72
+ const res = await app.request("/api/sessions");
73
+ expect(res.status).toBe(200);
74
+
75
+ const body = (await res.json()) as {
76
+ sessions: Array<{ name: string; stats: { totalTasks: number; doneTasks: number } }>;
77
+ };
78
+ expect(body.sessions.length).toBe(1);
79
+ expect(body.sessions[0]!.name).toBe("test-project");
80
+ expect(body.sessions[0]!.stats.totalTasks).toBe(2);
81
+ expect(body.sessions[0]!.stats.doneTasks).toBe(1);
82
+ });
83
+ });
84
+
85
+ describe("GET /api/project/:name", () => {
86
+ it("returns project detail", async () => {
87
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "claude" });
88
+ const name = agentIdentifier(pane);
89
+ saveTask(tmpDir, makeTask({ id: "001", status: "in-progress", assignee: name }));
90
+ mockPanes = [pane];
91
+
92
+ const app = createApp();
93
+ const res = await app.request("/api/project/test-project");
94
+ expect(res.status).toBe(200);
95
+
96
+ const body = (await res.json()) as {
97
+ session: string;
98
+ tasks: Task[];
99
+ agents: Array<{ paneTitle: string }>;
100
+ };
101
+ expect(body.session).toBe("test-project");
102
+ expect(body.tasks.length).toBe(1);
103
+ expect(body.agents.length).toBe(1);
104
+ expect(body.agents[0]!.paneTitle).toBe(name);
105
+ });
106
+
107
+ it("returns 404 for unknown session", async () => {
108
+ const app = createApp();
109
+ const res = await app.request("/api/project/nonexistent");
110
+ expect(res.status).toBe(404);
111
+ });
112
+ });
113
+
114
+ describe("POST /api/project/:name/task/:id", () => {
115
+ it("updates task status", async () => {
116
+ saveTask(tmpDir, makeTask({ id: "001", status: "todo" }));
117
+
118
+ const app = createApp();
119
+ const res = await app.request("/api/project/test-project/task/001", {
120
+ method: "POST",
121
+ headers: { "Content-Type": "application/json" },
122
+ body: JSON.stringify({ status: "in-progress" }),
123
+ });
124
+
125
+ expect(res.status).toBe(200);
126
+ const body = (await res.json()) as { ok: boolean; task: Task };
127
+ expect(body.ok).toBe(true);
128
+ expect(body.task.status).toBe("in-progress");
129
+
130
+ const loaded = loadTask(tmpDir, "001");
131
+ expect(loaded?.status).toBe("in-progress");
132
+ });
133
+
134
+ it("returns 404 for unknown session", async () => {
135
+ const app = createApp();
136
+ const res = await app.request("/api/project/nonexistent/task/001", {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({ status: "done" }),
140
+ });
141
+ expect(res.status).toBe(404);
142
+ });
143
+
144
+ it("returns 404 for unknown task", async () => {
145
+ const app = createApp();
146
+ const res = await app.request("/api/project/test-project/task/999", {
147
+ method: "POST",
148
+ headers: { "Content-Type": "application/json" },
149
+ body: JSON.stringify({ status: "done" }),
150
+ });
151
+ expect(res.status).toBe(404);
152
+ });
153
+
154
+ it("rejects invalid status via zod validation", async () => {
155
+ saveTask(tmpDir, makeTask({ id: "001" }));
156
+ const app = createApp();
157
+ const res = await app.request("/api/project/test-project/task/001", {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify({ status: "invalid-status" }),
161
+ });
162
+ expect(res.status).toBe(400);
163
+ });
164
+ });
165
+
166
+ describe("POST /api/project/:name/task (create)", () => {
167
+ it("creates a task", async () => {
168
+ const app = createApp();
169
+ const res = await app.request("/api/project/test-project/task", {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/json" },
172
+ body: JSON.stringify({ title: "New task", priority: 1 }),
173
+ });
174
+ expect(res.status).toBe(201);
175
+ const body = (await res.json()) as { ok: boolean; task: Task };
176
+ expect(body.ok).toBe(true);
177
+ expect(body.task.title).toBe("New task");
178
+ expect(body.task.status).toBe("todo");
179
+ expect(body.task.id).toBeTruthy();
180
+ });
181
+
182
+ it("returns 400 when title is missing", async () => {
183
+ const app = createApp();
184
+ const res = await app.request("/api/project/test-project/task", {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify({ description: "no title" }),
188
+ });
189
+ expect(res.status).toBe(400);
190
+ });
191
+
192
+ it("returns 400 when title is only whitespace", async () => {
193
+ const app = createApp();
194
+ const res = await app.request("/api/project/test-project/task", {
195
+ method: "POST",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({ title: " " }),
198
+ });
199
+ expect(res.status).toBe(400);
200
+ });
201
+
202
+ it("returns 404 for unknown session", async () => {
203
+ const app = createApp();
204
+ const res = await app.request("/api/project/nonexistent/task", {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({ title: "Test" }),
208
+ });
209
+ expect(res.status).toBe(404);
210
+ });
211
+ });
212
+
213
+ describe("DELETE /api/project/:name/task/:id", () => {
214
+ it("deletes a task", async () => {
215
+ saveTask(tmpDir, makeTask({ id: "001" }));
216
+ const app = createApp();
217
+ const res = await app.request("/api/project/test-project/task/001", {
218
+ method: "DELETE",
219
+ });
220
+ expect(res.status).toBe(200);
221
+ const body = (await res.json()) as { ok: boolean };
222
+ expect(body.ok).toBe(true);
223
+ expect(loadTask(tmpDir, "001")).toBe(null);
224
+ });
225
+
226
+ it("returns 404 for unknown task", async () => {
227
+ const app = createApp();
228
+ const res = await app.request("/api/project/test-project/task/999", {
229
+ method: "DELETE",
230
+ });
231
+ expect(res.status).toBe(404);
232
+ });
233
+
234
+ it("returns 404 for unknown session", async () => {
235
+ const app = createApp();
236
+ const res = await app.request("/api/project/nonexistent/task/001", {
237
+ method: "DELETE",
238
+ });
239
+ expect(res.status).toBe(404);
240
+ });
241
+ });
242
+
243
+ describe("GET /api/project/:name/plans", () => {
244
+ it("returns plan list with metadata", async () => {
245
+ const plansDir = join(tmpDir, "plans");
246
+ mkdirSync(plansDir, { recursive: true });
247
+ writeFileSync(
248
+ join(plansDir, "01-test.md"),
249
+ "# Plan 01\n\n**Status:** `pending`\n**Effort:** Low\n",
250
+ );
251
+
252
+ const app = createApp();
253
+ const res = await app.request("/api/project/test-project/plans");
254
+ expect(res.status).toBe(200);
255
+ const body = (await res.json()) as { plans: Array<{ name: string; status: string }> };
256
+ expect(body.plans.length >= 1).toBeTruthy();
257
+ const plan = body.plans.find((p) => p.name === "01-test");
258
+ expect(plan).toBeTruthy();
259
+ expect(plan!.status).toBe("pending");
260
+ });
261
+ });
262
+
263
+ describe("research endpoints", () => {
264
+ it("returns research state, active task, and recent findings", async () => {
265
+ saveTask(
266
+ tmpDir,
267
+ makeTask({
268
+ id: "010",
269
+ title: "Research: mission start",
270
+ status: "done",
271
+ updated: "2026-01-02T00:00:00Z",
272
+ tags: ["research", "mission_start"],
273
+ salientSummary: "Initial audit completed",
274
+ }),
275
+ );
276
+ saveTask(
277
+ tmpDir,
278
+ makeTask({
279
+ id: "011",
280
+ title: "Research: periodic",
281
+ status: "in-progress",
282
+ updated: "2026-01-03T00:00:00Z",
283
+ tags: ["research", "periodic"],
284
+ }),
285
+ );
286
+ saveResearchState(tmpDir, {
287
+ lastResearchAt: { periodic: "2026-01-03T00:00:00Z" },
288
+ missionStartAnalyzed: true,
289
+ milestoneTaskCounts: {},
290
+ activeResearchTaskId: "011",
291
+ retryWindow: [],
292
+ });
293
+
294
+ const app = createApp();
295
+ const res = await app.request("/api/project/test-project/research");
296
+
297
+ expect(res.status).toBe(200);
298
+ const body = (await res.json()) as {
299
+ state: { activeResearchTaskId: string | null; missionStartAnalyzed: boolean };
300
+ activeTask: Task | null;
301
+ findings: Task[];
302
+ };
303
+ expect(body.state.activeResearchTaskId).toBe("011");
304
+ expect(body.state.missionStartAnalyzed).toBe(true);
305
+ expect(body.activeTask?.id).toBe("011");
306
+ expect(body.findings.map((task) => task.id)).toEqual(["010"]);
307
+ });
308
+
309
+ it("manually dispatches research through the API", async () => {
310
+ mockPanes = [
311
+ makePane({
312
+ id: "%2",
313
+ index: 1,
314
+ title: "Researcher",
315
+ role: "researcher",
316
+ currentCommand: "zsh",
317
+ }),
318
+ ];
319
+
320
+ const app = createApp();
321
+ const res = await app.request("/api/project/test-project/research/trigger", {
322
+ method: "POST",
323
+ headers: { "Content-Type": "application/json" },
324
+ body: JSON.stringify({ type: "periodic" }),
325
+ });
326
+
327
+ expect(res.status).toBe(201);
328
+ const body = (await res.json()) as { ok: boolean; task: Task };
329
+ expect(body.ok).toBe(true);
330
+ expect(body.task.tags).toEqual(["research", "periodic"]);
331
+ expect(body.task.specialty).toBe("researcher");
332
+
333
+ const persisted = loadTask(tmpDir, body.task.id);
334
+ expect(persisted?.status).toBe("in-progress");
335
+ expect(loadResearchState(tmpDir).activeResearchTaskId).toBe(body.task.id);
336
+ });
337
+ });
338
+
339
+ describe("GET /api/project/:name/plans/:filename", () => {
340
+ it("returns plan content", async () => {
341
+ const plansDir = join(tmpDir, "plans");
342
+ mkdirSync(plansDir, { recursive: true });
343
+ writeFileSync(join(plansDir, "test-plan.md"), "# Test Plan\n\nSome content.");
344
+
345
+ const app = createApp();
346
+ const res = await app.request("/api/project/test-project/plans/test-plan");
347
+ expect(res.status).toBe(200);
348
+ const body = (await res.json()) as { name: string; content: string; marks: unknown };
349
+ expect(body.content.includes("Test Plan")).toBeTruthy();
350
+ });
351
+
352
+ it("returns 404 for missing plan", async () => {
353
+ const app = createApp();
354
+ const res = await app.request("/api/project/test-project/plans/nonexistent");
355
+ expect(res.status).toBe(404);
356
+ });
357
+ });
358
+
359
+ describe("POST /api/project/:name/plans/:filename", () => {
360
+ it("saves plan content", async () => {
361
+ const app = createApp();
362
+ const res = await app.request("/api/project/test-project/plans/new-plan", {
363
+ method: "POST",
364
+ headers: { "Content-Type": "application/json" },
365
+ body: JSON.stringify({ content: "# New Plan\n\nContent here." }),
366
+ });
367
+ expect(res.status).toBe(200);
368
+
369
+ const getRes = await app.request("/api/project/test-project/plans/new-plan");
370
+ expect(getRes.status).toBe(200);
371
+ const body = (await getRes.json()) as { content: string };
372
+ expect(body.content.includes("New Plan")).toBeTruthy();
373
+ });
374
+ });
375
+
376
+ describe("DELETE /api/project/:name/plans/:filename", () => {
377
+ it("deletes a plan file", async () => {
378
+ const plansDir = join(tmpDir, "plans");
379
+ mkdirSync(plansDir, { recursive: true });
380
+ writeFileSync(join(plansDir, "to-delete.md"), "# Delete me");
381
+
382
+ const app = createApp();
383
+ const res = await app.request("/api/project/test-project/plans/to-delete", {
384
+ method: "DELETE",
385
+ });
386
+ expect(res.status).toBe(200);
387
+
388
+ const getRes = await app.request("/api/project/test-project/plans/to-delete");
389
+ expect(getRes.status).toBe(404);
390
+ });
391
+ });
392
+
393
+ describe("POST /api/project/:name/plans/:filename/done", () => {
394
+ it("marks a plan as done", async () => {
395
+ const plansDir = join(tmpDir, "plans");
396
+ mkdirSync(plansDir, { recursive: true });
397
+ writeFileSync(join(plansDir, "70-test.md"), "# Plan 70\n\n**Status:** `in-progress`\n");
398
+
399
+ const app = createApp();
400
+ const res = await app.request("/api/project/test-project/plans/70-test/done", {
401
+ method: "POST",
402
+ });
403
+ expect(res.status).toBe(200);
404
+ const body = (await res.json()) as { ok: boolean; plan: { status: string } };
405
+ expect(body.ok).toBe(true);
406
+ expect(body.plan.status).toBe("done");
407
+ });
408
+ });
409
+
410
+ describe("GET /api/project/:name/diff", () => {
411
+ it("returns diff shape", async () => {
412
+ const app = createApp();
413
+ const res = await app.request("/api/project/test-project/diff");
414
+ expect(res.status).toBe(200);
415
+ const body = (await res.json()) as { diff: string; files: unknown[] };
416
+ expect(typeof body.diff).toBe("string");
417
+ expect(Array.isArray(body.files)).toBeTruthy();
418
+ });
419
+
420
+ it("returns 404 for unknown session", async () => {
421
+ const app = createApp();
422
+ const res = await app.request("/api/project/nonexistent/diff");
423
+ expect(res.status).toBe(404);
424
+ });
425
+ });
426
+
427
+ describe("GET /api/project/:name/events", () => {
428
+ it("returns recent events", async () => {
429
+ appendEvent(tmpDir, {
430
+ timestamp: new Date().toISOString(),
431
+ type: "dispatch",
432
+ taskId: "001",
433
+ message: "Test event",
434
+ });
435
+
436
+ const app = createApp();
437
+ const res = await app.request("/api/project/test-project/events");
438
+ expect(res.status).toBe(200);
439
+ const body = (await res.json()) as {
440
+ events: Array<{ type: string; message: string; relative: string }>;
441
+ };
442
+ expect(body.events.length >= 1).toBeTruthy();
443
+ expect(body.events[0]!.message).toBe("Test event");
444
+ expect(body.events[0]!.relative).toBeTruthy();
445
+ });
446
+
447
+ it("returns empty events when no log exists", async () => {
448
+ const app = createApp();
449
+ const res = await app.request("/api/project/test-project/events");
450
+ expect(res.status).toBe(200);
451
+ const body = (await res.json()) as { events: unknown[] };
452
+ expect(body.events).toEqual([]);
453
+ });
454
+
455
+ it("returns 404 for unknown session", async () => {
456
+ const app = createApp();
457
+ const res = await app.request("/api/project/nonexistent/events");
458
+ expect(res.status).toBe(404);
459
+ });
460
+ });
461
+
462
+ describe("GET /api/project/:name/diff/:file", () => {
463
+ it("returns per-file diff shape", async () => {
464
+ const app = createApp();
465
+ const res = await app.request("/api/project/test-project/diff/src/index.ts");
466
+ expect(res.status).toBe(200);
467
+
468
+ const body = (await res.json()) as { file: string; diff: string };
469
+ expect(body.file).toBe("src/index.ts");
470
+ expect(typeof body.diff).toBe("string");
471
+ });
472
+
473
+ it("returns 404 for unknown session", async () => {
474
+ const app = createApp();
475
+ const res = await app.request("/api/project/nonexistent/diff/file.ts");
476
+ expect(res.status).toBe(404);
477
+ });
478
+ });
479
+
480
+ // GET /api/events (SSE) — stream endpoint; smoke-test headers only
481
+ describe("GET /api/events (SSE)", () => {
482
+ it("returns SSE content-type header", async () => {
483
+ const app = createApp();
484
+ const res = await app.request("/api/events");
485
+
486
+ expect(res.status).toBe(200);
487
+ const contentType = res.headers.get("content-type");
488
+ expect(contentType?.includes("text/event-stream")).toBeTruthy();
489
+ });
490
+ });
491
+
492
+ describe("GET /", () => {
493
+ it("returns health check with API metadata", async () => {
494
+ const app = createApp();
495
+ const res = await app.request("/");
496
+ expect(res.status).toBe(200);
497
+
498
+ const body = (await res.json()) as { status: string; message: string; docs: string };
499
+ expect(body.status).toBe("ok");
500
+ expect(body.message).toBe("tmux-ide command center API");
501
+ expect(body.docs).toBe("/api/sessions");
502
+ });
503
+ });
504
+
505
+ describe("GET /api/project/:name/milestones", () => {
506
+ it("returns milestones with task counts", async () => {
507
+ saveMission(tmpDir, {
508
+ title: "Test",
509
+ description: "",
510
+ status: "active",
511
+ branch: null,
512
+ milestones: [
513
+ {
514
+ id: "M1",
515
+ title: "Phase 1",
516
+ description: "",
517
+ status: "active",
518
+ order: 1,
519
+ created: "2026-01-01T00:00:00Z",
520
+ updated: "2026-01-01T00:00:00Z",
521
+ },
522
+ {
523
+ id: "M2",
524
+ title: "Phase 2",
525
+ description: "",
526
+ status: "locked",
527
+ order: 2,
528
+ created: "2026-01-01T00:00:00Z",
529
+ updated: "2026-01-01T00:00:00Z",
530
+ },
531
+ ],
532
+ created: "2026-01-01T00:00:00Z",
533
+ updated: "2026-01-01T00:00:00Z",
534
+ });
535
+ saveTask(tmpDir, makeTask({ id: "001", milestone: "M1" }));
536
+ saveTask(tmpDir, makeTask({ id: "002", milestone: "M1", status: "done" }));
537
+ saveTask(tmpDir, makeTask({ id: "003", milestone: "M2" }));
538
+
539
+ const app = createApp();
540
+ const res = await app.request("/api/project/test-project/milestones");
541
+ expect(res.status).toBe(200);
542
+ const body = (await res.json()) as {
543
+ milestones: Array<{ id: string; taskCount: number; tasksDone: number }>;
544
+ };
545
+ expect(body.milestones.length).toBe(2);
546
+ expect(body.milestones[0]!.id).toBe("M1");
547
+ expect(body.milestones[0]!.taskCount).toBe(2);
548
+ expect(body.milestones[0]!.tasksDone).toBe(1);
549
+ expect(body.milestones[1]!.id).toBe("M2");
550
+ expect(body.milestones[1]!.taskCount).toBe(1);
551
+ });
552
+ });
553
+
554
+ describe("POST /api/project/:name/milestones", () => {
555
+ it("creates a milestone", async () => {
556
+ saveMission(tmpDir, {
557
+ title: "Test",
558
+ description: "",
559
+ status: "planning",
560
+ branch: null,
561
+ milestones: [],
562
+ created: "2026-01-01T00:00:00Z",
563
+ updated: "2026-01-01T00:00:00Z",
564
+ });
565
+
566
+ const app = createApp();
567
+ const res = await app.request("/api/project/test-project/milestones", {
568
+ method: "POST",
569
+ headers: { "Content-Type": "application/json" },
570
+ body: JSON.stringify({ title: "Foundation", sequence: 1 }),
571
+ });
572
+ expect(res.status).toBe(201);
573
+ const body = (await res.json()) as { ok: boolean; milestone: { id: string; status: string } };
574
+ expect(body.ok).toBe(true);
575
+ expect(body.milestone.id).toBe("M1");
576
+ expect(body.milestone.status).toBe("active");
577
+
578
+ // Verify persisted
579
+ const mission = loadMission(tmpDir)!;
580
+ expect(mission.milestones.length).toBe(1);
581
+ });
582
+
583
+ it("updates a milestone status", async () => {
584
+ saveMission(tmpDir, {
585
+ title: "Test",
586
+ description: "",
587
+ status: "active",
588
+ branch: null,
589
+ milestones: [
590
+ {
591
+ id: "M1",
592
+ title: "Phase 1",
593
+ description: "",
594
+ status: "active",
595
+ order: 1,
596
+ created: "2026-01-01T00:00:00Z",
597
+ updated: "2026-01-01T00:00:00Z",
598
+ },
599
+ ],
600
+ created: "2026-01-01T00:00:00Z",
601
+ updated: "2026-01-01T00:00:00Z",
602
+ });
603
+
604
+ const app = createApp();
605
+ const res = await app.request("/api/project/test-project/milestones/M1", {
606
+ method: "POST",
607
+ headers: { "Content-Type": "application/json" },
608
+ body: JSON.stringify({ status: "validating" }),
609
+ });
610
+ expect(res.status).toBe(200);
611
+ const body = (await res.json()) as { ok: boolean; milestone: { status: string } };
612
+ expect(body.milestone.status).toBe("validating");
613
+ });
614
+
615
+ it("rejects invalid milestone transitions", async () => {
616
+ saveMission(tmpDir, {
617
+ title: "Test",
618
+ description: "",
619
+ status: "active",
620
+ branch: null,
621
+ milestones: [
622
+ {
623
+ id: "M1",
624
+ title: "Phase 1",
625
+ description: "",
626
+ status: "locked",
627
+ order: 1,
628
+ created: "2026-01-01T00:00:00Z",
629
+ updated: "2026-01-01T00:00:00Z",
630
+ },
631
+ ],
632
+ created: "2026-01-01T00:00:00Z",
633
+ updated: "2026-01-01T00:00:00Z",
634
+ });
635
+
636
+ const app = createApp();
637
+ const res = await app.request("/api/project/test-project/milestones/M1", {
638
+ method: "POST",
639
+ headers: { "Content-Type": "application/json" },
640
+ body: JSON.stringify({ status: "done" }),
641
+ });
642
+
643
+ expect(res.status).toBe(409);
644
+ const body = (await res.json()) as { error: string };
645
+ expect(body.error).toContain("Invalid milestone status transition");
646
+ });
647
+
648
+ it("rejects invalid milestone status payloads", async () => {
649
+ saveMission(tmpDir, {
650
+ title: "Test",
651
+ description: "",
652
+ status: "active",
653
+ branch: null,
654
+ milestones: [
655
+ {
656
+ id: "M1",
657
+ title: "Phase 1",
658
+ description: "",
659
+ status: "active",
660
+ order: 1,
661
+ created: "2026-01-01T00:00:00Z",
662
+ updated: "2026-01-01T00:00:00Z",
663
+ },
664
+ ],
665
+ created: "2026-01-01T00:00:00Z",
666
+ updated: "2026-01-01T00:00:00Z",
667
+ });
668
+
669
+ const app = createApp();
670
+ const res = await app.request("/api/project/test-project/milestones/M1", {
671
+ method: "POST",
672
+ headers: { "Content-Type": "application/json" },
673
+ body: JSON.stringify({ status: "bogus" }),
674
+ });
675
+
676
+ expect(res.status).toBe(400);
677
+ });
678
+ });
679
+
680
+ describe("GET /api/project/:name/validation", () => {
681
+ it("returns validation state and contract", async () => {
682
+ mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
683
+ writeFileSync(join(tmpDir, ".tasks", "validation-contract.md"), "**ASSERT01**: Auth works");
684
+ saveValidationState(tmpDir, {
685
+ assertions: {
686
+ ASSERT01: {
687
+ status: "passing",
688
+ verifiedBy: "v",
689
+ verifiedAt: "2026-01-01T00:00:00Z",
690
+ evidence: "ok",
691
+ blockedBy: null,
692
+ },
693
+ },
694
+ lastVerified: "2026-01-01T00:00:00Z",
695
+ });
696
+
697
+ const app = createApp();
698
+ const res = await app.request("/api/project/test-project/validation");
699
+ expect(res.status).toBe(200);
700
+ const body = (await res.json()) as {
701
+ contract: string;
702
+ state: { assertions: Record<string, { status: string }> };
703
+ };
704
+ expect(body.contract).toContain("ASSERT01");
705
+ expect(body.state.assertions["ASSERT01"]!.status).toBe("passing");
706
+ });
707
+ });
708
+
709
+ describe("GET /api/project/:name/skills", () => {
710
+ it("returns loaded skills", async () => {
711
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
712
+ mkdirSync(skillsDir, { recursive: true });
713
+ writeFileSync(
714
+ join(skillsDir, "frontend.md"),
715
+ `---\nname: frontend\nspecialties: [frontend, css]\nrole: teammate\ndescription: Frontend dev\n---\nYou build UIs.`,
716
+ );
717
+
718
+ const app = createApp();
719
+ const res = await app.request("/api/project/test-project/skills");
720
+ expect(res.status).toBe(200);
721
+ const body = (await res.json()) as { skills: Array<{ name: string; specialties: string[] }> };
722
+ expect(body.skills.length).toBe(1);
723
+ expect(body.skills[0]!.name).toBe("frontend");
724
+ expect(body.skills[0]!.specialties).toContain("frontend");
725
+ });
726
+ });
727
+
728
+ describe("GET /api/project/:name/mission", () => {
729
+ it("returns mission with validation summary", async () => {
730
+ saveMission(tmpDir, {
731
+ title: "Ship v2",
732
+ description: "Major release",
733
+ status: "active",
734
+ branch: null,
735
+ milestones: [
736
+ {
737
+ id: "M1",
738
+ title: "Phase 1",
739
+ description: "",
740
+ status: "done",
741
+ order: 1,
742
+ created: "2026-01-01T00:00:00Z",
743
+ updated: "2026-01-01T00:00:00Z",
744
+ },
745
+ ],
746
+ created: "2026-01-01T00:00:00Z",
747
+ updated: "2026-01-01T00:00:00Z",
748
+ });
749
+ saveValidationState(tmpDir, {
750
+ assertions: {
751
+ A1: {
752
+ status: "passing",
753
+ verifiedBy: null,
754
+ verifiedAt: null,
755
+ evidence: null,
756
+ blockedBy: null,
757
+ },
758
+ A2: {
759
+ status: "failing",
760
+ verifiedBy: null,
761
+ verifiedAt: null,
762
+ evidence: null,
763
+ blockedBy: null,
764
+ },
765
+ },
766
+ lastVerified: null,
767
+ });
768
+
769
+ const app = createApp();
770
+ const res = await app.request("/api/project/test-project/mission");
771
+ expect(res.status).toBe(200);
772
+ const body = (await res.json()) as {
773
+ mission: { title: string };
774
+ validationSummary: { total: number; passing: number; failing: number };
775
+ };
776
+ expect(body.mission.title).toBe("Ship v2");
777
+ expect(body.validationSummary.total).toBe(2);
778
+ expect(body.validationSummary.passing).toBe(1);
779
+ expect(body.validationSummary.failing).toBe(1);
780
+ });
781
+ });
782
+
783
+ describe("POST /api/project/:name/mission/plan-complete", () => {
784
+ it("transitions mission from planning to active", async () => {
785
+ saveMission(tmpDir, {
786
+ title: "Test",
787
+ description: "",
788
+ status: "planning",
789
+ branch: null,
790
+ milestones: [
791
+ {
792
+ id: "M1",
793
+ title: "Phase 1",
794
+ description: "",
795
+ status: "locked",
796
+ order: 1,
797
+ created: "2026-01-01T00:00:00Z",
798
+ updated: "2026-01-01T00:00:00Z",
799
+ },
800
+ ],
801
+ created: "2026-01-01T00:00:00Z",
802
+ updated: "2026-01-01T00:00:00Z",
803
+ });
804
+
805
+ const app = createApp();
806
+ const res = await app.request("/api/project/test-project/mission/plan-complete", {
807
+ method: "POST",
808
+ });
809
+ expect(res.status).toBe(200);
810
+ const body = (await res.json()) as {
811
+ ok: boolean;
812
+ mission: { status: string; milestones: Array<{ id: string; status: string }> };
813
+ };
814
+ expect(body.ok).toBe(true);
815
+ expect(body.mission.status).toBe("active");
816
+ expect(body.mission.milestones[0]!.status).toBe("active");
817
+ });
818
+ });