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.
- package/CHANGELOG.md +148 -1
- package/README.md +160 -315
- package/SECURITY.md +21 -1
- package/catalog/discovered.json +31269 -26003
- package/catalog/packages.json +4 -4
- package/dist/src/cli.js +13 -0
- package/dist/src/commands/agents.js +3 -1
- package/dist/src/commands/catalog-candidate.js +150 -0
- package/dist/src/commands/catalog-workflows.js +147 -0
- package/dist/src/commands/catalog.js +9 -367
- package/dist/src/commands/coordinate.js +586 -0
- package/dist/src/commands/coordination-discussions.js +194 -0
- package/dist/src/commands/coordination-sessions.js +197 -0
- package/dist/src/commands/inventory.js +4 -2
- package/dist/src/core/agents/agent-inspection.js +26 -4
- package/dist/src/core/{routing → agents}/model-config.js +7 -2
- package/dist/src/core/catalog/catalog.js +1 -0
- package/dist/src/core/catalog/registry.js +44 -10
- package/dist/src/core/catalog/safety.js +36 -7
- package/dist/src/core/coordination/adapters/claude-code.js +154 -0
- package/dist/src/core/coordination/adapters/codex.js +137 -0
- package/dist/src/core/coordination/adapters/types.js +64 -0
- package/dist/src/core/coordination/auth.js +59 -0
- package/dist/src/core/coordination/bridge-lease.js +79 -0
- package/dist/src/core/coordination/conflict-preview.js +167 -0
- package/dist/src/core/coordination/contract-diff.js +106 -0
- package/dist/src/core/coordination/coordinator.js +552 -0
- package/dist/src/core/coordination/crash-recovery.js +169 -0
- package/dist/src/core/coordination/daemon.js +667 -0
- package/dist/src/core/coordination/discussion.js +340 -0
- package/dist/src/core/coordination/events.js +161 -0
- package/dist/src/core/coordination/http-api.js +118 -0
- package/dist/src/core/coordination/interrupt-policy.js +89 -0
- package/dist/src/core/coordination/lock.js +110 -0
- package/dist/src/core/coordination/mcp-server.js +424 -0
- package/dist/src/core/coordination/redaction.js +85 -0
- package/dist/src/core/coordination/replay.js +188 -0
- package/dist/src/core/coordination/retention.js +128 -0
- package/dist/src/core/coordination/runtime.js +21 -0
- package/dist/src/core/coordination/session-manager.js +366 -0
- package/dist/src/core/coordination/watcher.js +128 -0
- package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
- package/dist/src/core/{routing → delegation}/handoff.js +130 -63
- package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
- package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence.js +3 -214
- package/dist/src/core/discovery/community.js +12 -4
- package/dist/src/core/discovery/github-discovery.js +6 -2
- package/dist/src/core/discovery/private-discovery.js +6 -2
- package/dist/src/core/install/reconcile.js +1 -1
- package/dist/src/core/install/source.js +21 -7
- package/dist/src/core/install/uninstall.js +39 -3
- package/dist/src/core/reporting/cli-guide.js +10 -6
- package/dist/src/core/reporting/completion.js +61 -109
- package/dist/src/core/reporting/doctor.js +4 -6
- package/dist/src/core/runtime/bounded-json.js +65 -0
- package/dist/src/core/runtime/github.js +30 -16
- package/dist/src/core/runtime/mcp-recipes.js +1 -1
- package/dist/src/core/workspace/active-policy.js +1 -1
- package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
- package/docs/CATALOG.md +1 -1
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
- package/docs/DEMO_SCRIPT.md +19 -23
- package/docs/DISCOVERED.md +251 -250
- package/docs/FEATURE_TEST_MATRIX.md +10 -263
- package/docs/GITHUB_AUTHORIZATION.md +5 -0
- package/docs/LIVE_COLLABORATION.md +234 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
- package/docs/REFERENCE.md +163 -0
- package/docs/RELEASE_REVIEW.md +1 -2
- package/docs/TESTING.md +17 -1
- package/docs/USER_TEST_GUIDE.md +138 -3
- package/docs/assets/loadout-discover-activate.webp +0 -0
- package/docs/assets/loadout-handoff-coordinate.webp +0 -0
- package/docs/assets/loadout-social-preview.png +0 -0
- package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
- package/docs/decisions/002-local-daemon-authentication.md +32 -0
- package/docs/decisions/003-bounded-agent-discussions.md +91 -0
- package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
- package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
- package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
- package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
- package/package.json +18 -7
- package/skills/loadout-handoff/SKILL.md +238 -0
- package/MASTER_PLAN.md +0 -2207
- package/dist/src/core/routing/route.js +0 -539
- package/docs/ACTIVE_SET.md +0 -53
- package/docs/COMPATIBILITY_POLICY.md +0 -22
- package/docs/CONVERSION_AND_SANDBOX.md +0 -27
- package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
- package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
- package/docs/PROVIDER_CONFIGURATION.md +0 -45
- package/docs/README_RESEARCH.md +0 -36
- package/docs/REPOSITORY_STABILIZATION.md +0 -190
- package/docs/SAFE_UPDATE_DEMO.md +0 -25
- package/docs/SCHEMA_DECISIONS.md +0 -25
- package/docs/SUBMISSION_COPY.md +0 -90
- package/docs/TEAM_POLICY.md +0 -18
- package/docs/assets/loadout-workflow.png +0 -0
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
- package/skills/loadout-router/SKILL.md +0 -120
- /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
|
+
}
|