@dadado/agent-kit-cli 4.7.2 → 4.8.2

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.
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 22.38 22.45" fill="none" aria-hidden="true">
2
+ <!-- Cursor Interface Skin: stroke helmet mark (readable at 16px chrome icon size). -->
3
+ <path d="M9.61,22.1c-4.45-1.22-7.48-4.71-8.17-9.63v-2.47c0-5.39,4.37-9.75,9.75-9.75h0c5.39,0,9.75,4.37,9.75,9.75,0,.62.04,1.85,0,2.47-.34,4.86-3.54,8.41-8.2,9.63-.49.13-2.63.13-3.13,0Z" stroke="#e4e4e4" stroke-width=".5" stroke-miterlimit="10" fill="none"/>
4
+ <path d="M18.42,9.54c-.38,3.74-1.81,8.07-7.22,8.07s-6.66-4.49-7.22-8.07v-.19c0-3.47,3.23-6.28,7.22-6.28s7.22,2.81,7.22,6.28v.19Z" stroke="#e4e4e4" stroke-width=".5" stroke-miterlimit="10" fill="none"/>
5
+ <path d="M13.59,6.08c1.18.76,1.99,1.85,2.2,3.16.07.43.07.86,0,1.28s-.19.84-.37,1.25" stroke="#e4e4e4" stroke-width=".5" stroke-linecap="round" fill="none"/>
6
+ <g>
7
+ <line x1=".25" y1="7.9" x2=".25" y2="13.68" stroke="#e4e4e4" stroke-width=".5" stroke-linecap="round"/>
8
+ <line x1="22.13" y1="7.9" x2="22.13" y2="13.68" stroke="#e4e4e4" stroke-width=".5" stroke-linecap="round"/>
9
+ </g>
10
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="425" height="431" viewBox="0 0 425 431" fill="none"><path d="M336.63 276.677C332.594 322.739 284.509 369.238 212.312 369.238C140.116 369.238 96.6777 324.675 88.6322 281.275C87.4704 275.028 87.158 264.135 87.3829 256.289C87.4195 261.58 87.837 266.862 88.6322 272.093C96.6777 315.493 140.116 360.055 212.312 360.055C284.509 360.055 332.594 313.557 336.63 267.495C336.967 263.597 337.317 258.825 337.467 254.228C337.655 261.721 337.375 269.219 336.63 276.677V276.677Z" fill="#e2e8f0"></path><path d="M344.35 166.265H338.229C324.699 105.337 273.428 60 212.225 60C151.022 60 99.7884 105.337 86.1836 166.265H79.5998C77.5165 166.337 75.5467 167.232 74.1226 168.754C72.6986 170.277 71.9367 172.302 72.0041 174.385V231.778C71.9367 233.861 72.6986 235.887 74.1226 237.409C75.5467 238.931 77.5165 239.827 79.5998 239.898H87.7702C87.3062 245.328 87.1643 250.78 87.3455 256.227V256.314C87.382 261.605 87.7995 266.887 88.5947 272.118C96.6402 315.518 140.078 360.08 212.275 360.08C284.472 360.08 332.557 313.582 336.592 267.52C336.929 263.622 337.279 258.85 337.429 254.253C337.641 248.543 337.554 243.097 336.817 239.898H344.313C346.396 239.827 348.366 238.931 349.79 237.409C351.214 235.887 351.976 233.861 351.908 231.778V174.398C351.976 172.319 351.22 170.298 349.804 168.775C348.388 167.252 346.428 166.35 344.35 166.265V166.265Z" fill="#DADADA"></path><path d="M351.996 174.398V231.778C352.029 232.814 351.858 233.846 351.491 234.815C351.124 235.784 350.57 236.672 349.86 237.426C349.149 238.181 348.297 238.787 347.351 239.212C346.406 239.636 345.386 239.869 344.35 239.898H335.83C319.327 296.117 270.205 336.844 212.15 336.844C154.095 336.844 105.085 296.117 88.5823 239.898H79.5998C77.5165 239.827 75.5467 238.931 74.1226 237.409C72.6986 235.887 71.9367 233.861 72.0041 231.778V174.385C71.9367 172.302 72.6986 170.277 74.1226 168.754C75.5467 167.232 77.5165 166.337 79.5998 166.265H86.1836C99.7509 105.337 151.034 60 212.225 60C273.415 60 324.661 105.337 338.279 166.265H344.35C345.387 166.296 346.407 166.53 347.352 166.956C348.298 167.382 349.15 167.99 349.86 168.745C350.571 169.501 351.125 170.389 351.491 171.359C351.858 172.329 352.029 173.361 351.996 174.398V174.398Z" fill="#F6F6F6"></path><path d="M351.996 174.398V231.778C352.029 232.814 351.858 233.846 351.491 234.815C351.124 235.784 350.57 236.672 349.86 237.426C349.149 238.181 348.297 238.787 347.351 239.212C346.406 239.636 345.386 239.869 344.35 239.898H335.843C342.835 215.951 343.694 190.631 338.341 166.265H344.35C345.387 166.296 346.407 166.53 347.352 166.956C348.298 167.382 349.15 167.99 349.86 168.745C350.571 169.501 351.125 170.389 351.491 171.359C351.858 172.329 352.029 173.361 351.996 174.398V174.398Z" fill="#DADADA"></path><path d="M88.5823 239.898H79.5998C77.5165 239.827 75.5467 238.931 74.1226 237.409C72.6986 235.887 71.9367 233.861 72.0041 231.778V174.385C71.9367 172.302 72.6986 170.277 74.1226 168.754C75.5467 167.232 77.5165 166.337 79.5998 166.265H86.0712C80.718 190.631 81.5772 215.951 88.5698 239.898H88.5823Z" fill="#DADADA"></path><path d="M324.811 198.159C324.811 211.539 324.461 222.583 320.189 232.877C313.258 249.263 302.263 263.612 288.244 274.566C284.905 277.199 281.427 279.651 277.825 281.912L277.313 282.224L277.163 282.324C275.189 283.574 273.19 284.823 270.917 286.459C269.068 287.709 267.256 289.058 265.495 290.32C261.947 292.818 258.599 295.317 254.963 297.578C245.831 303.3 233.55 309.147 211.738 309.147C183.991 309.147 171.373 300.027 158.018 290.407C154.27 287.721 150.522 284.948 146.275 282.362C142.429 279.994 138.725 277.403 135.181 274.604C121.16 263.635 110.166 249.274 103.236 232.877C100.013 225.094 99.3137 216.786 99.2512 206.717V205.218C99.2512 203.656 99.2512 202.07 99.2512 200.483C99.2512 199.983 99.2512 199.471 99.2512 198.984V198.809C99.2512 198.134 99.2512 197.447 99.2512 196.773C99.2512 195.111 99.2512 193.475 99.2512 191.775C99.2512 190.689 99.2512 189.614 99.2512 188.577C99.3137 181.082 99.7884 173.036 102.774 165.54C110.467 146.665 123.49 130.438 140.253 118.842C161.199 104.214 186.178 96.4684 211.725 96.6792C235.15 96.6106 258.123 103.125 278.025 115.481C297.077 127.424 312.143 144.465 320.501 164.866C324.474 174.935 324.799 185.429 324.811 198.159Z" fill="url(#paint0_linear_108_70)"></path><g style="mix-blend-mode:color-dodge"><g style="mix-blend-mode:color-dodge"><path d="M290.631 150.924C301.025 158.882 307.821 182.156 308.121 197.26C308.296 207.329 299.75 207.179 299.525 197.922C299.238 186.066 294.678 168.589 285.408 159.993C269.143 144.952 282.323 144.54 290.631 150.924Z" fill="#e2e8f0"></path></g><g style="mix-blend-mode:color-dodge"><path d="M301.624 213.251C304.51 213.701 306.222 218.073 305.447 222.995C304.673 227.918 301.699 231.565 298.813 231.103C295.927 230.641 294.216 226.281 294.99 221.359C295.765 216.437 298.738 212.789 301.624 213.251Z" fill="#e2e8f0"></path></g></g><path d="M324.749 163.167C316.341 142.453 301.324 124.801 281.311 112.008C260.496 98.8265 236.338 91.8895 211.7 92.0194C185.184 91.8211 159.263 99.8765 137.53 115.069C120.008 127.19 106.395 144.15 98.3517 163.879C95.0786 172.087 94.6038 180.607 94.5039 188.627C94.5039 189.702 94.5039 190.789 94.5039 191.875C94.5039 193.5 94.5039 195.124 94.5039 196.773V200.521C94.5039 202.107 94.5039 203.669 94.5039 205.23V206.767C94.5039 217.399 95.3284 226.219 98.8514 234.714C106.094 251.829 117.578 266.818 132.22 278.264C135.901 281.183 139.752 283.882 143.751 286.347C147.799 288.846 151.572 291.569 155.22 294.205C168.587 303.862 182.417 313.869 211.675 313.869C234.675 313.869 247.68 307.623 257.374 301.576C261.122 299.215 264.72 296.579 268.168 294.155C269.905 292.906 271.716 291.657 273.528 290.345C275.764 288.783 277.688 287.521 279.587 286.347L279.749 286.247L280.311 285.885C284.057 283.548 287.669 281.004 291.13 278.264C305.769 266.817 317.249 251.829 324.486 234.714C329.084 223.62 329.484 212.102 329.484 198.197C329.484 184.929 329.134 173.911 324.749 163.167ZM320.189 232.877C313.258 249.263 302.263 263.612 288.244 274.566C284.905 277.199 281.427 279.651 277.825 281.912L277.313 282.224L277.163 282.324C275.189 283.574 273.19 284.823 270.917 286.459C269.068 287.709 267.256 289.058 265.495 290.32C261.947 292.818 258.599 295.317 254.963 297.578C245.831 303.3 233.55 309.147 211.738 309.147C183.991 309.147 171.373 300.027 158.018 290.407C154.27 287.721 150.522 284.948 146.275 282.362C142.429 279.994 138.725 277.403 135.181 274.604C121.16 263.635 110.166 249.274 103.236 232.877C100.013 225.094 99.3137 216.786 99.2512 206.717V205.218C99.2512 203.656 99.2512 202.07 99.2512 200.483C99.2512 199.983 99.2512 199.471 99.2512 198.984V198.809C99.2512 198.134 99.2512 197.447 99.2512 196.773C99.2512 195.111 99.2512 193.475 99.2512 191.776C99.2512 190.689 99.2512 189.614 99.2512 188.577C99.3137 181.082 99.7884 173.036 102.774 165.54C110.467 146.665 123.49 130.438 140.253 118.842C161.199 104.214 186.178 96.4684 211.725 96.6792C235.15 96.6106 258.123 103.125 278.025 115.481C297.077 127.424 312.143 144.465 320.501 164.866C324.561 174.86 324.886 185.367 324.899 198.097C324.799 211.539 324.461 222.583 320.189 232.877Z" fill="#e2e8f0"></path><defs><linearGradient id="paint0_linear_108_70" x1="288.457" y1="275.403" x2="134.906" y2="121.865" gradientUnits="userSpaceOnUse"><stop stop-color="#2739BD"></stop><stop offset="0.32" stop-color="#0C8DEB"></stop><stop offset="0.47" stop-color="#00B2FF"></stop><stop offset="0.67" stop-color="#00D0E7"></stop><stop offset="1" stop-color="#00FFC2"></stop></linearGradient></defs></svg>
@@ -0,0 +1,550 @@
1
+ #!/usr/bin/env node
2
+ // dashboard/serve.mjs
3
+ // Server for the Startup Kit Dashboard
4
+ // - Serves dashboard HTML
5
+ // - Generates dashboard-data.json on each request
6
+ // - SSE endpoint for live push updates
7
+
8
+ import { execFile } from "node:child_process";
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from "node:fs";
10
+ import { createServer } from "node:http";
11
+ import { dirname, extname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import {
14
+ DEFAULT_HOST,
15
+ REPO_ROOT_ENV,
16
+ allowlistConfig,
17
+ applyCorsHeaders,
18
+ authorizeMissionControlRequest,
19
+ broadcastAuthCookieHeader,
20
+ isAllowedOrigin,
21
+ isLoopbackAddress,
22
+ listLanIPv4Addresses,
23
+ mergeConfigAllowlist,
24
+ resolveBroadcastAuth,
25
+ resolveContextConfigPath,
26
+ resolveDashboardStatic,
27
+ resolveSnapshotRepoRoot,
28
+ validateConfigWriteBody,
29
+ } from "./lib/guards.mjs";
30
+ import {
31
+ PERIODIC_REFRESH_MS,
32
+ WATCH_DEBOUNCE_MS,
33
+ createTrailingDebounce,
34
+ resolveAgentTranscriptsWatchPath,
35
+ resolveWatchPaths,
36
+ } from "./lib/live-refresh.mjs";
37
+ import { buildInventoryBaseline } from "./lib/semantic-model.mjs";
38
+
39
+ const __dirname = dirname(fileURLToPath(import.meta.url));
40
+ /** Kit tree that owns `dashboard/` static assets and serve.mjs. */
41
+ const KIT_ROOT = join(__dirname, "..");
42
+ /** Workspace Mission Control snapshots (plans, HANDOFF, git). Defaults to KIT_ROOT. */
43
+ const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT);
44
+ const DASHBOARD_DIR = __dirname;
45
+ const authResolved = resolveBroadcastAuth(process.env);
46
+ if (!authResolved.ok) {
47
+ console.error(`[ERROR] ${authResolved.error}`);
48
+ process.exit(1);
49
+ }
50
+ const HOST = authResolved.host;
51
+ const PORT = Number.parseInt(process.env.PORT || "3333", 10);
52
+ const TOKEN_REQUIRED = authResolved.tokenRequired;
53
+ const AUTH_TOKEN = authResolved.token;
54
+ const DASHBOARD_REAL = realpathSync(DASHBOARD_DIR);
55
+
56
+ const MIME = {
57
+ ".html": "text/html; charset=utf-8",
58
+ ".json": "application/json; charset=utf-8",
59
+ ".js": "application/javascript; charset=utf-8",
60
+ ".mjs": "application/javascript; charset=utf-8",
61
+ ".css": "text/css",
62
+ ".png": "image/png",
63
+ ".svg": "image/svg+xml",
64
+ ".ico": "image/x-icon",
65
+ };
66
+
67
+ const dataScript = join(__dirname, "dashboard-data.mjs");
68
+
69
+ /** Compact inventory baseline fed back into the next data-script run via env. */
70
+ let previousInventoryJson = null;
71
+
72
+ /**
73
+ * @param {string} payload
74
+ * @returns {string|null}
75
+ */
76
+ function inventoryBaselineJsonFromPayload(payload) {
77
+ try {
78
+ const data = JSON.parse(payload);
79
+ if (!data || data.error) return null;
80
+ return JSON.stringify(
81
+ buildInventoryBaseline({
82
+ agents: data.agents,
83
+ skills: data.skills,
84
+ commands: data.commands,
85
+ memory: data.memory,
86
+ }),
87
+ );
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function setCorsHeaders(req, res) {
94
+ applyCorsHeaders(req, res, PORT);
95
+ }
96
+
97
+ /**
98
+ * @param {import("node:http").IncomingMessage} req
99
+ * @returns {Promise<string>}
100
+ */
101
+ function readRequestBody(req) {
102
+ return new Promise((resolve, reject) => {
103
+ const chunks = [];
104
+ let size = 0;
105
+ const MAX = 64 * 1024;
106
+ req.on("data", (chunk) => {
107
+ size += chunk.length;
108
+ if (size > MAX) {
109
+ reject(new Error("body too large"));
110
+ req.destroy();
111
+ return;
112
+ }
113
+ chunks.push(chunk);
114
+ });
115
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
116
+ req.on("error", reject);
117
+ });
118
+ }
119
+
120
+ /**
121
+ * @param {import("node:http").IncomingMessage} req
122
+ * @param {import("node:http").ServerResponse} res
123
+ * @param {number} status
124
+ * @param {object} payload
125
+ */
126
+ function sendJson(res, status, payload) {
127
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
128
+ res.end(JSON.stringify(payload));
129
+ }
130
+
131
+ /**
132
+ * Narrow allowlisted merge-write for `.cursor/context/config.json`.
133
+ * @param {import("node:http").IncomingMessage} req
134
+ * @param {import("node:http").ServerResponse} res
135
+ */
136
+ async function handleConfigWrite(req, res) {
137
+ const remote = req.socket?.remoteAddress;
138
+ if (!isLoopbackAddress(remote)) {
139
+ sendJson(res, 403, { error: "loopback only" });
140
+ return;
141
+ }
142
+ const origin = req.headers?.origin;
143
+ if (origin && !isAllowedOrigin(origin, PORT)) {
144
+ sendJson(res, 403, { error: "origin not allowed" });
145
+ return;
146
+ }
147
+
148
+ let rawBody;
149
+ try {
150
+ rawBody = await readRequestBody(req);
151
+ } catch (err) {
152
+ sendJson(res, 413, { error: err?.message || "body read failed" });
153
+ return;
154
+ }
155
+
156
+ let body;
157
+ try {
158
+ body = rawBody ? JSON.parse(rawBody) : null;
159
+ } catch {
160
+ sendJson(res, 400, { error: "invalid JSON" });
161
+ return;
162
+ }
163
+
164
+ const validated = validateConfigWriteBody(body);
165
+ if (!validated.ok) {
166
+ sendJson(res, 400, { error: validated.error });
167
+ return;
168
+ }
169
+
170
+ const locked = resolveContextConfigPath(ROOT, { existsSync, realpathSync, mkdirSync });
171
+ if (!locked.ok) {
172
+ sendJson(res, 500, { error: locked.error });
173
+ return;
174
+ }
175
+
176
+ let existing = {};
177
+ if (existsSync(locked.path)) {
178
+ try {
179
+ existing = JSON.parse(readFileSync(locked.path, "utf8"));
180
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
181
+ sendJson(res, 500, { error: "existing config is not an object" });
182
+ return;
183
+ }
184
+ } catch {
185
+ sendJson(res, 500, { error: "existing config parse error" });
186
+ return;
187
+ }
188
+ }
189
+
190
+ const merged = mergeConfigAllowlist(existing, validated.patch);
191
+ try {
192
+ writeFileSync(locked.path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
193
+ } catch (err) {
194
+ sendJson(res, 500, { error: err?.message || "write failed" });
195
+ return;
196
+ }
197
+
198
+ cachedPayload = null;
199
+ cachedAt = 0;
200
+ scheduleBroadcast();
201
+
202
+ sendJson(res, 200, { ok: true, config: allowlistConfig(merged) });
203
+ }
204
+
205
+ function resolveStaticPath(pathname) {
206
+ return resolveDashboardStatic(pathname, {
207
+ dashboardDir: DASHBOARD_DIR,
208
+ dashboardReal: DASHBOARD_REAL,
209
+ existsSync,
210
+ realpathSync,
211
+ });
212
+ }
213
+
214
+ // SSE clients
215
+ const sseClients = new Set();
216
+
217
+ // Short TTL cache + single-flight so concurrent HTTP/SSE requests do not
218
+ // stack child processes. Generation is async so heartbeats and accepts stay live.
219
+ const CACHE_TTL_MS = 2000;
220
+ let cachedPayload = null;
221
+ let cachedAt = 0;
222
+ /** @type {Promise<string> | null} */
223
+ let inFlight = null;
224
+
225
+ function errorPayload(message) {
226
+ // Every collection the panel reads must be present, or the render throws
227
+ // and a snapshot failure surfaces as "Render Failed" instead of an
228
+ // empty panel.
229
+ return JSON.stringify({
230
+ generatedAt: new Date().toISOString(),
231
+ version: "error",
232
+ error: message,
233
+ plans: [],
234
+ system: {},
235
+ agents: [],
236
+ commands: [],
237
+ skills: [],
238
+ memory: {},
239
+ git: {},
240
+ terminals: [],
241
+ processes: [],
242
+ health: { status: "error", checks: [] },
243
+ missionControl: null,
244
+ });
245
+ }
246
+
247
+ // Cold snapshot on a busy dogfood repo can exceed 15s; killing the child
248
+ // yields health:error and a Degraded badge with empty cockpit panels.
249
+ // Override with AGENT_KIT_DASHBOARD_DATA_TIMEOUT_MS (ms) when needed.
250
+ const DATA_SCRIPT_TIMEOUT_MS = (() => {
251
+ const raw = process.env.AGENT_KIT_DASHBOARD_DATA_TIMEOUT_MS;
252
+ const n = raw != null && raw !== "" ? Number(raw) : 60_000;
253
+ return Number.isFinite(n) && n > 0 ? n : 60_000;
254
+ })();
255
+
256
+ function runDataScript() {
257
+ return new Promise((resolve) => {
258
+ const env = {
259
+ ...process.env,
260
+ // Pin snapshot root for the child even if the parent cwd differs.
261
+ [REPO_ROOT_ENV]: ROOT,
262
+ // Empty string = cold start (dashboard-data treats falsy as no previous).
263
+ AGENT_KIT_PREV_INVENTORY: previousInventoryJson || "",
264
+ };
265
+ execFile(
266
+ process.execPath,
267
+ [dataScript],
268
+ {
269
+ cwd: ROOT,
270
+ encoding: "utf-8",
271
+ maxBuffer: 10 * 1024 * 1024,
272
+ timeout: DATA_SCRIPT_TIMEOUT_MS,
273
+ env,
274
+ },
275
+ (err, stdout) => {
276
+ if (err) {
277
+ const timedOut = err.killed || err.signal === "SIGTERM";
278
+ const message = timedOut
279
+ ? `dashboard-data.mjs timed out after ${DATA_SCRIPT_TIMEOUT_MS}ms (set AGENT_KIT_DASHBOARD_DATA_TIMEOUT_MS to raise)`
280
+ : err.message;
281
+ resolve(errorPayload(message));
282
+ return;
283
+ }
284
+ const baseline = inventoryBaselineJsonFromPayload(stdout);
285
+ if (baseline) previousInventoryJson = baseline;
286
+ resolve(stdout);
287
+ },
288
+ );
289
+ });
290
+ }
291
+
292
+ /**
293
+ * @param {{ force?: boolean }} [opts]
294
+ * @returns {Promise<string>}
295
+ */
296
+ async function generateData({ force = false } = {}) {
297
+ const now = Date.now();
298
+ if (!force && cachedPayload && now - cachedAt < CACHE_TTL_MS) {
299
+ return cachedPayload;
300
+ }
301
+ if (inFlight) {
302
+ const payload = await inFlight;
303
+ if (!force) return payload;
304
+ if (cachedPayload && Date.now() - cachedAt < CACHE_TTL_MS) {
305
+ return cachedPayload;
306
+ }
307
+ }
308
+ inFlight = runDataScript()
309
+ .then((payload) => {
310
+ cachedPayload = payload;
311
+ cachedAt = Date.now();
312
+ return payload;
313
+ })
314
+ .finally(() => {
315
+ inFlight = null;
316
+ });
317
+ return inFlight;
318
+ }
319
+
320
+ /**
321
+ * SSE data fields are line-oriented: only lines prefixed with `data:` become
322
+ * event.data. Pretty-printed JSON (newlines) would deliver a broken `{` fragment
323
+ * and the browser client would silently drop every push. Always emit one line.
324
+ * @param {string} data
325
+ */
326
+ function toSseDataLine(data) {
327
+ try {
328
+ return JSON.stringify(JSON.parse(data));
329
+ } catch {
330
+ return String(data).replace(/\r?\n/g, " ");
331
+ }
332
+ }
333
+
334
+ function writeSseData(res, data) {
335
+ res.write(`data: ${toSseDataLine(data)}\n\n`);
336
+ }
337
+
338
+ function broadcast(data) {
339
+ const msg = `data: ${toSseDataLine(data)}\n\n`;
340
+ for (const client of sseClients) {
341
+ try {
342
+ client.write(msg);
343
+ } catch {
344
+ sseClients.delete(client);
345
+ }
346
+ }
347
+ }
348
+
349
+ let lastBroadcastAt = 0;
350
+
351
+ /** Fresh snapshot + push. Skips work when nobody is listening. */
352
+ async function flushBroadcast() {
353
+ if (sseClients.size === 0) return;
354
+ const data = await generateData({ force: true });
355
+ lastBroadcastAt = Date.now();
356
+ broadcast(data);
357
+ }
358
+
359
+ // Watch bursts coalesce; maxWait guarantees a flush during continuous writes.
360
+ const scheduleBroadcast = createTrailingDebounce(
361
+ () => {
362
+ void flushBroadcast();
363
+ },
364
+ WATCH_DEBOUNCE_MS,
365
+ { maxWait: PERIODIC_REFRESH_MS },
366
+ );
367
+
368
+ // Watch every in-repo snapshot source (full `.cursor` tree + package + data script).
369
+ const watchPaths = resolveWatchPaths(ROOT, __dirname);
370
+ // External: agent transcripts (Field Report prompts). Outside SNAPSHOT_REPO_SOURCE_RELS;
371
+ // without this watch, prompt rows wait on PERIODIC_REFRESH_MS alone.
372
+ const agentTranscriptsWatch = resolveAgentTranscriptsWatchPath(ROOT);
373
+ const allWatchPaths = [...watchPaths, agentTranscriptsWatch];
374
+
375
+ for (const p of allWatchPaths) {
376
+ if (!existsSync(p)) {
377
+ console.warn(`[watch] skip missing path: ${p}`);
378
+ continue;
379
+ }
380
+ try {
381
+ watch(p, { recursive: true }, () => {
382
+ scheduleBroadcast();
383
+ });
384
+ } catch (err) {
385
+ console.warn(`[watch] failed for ${p}: ${err?.message ? err.message : err}`);
386
+ }
387
+ }
388
+
389
+ // Git / terminals / processes do not touch watched files; keep SSE clients fresh.
390
+ // Call flushBroadcast directly (not via trailing debounce) so watch-event storms
391
+ // cannot starve the periodic cadence.
392
+ setInterval(() => {
393
+ if (sseClients.size === 0) return;
394
+ if (Date.now() - lastBroadcastAt < PERIODIC_REFRESH_MS) return;
395
+ void flushBroadcast();
396
+ }, PERIODIC_REFRESH_MS);
397
+
398
+ const server = createServer((req, res) => {
399
+ const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
400
+ const path = url.pathname;
401
+
402
+ setCorsHeaders(req, res);
403
+
404
+ if (path === "/api/config" && req.method === "OPTIONS") {
405
+ const origin = req.headers?.origin;
406
+ if (origin && isAllowedOrigin(origin, PORT)) {
407
+ res.writeHead(204, {
408
+ "Access-Control-Allow-Origin": origin,
409
+ "Access-Control-Allow-Methods": "PUT, PATCH, OPTIONS",
410
+ "Access-Control-Allow-Headers": "Content-Type",
411
+ Vary: "Origin",
412
+ });
413
+ } else {
414
+ res.writeHead(204);
415
+ }
416
+ res.end();
417
+ return;
418
+ }
419
+
420
+ if (path === "/api/config" && (req.method === "PUT" || req.method === "PATCH")) {
421
+ // Config mutations stay loopback-only (token does not unlock writes from LAN).
422
+ void handleConfigWrite(req, res);
423
+ return;
424
+ }
425
+
426
+ const auth = authorizeMissionControlRequest(req, url, {
427
+ tokenRequired: TOKEN_REQUIRED,
428
+ expectedToken: AUTH_TOKEN,
429
+ });
430
+ if (!auth.ok) {
431
+ sendJson(res, auth.status || 401, { error: auth.error || "unauthorized" });
432
+ return;
433
+ }
434
+ if (auth.viaQuery && AUTH_TOKEN) {
435
+ res.setHeader("Set-Cookie", broadcastAuthCookieHeader(AUTH_TOKEN));
436
+ }
437
+
438
+ // SSE endpoint
439
+ if (path === "/api/events") {
440
+ res.writeHead(200, {
441
+ "Content-Type": "text/event-stream",
442
+ "Cache-Control": "no-cache",
443
+ Connection: "keep-alive",
444
+ });
445
+
446
+ sseClients.add(res);
447
+ const clientAddr = req.socket.remoteAddress || "unknown";
448
+ console.log(`[SSE] Client connected: ${clientAddr} (${sseClients.size} clients)`);
449
+
450
+ // Prefer warm cache so connect does not block the event loop; refresh after.
451
+ if (cachedPayload) {
452
+ try {
453
+ writeSseData(res, cachedPayload);
454
+ } catch {
455
+ // client gone
456
+ }
457
+ if (Date.now() - cachedAt >= CACHE_TTL_MS) {
458
+ void flushBroadcast();
459
+ }
460
+ } else {
461
+ void generateData().then((data) => {
462
+ try {
463
+ writeSseData(res, data);
464
+ } catch {
465
+ // client gone
466
+ }
467
+ });
468
+ }
469
+
470
+ req.on("close", () => {
471
+ sseClients.delete(res);
472
+ console.log(
473
+ `[SSE] Client disconnected: ${clientAddr} (${sseClients.size} clients remaining)`,
474
+ );
475
+ });
476
+
477
+ // Heartbeat every 30s to keep connection alive
478
+ const heartbeat = setInterval(() => {
479
+ try {
480
+ res.write(":heartbeat\n\n");
481
+ } catch {
482
+ clearInterval(heartbeat);
483
+ }
484
+ }, 30000);
485
+
486
+ req.on("close", () => clearInterval(heartbeat));
487
+ return;
488
+ }
489
+
490
+ // Data endpoint
491
+ if (path === "/dashboard-data.json" || path === "/api/data") {
492
+ void generateData().then((data) => {
493
+ res.writeHead(200, { "Content-Type": "application/json" });
494
+ res.end(data);
495
+ });
496
+ return;
497
+ }
498
+
499
+ const staticPath = resolveStaticPath(path);
500
+ if (!staticPath) {
501
+ res.writeHead(404);
502
+ res.end("Not found");
503
+ return;
504
+ }
505
+
506
+ const ext = extname(staticPath);
507
+ const contentType = MIME[ext] || "application/octet-stream";
508
+ const content = readFileSync(staticPath);
509
+ res.writeHead(200, { "Content-Type": contentType });
510
+ res.end(content);
511
+ });
512
+
513
+ server.listen(PORT, HOST, () => {
514
+ const loopback = HOST === DEFAULT_HOST || HOST === "localhost" || HOST === "::1";
515
+ if (!loopback) {
516
+ console.warn(
517
+ `[WARN] Mission Control broadcast mode on ${HOST}:${PORT}. Token gate is required; config writes stay loopback-only.`,
518
+ );
519
+ }
520
+
521
+ console.log("\n Mission Control\n");
522
+ if (loopback) {
523
+ const url = `http://127.0.0.1:${PORT}`;
524
+ console.log(` Local: ${url}`);
525
+ console.log(` Data: ${url}/dashboard-data.json`);
526
+ console.log(` Events: ${url}/api/events (SSE)`);
527
+ console.log(` Config: ${url}/api/config (PUT/PATCH, loopback)`);
528
+ } else {
529
+ const lan = listLanIPv4Addresses();
530
+ const tokenQ = AUTH_TOKEN ? `?token=${encodeURIComponent(AUTH_TOKEN)}` : "";
531
+ console.log(` Bind: ${HOST}:${PORT} (token-gated)`);
532
+ console.log(` Local: http://127.0.0.1:${PORT}/${tokenQ}`);
533
+ if (lan.length === 0) {
534
+ console.log(" LAN: (no non-internal IPv4 found; check Wi-Fi / Ethernet)");
535
+ } else {
536
+ for (const ip of lan) {
537
+ console.log(` LAN: http://${ip}:${PORT}/${tokenQ}`);
538
+ }
539
+ }
540
+ console.log(" Token: printed once above in each LAN URL (MISSION_CONTROL_TOKEN)");
541
+ console.log(" Config: PUT/PATCH /api/config remains loopback-only");
542
+ console.log(" Firewall: allow inbound TCP on this port for your LAN profile if needed");
543
+ }
544
+ console.log(` Snapshot: ${ROOT}`);
545
+ if (ROOT !== KIT_ROOT) {
546
+ console.log(` Kit: ${KIT_ROOT}`);
547
+ }
548
+ console.log("\n Open in Cursor Simple Browser or your browser.\n");
549
+ console.log(" Press Ctrl+C to stop.\n");
550
+ });