loadout-ai 0.7.0 → 0.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 (108) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/README.md +160 -315
  3. package/SECURITY.md +21 -1
  4. package/catalog/discovered.json +31269 -26003
  5. package/catalog/packages.json +4 -4
  6. package/dist/src/cli.js +13 -0
  7. package/dist/src/commands/agents.js +3 -1
  8. package/dist/src/commands/catalog-candidate.js +150 -0
  9. package/dist/src/commands/catalog-workflows.js +147 -0
  10. package/dist/src/commands/catalog.js +9 -367
  11. package/dist/src/commands/coordinate.js +586 -0
  12. package/dist/src/commands/coordination-discussions.js +194 -0
  13. package/dist/src/commands/coordination-sessions.js +197 -0
  14. package/dist/src/commands/inventory.js +4 -2
  15. package/dist/src/core/agents/agent-inspection.js +26 -4
  16. package/dist/src/core/{routing → agents}/model-config.js +7 -2
  17. package/dist/src/core/catalog/catalog.js +1 -0
  18. package/dist/src/core/catalog/registry.js +44 -10
  19. package/dist/src/core/catalog/safety.js +36 -7
  20. package/dist/src/core/coordination/adapters/claude-code.js +154 -0
  21. package/dist/src/core/coordination/adapters/codex.js +137 -0
  22. package/dist/src/core/coordination/adapters/types.js +64 -0
  23. package/dist/src/core/coordination/auth.js +59 -0
  24. package/dist/src/core/coordination/bridge-lease.js +79 -0
  25. package/dist/src/core/coordination/conflict-preview.js +167 -0
  26. package/dist/src/core/coordination/contract-diff.js +106 -0
  27. package/dist/src/core/coordination/coordinator.js +552 -0
  28. package/dist/src/core/coordination/crash-recovery.js +169 -0
  29. package/dist/src/core/coordination/daemon.js +667 -0
  30. package/dist/src/core/coordination/discussion.js +340 -0
  31. package/dist/src/core/coordination/events.js +161 -0
  32. package/dist/src/core/coordination/http-api.js +118 -0
  33. package/dist/src/core/coordination/interrupt-policy.js +89 -0
  34. package/dist/src/core/coordination/lock.js +110 -0
  35. package/dist/src/core/coordination/mcp-server.js +424 -0
  36. package/dist/src/core/coordination/redaction.js +85 -0
  37. package/dist/src/core/coordination/replay.js +188 -0
  38. package/dist/src/core/coordination/retention.js +128 -0
  39. package/dist/src/core/coordination/runtime.js +21 -0
  40. package/dist/src/core/coordination/session-manager.js +366 -0
  41. package/dist/src/core/coordination/watcher.js +128 -0
  42. package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
  43. package/dist/src/core/{routing → delegation}/handoff.js +130 -63
  44. package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
  45. package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
  46. package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
  47. package/dist/src/core/discovery/candidate-intelligence.js +3 -214
  48. package/dist/src/core/discovery/community.js +12 -4
  49. package/dist/src/core/discovery/github-discovery.js +6 -2
  50. package/dist/src/core/discovery/private-discovery.js +6 -2
  51. package/dist/src/core/install/reconcile.js +1 -1
  52. package/dist/src/core/install/source.js +21 -7
  53. package/dist/src/core/install/uninstall.js +39 -3
  54. package/dist/src/core/reporting/cli-guide.js +10 -6
  55. package/dist/src/core/reporting/completion.js +61 -109
  56. package/dist/src/core/reporting/doctor.js +4 -6
  57. package/dist/src/core/runtime/bounded-json.js +65 -0
  58. package/dist/src/core/runtime/github.js +30 -16
  59. package/dist/src/core/runtime/mcp-recipes.js +1 -1
  60. package/dist/src/core/workspace/active-policy.js +1 -1
  61. package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
  62. package/docs/CATALOG.md +1 -1
  63. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
  64. package/docs/DEMO_SCRIPT.md +19 -23
  65. package/docs/DISCOVERED.md +251 -250
  66. package/docs/FEATURE_TEST_MATRIX.md +10 -263
  67. package/docs/GITHUB_AUTHORIZATION.md +5 -0
  68. package/docs/LIVE_COLLABORATION.md +234 -0
  69. package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
  70. package/docs/REFERENCE.md +163 -0
  71. package/docs/RELEASE_REVIEW.md +1 -2
  72. package/docs/TESTING.md +17 -1
  73. package/docs/USER_TEST_GUIDE.md +138 -3
  74. package/docs/assets/loadout-discover-activate.webp +0 -0
  75. package/docs/assets/loadout-handoff-coordinate.webp +0 -0
  76. package/docs/assets/loadout-social-preview.png +0 -0
  77. package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
  78. package/docs/decisions/002-local-daemon-authentication.md +32 -0
  79. package/docs/decisions/003-bounded-agent-discussions.md +91 -0
  80. package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
  81. package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
  82. package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
  83. package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
  84. package/package.json +18 -7
  85. package/skills/loadout-handoff/SKILL.md +238 -0
  86. package/MASTER_PLAN.md +0 -2207
  87. package/dist/src/core/routing/route.js +0 -539
  88. package/docs/ACTIVE_SET.md +0 -53
  89. package/docs/COMPATIBILITY_POLICY.md +0 -22
  90. package/docs/CONVERSION_AND_SANDBOX.md +0 -27
  91. package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
  92. package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
  93. package/docs/PROVIDER_CONFIGURATION.md +0 -45
  94. package/docs/README_RESEARCH.md +0 -36
  95. package/docs/REPOSITORY_STABILIZATION.md +0 -190
  96. package/docs/SAFE_UPDATE_DEMO.md +0 -25
  97. package/docs/SCHEMA_DECISIONS.md +0 -25
  98. package/docs/SUBMISSION_COPY.md +0 -90
  99. package/docs/TEAM_POLICY.md +0 -18
  100. package/docs/assets/loadout-workflow.png +0 -0
  101. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
  102. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
  103. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
  104. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
  105. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
  106. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
  107. package/skills/loadout-router/SKILL.md +0 -120
  108. /package/dist/src/core/{routing → agents}/credentials.js +0 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * Local coordination daemon.
