@dadado/agent-kit-cli 5.2.0 → 5.3.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/dashboard/lib/guards.d.mts +33 -0
- package/dashboard/lib/guards.mjs +128 -0
- package/dashboard/logo-marketplace.svg +13 -0
- package/dashboard/start-broadcast.mjs +171 -25
- package/dist/index.js +19 -1
- package/package.json +1 -1
|
@@ -52,6 +52,39 @@ export function resolveMissionControlPort(args: {
|
|
|
52
52
|
probe: (port: number) => { listening: boolean; repoRoot: string | null };
|
|
53
53
|
opts?: PortOpts;
|
|
54
54
|
}): { port: number; reuse: boolean; explicit: boolean };
|
|
55
|
+
type BroadcastProbe = {
|
|
56
|
+
listening: boolean;
|
|
57
|
+
repoRoot?: string | null;
|
|
58
|
+
acceptsToken?: boolean;
|
|
59
|
+
tokenGated?: boolean;
|
|
60
|
+
lanReachable?: boolean | null;
|
|
61
|
+
};
|
|
62
|
+
type BroadcastListenerKind =
|
|
63
|
+
| "free"
|
|
64
|
+
| "self-broadcast"
|
|
65
|
+
| "self-other-mode"
|
|
66
|
+
| "foreign"
|
|
67
|
+
| "token-gated"
|
|
68
|
+
| "unknown";
|
|
69
|
+
export function classifyBroadcastListener(
|
|
70
|
+
info: BroadcastProbe,
|
|
71
|
+
repoRoot: string,
|
|
72
|
+
): BroadcastListenerKind;
|
|
73
|
+
export function describeBroadcastListener(
|
|
74
|
+
kind: string,
|
|
75
|
+
info?: { port?: number | null; repoRoot?: string | null },
|
|
76
|
+
): string;
|
|
77
|
+
export function resolveBroadcastPort(args: {
|
|
78
|
+
repoRoot: string;
|
|
79
|
+
envPort?: string | number | null;
|
|
80
|
+
probe: (port: number) => BroadcastProbe;
|
|
81
|
+
opts?: PortOpts;
|
|
82
|
+
}): {
|
|
83
|
+
port: number;
|
|
84
|
+
reuse: boolean;
|
|
85
|
+
explicit: boolean;
|
|
86
|
+
skipped: Array<{ port: number; kind: string; repoRoot: string | null }>;
|
|
87
|
+
};
|
|
55
88
|
export function isSafeRepoRelativePath(relPath: unknown): boolean;
|
|
56
89
|
export function resolveBindHost(envHost?: string | null): string;
|
|
57
90
|
export function isLoopbackBindHost(host: string | undefined | null): boolean;
|
package/dashboard/lib/guards.mjs
CHANGED
|
@@ -194,6 +194,134 @@ export function resolveMissionControlPort({ repoRoot, envPort, probe, opts = {}
|
|
|
194
194
|
);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Classify what holds a candidate Mission Control port, from the point of view
|
|
199
|
+
* of a **broadcast** (LAN) start.
|
|
200
|
+
*
|
|
201
|
+
* Broadcast needs a non-loopback, token-gated listener of its own. A loopback
|
|
202
|
+
* `/dashboard` for this same workspace answers `?token=…` with 200 (the query is
|
|
203
|
+
* ignored when no token is required), so "same repoRoot + accepts our token" is
|
|
204
|
+
* not sufficient: reuse also requires the listener to answer on a LAN address.
|
|
205
|
+
* `lanReachable` may be `null`/omitted when the host has no LAN IPv4 to probe —
|
|
206
|
+
* then a root + token match is accepted.
|
|
207
|
+
*
|
|
208
|
+
* `token-gated` means: something Mission-Control-shaped is listening but refuses
|
|
209
|
+
* our token, so its owner cannot be confirmed. Like `foreign` and `unknown` it is
|
|
210
|
+
* never killed and never reused.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ listening: boolean, repoRoot?: string|null, acceptsToken?: boolean, tokenGated?: boolean, lanReachable?: boolean|null }} info
|
|
213
|
+
* @param {string} repoRoot
|
|
214
|
+
* @returns {"free"|"self-broadcast"|"self-other-mode"|"foreign"|"token-gated"|"unknown"}
|
|
215
|
+
*/
|
|
216
|
+
export function classifyBroadcastListener(info, repoRoot) {
|
|
217
|
+
if (!info || info.listening !== true) return "free";
|
|
218
|
+
const mine = sameRepoRoot(info.repoRoot, repoRoot);
|
|
219
|
+
if (mine && info.acceptsToken === true) {
|
|
220
|
+
return info.lanReachable === false ? "self-other-mode" : "self-broadcast";
|
|
221
|
+
}
|
|
222
|
+
if (mine) return "self-other-mode";
|
|
223
|
+
if (info.repoRoot != null) return "foreign";
|
|
224
|
+
return info.tokenGated === true ? "token-gated" : "unknown";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Operator-facing wording for each {@link classifyBroadcastListener} kind. */
|
|
228
|
+
const BROADCAST_LISTENER_LABELS = {
|
|
229
|
+
free: "free",
|
|
230
|
+
"self-broadcast": "this workspace's broadcast",
|
|
231
|
+
"self-other-mode":
|
|
232
|
+
"this workspace, but not a broadcast listener (loopback /dashboard, or another token)",
|
|
233
|
+
foreign: "another workspace",
|
|
234
|
+
"token-gated": "a token-gated Mission Control whose owner this token cannot confirm",
|
|
235
|
+
unknown: "an unidentified process",
|
|
236
|
+
exhausted: "no free port in this workspace's range",
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* One honest line about who holds a port. Used by the broadcast preflight so the
|
|
241
|
+
* operator is told *which* instance is in the way instead of a blind kill recipe.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} kind
|
|
244
|
+
* @param {{ port?: number | null, repoRoot?: string | null }} [info]
|
|
245
|
+
* @returns {string}
|
|
246
|
+
*/
|
|
247
|
+
export function describeBroadcastListener(kind, info = {}) {
|
|
248
|
+
const label = BROADCAST_LISTENER_LABELS[kind] || BROADCAST_LISTENER_LABELS.unknown;
|
|
249
|
+
const owner =
|
|
250
|
+
kind === "foreign" && info.repoRoot ? `another workspace (${info.repoRoot})` : label;
|
|
251
|
+
return info.port == null ? owner : `${info.port}: ${owner}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Pick a listen port for a broadcast (LAN) Mission Control instance.
|
|
256
|
+
*
|
|
257
|
+
* Same per-workspace candidate walk as {@link resolveMissionControlPort}, with
|
|
258
|
+
* broadcast-aware reuse: only this workspace's own broadcast listener is reused.
|
|
259
|
+
* Anything else on a candidate port (our loopback panel, another workspace, an
|
|
260
|
+
* unidentified process) is **skipped, never killed** — that is what allows more
|
|
261
|
+
* than one Mission Control instance to run at once.
|
|
262
|
+
*
|
|
263
|
+
* Explicit `PORT` still refuses rather than walking, so an operator who pinned a
|
|
264
|
+
* port is told the truth instead of silently landing somewhere else.
|
|
265
|
+
*
|
|
266
|
+
* @param {object} args
|
|
267
|
+
* @param {string} args.repoRoot
|
|
268
|
+
* @param {string | number | undefined | null} [args.envPort] - raw `PORT` env
|
|
269
|
+
* @param {(port: number) => { listening: boolean, repoRoot?: string|null, acceptsToken?: boolean, tokenGated?: boolean, lanReachable?: boolean|null }} args.probe
|
|
270
|
+
* @param {{ base?: number, range?: number }} [args.opts]
|
|
271
|
+
* @returns {{ port: number, reuse: boolean, explicit: boolean, skipped: Array<{ port: number, kind: string, repoRoot: string | null }> }}
|
|
272
|
+
*/
|
|
273
|
+
export function resolveBroadcastPort({ repoRoot, envPort, probe, opts = {} }) {
|
|
274
|
+
const root = resolve(String(repoRoot || "").trim() || ".");
|
|
275
|
+
const raw =
|
|
276
|
+
envPort != null && String(envPort).trim() !== ""
|
|
277
|
+
? Number.parseInt(String(envPort), 10)
|
|
278
|
+
: Number.NaN;
|
|
279
|
+
/** @type {Array<{ port: number, kind: string, repoRoot: string | null }>} */
|
|
280
|
+
const skipped = [];
|
|
281
|
+
|
|
282
|
+
if (Number.isFinite(raw) && raw > 0) {
|
|
283
|
+
const info = probe(raw) || { listening: false };
|
|
284
|
+
const kind = classifyBroadcastListener(info, root);
|
|
285
|
+
if (kind === "free") return { port: raw, reuse: false, explicit: true, skipped };
|
|
286
|
+
if (kind === "self-broadcast") return { port: raw, reuse: true, explicit: true, skipped };
|
|
287
|
+
const owner = info.repoRoot ? String(info.repoRoot) : null;
|
|
288
|
+
skipped.push({ port: raw, kind, repoRoot: owner });
|
|
289
|
+
const detail =
|
|
290
|
+
kind === "self-other-mode"
|
|
291
|
+
? "it is this workspace's Mission Control but not a broadcast listener (loopback /dashboard, or a different MISSION_CONTROL_TOKEN)"
|
|
292
|
+
: kind === "foreign"
|
|
293
|
+
? `it is Mission Control for ${owner}, and broadcast never touches another workspace`
|
|
294
|
+
: kind === "token-gated"
|
|
295
|
+
? "a token-gated Mission Control is there whose owner this token cannot confirm, so it is left alone"
|
|
296
|
+
: "an unidentified process is there, so it is left alone";
|
|
297
|
+
const err = new Error(
|
|
298
|
+
`PORT ${raw} is not available for broadcast: ${detail}. Unset PORT to let broadcast pick a free per-workspace port instead.`,
|
|
299
|
+
);
|
|
300
|
+
Object.assign(err, {
|
|
301
|
+
broadcast: { port: raw, kind, repoRoot: owner, explicit: true, skipped },
|
|
302
|
+
});
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const port of portCandidatesForRepoRoot(root, opts)) {
|
|
307
|
+
const info = probe(port) || { listening: false };
|
|
308
|
+
const kind = classifyBroadcastListener(info, root);
|
|
309
|
+
if (kind === "free") return { port, reuse: false, explicit: false, skipped };
|
|
310
|
+
if (kind === "self-broadcast") return { port, reuse: true, explicit: false, skipped };
|
|
311
|
+
skipped.push({ port, kind, repoRoot: info.repoRoot ? String(info.repoRoot) : null });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const base = Number.isFinite(opts.base) ? opts.base : DEFAULT_PORT_BASE;
|
|
315
|
+
const range = Number.isFinite(opts.range) && opts.range > 0 ? opts.range : DEFAULT_PORT_RANGE;
|
|
316
|
+
const err = new Error(
|
|
317
|
+
`No free Mission Control broadcast port in ${base}-${base + range - 1} for ${root}. Stop one of your own unused instances, or set PORT to a free port.`,
|
|
318
|
+
);
|
|
319
|
+
Object.assign(err, {
|
|
320
|
+
broadcast: { port: null, kind: "exhausted", repoRoot: null, explicit: false, skipped },
|
|
321
|
+
});
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
|
|
197
325
|
export const MAX_STRING = {
|
|
198
326
|
branch: 64,
|
|
199
327
|
lastCommit: 120,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
|
|
2
|
+
<!-- Marketplace 1:1 logotype. Plate fill is Mission Control bg-primary #0b0e14. Artwork is the Cursor-skin stroke helmet from logo-cursor.svg, centered with ~10% padding. Chrome marks stay in logo.svg and logo-cursor.svg. -->
|
|
3
|
+
<rect width="512" height="512" rx="96" fill="#0b0e14"/>
|
|
4
|
+
<g transform="translate(51.839 51.2) scale(18.244989)">
|
|
5
|
+
<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"/>
|
|
6
|
+
<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"/>
|
|
7
|
+
<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"/>
|
|
8
|
+
<g>
|
|
9
|
+
<line x1=".25" y1="7.9" x2=".25" y2="13.68" stroke="#e4e4e4" stroke-width=".5" stroke-linecap="round"/>
|
|
10
|
+
<line x1="22.13" y1="7.9" x2="22.13" y2="13.68" stroke="#e4e4e4" stroke-width=".5" stroke-linecap="round"/>
|
|
11
|
+
</g>
|
|
12
|
+
</g>
|
|
13
|
+
</svg>
|
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
* Opt-in LAN bind: HOST=0.0.0.0 (or explicit non-loopback), requires
|
|
6
6
|
* MISSION_CONTROL_TOKEN (generated when unset), detach-starts serve.mjs,
|
|
7
7
|
* prints LAN URL(s) with token. Does not weaken loopback `/dashboard`.
|
|
8
|
+
*
|
|
9
|
+
* Multi-instance: the listen port is the same per-workspace allocation the
|
|
10
|
+
* loopback starter uses (hash of the snapshot root in the 3333-3588 range unless
|
|
11
|
+
* PORT is set). Candidate ports held by this workspace's loopback panel, another
|
|
12
|
+
* workspace, or an unidentified process are skipped - never killed - so a
|
|
13
|
+
* broadcast can come up beside an already-running Mission Control.
|
|
8
14
|
*/
|
|
9
15
|
|
|
10
16
|
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
11
17
|
import { existsSync, openSync, realpathSync } from "node:fs";
|
|
12
|
-
import { basename, dirname, join } from "node:path";
|
|
18
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
13
19
|
import { fileURLToPath } from "node:url";
|
|
14
20
|
import {
|
|
15
21
|
buildBroadcastShareUrl,
|
|
@@ -19,13 +25,17 @@ import {
|
|
|
19
25
|
} from "./lib/broadcast-share.mjs";
|
|
20
26
|
import {
|
|
21
27
|
BROADCAST_TOKEN_ENV,
|
|
28
|
+
REPO_ROOT_ENV,
|
|
29
|
+
describeBroadcastListener,
|
|
22
30
|
escapePerlDoubleQuoted,
|
|
23
31
|
generateBroadcastToken,
|
|
24
32
|
isLoopbackBindHost,
|
|
25
33
|
isValidBroadcastToken,
|
|
26
34
|
listLanIPv4Addresses,
|
|
27
35
|
normalizeAuthToken,
|
|
36
|
+
repoRootLogId,
|
|
28
37
|
resolveBindHost,
|
|
38
|
+
resolveBroadcastPort,
|
|
29
39
|
resolveContextConfigPath,
|
|
30
40
|
resolveSnapshotRepoRoot,
|
|
31
41
|
} from "./lib/guards.mjs";
|
|
@@ -37,11 +47,14 @@ const KIT_ROOT = join(__dirname, "..");
|
|
|
37
47
|
const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT);
|
|
38
48
|
process.title = `Mission Control · ${basename(ROOT) || "workspace"}`;
|
|
39
49
|
const SERVE = join(__dirname, "serve.mjs");
|
|
40
|
-
const LOG =
|
|
41
|
-
|
|
50
|
+
const LOG =
|
|
51
|
+
process.env.MISSION_CONTROL_LOG || `/tmp/mission-control-broadcast-${repoRootLogId(ROOT)}.log`;
|
|
42
52
|
const READY_TIMEOUT_MS = 20_000;
|
|
43
53
|
const READY_POLL_MS = 250;
|
|
44
54
|
|
|
55
|
+
/** Allocated listen port for this run (see resolveBroadcastPort in main). */
|
|
56
|
+
let PORT = 0;
|
|
57
|
+
|
|
45
58
|
function resolveBroadcastEnv() {
|
|
46
59
|
const env = { ...process.env };
|
|
47
60
|
let host = resolveBindHost(env.HOST);
|
|
@@ -58,30 +71,84 @@ function resolveBroadcastEnv() {
|
|
|
58
71
|
return { env, host, token };
|
|
59
72
|
}
|
|
60
73
|
|
|
61
|
-
function urlsForProbe(token) {
|
|
74
|
+
function urlsForProbe(token, port = PORT) {
|
|
62
75
|
const q = `?token=${encodeURIComponent(token)}`;
|
|
63
|
-
const urls = [`http://127.0.0.1:${
|
|
76
|
+
const urls = [`http://127.0.0.1:${port}/${q}`];
|
|
64
77
|
for (const ip of listLanIPv4Addresses()) {
|
|
65
|
-
urls.push(`http://${ip}:${
|
|
78
|
+
urls.push(`http://${ip}:${port}/${q}`);
|
|
66
79
|
}
|
|
67
80
|
return urls;
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
/** HTTP status code as a string; "000" when the connection failed. */
|
|
84
|
+
function httpStatus(url) {
|
|
85
|
+
try {
|
|
86
|
+
return execFileSync(
|
|
87
|
+
"curl",
|
|
88
|
+
["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", url],
|
|
89
|
+
{ encoding: "utf8", timeout: 5000 },
|
|
90
|
+
).trim();
|
|
91
|
+
} catch {
|
|
92
|
+
return "000";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
function probeHttp(url) {
|
|
97
|
+
return httpStatus(url) === "200";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Snapshot root reported by a listener that accepts our token, else null. */
|
|
101
|
+
function snapshotRootAt(port, token) {
|
|
102
|
+
const url = `http://127.0.0.1:${port}/dashboard-data.json?token=${encodeURIComponent(token)}`;
|
|
71
103
|
try {
|
|
72
|
-
const
|
|
104
|
+
const raw = execFileSync("curl", ["-sf", url], {
|
|
73
105
|
encoding: "utf8",
|
|
74
|
-
timeout:
|
|
75
|
-
|
|
76
|
-
|
|
106
|
+
timeout: 8000,
|
|
107
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
108
|
+
});
|
|
109
|
+
const root = JSON.parse(raw)?.system?.repoRoot;
|
|
110
|
+
return typeof root === "string" && root.trim() ? resolve(root.trim()) : null;
|
|
77
111
|
} catch {
|
|
78
|
-
return
|
|
112
|
+
return null;
|
|
79
113
|
}
|
|
80
114
|
}
|
|
81
115
|
|
|
82
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Probe one candidate port for `resolveBroadcastPort`.
|
|
118
|
+
*
|
|
119
|
+
* `lanReachable` is what separates our own broadcast (reusable) from our own
|
|
120
|
+
* loopback panel: a loopback listener needs no token, so it answers `?token=…`
|
|
121
|
+
* with 200 on 127.0.0.1 while staying unreachable on every LAN address.
|
|
122
|
+
*/
|
|
123
|
+
function probeBroadcastPort(port, token) {
|
|
124
|
+
const q = `?token=${encodeURIComponent(token)}`;
|
|
125
|
+
const loopbackStatus = httpStatus(`http://127.0.0.1:${port}/${q}`);
|
|
126
|
+
const listening = loopbackStatus !== "000" || listeningPids(port).length > 0;
|
|
127
|
+
if (!listening) {
|
|
128
|
+
return { listening: false, repoRoot: null, acceptsToken: false, tokenGated: false };
|
|
129
|
+
}
|
|
130
|
+
const acceptsToken = loopbackStatus === "200";
|
|
131
|
+
if (!acceptsToken) {
|
|
132
|
+
return {
|
|
133
|
+
listening: true,
|
|
134
|
+
repoRoot: null,
|
|
135
|
+
acceptsToken: false,
|
|
136
|
+
tokenGated: loopbackStatus === "401" || loopbackStatus === "403",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const ips = listLanIPv4Addresses();
|
|
140
|
+
return {
|
|
141
|
+
listening: true,
|
|
142
|
+
repoRoot: snapshotRootAt(port, token),
|
|
143
|
+
acceptsToken: true,
|
|
144
|
+
tokenGated: false,
|
|
145
|
+
lanReachable: ips.length > 0 ? ips.some((ip) => probeHttp(`http://${ip}:${port}/${q}`)) : null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function listeningPids(port = PORT) {
|
|
83
150
|
try {
|
|
84
|
-
const out = execFileSync("lsof", ["-nP", `-iTCP:${
|
|
151
|
+
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
85
152
|
encoding: "utf8",
|
|
86
153
|
timeout: 3000,
|
|
87
154
|
}).trim();
|
|
@@ -124,6 +191,7 @@ function detachStart(env) {
|
|
|
124
191
|
const hostEsc = escapePerlDoubleQuoted(String(env.HOST));
|
|
125
192
|
const tokenEsc = escapePerlDoubleQuoted(String(env[BROADCAST_TOKEN_ENV]));
|
|
126
193
|
const portEsc = escapePerlDoubleQuoted(String(PORT));
|
|
194
|
+
const snapEsc = escapePerlDoubleQuoted(ROOT);
|
|
127
195
|
const perl = [
|
|
128
196
|
"use POSIX qw(setsid);",
|
|
129
197
|
"exit if fork;",
|
|
@@ -136,6 +204,7 @@ function detachStart(env) {
|
|
|
136
204
|
`$ENV{HOST}="${hostEsc}";`,
|
|
137
205
|
`$ENV{${BROADCAST_TOKEN_ENV}}="${tokenEsc}";`,
|
|
138
206
|
`$ENV{PORT}="${portEsc}";`,
|
|
207
|
+
`$ENV{${REPO_ROOT_ENV}}="${snapEsc}";`,
|
|
139
208
|
`exec("node","${serveEsc}");`,
|
|
140
209
|
].join(" ");
|
|
141
210
|
|
|
@@ -159,6 +228,68 @@ async function waitReady(urls) {
|
|
|
159
228
|
return null;
|
|
160
229
|
}
|
|
161
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Preflight: name every candidate port we walked past. Nothing here is killed —
|
|
233
|
+
* a busy port means another Mission Control (ours or someone else's) keeps
|
|
234
|
+
* running and broadcast lands on the next free per-workspace port.
|
|
235
|
+
*/
|
|
236
|
+
function reportSkipped(skipped) {
|
|
237
|
+
if (!skipped || skipped.length === 0) return;
|
|
238
|
+
console.log("Ports already held (left running):");
|
|
239
|
+
for (const entry of skipped) {
|
|
240
|
+
console.log(` ${describeBroadcastListener(entry.kind, entry)}`);
|
|
241
|
+
}
|
|
242
|
+
if (skipped.some((entry) => entry.kind === "token-gated")) {
|
|
243
|
+
console.log(
|
|
244
|
+
` Export ${BROADCAST_TOKEN_ENV} with an existing broadcast's token to reuse it instead of starting another.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Recovery text for a port we may not take. Never a blind kill of a foreign listener. */
|
|
250
|
+
function recoveryLines(kind, port) {
|
|
251
|
+
switch (kind) {
|
|
252
|
+
case "self-other-mode":
|
|
253
|
+
return [
|
|
254
|
+
` That listener is this workspace (${ROOT}). Stop it yourself if you want this exact port:`,
|
|
255
|
+
` kill "$(lsof -nP -iTCP:${port} -sTCP:LISTEN -t)"`,
|
|
256
|
+
" Or retry with PORT unset: broadcast will take a free per-workspace port and leave it running.",
|
|
257
|
+
];
|
|
258
|
+
case "foreign":
|
|
259
|
+
return [
|
|
260
|
+
" Leave that workspace's Mission Control running. Retry with PORT unset to take a free per-workspace port.",
|
|
261
|
+
];
|
|
262
|
+
case "token-gated":
|
|
263
|
+
return [
|
|
264
|
+
` Export ${BROADCAST_TOKEN_ENV} with that instance's token to reuse it, or retry with PORT unset.`,
|
|
265
|
+
];
|
|
266
|
+
case "exhausted":
|
|
267
|
+
return [
|
|
268
|
+
" Every candidate port in this workspace's range is held by an instance that is not ours to stop.",
|
|
269
|
+
" Stop one of your own Mission Control instances, or set PORT to a port you know is free.",
|
|
270
|
+
];
|
|
271
|
+
default:
|
|
272
|
+
return [
|
|
273
|
+
" Leave that process alone. Retry with PORT unset to take a free per-workspace port.",
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function reportRefusal(err) {
|
|
279
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
280
|
+
const info = err?.broadcast;
|
|
281
|
+
if (!info) return;
|
|
282
|
+
if (info.kind === "exhausted" && info.skipped?.length) {
|
|
283
|
+
console.error("Ports checked (all left running):");
|
|
284
|
+
for (const entry of info.skipped.slice(0, 8)) {
|
|
285
|
+
console.error(` ${describeBroadcastListener(entry.kind, entry)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const line of recoveryLines(info.kind, info.port)) {
|
|
289
|
+
console.error(line);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
162
293
|
async function main() {
|
|
163
294
|
const { env, host, token } = resolveBroadcastEnv();
|
|
164
295
|
if (isLoopbackBindHost(host)) {
|
|
@@ -168,6 +299,26 @@ async function main() {
|
|
|
168
299
|
process.exit(1);
|
|
169
300
|
}
|
|
170
301
|
|
|
302
|
+
let allocation;
|
|
303
|
+
try {
|
|
304
|
+
allocation = resolveBroadcastPort({
|
|
305
|
+
repoRoot: ROOT,
|
|
306
|
+
envPort: process.env.PORT,
|
|
307
|
+
probe: (port) => probeBroadcastPort(port, token),
|
|
308
|
+
});
|
|
309
|
+
} catch (err) {
|
|
310
|
+
reportRefusal(err);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
reportSkipped(allocation.skipped);
|
|
315
|
+
|
|
316
|
+
PORT = allocation.port;
|
|
317
|
+
// Pin the allocation for this process and the detached server.
|
|
318
|
+
process.env.PORT = String(PORT);
|
|
319
|
+
env.PORT = String(PORT);
|
|
320
|
+
env[REPO_ROOT_ENV] = ROOT;
|
|
321
|
+
|
|
171
322
|
const urls = urlsForProbe(token);
|
|
172
323
|
const primaryLan = listLanIPv4Addresses()[0];
|
|
173
324
|
const displayUrl =
|
|
@@ -175,15 +326,9 @@ async function main() {
|
|
|
175
326
|
? `http://${primaryLan}:${PORT}/?token=${encodeURIComponent(token)}`
|
|
176
327
|
: urls[0];
|
|
177
328
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
console.error(
|
|
182
|
-
`Port ${PORT} is listening but did not accept the broadcast token. Stop the existing Mission Control instance (loopback /dashboard) first, then retry.`,
|
|
183
|
-
);
|
|
184
|
-
console.error(` kill "$(lsof -nP -iTCP:${PORT} -sTCP:LISTEN -t)"`);
|
|
185
|
-
process.exit(1);
|
|
186
|
-
}
|
|
329
|
+
if (allocation.reuse) {
|
|
330
|
+
console.log(`Mission Control broadcast already listening on port ${PORT} for ${ROOT}`);
|
|
331
|
+
} else {
|
|
187
332
|
console.log(`Starting Mission Control broadcast on ${host}:${PORT}…`);
|
|
188
333
|
detachStart(env);
|
|
189
334
|
const ready = await waitReady(urls);
|
|
@@ -192,8 +337,6 @@ async function main() {
|
|
|
192
337
|
console.error(`Check the log: ${LOG}`);
|
|
193
338
|
process.exit(1);
|
|
194
339
|
}
|
|
195
|
-
} else {
|
|
196
|
-
console.log(`Mission Control broadcast already listening on port ${PORT}`);
|
|
197
340
|
}
|
|
198
341
|
|
|
199
342
|
const shareBase = resolveShareBase(process.env);
|
|
@@ -233,7 +376,10 @@ async function main() {
|
|
|
233
376
|
" Share is a cosmetic Mission Kit (or BYO) link; phone must still reach this LAN.",
|
|
234
377
|
);
|
|
235
378
|
}
|
|
236
|
-
console.log(
|
|
379
|
+
console.log(` Root: ${ROOT}`);
|
|
380
|
+
console.log(
|
|
381
|
+
" Config writes stay loopback-only. Stop this workspace only: kill the LISTEN pid on this port.",
|
|
382
|
+
);
|
|
237
383
|
console.log(" Firewall: allow inbound TCP on this port for your LAN profile if needed.");
|
|
238
384
|
console.log("");
|
|
239
385
|
|
package/dist/index.js
CHANGED
|
@@ -196,7 +196,25 @@ var KNOWN_SHIPPED_OVERLAY_HASHES = /* @__PURE__ */ new Set([
|
|
|
196
196
|
"fa5cf460eb314437081f7cea30dc8041c0bd6fc3f560a74bc2d7be1bf07384b0",
|
|
197
197
|
"fc39ec6d8a22498697f968ffc0fe5f717bed97afd68f922c76a80ed1f10d6579",
|
|
198
198
|
"a8756742197c3a2e1a6d64f4bde2a88db48abb66a0f449d625cd2e1c7b8d0cb4",
|
|
199
|
-
"6a5f8795a4a26b419f167e249576b798781c95308dc1ea50958351de713231d4"
|
|
199
|
+
"6a5f8795a4a26b419f167e249576b798781c95308dc1ea50958351de713231d4",
|
|
200
|
+
"4009c5e2faf9775d5708cdff0b5f9da80fd3ce390cc42aed0bb94272f6b05b1b",
|
|
201
|
+
"a8e070fae187908ef7b2cf2a41605e3079329403b1802b5fc67e83294c96a070",
|
|
202
|
+
"81186b528105f2873818ea91f51d20c95a68c0c4b3d128aa8896dd38a1bb3159",
|
|
203
|
+
"b2c7db73a0e18bd475651001722aa34b39276af9f73f0921102b680eb42bea0e",
|
|
204
|
+
"d03445b726cb6afe2ff37a936de97605cc60e441896e06385abfc15a127e2f45",
|
|
205
|
+
"f5ef970adaf4cfa7af551e35cb41b4052576109f5444a280b1e26562338b39e8",
|
|
206
|
+
"fe3f8e2339a7f183545f2aad6b7f02d3687277ca67597fa7620bfee269392bcd",
|
|
207
|
+
"b2489e381ea7daaef7299e5aede437a8baaa39baaddaac849efbf0a6ccb84a2b",
|
|
208
|
+
"0ae7d076904fd342de3ba38745f38fed8ca5c1f02d7b099d27deeb0a9be79740",
|
|
209
|
+
"15ca459811f0f050f4eeff77d4567b45c0790beb77904ecf37e145d65a858c77",
|
|
210
|
+
"1dc329c0c0695a4395bb7c30db0fb7b6fb06bcf5fdc1d6a475656ac6a525aa5f",
|
|
211
|
+
"35bb0008ef52af147a45fba667f7dceb841d537ad5e185317672577afbcbc189",
|
|
212
|
+
"42b701d224f4e690dc027d66fcc1214bab31a0ae0c50ef982124f57d4dae4982",
|
|
213
|
+
"65272f85a32f8fe95ed19d0823fa9d1d6dee5bc18a0244f7b553ea5162ba9b36",
|
|
214
|
+
"73cd50bac290b84df6245d3647e686dee8ec3b0434736d08fdba6e30d342ef0a",
|
|
215
|
+
"61b635ea8a08171062bd329a4615d7426eb787eb796271aa882f55f39db56810",
|
|
216
|
+
"86afbea8f64de68a79ad5e374f3132bdbe2582b94321fb3d438314838e36c776",
|
|
217
|
+
"65cc1c0293b145e48ed73ad0ca9ab33cbba5ed834a5bfb31f195ed30c2f143df"
|
|
200
218
|
]);
|
|
201
219
|
|
|
202
220
|
// src/lifecycle/paths.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dadado/agent-kit-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "Agent Kit CLI: HITL framework install and tooling for AI-assisted IDEs (rules, skills, plan/handoff, context).",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"type": "module",
|