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,1426 @@
1
+ import { execFileSync, execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { Hono } from "hono";
6
+ import { streamSSE } from "hono/streaming";
7
+ import { cors } from "hono/cors";
8
+ import {
9
+ discoverSessions,
10
+ buildOverviews,
11
+ buildProjectDetail,
12
+ buildOrchestratorSnapshot,
13
+ updateTask,
14
+ type SessionOverview,
15
+ type ProjectDetail,
16
+ } from "./discovery.ts";
17
+ import {
18
+ listSessionPanes,
19
+ sendCommand,
20
+ sendText,
21
+ getPaneBusyStatus,
22
+ } from "../widgets/lib/pane-comms.ts";
23
+ import { resolvePane } from "../send.ts";
24
+ import { getSessionState, killSession, stopSessionMonitor } from "../lib/tmux.ts";
25
+ import { readConfig } from "../lib/yaml-io.ts";
26
+ import {
27
+ ensureTasksDir,
28
+ nextTaskId,
29
+ saveTask,
30
+ deleteTask,
31
+ loadMission,
32
+ saveMission,
33
+ loadTasks,
34
+ type Task,
35
+ } from "../lib/task-store.ts";
36
+ import { readEvents, appendEvent } from "../lib/event-log.ts";
37
+ import { extractMarks, calculateStats, tagContent } from "../lib/authorship.ts";
38
+ import {
39
+ loadValidationState,
40
+ loadValidationContract,
41
+ saveValidationState,
42
+ checkCoverage,
43
+ } from "../lib/validation.ts";
44
+ import { loadSkills, loadSkill } from "../lib/skill-registry.ts";
45
+ import { computeMetrics, loadMissionHistory } from "../lib/metrics.ts";
46
+ import { loadPlans, markPlanDone } from "../lib/plan-store.ts";
47
+ import {
48
+ loadCheckpoints,
49
+ loadCheckpoint,
50
+ loadCheckpointsForTask,
51
+ saveCheckpoint,
52
+ deleteCheckpoint,
53
+ nextCheckpointId,
54
+ loadReviews,
55
+ loadReview,
56
+ loadReviewsForTask,
57
+ saveReview,
58
+ deleteReview,
59
+ nextReviewId,
60
+ type Checkpoint,
61
+ type ReviewRequest,
62
+ type ReviewComment,
63
+ } from "../lib/workflow-store.ts";
64
+ import { zValidator } from "@hono/zod-validator";
65
+ import {
66
+ updateTaskSchema,
67
+ createTaskSchema,
68
+ savePlanSchema,
69
+ sendCommandSchema,
70
+ createMilestoneSchema,
71
+ updateMilestoneSchema,
72
+ updateAssertionSchema,
73
+ triggerResearchSchema,
74
+ } from "./schemas.ts";
75
+ import { AuthService } from "../lib/auth/auth-service.ts";
76
+ import { authMiddleware } from "../lib/auth/middleware.ts";
77
+ import type { AuthConfig } from "../lib/auth/types.ts";
78
+ import { TunnelManager } from "../lib/tunnels/manager.ts";
79
+ import { RemoteRegistry } from "../lib/hq/registry.ts";
80
+ import { RegistrationPayloadSchema } from "../lib/hq/types.ts";
81
+ import { dispatchResearch, loadResearchState } from "../lib/research.ts";
82
+
83
+ export interface CreateAppOptions {
84
+ authService?: AuthService;
85
+ authConfig?: AuthConfig;
86
+ tunnelManager?: TunnelManager;
87
+ remoteRegistry?: RemoteRegistry;
88
+ }
89
+
90
+ const ALLOWED_MILESTONE_TRANSITIONS = new Map([
91
+ ["locked", new Set(["active"])],
92
+ ["active", new Set(["validating"])],
93
+ ["validating", new Set(["done", "active"])],
94
+ ["done", new Set<string>()],
95
+ ]);
96
+
97
+ function isValidMilestoneTransition(
98
+ from: "locked" | "active" | "done" | "validating",
99
+ to: "locked" | "active" | "done" | "validating",
100
+ ): boolean {
101
+ if (from === to) return true;
102
+ return ALLOWED_MILESTONE_TRANSITIONS.get(from)?.has(to) ?? false;
103
+ }
104
+
105
+ export function createApp(options: CreateAppOptions = {}): Hono {
106
+ const authConfig: AuthConfig = options.authConfig ?? { method: "none", token_expiry: 86400 };
107
+ const authService = options.authService ?? new AuthService();
108
+
109
+ const app = new Hono();
110
+
111
+ // Allow cross-origin (Next.js dashboard, Tailscale, etc.)
112
+ app.use("/*", cors());
113
+
114
+ // Auth middleware — passes through when method is "none"
115
+ app.use("/*", authMiddleware(authService, authConfig));
116
+
117
+ // Global error handler
118
+ app.onError((err, c) => {
119
+ console.error("[command-center]", err.message);
120
+ return c.json({ error: err.message }, 500);
121
+ });
122
+
123
+ // --- Auth routes (always available, bypassed by middleware) ---
124
+
125
+ app.post("/api/auth/challenge", async (c) => {
126
+ const body = await c.req.json();
127
+ const userId = body.userId ?? authService.getCurrentUser();
128
+ const challenge = authService.createChallenge(userId);
129
+ return c.json(challenge);
130
+ });
131
+
132
+ app.post("/api/auth/verify", async (c) => {
133
+ const body = await c.req.json();
134
+ const result = await authService.authenticateWithSSHKey({
135
+ publicKey: body.publicKey,
136
+ signature: body.signature,
137
+ challengeId: body.challengeId,
138
+ });
139
+ if (!result.success) {
140
+ return c.json({ error: result.error }, 401);
141
+ }
142
+ return c.json({ token: result.token, userId: result.userId });
143
+ });
144
+
145
+ app.post("/api/auth/token", async (c) => {
146
+ if (authConfig.method !== "none") {
147
+ return c.json({ error: "Direct token generation requires auth method 'none'" }, 403);
148
+ }
149
+ const body = await c.req.json();
150
+ const userId = body.userId ?? authService.getCurrentUser();
151
+ const token = authService.generateToken(userId);
152
+ return c.json({ token, userId });
153
+ });
154
+
155
+ // --- API routes ---
156
+
157
+ app.get("/api/sessions", (c) => {
158
+ const sessions = discoverSessions();
159
+ const overviews = buildOverviews(sessions);
160
+ return c.json({ sessions: overviews });
161
+ });
162
+
163
+ app.get("/api/project/:name", (c) => {
164
+ const name = c.req.param("name");
165
+ const sessions = discoverSessions();
166
+ const session = sessions.find((s) => s.name === name);
167
+ if (!session) {
168
+ return c.json({ error: "Session not found" }, 404);
169
+ }
170
+ const detail = buildProjectDetail(session);
171
+ const orchestratorSnapshot = buildOrchestratorSnapshot(session);
172
+ let orchestratorConfig: { enabled: boolean; dispatchMode: string } | null = null;
173
+ try {
174
+ const { config } = readConfig(session.dir);
175
+ const orch = config.orchestrator;
176
+ if (orch) {
177
+ orchestratorConfig = {
178
+ enabled: orch.enabled ?? false,
179
+ dispatchMode: orch.dispatch_mode ?? "tasks",
180
+ };
181
+ }
182
+ } catch {
183
+ // unreadable ide.yml
184
+ }
185
+ return c.json({ ...detail, orchestratorSnapshot, orchestratorConfig });
186
+ });
187
+
188
+ app.get("/api/project/:name/panes", (c) => {
189
+ const name = c.req.param("name");
190
+ let panes: ReturnType<typeof listSessionPanes>;
191
+ try {
192
+ panes = listSessionPanes(name);
193
+ } catch {
194
+ return c.json({ error: "Session not found" }, 404);
195
+ }
196
+ if (panes.length === 0) {
197
+ // Verify the session actually exists before returning empty
198
+ const sessions = discoverSessions();
199
+ if (!sessions.find((s) => s.name === name)) {
200
+ return c.json({ error: "Session not found" }, 404);
201
+ }
202
+ }
203
+ return c.json({
204
+ panes: panes.map((p) => ({
205
+ id: p.id,
206
+ index: p.index,
207
+ title: p.title,
208
+ currentCommand: p.currentCommand,
209
+ width: p.width,
210
+ height: p.height,
211
+ active: p.active,
212
+ role: p.role,
213
+ name: p.name,
214
+ type: p.type,
215
+ })),
216
+ });
217
+ });
218
+
219
+ app.post("/api/project/:name/task/:id", zValidator("json", updateTaskSchema), async (c) => {
220
+ const name = c.req.param("name");
221
+ const taskId = c.req.param("id");
222
+
223
+ const sessions = discoverSessions();
224
+ const session = sessions.find((s) => s.name === name);
225
+ if (!session) {
226
+ return c.json({ error: "Session not found" }, 404);
227
+ }
228
+
229
+ const body = c.req.valid("json");
230
+ const updated = updateTask(session.dir, taskId, body);
231
+ if (!updated) {
232
+ return c.json({ error: "Task not found" }, 404);
233
+ }
234
+
235
+ return c.json({ ok: true, task: updated });
236
+ });
237
+
238
+ // Create task
239
+ app.post("/api/project/:name/task", zValidator("json", createTaskSchema), async (c) => {
240
+ const name = c.req.param("name");
241
+ const sessions = discoverSessions();
242
+ const session = sessions.find((s) => s.name === name);
243
+ if (!session) {
244
+ return c.json({ error: "Session not found" }, 404);
245
+ }
246
+
247
+ const body = c.req.valid("json");
248
+
249
+ ensureTasksDir(session.dir);
250
+ const id = nextTaskId(session.dir);
251
+ const now = new Date().toISOString();
252
+ const task: Task = {
253
+ id,
254
+ title: body.title.trim(),
255
+ description: body.description ?? "",
256
+ goal: body.goal ?? null,
257
+ status: "todo",
258
+ assignee: null,
259
+ priority: body.priority ?? 2,
260
+ created: now,
261
+ updated: now,
262
+ tags: body.tags ?? [],
263
+ proof: null,
264
+ depends_on: [],
265
+ retryCount: 0,
266
+ maxRetries: 5,
267
+ lastError: null,
268
+ nextRetryAt: null,
269
+ milestone: null,
270
+ specialty: null,
271
+ fulfills: [],
272
+ discoveredIssues: [],
273
+ salientSummary: null,
274
+ };
275
+ saveTask(session.dir, task);
276
+ return c.json({ ok: true, task }, 201);
277
+ });
278
+
279
+ // Delete task
280
+ app.delete("/api/project/:name/task/:id", (c) => {
281
+ const name = c.req.param("name");
282
+ const taskId = c.req.param("id");
283
+ const sessions = discoverSessions();
284
+ const session = sessions.find((s) => s.name === name);
285
+ if (!session) {
286
+ return c.json({ error: "Session not found" }, 404);
287
+ }
288
+
289
+ if (!deleteTask(session.dir, taskId)) {
290
+ return c.json({ error: "Task not found" }, 404);
291
+ }
292
+ return c.json({ ok: true, deleted: taskId });
293
+ });
294
+
295
+ // List plan files with status metadata
296
+ app.get("/api/project/:name/plans", (c) => {
297
+ const name = c.req.param("name");
298
+ const sessions = discoverSessions();
299
+ const session = sessions.find((s) => s.name === name);
300
+ if (!session) {
301
+ return c.json({ error: "Session not found" }, 404);
302
+ }
303
+
304
+ const plans = loadPlans(session.dir).map((p) => ({
305
+ name: p.name,
306
+ path: `${p.name}.md`,
307
+ title: p.title,
308
+ status: p.status,
309
+ effort: p.effort ?? null,
310
+ completed: p.completed ?? null,
311
+ }));
312
+
313
+ return c.json({ plans });
314
+ });
315
+
316
+ // Read a single plan file
317
+ app.get("/api/project/:name/plans/:filename", (c) => {
318
+ const name = c.req.param("name");
319
+ const filename = c.req.param("filename");
320
+ const sessions = discoverSessions();
321
+ const session = sessions.find((s) => s.name === name);
322
+ if (!session) {
323
+ return c.json({ error: "Session not found" }, 404);
324
+ }
325
+
326
+ // Sanitize filename — no path traversal
327
+ const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "");
328
+ const filePath = join(
329
+ session.dir,
330
+ "plans",
331
+ safeName.endsWith(".md") ? safeName : `${safeName}.md`,
332
+ );
333
+
334
+ if (!existsSync(filePath)) {
335
+ return c.json({ error: "Plan not found" }, 404);
336
+ }
337
+
338
+ const raw = readFileSync(filePath, "utf-8");
339
+ const { content, marks } = extractMarks(raw);
340
+ const stats = marks ? calculateStats(marks.marks) : null;
341
+ return c.json({
342
+ name: safeName.replace(/\.md$/, ""),
343
+ content,
344
+ marks: marks?.marks ?? null,
345
+ stats,
346
+ });
347
+ });
348
+
349
+ // Save a plan file (with authorship tagging)
350
+ app.post("/api/project/:name/plans/:filename", zValidator("json", savePlanSchema), async (c) => {
351
+ const name = c.req.param("name");
352
+ const filename = c.req.param("filename");
353
+ const sessions = discoverSessions();
354
+ const session = sessions.find((s) => s.name === name);
355
+ if (!session) {
356
+ return c.json({ error: "Session not found" }, 404);
357
+ }
358
+
359
+ const body = c.req.valid("json");
360
+
361
+ const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "");
362
+ const filePath = join(
363
+ session.dir,
364
+ "plans",
365
+ safeName.endsWith(".md") ? safeName : `${safeName}.md`,
366
+ );
367
+ const plansDir = join(session.dir, "plans");
368
+ if (!existsSync(plansDir)) mkdirSync(plansDir, { recursive: true });
369
+
370
+ // Auto-tag uncovered character ranges as human-authored
371
+ const tagged = tagContent(body.content, "human");
372
+ writeFileSync(filePath, tagged);
373
+
374
+ return c.json({ ok: true });
375
+ });
376
+
377
+ // Delete a plan file
378
+ app.delete("/api/project/:name/plans/:filename", (c) => {
379
+ const name = c.req.param("name");
380
+ const filename = c.req.param("filename");
381
+ const sessions = discoverSessions();
382
+ const session = sessions.find((s) => s.name === name);
383
+ if (!session) {
384
+ return c.json({ error: "Session not found" }, 404);
385
+ }
386
+
387
+ const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "");
388
+ const filePath = join(
389
+ session.dir,
390
+ "plans",
391
+ safeName.endsWith(".md") ? safeName : `${safeName}.md`,
392
+ );
393
+
394
+ if (!existsSync(filePath)) {
395
+ return c.json({ error: "Plan not found" }, 404);
396
+ }
397
+
398
+ unlinkSync(filePath);
399
+ return c.json({ ok: true, deleted: safeName });
400
+ });
401
+
402
+ // Mark a plan as done
403
+ app.post("/api/project/:name/plans/:filename/done", (c) => {
404
+ const name = c.req.param("name");
405
+ const filename = c.req.param("filename");
406
+ const sessions = discoverSessions();
407
+ const session = sessions.find((s) => s.name === name);
408
+ if (!session) {
409
+ return c.json({ error: "Session not found" }, 404);
410
+ }
411
+
412
+ const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "").replace(/\.md$/, "");
413
+ const result = markPlanDone(session.dir, safeName);
414
+ if (!result) {
415
+ return c.json({ error: "Plan not found" }, 404);
416
+ }
417
+
418
+ return c.json({ ok: true, plan: result });
419
+ });
420
+
421
+ // --- Checkpoints ---
422
+
423
+ app.get("/api/project/:name/checkpoints", (c) => {
424
+ const name = c.req.param("name");
425
+ const sessions = discoverSessions();
426
+ const session = sessions.find((s) => s.name === name);
427
+ if (!session) return c.json({ error: "Session not found" }, 404);
428
+ const taskId = c.req.query("task");
429
+ const list = taskId
430
+ ? loadCheckpointsForTask(session.dir, taskId)
431
+ : loadCheckpoints(session.dir);
432
+ return c.json({ checkpoints: list });
433
+ });
434
+
435
+ app.get("/api/project/:name/checkpoints/:id", (c) => {
436
+ const name = c.req.param("name");
437
+ const id = c.req.param("id");
438
+ const sessions = discoverSessions();
439
+ const session = sessions.find((s) => s.name === name);
440
+ if (!session) return c.json({ error: "Session not found" }, 404);
441
+ const cp = loadCheckpoint(session.dir, id);
442
+ if (!cp) return c.json({ error: "Checkpoint not found" }, 404);
443
+ return c.json({ checkpoint: cp });
444
+ });
445
+
446
+ app.post("/api/project/:name/checkpoints", async (c) => {
447
+ const name = c.req.param("name");
448
+ const sessions = discoverSessions();
449
+ const session = sessions.find((s) => s.name === name);
450
+ if (!session) return c.json({ error: "Session not found" }, 404);
451
+ const body = await c.req.json();
452
+ const now = new Date().toISOString();
453
+ const id = nextCheckpointId(session.dir);
454
+ const checkpoint: Checkpoint = {
455
+ id,
456
+ taskId: body.taskId ?? "",
457
+ title: body.title ?? `Checkpoint ${id}`,
458
+ description: body.description ?? "",
459
+ status: "pending",
460
+ createdBy: body.createdBy ?? "",
461
+ reviewedBy: null,
462
+ created: now,
463
+ updated: now,
464
+ diff: body.diff ?? null,
465
+ files: body.files ?? [],
466
+ comments: [],
467
+ };
468
+ saveCheckpoint(session.dir, checkpoint);
469
+ return c.json({ ok: true, checkpoint }, 201);
470
+ });
471
+
472
+ app.post("/api/project/:name/checkpoints/:id", async (c) => {
473
+ const name = c.req.param("name");
474
+ const id = c.req.param("id");
475
+ const sessions = discoverSessions();
476
+ const session = sessions.find((s) => s.name === name);
477
+ if (!session) return c.json({ error: "Session not found" }, 404);
478
+ const existing = loadCheckpoint(session.dir, id);
479
+ if (!existing) return c.json({ error: "Checkpoint not found" }, 404);
480
+ const body = await c.req.json();
481
+ const updated: Checkpoint = {
482
+ ...existing,
483
+ ...body,
484
+ id: existing.id,
485
+ created: existing.created,
486
+ updated: new Date().toISOString(),
487
+ };
488
+ saveCheckpoint(session.dir, updated);
489
+ return c.json({ ok: true, checkpoint: updated });
490
+ });
491
+
492
+ app.delete("/api/project/:name/checkpoints/:id", (c) => {
493
+ const name = c.req.param("name");
494
+ const id = c.req.param("id");
495
+ const sessions = discoverSessions();
496
+ const session = sessions.find((s) => s.name === name);
497
+ if (!session) return c.json({ error: "Session not found" }, 404);
498
+ if (!deleteCheckpoint(session.dir, id)) return c.json({ error: "Checkpoint not found" }, 404);
499
+ return c.json({ ok: true, deleted: id });
500
+ });
501
+
502
+ // --- Reviews ---
503
+
504
+ app.get("/api/project/:name/reviews", (c) => {
505
+ const name = c.req.param("name");
506
+ const sessions = discoverSessions();
507
+ const session = sessions.find((s) => s.name === name);
508
+ if (!session) return c.json({ error: "Session not found" }, 404);
509
+ const taskId = c.req.query("task");
510
+ const list = taskId ? loadReviewsForTask(session.dir, taskId) : loadReviews(session.dir);
511
+ return c.json({ reviews: list });
512
+ });
513
+
514
+ app.get("/api/project/:name/reviews/:id", (c) => {
515
+ const name = c.req.param("name");
516
+ const id = c.req.param("id");
517
+ const sessions = discoverSessions();
518
+ const session = sessions.find((s) => s.name === name);
519
+ if (!session) return c.json({ error: "Session not found" }, 404);
520
+ const review = loadReview(session.dir, id);
521
+ if (!review) return c.json({ error: "Review not found" }, 404);
522
+ return c.json({ review });
523
+ });
524
+
525
+ app.post("/api/project/:name/reviews", async (c) => {
526
+ const name = c.req.param("name");
527
+ const sessions = discoverSessions();
528
+ const session = sessions.find((s) => s.name === name);
529
+ if (!session) return c.json({ error: "Session not found" }, 404);
530
+ const body = await c.req.json();
531
+ const now = new Date().toISOString();
532
+ const id = nextReviewId(session.dir);
533
+ const review: ReviewRequest = {
534
+ id,
535
+ taskId: body.taskId ?? "",
536
+ checkpointId: body.checkpointId ?? null,
537
+ title: body.title ?? `Review ${id}`,
538
+ description: body.description ?? "",
539
+ status: "open",
540
+ requestedBy: body.requestedBy ?? "",
541
+ reviewer: body.reviewer ?? null,
542
+ created: now,
543
+ updated: now,
544
+ comments: [],
545
+ };
546
+ saveReview(session.dir, review);
547
+ return c.json({ ok: true, review }, 201);
548
+ });
549
+
550
+ app.post("/api/project/:name/reviews/:id", async (c) => {
551
+ const name = c.req.param("name");
552
+ const id = c.req.param("id");
553
+ const sessions = discoverSessions();
554
+ const session = sessions.find((s) => s.name === name);
555
+ if (!session) return c.json({ error: "Session not found" }, 404);
556
+ const existing = loadReview(session.dir, id);
557
+ if (!existing) return c.json({ error: "Review not found" }, 404);
558
+ const body = await c.req.json();
559
+ const updated: ReviewRequest = {
560
+ ...existing,
561
+ ...body,
562
+ id: existing.id,
563
+ created: existing.created,
564
+ updated: new Date().toISOString(),
565
+ };
566
+ saveReview(session.dir, updated);
567
+ return c.json({ ok: true, review: updated });
568
+ });
569
+
570
+ app.post("/api/project/:name/reviews/:id/comment", async (c) => {
571
+ const name = c.req.param("name");
572
+ const id = c.req.param("id");
573
+ const sessions = discoverSessions();
574
+ const session = sessions.find((s) => s.name === name);
575
+ if (!session) return c.json({ error: "Session not found" }, 404);
576
+ const existing = loadReview(session.dir, id);
577
+ if (!existing) return c.json({ error: "Review not found" }, 404);
578
+ const body = await c.req.json();
579
+ const comment: ReviewComment = {
580
+ author: body.author ?? "",
581
+ body: body.body ?? "",
582
+ created: new Date().toISOString(),
583
+ };
584
+ existing.comments.push(comment);
585
+ existing.updated = comment.created;
586
+ saveReview(session.dir, existing);
587
+ return c.json({ ok: true, review: existing });
588
+ });
589
+
590
+ app.delete("/api/project/:name/reviews/:id", (c) => {
591
+ const name = c.req.param("name");
592
+ const id = c.req.param("id");
593
+ const sessions = discoverSessions();
594
+ const session = sessions.find((s) => s.name === name);
595
+ if (!session) return c.json({ error: "Session not found" }, 404);
596
+ if (!deleteReview(session.dir, id)) return c.json({ error: "Review not found" }, 404);
597
+ return c.json({ ok: true, deleted: id });
598
+ });
599
+
600
+ app.get("/api/project/:name/diff", (c) => {
601
+ const name = c.req.param("name");
602
+ const sessions = discoverSessions();
603
+ const session = sessions.find((s) => s.name === name);
604
+ if (!session) {
605
+ return c.json({ error: "Session not found" }, 404);
606
+ }
607
+
608
+ // Get git diff (staged + unstaged vs HEAD)
609
+ let diff = "";
610
+ try {
611
+ diff = execFileSync("git", ["diff", "HEAD"], {
612
+ cwd: session.dir,
613
+ encoding: "utf-8",
614
+ stdio: ["ignore", "pipe", "ignore"],
615
+ });
616
+ } catch {
617
+ // No HEAD yet or not a git repo — try unstaged only
618
+ }
619
+ if (!diff) {
620
+ try {
621
+ diff = execFileSync("git", ["diff"], {
622
+ cwd: session.dir,
623
+ encoding: "utf-8",
624
+ stdio: ["ignore", "pipe", "ignore"],
625
+ });
626
+ } catch {
627
+ // Not a git repo
628
+ }
629
+ }
630
+
631
+ // Get list of changed files with stats
632
+ interface DiffFile {
633
+ file: string;
634
+ additions: number;
635
+ deletions: number;
636
+ }
637
+ let files: DiffFile[] = [];
638
+ try {
639
+ const numstat = execFileSync("git", ["diff", "--numstat", "HEAD"], {
640
+ cwd: session.dir,
641
+ encoding: "utf-8",
642
+ stdio: ["ignore", "pipe", "ignore"],
643
+ });
644
+ files = numstat
645
+ .split("\n")
646
+ .filter(Boolean)
647
+ .map((line) => {
648
+ const [added, removed, file] = line.split("\t");
649
+ return {
650
+ file: file!,
651
+ additions: parseInt(added!, 10) || 0,
652
+ deletions: parseInt(removed!, 10) || 0,
653
+ };
654
+ });
655
+ } catch {
656
+ // Fall back to unstaged
657
+ try {
658
+ const numstat = execFileSync("git", ["diff", "--numstat"], {
659
+ cwd: session.dir,
660
+ encoding: "utf-8",
661
+ stdio: ["ignore", "pipe", "ignore"],
662
+ });
663
+ files = numstat
664
+ .split("\n")
665
+ .filter(Boolean)
666
+ .map((line) => {
667
+ const [added, removed, file] = line.split("\t");
668
+ return {
669
+ file: file!,
670
+ additions: parseInt(added!, 10) || 0,
671
+ deletions: parseInt(removed!, 10) || 0,
672
+ };
673
+ });
674
+ } catch {
675
+ // Not a git repo
676
+ }
677
+ }
678
+
679
+ return c.json({ diff, files });
680
+ });
681
+
682
+ // Per-file diff endpoint
683
+ app.get("/api/project/:name/diff/:file{.+}", (c) => {
684
+ const name = c.req.param("name");
685
+ const file = c.req.param("file");
686
+ const sessions = discoverSessions();
687
+ const session = sessions.find((s) => s.name === name);
688
+ if (!session) return c.json({ error: "Session not found" }, 404);
689
+
690
+ let diff = "";
691
+ try {
692
+ diff = execFileSync("git", ["diff", "HEAD", "--", file], {
693
+ cwd: session.dir,
694
+ encoding: "utf-8",
695
+ stdio: ["ignore", "pipe", "ignore"],
696
+ });
697
+ } catch {
698
+ // no committed diff
699
+ }
700
+ if (!diff) {
701
+ try {
702
+ diff = execFileSync("git", ["diff", "--", file], {
703
+ cwd: session.dir,
704
+ encoding: "utf-8",
705
+ stdio: ["ignore", "pipe", "ignore"],
706
+ });
707
+ } catch {
708
+ // no working-tree diff
709
+ }
710
+ }
711
+
712
+ return c.json({ file, diff });
713
+ });
714
+
715
+ // --- Milestone endpoints ---
716
+
717
+ app.get("/api/project/:name/milestones", (c) => {
718
+ const name = c.req.param("name");
719
+ const sessions = discoverSessions();
720
+ const session = sessions.find((s) => s.name === name);
721
+ if (!session) return c.json({ error: "Session not found" }, 404);
722
+ const mission = loadMission(session.dir);
723
+ if (!mission) return c.json({ milestones: [] });
724
+ const tasks = loadTasks(session.dir);
725
+ const milestones = [...mission.milestones]
726
+ .sort((a, b) => a.order - b.order)
727
+ .map((m) => {
728
+ const mTasks = tasks.filter((t) => t.milestone === m.id);
729
+ return {
730
+ ...m,
731
+ taskCount: mTasks.length,
732
+ tasksDone: mTasks.filter((t) => t.status === "done").length,
733
+ };
734
+ });
735
+ return c.json({ milestones });
736
+ });
737
+
738
+ app.get("/api/project/:name/milestones/:id", (c) => {
739
+ const name = c.req.param("name");
740
+ const milestoneId = c.req.param("id");
741
+ const sessions = discoverSessions();
742
+ const session = sessions.find((s) => s.name === name);
743
+ if (!session) return c.json({ error: "Session not found" }, 404);
744
+ const mission = loadMission(session.dir);
745
+ if (!mission) return c.json({ error: "No mission" }, 404);
746
+ const milestone = mission.milestones.find((m) => m.id === milestoneId);
747
+ if (!milestone) return c.json({ error: "Milestone not found" }, 404);
748
+ const tasks = loadTasks(session.dir).filter((t) => t.milestone === milestoneId);
749
+ return c.json({ milestone, tasks });
750
+ });
751
+
752
+ app.post(
753
+ "/api/project/:name/milestones",
754
+ zValidator("json", createMilestoneSchema),
755
+ async (c) => {
756
+ const name = c.req.param("name");
757
+ const sessions = discoverSessions();
758
+ const session = sessions.find((s) => s.name === name);
759
+ if (!session) return c.json({ error: "Session not found" }, 404);
760
+ const mission = loadMission(session.dir);
761
+ if (!mission) return c.json({ error: "No mission" }, 404);
762
+ const body = c.req.valid("json");
763
+ const id = `M${body.sequence}`;
764
+ if (mission.milestones.find((m) => m.id === id)) {
765
+ return c.json({ error: `Milestone ${id} already exists` }, 409);
766
+ }
767
+ const now = new Date().toISOString();
768
+ const hasActive = mission.milestones.some(
769
+ (m) => m.status === "active" || m.status === "done",
770
+ );
771
+ const milestone = {
772
+ id,
773
+ title: body.title,
774
+ description: body.description ?? "",
775
+ status: (hasActive ? "locked" : "active") as "locked" | "active",
776
+ order: body.sequence,
777
+ created: now,
778
+ updated: now,
779
+ };
780
+ mission.milestones.push(milestone);
781
+ mission.milestones.sort((a, b) => a.order - b.order);
782
+ mission.updated = now;
783
+ saveMission(session.dir, mission);
784
+ return c.json({ ok: true, milestone }, 201);
785
+ },
786
+ );
787
+
788
+ app.post(
789
+ "/api/project/:name/milestones/:id",
790
+ zValidator("json", updateMilestoneSchema),
791
+ async (c) => {
792
+ const name = c.req.param("name");
793
+ const milestoneId = c.req.param("id");
794
+ const sessions = discoverSessions();
795
+ const session = sessions.find((s) => s.name === name);
796
+ if (!session) return c.json({ error: "Session not found" }, 404);
797
+ const mission = loadMission(session.dir);
798
+ if (!mission) return c.json({ error: "No mission" }, 404);
799
+ const milestone = mission.milestones.find((m) => m.id === milestoneId);
800
+ if (!milestone) return c.json({ error: "Milestone not found" }, 404);
801
+ const body = c.req.valid("json");
802
+ if (body.status && !isValidMilestoneTransition(milestone.status, body.status)) {
803
+ return c.json(
804
+ {
805
+ error: `Invalid milestone status transition: ${milestone.status} -> ${body.status}`,
806
+ },
807
+ 409,
808
+ );
809
+ }
810
+ if (body.status) milestone.status = body.status;
811
+ if (body.title) milestone.title = body.title;
812
+ if (body.description !== undefined) milestone.description = body.description;
813
+ milestone.updated = new Date().toISOString();
814
+ mission.updated = milestone.updated;
815
+ saveMission(session.dir, mission);
816
+ return c.json({ ok: true, milestone });
817
+ },
818
+ );
819
+
820
+ // --- Validation endpoints ---
821
+
822
+ app.get("/api/project/:name/validation", (c) => {
823
+ const name = c.req.param("name");
824
+ const sessions = discoverSessions();
825
+ const session = sessions.find((s) => s.name === name);
826
+ if (!session) return c.json({ error: "Session not found" }, 404);
827
+ const state = loadValidationState(session.dir);
828
+ const contract = loadValidationContract(session.dir);
829
+ return c.json({ contract: contract ?? null, state: state ?? null });
830
+ });
831
+
832
+ app.get("/api/project/:name/validation/coverage", (c) => {
833
+ const name = c.req.param("name");
834
+ const sessions = discoverSessions();
835
+ const session = sessions.find((s) => s.name === name);
836
+ if (!session) return c.json({ error: "Session not found" }, 404);
837
+ return c.json(checkCoverage(session.dir));
838
+ });
839
+
840
+ app.post(
841
+ "/api/project/:name/validation/assert/:assertId",
842
+ zValidator("json", updateAssertionSchema),
843
+ async (c) => {
844
+ const name = c.req.param("name");
845
+ const assertId = c.req.param("assertId");
846
+ const sessions = discoverSessions();
847
+ const session = sessions.find((s) => s.name === name);
848
+ if (!session) return c.json({ error: "Session not found" }, 404);
849
+ const body = c.req.valid("json");
850
+ ensureTasksDir(session.dir);
851
+ const state = loadValidationState(session.dir) ?? { assertions: {}, lastVerified: null };
852
+ state.assertions[assertId] = {
853
+ status: body.status,
854
+ verifiedBy: body.verifiedBy ?? null,
855
+ verifiedAt: new Date().toISOString(),
856
+ evidence: body.evidence ?? null,
857
+ blockedBy: body.status === "blocked" ? (body.evidence ?? null) : null,
858
+ };
859
+ state.lastVerified = new Date().toISOString();
860
+ saveValidationState(session.dir, state);
861
+ return c.json({ ok: true, assertionId: assertId, ...state.assertions[assertId] });
862
+ },
863
+ );
864
+
865
+ app.get("/api/project/:name/research", (c) => {
866
+ const name = c.req.param("name");
867
+ const sessions = discoverSessions();
868
+ const session = sessions.find((s) => s.name === name);
869
+ if (!session) return c.json({ error: "Session not found" }, 404);
870
+
871
+ const state = loadResearchState(session.dir);
872
+ const tasks = loadTasks(session.dir);
873
+ const activeTask =
874
+ state.activeResearchTaskId != null
875
+ ? (tasks.find((task) => task.id === state.activeResearchTaskId) ?? null)
876
+ : null;
877
+ const findings = tasks
878
+ .filter((task) => task.tags.includes("research") && task.status === "done")
879
+ .sort((a, b) => Date.parse(b.updated) - Date.parse(a.updated))
880
+ .slice(0, 10);
881
+
882
+ return c.json({ state, activeTask, findings });
883
+ });
884
+
885
+ app.post(
886
+ "/api/project/:name/research/trigger",
887
+ zValidator("json", triggerResearchSchema),
888
+ async (c) => {
889
+ const name = c.req.param("name");
890
+ const sessions = discoverSessions();
891
+ const session = sessions.find((s) => s.name === name);
892
+ if (!session) return c.json({ error: "Session not found" }, 404);
893
+
894
+ const body = c.req.valid("json");
895
+ const tasks = loadTasks(session.dir);
896
+ const researchState = loadResearchState(session.dir);
897
+
898
+ let maxConcurrentAgents = 10;
899
+ let masterPane: string | null = null;
900
+ let researchEnabled = true;
901
+ try {
902
+ const { config } = readConfig(session.dir);
903
+ maxConcurrentAgents = config.orchestrator?.max_concurrent_agents ?? 10;
904
+ masterPane = config.orchestrator?.master_pane ?? null;
905
+ researchEnabled = config.orchestrator?.research?.enabled ?? true;
906
+ } catch {
907
+ // ide.yml unreadable — use defaults
908
+ }
909
+
910
+ const task = dispatchResearch(
911
+ {
912
+ session: name,
913
+ dir: session.dir,
914
+ masterPane,
915
+ maxConcurrentAgents,
916
+ research: { enabled: researchEnabled },
917
+ },
918
+ {
919
+ lastActivity: new Map(),
920
+ previousTasks: new Map(),
921
+ claimedTasks: new Set(),
922
+ taskClaimTimes: new Map(),
923
+ },
924
+ researchState,
925
+ tasks,
926
+ listSessionPanes(name),
927
+ {
928
+ type: body.type,
929
+ reason: `Manual research trigger: ${body.type}`,
930
+ },
931
+ );
932
+
933
+ if (!task) {
934
+ return c.json({ error: `Unable to dispatch research trigger "${body.type}"` }, 409);
935
+ }
936
+
937
+ return c.json({ ok: true, task }, 201);
938
+ },
939
+ );
940
+
941
+ // --- Skill endpoints ---
942
+
943
+ app.get("/api/project/:name/skills", (c) => {
944
+ const name = c.req.param("name");
945
+ const sessions = discoverSessions();
946
+ const session = sessions.find((s) => s.name === name);
947
+ if (!session) return c.json({ error: "Session not found" }, 404);
948
+ const skills = loadSkills(session.dir);
949
+ return c.json({ skills });
950
+ });
951
+
952
+ app.get("/api/project/:name/skills/:skillName", (c) => {
953
+ const name = c.req.param("name");
954
+ const skillName = c.req.param("skillName");
955
+ const sessions = discoverSessions();
956
+ const session = sessions.find((s) => s.name === name);
957
+ if (!session) return c.json({ error: "Session not found" }, 404);
958
+ const skill = loadSkill(session.dir, skillName);
959
+ if (!skill) return c.json({ error: "Skill not found" }, 404);
960
+ return c.json({ skill });
961
+ });
962
+
963
+ // --- Mission endpoints ---
964
+
965
+ app.get("/api/project/:name/mission", (c) => {
966
+ const name = c.req.param("name");
967
+ const sessions = discoverSessions();
968
+ const session = sessions.find((s) => s.name === name);
969
+ if (!session) return c.json({ error: "Session not found" }, 404);
970
+ const mission = loadMission(session.dir);
971
+ if (!mission) return c.json({ error: "No mission" }, 404);
972
+ const valState = loadValidationState(session.dir);
973
+ const assertions = valState ? Object.values(valState.assertions) : [];
974
+ const validationSummary = {
975
+ total: assertions.length,
976
+ passing: assertions.filter((a) => a.status === "passing").length,
977
+ failing: assertions.filter((a) => a.status === "failing").length,
978
+ pending: assertions.filter((a) => a.status === "pending").length,
979
+ blocked: assertions.filter((a) => a.status === "blocked").length,
980
+ };
981
+ return c.json({ mission, validationSummary });
982
+ });
983
+
984
+ app.post("/api/project/:name/mission/plan-complete", async (c) => {
985
+ const name = c.req.param("name");
986
+ const sessions = discoverSessions();
987
+ const session = sessions.find((s) => s.name === name);
988
+ if (!session) return c.json({ error: "Session not found" }, 404);
989
+ const mission = loadMission(session.dir);
990
+ if (!mission) return c.json({ error: "No mission" }, 404);
991
+ if (mission.status !== "planning") {
992
+ return c.json({ error: `Mission is "${mission.status}", expected "planning"` }, 409);
993
+ }
994
+ mission.status = "active";
995
+ const sorted = [...mission.milestones].sort((a, b) => a.order - b.order);
996
+ const first = sorted.find((m) => m.status === "locked");
997
+ if (first) {
998
+ first.status = "active";
999
+ first.updated = new Date().toISOString();
1000
+ }
1001
+ mission.updated = new Date().toISOString();
1002
+ saveMission(session.dir, mission);
1003
+ return c.json({ ok: true, mission });
1004
+ });
1005
+
1006
+ // --- Metrics endpoints ---
1007
+
1008
+ app.get("/api/project/:name/metrics", (c) => {
1009
+ const name = c.req.param("name");
1010
+ const sessions = discoverSessions();
1011
+ const session = sessions.find((s) => s.name === name);
1012
+ if (!session) return c.json({ error: "Session not found" }, 404);
1013
+ return c.json(computeMetrics(session.dir));
1014
+ });
1015
+
1016
+ app.get("/api/project/:name/metrics/agents", (c) => {
1017
+ const name = c.req.param("name");
1018
+ const sessions = discoverSessions();
1019
+ const session = sessions.find((s) => s.name === name);
1020
+ if (!session) return c.json({ error: "Session not found" }, 404);
1021
+ return c.json({ agents: computeMetrics(session.dir).agents });
1022
+ });
1023
+
1024
+ app.get("/api/project/:name/metrics/timeline", (c) => {
1025
+ const name = c.req.param("name");
1026
+ const sessions = discoverSessions();
1027
+ const session = sessions.find((s) => s.name === name);
1028
+ if (!session) return c.json({ error: "Session not found" }, 404);
1029
+ return c.json({ timeline: computeMetrics(session.dir).timeline });
1030
+ });
1031
+
1032
+ app.get("/api/project/:name/metrics/history", (c) => {
1033
+ const name = c.req.param("name");
1034
+ const sessions = discoverSessions();
1035
+ const session = sessions.find((s) => s.name === name);
1036
+ if (!session) return c.json({ error: "Session not found" }, 404);
1037
+ return c.json({ history: loadMissionHistory(session.dir) });
1038
+ });
1039
+
1040
+ // Events endpoint — returns recent orchestrator events
1041
+ app.get("/api/project/:name/events", (c) => {
1042
+ const name = c.req.param("name");
1043
+ const sessions = discoverSessions();
1044
+ const session = sessions.find((s) => s.name === name);
1045
+ if (!session) {
1046
+ return c.json({ error: "Session not found" }, 404);
1047
+ }
1048
+
1049
+ const allEvents = readEvents(session.dir);
1050
+ // Return last 50 events, newest first
1051
+ const recent = allEvents.slice(-50).reverse();
1052
+
1053
+ // Add relative timestamps
1054
+ const now = Date.now();
1055
+ const withRelative = recent.map((e) => {
1056
+ const ms = now - new Date(e.timestamp).getTime();
1057
+ let relative: string;
1058
+ if (ms < 60000) relative = `${Math.floor(ms / 1000)}s ago`;
1059
+ else if (ms < 3600000) relative = `${Math.floor(ms / 60000)}m ago`;
1060
+ else if (ms < 86400000) relative = `${Math.floor(ms / 3600000)}h ago`;
1061
+ else relative = `${Math.floor(ms / 86400000)}d ago`;
1062
+ return { ...e, relative };
1063
+ });
1064
+
1065
+ return c.json({ events: withRelative });
1066
+ });
1067
+
1068
+ // --- Remote command execution endpoints ---
1069
+
1070
+ // Send message to a pane by name/title/role/ID
1071
+ app.post("/api/project/:name/send", zValidator("json", sendCommandSchema), async (c) => {
1072
+ const name = c.req.param("name");
1073
+ const sessions = discoverSessions();
1074
+ const session = sessions.find((s) => s.name === name);
1075
+ if (!session) {
1076
+ return c.json({ error: "Session not found" }, 404);
1077
+ }
1078
+
1079
+ const { target, message, noEnter } = c.req.valid("json");
1080
+
1081
+ const panes = listSessionPanes(name);
1082
+ const pane = resolvePane(panes, target);
1083
+ if (!pane) {
1084
+ const available = panes.map((p) => ({
1085
+ id: p.id,
1086
+ title: p.title,
1087
+ name: p.name,
1088
+ role: p.role,
1089
+ }));
1090
+ return c.json({ error: "Pane not found", target, available }, 404);
1091
+ }
1092
+
1093
+ const busyStatus = getPaneBusyStatus(name, pane.id);
1094
+
1095
+ // Collapse multiline for agent panes
1096
+ const prepared = busyStatus === "agent" ? message.replace(/\n+/g, " ").trim() : message;
1097
+
1098
+ if (noEnter) {
1099
+ sendText(name, pane.id, prepared);
1100
+ } else {
1101
+ sendCommand(name, pane.id, prepared);
1102
+ }
1103
+
1104
+ appendEvent(session.dir, {
1105
+ timestamp: new Date().toISOString(),
1106
+ type: "send",
1107
+ target: pane.name ?? pane.title,
1108
+ paneId: pane.id,
1109
+ message: prepared.length > 100 ? prepared.slice(0, 100) + "..." : prepared,
1110
+ });
1111
+
1112
+ return c.json({
1113
+ ok: true,
1114
+ session: name,
1115
+ target: {
1116
+ paneId: pane.id,
1117
+ name: pane.name,
1118
+ title: pane.title,
1119
+ role: pane.role,
1120
+ },
1121
+ busyStatus,
1122
+ });
1123
+ });
1124
+
1125
+ // Launch a tmux-ide session (shells out to CLI since launch has complex side effects)
1126
+ const execFileAsync = promisify(execFile);
1127
+
1128
+ app.post("/api/project/:name/launch", async (c) => {
1129
+ const name = c.req.param("name");
1130
+ const sessions = discoverSessions();
1131
+ const session = sessions.find((s) => s.name === name);
1132
+ if (!session) {
1133
+ return c.json({ error: "Session not found" }, 404);
1134
+ }
1135
+
1136
+ // Check if already running
1137
+ const state = getSessionState(name);
1138
+ if (state.running) {
1139
+ return c.json({ ok: true, session: name, status: "already_running" });
1140
+ }
1141
+
1142
+ try {
1143
+ await execFileAsync("tmux-ide", ["--json"], {
1144
+ cwd: session.dir,
1145
+ timeout: 30000,
1146
+ env: { ...process.env, TMUX: "" }, // Clear TMUX to avoid nesting
1147
+ });
1148
+ return c.json({ ok: true, session: name, status: "launched" });
1149
+ } catch (err: unknown) {
1150
+ const message = err instanceof Error ? err.message : String(err);
1151
+ return c.json({ error: "Launch failed", detail: message }, 500);
1152
+ }
1153
+ });
1154
+
1155
+ // Stop a tmux-ide session
1156
+ app.post("/api/project/:name/stop", async (c) => {
1157
+ const name = c.req.param("name");
1158
+ const sessions = discoverSessions();
1159
+ const session = sessions.find((s) => s.name === name);
1160
+ if (!session) {
1161
+ return c.json({ error: "Session not found" }, 404);
1162
+ }
1163
+
1164
+ const state = getSessionState(name);
1165
+ if (!state.running) {
1166
+ return c.json({ ok: true, session: name, status: "not_running" });
1167
+ }
1168
+
1169
+ stopSessionMonitor(name);
1170
+ const result = killSession(name);
1171
+ if (result.stopped) {
1172
+ return c.json({ ok: true, session: name, status: "stopped" });
1173
+ }
1174
+ return c.json({ error: "Stop failed", reason: result.reason }, 500);
1175
+ });
1176
+
1177
+ // SSE endpoint — cursor-based event streaming with orchestrator state
1178
+ app.get("/api/events", (c) => {
1179
+ return streamSSE(c, async (stream) => {
1180
+ let prevOverviews: SessionOverview[] = [];
1181
+ let prevDetails = new Map<string, ProjectDetail>();
1182
+ const eventCursors = new Map<string, number>(); // session → last-seen event count
1183
+ let prevOrchHashes = new Map<string, string>(); // session → orchestrator snapshot hash
1184
+
1185
+ const poll = () => {
1186
+ const sessions = discoverSessions();
1187
+ const overviews = buildOverviews(sessions);
1188
+
1189
+ // Detect session-level changes
1190
+ const prevNames = new Set(prevOverviews.map((s) => s.name));
1191
+ const currNames = new Set(overviews.map((s) => s.name));
1192
+
1193
+ for (const overview of overviews) {
1194
+ if (!prevNames.has(overview.name)) {
1195
+ stream.writeSSE({
1196
+ event: "session_added",
1197
+ data: JSON.stringify(overview),
1198
+ });
1199
+ continue;
1200
+ }
1201
+
1202
+ const prev = prevOverviews.find((s) => s.name === overview.name);
1203
+ if (
1204
+ prev &&
1205
+ (prev.stats.doneTasks !== overview.stats.doneTasks ||
1206
+ prev.stats.totalTasks !== overview.stats.totalTasks ||
1207
+ prev.stats.activeAgents !== overview.stats.activeAgents)
1208
+ ) {
1209
+ stream.writeSSE({
1210
+ event: "session_update",
1211
+ data: JSON.stringify(overview),
1212
+ });
1213
+ }
1214
+ }
1215
+
1216
+ for (const prev of prevOverviews) {
1217
+ if (!currNames.has(prev.name)) {
1218
+ stream.writeSSE({
1219
+ event: "session_removed",
1220
+ data: JSON.stringify({ name: prev.name }),
1221
+ });
1222
+ }
1223
+ }
1224
+
1225
+ // Per-session: event log cursor + orchestrator state + task/agent diffs
1226
+ for (const session of sessions) {
1227
+ // Cursor-based event streaming from event log
1228
+ const events = readEvents(session.dir);
1229
+ const cursor = eventCursors.get(session.name) ?? 0;
1230
+
1231
+ // Handle log rotation: if events.length < cursor, log was rotated
1232
+ const effectiveCursor = events.length < cursor ? 0 : cursor;
1233
+ const newEvents = events.slice(effectiveCursor);
1234
+
1235
+ for (const evt of newEvents) {
1236
+ const eventId = `${evt.timestamp}:${evt.type}:${evt.taskId ?? ""}`;
1237
+ stream.writeSSE({
1238
+ id: eventId,
1239
+ event: "orchestrator_event",
1240
+ data: JSON.stringify({ session: session.name, ...evt }),
1241
+ });
1242
+ }
1243
+ eventCursors.set(session.name, events.length);
1244
+
1245
+ // Orchestrator state snapshot (emit only on change)
1246
+ const orchSnapshot = buildOrchestratorSnapshot(session);
1247
+ const orchHash = JSON.stringify(orchSnapshot);
1248
+ const prevHash = prevOrchHashes.get(session.name);
1249
+ if (orchHash !== prevHash) {
1250
+ stream.writeSSE({
1251
+ event: "orchestrator_state",
1252
+ data: JSON.stringify({ session: session.name, ...orchSnapshot }),
1253
+ });
1254
+ prevOrchHashes.set(session.name, orchHash);
1255
+ }
1256
+
1257
+ // Task-level and agent-level diffs (existing logic)
1258
+ const detail = buildProjectDetail(session);
1259
+ const prevDetail = prevDetails.get(session.name);
1260
+
1261
+ if (prevDetail) {
1262
+ const prevTaskMap = new Map(prevDetail.tasks.map((t) => [t.id, t]));
1263
+ for (const task of detail.tasks) {
1264
+ const prevTask = prevTaskMap.get(task.id);
1265
+ if (!prevTask) {
1266
+ stream.writeSSE({
1267
+ event: "task_update",
1268
+ data: JSON.stringify({
1269
+ session: session.name,
1270
+ taskId: task.id,
1271
+ status: task.status,
1272
+ title: task.title,
1273
+ }),
1274
+ });
1275
+ } else if (prevTask.status !== task.status || prevTask.assignee !== task.assignee) {
1276
+ stream.writeSSE({
1277
+ event: "task_update",
1278
+ data: JSON.stringify({
1279
+ session: session.name,
1280
+ taskId: task.id,
1281
+ status: task.status,
1282
+ assignee: task.assignee,
1283
+ }),
1284
+ });
1285
+ }
1286
+ }
1287
+
1288
+ // Detect agent status changes
1289
+ const prevAgentMap = new Map(prevDetail.agents.map((a) => [a.paneTitle, a]));
1290
+ for (const agent of detail.agents) {
1291
+ const prevAgent = prevAgentMap.get(agent.paneTitle);
1292
+ if (!prevAgent || prevAgent.isBusy !== agent.isBusy) {
1293
+ stream.writeSSE({
1294
+ event: "agent_status",
1295
+ data: JSON.stringify({
1296
+ session: session.name,
1297
+ agent: agent.paneTitle,
1298
+ busy: agent.isBusy,
1299
+ taskId: agent.taskId,
1300
+ }),
1301
+ });
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ prevDetails.set(session.name, detail);
1307
+ }
1308
+
1309
+ prevOverviews = overviews;
1310
+ };
1311
+
1312
+ // Initial snapshot
1313
+ poll();
1314
+
1315
+ // Poll every 2 seconds
1316
+ while (true) {
1317
+ await stream.sleep(2000);
1318
+ poll();
1319
+ }
1320
+ });
1321
+ });
1322
+
1323
+ // --- HQ endpoints ---
1324
+
1325
+ const remoteRegistry = options.remoteRegistry ?? null;
1326
+
1327
+ app.post("/api/hq/register", async (c) => {
1328
+ if (!remoteRegistry) return c.json({ error: "HQ registry not enabled" }, 501);
1329
+ const body = await c.req.json();
1330
+ const parsed = RegistrationPayloadSchema.safeParse(body);
1331
+ if (!parsed.success)
1332
+ return c.json({ error: "Invalid payload", details: parsed.error.issues }, 400);
1333
+ try {
1334
+ const machine = remoteRegistry.register(parsed.data);
1335
+ return c.json({
1336
+ ok: true,
1337
+ id: machine.id,
1338
+ name: machine.name,
1339
+ registeredAt: machine.registeredAt.toISOString(),
1340
+ });
1341
+ } catch (err) {
1342
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 409);
1343
+ }
1344
+ });
1345
+
1346
+ app.get("/api/hq/machines", (c) => {
1347
+ if (!remoteRegistry) return c.json({ machines: [] });
1348
+ const machines = remoteRegistry.getMachines().map((m) => ({
1349
+ id: m.id,
1350
+ name: m.name,
1351
+ url: m.url,
1352
+ registeredAt: m.registeredAt.toISOString(),
1353
+ lastHeartbeat: m.lastHeartbeat.toISOString(),
1354
+ sessions: Array.from(m.sessionIds),
1355
+ }));
1356
+ return c.json({ machines });
1357
+ });
1358
+
1359
+ app.get("/api/hq/machines/:id", (c) => {
1360
+ if (!remoteRegistry) return c.json({ error: "HQ registry not enabled" }, 501);
1361
+ const machine = remoteRegistry.getMachine(c.req.param("id"));
1362
+ if (!machine) return c.json({ error: "Machine not found" }, 404);
1363
+ return c.json({
1364
+ id: machine.id,
1365
+ name: machine.name,
1366
+ url: machine.url,
1367
+ registeredAt: machine.registeredAt.toISOString(),
1368
+ lastHeartbeat: machine.lastHeartbeat.toISOString(),
1369
+ sessions: Array.from(machine.sessionIds),
1370
+ });
1371
+ });
1372
+
1373
+ app.delete("/api/hq/machines/:id", (c) => {
1374
+ if (!remoteRegistry) return c.json({ error: "HQ registry not enabled" }, 501);
1375
+ const removed = remoteRegistry.unregister(c.req.param("id"));
1376
+ if (!removed) return c.json({ error: "Machine not found" }, 404);
1377
+ return c.json({ ok: true });
1378
+ });
1379
+
1380
+ // --- Tunnel endpoints ---
1381
+
1382
+ const tunnelManager = options.tunnelManager ?? null;
1383
+
1384
+ app.get("/api/tunnel", async (c) => {
1385
+ if (!tunnelManager) return c.json({ running: false, provider: null });
1386
+ const status = await tunnelManager.status();
1387
+ return c.json(status);
1388
+ });
1389
+
1390
+ app.post("/api/tunnel/start", async (c) => {
1391
+ if (!tunnelManager) return c.json({ error: "Tunnel manager not configured" }, 501);
1392
+ const body = await c.req.json().catch(() => ({}));
1393
+ const { tunnelConfigSchema } = await import("../lib/tunnels/types.ts");
1394
+ const parsed = tunnelConfigSchema.safeParse(body);
1395
+ if (!parsed.success)
1396
+ return c.json({ error: "Invalid tunnel config", details: parsed.error.issues }, 400);
1397
+ const status = await tunnelManager.start(parsed.data);
1398
+ return c.json(status);
1399
+ });
1400
+
1401
+ app.post("/api/tunnel/stop", async (c) => {
1402
+ if (!tunnelManager) return c.json({ error: "Tunnel manager not configured" }, 501);
1403
+ await tunnelManager.stop();
1404
+ return c.json({ ok: true });
1405
+ });
1406
+
1407
+ // Health check for daemon liveness probes
1408
+ app.get("/health", (c) => {
1409
+ return c.json({
1410
+ ok: true,
1411
+ uptime: Math.round(process.uptime()),
1412
+ version: "2.0.0",
1413
+ });
1414
+ });
1415
+
1416
+ // Root: API info
1417
+ app.get("/", (c) => {
1418
+ return c.json({
1419
+ status: "ok",
1420
+ message: "tmux-ide command center API",
1421
+ docs: "/api/sessions",
1422
+ });
1423
+ });
1424
+
1425
+ return app;
1426
+ }