3
+ *
4
+ * Lightweight HTTP server with REST API and Server-Sent Events (SSE) for
5
+ * live push notifications. No external dependencies — uses Node's built-in
6
+ * http module.
7
+ *
8
+ * Endpoints:
9
+ * GET /api/snapshot/:agent — bounded state for agent
10
+ * GET /api/contracts — active contracts
11
+ * GET /api/ownership — file ownership map
12
+ * GET /api/events?after=N — events after cursor
13
+ * POST /api/emit — emit a new event
14
+ * POST /api/ack — acknowledge events
15
+ * GET /api/subscribe/:agent — SSE stream for agent (or /api/subscribe for all)
16
+ * GET /api/status — daemon health + log stats
17
+ * POST /api/compact — trigger log compaction
18
+ * GET / — web dashboard
19
+ */
20
+ import { createServer, } from "node:http";
21
+ import { readFile } from "node:fs/promises";
22
+ import { join, dirname } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { z } from "zod";
25
+ import { emit, readAfterCursor, snapshot, getContracts, getOwnership, getAckState, claimOwnership, releaseOwnership, publishContract, CoordinationConflictError, } from "./coordinator.js";
26
+ import { watchCoordination } from "./watcher.js";
27
+ import { compact, logSize } from "./retention.js";
28
+ import { COORDINATION_EVENT_TYPES, validatePayload, } from "./events.js";
29
+ import { writePidFile, removePidFile, isKillSwitchActive, getDaemonStatus, } from "./crash-recovery.js";
30
+ import { ensureDaemonToken, hasValidBearerToken } from "./auth.js";
31
+ import { HttpError, readJsonBody, sendError, sendJson, setEventStreamHeaders, setResponseHeaders, validateRequestSource, } from "./http-api.js";
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const agentNameSchema = z.string().trim().min(1).max(128);
34
+ const cursorSchema = z.coerce.number().int().min(-1);
35
+ const emitRequestSchema = z
36
+ .object({
37
+ from: agentNameSchema,
38
+ to: agentNameSchema.default("*"),
39
+ type: z.enum(COORDINATION_EVENT_TYPES),
40
+ description: z.string().trim().min(1).max(8_192),
41
+ context: z.string().max(65_536).optional(),
42
+ resolves: z.string().trim().min(1).max(128).optional(),
43
+ payload: z.record(z.string(), z.unknown()).optional(),
44
+ })
45
+ .strict();
46
+ const ackRequestSchema = z
47
+ .object({
48
+ agent: agentNameSchema,
49
+ seq: z.number().int().nonnegative(),
50
+ note: z.string().max(1_000).optional(),
51
+ })
52
+ .strict();
53
+ function validateInput(schema, input) {
54
+ const result = schema.safeParse(input);
55
+ if (!result.success) {
56
+ throw new HttpError(400, "invalid_request", "Request validation failed");
57
+ }
58
+ return result.data;
59
+ }
60
+ function sendSSE(client, events) {
61
+ for (const event of events) {
62
+ client.res.write(`data: ${JSON.stringify(event)}\n\n`);
63
+ client.cursor = Math.max(client.cursor, event.seq);
64
+ }
65
+ }
66
+ function broadcastToClients(state, events) {
67
+ for (const client of state.clients.values()) {
68
+ const relevant = client.agent
69
+ ? events.filter((e) => e.to === client.agent || e.to === "*" || e.from === client.agent)
70
+ : events;
71
+ if (relevant.length > 0) {
72
+ try {
73
+ sendSSE(client, relevant);
74
+ }
75
+ catch {
76
+ // Client disconnected, will be cleaned up
77
+ state.clients.delete(client.id);
78
+ }
79
+ }
80
+ }
81
+ }
82
+ async function handleRequest(req, res, state) {
83
+ validateRequestSource(req);
84
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
85
+ const path = url.pathname;
86
+ // Dashboard
87
+ if (path === "/" && req.method === "GET") {
88
+ try {
89
+ const html = await readFile(join(__dirname, "dashboard.html"), "utf-8");
90
+ res.writeHead(200, {
91
+ "Content-Type": "text/html; charset=utf-8",
92
+ "Cache-Control": "no-store",
93
+ "Referrer-Policy": "no-referrer",
94
+ "X-Content-Type-Options": "nosniff",
95
+ });
96
+ res.end(html);
97
+ }
98
+ catch {
99
+ res.writeHead(200, {
100
+ "Content-Type": "text/html; charset=utf-8",
101
+ "Cache-Control": "no-store",
102
+ "Referrer-Policy": "no-referrer",
103
+ "X-Content-Type-Options": "nosniff",
104
+ });
105
+ res.end(generateDashboardHTML());
106
+ }
107
+ return;
108
+ }
109
+ // API routes
110
+ if (path.startsWith("/api/")) {
111
+ if (!hasValidBearerToken(req.headers, state.token)) {
112
+ res.setHeader("WWW-Authenticate", 'Bearer realm="loadout-coordination"');
113
+ return sendError(req, res, "unauthorized", "Bearer authentication required", 401);
114
+ }
115
+ if (req.method === "OPTIONS") {
116
+ setResponseHeaders(req, res);
117
+ res.writeHead(204);
118
+ res.end();
119
+ return;
120
+ }
121
+ const route = path.slice(4); // strip /api
122
+ // GET /api/snapshot/:agent
123
+ if (route.startsWith("/snapshot/") && req.method === "GET") {
124
+ const agent = validateInput(agentNameSchema, decodeURIComponent(route.slice(10)));
125
+ const snap = await snapshot(state.projectRoot, agent);
126
+ return sendJson(req, res, snap);
127
+ }
128
+ // GET /api/contracts
129
+ if (route === "/contracts" && req.method === "GET") {
130
+ const contracts = await getContracts(state.projectRoot);
131
+ return sendJson(req, res, [...contracts.values()]);
132
+ }
133
+ // GET /api/ownership
134
+ if (route === "/ownership" && req.method === "GET") {
135
+ const ownership = await getOwnership(state.projectRoot);
136
+ return sendJson(req, res, [...ownership.values()]);
137
+ }
138
+ // GET /api/events?after=N
139
+ if (route === "/events" && req.method === "GET") {
140
+ const after = validateInput(cursorSchema, url.searchParams.get("after") ?? "-1");
141
+ const { events, highSeq } = await readAfterCursor(state.projectRoot, after);
142
+ return sendJson(req, res, { events, highSeq });
143
+ }
144
+ // POST /api/emit
145
+ if (route === "/emit" && req.method === "POST") {
146
+ // Kill switch check
147
+ const ks = await isKillSwitchActive(state.projectRoot);
148
+ if (ks.active) {
149
+ return sendError(req, res, "kill_switch_active", `Kill switch active: ${ks.reason}`, 503);
150
+ }
151
+ const body = validateInput(emitRequestSchema, await readJsonBody(req));
152
+ const payloadResult = validatePayload(body.type, body.payload);
153
+ if (!payloadResult.success) {
154
+ throw new HttpError(400, "invalid_request", "Request validation failed");
155
+ }
156
+ const payload = payloadResult.data;
157
+ const event = body.type === "ownership" && payload
158
+ ? payload.mode === "release"
159
+ ? await releaseOwnership(state.projectRoot, {
160
+ agent: body.from,
161
+ paths: payload.paths,
162
+ })
163
+ : await claimOwnership(state.projectRoot, {
164
+ agent: body.from,
165
+ paths: payload.paths,
166
+ mode: payload.mode,
167
+ reason: payload.reason,
168
+ })
169
+ : body.type === "contract" && payload
170
+ ? await publishContract(state.projectRoot, {
171
+ from: body.from,
172
+ name: payload.name,
173
+ revision: payload.revision,
174
+ body: payload.body,
175
+ format: payload.format,
176
+ })
177
+ : await emit(state.projectRoot, {
178
+ from: body.from,
179
+ to: body.to,
180
+ type: body.type,
181
+ description: body.description,
182
+ context: body.context,
183
+ resolves: body.resolves,
184
+ payload,
185
+ });
186
+ return sendJson(req, res, event, 201);
187
+ }
188
+ // POST /api/ack
189
+ if (route === "/ack" && req.method === "POST") {
190
+ const body = validateInput(ackRequestSchema, await readJsonBody(req));
191
+ const event = await emit(state.projectRoot, {
192
+ from: body.agent,
193
+ to: "*",
194
+ type: "ack",
195
+ description: `Acknowledged through seq ${body.seq}`,
196
+ payload: {
197
+ eventSeq: body.seq,
198
+ ...(body.note ? { note: body.note } : {}),
199
+ },
200
+ });
201
+ return sendJson(req, res, event, 201);
202
+ }
203
+ // GET /api/subscribe/:agent or /api/subscribe
204
+ if (route.startsWith("/subscribe") && req.method === "GET") {
205
+ const agent = route.length > 10 ? decodeURIComponent(route.slice(11)) : undefined;
206
+ const cursor = validateInput(cursorSchema, url.searchParams.get("cursor") ?? "-1");
207
+ setEventStreamHeaders(req, res);
208
+ // Send initial keepalive
209
+ res.write(": connected\n\n");
210
+ const clientId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
211
+ const client = { id: clientId, agent, res, cursor };
212
+ state.clients.set(clientId, client);
213
+ // Send backlog
214
+ if (cursor >= 0) {
215
+ const { events } = await readAfterCursor(state.projectRoot, cursor);
216
+ const relevant = agent
217
+ ? events.filter((e) => e.to === agent || e.to === "*" || e.from === agent)
218
+ : events;
219
+ if (relevant.length > 0) {
220
+ sendSSE(client, relevant);
221
+ }
222
+ }
223
+ // Keepalive every 30s
224
+ const keepalive = setInterval(() => {
225
+ try {
226
+ res.write(": keepalive\n\n");
227
+ }
228
+ catch {
229
+ clearInterval(keepalive);
230
+ state.clients.delete(clientId);
231
+ }
232
+ }, 30000);
233
+ req.on("close", () => {
234
+ clearInterval(keepalive);
235
+ state.clients.delete(clientId);
236
+ });
237
+ return;
238
+ }
239
+ // GET /api/status
240
+ if (route === "/status" && req.method === "GET") {
241
+ const size = await logSize(state.projectRoot);
242
+ const ackState = await getAckState(state.projectRoot);
243
+ return sendJson(req, res, {
244
+ daemon: "running",
245
+ startedAt: state.startedAt,
246
+ connectedClients: state.clients.size,
247
+ clients: [...state.clients.values()].map((c) => ({
248
+ id: c.id,
249
+ agent: c.agent ?? "all",
250
+ cursor: c.cursor,
251
+ })),
252
+ log: size,
253
+ ackCursors: Object.fromEntries(ackState.cursors),
254
+ unackedCount: ackState.unacked.length,
255
+ });
256
+ }
257
+ // POST /api/compact
258
+ if (route === "/compact" && req.method === "POST") {
259
+ const result = await compact(state.projectRoot);
260
+ return sendJson(req, res, result);
261
+ }
262
+ return sendError(req, res, "not_found", "Not found", 404);
263
+ }
264
+ sendError(req, res, "not_found", "Not found", 404);
265
+ }
266
+ export function generateDashboardHTML() {
267
+ return `<!DOCTYPE html>
268
+ <html lang="en">
269
+ <head>
270
+ <meta charset="utf-8">
271
+ <meta name="viewport" content="width=device-width, initial-scale=1">
272
+ <title>Loadout Coordination</title>
273
+ <style>
274
+ :root {
275
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
276
+ --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
277
+ --green: #3fb950; --yellow: #d29922; --red: #f85149;
278
+ --cyan: #39d2c0; --purple: #bc8cff;
279
+ }
280
+ * { margin: 0; padding: 0; box-sizing: border-box; }
281
+ body {
282
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
283
+ background: var(--bg); color: var(--text);
284
+ line-height: 1.5; padding: 1rem;
285
+ }
286
+ .header {
287
+ display: flex; align-items: center; gap: 1rem;
288
+ padding: 1rem; margin-bottom: 1.5rem;
289
+ border-bottom: 1px solid var(--border);
290
+ }
291
+ .header h1 { font-size: 1.4rem; font-weight: 600; }
292
+ .status-dot {
293
+ width: 10px; height: 10px; border-radius: 50%;
294
+ background: var(--green); animation: pulse 2s infinite;
295
+ }
296
+ @keyframes pulse { 50% { opacity: 0.5; } }
297
+ .grid {
298
+ display: grid;
299
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
300
+ gap: 1rem; margin-bottom: 1.5rem;
301
+ }
302
+ .card {
303
+ background: var(--surface); border: 1px solid var(--border);
304
+ border-radius: 8px; padding: 1rem;
305
+ }
306
+ .card h2 {
307
+ font-size: 0.85rem; text-transform: uppercase;
308
+ letter-spacing: 0.05em; color: var(--muted);
309
+ margin-bottom: 0.75rem; display: flex;
310
+ align-items: center; gap: 0.5rem;
311
+ }
312
+ .card h2 .count {
313
+ background: var(--border); border-radius: 10px;
314
+ padding: 0 8px; font-size: 0.75rem; color: var(--text);
315
+ }
316
+ .item {
317
+ padding: 0.5rem 0; border-bottom: 1px solid var(--border);
318
+ font-size: 0.9rem;
319
+ }
320
+ .item:last-child { border-bottom: none; }
321
+ .agent { color: var(--accent); font-weight: 600; }
322
+ .badge {
323
+ display: inline-block; padding: 1px 6px; border-radius: 4px;
324
+ font-size: 0.75rem; font-weight: 500;
325
+ }
326
+ .badge-exclusive { background: rgba(248,81,73,0.2); color: var(--red); }
327
+ .badge-shared { background: rgba(210,153,34,0.2); color: var(--yellow); }
328
+ .badge-contract { background: rgba(57,210,192,0.2); color: var(--cyan); }
329
+ .badge-decision { background: rgba(188,140,255,0.2); color: var(--purple); }
330
+ .event-feed {
331
+ background: var(--surface); border: 1px solid var(--border);
332
+ border-radius: 8px; padding: 1rem; max-height: 500px;
333
+ overflow-y: auto;
334
+ }
335
+ .event {
336
+ padding: 0.4rem 0; border-bottom: 1px solid var(--border);
337
+ font-size: 0.85rem; font-family: 'SF Mono', Monaco, monospace;
338
+ }
339
+ .event:last-child { border-bottom: none; }
340
+ .event .time { color: var(--muted); }
341
+ .event .type { font-weight: 600; }
342
+ .type-contract { color: var(--cyan); }
343
+ .type-ownership { color: var(--yellow); }
344
+ .type-decision { color: var(--purple); }
345
+ .type-update { color: var(--green); }
346
+ .type-ack { color: var(--muted); }
347
+ .type-task { color: var(--accent); }
348
+ .type-done { color: var(--green); }
349
+ .type-error { color: var(--red); }
350
+ .stat-row {
351
+ display: flex; justify-content: space-between;
352
+ padding: 0.3rem 0; font-size: 0.9rem;
353
+ }
354
+ .stat-value { font-weight: 600; color: var(--accent); }
355
+ .empty { color: var(--muted); font-style: italic; font-size: 0.9rem; }
356
+ .connected-badge {
357
+ font-size: 0.75rem; color: var(--green);
358
+ margin-left: auto;
359
+ }
360
+ </style>
361
+ </head>
362
+ <body>
363
+ <div class="header">
364
+ <div class="status-dot" id="status-dot"></div>
365
+ <h1>Loadout Coordination</h1>
366
+ <span class="connected-badge" id="connection-status">connecting...</span>
367
+ </div>
368
+
369
+ <div class="grid">
370
+ <div class="card" id="stats-card">
371
+ <h2>📊 Status</h2>
372
+ <div id="stats"><span class="empty">Loading...</span></div>
373
+ </div>
374
+ <div class="card">
375
+ <h2>📋 Contracts <span class="count" id="contract-count">0</span></h2>
376
+ <div id="contracts"><span class="empty">No contracts</span></div>
377
+ </div>
378
+ <div class="card">
379
+ <h2>🔒 File Ownership <span class="count" id="ownership-count">0</span></h2>
380
+ <div id="ownership"><span class="empty">No ownership claims</span></div>
381
+ </div>
382
+ <div class="card">
383
+ <h2>✅ Ack State</h2>
384
+ <div id="acks"><span class="empty">No acknowledgements</span></div>
385
+ </div>
386
+ </div>
387
+
388
+ <div class="event-feed">
389
+ <h2 style="font-size:0.85rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:0.75rem;">
390
+ ⚡ Live Event Feed
391
+ </h2>
392
+ <div id="events"><span class="empty">Waiting for events...</span></div>
393
+ </div>
394
+
395
+ <script>
396
+ const API = window.location.origin;
397
+ const TOKEN_KEY = 'loadout-coordination-token';
398
+ const fragmentToken = new URLSearchParams(window.location.hash.slice(1)).get('token');
399
+ if (fragmentToken) {
400
+ sessionStorage.setItem(TOKEN_KEY, fragmentToken);
401
+ history.replaceState(null, '', window.location.pathname + window.location.search);
402
+ }
403
+ const daemonToken = sessionStorage.getItem(TOKEN_KEY) || window.prompt('Enter the Loadout daemon token:') || '';
404
+ if (daemonToken) sessionStorage.setItem(TOKEN_KEY, daemonToken);
405
+ let eventController = null;
406
+ const maxEvents = 200;
407
+ const eventBuffer = [];
408
+
409
+ async function fetchJSON(path) {
410
+ const res = await fetch(API + path, {
411
+ headers: { Authorization: 'Bearer ' + daemonToken },
412
+ });
413
+ if (!res.ok) throw new Error('HTTP ' + res.status);
414
+ return res.json();
415
+ }
416
+
417
+ function escapeHtml(s) {
418
+ const d = document.createElement('div');
419
+ d.textContent = s;
420
+ return d.innerHTML;
421
+ }
422
+
423
+ async function refreshState() {
424
+ try {
425
+ const [status, contracts, ownership] = await Promise.all([
426
+ fetchJSON('/api/status'),
427
+ fetchJSON('/api/contracts'),
428
+ fetchJSON('/api/ownership'),
429
+ ]);
430
+
431
+ // Stats
432
+ document.getElementById('stats').innerHTML = [
433
+ statRow('Events', status.log.events),
434
+ statRow('Log size', formatBytes(status.log.bytes)),
435
+ statRow('Connected clients', status.connectedClients),
436
+ statRow('Unacked events', status.unackedCount),
437
+ statRow('Uptime', formatUptime(status.startedAt)),
438
+ ].join('');
439
+
440
+ // Contracts
441
+ document.getElementById('contract-count').textContent = contracts.length;
442
+ document.getElementById('contracts').innerHTML = contracts.length
443
+ ? contracts.map(c =>
444
+ '<div class="item">' +
445
+ '<span class="badge badge-contract">' + escapeHtml(c.name) + ' rev' + c.revision + '</span> ' +
446
+ 'by <span class="agent">' + escapeHtml(c.publisher) + '</span>' +
447
+ (c.format ? ' <span style="color:var(--muted)">(' + escapeHtml(c.format) + ')</span>' : '') +
448
+ '</div>'
449
+ ).join('')
450
+ : '<span class="empty">No contracts</span>';
451
+
452
+ // Ownership
453
+ document.getElementById('ownership-count').textContent = ownership.length;
454
+ document.getElementById('ownership').innerHTML = ownership.length
455
+ ? ownership.map(o =>
456
+ '<div class="item">' +
457
+ '<span class="agent">' + escapeHtml(o.agent) + '</span> ' +
458
+ '<span class="badge badge-' + o.mode + '">' + o.mode + '</span> ' +
459
+ escapeHtml(o.paths.join(', ')) +
460
+ '</div>'
461
+ ).join('')
462
+ : '<span class="empty">No ownership claims</span>';
463
+
464
+ // Ack state
465
+ const ackHtml = Object.entries(status.ackCursors).map(([agent, cursor]) =>
466
+ '<div class="item">' +
467
+ '<span class="agent">' + escapeHtml(agent) + '</span>: seq ' + cursor +
468
+ '</div>'
469
+ ).join('');
470
+ document.getElementById('acks').innerHTML = ackHtml || '<span class="empty">No acknowledgements</span>';
471
+
472
+ } catch (e) {
473
+ console.error('Refresh failed:', e);
474
+ }
475
+ }
476
+
477
+ function statRow(label, value) {
478
+ return '<div class="stat-row"><span>' + label + '</span><span class="stat-value">' + value + '</span></div>';
479
+ }
480
+
481
+ function formatBytes(b) {
482
+ if (b < 1024) return b + ' B';
483
+ if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
484
+ return (b / (1024 * 1024)).toFixed(1) + ' MB';
485
+ }
486
+
487
+ function formatUptime(startedAt) {
488
+ const ms = Date.now() - new Date(startedAt).getTime();
489
+ const s = Math.floor(ms / 1000);
490
+ if (s < 60) return s + 's';
491
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
492
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
493
+ }
494
+
495
+ function renderEvent(event) {
496
+ const time = event.timestamp ? event.timestamp.slice(11, 19) : '';
497
+ return '<div class="event">' +
498
+ '<span class="time">' + time + '</span> ' +
499
+ '<span class="type type-' + event.type + '">[' + event.type + ']</span> ' +
500
+ '<span class="agent">' + escapeHtml(event.from) + '</span>' +
501
+ (event.to !== '*' ? ' → ' + escapeHtml(event.to) : '') + ' ' +
502
+ escapeHtml(event.description) +
503
+ '</div>';
504
+ }
505
+
506
+ function addEvent(event) {
507
+ eventBuffer.unshift(event);
508
+ if (eventBuffer.length > maxEvents) eventBuffer.pop();
509
+ const el = document.getElementById('events');
510
+ el.innerHTML = eventBuffer.map(renderEvent).join('');
511
+ }
512
+
513
+ async function connectSSE() {
514
+ if (eventController) eventController.abort();
515
+ eventController = new AbortController();
516
+ try {
517
+ const response = await fetch(API + '/api/subscribe', {
518
+ headers: { Authorization: 'Bearer ' + daemonToken },
519
+ signal: eventController.signal,
520
+ });
521
+ if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
522
+ document.getElementById('connection-status').textContent = 'connected';
523
+ document.getElementById('status-dot').style.background = 'var(--green)';
524
+ const reader = response.body.getReader();
525
+ const decoder = new TextDecoder();
526
+ let pending = '';
527
+ while (true) {
528
+ const chunk = await reader.read();
529
+ if (chunk.done) break;
530
+ pending += decoder.decode(chunk.value, { stream: true });
531
+ const messages = pending.split('\\n\\n');
532
+ pending = messages.pop() || '';
533
+ for (const message of messages) {
534
+ const data = message.split('\\n').find(line => line.startsWith('data: '));
535
+ if (!data) continue;
536
+ const event = JSON.parse(data.slice(6));
537
+ addEvent(event);
538
+ refreshState();
539
+ }
540
+ }
541
+ throw new Error('SSE stream ended');
542
+ } catch (error) {
543
+ if (error instanceof DOMException && error.name === 'AbortError') return;
544
+ document.getElementById('connection-status').textContent = 'reconnecting...';
545
+ document.getElementById('status-dot').style.background = 'var(--red)';
546
+ setTimeout(connectSSE, 3000);
547
+ }
548
+ }
549
+
550
+ // Initial load
551
+ (async () => {
552
+ // Load recent events
553
+ try {
554
+ const { events } = await fetchJSON('/api/events?after=-1');
555
+ const recent = events.slice(-50);
556
+ for (const e of recent) eventBuffer.push(e);
557
+ document.getElementById('events').innerHTML =
558
+ eventBuffer.length ? eventBuffer.map(renderEvent).join('')
559
+ : '<span class="empty">No events yet</span>';
560
+ } catch {}
561
+
562
+ await refreshState();
563
+ connectSSE();
564
+
565
+ // Refresh state every 10s
566
+ setInterval(refreshState, 10000);
567
+ })();
568
+ </script>
569
+ </body>
570
+ </html>`;
571
+ }
572
+ export async function startDaemon(projectRoot, port = 4510) {
573
+ // Check kill switch
574
+ const killSwitch = await isKillSwitchActive(projectRoot);
575
+ if (killSwitch.active) {
576
+ throw new Error(`Kill switch active: ${killSwitch.reason}\n` +
577
+ `Deactivate with: loadout daemon resume`);
578
+ }
579
+ const existing = await getDaemonStatus(projectRoot);
580
+ if (existing.running && existing.info) {
581
+ throw new Error(`Coordination daemon already running on port ${existing.info.port}; that project daemon is already in use`);
582
+ }
583
+ const token = await ensureDaemonToken(projectRoot);
584
+ const state = {
585
+ projectRoot,
586
+ token,
587
+ clients: new Map(),
588
+ watcher: null,
589
+ startedAt: new Date().toISOString(),
590
+ };
591
+ // Start file watcher to push events to SSE clients
592
+ try {
593
+ state.watcher = await watchCoordination(projectRoot, {
594
+ onEvents(events) {
595
+ broadcastToClients(state, events);
596
+ },
597
+ onError(err) {
598
+ console.error(`Watcher error: ${err.message}`);
599
+ },
600
+ });
601
+ }
602
+ catch {
603
+ // Watcher failed — daemon still works, just no push
604
+ }
605
+ const server = createServer((req, res) => {
606
+ handleRequest(req, res, state).catch((err) => {
607
+ if (!res.headersSent) {
608
+ if (err instanceof HttpError) {
609
+ sendError(req, res, err.code, err.message, err.status);
610
+ }
611
+ else if (err instanceof CoordinationConflictError) {
612
+ sendError(req, res, err.code, err.message, 409);
613
+ }
614
+ else if (err instanceof URIError) {
615
+ sendError(req, res, "invalid_request", "Invalid request URL", 400);
616
+ }
617
+ else {
618
+ console.error("Request error:", err);
619
+ sendError(req, res, "internal_error", "Internal server error", 500);
620
+ }
621
+ }
622
+ });
623
+ });
624
+ return new Promise((resolve, reject) => {
625
+ server.on("error", (err) => {
626
+ if (err.code === "EADDRINUSE") {
627
+ reject(new Error(`Port ${port} already in use — daemon may already be running`));
628
+ }
629
+ else {
630
+ reject(err);
631
+ }
632
+ });
633
+ server.listen(port, "127.0.0.1", async () => {
634
+ // Write PID file after successful bind
635
+ await writePidFile(projectRoot, port);
636
+ // Clean up PID file on exit — only register once per process
637
+ const onExit = () => removePidFile(projectRoot);
638
+ const onTerm = async () => {
639
+ await removePidFile(projectRoot);
640
+ process.exit(0);
641
+ };
642
+ process.once("exit", onExit);
643
+ process.once("SIGTERM", onTerm);
644
+ resolve({
645
+ port,
646
+ token,
647
+ dashboardUrl: `http://127.0.0.1:${port}/#token=${encodeURIComponent(token)}`,
648
+ close() {
649
+ process.removeListener("exit", onExit);
650
+ process.removeListener("SIGTERM", onTerm);
651
+ state.watcher?.stop();
652
+ for (const client of state.clients.values()) {
653
+ try {
654
+ client.res.end();
655
+ }
656
+ catch {
657
+ // ignore
658
+ }
659
+ }
660
+ state.clients.clear();
661
+ server.close();
662
+ removePidFile(projectRoot);
663
+ },
664
+ });
665
+ });
666
+ });
667
+ }