@dadado/agent-kit-cli 4.8.0 → 4.8.3

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,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Terminal counterpart to `/dashboard-broadcast`.
4
+ *
5
+ * Opt-in LAN bind: HOST=0.0.0.0 (or explicit non-loopback), requires
6
+ * MISSION_CONTROL_TOKEN (generated when unset), detach-starts serve.mjs,
7
+ * prints LAN URL(s) with token. Does not weaken loopback `/dashboard`.
8
+ */
9
+
10
+ import { execFileSync, execSync, spawn } from "node:child_process";
11
+ import { existsSync, openSync } from "node:fs";
12
+ import { platform } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import {
16
+ BROADCAST_TOKEN_ENV,
17
+ escapePerlDoubleQuoted,
18
+ generateBroadcastToken,
19
+ isLoopbackBindHost,
20
+ isValidBroadcastToken,
21
+ listLanIPv4Addresses,
22
+ normalizeAuthToken,
23
+ resolveBindHost,
24
+ } from "./lib/guards.mjs";
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const ROOT = join(__dirname, "..");
28
+ const SERVE = join(__dirname, "serve.mjs");
29
+ const LOG = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control-broadcast.log";
30
+ const PORT = Number.parseInt(process.env.PORT || "3333", 10);
31
+ const READY_TIMEOUT_MS = 20_000;
32
+ const READY_POLL_MS = 250;
33
+
34
+ function resolveBroadcastEnv() {
35
+ const env = { ...process.env };
36
+ let host = resolveBindHost(env.HOST);
37
+ if (isLoopbackBindHost(host)) {
38
+ host = "0.0.0.0";
39
+ }
40
+ env.HOST = host;
41
+
42
+ let token = normalizeAuthToken(env[BROADCAST_TOKEN_ENV]);
43
+ if (!isValidBroadcastToken(token)) {
44
+ token = generateBroadcastToken();
45
+ env[BROADCAST_TOKEN_ENV] = token;
46
+ }
47
+ return { env, host, token };
48
+ }
49
+
50
+ function urlsForProbe(token) {
51
+ const q = `?token=${encodeURIComponent(token)}`;
52
+ const urls = [`http://127.0.0.1:${PORT}/${q}`];
53
+ for (const ip of listLanIPv4Addresses()) {
54
+ urls.push(`http://${ip}:${PORT}/${q}`);
55
+ }
56
+ return urls;
57
+ }
58
+
59
+ function probeHttp(url) {
60
+ try {
61
+ const code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], {
62
+ encoding: "utf8",
63
+ timeout: 3000,
64
+ }).trim();
65
+ return code === "200";
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function listeningPids() {
72
+ try {
73
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"], {
74
+ encoding: "utf8",
75
+ timeout: 3000,
76
+ }).trim();
77
+ return out ? out.split(/\n+/).filter(Boolean) : [];
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function hasSetsid() {
84
+ try {
85
+ execSync("command -v setsid >/dev/null 2>&1", { shell: true });
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function detachStart(env) {
93
+ if (!existsSync(SERVE)) {
94
+ throw new Error(`Missing server entry: ${SERVE}`);
95
+ }
96
+
97
+ if (hasSetsid()) {
98
+ const out = openSync(LOG, "a");
99
+ const child = spawn("setsid", ["node", SERVE], {
100
+ cwd: ROOT,
101
+ detached: true,
102
+ stdio: ["ignore", out, out],
103
+ env,
104
+ });
105
+ child.unref();
106
+ return;
107
+ }
108
+
109
+ // Escape @/$ so scoped package paths (node_modules/@scope/...) survive Perl qq.
110
+ const rootEsc = escapePerlDoubleQuoted(ROOT);
111
+ const serveEsc = escapePerlDoubleQuoted(SERVE);
112
+ const logEsc = escapePerlDoubleQuoted(LOG);
113
+ const hostEsc = escapePerlDoubleQuoted(String(env.HOST));
114
+ const tokenEsc = escapePerlDoubleQuoted(String(env[BROADCAST_TOKEN_ENV]));
115
+ const portEsc = escapePerlDoubleQuoted(String(PORT));
116
+ const perl = [
117
+ "use POSIX qw(setsid);",
118
+ "exit if fork;",
119
+ "setsid();",
120
+ "exit if fork;",
121
+ 'open(STDIN,"<","/dev/null");',
122
+ `open(STDOUT,">","${logEsc}");`,
123
+ 'open(STDERR,">&STDOUT");',
124
+ `chdir("${rootEsc}");`,
125
+ `$ENV{HOST}="${hostEsc}";`,
126
+ `$ENV{${BROADCAST_TOKEN_ENV}}="${tokenEsc}";`,
127
+ `$ENV{PORT}="${portEsc}";`,
128
+ `exec("node","${serveEsc}");`,
129
+ ].join(" ");
130
+
131
+ const child = spawn("perl", ["-e", perl], {
132
+ cwd: ROOT,
133
+ detached: true,
134
+ stdio: "ignore",
135
+ env,
136
+ });
137
+ child.unref();
138
+ }
139
+
140
+ async function waitReady(urls) {
141
+ const deadline = Date.now() + READY_TIMEOUT_MS;
142
+ while (Date.now() < deadline) {
143
+ for (const url of urls) {
144
+ if (probeHttp(url)) return url;
145
+ }
146
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function openBrowser(url) {
152
+ const os = platform();
153
+ try {
154
+ if (os === "darwin") {
155
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
156
+ return true;
157
+ }
158
+ if (os === "win32") {
159
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
160
+ return true;
161
+ }
162
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ async function main() {
170
+ const { env, host, token } = resolveBroadcastEnv();
171
+ if (isLoopbackBindHost(host)) {
172
+ console.error(
173
+ "Broadcast refused: bind host resolved to loopback. Set HOST to a non-loopback address.",
174
+ );
175
+ process.exit(1);
176
+ }
177
+
178
+ const urls = urlsForProbe(token);
179
+ const primaryLan = listLanIPv4Addresses()[0];
180
+ const displayUrl =
181
+ primaryLan != null
182
+ ? `http://${primaryLan}:${PORT}/?token=${encodeURIComponent(token)}`
183
+ : urls[0];
184
+
185
+ const already = listeningPids().length > 0 && urls.some((u) => probeHttp(u));
186
+ if (!already) {
187
+ if (listeningPids().length > 0) {
188
+ console.error(
189
+ `Port ${PORT} is listening but did not accept the broadcast token. Stop the existing Mission Control instance (loopback /dashboard) first, then retry.`,
190
+ );
191
+ console.error(` kill "$(lsof -nP -iTCP:${PORT} -sTCP:LISTEN -t)"`);
192
+ process.exit(1);
193
+ }
194
+ console.log(`Starting Mission Control broadcast on ${host}:${PORT}…`);
195
+ detachStart(env);
196
+ const ready = await waitReady(urls);
197
+ if (!ready) {
198
+ console.error(`Mission Control broadcast did not answer within ${READY_TIMEOUT_MS}ms.`);
199
+ console.error(`Check the log: ${LOG}`);
200
+ process.exit(1);
201
+ }
202
+ } else {
203
+ console.log(`Mission Control broadcast already listening on port ${PORT}`);
204
+ }
205
+
206
+ console.log("");
207
+ console.log(" Mission Control (LAN broadcast)");
208
+ console.log(` Bind: ${host}:${PORT}`);
209
+ console.log(` Token: ${token}`);
210
+ for (const ip of listLanIPv4Addresses()) {
211
+ console.log(` LAN: http://${ip}:${PORT}/?token=${encodeURIComponent(token)}`);
212
+ }
213
+ console.log(` Local: http://127.0.0.1:${PORT}/?token=${encodeURIComponent(token)}`);
214
+ console.log(" Config writes stay loopback-only. Stop: kill the LISTEN pid on this port.");
215
+ console.log(" Firewall: allow inbound TCP on this port for your LAN profile if needed.");
216
+ console.log("");
217
+
218
+ if (process.env.MISSION_CONTROL_NO_OPEN === "1") {
219
+ return;
220
+ }
221
+ if (openBrowser(displayUrl)) {
222
+ console.log("Opened primary URL in the default browser.");
223
+ } else {
224
+ console.log("Open a LAN URL above on your phone/tablet browser.");
225
+ }
226
+ }
227
+
228
+ main().catch((err) => {
229
+ console.error(err instanceof Error ? err.message : err);
230
+ process.exit(1);
231
+ });
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Terminal counterpart to the `/dashboard` slash command.
4
+ *
5
+ * Allocates a stable per-workspace listen port (hash of snapshot root in the
6
+ * 3333–3588 range unless PORT is set), detach-starts `serve.mjs` when needed,
7
+ * waits until HTTP 200, prints the URL, and opens the default browser.
8
+ *
9
+ * Never kills a listener whose system.repoRoot belongs to another workspace.
10
+ *
11
+ * Snapshot root defaults to this kit tree. Set MISSION_CONTROL_REPO_ROOT to
12
+ * point Mission Control at a consumer workspace while still serving static
13
+ * assets from this checkout.
14
+ *
15
+ * Foreground serve for debugging remains: `npm run start:dashboard`.
16
+ */
17
+
18
+ import { execFileSync, execSync, spawn } from "node:child_process";
19
+ import { existsSync, openSync } from "node:fs";
20
+ import { platform } from "node:os";
21
+ import { dirname, join, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import {
24
+ REPO_ROOT_ENV,
25
+ escapePerlDoubleQuoted,
26
+ repoRootLogId,
27
+ resolveMissionControlPort,
28
+ resolveSnapshotRepoRoot,
29
+ sameRepoRoot,
30
+ } from "./lib/guards.mjs";
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const KIT_ROOT = join(__dirname, "..");
34
+ const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT);
35
+ const SERVE = join(__dirname, "serve.mjs");
36
+ const HOST = process.env.HOST || "127.0.0.1";
37
+ const DISPLAY_HOST = HOST === "0.0.0.0" ? "127.0.0.1" : HOST;
38
+ const READY_TIMEOUT_MS = 20_000;
39
+ const READY_POLL_MS = 250;
40
+
41
+ /** @type {number} */
42
+ let PORT;
43
+ /** @type {string} */
44
+ let URL;
45
+ /** @type {string} */
46
+ let DATA_URL;
47
+ /** @type {string} */
48
+ let LOG;
49
+
50
+ function setPort(port) {
51
+ PORT = port;
52
+ URL = `http://${DISPLAY_HOST}:${PORT}/`;
53
+ DATA_URL = `http://${DISPLAY_HOST}:${PORT}/dashboard-data.json`;
54
+ LOG = process.env.MISSION_CONTROL_LOG || `/tmp/mission-control-${repoRootLogId(ROOT)}.log`;
55
+ }
56
+
57
+ function probeHttp(url = URL) {
58
+ try {
59
+ const code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], {
60
+ encoding: "utf8",
61
+ timeout: 3000,
62
+ }).trim();
63
+ return code === "200";
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function listeningPids(port = PORT) {
70
+ try {
71
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
72
+ encoding: "utf8",
73
+ timeout: 3000,
74
+ }).trim();
75
+ return out ? out.split(/\n+/).filter(Boolean) : [];
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ /** @returns {string | null} */
82
+ function runningSnapshotRoot(port = PORT) {
83
+ const dataUrl = `http://${DISPLAY_HOST}:${port}/dashboard-data.json`;
84
+ try {
85
+ const raw = execFileSync("curl", ["-sf", dataUrl], {
86
+ encoding: "utf8",
87
+ timeout: 8000,
88
+ maxBuffer: 10 * 1024 * 1024,
89
+ });
90
+ const data = JSON.parse(raw);
91
+ const root = data?.system?.repoRoot;
92
+ return typeof root === "string" && root.trim() ? resolve(root.trim()) : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * @param {number} port
100
+ * @returns {{ listening: boolean, repoRoot: string | null }}
101
+ */
102
+ function probePort(port) {
103
+ const pids = listeningPids(port);
104
+ if (pids.length === 0 && !probeHttp(`http://${DISPLAY_HOST}:${port}/`)) {
105
+ return { listening: false, repoRoot: null };
106
+ }
107
+ return { listening: true, repoRoot: runningSnapshotRoot(port) };
108
+ }
109
+
110
+ function killListeners(pids) {
111
+ for (const pid of pids) {
112
+ try {
113
+ process.kill(Number(pid), "SIGTERM");
114
+ } catch {
115
+ // already gone
116
+ }
117
+ }
118
+ }
119
+
120
+ function hasSetsid() {
121
+ try {
122
+ execSync("command -v setsid >/dev/null 2>&1", { shell: true });
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function detachStart() {
130
+ if (!existsSync(SERVE)) {
131
+ throw new Error(`Missing server entry: ${SERVE}`);
132
+ }
133
+
134
+ const env = {
135
+ ...process.env,
136
+ [REPO_ROOT_ENV]: ROOT,
137
+ PORT: String(PORT),
138
+ };
139
+
140
+ if (hasSetsid()) {
141
+ const out = openSync(LOG, "a");
142
+ const child = spawn("setsid", ["node", SERVE], {
143
+ cwd: KIT_ROOT,
144
+ detached: true,
145
+ stdio: ["ignore", out, out],
146
+ env,
147
+ });
148
+ child.unref();
149
+ return;
150
+ }
151
+
152
+ // macOS and other hosts without setsid: Perl double-fork + setsid().
153
+ // Escape @/$ so scoped package paths (node_modules/@scope/...) survive Perl qq.
154
+ const rootEsc = escapePerlDoubleQuoted(KIT_ROOT);
155
+ const serveEsc = escapePerlDoubleQuoted(SERVE);
156
+ const logEsc = escapePerlDoubleQuoted(LOG);
157
+ const portEsc = escapePerlDoubleQuoted(String(PORT));
158
+ const snapEsc = escapePerlDoubleQuoted(ROOT);
159
+ const perl = [
160
+ "use POSIX qw(setsid);",
161
+ "exit if fork;",
162
+ "setsid();",
163
+ "exit if fork;",
164
+ 'open(STDIN,"<","/dev/null");',
165
+ `open(STDOUT,">","${logEsc}");`,
166
+ 'open(STDERR,">&STDOUT");',
167
+ `chdir("${rootEsc}");`,
168
+ `$ENV{PORT}="${portEsc}";`,
169
+ `$ENV{${REPO_ROOT_ENV}}="${snapEsc}";`,
170
+ `exec("node","${serveEsc}");`,
171
+ ].join(" ");
172
+
173
+ const child = spawn("perl", ["-e", perl], {
174
+ cwd: KIT_ROOT,
175
+ detached: true,
176
+ stdio: "ignore",
177
+ env,
178
+ });
179
+ child.unref();
180
+ }
181
+
182
+ async function waitReady() {
183
+ const deadline = Date.now() + READY_TIMEOUT_MS;
184
+ while (Date.now() < deadline) {
185
+ if (probeHttp()) return true;
186
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
187
+ }
188
+ return false;
189
+ }
190
+
191
+ function openBrowser(url) {
192
+ const os = platform();
193
+ try {
194
+ if (os === "darwin") {
195
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
196
+ return true;
197
+ }
198
+ if (os === "win32") {
199
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
200
+ return true;
201
+ }
202
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ async function ensureServer() {
210
+ const allocation = resolveMissionControlPort({
211
+ repoRoot: ROOT,
212
+ envPort: process.env.PORT,
213
+ probe: probePort,
214
+ });
215
+ setPort(allocation.port);
216
+ // Pin PORT for this process and children.
217
+ process.env.PORT = String(PORT);
218
+
219
+ if (allocation.reuse) {
220
+ console.log(`Mission Control already listening at ${URL}`);
221
+ if (ROOT !== KIT_ROOT) {
222
+ console.log(`Snapshot root: ${ROOT}`);
223
+ }
224
+ return;
225
+ }
226
+
227
+ // Own port free (or we are about to bind). If something is listening without
228
+ // a matching repoRoot, resolveMissionControlPort already skipped it — except
229
+ // explicit PORT, which throws. Optional: restart our own stale instance when
230
+ // listening but probe returned matching root with reuse=false (should not happen).
231
+ const pids = listeningPids();
232
+ if (pids.length > 0) {
233
+ const current = runningSnapshotRoot();
234
+ if (sameRepoRoot(current, ROOT)) {
235
+ // Healthy reuse should have been reuse:true; treat as restart of ours only.
236
+ console.log(`Restarting Mission Control for ${ROOT} on port ${PORT}…`);
237
+ killListeners(pids);
238
+ await new Promise((r) => setTimeout(r, 400));
239
+ } else if (current == null) {
240
+ // Explicit PORT path cannot reach here (throws). Hashed path skips unknowns.
241
+ // Defensive: do not kill.
242
+ throw new Error(
243
+ `Port ${PORT} is busy (${pids.join(",")}) and is not this workspace. Refusing to kill.`,
244
+ );
245
+ } else {
246
+ throw new Error(
247
+ `Port ${PORT} is snapshotting ${current}; refusing to kill. Unset PORT or stop that instance.`,
248
+ );
249
+ }
250
+ }
251
+
252
+ console.log(`Starting Mission Control on ${URL}…`);
253
+ if (ROOT !== KIT_ROOT) {
254
+ console.log(`Snapshot root: ${ROOT}`);
255
+ }
256
+ detachStart();
257
+ const ready = await waitReady();
258
+ if (!ready) {
259
+ console.error(`Mission Control did not answer ${URL} within ${READY_TIMEOUT_MS}ms.`);
260
+ console.error(`Check the log: ${LOG}`);
261
+ process.exit(1);
262
+ }
263
+ }
264
+
265
+ async function main() {
266
+ process.env[REPO_ROOT_ENV] = ROOT;
267
+
268
+ await ensureServer();
269
+
270
+ console.log(URL);
271
+ if (process.env.MISSION_CONTROL_NO_OPEN === "1") {
272
+ return;
273
+ }
274
+ if (openBrowser(URL)) {
275
+ console.log(
276
+ "Opened in the default browser. In Cursor, Simple Browser or /dashboard also works.",
277
+ );
278
+ } else {
279
+ console.log("Open that URL in a browser (Cursor: Simple Browser, or run /dashboard in chat).");
280
+ }
281
+ }
282
+
283
+ main().catch((err) => {
284
+ console.error(err instanceof Error ? err.message : err);
285
+ process.exit(1);
286
+ });