shipwright-cli 1.7.1 → 1.9.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 (105) hide show
  1. package/.claude/agents/code-reviewer.md +90 -0
  2. package/.claude/agents/devops-engineer.md +142 -0
  3. package/.claude/agents/pipeline-agent.md +80 -0
  4. package/.claude/agents/shell-script-specialist.md +150 -0
  5. package/.claude/agents/test-specialist.md +196 -0
  6. package/.claude/hooks/post-tool-use.sh +38 -0
  7. package/.claude/hooks/pre-tool-use.sh +25 -0
  8. package/.claude/hooks/session-started.sh +37 -0
  9. package/README.md +212 -814
  10. package/claude-code/CLAUDE.md.shipwright +54 -0
  11. package/claude-code/hooks/notify-idle.sh +2 -2
  12. package/claude-code/hooks/session-start.sh +24 -0
  13. package/claude-code/hooks/task-completed.sh +6 -2
  14. package/claude-code/settings.json.template +12 -0
  15. package/dashboard/public/app.js +4422 -0
  16. package/dashboard/public/index.html +816 -0
  17. package/dashboard/public/styles.css +4755 -0
  18. package/dashboard/server.ts +4315 -0
  19. package/docs/KNOWN-ISSUES.md +18 -10
  20. package/docs/TIPS.md +38 -26
  21. package/docs/patterns/README.md +33 -23
  22. package/package.json +9 -5
  23. package/scripts/adapters/iterm2-adapter.sh +1 -1
  24. package/scripts/adapters/tmux-adapter.sh +52 -23
  25. package/scripts/adapters/wezterm-adapter.sh +26 -14
  26. package/scripts/lib/compat.sh +200 -0
  27. package/scripts/lib/helpers.sh +72 -0
  28. package/scripts/postinstall.mjs +72 -13
  29. package/scripts/{cct → sw} +109 -21
  30. package/scripts/sw-adversarial.sh +274 -0
  31. package/scripts/sw-architecture-enforcer.sh +330 -0
  32. package/scripts/sw-checkpoint.sh +390 -0
  33. package/scripts/{cct-cleanup.sh → sw-cleanup.sh} +3 -1
  34. package/scripts/sw-connect.sh +619 -0
  35. package/scripts/{cct-cost.sh → sw-cost.sh} +368 -34
  36. package/scripts/{cct-daemon.sh → sw-daemon.sh} +2217 -204
  37. package/scripts/sw-dashboard.sh +477 -0
  38. package/scripts/sw-developer-simulation.sh +252 -0
  39. package/scripts/sw-docs.sh +635 -0
  40. package/scripts/sw-doctor.sh +907 -0
  41. package/scripts/{cct-fix.sh → sw-fix.sh} +10 -6
  42. package/scripts/{cct-fleet.sh → sw-fleet.sh} +498 -22
  43. package/scripts/sw-github-checks.sh +521 -0
  44. package/scripts/sw-github-deploy.sh +533 -0
  45. package/scripts/sw-github-graphql.sh +972 -0
  46. package/scripts/sw-heartbeat.sh +293 -0
  47. package/scripts/{cct-init.sh → sw-init.sh} +144 -11
  48. package/scripts/sw-intelligence.sh +1196 -0
  49. package/scripts/sw-jira.sh +643 -0
  50. package/scripts/sw-launchd.sh +364 -0
  51. package/scripts/sw-linear.sh +648 -0
  52. package/scripts/{cct-logs.sh → sw-logs.sh} +72 -2
  53. package/scripts/{cct-loop.sh → sw-loop.sh} +534 -44
  54. package/scripts/{cct-memory.sh → sw-memory.sh} +321 -38
  55. package/scripts/sw-patrol-meta.sh +417 -0
  56. package/scripts/sw-pipeline-composer.sh +455 -0
  57. package/scripts/{cct-pipeline.sh → sw-pipeline.sh} +2319 -178
  58. package/scripts/sw-predictive.sh +820 -0
  59. package/scripts/{cct-prep.sh → sw-prep.sh} +339 -49
  60. package/scripts/{cct-ps.sh → sw-ps.sh} +6 -4
  61. package/scripts/{cct-reaper.sh → sw-reaper.sh} +6 -4
  62. package/scripts/sw-remote.sh +687 -0
  63. package/scripts/sw-self-optimize.sh +947 -0
  64. package/scripts/sw-session.sh +519 -0
  65. package/scripts/sw-setup.sh +234 -0
  66. package/scripts/sw-status.sh +605 -0
  67. package/scripts/{cct-templates.sh → sw-templates.sh} +9 -4
  68. package/scripts/sw-tmux.sh +591 -0
  69. package/scripts/sw-tracker-jira.sh +277 -0
  70. package/scripts/sw-tracker-linear.sh +292 -0
  71. package/scripts/sw-tracker.sh +409 -0
  72. package/scripts/{cct-upgrade.sh → sw-upgrade.sh} +103 -46
  73. package/scripts/{cct-worktree.sh → sw-worktree.sh} +3 -0
  74. package/templates/pipelines/autonomous.json +27 -5
  75. package/templates/pipelines/full.json +12 -0
  76. package/templates/pipelines/standard.json +12 -0
  77. package/tmux/{claude-teams-overlay.conf → shipwright-overlay.conf} +27 -9
  78. package/tmux/templates/accessibility.json +34 -0
  79. package/tmux/templates/api-design.json +35 -0
  80. package/tmux/templates/architecture.json +1 -0
  81. package/tmux/templates/bug-fix.json +9 -0
  82. package/tmux/templates/code-review.json +1 -0
  83. package/tmux/templates/compliance.json +36 -0
  84. package/tmux/templates/data-pipeline.json +36 -0
  85. package/tmux/templates/debt-paydown.json +34 -0
  86. package/tmux/templates/devops.json +1 -0
  87. package/tmux/templates/documentation.json +1 -0
  88. package/tmux/templates/exploration.json +1 -0
  89. package/tmux/templates/feature-dev.json +1 -0
  90. package/tmux/templates/full-stack.json +8 -0
  91. package/tmux/templates/i18n.json +34 -0
  92. package/tmux/templates/incident-response.json +36 -0
  93. package/tmux/templates/migration.json +1 -0
  94. package/tmux/templates/observability.json +35 -0
  95. package/tmux/templates/onboarding.json +33 -0
  96. package/tmux/templates/performance.json +35 -0
  97. package/tmux/templates/refactor.json +1 -0
  98. package/tmux/templates/release.json +35 -0
  99. package/tmux/templates/security-audit.json +8 -0
  100. package/tmux/templates/spike.json +34 -0
  101. package/tmux/templates/testing.json +1 -0
  102. package/tmux/tmux.conf +98 -9
  103. package/scripts/cct-doctor.sh +0 -414
  104. package/scripts/cct-session.sh +0 -284
  105. package/scripts/cct-status.sh +0 -169
@@ -0,0 +1,4315 @@
1
+ import {
2
+ readFileSync,
3
+ readdirSync,
4
+ writeFileSync,
5
+ renameSync,
6
+ mkdirSync,
7
+ existsSync,
8
+ unlinkSync,
9
+ appendFileSync,
10
+ watch,
11
+ type FSWatcher,
12
+ } from "fs";
13
+ import { join, extname } from "path";
14
+ import { execSync } from "child_process";
15
+
16
+ // ─── Config ──────────────────────────────────────────────────────────
17
+ const PORT = parseInt(
18
+ process.argv[2] || process.env.SHIPWRIGHT_DASHBOARD_PORT || "8767",
19
+ );
20
+ const HOME = process.env.HOME || "";
21
+ const EVENTS_FILE = join(HOME, ".shipwright", "events.jsonl");
22
+ const DAEMON_STATE = join(HOME, ".shipwright", "daemon-state.json");
23
+ const LOGS_DIR = join(HOME, ".shipwright", "logs");
24
+ const HEARTBEAT_DIR = join(HOME, ".shipwright", "heartbeats");
25
+ const MACHINES_FILE = join(HOME, ".shipwright", "machines.json");
26
+ const COSTS_FILE = join(HOME, ".shipwright", "costs.json");
27
+ const BUDGET_FILE = join(HOME, ".shipwright", "budget.json");
28
+ const MEMORY_DIR = join(HOME, ".shipwright", "memory");
29
+ const PUBLIC_DIR = join(import.meta.dir, "public");
30
+ const WS_PUSH_INTERVAL_MS = 2000;
31
+
32
+ // ─── Auth Config ────────────────────────────────────────────────────
33
+ // Mode 1: Full OAuth — set GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET + DASHBOARD_REPO
34
+ const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || "";
35
+ const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || "";
36
+ // Mode 2: PAT-based — set GITHUB_PAT + DASHBOARD_REPO (simpler, single-admin)
37
+ const GITHUB_PAT = process.env.GITHUB_PAT || "";
38
+ const DASHBOARD_REPO = process.env.DASHBOARD_REPO || ""; // "owner/repo"
39
+ const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomUUID();
40
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
41
+ const ALLOWED_PERMISSIONS = ["admin", "write"];
42
+
43
+ // ─── ANSI helpers ────────────────────────────────────────────────────
44
+ const CYAN = "\x1b[38;2;0;212;255m";
45
+ const GREEN = "\x1b[38;2;74;222;128m";
46
+ const BOLD = "\x1b[1m";
47
+ const DIM = "\x1b[2m";
48
+ const ULINE = "\x1b[4m";
49
+ const RESET = "\x1b[0m";
50
+
51
+ // ─── Types ───────────────────────────────────────────────────────────
52
+ interface DaemonEvent {
53
+ ts: string;
54
+ ts_epoch?: number;
55
+ type: string;
56
+ issue?: number;
57
+ stage?: string;
58
+ duration_s?: number;
59
+ pid?: number;
60
+ issues_found?: number;
61
+ active?: number;
62
+ from?: number;
63
+ to?: number;
64
+ max_by_cpu?: number;
65
+ max_by_mem?: number;
66
+ max_by_budget?: number;
67
+ cpu_cores?: number;
68
+ avail_mem_gb?: number;
69
+ result?: string;
70
+ [key: string]: unknown;
71
+ }
72
+
73
+ interface Pipeline {
74
+ issue: number;
75
+ title: string;
76
+ stage: string;
77
+ elapsed_s: number;
78
+ worktree: string;
79
+ iteration: number;
80
+ maxIterations: number;
81
+ stagesDone: string[];
82
+ linesWritten: number;
83
+ testsPassing: boolean;
84
+ }
85
+
86
+ interface QueueItem {
87
+ issue: number;
88
+ title: string;
89
+ score: number;
90
+ }
91
+
92
+ interface DoraMetric {
93
+ value: number;
94
+ unit: string;
95
+ grade: "Elite" | "High" | "Medium" | "Low";
96
+ }
97
+
98
+ interface DoraGrades {
99
+ deploy_freq: DoraMetric;
100
+ lead_time: DoraMetric;
101
+ cfr: DoraMetric;
102
+ mttr: DoraMetric;
103
+ }
104
+
105
+ interface ConnectedDeveloper {
106
+ developer_id: string;
107
+ machine_name: string;
108
+ hostname: string;
109
+ platform: string;
110
+ last_heartbeat: number; // epoch ms
111
+ daemon_running: boolean;
112
+ daemon_pid: number | null;
113
+ active_jobs: Array<{ issue: number; title: string; stage: string }>;
114
+ queued: number[];
115
+ events_since: number; // last synced event timestamp
116
+ }
117
+
118
+ interface TeamState {
119
+ developers: Array<ConnectedDeveloper & { _presence?: string }>;
120
+ total_online: number;
121
+ total_active_pipelines: number;
122
+ total_queued: number;
123
+ }
124
+
125
+ interface FleetState {
126
+ timestamp: string;
127
+ daemon: {
128
+ running: boolean;
129
+ pid: number | null;
130
+ uptime_s: number;
131
+ maxParallel: number;
132
+ pollInterval: number;
133
+ };
134
+ pipelines: Pipeline[];
135
+ queue: QueueItem[];
136
+ events: DaemonEvent[];
137
+ scale: {
138
+ from?: number;
139
+ to?: number;
140
+ maxByCpu?: number;
141
+ maxByMem?: number;
142
+ maxByBudget?: number;
143
+ cpuCores?: number;
144
+ availMemGb?: number;
145
+ };
146
+ metrics: {
147
+ cpuCores: number;
148
+ completed: number;
149
+ failed: number;
150
+ };
151
+ agents: AgentInfo[];
152
+ machines: MachineInfo[];
153
+ cost: CostInfo;
154
+ dora: DoraGrades;
155
+ team?: TeamState;
156
+ }
157
+
158
+ interface HealthResponse {
159
+ status: "ok";
160
+ uptime_s: number;
161
+ connections: number;
162
+ }
163
+
164
+ interface Session {
165
+ githubUser: string;
166
+ accessToken: string;
167
+ avatarUrl: string;
168
+ isAdmin: boolean;
169
+ expiresAt: number;
170
+ }
171
+
172
+ // ─── Session Store ──────────────────────────────────────────────────
173
+ const sessions = new Map<string, Session>();
174
+
175
+ function createSession(data: Omit<Session, "expiresAt">): string {
176
+ const sessionId = crypto.randomUUID();
177
+ sessions.set(sessionId, {
178
+ ...data,
179
+ expiresAt: Date.now() + SESSION_TTL_MS,
180
+ });
181
+ saveSessions();
182
+ return sessionId;
183
+ }
184
+
185
+ function getSession(req: Request): Session | null {
186
+ const cookie = req.headers.get("cookie");
187
+ if (!cookie) return null;
188
+
189
+ const match = cookie.match(/fleet_session=([^;]+)/);
190
+ if (!match) return null;
191
+
192
+ const sessionId = match[1];
193
+ const session = sessions.get(sessionId);
194
+ if (!session) return null;
195
+
196
+ if (Date.now() > session.expiresAt) {
197
+ sessions.delete(sessionId);
198
+ saveSessions();
199
+ return null;
200
+ }
201
+
202
+ return session;
203
+ }
204
+
205
+ function getSessionFromCookie(cookie: string | null): Session | null {
206
+ if (!cookie) return null;
207
+ const match = cookie.match(/fleet_session=([^;]+)/);
208
+ if (!match) return null;
209
+ const session = sessions.get(match[1]);
210
+ if (!session || Date.now() > session.expiresAt) return null;
211
+ return session;
212
+ }
213
+
214
+ function sessionCookie(sessionId: string): string {
215
+ return `fleet_session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
216
+ }
217
+
218
+ function clearSessionCookie(): string {
219
+ return "fleet_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
220
+ }
221
+
222
+ // ─── File-backed Sessions ───────────────────────────────────────────
223
+ const SESSIONS_FILE = join(HOME, ".shipwright", "sessions.json");
224
+
225
+ function loadSessions(): void {
226
+ try {
227
+ if (existsSync(SESSIONS_FILE)) {
228
+ const data = JSON.parse(readFileSync(SESSIONS_FILE, "utf-8"));
229
+ const now = Date.now();
230
+ if (data && typeof data === "object") {
231
+ for (const [id, sess] of Object.entries(data)) {
232
+ const s = sess as Session;
233
+ if (s.expiresAt > now) {
234
+ sessions.set(id, s);
235
+ }
236
+ }
237
+ }
238
+ }
239
+ } catch {
240
+ /* start fresh */
241
+ }
242
+ }
243
+
244
+ function saveSessions(): void {
245
+ const dir = join(HOME, ".shipwright");
246
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
247
+ const obj: Record<string, Session> = {};
248
+ for (const [id, sess] of sessions) {
249
+ obj[id] = sess;
250
+ }
251
+ const tmp = SESSIONS_FILE + ".tmp";
252
+ writeFileSync(tmp, JSON.stringify(obj, null, 2));
253
+ renameSync(tmp, SESSIONS_FILE);
254
+ }
255
+
256
+ // ─── Developer Registry ─────────────────────────────────────────────
257
+ const DEVELOPER_REGISTRY_FILE = join(
258
+ HOME,
259
+ ".shipwright",
260
+ "developer-registry.json",
261
+ );
262
+ const TEAM_EVENTS_FILE = join(HOME, ".shipwright", "team-events.jsonl");
263
+ const developerRegistry = new Map<string, ConnectedDeveloper>();
264
+
265
+ function loadDeveloperRegistry(): void {
266
+ try {
267
+ if (existsSync(DEVELOPER_REGISTRY_FILE)) {
268
+ const data = JSON.parse(readFileSync(DEVELOPER_REGISTRY_FILE, "utf-8"));
269
+ if (Array.isArray(data)) {
270
+ for (const dev of data) {
271
+ const key = `${dev.developer_id}@${dev.machine_name}`;
272
+ developerRegistry.set(key, dev);
273
+ }
274
+ }
275
+ }
276
+ } catch {
277
+ /* start fresh */
278
+ }
279
+ }
280
+
281
+ function saveDeveloperRegistry(): void {
282
+ const dir = join(HOME, ".shipwright");
283
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
284
+ const data = JSON.stringify(Array.from(developerRegistry.values()), null, 2);
285
+ const tmp = DEVELOPER_REGISTRY_FILE + ".tmp";
286
+ writeFileSync(tmp, data);
287
+ renameSync(tmp, DEVELOPER_REGISTRY_FILE);
288
+ }
289
+
290
+ function getPresenceStatus(
291
+ lastHeartbeat: number,
292
+ ): "online" | "idle" | "offline" {
293
+ const age = Date.now() - lastHeartbeat;
294
+ if (age < 30_000) return "online";
295
+ if (age < 120_000) return "idle";
296
+ return "offline";
297
+ }
298
+
299
+ function getTeamState(): TeamState {
300
+ const developers = Array.from(developerRegistry.values()).filter(
301
+ (d) => Date.now() - d.last_heartbeat < 86_400_000,
302
+ ); // exclude >24h offline
303
+ const online = developers.filter(
304
+ (d) => getPresenceStatus(d.last_heartbeat) === "online",
305
+ );
306
+ return {
307
+ developers: developers.map((d) => ({
308
+ ...d,
309
+ _presence: getPresenceStatus(d.last_heartbeat),
310
+ })),
311
+ total_online: online.length,
312
+ total_active_pipelines: developers.reduce(
313
+ (sum, d) => sum + d.active_jobs.length,
314
+ 0,
315
+ ),
316
+ total_queued: developers.reduce((sum, d) => sum + d.queued.length, 0),
317
+ };
318
+ }
319
+
320
+ // Invite tokens (file-backed, separate from join-tokens)
321
+ const INVITE_TOKENS_FILE = join(HOME, ".shipwright", "invite-tokens.json");
322
+ const inviteTokens = new Map<
323
+ string,
324
+ { token: string; created_at: string; expires_at: string }
325
+ >();
326
+
327
+ function loadInviteTokens(): void {
328
+ try {
329
+ if (existsSync(INVITE_TOKENS_FILE)) {
330
+ const data = JSON.parse(readFileSync(INVITE_TOKENS_FILE, "utf-8"));
331
+ if (Array.isArray(data)) {
332
+ const now = Date.now();
333
+ for (const t of data) {
334
+ // Skip expired tokens on load
335
+ if (new Date(t.expires_at).getTime() > now) {
336
+ inviteTokens.set(t.token, t);
337
+ }
338
+ }
339
+ }
340
+ }
341
+ } catch {
342
+ /* start fresh */
343
+ }
344
+ }
345
+
346
+ function saveInviteTokens(): void {
347
+ const dir = join(HOME, ".shipwright");
348
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
349
+ const data = JSON.stringify(Array.from(inviteTokens.values()), null, 2);
350
+ const tmp = INVITE_TOKENS_FILE + ".tmp";
351
+ writeFileSync(tmp, data);
352
+ renameSync(tmp, INVITE_TOKENS_FILE);
353
+ }
354
+
355
+ // ─── Auth check ─────────────────────────────────────────────────────
356
+ type AuthMode = "oauth" | "pat" | "none";
357
+
358
+ function getAuthMode(): AuthMode {
359
+ if (GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET && DASHBOARD_REPO)
360
+ return "oauth";
361
+ if (GITHUB_PAT && DASHBOARD_REPO) return "pat";
362
+ return "none";
363
+ }
364
+
365
+ function isAuthEnabled(): boolean {
366
+ return getAuthMode() !== "none";
367
+ }
368
+
369
+ // ─── Public routes (no auth required) ───────────────────────────────
370
+ function isPublicRoute(pathname: string): boolean {
371
+ return (
372
+ pathname === "/login" ||
373
+ pathname.startsWith("/auth/") ||
374
+ pathname === "/api/health" ||
375
+ pathname.startsWith("/api/join/") ||
376
+ pathname.startsWith("/api/connect/") ||
377
+ pathname === "/api/team" ||
378
+ pathname === "/api/team/activity" ||
379
+ pathname === "/api/team/invite" ||
380
+ pathname.startsWith("/api/team/invite/") ||
381
+ pathname === "/api/claim" ||
382
+ pathname === "/api/claim/release" ||
383
+ pathname === "/api/webhook/ci"
384
+ );
385
+ }
386
+
387
+ // ─── Login Page HTML ────────────────────────────────────────────────
388
+ function loginPageHTML(error?: string): string {
389
+ const mode = getAuthMode();
390
+
391
+ const errorHtml = error
392
+ ? `<p style="color:#f43f5e;margin-bottom:1.5rem;font-size:0.9rem;">${error}</p>`
393
+ : "";
394
+
395
+ // PAT mode: show a username input form
396
+ const patForm = `
397
+ ${errorHtml}
398
+ <form method="POST" action="/auth/pat-login" style="display:flex;flex-direction:column;gap:1rem;">
399
+ <input name="username" type="text" required
400
+ placeholder="GitHub username"
401
+ style="
402
+ background: rgba(0,212,255,0.04);
403
+ border: 1px solid rgba(0,212,255,0.15);
404
+ border-radius: 8px;
405
+ padding: 0.85rem 1rem;
406
+ color: #e8ecf4;
407
+ font-family: 'Plus Jakarta Sans', sans-serif;
408
+ font-size: 0.95rem;
409
+ outline: none;
410
+ transition: border-color 0.2s;
411
+ "
412
+ onfocus="this.style.borderColor='rgba(0,212,255,0.4)'"
413
+ onblur="this.style.borderColor='rgba(0,212,255,0.15)'"
414
+ />
415
+ <button type="submit" class="btn-github" style="justify-content:center;">
416
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
417
+ Verify &amp; Sign In
418
+ </button>
419
+ </form>`;
420
+
421
+ // OAuth mode: show redirect button
422
+ const oauthBtn = `
423
+ ${errorHtml}
424
+ <a class="btn-github" href="/auth/github">
425
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
426
+ Sign in with GitHub
427
+ </a>`;
428
+
429
+ const actionBlock = mode === "pat" ? patForm : oauthBtn;
430
+ const subtitle =
431
+ mode === "pat"
432
+ ? "Enter your GitHub username to verify access"
433
+ : "Sign in to access the dashboard";
434
+
435
+ return `<!DOCTYPE html>
436
+ <html lang="en">
437
+ <head>
438
+ <meta charset="UTF-8">
439
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
440
+ <title>Fleet Command \u2014 Sign In</title>
441
+ <link rel="preconnect" href="https://fonts.googleapis.com">
442
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
443
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
444
+ <style>
445
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
446
+ body {
447
+ background: #060a14;
448
+ min-height: 100vh;
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ font-family: 'Plus Jakarta Sans', sans-serif;
453
+ color: #e8ecf4;
454
+ }
455
+ .card {
456
+ max-width: 400px;
457
+ width: 90%;
458
+ background: rgba(10, 22, 40, 0.8);
459
+ border: 1px solid rgba(0, 212, 255, 0.08);
460
+ border-radius: 16px;
461
+ padding: 3rem;
462
+ text-align: center;
463
+ }
464
+ .card .anchor { font-size: 2.5rem; margin-bottom: 1rem; opacity: 0.7; }
465
+ .card h1 {
466
+ font-family: 'Instrument Serif', serif;
467
+ font-size: 2rem;
468
+ font-weight: 400;
469
+ color: #e8ecf4;
470
+ margin-bottom: 0.5rem;
471
+ }
472
+ .card p {
473
+ font-size: 0.95rem;
474
+ color: #8899b8;
475
+ margin-bottom: 2rem;
476
+ line-height: 1.5;
477
+ }
478
+ .btn-github {
479
+ display: inline-flex;
480
+ align-items: center;
481
+ gap: 0.6rem;
482
+ background: linear-gradient(135deg, #00d4ff, #0066ff);
483
+ color: #060a14;
484
+ border: none;
485
+ border-radius: 8px;
486
+ padding: 0.85rem 2rem;
487
+ font-family: 'Plus Jakarta Sans', sans-serif;
488
+ font-size: 0.95rem;
489
+ font-weight: 700;
490
+ cursor: pointer;
491
+ text-decoration: none;
492
+ transition: opacity 0.2s, transform 0.15s;
493
+ width: 100%;
494
+ }
495
+ .btn-github:hover { opacity: 0.9; transform: translateY(-1px); }
496
+ .btn-github:active { transform: translateY(0); }
497
+ .btn-github svg { width: 20px; height: 20px; fill: #060a14; }
498
+ </style>
499
+ </head>
500
+ <body>
501
+ <div class="card">
502
+ <div class="anchor">\u2693</div>
503
+ <h1>Fleet Command</h1>
504
+ <p>${subtitle}</p>
505
+ ${actionBlock}
506
+ </div>
507
+ </body>
508
+ </html>`;
509
+ }
510
+
511
+ // ─── Access Denied Page HTML ────────────────────────────────────────
512
+ function accessDeniedHTML(repo: string): string {
513
+ return `<!DOCTYPE html>
514
+ <html lang="en">
515
+ <head>
516
+ <meta charset="UTF-8">
517
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
518
+ <title>Fleet Command — Access Denied</title>
519
+ <link rel="preconnect" href="https://fonts.googleapis.com">
520
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
521
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
522
+ <style>
523
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
524
+ body {
525
+ background: #060a14;
526
+ min-height: 100vh;
527
+ display: flex;
528
+ align-items: center;
529
+ justify-content: center;
530
+ font-family: 'Plus Jakarta Sans', sans-serif;
531
+ color: #e8ecf4;
532
+ }
533
+ .card {
534
+ max-width: 400px;
535
+ width: 90%;
536
+ background: rgba(10, 22, 40, 0.8);
537
+ border: 1px solid rgba(0, 212, 255, 0.08);
538
+ border-radius: 16px;
539
+ padding: 3rem;
540
+ text-align: center;
541
+ }
542
+ .card .icon {
543
+ font-size: 2.5rem;
544
+ margin-bottom: 1rem;
545
+ opacity: 0.7;
546
+ }
547
+ .card h1 {
548
+ font-family: 'Instrument Serif', serif;
549
+ font-size: 2rem;
550
+ font-weight: 400;
551
+ color: #ff6b6b;
552
+ margin-bottom: 0.75rem;
553
+ }
554
+ .card p {
555
+ font-size: 0.95rem;
556
+ color: #8899b8;
557
+ margin-bottom: 2rem;
558
+ line-height: 1.5;
559
+ }
560
+ .card code {
561
+ background: rgba(0, 212, 255, 0.08);
562
+ padding: 0.15rem 0.5rem;
563
+ border-radius: 4px;
564
+ font-size: 0.9rem;
565
+ color: #00d4ff;
566
+ }
567
+ .link {
568
+ color: #00d4ff;
569
+ text-decoration: none;
570
+ font-weight: 600;
571
+ font-size: 0.95rem;
572
+ transition: opacity 0.2s;
573
+ }
574
+ .link:hover { opacity: 0.8; }
575
+ </style>
576
+ </head>
577
+ <body>
578
+ <div class="card">
579
+ <div class="icon">\u26D4</div>
580
+ <h1>Access Denied</h1>
581
+ <p>You need admin or write access to <code>${repo}</code> to view this dashboard.</p>
582
+ <a class="link" href="/auth/logout">Sign in with a different account</a>
583
+ </div>
584
+ </body>
585
+ </html>`;
586
+ }
587
+
588
+ // ─── WebSocket client tracking ───────────────────────────────────────
589
+ const wsClients = new Set<import("bun").ServerWebSocket<unknown>>();
590
+ const startTime = Date.now();
591
+
592
+ function broadcastToClients(data: FleetState): void {
593
+ const payload = JSON.stringify(data);
594
+ for (const ws of wsClients) {
595
+ try {
596
+ ws.send(payload);
597
+ } catch {
598
+ wsClients.delete(ws);
599
+ }
600
+ }
601
+ }
602
+
603
+ // ─── Data Collection ─────────────────────────────────────────────────
604
+ function readEvents(): DaemonEvent[] {
605
+ if (!existsSync(EVENTS_FILE)) return [];
606
+ try {
607
+ const content = readFileSync(EVENTS_FILE, "utf-8").trim();
608
+ if (!content) return [];
609
+ return content
610
+ .split("\n")
611
+ .filter((l) => l.trim())
612
+ .map((l) => {
613
+ try {
614
+ return JSON.parse(l);
615
+ } catch {
616
+ return null;
617
+ }
618
+ })
619
+ .filter(Boolean) as DaemonEvent[];
620
+ } catch {
621
+ return [];
622
+ }
623
+ }
624
+
625
+ function readDaemonState(): Record<string, unknown> | null {
626
+ if (!existsSync(DAEMON_STATE)) return null;
627
+ try {
628
+ return JSON.parse(readFileSync(DAEMON_STATE, "utf-8"));
629
+ } catch {
630
+ return null;
631
+ }
632
+ }
633
+
634
+ function getCpuCores(): number {
635
+ try {
636
+ if (process.platform === "darwin") {
637
+ return parseInt(
638
+ execSync("sysctl -n hw.ncpu", { encoding: "utf-8" }).trim(),
639
+ );
640
+ }
641
+ // Linux: read from /proc/cpuinfo
642
+ if (existsSync("/proc/cpuinfo")) {
643
+ const cpuinfo = readFileSync("/proc/cpuinfo", "utf-8");
644
+ const count = cpuinfo
645
+ .split("\n")
646
+ .filter((l) => l.startsWith("processor")).length;
647
+ if (count > 0) return count;
648
+ }
649
+ return parseInt(execSync("nproc", { encoding: "utf-8" }).trim());
650
+ } catch {
651
+ return 8;
652
+ }
653
+ }
654
+
655
+ function readLogIterations(issue: number): {
656
+ iteration: number;
657
+ maxIterations: number;
658
+ linesWritten: number;
659
+ testsPassing: boolean;
660
+ } {
661
+ const logFile = join(LOGS_DIR, `issue-${issue}.log`);
662
+ if (!existsSync(logFile))
663
+ return {
664
+ iteration: 0,
665
+ maxIterations: 20,
666
+ linesWritten: 0,
667
+ testsPassing: false,
668
+ };
669
+ try {
670
+ const content = readFileSync(logFile, "utf-8");
671
+ const iters = [...content.matchAll(/Iteration (\d+)\/(\d+)/g)];
672
+ const last = iters.length > 0 ? iters[iters.length - 1] : null;
673
+ const lineMatches = [...content.matchAll(/(\d+) insertions?\(\+\)/g)];
674
+ const linesWritten = lineMatches.reduce(
675
+ (sum, m) => sum + parseInt(m[1]),
676
+ 0,
677
+ );
678
+ const testsPassing =
679
+ content.includes("Tests: passed") ||
680
+ content.toLowerCase().includes("tests passed");
681
+ return {
682
+ iteration: last ? parseInt(last[1]) : 0,
683
+ maxIterations: last ? parseInt(last[2]) : 20,
684
+ linesWritten,
685
+ testsPassing,
686
+ };
687
+ } catch {
688
+ return {
689
+ iteration: 0,
690
+ maxIterations: 20,
691
+ linesWritten: 0,
692
+ testsPassing: false,
693
+ };
694
+ }
695
+ }
696
+
697
+ function getFleetState(): FleetState {
698
+ const events = readEvents();
699
+ const daemonState = readDaemonState();
700
+ const now = Math.floor(Date.now() / 1000);
701
+
702
+ const state: FleetState = {
703
+ timestamp: new Date().toISOString(),
704
+ daemon: {
705
+ running: false,
706
+ pid: null,
707
+ uptime_s: 0,
708
+ maxParallel: 2,
709
+ pollInterval: 30,
710
+ },
711
+ pipelines: [],
712
+ queue: [],
713
+ events: events.slice(-25),
714
+ scale: {},
715
+ metrics: { cpuCores: getCpuCores(), completed: 0, failed: 0 },
716
+ agents: getAgents(),
717
+ machines: getMachines(),
718
+ cost: getCostInfo(),
719
+ dora: calculateDoraGrades(events, 7),
720
+ };
721
+
722
+ // Daemon state
723
+ if (daemonState) {
724
+ state.daemon.running = true;
725
+ state.daemon.pid = (daemonState.pid as number) || null;
726
+ state.daemon.maxParallel = (daemonState.max_parallel as number) || 2;
727
+ state.daemon.pollInterval = (daemonState.poll_interval as number) || 30;
728
+ const started = daemonState.started_at as string;
729
+ if (started) {
730
+ try {
731
+ state.daemon.uptime_s =
732
+ now - Math.floor(new Date(started).getTime() / 1000);
733
+ } catch {
734
+ /* ignore */
735
+ }
736
+ }
737
+
738
+ // Active jobs → pipelines
739
+ const activeJobs =
740
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
741
+ for (const job of activeJobs) {
742
+ const issue = (job.issue as number) || 0;
743
+ const logInfo = readLogIterations(issue);
744
+ state.pipelines.push({
745
+ issue,
746
+ title: (job.title as string) || "",
747
+ stage: (job.stage as string) || "build",
748
+ elapsed_s: now - ((job.started_epoch as number) || now),
749
+ worktree: `daemon-issue-${issue}`,
750
+ iteration: logInfo.iteration,
751
+ maxIterations: logInfo.maxIterations,
752
+ stagesDone: [],
753
+ linesWritten: logInfo.linesWritten,
754
+ testsPassing: logInfo.testsPassing,
755
+ });
756
+ }
757
+
758
+ // Queued items — daemon stores these as plain issue numbers
759
+ const queued =
760
+ (daemonState.queued as Array<number | Record<string, unknown>>) || [];
761
+ for (const q of queued) {
762
+ if (typeof q === "number") {
763
+ state.queue.push({ issue: q, title: "", score: 0 });
764
+ } else {
765
+ state.queue.push({
766
+ issue: (q.issue as number) || 0,
767
+ title: (q.title as string) || "",
768
+ score: (q.score as number) || 0,
769
+ });
770
+ }
771
+ }
772
+ }
773
+
774
+ // Extract latest scale info from events (most recent first)
775
+ for (const e of [...events].reverse()) {
776
+ if (e.type === "daemon.scale" && !state.scale.to) {
777
+ state.scale = {
778
+ from: e.from,
779
+ to: e.to,
780
+ maxByCpu: e.max_by_cpu,
781
+ maxByMem: e.max_by_mem,
782
+ maxByBudget: e.max_by_budget,
783
+ cpuCores: e.cpu_cores,
784
+ availMemGb: e.avail_mem_gb,
785
+ };
786
+ }
787
+ }
788
+
789
+ // Build stage history per issue + pipeline metrics
790
+ const issueStages: Record<number, string[]> = {};
791
+ for (const e of events) {
792
+ if (e.issue && e.issue > 0 && e.type === "stage.completed") {
793
+ if (!issueStages[e.issue]) issueStages[e.issue] = [];
794
+ issueStages[e.issue].push(e.stage || "");
795
+ }
796
+ if (e.type === "pipeline.completed") {
797
+ if (e.result === "success") state.metrics.completed++;
798
+ else state.metrics.failed++;
799
+ }
800
+ }
801
+ for (const p of state.pipelines) {
802
+ if (issueStages[p.issue]) p.stagesDone = issueStages[p.issue];
803
+ }
804
+
805
+ // Add team data if any developers are connected
806
+ if (developerRegistry.size > 0) {
807
+ state.team = getTeamState();
808
+ }
809
+
810
+ return state;
811
+ }
812
+
813
+ // ─── Worktree Discovery ──────────────────────────────────────────────
814
+ function findWorktreeBase(issue: number): string | null {
815
+ const daemonState = readDaemonState();
816
+ if (!daemonState) return null;
817
+
818
+ // Check active jobs for worktree path + repo
819
+ const activeJobs =
820
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
821
+ for (const job of activeJobs) {
822
+ if ((job.issue as number) === issue) {
823
+ const worktree = (job.worktree as string) || "";
824
+ const repo = (job.repo as string) || "";
825
+ if (worktree) {
826
+ // If repo is set, combine; otherwise try common locations
827
+ if (repo) return join(repo, worktree);
828
+ return resolveWorktreePath(worktree);
829
+ }
830
+ }
831
+ }
832
+
833
+ // Not in active jobs — try the default worktree naming convention
834
+ return resolveWorktreePath(`.worktrees/daemon-issue-${issue}`);
835
+ }
836
+
837
+ function resolveWorktreePath(relative: string): string | null {
838
+ // Try well-known repo locations
839
+ const candidates: string[] = [];
840
+
841
+ // Check env var
842
+ if (process.env.VOICEAI_REPO) {
843
+ candidates.push(join(process.env.VOICEAI_REPO, relative));
844
+ }
845
+
846
+ // Scan daemon state for any repo paths from completed or active jobs
847
+ const daemonState = readDaemonState();
848
+ if (daemonState) {
849
+ const activeJobs =
850
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
851
+ for (const job of activeJobs) {
852
+ const repo = (job.repo as string) || "";
853
+ if (repo) candidates.push(join(repo, relative));
854
+ }
855
+ }
856
+
857
+ // Try common parent directories where worktrees might live
858
+ const homeDir = process.env.HOME || "";
859
+ const commonBases = [
860
+ join(homeDir, "Documents/voiceai"),
861
+ join(homeDir, "Documents/claude-code-teams-tmux"),
862
+ join(homeDir, "voiceai"),
863
+ ];
864
+ for (const base of commonBases) {
865
+ candidates.push(join(base, relative));
866
+ }
867
+
868
+ for (const candidate of candidates) {
869
+ if (existsSync(candidate)) return candidate;
870
+ }
871
+ return null;
872
+ }
873
+
874
+ function readFileOr(filePath: string, fallback: string): string {
875
+ try {
876
+ if (existsSync(filePath)) return readFileSync(filePath, "utf-8");
877
+ } catch {
878
+ // ignore
879
+ }
880
+ return fallback;
881
+ }
882
+
883
+ // ─── Pipeline Detail ─────────────────────────────────────────────────
884
+ interface PipelineDetail {
885
+ issue: number;
886
+ title: string;
887
+ stage: string;
888
+ stageHistory: Array<{ stage: string; duration_s: number; ts: string }>;
889
+ plan: string;
890
+ design: string;
891
+ dod: string;
892
+ intake: Record<string, unknown> | null;
893
+ elapsed_s: number;
894
+ branch: string;
895
+ prLink: string;
896
+ }
897
+
898
+ function getPipelineDetail(issue: number): PipelineDetail {
899
+ const events = readEvents();
900
+ const daemonState = readDaemonState();
901
+ const worktreeBase = findWorktreeBase(issue);
902
+
903
+ // Gather stage history from events
904
+ const stageHistory: Array<{ stage: string; duration_s: number; ts: string }> =
905
+ [];
906
+ let currentStage = "";
907
+ let prLink = "";
908
+ let title = "";
909
+ let pipelineStartEpoch = 0;
910
+
911
+ for (const e of events) {
912
+ if (e.issue !== issue) continue;
913
+ if (e.type === "pipeline.started") {
914
+ pipelineStartEpoch = e.ts_epoch || 0;
915
+ }
916
+ if (e.type === "stage.completed") {
917
+ stageHistory.push({
918
+ stage: e.stage || "",
919
+ duration_s: e.duration_s || 0,
920
+ ts: e.ts,
921
+ });
922
+ }
923
+ if (e.type === "stage.started") {
924
+ currentStage = e.stage || "";
925
+ }
926
+ if (e.type === "pipeline.completed" && e.pr_url) {
927
+ prLink = e.pr_url as string;
928
+ }
929
+ }
930
+
931
+ // Get title from daemon state
932
+ if (daemonState) {
933
+ const activeJobs =
934
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
935
+ for (const job of activeJobs) {
936
+ if ((job.issue as number) === issue) {
937
+ title = (job.title as string) || "";
938
+ }
939
+ }
940
+ }
941
+
942
+ // Read artifacts from worktree
943
+ let plan = "";
944
+ let design = "";
945
+ let dod = "";
946
+ let intake: Record<string, unknown> | null = null;
947
+ let branch = "";
948
+
949
+ if (worktreeBase) {
950
+ const artifactsDir = join(worktreeBase, ".claude", "pipeline-artifacts");
951
+ plan = readFileOr(join(artifactsDir, "plan.md"), "");
952
+ design = readFileOr(join(artifactsDir, "design.md"), "");
953
+ dod = readFileOr(join(artifactsDir, "dod.md"), "");
954
+
955
+ const intakeRaw = readFileOr(join(artifactsDir, "intake.json"), "");
956
+ if (intakeRaw) {
957
+ try {
958
+ intake = JSON.parse(intakeRaw);
959
+ } catch {
960
+ // ignore malformed JSON
961
+ }
962
+ }
963
+
964
+ // Try to read current branch from worktree
965
+ try {
966
+ const headFile = join(worktreeBase, ".git");
967
+ if (existsSync(headFile)) {
968
+ const gitContent = readFileSync(headFile, "utf-8").trim();
969
+ if (gitContent.startsWith("gitdir:")) {
970
+ // It's a worktree — read HEAD from the gitdir
971
+ const gitDir = gitContent.replace("gitdir: ", "").trim();
972
+ const headPath = join(gitDir, "HEAD");
973
+ if (existsSync(headPath)) {
974
+ const ref = readFileSync(headPath, "utf-8").trim();
975
+ branch = ref.startsWith("ref: refs/heads/")
976
+ ? ref.replace("ref: refs/heads/", "")
977
+ : ref.substring(0, 12);
978
+ }
979
+ } else {
980
+ // Normal .git directory — read HEAD directly
981
+ const headPath = join(worktreeBase, ".git", "HEAD");
982
+ if (existsSync(headPath)) {
983
+ const ref = readFileSync(headPath, "utf-8").trim();
984
+ branch = ref.startsWith("ref: refs/heads/")
985
+ ? ref.replace("ref: refs/heads/", "")
986
+ : ref.substring(0, 12);
987
+ }
988
+ }
989
+ }
990
+ } catch {
991
+ // ignore
992
+ }
993
+ }
994
+
995
+ const now = Math.floor(Date.now() / 1000);
996
+ const elapsed_s = pipelineStartEpoch > 0 ? now - pipelineStartEpoch : 0;
997
+
998
+ return {
999
+ issue,
1000
+ title,
1001
+ stage: currentStage,
1002
+ stageHistory,
1003
+ plan,
1004
+ design,
1005
+ dod,
1006
+ intake,
1007
+ elapsed_s,
1008
+ branch,
1009
+ prLink,
1010
+ };
1011
+ }
1012
+
1013
+ // ─── Historical Metrics ──────────────────────────────────────────────
1014
+ interface MetricsHistory {
1015
+ success_rate: number;
1016
+ avg_duration_s: number;
1017
+ throughput_per_hour: number;
1018
+ total_completed: number;
1019
+ total_failed: number;
1020
+ stage_durations: Record<string, number>;
1021
+ daily_counts: Array<{ date: string; completed: number; failed: number }>;
1022
+ dora_grades: DoraGrades;
1023
+ }
1024
+
1025
+ function getMetricsHistory(doraPeriodDays: number = 7): MetricsHistory {
1026
+ const events = readEvents();
1027
+ const now = Math.floor(Date.now() / 1000);
1028
+
1029
+ let completed = 0;
1030
+ let failed = 0;
1031
+ let totalDuration = 0;
1032
+ const stageDurations: Record<string, number[]> = {};
1033
+ const dailyMap: Record<string, { completed: number; failed: number }> = {};
1034
+
1035
+ // Count completions in last 24h for throughput
1036
+ let completedLast24h = 0;
1037
+ const oneDayAgo = now - 86400;
1038
+
1039
+ // Initialize last 7 days
1040
+ for (let i = 6; i >= 0; i--) {
1041
+ const d = new Date((now - i * 86400) * 1000);
1042
+ const key = d.toISOString().split("T")[0];
1043
+ dailyMap[key] = { completed: 0, failed: 0 };
1044
+ }
1045
+
1046
+ for (const e of events) {
1047
+ if (e.type === "pipeline.completed") {
1048
+ const isSuccess = e.result === "success";
1049
+ if (isSuccess) {
1050
+ completed++;
1051
+ totalDuration += e.duration_s || 0;
1052
+ } else {
1053
+ failed++;
1054
+ }
1055
+
1056
+ // Throughput: count last 24h
1057
+ if ((e.ts_epoch || 0) >= oneDayAgo && isSuccess) {
1058
+ completedLast24h++;
1059
+ }
1060
+
1061
+ // Daily counts
1062
+ const dateKey = (e.ts || "").split("T")[0];
1063
+ if (dailyMap[dateKey]) {
1064
+ if (isSuccess) dailyMap[dateKey].completed++;
1065
+ else dailyMap[dateKey].failed++;
1066
+ }
1067
+ }
1068
+
1069
+ if (e.type === "stage.completed" && e.stage) {
1070
+ if (!stageDurations[e.stage]) stageDurations[e.stage] = [];
1071
+ stageDurations[e.stage].push(e.duration_s || 0);
1072
+ }
1073
+ }
1074
+
1075
+ const total = completed + failed;
1076
+ const avgStageDurations: Record<string, number> = {};
1077
+ for (const [stage, durations] of Object.entries(stageDurations)) {
1078
+ const sum = durations.reduce((a, b) => a + b, 0);
1079
+ avgStageDurations[stage] = Math.round(sum / durations.length);
1080
+ }
1081
+
1082
+ const daily_counts = Object.entries(dailyMap)
1083
+ .sort(([a], [b]) => a.localeCompare(b))
1084
+ .map(([date, counts]) => ({ date, ...counts }));
1085
+
1086
+ return {
1087
+ success_rate: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
1088
+ avg_duration_s: completed > 0 ? Math.round(totalDuration / completed) : 0,
1089
+ throughput_per_hour: Math.round((completedLast24h / 24) * 100) / 100,
1090
+ total_completed: completed,
1091
+ total_failed: failed,
1092
+ stage_durations: avgStageDurations,
1093
+ daily_counts,
1094
+ dora_grades: calculateDoraGrades(events, doraPeriodDays),
1095
+ };
1096
+ }
1097
+
1098
+ // ─── DORA Grades ────────────────────────────────────────────────────
1099
+ function calculateDoraGrades(
1100
+ events: DaemonEvent[],
1101
+ periodDays: number,
1102
+ ): DoraGrades {
1103
+ const now = Math.floor(Date.now() / 1000);
1104
+ const cutoff = now - periodDays * 86400;
1105
+
1106
+ // Filter to events within the period
1107
+ const recent = events.filter((e) => (e.ts_epoch || 0) >= cutoff);
1108
+
1109
+ // --- Deployment Frequency ---
1110
+ // Count successful completions in the period
1111
+ let completedCount = 0;
1112
+ for (const e of recent) {
1113
+ if (e.type === "pipeline.completed" && e.result === "success") {
1114
+ completedCount++;
1115
+ }
1116
+ }
1117
+ const deploysPerDay =
1118
+ periodDays > 0 ? Math.round((completedCount / periodDays) * 100) / 100 : 0;
1119
+
1120
+ let deployGrade: DoraMetric["grade"];
1121
+ if (deploysPerDay >= 1) deployGrade = "Elite";
1122
+ else if (deploysPerDay >= 1 / 7) deployGrade = "High";
1123
+ else if (deploysPerDay >= 1 / 30) deployGrade = "Medium";
1124
+ else deployGrade = "Low";
1125
+
1126
+ // --- Lead Time ---
1127
+ // Average time from pipeline.started to pipeline.completed (success only)
1128
+ const leadTimes: number[] = [];
1129
+ const startEpochs: Record<number, number> = {};
1130
+ for (const e of recent) {
1131
+ if (e.type === "pipeline.started" && e.issue) {
1132
+ startEpochs[e.issue] = e.ts_epoch || 0;
1133
+ }
1134
+ if (e.type === "pipeline.completed" && e.result === "success" && e.issue) {
1135
+ const startEpoch = startEpochs[e.issue];
1136
+ if (startEpoch && startEpoch > 0) {
1137
+ const endEpoch = e.ts_epoch || 0;
1138
+ if (endEpoch > startEpoch) {
1139
+ leadTimes.push((endEpoch - startEpoch) / 3600); // hours
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ const avgLeadTime =
1145
+ leadTimes.length > 0
1146
+ ? Math.round(
1147
+ (leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) * 100,
1148
+ ) / 100
1149
+ : 0;
1150
+
1151
+ let leadGrade: DoraMetric["grade"];
1152
+ if (leadTimes.length === 0) leadGrade = "Low";
1153
+ else if (avgLeadTime < 1) leadGrade = "Elite";
1154
+ else if (avgLeadTime < 24) leadGrade = "High";
1155
+ else if (avgLeadTime < 168) leadGrade = "Medium";
1156
+ else leadGrade = "Low";
1157
+
1158
+ // --- Change Failure Rate ---
1159
+ let totalCompleted = 0;
1160
+ let totalFailed = 0;
1161
+ for (const e of recent) {
1162
+ if (e.type === "pipeline.completed") {
1163
+ if (e.result === "success") totalCompleted++;
1164
+ else totalFailed++;
1165
+ }
1166
+ }
1167
+ const total = totalCompleted + totalFailed;
1168
+ const cfr = total > 0 ? Math.round((totalFailed / total) * 10000) / 100 : 0;
1169
+
1170
+ let cfrGrade: DoraMetric["grade"];
1171
+ if (total === 0) cfrGrade = "Low";
1172
+ else if (cfr < 5) cfrGrade = "Elite";
1173
+ else if (cfr < 10) cfrGrade = "High";
1174
+ else if (cfr < 15) cfrGrade = "Medium";
1175
+ else cfrGrade = "Low";
1176
+
1177
+ // --- Mean Time to Recovery ---
1178
+ // For each issue that failed, find time between failure and next success
1179
+ const recoveryTimes: number[] = [];
1180
+ // Track the most recent failure epoch per issue
1181
+ const failureEpochs: Record<number, number> = {};
1182
+ for (const e of recent) {
1183
+ if (!e.issue) continue;
1184
+ if (e.type === "pipeline.completed" && e.result !== "success") {
1185
+ failureEpochs[e.issue] = e.ts_epoch || 0;
1186
+ }
1187
+ if (e.type === "pipeline.completed" && e.result === "success") {
1188
+ const failEpoch = failureEpochs[e.issue];
1189
+ if (failEpoch && failEpoch > 0) {
1190
+ const recoverEpoch = e.ts_epoch || 0;
1191
+ if (recoverEpoch > failEpoch) {
1192
+ recoveryTimes.push((recoverEpoch - failEpoch) / 3600); // hours
1193
+ }
1194
+ delete failureEpochs[e.issue];
1195
+ }
1196
+ }
1197
+ }
1198
+ const avgMttr =
1199
+ recoveryTimes.length > 0
1200
+ ? Math.round(
1201
+ (recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length) *
1202
+ 100,
1203
+ ) / 100
1204
+ : 0;
1205
+
1206
+ let mttrGrade: DoraMetric["grade"];
1207
+ if (recoveryTimes.length === 0) mttrGrade = "Elite";
1208
+ else if (avgMttr < 1) mttrGrade = "Elite";
1209
+ else if (avgMttr < 24) mttrGrade = "High";
1210
+ else if (avgMttr < 168) mttrGrade = "Medium";
1211
+ else mttrGrade = "Low";
1212
+
1213
+ return {
1214
+ deploy_freq: { value: deploysPerDay, unit: "per day", grade: deployGrade },
1215
+ lead_time: { value: avgLeadTime, unit: "hours", grade: leadGrade },
1216
+ cfr: { value: cfr, unit: "%", grade: cfrGrade },
1217
+ mttr: { value: avgMttr, unit: "hours", grade: mttrGrade },
1218
+ };
1219
+ }
1220
+
1221
+ // ─── Agent Heartbeats ────────────────────────────────────────────────
1222
+ interface AgentInfo {
1223
+ id: string;
1224
+ issue: number;
1225
+ title: string;
1226
+ machine: string;
1227
+ stage: string;
1228
+ iteration: number;
1229
+ activity: string;
1230
+ memory_mb: number;
1231
+ cpu_pct: number;
1232
+ status: "active" | "idle" | "stale" | "dead";
1233
+ heartbeat_age_s: number;
1234
+ started_at: string;
1235
+ elapsed_s: number;
1236
+ }
1237
+
1238
+ function getAgents(): AgentInfo[] {
1239
+ const agents: AgentInfo[] = [];
1240
+ const daemonState = readDaemonState();
1241
+ const now = Math.floor(Date.now() / 1000);
1242
+
1243
+ // Build title map from active jobs
1244
+ const jobMap: Record<number, Record<string, unknown>> = {};
1245
+ if (daemonState) {
1246
+ const activeJobs =
1247
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
1248
+ for (const job of activeJobs) {
1249
+ const issue = (job.issue as number) || 0;
1250
+ if (issue) jobMap[issue] = job;
1251
+ }
1252
+ }
1253
+
1254
+ // Read heartbeat files
1255
+ if (existsSync(HEARTBEAT_DIR)) {
1256
+ try {
1257
+ const files = readdirSync(HEARTBEAT_DIR).filter((f) =>
1258
+ f.endsWith(".json"),
1259
+ );
1260
+ for (const file of files) {
1261
+ try {
1262
+ const content = readFileSync(join(HEARTBEAT_DIR, file), "utf-8");
1263
+ const hb = JSON.parse(content);
1264
+ const updatedAt = hb.updated_at || "";
1265
+ let hbEpoch = 0;
1266
+ try {
1267
+ hbEpoch = Math.floor(new Date(updatedAt).getTime() / 1000);
1268
+ } catch {
1269
+ /* ignore */
1270
+ }
1271
+ const age = hbEpoch > 0 ? now - hbEpoch : 9999;
1272
+
1273
+ let status: AgentInfo["status"] = "active";
1274
+ if (age > 120) status = "stale";
1275
+ else if (age > 30) status = "idle";
1276
+
1277
+ // Check if PID is referenced in daemon active jobs
1278
+ const issue = (hb.issue as number) || 0;
1279
+ const job = issue ? jobMap[issue] : undefined;
1280
+ const startedAt = job ? (job.started_at as string) || "" : updatedAt;
1281
+ let elapsed = 0;
1282
+ if (startedAt) {
1283
+ try {
1284
+ elapsed = now - Math.floor(new Date(startedAt).getTime() / 1000);
1285
+ } catch {
1286
+ /* ignore */
1287
+ }
1288
+ }
1289
+
1290
+ agents.push({
1291
+ id: file.replace(".json", ""),
1292
+ issue,
1293
+ title: job ? (job.title as string) || "" : "",
1294
+ machine: (hb.machine as string) || "localhost",
1295
+ stage: (hb.stage as string) || "",
1296
+ iteration: (hb.iteration as number) || 0,
1297
+ activity: (hb.last_activity as string) || "",
1298
+ memory_mb: (hb.memory_mb as number) || 0,
1299
+ cpu_pct: (hb.cpu_pct as number) || 0,
1300
+ status,
1301
+ heartbeat_age_s: age,
1302
+ started_at: startedAt,
1303
+ elapsed_s: elapsed,
1304
+ });
1305
+ } catch {
1306
+ // Skip malformed heartbeat files
1307
+ }
1308
+ }
1309
+ } catch {
1310
+ // Heartbeat dir read failed
1311
+ }
1312
+ }
1313
+
1314
+ return agents;
1315
+ }
1316
+
1317
+ // ─── Timeline ──────────────────────────────────────────────────────
1318
+ interface TimelineEntry {
1319
+ issue: number;
1320
+ title: string;
1321
+ segments: Array<{
1322
+ stage: string;
1323
+ start: string;
1324
+ end: string | null;
1325
+ status: "complete" | "running" | "failed";
1326
+ }>;
1327
+ }
1328
+
1329
+ function getTimeline(rangeHours: number): TimelineEntry[] {
1330
+ const events = readEvents();
1331
+ const daemonState = readDaemonState();
1332
+ const now = Math.floor(Date.now() / 1000);
1333
+ const cutoff = now - rangeHours * 3600;
1334
+
1335
+ // Build issue title map
1336
+ const titleMap: Record<number, string> = {};
1337
+ if (daemonState) {
1338
+ const activeJobs =
1339
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
1340
+ for (const job of activeJobs) {
1341
+ const issue = (job.issue as number) || 0;
1342
+ const title = (job.title as string) || "";
1343
+ if (issue && title) titleMap[issue] = title;
1344
+ }
1345
+ }
1346
+
1347
+ // Group events by issue
1348
+ const issueEvents: Record<number, DaemonEvent[]> = {};
1349
+ for (const e of events) {
1350
+ if (!e.issue || e.issue <= 0) continue;
1351
+ const epoch = e.ts_epoch || 0;
1352
+ if (epoch < cutoff) continue;
1353
+ if (!issueEvents[e.issue]) issueEvents[e.issue] = [];
1354
+ issueEvents[e.issue].push(e);
1355
+ if (e.type === "pipeline.completed" && !titleMap[e.issue]) {
1356
+ titleMap[e.issue] = (e.title as string) || "";
1357
+ }
1358
+ }
1359
+
1360
+ const timeline: TimelineEntry[] = [];
1361
+ for (const [issueStr, evts] of Object.entries(issueEvents)) {
1362
+ const issue = parseInt(issueStr);
1363
+ const segments: TimelineEntry["segments"] = [];
1364
+
1365
+ for (const e of evts) {
1366
+ if (e.type === "stage.started") {
1367
+ segments.push({
1368
+ stage: e.stage || "",
1369
+ start: e.ts,
1370
+ end: null,
1371
+ status: "running",
1372
+ });
1373
+ }
1374
+ if (e.type === "stage.completed") {
1375
+ // Find matching running segment
1376
+ for (let i = segments.length - 1; i >= 0; i--) {
1377
+ if (
1378
+ segments[i].stage === e.stage &&
1379
+ segments[i].status === "running"
1380
+ ) {
1381
+ segments[i].end = e.ts;
1382
+ segments[i].status = "complete";
1383
+ break;
1384
+ }
1385
+ }
1386
+ }
1387
+ if (e.type === "stage.failed") {
1388
+ for (let i = segments.length - 1; i >= 0; i--) {
1389
+ if (
1390
+ segments[i].stage === e.stage &&
1391
+ segments[i].status === "running"
1392
+ ) {
1393
+ segments[i].end = e.ts;
1394
+ segments[i].status = "failed";
1395
+ break;
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+
1401
+ if (segments.length > 0) {
1402
+ timeline.push({
1403
+ issue,
1404
+ title: titleMap[issue] || "",
1405
+ segments,
1406
+ });
1407
+ }
1408
+ }
1409
+
1410
+ return timeline;
1411
+ }
1412
+
1413
+ // ─── Machines ──────────────────────────────────────────────────────
1414
+ interface MachineInfo {
1415
+ name: string;
1416
+ host: string;
1417
+ role: string;
1418
+ max_workers: number;
1419
+ active_workers: number;
1420
+ registered_at: string;
1421
+ ssh_user?: string;
1422
+ shipwright_path?: string;
1423
+ status: "online" | "degraded" | "offline";
1424
+ health: {
1425
+ daemon_running: boolean;
1426
+ heartbeat_count: number;
1427
+ last_heartbeat_s_ago: number;
1428
+ };
1429
+ join_token?: string;
1430
+ }
1431
+
1432
+ interface MachinesFileData {
1433
+ machines: Array<Record<string, unknown>>;
1434
+ }
1435
+
1436
+ const JOIN_TOKENS_FILE = join(HOME, ".shipwright", "join-tokens.json");
1437
+ const MACHINE_HEALTH_FILE = join(HOME, ".shipwright", "machine-health.json");
1438
+
1439
+ function readMachinesFile(): MachinesFileData {
1440
+ const raw = readFileOr(MACHINES_FILE, '{"machines":[]}');
1441
+ try {
1442
+ const data = JSON.parse(raw);
1443
+ return { machines: data.machines || [] };
1444
+ } catch {
1445
+ return { machines: [] };
1446
+ }
1447
+ }
1448
+
1449
+ function writeMachinesFile(data: MachinesFileData): void {
1450
+ const dir = join(HOME, ".shipwright");
1451
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1452
+ const tmp = MACHINES_FILE + ".tmp";
1453
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
1454
+ renameSync(tmp, MACHINES_FILE);
1455
+ }
1456
+
1457
+ function enrichMachineHealth(
1458
+ machine: Record<string, unknown>,
1459
+ agents: AgentInfo[],
1460
+ ): MachineInfo {
1461
+ const name = (machine.name as string) || "";
1462
+ const host = (machine.host as string) || "";
1463
+ const activeWorkers = agents.filter(
1464
+ (a) => a.machine === name || a.machine === host,
1465
+ ).length;
1466
+
1467
+ // Read health data if available
1468
+ let daemonRunning = false;
1469
+ let heartbeatCount = 0;
1470
+ let lastHeartbeatSAgo = 9999;
1471
+
1472
+ try {
1473
+ if (existsSync(MACHINE_HEALTH_FILE)) {
1474
+ const healthData = JSON.parse(readFileSync(MACHINE_HEALTH_FILE, "utf-8"));
1475
+ const mHealth = healthData[name] || healthData[host];
1476
+ if (mHealth) {
1477
+ daemonRunning = !!mHealth.daemon_running;
1478
+ heartbeatCount = (mHealth.heartbeat_count as number) || 0;
1479
+ if (mHealth.last_check) {
1480
+ const checkEpoch = Math.floor(
1481
+ new Date(mHealth.last_check as string).getTime() / 1000,
1482
+ );
1483
+ lastHeartbeatSAgo = Math.floor(Date.now() / 1000) - checkEpoch;
1484
+ }
1485
+ }
1486
+ }
1487
+ } catch {
1488
+ // ignore health read errors
1489
+ }
1490
+
1491
+ // Also check heartbeat files for this machine
1492
+ const machineHeartbeats = agents.filter(
1493
+ (a) => a.machine === name || a.machine === host,
1494
+ );
1495
+ if (machineHeartbeats.length > 0) {
1496
+ heartbeatCount = machineHeartbeats.length;
1497
+ const minAge = Math.min(...machineHeartbeats.map((a) => a.heartbeat_age_s));
1498
+ if (minAge < lastHeartbeatSAgo) lastHeartbeatSAgo = minAge;
1499
+ }
1500
+
1501
+ let status: MachineInfo["status"] = "offline";
1502
+ if (lastHeartbeatSAgo < 60) status = "online";
1503
+ else if (lastHeartbeatSAgo < 300 || daemonRunning) status = "degraded";
1504
+
1505
+ return {
1506
+ name,
1507
+ host,
1508
+ role: (machine.role as string) || "worker",
1509
+ max_workers: (machine.max_workers as number) || 4,
1510
+ active_workers: activeWorkers,
1511
+ registered_at: (machine.registered_at as string) || "",
1512
+ ssh_user: (machine.ssh_user as string) || undefined,
1513
+ shipwright_path: (machine.shipwright_path as string) || undefined,
1514
+ status,
1515
+ health: {
1516
+ daemon_running: daemonRunning,
1517
+ heartbeat_count: heartbeatCount,
1518
+ last_heartbeat_s_ago: lastHeartbeatSAgo,
1519
+ },
1520
+ join_token: (machine.join_token as string) || undefined,
1521
+ };
1522
+ }
1523
+
1524
+ function getMachines(): MachineInfo[] {
1525
+ const data = readMachinesFile();
1526
+ if (data.machines.length === 0) return [];
1527
+ const agents = getAgents();
1528
+ return data.machines.map((m) => enrichMachineHealth(m, agents));
1529
+ }
1530
+
1531
+ function generateJoinScript(
1532
+ token: string,
1533
+ dashboardUrl: string,
1534
+ maxWorkers: number,
1535
+ ): string {
1536
+ return `#!/usr/bin/env bash
1537
+ set -euo pipefail
1538
+ # Shipwright remote worker join script
1539
+ # Generated by Shipwright Dashboard
1540
+
1541
+ DASHBOARD_URL="${dashboardUrl}"
1542
+ JOIN_TOKEN="${token}"
1543
+ MAX_WORKERS="${maxWorkers}"
1544
+
1545
+ echo "==> Joining Shipwright cluster..."
1546
+ echo " Dashboard: \${DASHBOARD_URL}"
1547
+ echo " Max workers: \${MAX_WORKERS}"
1548
+
1549
+ # Verify shipwright is installed
1550
+ if ! command -v shipwright &>/dev/null && ! command -v sw &>/dev/null; then
1551
+ echo "ERROR: shipwright not found in PATH"
1552
+ echo "Install: curl -fsSL https://raw.githubusercontent.com/sethdford/shipwright/main/install.sh | bash"
1553
+ exit 1
1554
+ fi
1555
+
1556
+ SW=\$(command -v shipwright || command -v sw)
1557
+
1558
+ # Register this machine with the dashboard
1559
+ HOSTNAME=\$(hostname)
1560
+ \$SW remote add "\${HOSTNAME}" \\
1561
+ --host "\$(hostname -f 2>/dev/null || hostname)" \\
1562
+ --max-workers "\${MAX_WORKERS}" \\
1563
+ --join-token "\${JOIN_TOKEN}" 2>/dev/null || true
1564
+
1565
+ echo "==> Machine registered. Starting daemon..."
1566
+ \$SW daemon start --max-parallel "\${MAX_WORKERS}"
1567
+ `;
1568
+ }
1569
+
1570
+ // ─── Cost Data ─────────────────────────────────────────────────────
1571
+ interface CostInfo {
1572
+ today_spent: number;
1573
+ daily_budget: number;
1574
+ pct_used: number;
1575
+ }
1576
+
1577
+ function getCostInfo(): CostInfo {
1578
+ let todaySpent = 0;
1579
+ let dailyBudget = 0;
1580
+
1581
+ if (existsSync(COSTS_FILE)) {
1582
+ try {
1583
+ const data = JSON.parse(readFileSync(COSTS_FILE, "utf-8"));
1584
+ // Cost file format: {entries: [{cost_usd, ts_epoch, ...}]}
1585
+ // Sum entries from today (midnight UTC)
1586
+ const entries = (data.entries as Array<Record<string, unknown>>) || [];
1587
+ const todayMidnight = new Date();
1588
+ todayMidnight.setUTCHours(0, 0, 0, 0);
1589
+ const cutoff = Math.floor(todayMidnight.getTime() / 1000);
1590
+ for (const entry of entries) {
1591
+ const epoch = (entry.ts_epoch as number) || 0;
1592
+ if (epoch >= cutoff) {
1593
+ todaySpent += (entry.cost_usd as number) || 0;
1594
+ }
1595
+ }
1596
+ todaySpent = Math.round(todaySpent * 100) / 100;
1597
+ } catch {
1598
+ /* ignore */
1599
+ }
1600
+ }
1601
+
1602
+ if (existsSync(BUDGET_FILE)) {
1603
+ try {
1604
+ const data = JSON.parse(readFileSync(BUDGET_FILE, "utf-8"));
1605
+ // Budget file format: {daily_budget_usd: N, enabled: bool}
1606
+ dailyBudget = (data.daily_budget_usd as number) || 0;
1607
+ } catch {
1608
+ /* ignore */
1609
+ }
1610
+ }
1611
+
1612
+ const pctUsed =
1613
+ dailyBudget > 0 ? Math.round((todaySpent / dailyBudget) * 10000) / 100 : 0;
1614
+ return {
1615
+ today_spent: todaySpent,
1616
+ daily_budget: dailyBudget,
1617
+ pct_used: pctUsed,
1618
+ };
1619
+ }
1620
+
1621
+ // ─── Plan Content ────────────────────────────────────────────────────
1622
+ function getPlanContent(issue: number): { content: string } {
1623
+ const worktreeBase = findWorktreeBase(issue);
1624
+ if (!worktreeBase) return { content: "" };
1625
+ const planPath = join(
1626
+ worktreeBase,
1627
+ ".claude",
1628
+ "pipeline-artifacts",
1629
+ "plan.md",
1630
+ );
1631
+ return { content: readFileOr(planPath, "") };
1632
+ }
1633
+
1634
+ // ─── Activity Feed ───────────────────────────────────────────────────
1635
+ interface ActivityEvent extends DaemonEvent {
1636
+ issueTitle?: string;
1637
+ }
1638
+
1639
+ interface ActivityFeed {
1640
+ events: ActivityEvent[];
1641
+ total: number;
1642
+ hasMore: boolean;
1643
+ }
1644
+
1645
+ function getActivityFeed(
1646
+ limit: number,
1647
+ offset: number,
1648
+ typeFilter: string,
1649
+ issueFilter: string,
1650
+ ): ActivityFeed {
1651
+ const allEvents = readEvents();
1652
+ const daemonState = readDaemonState();
1653
+
1654
+ // Build issue title map from daemon state
1655
+ const titleMap: Record<number, string> = {};
1656
+ if (daemonState) {
1657
+ const activeJobs =
1658
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
1659
+ for (const job of activeJobs) {
1660
+ const issue = (job.issue as number) || 0;
1661
+ const title = (job.title as string) || "";
1662
+ if (issue && title) titleMap[issue] = title;
1663
+ }
1664
+ }
1665
+
1666
+ // Filter events
1667
+ let filtered = allEvents;
1668
+ if (typeFilter && typeFilter !== "all") {
1669
+ filtered = filtered.filter(
1670
+ (e) => e.type === typeFilter || e.type.startsWith(typeFilter + "."),
1671
+ );
1672
+ }
1673
+ if (issueFilter) {
1674
+ const issueNum = parseInt(issueFilter);
1675
+ if (!isNaN(issueNum)) {
1676
+ filtered = filtered.filter((e) => e.issue === issueNum);
1677
+ }
1678
+ }
1679
+
1680
+ // Reverse for newest-first
1681
+ filtered = [...filtered].reverse();
1682
+ const total = filtered.length;
1683
+ const page = filtered.slice(offset, offset + limit);
1684
+
1685
+ // Enrich with titles
1686
+ const enriched: ActivityEvent[] = page.map((e) => ({
1687
+ ...e,
1688
+ issueTitle: e.issue ? titleMap[e.issue] || "" : undefined,
1689
+ }));
1690
+
1691
+ return {
1692
+ events: enriched,
1693
+ total,
1694
+ hasMore: offset + limit < total,
1695
+ };
1696
+ }
1697
+
1698
+ // ─── Linear Integration Status ───────────────────────────────────────
1699
+ interface LinearStatus {
1700
+ configured: boolean;
1701
+ configSource: string;
1702
+ linkedIssues: Record<string, unknown>;
1703
+ }
1704
+
1705
+ function getLinearStatus(): LinearStatus {
1706
+ const hasEnvKey = !!process.env.LINEAR_API_KEY;
1707
+ const configPath = join(HOME, ".shipwright", "tracker-config.json");
1708
+ const hasConfigFile = existsSync(configPath);
1709
+
1710
+ let linkedIssues: Record<string, unknown> = {};
1711
+ if (hasConfigFile) {
1712
+ try {
1713
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
1714
+ linkedIssues = (config.linked_issues as Record<string, unknown>) || {};
1715
+ } catch {
1716
+ // ignore
1717
+ }
1718
+ }
1719
+
1720
+ return {
1721
+ configured: hasEnvKey || hasConfigFile,
1722
+ configSource: hasEnvKey ? "env" : hasConfigFile ? "file" : "none",
1723
+ linkedIssues,
1724
+ };
1725
+ }
1726
+
1727
+ // ─── GitHub CLI Cache ────────────────────────────────────────────────
1728
+ const ghCache = new Map<string, { data: unknown; ts: number }>();
1729
+ const GH_CACHE_TTL_MS = 30_000;
1730
+
1731
+ function ghCached<T>(key: string, fn: () => T): T {
1732
+ const now = Date.now();
1733
+ const cached = ghCache.get(key);
1734
+ if (cached && now - cached.ts < GH_CACHE_TTL_MS) return cached.data as T;
1735
+ const data = fn();
1736
+ ghCache.set(key, { data, ts: now });
1737
+ return data;
1738
+ }
1739
+
1740
+ // ─── Memory System Helpers ──────────────────────────────────────────
1741
+ function readMemoryFiles(filename: string): unknown[] {
1742
+ if (!existsSync(MEMORY_DIR)) return [];
1743
+ const results: unknown[] = [];
1744
+ try {
1745
+ const subdirs = readdirSync(MEMORY_DIR);
1746
+ for (const sub of subdirs) {
1747
+ const filePath = join(MEMORY_DIR, sub, filename);
1748
+ if (existsSync(filePath)) {
1749
+ try {
1750
+ const content = readFileSync(filePath, "utf-8");
1751
+ const parsed = JSON.parse(content);
1752
+ if (Array.isArray(parsed)) results.push(...parsed);
1753
+ else results.push(parsed);
1754
+ } catch {
1755
+ // skip malformed files
1756
+ }
1757
+ }
1758
+ }
1759
+ } catch {
1760
+ // ignore
1761
+ }
1762
+ return results;
1763
+ }
1764
+
1765
+ function stripAnsi(content: string): string {
1766
+ return content.replace(/\x1b\[[0-9;]*m/g, "");
1767
+ }
1768
+
1769
+ // ─── Model Pricing (per 1M tokens) ──────────────────────────────────
1770
+ const MODEL_PRICING: Record<string, { input: number; output: number }> = {
1771
+ opus: { input: 15, output: 75 },
1772
+ sonnet: { input: 3, output: 15 },
1773
+ haiku: { input: 0.25, output: 1.25 },
1774
+ };
1775
+
1776
+ // ─── Static file serving ─────────────────────────────────────────────
1777
+ const MIME_TYPES: Record<string, string> = {
1778
+ ".html": "text/html; charset=utf-8",
1779
+ ".css": "text/css; charset=utf-8",
1780
+ ".js": "application/javascript; charset=utf-8",
1781
+ ".json": "application/json",
1782
+ ".png": "image/png",
1783
+ ".svg": "image/svg+xml",
1784
+ ".ico": "image/x-icon",
1785
+ ".woff2": "font/woff2",
1786
+ ".woff": "font/woff",
1787
+ };
1788
+
1789
+ function serveStaticFile(pathname: string): Response | null {
1790
+ // Map / to /index.html
1791
+ const filePath =
1792
+ pathname === "/" || pathname === "/index.html"
1793
+ ? join(PUBLIC_DIR, "index.html")
1794
+ : join(PUBLIC_DIR, pathname);
1795
+
1796
+ // Prevent directory traversal
1797
+ if (!filePath.startsWith(PUBLIC_DIR)) {
1798
+ return new Response("Forbidden", { status: 403 });
1799
+ }
1800
+
1801
+ const file = Bun.file(filePath);
1802
+ // Bun.file doesn't throw on missing files — check existence
1803
+ if (!existsSync(filePath)) return null;
1804
+
1805
+ const ext = extname(filePath);
1806
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
1807
+
1808
+ return new Response(file, {
1809
+ headers: {
1810
+ "Content-Type": contentType,
1811
+ "Cache-Control": "no-cache",
1812
+ },
1813
+ });
1814
+ }
1815
+
1816
+ // ─── File watcher for events.jsonl ───────────────────────────────────
1817
+ let eventsWatcher: FSWatcher | null = null;
1818
+
1819
+ function startEventsWatcher(): void {
1820
+ // Watch the directory containing events.jsonl (file may not exist yet)
1821
+ const watchDir = join(HOME, ".shipwright");
1822
+ if (!existsSync(watchDir)) return;
1823
+
1824
+ try {
1825
+ eventsWatcher = watch(watchDir, (eventType, filename) => {
1826
+ if (filename === "events.jsonl" || filename === "daemon-state.json") {
1827
+ // Push fresh state to all connected clients immediately
1828
+ if (wsClients.size > 0) {
1829
+ broadcastToClients(getFleetState());
1830
+ }
1831
+ }
1832
+ });
1833
+ } catch {
1834
+ // Watcher may fail on some systems — fall back to interval-only
1835
+ }
1836
+ }
1837
+
1838
+ // ─── Periodic WebSocket push ─────────────────────────────────────────
1839
+ let lastPushedJson = "";
1840
+
1841
+ function periodicPush(): void {
1842
+ if (wsClients.size === 0) return;
1843
+
1844
+ const state = getFleetState();
1845
+ const json = JSON.stringify(state);
1846
+ // Skip push if nothing changed (file watcher already pushed)
1847
+ if (json === lastPushedJson) return;
1848
+ lastPushedJson = json;
1849
+
1850
+ broadcastToClients(state);
1851
+ }
1852
+
1853
+ // ─── GitHub OAuth helpers ───────────────────────────────────────────
1854
+ async function exchangeCodeForToken(code: string): Promise<string | null> {
1855
+ try {
1856
+ const resp = await fetch("https://github.com/login/oauth/access_token", {
1857
+ method: "POST",
1858
+ headers: {
1859
+ "Content-Type": "application/json",
1860
+ Accept: "application/json",
1861
+ },
1862
+ body: JSON.stringify({
1863
+ client_id: GITHUB_CLIENT_ID,
1864
+ client_secret: GITHUB_CLIENT_SECRET,
1865
+ code,
1866
+ }),
1867
+ });
1868
+ const data = (await resp.json()) as { access_token?: string };
1869
+ return data.access_token || null;
1870
+ } catch {
1871
+ return null;
1872
+ }
1873
+ }
1874
+
1875
+ async function getGitHubUser(
1876
+ token: string,
1877
+ ): Promise<{ login: string; avatar_url: string } | null> {
1878
+ try {
1879
+ const resp = await fetch("https://api.github.com/user", {
1880
+ headers: {
1881
+ Authorization: `Bearer ${token}`,
1882
+ Accept: "application/vnd.github+json",
1883
+ "User-Agent": "Shipwright-Fleet-Command",
1884
+ },
1885
+ });
1886
+ if (!resp.ok) return null;
1887
+ const data = (await resp.json()) as { login: string; avatar_url: string };
1888
+ return data;
1889
+ } catch {
1890
+ return null;
1891
+ }
1892
+ }
1893
+
1894
+ async function checkRepoPermission(
1895
+ token: string,
1896
+ username: string,
1897
+ ): Promise<string | null> {
1898
+ if (!DASHBOARD_REPO) return null;
1899
+ const [owner, repo] = DASHBOARD_REPO.split("/");
1900
+ if (!owner || !repo) return null;
1901
+
1902
+ try {
1903
+ const resp = await fetch(
1904
+ `https://api.github.com/repos/${owner}/${repo}/collaborators/${username}/permission`,
1905
+ {
1906
+ headers: {
1907
+ Authorization: `Bearer ${token}`,
1908
+ Accept: "application/vnd.github+json",
1909
+ "User-Agent": "Shipwright-Fleet-Command",
1910
+ },
1911
+ },
1912
+ );
1913
+ if (!resp.ok) return null;
1914
+ const data = (await resp.json()) as { permission?: string };
1915
+ return data.permission || null;
1916
+ } catch {
1917
+ return null;
1918
+ }
1919
+ }
1920
+
1921
+ // ─── Auth route handlers ────────────────────────────────────────────
1922
+ function handleAuthGitHub(): Response {
1923
+ const params = new URLSearchParams({
1924
+ client_id: GITHUB_CLIENT_ID,
1925
+ scope: "read:org repo",
1926
+ redirect_uri: "", // GitHub uses the registered callback URL by default
1927
+ });
1928
+ // Remove empty redirect_uri — let GitHub use the app's registered callback
1929
+ params.delete("redirect_uri");
1930
+
1931
+ return new Response(null, {
1932
+ status: 302,
1933
+ headers: {
1934
+ Location: `https://github.com/login/oauth/authorize?${params.toString()}`,
1935
+ },
1936
+ });
1937
+ }
1938
+
1939
+ async function handleAuthCallback(url: URL): Promise<Response> {
1940
+ const code = url.searchParams.get("code");
1941
+ if (!code) {
1942
+ return new Response("Missing code parameter", { status: 400 });
1943
+ }
1944
+
1945
+ // Exchange code for access token
1946
+ const accessToken = await exchangeCodeForToken(code);
1947
+ if (!accessToken) {
1948
+ return new Response("Failed to exchange code for token", { status: 500 });
1949
+ }
1950
+
1951
+ // Get GitHub user info
1952
+ const user = await getGitHubUser(accessToken);
1953
+ if (!user) {
1954
+ return new Response("Failed to get user info", { status: 500 });
1955
+ }
1956
+
1957
+ // Check repo permission
1958
+ const permission = await checkRepoPermission(accessToken, user.login);
1959
+ const isAdmin = !!permission && ALLOWED_PERMISSIONS.includes(permission);
1960
+
1961
+ if (!isAdmin) {
1962
+ return new Response(accessDeniedHTML(DASHBOARD_REPO), {
1963
+ status: 403,
1964
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1965
+ });
1966
+ }
1967
+
1968
+ // Create session and redirect to dashboard
1969
+ const sessionId = createSession({
1970
+ githubUser: user.login,
1971
+ accessToken,
1972
+ avatarUrl: user.avatar_url,
1973
+ isAdmin,
1974
+ });
1975
+
1976
+ return new Response(null, {
1977
+ status: 302,
1978
+ headers: {
1979
+ Location: "/",
1980
+ "Set-Cookie": sessionCookie(sessionId),
1981
+ },
1982
+ });
1983
+ }
1984
+
1985
+ function handleAuthLogout(req: Request): Response {
1986
+ // Remove session from store
1987
+ const cookie = req.headers.get("cookie");
1988
+ if (cookie) {
1989
+ const match = cookie.match(/fleet_session=([^;]+)/);
1990
+ if (match) {
1991
+ sessions.delete(match[1]);
1992
+ saveSessions();
1993
+ }
1994
+ }
1995
+
1996
+ return new Response(null, {
1997
+ status: 302,
1998
+ headers: {
1999
+ Location: "/login",
2000
+ "Set-Cookie": clearSessionCookie(),
2001
+ },
2002
+ });
2003
+ }
2004
+
2005
+ // ─── PAT-based login handler ─────────────────────────────────────────
2006
+ async function handlePatLogin(req: Request): Promise<Response> {
2007
+ // Parse form body
2008
+ const formData = await req.formData();
2009
+ const username = ((formData.get("username") as string) || "").trim();
2010
+
2011
+ if (!username) {
2012
+ return new Response(loginPageHTML("Please enter a GitHub username"), {
2013
+ status: 400,
2014
+ headers: { "Content-Type": "text/html; charset=utf-8" },
2015
+ });
2016
+ }
2017
+
2018
+ // Use the PAT to check this user's permission on the target repo
2019
+ const permission = await checkRepoPermission(GITHUB_PAT, username);
2020
+ const isAdmin = !!permission && ALLOWED_PERMISSIONS.includes(permission);
2021
+
2022
+ if (!isAdmin) {
2023
+ return new Response(accessDeniedHTML(DASHBOARD_REPO), {
2024
+ status: 403,
2025
+ headers: { "Content-Type": "text/html; charset=utf-8" },
2026
+ });
2027
+ }
2028
+
2029
+ // Fetch their avatar URL for the dashboard
2030
+ let avatarUrl = "";
2031
+ try {
2032
+ const resp = await fetch(`https://api.github.com/users/${username}`, {
2033
+ headers: {
2034
+ Authorization: `Bearer ${GITHUB_PAT}`,
2035
+ Accept: "application/vnd.github+json",
2036
+ "User-Agent": "Shipwright-Fleet-Command",
2037
+ },
2038
+ });
2039
+ if (resp.ok) {
2040
+ const data = (await resp.json()) as {
2041
+ avatar_url?: string;
2042
+ name?: string;
2043
+ };
2044
+ avatarUrl = data.avatar_url || "";
2045
+ }
2046
+ } catch {
2047
+ // Non-critical — proceed without avatar
2048
+ }
2049
+
2050
+ const sessionId = createSession({
2051
+ githubUser: username,
2052
+ accessToken: "", // PAT mode doesn't give per-user tokens
2053
+ avatarUrl,
2054
+ isAdmin,
2055
+ });
2056
+
2057
+ return new Response(null, {
2058
+ status: 302,
2059
+ headers: {
2060
+ Location: "/",
2061
+ "Set-Cookie": sessionCookie(sessionId),
2062
+ },
2063
+ });
2064
+ }
2065
+
2066
+ // ─── HTTP + WebSocket server ─────────────────────────────────────────
2067
+ const CORS_HEADERS = {
2068
+ "Access-Control-Allow-Origin": "*",
2069
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
2070
+ "Access-Control-Allow-Headers": "Content-Type",
2071
+ };
2072
+
2073
+ const server = Bun.serve({
2074
+ port: PORT,
2075
+
2076
+ async fetch(req, server) {
2077
+ const url = new URL(req.url);
2078
+ const pathname = url.pathname;
2079
+
2080
+ // CORS preflight
2081
+ if (req.method === "OPTIONS") {
2082
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
2083
+ }
2084
+
2085
+ // ── Public routes (no auth required) ──────────────────────────
2086
+
2087
+ // Health check — always public
2088
+ if (pathname === "/api/health") {
2089
+ const health: HealthResponse = {
2090
+ status: "ok",
2091
+ uptime_s: Math.floor((Date.now() - startTime) / 1000),
2092
+ connections: wsClients.size,
2093
+ };
2094
+ return new Response(JSON.stringify(health), {
2095
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2096
+ });
2097
+ }
2098
+
2099
+ // GET /api/join/{token} — Serve join script (public, no auth required)
2100
+ if (pathname.startsWith("/api/join/") && req.method === "GET") {
2101
+ const token = pathname.split("/")[3] || "";
2102
+ if (!token) {
2103
+ return new Response("Missing token", { status: 400 });
2104
+ }
2105
+ try {
2106
+ let tokens: Array<Record<string, unknown>> = [];
2107
+ try {
2108
+ if (existsSync(JOIN_TOKENS_FILE)) {
2109
+ tokens = JSON.parse(readFileSync(JOIN_TOKENS_FILE, "utf-8"));
2110
+ }
2111
+ } catch {
2112
+ tokens = [];
2113
+ }
2114
+ const entry = tokens.find((t) => (t.token as string) === token);
2115
+ if (!entry) {
2116
+ return new Response(
2117
+ "#!/usr/bin/env bash\necho 'ERROR: Invalid or expired join token'\nexit 1\n",
2118
+ {
2119
+ headers: { "Content-Type": "text/plain", ...CORS_HEADERS },
2120
+ },
2121
+ );
2122
+ }
2123
+ const dashboardUrl = `${url.protocol}//${url.host}`;
2124
+ const maxWorkers = (entry.max_workers as number) || 4;
2125
+ const script = generateJoinScript(token, dashboardUrl, maxWorkers);
2126
+
2127
+ // Mark token as used
2128
+ entry.used = true;
2129
+ entry.used_at = new Date().toISOString();
2130
+ const dir = join(HOME, ".shipwright");
2131
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2132
+ const tokensTmp = JOIN_TOKENS_FILE + ".tmp";
2133
+ writeFileSync(tokensTmp, JSON.stringify(tokens, null, 2), "utf-8");
2134
+ renameSync(tokensTmp, JOIN_TOKENS_FILE);
2135
+
2136
+ return new Response(script, {
2137
+ headers: { "Content-Type": "text/plain", ...CORS_HEADERS },
2138
+ });
2139
+ } catch (err) {
2140
+ return new Response(
2141
+ `#!/usr/bin/env bash\necho 'ERROR: ${String(err)}'\nexit 1\n`,
2142
+ {
2143
+ status: 500,
2144
+ headers: { "Content-Type": "text/plain", ...CORS_HEADERS },
2145
+ },
2146
+ );
2147
+ }
2148
+ }
2149
+
2150
+ // Auth routes
2151
+ if (pathname === "/login") {
2152
+ if (!isAuthEnabled()) {
2153
+ // No auth configured — serve dashboard directly
2154
+ const staticResponse = serveStaticFile("/");
2155
+ if (staticResponse) return staticResponse;
2156
+ return new Response("Dashboard not found", { status: 404 });
2157
+ }
2158
+ return new Response(loginPageHTML(), {
2159
+ headers: { "Content-Type": "text/html; charset=utf-8" },
2160
+ });
2161
+ }
2162
+
2163
+ if (pathname === "/auth/github") {
2164
+ if (getAuthMode() !== "oauth") {
2165
+ return new Response("OAuth not configured", { status: 500 });
2166
+ }
2167
+ return handleAuthGitHub();
2168
+ }
2169
+
2170
+ if (pathname === "/auth/callback") {
2171
+ if (getAuthMode() !== "oauth") {
2172
+ return new Response("OAuth not configured", { status: 500 });
2173
+ }
2174
+ return handleAuthCallback(url);
2175
+ }
2176
+
2177
+ if (pathname === "/auth/pat-login" && req.method === "POST") {
2178
+ if (getAuthMode() !== "pat") {
2179
+ return new Response("PAT auth not configured", { status: 500 });
2180
+ }
2181
+ return handlePatLogin(req);
2182
+ }
2183
+
2184
+ if (pathname === "/auth/logout") {
2185
+ return handleAuthLogout(req);
2186
+ }
2187
+
2188
+ // ── Auth gate ─────────────────────────────────────────────────
2189
+ // If auth is enabled, enforce it on all remaining routes
2190
+ if (isAuthEnabled()) {
2191
+ const session = getSession(req);
2192
+ if (!session) {
2193
+ // WebSocket upgrade attempt without auth
2194
+ if (pathname === "/ws") {
2195
+ return new Response("Unauthorized", { status: 401 });
2196
+ }
2197
+ return new Response(null, {
2198
+ status: 302,
2199
+ headers: { Location: "/login" },
2200
+ });
2201
+ }
2202
+ }
2203
+
2204
+ // ── Protected routes ──────────────────────────────────────────
2205
+
2206
+ // WebSocket upgrade
2207
+ if (pathname === "/ws") {
2208
+ const upgraded = server.upgrade(req);
2209
+ if (upgraded) return undefined as unknown as Response;
2210
+ return new Response("WebSocket upgrade failed", { status: 400 });
2211
+ }
2212
+
2213
+ // REST: fleet state
2214
+ if (pathname === "/api/status") {
2215
+ return new Response(JSON.stringify(getFleetState()), {
2216
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2217
+ });
2218
+ }
2219
+
2220
+ // REST: pipeline detail for a specific issue
2221
+ if (pathname.startsWith("/api/pipeline/")) {
2222
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2223
+ if (!issueNum || isNaN(issueNum)) {
2224
+ return new Response(JSON.stringify({ error: "Invalid issue number" }), {
2225
+ status: 400,
2226
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2227
+ });
2228
+ }
2229
+ return new Response(JSON.stringify(getPipelineDetail(issueNum)), {
2230
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2231
+ });
2232
+ }
2233
+
2234
+ // REST: historical metrics aggregated from events.jsonl
2235
+ if (pathname === "/api/metrics/history") {
2236
+ const period = parseInt(url.searchParams.get("period") || "7");
2237
+ const doraPeriod = period > 0 && period <= 365 ? period : 7;
2238
+ return new Response(JSON.stringify(getMetricsHistory(doraPeriod)), {
2239
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2240
+ });
2241
+ }
2242
+
2243
+ // REST: plan markdown for a specific issue
2244
+ if (pathname.startsWith("/api/plans/")) {
2245
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2246
+ if (!issueNum || isNaN(issueNum)) {
2247
+ return new Response(JSON.stringify({ error: "Invalid issue number" }), {
2248
+ status: 400,
2249
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2250
+ });
2251
+ }
2252
+ return new Response(JSON.stringify(getPlanContent(issueNum)), {
2253
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2254
+ });
2255
+ }
2256
+
2257
+ // REST: enhanced activity feed with pagination and filtering
2258
+ if (pathname === "/api/activity") {
2259
+ const limit = Math.min(
2260
+ parseInt(url.searchParams.get("limit") || "50"),
2261
+ 200,
2262
+ );
2263
+ const offset = parseInt(url.searchParams.get("offset") || "0");
2264
+ const typeFilter = url.searchParams.get("type") || "all";
2265
+ const issueFilter = url.searchParams.get("issue") || "";
2266
+ return new Response(
2267
+ JSON.stringify(getActivityFeed(limit, offset, typeFilter, issueFilter)),
2268
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2269
+ );
2270
+ }
2271
+
2272
+ // REST: Agent heartbeats
2273
+ if (pathname === "/api/agents") {
2274
+ return new Response(JSON.stringify(getAgents()), {
2275
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2276
+ });
2277
+ }
2278
+
2279
+ // REST: Timeline (Gantt data)
2280
+ if (pathname === "/api/timeline") {
2281
+ const rangeParam = url.searchParams.get("range") || "24h";
2282
+ const hours = parseInt(rangeParam) || 24;
2283
+ return new Response(JSON.stringify(getTimeline(hours)), {
2284
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2285
+ });
2286
+ }
2287
+
2288
+ // REST: Bulk intervention (must be before generic /api/intervention/)
2289
+ if (pathname === "/api/intervention/bulk" && req.method === "POST") {
2290
+ try {
2291
+ const body = (await req.json()) as {
2292
+ issues: number[];
2293
+ action: string;
2294
+ };
2295
+ const { issues, action } = body;
2296
+ if (!Array.isArray(issues) || !action) {
2297
+ return new Response(
2298
+ JSON.stringify({ error: "Missing issues array or action" }),
2299
+ {
2300
+ status: 400,
2301
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2302
+ },
2303
+ );
2304
+ }
2305
+
2306
+ const dState = readDaemonState();
2307
+ const activeJobsList = dState
2308
+ ? (dState.active_jobs as Array<Record<string, unknown>>) || []
2309
+ : [];
2310
+
2311
+ const results: Array<{ issue: number; ok: boolean; error?: string }> =
2312
+ [];
2313
+ for (const issueNum of issues) {
2314
+ try {
2315
+ let pid: number | null = null;
2316
+ for (const job of activeJobsList) {
2317
+ if ((job.issue as number) === issueNum) {
2318
+ pid = (job.pid as number) || null;
2319
+ break;
2320
+ }
2321
+ }
2322
+ if (!pid) {
2323
+ results.push({
2324
+ issue: issueNum,
2325
+ ok: false,
2326
+ error: "No active PID found",
2327
+ });
2328
+ continue;
2329
+ }
2330
+ switch (action) {
2331
+ case "pause":
2332
+ execSync(`kill -STOP ${pid}`);
2333
+ break;
2334
+ case "resume":
2335
+ execSync(`kill -CONT ${pid}`);
2336
+ break;
2337
+ case "abort":
2338
+ execSync(`kill -TERM ${pid}`);
2339
+ break;
2340
+ default:
2341
+ results.push({
2342
+ issue: issueNum,
2343
+ ok: false,
2344
+ error: `Unknown action: ${action}`,
2345
+ });
2346
+ continue;
2347
+ }
2348
+ results.push({ issue: issueNum, ok: true });
2349
+ } catch (err) {
2350
+ results.push({
2351
+ issue: issueNum,
2352
+ ok: false,
2353
+ error: String(err),
2354
+ });
2355
+ }
2356
+ }
2357
+
2358
+ return new Response(JSON.stringify({ results }), {
2359
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2360
+ });
2361
+ } catch (err) {
2362
+ return new Response(JSON.stringify({ error: String(err) }), {
2363
+ status: 500,
2364
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2365
+ });
2366
+ }
2367
+ }
2368
+
2369
+ // REST: Intervention actions
2370
+ if (pathname.startsWith("/api/intervention/") && req.method === "POST") {
2371
+ const parts = pathname.split("/");
2372
+ const issueNum = parseInt(parts[3]);
2373
+ const action = parts[4]; // pause, resume, abort, message, skip
2374
+ if (!issueNum || !action) {
2375
+ return new Response(JSON.stringify({ error: "Invalid intervention" }), {
2376
+ status: 400,
2377
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2378
+ });
2379
+ }
2380
+
2381
+ // Find PID from daemon state
2382
+ const daemonState = readDaemonState();
2383
+ let pid: number | null = null;
2384
+ let worktreeBase: string | null = null;
2385
+ if (daemonState) {
2386
+ const activeJobs =
2387
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
2388
+ for (const job of activeJobs) {
2389
+ if ((job.issue as number) === issueNum) {
2390
+ pid = (job.pid as number) || null;
2391
+ const wt = (job.worktree as string) || "";
2392
+ const repo = (job.repo as string) || "";
2393
+ if (wt && repo) worktreeBase = join(repo, wt);
2394
+ break;
2395
+ }
2396
+ }
2397
+ }
2398
+
2399
+ try {
2400
+ switch (action) {
2401
+ case "pause":
2402
+ if (pid) execSync(`kill -STOP ${pid}`);
2403
+ break;
2404
+ case "resume":
2405
+ if (pid) execSync(`kill -CONT ${pid}`);
2406
+ break;
2407
+ case "abort":
2408
+ if (pid) execSync(`kill -TERM ${pid}`);
2409
+ break;
2410
+ case "message": {
2411
+ const body = (await req.json()) as { message?: string };
2412
+ const msg = body.message || "";
2413
+ if (worktreeBase && msg) {
2414
+ const msgDir = join(
2415
+ worktreeBase,
2416
+ ".claude",
2417
+ "pipeline-artifacts",
2418
+ );
2419
+ mkdirSync(msgDir, { recursive: true });
2420
+ const tmpFile = join(msgDir, "human-message.txt.tmp");
2421
+ const msgFile = join(msgDir, "human-message.txt");
2422
+ writeFileSync(tmpFile, msg, "utf-8");
2423
+ renameSync(tmpFile, msgFile);
2424
+ }
2425
+ break;
2426
+ }
2427
+ case "skip": {
2428
+ if (worktreeBase) {
2429
+ const artDir = join(
2430
+ worktreeBase,
2431
+ ".claude",
2432
+ "pipeline-artifacts",
2433
+ );
2434
+ mkdirSync(artDir, { recursive: true });
2435
+ const tmpFile = join(artDir, "skip-stage.txt.tmp");
2436
+ const skipFile = join(artDir, "skip-stage.txt");
2437
+ writeFileSync(tmpFile, "skip", "utf-8");
2438
+ renameSync(tmpFile, skipFile);
2439
+ }
2440
+ break;
2441
+ }
2442
+ default:
2443
+ return new Response(JSON.stringify({ error: "Unknown action" }), {
2444
+ status: 400,
2445
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2446
+ });
2447
+ }
2448
+ return new Response(
2449
+ JSON.stringify({ ok: true, action, issue: issueNum }),
2450
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2451
+ );
2452
+ } catch (err) {
2453
+ return new Response(JSON.stringify({ error: String(err) }), {
2454
+ status: 500,
2455
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2456
+ });
2457
+ }
2458
+ }
2459
+
2460
+ // REST: Linear integration status
2461
+ if (pathname === "/api/linear/status") {
2462
+ return new Response(JSON.stringify(getLinearStatus()), {
2463
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2464
+ });
2465
+ }
2466
+
2467
+ // REST: current user info
2468
+ if (pathname === "/api/me") {
2469
+ if (!isAuthEnabled()) {
2470
+ return new Response(
2471
+ JSON.stringify({ username: "local", avatarUrl: "", isAdmin: true }),
2472
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2473
+ );
2474
+ }
2475
+ const session = getSession(req);
2476
+ if (!session) {
2477
+ return new Response(JSON.stringify({ error: "Not authenticated" }), {
2478
+ status: 401,
2479
+ headers: { "Content-Type": "application/json" },
2480
+ });
2481
+ }
2482
+ return new Response(
2483
+ JSON.stringify({
2484
+ username: session.githubUser,
2485
+ avatarUrl: session.avatarUrl,
2486
+ isAdmin: session.isAdmin,
2487
+ }),
2488
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2489
+ );
2490
+ }
2491
+
2492
+ // ── Phase 1: Pipeline Deep-Dive endpoints ─────────────────────
2493
+
2494
+ // REST: Pipeline build logs
2495
+ if (pathname.startsWith("/api/logs/")) {
2496
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2497
+ if (!issueNum || isNaN(issueNum)) {
2498
+ return new Response(JSON.stringify({ error: "Invalid issue number" }), {
2499
+ status: 400,
2500
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2501
+ });
2502
+ }
2503
+ const logFile = join(LOGS_DIR, `issue-${issueNum}.log`);
2504
+ const raw = readFileOr(logFile, "");
2505
+ return new Response(JSON.stringify({ content: stripAnsi(raw) }), {
2506
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2507
+ });
2508
+ }
2509
+
2510
+ // REST: Pipeline artifacts (plan, design, dod, test-results, review, coverage)
2511
+ if (pathname.startsWith("/api/artifacts/")) {
2512
+ const parts = pathname.split("/");
2513
+ const issueNum = parseInt(parts[3] || "0");
2514
+ const artifactType = parts[4] || "";
2515
+ if (!issueNum || isNaN(issueNum) || !artifactType) {
2516
+ return new Response(
2517
+ JSON.stringify({ error: "Invalid issue or artifact type" }),
2518
+ {
2519
+ status: 400,
2520
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2521
+ },
2522
+ );
2523
+ }
2524
+ const worktreeBase = findWorktreeBase(issueNum);
2525
+ let content = "";
2526
+ let fileType = "md";
2527
+ if (worktreeBase) {
2528
+ const artifactsDir = join(
2529
+ worktreeBase,
2530
+ ".claude",
2531
+ "pipeline-artifacts",
2532
+ );
2533
+ // Try .md, .log, .json extensions
2534
+ for (const ext of [".md", ".log", ".json"]) {
2535
+ const filePath = join(artifactsDir, `${artifactType}${ext}`);
2536
+ if (existsSync(filePath)) {
2537
+ content = readFileOr(filePath, "");
2538
+ fileType = ext.slice(1);
2539
+ break;
2540
+ }
2541
+ }
2542
+ }
2543
+ return new Response(JSON.stringify({ content, type: fileType }), {
2544
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2545
+ });
2546
+ }
2547
+
2548
+ // REST: GitHub issue + PR info
2549
+ if (pathname.startsWith("/api/github/")) {
2550
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2551
+ if (!issueNum || isNaN(issueNum)) {
2552
+ return new Response(JSON.stringify({ error: "Invalid issue number" }), {
2553
+ status: 400,
2554
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2555
+ });
2556
+ }
2557
+ const data = ghCached(`github-${issueNum}`, () => {
2558
+ try {
2559
+ const issueRaw = execSync(
2560
+ `gh issue view ${issueNum} --json title,state,labels,assignees,url`,
2561
+ { encoding: "utf-8", timeout: 10000 },
2562
+ );
2563
+ const prRaw = execSync(
2564
+ `gh pr list --search "issue-${issueNum}" --json number,state,url,statusCheckRollup,reviews`,
2565
+ { encoding: "utf-8", timeout: 10000 },
2566
+ );
2567
+ const issue = JSON.parse(issueRaw);
2568
+ const prs = JSON.parse(prRaw) as Array<Record<string, unknown>>;
2569
+ const pr = prs.length > 0 ? prs[0] : null;
2570
+ const checks = pr
2571
+ ? (
2572
+ (pr.statusCheckRollup as Array<Record<string, string>>) || []
2573
+ ).map((c) => ({
2574
+ name: c.name || c.context || "",
2575
+ status: (c.conclusion || c.state || "pending").toLowerCase(),
2576
+ }))
2577
+ : [];
2578
+ return {
2579
+ configured: true,
2580
+ issue_title: issue.title || "",
2581
+ issue_state: (issue.state || "").toLowerCase(),
2582
+ issue_url: issue.url || "",
2583
+ pr_number: pr ? (pr.number as number) : null,
2584
+ pr_state: pr ? ((pr.state as string) || "").toLowerCase() : null,
2585
+ pr_url: pr ? (pr.url as string) || "" : null,
2586
+ checks,
2587
+ };
2588
+ } catch {
2589
+ return { configured: false };
2590
+ }
2591
+ });
2592
+ return new Response(JSON.stringify(data), {
2593
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2594
+ });
2595
+ }
2596
+
2597
+ // REST: Events filtered by issue
2598
+ if (pathname.startsWith("/api/events/")) {
2599
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2600
+ if (!issueNum || isNaN(issueNum)) {
2601
+ return new Response(JSON.stringify({ error: "Invalid issue number" }), {
2602
+ status: 400,
2603
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2604
+ });
2605
+ }
2606
+ const allEvents = readEvents();
2607
+ const filtered = allEvents.filter((e) => e.issue === issueNum);
2608
+ return new Response(JSON.stringify({ events: filtered }), {
2609
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2610
+ });
2611
+ }
2612
+
2613
+ // REST: Memory failure patterns for a specific issue
2614
+ if (pathname.startsWith("/api/memory/failures/")) {
2615
+ const issueNum = parseInt(pathname.split("/")[4] || "0");
2616
+ if (!issueNum || isNaN(issueNum)) {
2617
+ return new Response(JSON.stringify({ error: "Invalid issue number" }), {
2618
+ status: 400,
2619
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2620
+ });
2621
+ }
2622
+ const allPatterns = readMemoryFiles("failures.json") as Array<
2623
+ Record<string, unknown>
2624
+ >;
2625
+ const matched = allPatterns.filter((p) => {
2626
+ const issues = (p.issues as number[]) || [];
2627
+ const issue = p.issue as number;
2628
+ return issues.includes(issueNum) || issue === issueNum;
2629
+ });
2630
+ return new Response(JSON.stringify({ patterns: matched }), {
2631
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2632
+ });
2633
+ }
2634
+
2635
+ // ── Phase 2: Queue Intelligence + Cost Analytics ─────────────
2636
+
2637
+ // REST: Detailed queue with triage scores
2638
+ if (pathname === "/api/queue/detailed") {
2639
+ const daemonState = readDaemonState();
2640
+ const events = readEvents();
2641
+ const queued = daemonState
2642
+ ? (daemonState.queued as Array<number | Record<string, unknown>>) || []
2643
+ : [];
2644
+
2645
+ // Build triage score map from most recent daemon.triage events
2646
+ const triageMap: Record<number, Record<string, unknown>> = {};
2647
+ for (const e of events) {
2648
+ if (e.type === "daemon.triage" && e.issue) {
2649
+ triageMap[e.issue] = {
2650
+ complexity: e.complexity,
2651
+ impact: e.impact,
2652
+ priority: e.priority,
2653
+ age: e.age,
2654
+ dependency: e.dependency,
2655
+ memory: e.memory,
2656
+ score: e.score,
2657
+ };
2658
+ }
2659
+ }
2660
+
2661
+ const enriched = queued.map((q) => {
2662
+ const issue = typeof q === "number" ? q : (q.issue as number) || 0;
2663
+ const title = typeof q === "number" ? "" : (q.title as string) || "";
2664
+ const score = typeof q === "number" ? 0 : (q.score as number) || 0;
2665
+ const triage = triageMap[issue] || {};
2666
+ return { issue, title, score, ...triage };
2667
+ });
2668
+
2669
+ return new Response(JSON.stringify({ queue: enriched }), {
2670
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2671
+ });
2672
+ }
2673
+
2674
+ // REST: Cost breakdown by stage, model, issue
2675
+ if (pathname === "/api/costs/breakdown") {
2676
+ const period = parseInt(url.searchParams.get("period") || "7");
2677
+ const events = readEvents();
2678
+ const now = Math.floor(Date.now() / 1000);
2679
+ const cutoff = now - period * 86400;
2680
+
2681
+ const byStage: Record<string, number> = {};
2682
+ const byModel: Record<string, number> = {};
2683
+ const byIssue: Record<number, number> = {};
2684
+ let total = 0;
2685
+
2686
+ for (const e of events) {
2687
+ if ((e.ts_epoch || 0) < cutoff) continue;
2688
+ if (e.type !== "pipeline.cost" && e.type !== "cost.record") continue;
2689
+
2690
+ let cost = (e.cost_usd as number) || 0;
2691
+ if (!cost) {
2692
+ // Calculate from tokens if cost not directly recorded
2693
+ const inputTokens = (e.input_tokens as number) || 0;
2694
+ const outputTokens = (e.output_tokens as number) || 0;
2695
+ const model = ((e.model as string) || "sonnet").toLowerCase();
2696
+ const pricing = MODEL_PRICING[model] || MODEL_PRICING["sonnet"];
2697
+ cost =
2698
+ (inputTokens / 1_000_000) * pricing.input +
2699
+ (outputTokens / 1_000_000) * pricing.output;
2700
+ }
2701
+
2702
+ if (cost <= 0) continue;
2703
+ total += cost;
2704
+
2705
+ const stage = (e.stage as string) || "unknown";
2706
+ byStage[stage] = (byStage[stage] || 0) + cost;
2707
+
2708
+ const model = (e.model as string) || "unknown";
2709
+ byModel[model] = (byModel[model] || 0) + cost;
2710
+
2711
+ if (e.issue) {
2712
+ byIssue[e.issue] = (byIssue[e.issue] || 0) + cost;
2713
+ }
2714
+ }
2715
+
2716
+ // Round all values
2717
+ for (const k of Object.keys(byStage))
2718
+ byStage[k] = Math.round(byStage[k] * 100) / 100;
2719
+ for (const k of Object.keys(byModel))
2720
+ byModel[k] = Math.round(byModel[k] * 100) / 100;
2721
+ for (const k of Object.keys(byIssue))
2722
+ byIssue[parseInt(k)] = Math.round(byIssue[parseInt(k)] * 100) / 100;
2723
+
2724
+ return new Response(
2725
+ JSON.stringify({
2726
+ byStage,
2727
+ byModel,
2728
+ byIssue,
2729
+ total: Math.round(total * 100) / 100,
2730
+ }),
2731
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2732
+ );
2733
+ }
2734
+
2735
+ // REST: Cost trend (daily aggregation)
2736
+ if (pathname === "/api/costs/trend") {
2737
+ const period = parseInt(url.searchParams.get("period") || "30");
2738
+ const events = readEvents();
2739
+ const now = Math.floor(Date.now() / 1000);
2740
+ const cutoff = now - period * 86400;
2741
+
2742
+ const dailyMap: Record<string, number> = {};
2743
+ // Initialize all days
2744
+ for (let i = period - 1; i >= 0; i--) {
2745
+ const d = new Date((now - i * 86400) * 1000);
2746
+ dailyMap[d.toISOString().split("T")[0]] = 0;
2747
+ }
2748
+
2749
+ for (const e of events) {
2750
+ if ((e.ts_epoch || 0) < cutoff) continue;
2751
+ if (e.type !== "pipeline.cost" && e.type !== "cost.record") continue;
2752
+ let cost = (e.cost_usd as number) || 0;
2753
+ if (!cost) {
2754
+ const inputTokens = (e.input_tokens as number) || 0;
2755
+ const outputTokens = (e.output_tokens as number) || 0;
2756
+ const model = ((e.model as string) || "sonnet").toLowerCase();
2757
+ const pricing = MODEL_PRICING[model] || MODEL_PRICING["sonnet"];
2758
+ cost =
2759
+ (inputTokens / 1_000_000) * pricing.input +
2760
+ (outputTokens / 1_000_000) * pricing.output;
2761
+ }
2762
+ if (cost <= 0) continue;
2763
+ const dateKey = (e.ts || "").split("T")[0];
2764
+ if (dateKey in dailyMap) {
2765
+ dailyMap[dateKey] += cost;
2766
+ }
2767
+ }
2768
+
2769
+ const daily = Object.entries(dailyMap)
2770
+ .sort(([a], [b]) => a.localeCompare(b))
2771
+ .map(([date, cost]) => ({ date, cost: Math.round(cost * 100) / 100 }));
2772
+
2773
+ return new Response(JSON.stringify({ daily }), {
2774
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2775
+ });
2776
+ }
2777
+
2778
+ // REST: DORA trend (weekly sliding windows)
2779
+ if (pathname === "/api/metrics/dora-trend") {
2780
+ const period = parseInt(url.searchParams.get("period") || "30");
2781
+ const events = readEvents();
2782
+ const weeks: Array<{ week: string; grades: DoraGrades }> = [];
2783
+
2784
+ // Create weekly windows
2785
+ const now = Math.floor(Date.now() / 1000);
2786
+ const numWeeks = Math.ceil(period / 7);
2787
+ for (let i = numWeeks - 1; i >= 0; i--) {
2788
+ const weekEnd = now - i * 7 * 86400;
2789
+ const weekStart = weekEnd - 7 * 86400;
2790
+ const weekEvents = events.filter(
2791
+ (e) => (e.ts_epoch || 0) >= weekStart && (e.ts_epoch || 0) < weekEnd,
2792
+ );
2793
+ const weekDate = new Date(weekEnd * 1000).toISOString().split("T")[0];
2794
+ weeks.push({
2795
+ week: weekDate,
2796
+ grades: calculateDoraGrades(weekEvents, 7),
2797
+ });
2798
+ }
2799
+
2800
+ return new Response(JSON.stringify({ weeks }), {
2801
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2802
+ });
2803
+ }
2804
+
2805
+ // ── Phase 3: Memory + Patrol + Failure Heatmap ──────────────
2806
+
2807
+ // REST: All memory failure patterns aggregated
2808
+ if (pathname === "/api/memory/patterns") {
2809
+ const allPatterns = readMemoryFiles("failures.json") as Array<
2810
+ Record<string, unknown>
2811
+ >;
2812
+ // Aggregate by pattern signature (error message or pattern field)
2813
+ const freqMap: Record<
2814
+ string,
2815
+ { pattern: string; frequency: number; rootCause: string; fix: string }
2816
+ > = {};
2817
+ for (const p of allPatterns) {
2818
+ const key = (p.pattern as string) || (p.error as string) || "unknown";
2819
+ if (!freqMap[key]) {
2820
+ freqMap[key] = {
2821
+ pattern: key,
2822
+ frequency: 0,
2823
+ rootCause:
2824
+ (p.root_cause as string) || (p.rootCause as string) || "",
2825
+ fix: (p.fix as string) || (p.resolution as string) || "",
2826
+ };
2827
+ }
2828
+ freqMap[key].frequency++;
2829
+ }
2830
+ const patterns = Object.values(freqMap).sort(
2831
+ (a, b) => b.frequency - a.frequency,
2832
+ );
2833
+ return new Response(JSON.stringify({ patterns }), {
2834
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2835
+ });
2836
+ }
2837
+
2838
+ // REST: Memory decisions
2839
+ if (pathname === "/api/memory/decisions") {
2840
+ const decisions = readMemoryFiles("decisions.json");
2841
+ return new Response(JSON.stringify({ decisions }), {
2842
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2843
+ });
2844
+ }
2845
+
2846
+ // REST: Global memory/learnings
2847
+ if (pathname === "/api/memory/global") {
2848
+ const globalPath = join(MEMORY_DIR, "global.json");
2849
+ let learnings: unknown[] = [];
2850
+ if (existsSync(globalPath)) {
2851
+ try {
2852
+ const data = JSON.parse(readFileSync(globalPath, "utf-8"));
2853
+ learnings = Array.isArray(data) ? data : data.learnings || [];
2854
+ } catch {
2855
+ // ignore
2856
+ }
2857
+ }
2858
+ return new Response(JSON.stringify({ learnings }), {
2859
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2860
+ });
2861
+ }
2862
+
2863
+ // REST: Recent patrol findings
2864
+ if (pathname === "/api/patrol/recent") {
2865
+ const events = readEvents();
2866
+ const findings: DaemonEvent[] = [];
2867
+ const runs: DaemonEvent[] = [];
2868
+ for (const e of events) {
2869
+ if (e.type === "patrol.finding") findings.push(e);
2870
+ if (e.type === "patrol.completed") runs.push(e);
2871
+ }
2872
+ return new Response(
2873
+ JSON.stringify({
2874
+ findings: findings.slice(-50),
2875
+ runs: runs.slice(-20),
2876
+ }),
2877
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2878
+ );
2879
+ }
2880
+
2881
+ // REST: Failure heatmap (stage x day)
2882
+ if (pathname === "/api/metrics/failure-heatmap") {
2883
+ const events = readEvents();
2884
+ const heatmap: Record<string, Record<string, number>> = {};
2885
+ for (const e of events) {
2886
+ if (e.type !== "stage.failed") continue;
2887
+ const stage = e.stage || "unknown";
2888
+ const date = (e.ts || "").split("T")[0];
2889
+ if (!date) continue;
2890
+ if (!heatmap[stage]) heatmap[stage] = {};
2891
+ heatmap[stage][date] = (heatmap[stage][date] || 0) + 1;
2892
+ }
2893
+ return new Response(JSON.stringify({ heatmap }), {
2894
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2895
+ });
2896
+ }
2897
+
2898
+ // ── Phase 4: Performance Analytics ──────────────────────────
2899
+
2900
+ // REST: Per-stage performance stats
2901
+ if (pathname === "/api/metrics/stage-performance") {
2902
+ const period = parseInt(url.searchParams.get("period") || "7");
2903
+ const events = readEvents();
2904
+ const now = Math.floor(Date.now() / 1000);
2905
+ const cutoff = now - period * 86400;
2906
+ const halfCutoff = now - Math.floor(period / 2) * 86400;
2907
+
2908
+ const stageData: Record<
2909
+ string,
2910
+ {
2911
+ durations: number[];
2912
+ costs: number[];
2913
+ firstHalf: number[];
2914
+ secondHalf: number[];
2915
+ }
2916
+ > = {};
2917
+
2918
+ for (const e of events) {
2919
+ if ((e.ts_epoch || 0) < cutoff) continue;
2920
+ if (e.type === "stage.completed" && e.stage) {
2921
+ const stage = e.stage;
2922
+ if (!stageData[stage]) {
2923
+ stageData[stage] = {
2924
+ durations: [],
2925
+ costs: [],
2926
+ firstHalf: [],
2927
+ secondHalf: [],
2928
+ };
2929
+ }
2930
+ const dur = e.duration_s || 0;
2931
+ stageData[stage].durations.push(dur);
2932
+ if ((e.ts_epoch || 0) < halfCutoff) {
2933
+ stageData[stage].firstHalf.push(dur);
2934
+ } else {
2935
+ stageData[stage].secondHalf.push(dur);
2936
+ }
2937
+ const cost = (e.cost_usd as number) || 0;
2938
+ if (cost > 0) stageData[stage].costs.push(cost);
2939
+ }
2940
+ }
2941
+
2942
+ const stages = Object.entries(stageData).map(([name, data]) => {
2943
+ const sum = data.durations.reduce((a, b) => a + b, 0);
2944
+ const avg = data.durations.length > 0 ? sum / data.durations.length : 0;
2945
+ const firstAvg =
2946
+ data.firstHalf.length > 0
2947
+ ? data.firstHalf.reduce((a, b) => a + b, 0) / data.firstHalf.length
2948
+ : avg;
2949
+ const secondAvg =
2950
+ data.secondHalf.length > 0
2951
+ ? data.secondHalf.reduce((a, b) => a + b, 0) /
2952
+ data.secondHalf.length
2953
+ : avg;
2954
+ const trend =
2955
+ firstAvg > 0
2956
+ ? Math.round(((secondAvg - firstAvg) / firstAvg) * 100)
2957
+ : 0;
2958
+ const costSum = data.costs.reduce((a, b) => a + b, 0);
2959
+
2960
+ return {
2961
+ name,
2962
+ avgDuration: Math.round(avg),
2963
+ minDuration: Math.min(...data.durations),
2964
+ maxDuration: Math.max(...data.durations),
2965
+ count: data.durations.length,
2966
+ cost: Math.round(costSum * 100) / 100,
2967
+ trend,
2968
+ };
2969
+ });
2970
+
2971
+ return new Response(JSON.stringify({ stages }), {
2972
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2973
+ });
2974
+ }
2975
+
2976
+ // REST: Bottleneck analysis
2977
+ if (pathname === "/api/metrics/bottlenecks") {
2978
+ const events = readEvents();
2979
+ const now = Math.floor(Date.now() / 1000);
2980
+ const cutoff = now - 7 * 86400;
2981
+
2982
+ const stageDurs: Record<string, number[]> = {};
2983
+ for (const e of events) {
2984
+ if ((e.ts_epoch || 0) < cutoff) continue;
2985
+ if (e.type === "stage.completed" && e.stage) {
2986
+ if (!stageDurs[e.stage]) stageDurs[e.stage] = [];
2987
+ stageDurs[e.stage].push(e.duration_s || 0);
2988
+ }
2989
+ }
2990
+
2991
+ const bottlenecks = Object.entries(stageDurs)
2992
+ .map(([stage, durs]) => {
2993
+ const avg = durs.reduce((a, b) => a + b, 0) / durs.length;
2994
+ return { stage, avgDuration: Math.round(avg), count: durs.length };
2995
+ })
2996
+ .sort((a, b) => b.avgDuration - a.avgDuration)
2997
+ .slice(0, 5)
2998
+ .map((b) => ({
2999
+ stage: b.stage,
3000
+ avgDuration: b.avgDuration,
3001
+ impact:
3002
+ b.avgDuration > 600
3003
+ ? "high"
3004
+ : b.avgDuration > 300
3005
+ ? "medium"
3006
+ : "low",
3007
+ suggestion:
3008
+ b.avgDuration > 600
3009
+ ? `${b.stage} averages ${Math.round(b.avgDuration / 60)}min — consider parallelization or caching`
3010
+ : b.avgDuration > 300
3011
+ ? `${b.stage} is moderately slow — review for optimization opportunities`
3012
+ : `${b.stage} is performing well`,
3013
+ }));
3014
+
3015
+ return new Response(JSON.stringify({ bottlenecks }), {
3016
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3017
+ });
3018
+ }
3019
+
3020
+ // REST: Throughput trend (issues per hour, daily)
3021
+ if (pathname === "/api/metrics/throughput-trend") {
3022
+ const period = parseInt(url.searchParams.get("period") || "30");
3023
+ const events = readEvents();
3024
+ const now = Math.floor(Date.now() / 1000);
3025
+ const cutoff = now - period * 86400;
3026
+
3027
+ const dailyMap: Record<string, number> = {};
3028
+ for (let i = period - 1; i >= 0; i--) {
3029
+ const d = new Date((now - i * 86400) * 1000);
3030
+ dailyMap[d.toISOString().split("T")[0]] = 0;
3031
+ }
3032
+
3033
+ for (const e of events) {
3034
+ if ((e.ts_epoch || 0) < cutoff) continue;
3035
+ if (e.type === "pipeline.completed" && e.result === "success") {
3036
+ const dateKey = (e.ts || "").split("T")[0];
3037
+ if (dateKey in dailyMap) dailyMap[dateKey]++;
3038
+ }
3039
+ }
3040
+
3041
+ const daily = Object.entries(dailyMap)
3042
+ .sort(([a], [b]) => a.localeCompare(b))
3043
+ .map(([date, count]) => ({
3044
+ date,
3045
+ throughput: Math.round((count / 24) * 100) / 100,
3046
+ }));
3047
+
3048
+ return new Response(JSON.stringify({ daily }), {
3049
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3050
+ });
3051
+ }
3052
+
3053
+ // REST: Capacity estimation
3054
+ if (pathname === "/api/metrics/capacity") {
3055
+ const daemonState = readDaemonState();
3056
+ const events = readEvents();
3057
+ const now = Math.floor(Date.now() / 1000);
3058
+
3059
+ // Queue depth
3060
+ const queued = daemonState
3061
+ ? ((daemonState.queued as Array<unknown>) || []).length
3062
+ : 0;
3063
+
3064
+ // Calculate current rate (completions per hour in last 24h)
3065
+ const oneDayAgo = now - 86400;
3066
+ let completedLast24h = 0;
3067
+ for (const e of events) {
3068
+ if (
3069
+ e.type === "pipeline.completed" &&
3070
+ e.result === "success" &&
3071
+ (e.ts_epoch || 0) >= oneDayAgo
3072
+ ) {
3073
+ completedLast24h++;
3074
+ }
3075
+ }
3076
+ const currentRate = Math.round((completedLast24h / 24) * 100) / 100;
3077
+ const estimatedClearTime =
3078
+ currentRate > 0
3079
+ ? `${Math.round(queued / currentRate)}h`
3080
+ : queued > 0
3081
+ ? "unknown"
3082
+ : "0h";
3083
+
3084
+ return new Response(
3085
+ JSON.stringify({
3086
+ queueDepth: queued,
3087
+ currentRate,
3088
+ estimatedClearTime,
3089
+ }),
3090
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
3091
+ );
3092
+ }
3093
+
3094
+ // ── Phase 5: Alerts + Bulk Actions + Emergency ──────────────
3095
+
3096
+ // REST: Computed alerts
3097
+ if (pathname === "/api/alerts") {
3098
+ const events = readEvents();
3099
+ const daemonState = readDaemonState();
3100
+ const costInfo = getCostInfo();
3101
+ const now = Math.floor(Date.now() / 1000);
3102
+ const alerts: Array<{
3103
+ type: string;
3104
+ severity: string;
3105
+ message: string;
3106
+ issue?: number;
3107
+ actions?: string[];
3108
+ }> = [];
3109
+
3110
+ // Stuck pipelines: no stage change > 30min
3111
+ if (daemonState) {
3112
+ const activeJobs =
3113
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
3114
+ for (const job of activeJobs) {
3115
+ const issue = (job.issue as number) || 0;
3116
+ // Find most recent stage event for this issue
3117
+ let lastStageEpoch = 0;
3118
+ for (const e of events) {
3119
+ if (
3120
+ e.issue === issue &&
3121
+ (e.type === "stage.started" || e.type === "stage.completed")
3122
+ ) {
3123
+ lastStageEpoch = Math.max(lastStageEpoch, e.ts_epoch || 0);
3124
+ }
3125
+ }
3126
+ if (lastStageEpoch > 0 && now - lastStageEpoch > 1800) {
3127
+ alerts.push({
3128
+ type: "stuck_pipeline",
3129
+ severity: "warning",
3130
+ message: `Pipeline for issue #${issue} has had no stage change for ${Math.round((now - lastStageEpoch) / 60)}min`,
3131
+ issue,
3132
+ actions: ["pause", "abort", "message"],
3133
+ });
3134
+ }
3135
+ }
3136
+ }
3137
+
3138
+ // Budget warning (>80%)
3139
+ if (costInfo.pct_used > 80) {
3140
+ alerts.push({
3141
+ type: "budget_warning",
3142
+ severity: costInfo.pct_used > 95 ? "critical" : "warning",
3143
+ message: `Budget usage at ${costInfo.pct_used}% ($${costInfo.today_spent}/$${costInfo.daily_budget})`,
3144
+ actions: ["pause_daemon"],
3145
+ });
3146
+ }
3147
+
3148
+ // Queue depth (>10)
3149
+ const queueDepth = daemonState
3150
+ ? ((daemonState.queued as Array<unknown>) || []).length
3151
+ : 0;
3152
+ if (queueDepth > 10) {
3153
+ alerts.push({
3154
+ type: "queue_depth",
3155
+ severity: queueDepth > 20 ? "critical" : "warning",
3156
+ message: `Queue depth is ${queueDepth} issues`,
3157
+ actions: ["scale_up"],
3158
+ });
3159
+ }
3160
+
3161
+ // Failure spike (>3 failures/hr)
3162
+ const oneHourAgo = now - 3600;
3163
+ let failuresLastHour = 0;
3164
+ for (const e of events) {
3165
+ if (
3166
+ e.type === "pipeline.completed" &&
3167
+ e.result !== "success" &&
3168
+ (e.ts_epoch || 0) >= oneHourAgo
3169
+ ) {
3170
+ failuresLastHour++;
3171
+ }
3172
+ }
3173
+ if (failuresLastHour > 3) {
3174
+ alerts.push({
3175
+ type: "failure_spike",
3176
+ severity: "critical",
3177
+ message: `${failuresLastHour} pipeline failures in the last hour`,
3178
+ actions: ["emergency_brake", "review_logs"],
3179
+ });
3180
+ }
3181
+
3182
+ // Stale heartbeat (>5min)
3183
+ if (existsSync(HEARTBEAT_DIR)) {
3184
+ try {
3185
+ const files = readdirSync(HEARTBEAT_DIR).filter((f) =>
3186
+ f.endsWith(".json"),
3187
+ );
3188
+ for (const file of files) {
3189
+ try {
3190
+ const hb = JSON.parse(
3191
+ readFileSync(join(HEARTBEAT_DIR, file), "utf-8"),
3192
+ );
3193
+ const updatedAt = hb.updated_at || "";
3194
+ let hbEpoch = 0;
3195
+ try {
3196
+ hbEpoch = Math.floor(new Date(updatedAt).getTime() / 1000);
3197
+ } catch {
3198
+ /* ignore */
3199
+ }
3200
+ if (hbEpoch > 0 && now - hbEpoch > 300) {
3201
+ const issue = (hb.issue as number) || 0;
3202
+ alerts.push({
3203
+ type: "stale_heartbeat",
3204
+ severity: "warning",
3205
+ message: `Agent heartbeat for ${file.replace(".json", "")} is ${Math.round((now - hbEpoch) / 60)}min stale`,
3206
+ issue: issue || undefined,
3207
+ actions: ["abort", "investigate"],
3208
+ });
3209
+ }
3210
+ } catch {
3211
+ // skip
3212
+ }
3213
+ }
3214
+ } catch {
3215
+ // ignore
3216
+ }
3217
+ }
3218
+
3219
+ return new Response(JSON.stringify({ alerts }), {
3220
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3221
+ });
3222
+ }
3223
+
3224
+ // REST: Emergency brake — pause all active pipelines + clear queue
3225
+ if (pathname === "/api/emergency-brake" && req.method === "POST") {
3226
+ try {
3227
+ const daemonState = readDaemonState();
3228
+ let paused = 0;
3229
+ let queued = 0;
3230
+
3231
+ if (daemonState) {
3232
+ const activeJobs =
3233
+ (daemonState.active_jobs as Array<Record<string, unknown>>) || [];
3234
+ for (const job of activeJobs) {
3235
+ const pid = (job.pid as number) || 0;
3236
+ if (pid) {
3237
+ try {
3238
+ execSync(`kill -STOP ${pid}`);
3239
+ paused++;
3240
+ } catch {
3241
+ // process may already be gone
3242
+ }
3243
+ }
3244
+ }
3245
+ queued = ((daemonState.queued as Array<unknown>) || []).length;
3246
+ }
3247
+
3248
+ return new Response(JSON.stringify({ paused, queued }), {
3249
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3250
+ });
3251
+ } catch (err) {
3252
+ return new Response(JSON.stringify({ error: String(err) }), {
3253
+ status: 500,
3254
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3255
+ });
3256
+ }
3257
+ }
3258
+
3259
+ // ── Machine Management endpoints ──────────────────────────────
3260
+
3261
+ // POST /api/machines — Register a new machine
3262
+ if (pathname === "/api/machines" && req.method === "POST") {
3263
+ try {
3264
+ const body = (await req.json()) as Record<string, unknown>;
3265
+ const name = (body.name as string) || "";
3266
+ const host = (body.host as string) || "";
3267
+ if (!name || !host) {
3268
+ return new Response(
3269
+ JSON.stringify({ error: "name and host are required" }),
3270
+ {
3271
+ status: 400,
3272
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3273
+ },
3274
+ );
3275
+ }
3276
+ const data = readMachinesFile();
3277
+ if (data.machines.some((m) => (m.name as string) === name)) {
3278
+ return new Response(
3279
+ JSON.stringify({ error: `Machine "${name}" already exists` }),
3280
+ {
3281
+ status: 409,
3282
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3283
+ },
3284
+ );
3285
+ }
3286
+ const newMachine: Record<string, unknown> = {
3287
+ name,
3288
+ host,
3289
+ role: (body.role as string) || "worker",
3290
+ max_workers: (body.max_workers as number) || 4,
3291
+ ssh_user: (body.ssh_user as string) || undefined,
3292
+ shipwright_path: (body.shipwright_path as string) || undefined,
3293
+ registered_at: new Date().toISOString(),
3294
+ };
3295
+ data.machines.push(newMachine);
3296
+ writeMachinesFile(data);
3297
+ const agents = getAgents();
3298
+ return new Response(
3299
+ JSON.stringify(enrichMachineHealth(newMachine, agents)),
3300
+ {
3301
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3302
+ },
3303
+ );
3304
+ } catch (err) {
3305
+ return new Response(JSON.stringify({ error: String(err) }), {
3306
+ status: 500,
3307
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3308
+ });
3309
+ }
3310
+ }
3311
+
3312
+ // GET /api/machines — List all machines with enriched health
3313
+ if (pathname === "/api/machines" && req.method === "GET") {
3314
+ return new Response(JSON.stringify(getMachines()), {
3315
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3316
+ });
3317
+ }
3318
+
3319
+ // PATCH /api/machines/{name} — Scale workers or update fields
3320
+ if (
3321
+ pathname.startsWith("/api/machines/") &&
3322
+ !pathname.includes("/health-check") &&
3323
+ req.method === "PATCH"
3324
+ ) {
3325
+ const machineName = decodeURIComponent(pathname.split("/")[3] || "");
3326
+ if (!machineName) {
3327
+ return new Response(
3328
+ JSON.stringify({ error: "Machine name is required" }),
3329
+ {
3330
+ status: 400,
3331
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3332
+ },
3333
+ );
3334
+ }
3335
+ try {
3336
+ const body = (await req.json()) as Record<string, unknown>;
3337
+ const data = readMachinesFile();
3338
+ const idx = data.machines.findIndex(
3339
+ (m) => (m.name as string) === machineName,
3340
+ );
3341
+ if (idx === -1) {
3342
+ return new Response(
3343
+ JSON.stringify({ error: `Machine "${machineName}" not found` }),
3344
+ {
3345
+ status: 400,
3346
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3347
+ },
3348
+ );
3349
+ }
3350
+ // Update allowed fields
3351
+ if (body.max_workers !== undefined)
3352
+ data.machines[idx].max_workers = body.max_workers;
3353
+ if (body.role !== undefined) data.machines[idx].role = body.role;
3354
+ if (body.ssh_user !== undefined)
3355
+ data.machines[idx].ssh_user = body.ssh_user;
3356
+ if (body.shipwright_path !== undefined)
3357
+ data.machines[idx].shipwright_path = body.shipwright_path;
3358
+
3359
+ // If scaling, attempt to send command to remote machine
3360
+ if (body.max_workers !== undefined) {
3361
+ const machine = data.machines[idx];
3362
+ const sshUser = (machine.ssh_user as string) || "";
3363
+ const mHost = (machine.host as string) || "";
3364
+ const swPath = (machine.shipwright_path as string) || "shipwright";
3365
+ if (
3366
+ sshUser &&
3367
+ mHost &&
3368
+ mHost !== "localhost" &&
3369
+ mHost !== "127.0.0.1"
3370
+ ) {
3371
+ try {
3372
+ execSync(
3373
+ `ssh -o ConnectTimeout=5 ${sshUser}@${mHost} "${swPath} daemon scale ${body.max_workers}" 2>/dev/null`,
3374
+ { timeout: 10000 },
3375
+ );
3376
+ } catch {
3377
+ // Remote scale command failed — update saved anyway
3378
+ }
3379
+ }
3380
+ }
3381
+
3382
+ writeMachinesFile(data);
3383
+ const agents = getAgents();
3384
+ return new Response(
3385
+ JSON.stringify(enrichMachineHealth(data.machines[idx], agents)),
3386
+ {
3387
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3388
+ },
3389
+ );
3390
+ } catch (err) {
3391
+ return new Response(JSON.stringify({ error: String(err) }), {
3392
+ status: 500,
3393
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3394
+ });
3395
+ }
3396
+ }
3397
+
3398
+ // POST /api/machines/{name}/health-check — On-demand health check
3399
+ if (
3400
+ pathname.match(/^\/api\/machines\/[^/]+\/health-check$/) &&
3401
+ req.method === "POST"
3402
+ ) {
3403
+ const machineName = decodeURIComponent(pathname.split("/")[3] || "");
3404
+ try {
3405
+ const data = readMachinesFile();
3406
+ const machine = data.machines.find(
3407
+ (m) => (m.name as string) === machineName,
3408
+ );
3409
+ if (!machine) {
3410
+ return new Response(
3411
+ JSON.stringify({ error: `Machine "${machineName}" not found` }),
3412
+ {
3413
+ status: 400,
3414
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3415
+ },
3416
+ );
3417
+ }
3418
+
3419
+ const sshUser = (machine.ssh_user as string) || "";
3420
+ const mHost = (machine.host as string) || "";
3421
+ const swPath = (machine.shipwright_path as string) || "shipwright";
3422
+ let daemonRunning = false;
3423
+ let reachable = false;
3424
+
3425
+ if (!mHost || mHost === "localhost" || mHost === "127.0.0.1") {
3426
+ // Local machine — check daemon state directly
3427
+ reachable = true;
3428
+ try {
3429
+ const dState = readFileOr(DAEMON_STATE, "");
3430
+ if (dState) {
3431
+ const parsed = JSON.parse(dState);
3432
+ daemonRunning = !!parsed.pid;
3433
+ }
3434
+ } catch {
3435
+ // ignore
3436
+ }
3437
+ } else if (sshUser) {
3438
+ try {
3439
+ const result = execSync(
3440
+ `ssh -o ConnectTimeout=5 -o BatchMode=yes ${sshUser}@${mHost} "${swPath} ps 2>/dev/null || echo OFFLINE"`,
3441
+ { timeout: 10000, encoding: "utf-8" },
3442
+ );
3443
+ reachable = true;
3444
+ daemonRunning =
3445
+ !result.includes("OFFLINE") && !result.includes("No daemon");
3446
+ } catch {
3447
+ reachable = false;
3448
+ }
3449
+ }
3450
+
3451
+ // Save health data
3452
+ let healthData: Record<string, Record<string, unknown>> = {};
3453
+ try {
3454
+ if (existsSync(MACHINE_HEALTH_FILE)) {
3455
+ healthData = JSON.parse(readFileSync(MACHINE_HEALTH_FILE, "utf-8"));
3456
+ }
3457
+ } catch {
3458
+ // ignore
3459
+ }
3460
+ healthData[machineName] = {
3461
+ daemon_running: daemonRunning,
3462
+ reachable,
3463
+ last_check: new Date().toISOString(),
3464
+ };
3465
+ const healthTmp = MACHINE_HEALTH_FILE + ".tmp";
3466
+ writeFileSync(healthTmp, JSON.stringify(healthData, null, 2), "utf-8");
3467
+ renameSync(healthTmp, MACHINE_HEALTH_FILE);
3468
+
3469
+ const agents = getAgents();
3470
+ return new Response(
3471
+ JSON.stringify({
3472
+ machine: enrichMachineHealth(machine, agents),
3473
+ reachable,
3474
+ daemon_running: daemonRunning,
3475
+ checked_at: new Date().toISOString(),
3476
+ }),
3477
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
3478
+ );
3479
+ } catch (err) {
3480
+ return new Response(JSON.stringify({ error: String(err) }), {
3481
+ status: 500,
3482
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3483
+ });
3484
+ }
3485
+ }
3486
+
3487
+ // DELETE /api/machines/{name} — Remove a machine
3488
+ if (pathname.startsWith("/api/machines/") && req.method === "DELETE") {
3489
+ const machineName = decodeURIComponent(pathname.split("/")[3] || "");
3490
+ if (!machineName) {
3491
+ return new Response(
3492
+ JSON.stringify({ error: "Machine name is required" }),
3493
+ {
3494
+ status: 400,
3495
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3496
+ },
3497
+ );
3498
+ }
3499
+ try {
3500
+ const data = readMachinesFile();
3501
+ const idx = data.machines.findIndex(
3502
+ (m) => (m.name as string) === machineName,
3503
+ );
3504
+ if (idx === -1) {
3505
+ return new Response(
3506
+ JSON.stringify({ error: `Machine "${machineName}" not found` }),
3507
+ {
3508
+ status: 400,
3509
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3510
+ },
3511
+ );
3512
+ }
3513
+ data.machines.splice(idx, 1);
3514
+ writeMachinesFile(data);
3515
+ return new Response(
3516
+ JSON.stringify({ ok: true, removed: machineName }),
3517
+ {
3518
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3519
+ },
3520
+ );
3521
+ } catch (err) {
3522
+ return new Response(JSON.stringify({ error: String(err) }), {
3523
+ status: 500,
3524
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3525
+ });
3526
+ }
3527
+ }
3528
+
3529
+ // POST /api/join-token — Generate a join token and command
3530
+ if (pathname === "/api/join-token" && req.method === "POST") {
3531
+ try {
3532
+ const body = (await req.json()) as Record<string, unknown>;
3533
+ const maxWorkers = (body.max_workers as number) || 4;
3534
+ const label = (body.label as string) || "";
3535
+ const token = crypto.randomUUID();
3536
+ const dashboardUrl = `${url.protocol}//${url.host}`;
3537
+
3538
+ // Save token
3539
+ let tokens: Array<Record<string, unknown>> = [];
3540
+ try {
3541
+ if (existsSync(JOIN_TOKENS_FILE)) {
3542
+ const raw = readFileSync(JOIN_TOKENS_FILE, "utf-8");
3543
+ tokens = JSON.parse(raw);
3544
+ }
3545
+ } catch {
3546
+ tokens = [];
3547
+ }
3548
+ tokens.push({
3549
+ token,
3550
+ label,
3551
+ max_workers: maxWorkers,
3552
+ created_at: new Date().toISOString(),
3553
+ used: false,
3554
+ });
3555
+ const tokensTmp = JOIN_TOKENS_FILE + ".tmp";
3556
+ writeFileSync(tokensTmp, JSON.stringify(tokens, null, 2), "utf-8");
3557
+ renameSync(tokensTmp, JOIN_TOKENS_FILE);
3558
+
3559
+ const joinUrl = `${dashboardUrl}/api/join/${token}`;
3560
+ const joinCmd = `curl -fsSL "${joinUrl}" | bash`;
3561
+
3562
+ return new Response(
3563
+ JSON.stringify({
3564
+ token,
3565
+ join_url: joinUrl,
3566
+ join_cmd: joinCmd,
3567
+ max_workers: maxWorkers,
3568
+ }),
3569
+ { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
3570
+ );
3571
+ } catch (err) {
3572
+ return new Response(JSON.stringify({ error: String(err) }), {
3573
+ status: 500,
3574
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3575
+ });
3576
+ }
3577
+ }
3578
+
3579
+ // GET /api/join-tokens — List active join tokens
3580
+ if (pathname === "/api/join-tokens" && req.method === "GET") {
3581
+ try {
3582
+ let tokens: Array<Record<string, unknown>> = [];
3583
+ try {
3584
+ if (existsSync(JOIN_TOKENS_FILE)) {
3585
+ tokens = JSON.parse(readFileSync(JOIN_TOKENS_FILE, "utf-8"));
3586
+ }
3587
+ } catch {
3588
+ tokens = [];
3589
+ }
3590
+ return new Response(JSON.stringify(tokens), {
3591
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3592
+ });
3593
+ } catch (err) {
3594
+ return new Response(JSON.stringify({ error: String(err) }), {
3595
+ status: 500,
3596
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3597
+ });
3598
+ }
3599
+ }
3600
+
3601
+ // ── Daemon Control endpoints ─────────────────────────────────
3602
+
3603
+ // POST /api/daemon/start — Start daemon in background
3604
+ if (pathname === "/api/daemon/start" && req.method === "POST") {
3605
+ try {
3606
+ execSync("shipwright daemon start --detach", {
3607
+ timeout: 10000,
3608
+ stdio: "pipe",
3609
+ });
3610
+ return new Response(JSON.stringify({ ok: true, action: "started" }), {
3611
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3612
+ });
3613
+ } catch (err) {
3614
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3615
+ status: 500,
3616
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3617
+ });
3618
+ }
3619
+ }
3620
+
3621
+ // POST /api/daemon/stop — Stop daemon by PID
3622
+ if (pathname === "/api/daemon/stop" && req.method === "POST") {
3623
+ try {
3624
+ let pid = 0;
3625
+ try {
3626
+ if (existsSync(DAEMON_STATE)) {
3627
+ const state = JSON.parse(readFileSync(DAEMON_STATE, "utf-8"));
3628
+ pid = state.pid || 0;
3629
+ }
3630
+ } catch {
3631
+ // state file may be corrupt
3632
+ }
3633
+ if (pid > 0) {
3634
+ try {
3635
+ execSync(`kill -TERM ${pid}`, { timeout: 5000, stdio: "pipe" });
3636
+ } catch {
3637
+ // process may already be gone
3638
+ }
3639
+ }
3640
+ // Also try the daemon stop command
3641
+ try {
3642
+ execSync("shipwright daemon stop", { timeout: 10000, stdio: "pipe" });
3643
+ } catch {
3644
+ // may fail if already stopped
3645
+ }
3646
+ return new Response(
3647
+ JSON.stringify({ ok: true, action: "stopped", pid }),
3648
+ {
3649
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3650
+ },
3651
+ );
3652
+ } catch (err) {
3653
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3654
+ status: 500,
3655
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3656
+ });
3657
+ }
3658
+ }
3659
+
3660
+ // POST /api/daemon/pause — Pause daemon polling
3661
+ if (pathname === "/api/daemon/pause" && req.method === "POST") {
3662
+ try {
3663
+ const flagPath = join(HOME, ".shipwright", "daemon-pause.flag");
3664
+ const dir = join(HOME, ".shipwright");
3665
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3666
+ writeFileSync(
3667
+ flagPath,
3668
+ JSON.stringify({ paused: true, at: new Date().toISOString() }),
3669
+ );
3670
+ return new Response(JSON.stringify({ ok: true, action: "paused" }), {
3671
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3672
+ });
3673
+ } catch (err) {
3674
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3675
+ status: 500,
3676
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3677
+ });
3678
+ }
3679
+ }
3680
+
3681
+ // POST /api/daemon/resume — Resume daemon polling
3682
+ if (pathname === "/api/daemon/resume" && req.method === "POST") {
3683
+ try {
3684
+ const flagPath = join(HOME, ".shipwright", "daemon-pause.flag");
3685
+ if (existsSync(flagPath)) {
3686
+ unlinkSync(flagPath);
3687
+ }
3688
+ return new Response(JSON.stringify({ ok: true, action: "resumed" }), {
3689
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3690
+ });
3691
+ } catch (err) {
3692
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3693
+ status: 500,
3694
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3695
+ });
3696
+ }
3697
+ }
3698
+
3699
+ // GET /api/daemon/config — Return daemon configuration
3700
+ if (pathname === "/api/daemon/config" && req.method === "GET") {
3701
+ try {
3702
+ // Look for daemon-config.json in common locations
3703
+ const configPaths = [
3704
+ join(process.cwd(), ".claude", "daemon-config.json"),
3705
+ join(HOME, ".claude", "daemon-config.json"),
3706
+ ];
3707
+ let config: Record<string, unknown> = {};
3708
+ for (const p of configPaths) {
3709
+ if (existsSync(p)) {
3710
+ config = JSON.parse(readFileSync(p, "utf-8"));
3711
+ break;
3712
+ }
3713
+ }
3714
+ // Add budget info
3715
+ let budget: Record<string, unknown> = {};
3716
+ try {
3717
+ if (existsSync(BUDGET_FILE)) {
3718
+ budget = JSON.parse(readFileSync(BUDGET_FILE, "utf-8"));
3719
+ }
3720
+ } catch {
3721
+ // no budget set
3722
+ }
3723
+ // Check pause state
3724
+ const pauseFlag = join(HOME, ".shipwright", "daemon-pause.flag");
3725
+ const paused = existsSync(pauseFlag);
3726
+
3727
+ return new Response(JSON.stringify({ config, budget, paused }), {
3728
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3729
+ });
3730
+ } catch (err) {
3731
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3732
+ status: 500,
3733
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3734
+ });
3735
+ }
3736
+ }
3737
+
3738
+ // POST /api/daemon/patrol — Trigger a one-off patrol run
3739
+ if (pathname === "/api/daemon/patrol" && req.method === "POST") {
3740
+ try {
3741
+ // Run patrol in background (don't block the response)
3742
+ execSync("nohup shipwright daemon patrol --once > /dev/null 2>&1 &", {
3743
+ timeout: 5000,
3744
+ stdio: "pipe",
3745
+ shell: "/bin/bash",
3746
+ });
3747
+ return new Response(
3748
+ JSON.stringify({ ok: true, action: "patrol_triggered" }),
3749
+ {
3750
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3751
+ },
3752
+ );
3753
+ } catch (err) {
3754
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3755
+ status: 500,
3756
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3757
+ });
3758
+ }
3759
+ }
3760
+
3761
+ // ── Multi-Developer Platform endpoints ──────────────────────────
3762
+
3763
+ // POST /api/connect/heartbeat — Update developer presence
3764
+ if (pathname === "/api/connect/heartbeat" && req.method === "POST") {
3765
+ try {
3766
+ const body = (await req.json()) as any;
3767
+
3768
+ // Optional auth: if invite tokens exist, require a valid one
3769
+ if (inviteTokens.size > 0) {
3770
+ const authToken =
3771
+ body.invite_token ||
3772
+ (req.headers.get("authorization") || "").replace("Bearer ", "");
3773
+ if (authToken) {
3774
+ const entry = inviteTokens.get(authToken);
3775
+ if (!entry || new Date(entry.expires_at).getTime() < Date.now()) {
3776
+ return new Response(
3777
+ JSON.stringify({ error: "Invalid or expired invite token" }),
3778
+ {
3779
+ status: 403,
3780
+ headers: {
3781
+ "Content-Type": "application/json",
3782
+ ...CORS_HEADERS,
3783
+ },
3784
+ },
3785
+ );
3786
+ }
3787
+ }
3788
+ // If no token provided but tokens exist, check if developer is already registered
3789
+ else {
3790
+ const existingKey = `${body.developer_id}@${body.machine_name}`;
3791
+ if (!developerRegistry.has(existingKey)) {
3792
+ return new Response(
3793
+ JSON.stringify({
3794
+ error: "Invite token required for new developers",
3795
+ hint: "Run: shipwright connect join --url <dashboard> --token <token>",
3796
+ }),
3797
+ {
3798
+ status: 403,
3799
+ headers: {
3800
+ "Content-Type": "application/json",
3801
+ ...CORS_HEADERS,
3802
+ },
3803
+ },
3804
+ );
3805
+ }
3806
+ }
3807
+ }
3808
+
3809
+ const key = `${body.developer_id}@${body.machine_name}`;
3810
+
3811
+ developerRegistry.set(key, {
3812
+ developer_id: body.developer_id,
3813
+ machine_name: body.machine_name,
3814
+ hostname: body.hostname || body.machine_name,
3815
+ platform: body.platform || "unknown",
3816
+ last_heartbeat: Date.now(),
3817
+ daemon_running: body.daemon_running || false,
3818
+ daemon_pid: body.daemon_pid || null,
3819
+ active_jobs: body.active_jobs || [],
3820
+ queued: body.queued || [],
3821
+ events_since: body.events_since || 0,
3822
+ });
3823
+
3824
+ // Append incoming events to team events log
3825
+ if (body.events && Array.isArray(body.events)) {
3826
+ const dir = join(HOME, ".shipwright");
3827
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3828
+ const enriched = body.events
3829
+ .map((e: any) =>
3830
+ JSON.stringify({
3831
+ ...e,
3832
+ from_developer: body.developer_id,
3833
+ from_machine: body.machine_name,
3834
+ }),
3835
+ )
3836
+ .join("\n");
3837
+ if (enriched) {
3838
+ appendFileSync(TEAM_EVENTS_FILE, enriched + "\n");
3839
+ }
3840
+ }
3841
+
3842
+ saveDeveloperRegistry();
3843
+
3844
+ // Broadcast updated state to dashboard clients
3845
+ if (wsClients.size > 0) {
3846
+ broadcastToClients(getFleetState());
3847
+ }
3848
+
3849
+ return new Response(
3850
+ JSON.stringify({ ok: true, team_size: developerRegistry.size }),
3851
+ {
3852
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3853
+ },
3854
+ );
3855
+ } catch (err) {
3856
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3857
+ status: 500,
3858
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3859
+ });
3860
+ }
3861
+ }
3862
+
3863
+ // POST /api/connect/disconnect — Mark developer offline
3864
+ if (pathname === "/api/connect/disconnect" && req.method === "POST") {
3865
+ try {
3866
+ const body = (await req.json()) as any;
3867
+ const key = `${body.developer_id}@${body.machine_name}`;
3868
+ const dev = developerRegistry.get(key);
3869
+ if (dev) {
3870
+ dev.last_heartbeat = 0; // mark as offline immediately
3871
+ dev.daemon_running = false;
3872
+ dev.daemon_pid = null;
3873
+ dev.active_jobs = [];
3874
+ developerRegistry.set(key, dev);
3875
+ saveDeveloperRegistry();
3876
+ }
3877
+
3878
+ if (wsClients.size > 0) {
3879
+ broadcastToClients(getFleetState());
3880
+ }
3881
+
3882
+ return new Response(JSON.stringify({ ok: true }), {
3883
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3884
+ });
3885
+ } catch (err) {
3886
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
3887
+ status: 500,
3888
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3889
+ });
3890
+ }
3891
+ }
3892
+
3893
+ // GET /api/team — Return all connected developers with presence
3894
+ if (pathname === "/api/team" && req.method === "GET") {
3895
+ return new Response(JSON.stringify(getTeamState()), {
3896
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3897
+ });
3898
+ }
3899
+
3900
+ // GET /api/team/activity — Return last 100 team events
3901
+ if (pathname === "/api/team/activity" && req.method === "GET") {
3902
+ try {
3903
+ let events: unknown[] = [];
3904
+ if (existsSync(TEAM_EVENTS_FILE)) {
3905
+ const lines = readFileSync(TEAM_EVENTS_FILE, "utf-8")
3906
+ .trim()
3907
+ .split("\n")
3908
+ .filter(Boolean);
3909
+ const recent = lines.slice(-100);
3910
+ events = recent
3911
+ .map((line) => {
3912
+ try {
3913
+ return JSON.parse(line);
3914
+ } catch {
3915
+ return null;
3916
+ }
3917
+ })
3918
+ .filter(Boolean);
3919
+ }
3920
+ return new Response(JSON.stringify(events), {
3921
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3922
+ });
3923
+ } catch (err) {
3924
+ return new Response(JSON.stringify({ error: String(err) }), {
3925
+ status: 500,
3926
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3927
+ });
3928
+ }
3929
+ }
3930
+
3931
+ // POST /api/claim — Label-based claim coordination
3932
+ if (pathname === "/api/claim" && req.method === "POST") {
3933
+ try {
3934
+ const body = (await req.json()) as any;
3935
+ const issue = body.issue as number;
3936
+ const machine = (body.machine || body.machine_name) as string;
3937
+ const repo = (body.repo as string) || "";
3938
+
3939
+ // Check for existing claimed:* label
3940
+ const repoFlag = repo ? ` -R ${repo}` : "";
3941
+ let labels = "";
3942
+ try {
3943
+ labels = execSync(
3944
+ `gh issue view ${issue}${repoFlag} --json labels -q '.labels[].name'`,
3945
+ {
3946
+ encoding: "utf-8",
3947
+ timeout: 10000,
3948
+ stdio: ["pipe", "pipe", "pipe"],
3949
+ },
3950
+ ).trim();
3951
+ } catch {
3952
+ labels = "";
3953
+ }
3954
+
3955
+ const claimedLabel = labels
3956
+ .split("\n")
3957
+ .find((l: string) => l.startsWith("claimed:"));
3958
+ if (claimedLabel) {
3959
+ return new Response(
3960
+ JSON.stringify({
3961
+ approved: false,
3962
+ claimed_by: claimedLabel.replace("claimed:", ""),
3963
+ }),
3964
+ {
3965
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3966
+ },
3967
+ );
3968
+ }
3969
+
3970
+ // Ensure the claimed label exists (no-op if already created)
3971
+ try {
3972
+ execSync(
3973
+ `gh label create "claimed:${machine}"${repoFlag} --color EDEDED --description "Claimed by ${machine}" --force`,
3974
+ { timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
3975
+ );
3976
+ } catch {
3977
+ /* label may already exist or gh label create not supported — fallback below */
3978
+ }
3979
+
3980
+ // Add claimed:<machine> label
3981
+ try {
3982
+ execSync(
3983
+ `gh issue edit ${issue}${repoFlag} --add-label "claimed:${machine}"`,
3984
+ {
3985
+ timeout: 10000,
3986
+ stdio: ["pipe", "pipe", "pipe"],
3987
+ },
3988
+ );
3989
+ } catch {
3990
+ return new Response(
3991
+ JSON.stringify({ approved: false, error: "Failed to set label" }),
3992
+ {
3993
+ status: 500,
3994
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3995
+ },
3996
+ );
3997
+ }
3998
+
3999
+ return new Response(
4000
+ JSON.stringify({ approved: true, claimed_by: machine }),
4001
+ {
4002
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4003
+ },
4004
+ );
4005
+ } catch (err) {
4006
+ return new Response(
4007
+ JSON.stringify({ approved: false, error: String(err) }),
4008
+ {
4009
+ status: 500,
4010
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4011
+ },
4012
+ );
4013
+ }
4014
+ }
4015
+
4016
+ // POST /api/claim/release — Remove claimed:* label from issue
4017
+ if (pathname === "/api/claim/release" && req.method === "POST") {
4018
+ try {
4019
+ const body = (await req.json()) as any;
4020
+ const issue = body.issue as number;
4021
+ const machine = ((body.machine || body.machine_name) as string) || "";
4022
+ const repo = (body.repo as string) || "";
4023
+
4024
+ const repoFlag = repo ? ` -R ${repo}` : "";
4025
+ const label = machine ? `claimed:${machine}` : "";
4026
+
4027
+ // Find the actual claimed label if machine not specified
4028
+ let targetLabel = label;
4029
+ if (!targetLabel) {
4030
+ try {
4031
+ const labels = execSync(
4032
+ `gh issue view ${issue}${repoFlag} --json labels -q '.labels[].name'`,
4033
+ {
4034
+ encoding: "utf-8",
4035
+ timeout: 10000,
4036
+ stdio: ["pipe", "pipe", "pipe"],
4037
+ },
4038
+ ).trim();
4039
+ const found = labels
4040
+ .split("\n")
4041
+ .find((l: string) => l.startsWith("claimed:"));
4042
+ targetLabel = found || "";
4043
+ } catch {
4044
+ /* ignore */
4045
+ }
4046
+ }
4047
+
4048
+ if (targetLabel) {
4049
+ try {
4050
+ execSync(
4051
+ `gh issue edit ${issue}${repoFlag} --remove-label "${targetLabel}"`,
4052
+ {
4053
+ timeout: 10000,
4054
+ stdio: ["pipe", "pipe", "pipe"],
4055
+ },
4056
+ );
4057
+ } catch {
4058
+ /* label may already be removed */
4059
+ }
4060
+ }
4061
+
4062
+ return new Response(JSON.stringify({ ok: true }), {
4063
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4064
+ });
4065
+ } catch (err) {
4066
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
4067
+ status: 500,
4068
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4069
+ });
4070
+ }
4071
+ }
4072
+
4073
+ // POST /api/webhook/ci — Accept CI pipeline events
4074
+ if (pathname === "/api/webhook/ci" && req.method === "POST") {
4075
+ try {
4076
+ const body = (await req.json()) as any;
4077
+ const dir = join(HOME, ".shipwright");
4078
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
4079
+
4080
+ const event = {
4081
+ ...body,
4082
+ from_developer: "github-actions",
4083
+ from_machine: "ci",
4084
+ received_at: new Date().toISOString(),
4085
+ };
4086
+ appendFileSync(TEAM_EVENTS_FILE, JSON.stringify(event) + "\n");
4087
+
4088
+ // Broadcast to dashboard clients
4089
+ if (wsClients.size > 0) {
4090
+ broadcastToClients(getFleetState());
4091
+ }
4092
+
4093
+ return new Response(JSON.stringify({ ok: true }), {
4094
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4095
+ });
4096
+ } catch (err) {
4097
+ return new Response(JSON.stringify({ ok: false, error: String(err) }), {
4098
+ status: 500,
4099
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4100
+ });
4101
+ }
4102
+ }
4103
+
4104
+ // POST /api/team/invite — Generate a team invite token
4105
+ if (pathname === "/api/team/invite" && req.method === "POST") {
4106
+ try {
4107
+ const token = crypto.randomUUID();
4108
+ const now = new Date();
4109
+ const expires = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24h
4110
+ inviteTokens.set(token, {
4111
+ token,
4112
+ created_at: now.toISOString(),
4113
+ expires_at: expires.toISOString(),
4114
+ });
4115
+ saveInviteTokens();
4116
+
4117
+ const dashboardUrl = `${url.protocol}//${url.host}`;
4118
+ const command = `shipwright connect join --url ${dashboardUrl} --token ${token}`;
4119
+
4120
+ return new Response(
4121
+ JSON.stringify({ token, command, expires_at: expires.toISOString() }),
4122
+ {
4123
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4124
+ },
4125
+ );
4126
+ } catch (err) {
4127
+ return new Response(JSON.stringify({ error: String(err) }), {
4128
+ status: 500,
4129
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4130
+ });
4131
+ }
4132
+ }
4133
+
4134
+ // GET /api/team/invite/<token> — Verify an invite token
4135
+ if (pathname.startsWith("/api/team/invite/") && req.method === "GET") {
4136
+ const token = pathname.split("/")[4] || "";
4137
+ if (!token) {
4138
+ return new Response(
4139
+ JSON.stringify({ valid: false, error: "Missing token" }),
4140
+ {
4141
+ status: 400,
4142
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4143
+ },
4144
+ );
4145
+ }
4146
+ const entry = inviteTokens.get(token);
4147
+ if (!entry || new Date(entry.expires_at).getTime() < Date.now()) {
4148
+ if (entry) {
4149
+ inviteTokens.delete(token);
4150
+ saveInviteTokens();
4151
+ }
4152
+ return new Response(
4153
+ JSON.stringify({ valid: false, error: "Invalid or expired token" }),
4154
+ {
4155
+ status: 404,
4156
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4157
+ },
4158
+ );
4159
+ }
4160
+ const dashboardUrl = `${url.protocol}//${url.host}`;
4161
+ return new Response(
4162
+ JSON.stringify({
4163
+ valid: true,
4164
+ dashboard_url: dashboardUrl,
4165
+ team_name: "shipwright",
4166
+ expires_at: entry.expires_at,
4167
+ }),
4168
+ {
4169
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4170
+ },
4171
+ );
4172
+ }
4173
+
4174
+ // Static files from public/
4175
+ const staticResponse = serveStaticFile(pathname);
4176
+ if (staticResponse) return staticResponse;
4177
+
4178
+ return new Response("Not Found", { status: 404 });
4179
+ },
4180
+
4181
+ websocket: {
4182
+ open(ws) {
4183
+ wsClients.add(ws);
4184
+ // Send initial state immediately on connect
4185
+ try {
4186
+ ws.send(JSON.stringify(getFleetState()));
4187
+ } catch {
4188
+ wsClients.delete(ws);
4189
+ }
4190
+ },
4191
+ message(_ws, _message) {
4192
+ // Clients don't send meaningful messages; server is push-only
4193
+ },
4194
+ close(ws) {
4195
+ wsClients.delete(ws);
4196
+ },
4197
+ },
4198
+ });
4199
+
4200
+ // Start background tasks
4201
+ startEventsWatcher();
4202
+ loadSessions();
4203
+ loadDeveloperRegistry();
4204
+ loadInviteTokens();
4205
+ const pushInterval = setInterval(periodicPush, WS_PUSH_INTERVAL_MS);
4206
+
4207
+ // Stale claim reaper — runs every 5 minutes
4208
+ const staleClaimInterval = setInterval(
4209
+ () => {
4210
+ try {
4211
+ const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
4212
+ for (const [key, dev] of developerRegistry) {
4213
+ if (dev.last_heartbeat < twoHoursAgo && dev.last_heartbeat > 0) {
4214
+ // Check if this developer has claimed issues via labels
4215
+ try {
4216
+ const result = execSync(
4217
+ `gh issue list --label "claimed:${dev.machine_name}" --state open --json number -q '.[].number'`,
4218
+ {
4219
+ encoding: "utf-8",
4220
+ timeout: 15000,
4221
+ stdio: ["pipe", "pipe", "pipe"],
4222
+ },
4223
+ ).trim();
4224
+ if (result) {
4225
+ const issues = result.split("\n").filter(Boolean);
4226
+ for (const issueNum of issues) {
4227
+ try {
4228
+ execSync(
4229
+ `gh issue edit ${issueNum} --remove-label "claimed:${dev.machine_name}"`,
4230
+ { timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
4231
+ );
4232
+ } catch {
4233
+ /* label may already be removed */
4234
+ }
4235
+ }
4236
+ }
4237
+ } catch {
4238
+ /* gh may not be available or no issues found */
4239
+ }
4240
+ }
4241
+ }
4242
+ } catch {
4243
+ /* reaper errors are non-fatal */
4244
+ }
4245
+ },
4246
+ 5 * 60 * 1000,
4247
+ );
4248
+
4249
+ // Invite token cleanup — runs every 15 minutes
4250
+ const inviteCleanupInterval = setInterval(
4251
+ () => {
4252
+ try {
4253
+ const now = Date.now();
4254
+ let removed = 0;
4255
+ for (const [key, entry] of inviteTokens) {
4256
+ if (new Date(entry.expires_at).getTime() < now) {
4257
+ inviteTokens.delete(key);
4258
+ removed++;
4259
+ }
4260
+ }
4261
+ if (removed > 0) saveInviteTokens();
4262
+ } catch {
4263
+ /* cleanup errors are non-fatal */
4264
+ }
4265
+ },
4266
+ 15 * 60 * 1000,
4267
+ );
4268
+
4269
+ // Graceful shutdown
4270
+ process.on("SIGINT", () => {
4271
+ clearInterval(pushInterval);
4272
+ clearInterval(staleClaimInterval);
4273
+ clearInterval(inviteCleanupInterval);
4274
+ if (eventsWatcher) eventsWatcher.close();
4275
+ for (const ws of wsClients) {
4276
+ try {
4277
+ ws.close(1001, "Server shutting down");
4278
+ } catch {
4279
+ // ignore
4280
+ }
4281
+ }
4282
+ wsClients.clear();
4283
+ server.stop();
4284
+ process.exit(0);
4285
+ });
4286
+
4287
+ // ─── Startup banner ──────────────────────────────────────────────────
4288
+ const authModeLabel = (() => {
4289
+ const m = getAuthMode();
4290
+ if (m === "oauth")
4291
+ return `${GREEN}\u25CF${RESET} Auth: GitHub OAuth (repo: ${DASHBOARD_REPO})`;
4292
+ if (m === "pat")
4293
+ return `${GREEN}\u25CF${RESET} Auth: PAT-verified (repo: ${DASHBOARD_REPO})`;
4294
+ return `${DIM}\u25CB Auth: disabled (set GITHUB_PAT + DASHBOARD_REPO, or GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET + DASHBOARD_REPO)${RESET}`;
4295
+ })();
4296
+
4297
+ console.log(
4298
+ `\n ${CYAN}\u2693${RESET} ${BOLD}Shipwright Fleet Command${RESET}`,
4299
+ );
4300
+ console.log(
4301
+ ` ${GREEN}\u25CF${RESET} Dashboard: ${ULINE}http://localhost:${server.port}${RESET}`,
4302
+ );
4303
+ console.log(
4304
+ ` ${GREEN}\u25CF${RESET} API: ${ULINE}http://localhost:${server.port}/api/status${RESET}`,
4305
+ );
4306
+ console.log(
4307
+ ` ${GREEN}\u25CF${RESET} WebSocket: ${ULINE}ws://localhost:${server.port}/ws${RESET}`,
4308
+ );
4309
+ console.log(
4310
+ ` ${GREEN}\u25CF${RESET} Health: ${ULINE}http://localhost:${server.port}/api/health${RESET}`,
4311
+ );
4312
+ console.log(` ${authModeLabel}`);
4313
+ console.log(
4314
+ ` ${DIM}Push interval: ${WS_PUSH_INTERVAL_MS}ms | File watcher: ${eventsWatcher ? "active" : "fallback to interval"}${RESET}\n`,
4315
+ );