@dadado/agent-kit-cli 5.2.1 → 5.4.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.
@@ -0,0 +1,25 @@
1
+ # Mission Control runtime
2
+
3
+ The dashboard the CLI serves. Plain ES modules, no build step: `serve.mjs` reads this directory
4
+ directly, and `packages/cli/dashboard/` is a generated copy (gitignored, produced by
5
+ `scripts/sync-cli-dashboard.mjs` at build/prepack time so the npm tarball can ship it).
6
+
7
+ | Path | Role |
8
+ | --- | --- |
9
+ | `serve.mjs` | HTTP server, auth gate, SSE |
10
+ | `start.mjs` / `start-broadcast.mjs` | loopback and LAN entry points, per-workspace port allocation |
11
+ | `dashboard-data.mjs` | snapshot builder for the panel |
12
+ | `dashboard.html`, `open.html` | panel and share shell |
13
+ | `lib/*.mjs` | guards, semantic model, live refresh, browser open, terminal snapshot |
14
+ | `lib/guards.d.mts` | hand-written types consumed by the CLI package (parity is pinned by a test) |
15
+
16
+ ## Where the tests live
17
+
18
+ Tests for these modules are in `packages/cli/src/dashboard/*.test.ts`, not next to the source. The
19
+ CLI package owns the only test runner in the workspace (vitest), and those suites import the `.mjs`
20
+ files directly (`../../../../dashboard/lib/...`) so there is one implementation under test rather
21
+ than a copy. Lint and format are covered from the repository root (`pnpm lint` checks `dashboard/**`
22
+ before it fans out to the workspace packages).
23
+
24
+ `dashboard.html` is outside Biome's scope; CSS/HTML-only changes are covered by
25
+ `packages/cli/src/dashboard/plugin-ux-validation.test.ts` instead.
@@ -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;
@@ -47,12 +47,12 @@ export function escapePerlDoubleQuoted(value) {
47
47
 
48
48
  /**
49
49
  * Resolve the repository root Mission Control should snapshot.
50
- * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
50
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined> | undefined} env - `undefined` falls back to `process.env`
51
51
  * @param {string} kitRoot - absolute path to the kit tree (parent of `dashboard/`)
52
52
  * @returns {string} absolute snapshot root
53
53
  */
54
- export function resolveSnapshotRepoRoot(env = process.env, kitRoot) {
55
- const raw = env?.[REPO_ROOT_ENV];
54
+ export function resolveSnapshotRepoRoot(env, kitRoot) {
55
+ const raw = (env ?? process.env)?.[REPO_ROOT_ENV];
56
56
  if (typeof raw === "string" && raw.trim()) {
57
57
  return resolve(raw.trim());
58
58
  }
@@ -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,
@@ -272,7 +400,7 @@ export function tokensMatch(a, b) {
272
400
  /**
273
401
  * Resolve bind + token gate for Mission Control serve.
274
402
  * Non-loopback bind requires a valid MISSION_CONTROL_TOKEN (no warn-only 0.0.0.0).
275
- * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
403
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined> | undefined} env - `undefined` falls back to `process.env`
276
404
  * @returns
277
405
  * | { ok: true, host: string, tokenRequired: boolean, token: string | null, broadcast: boolean }
278
406
  * | { ok: false, error: string }
@@ -11,8 +11,7 @@
11
11
  * ## Follow-up plan (also "Followup plan")
12
12
  * ## Residuals plan
13
13
  */
14
- export const TRIAGE_HEADING_RE =
15
- /^#{2,6}\s+(?:Triage note|Follow-?up plan|Residuals plan)\b/im;
14
+ export const TRIAGE_HEADING_RE = /^#{2,6}\s+(?:Triage note|Follow-?up plan|Residuals plan)\b/im;
16
15
 
17
16
  /** True when markdown carries a durable triage heading. */
18
17
  export function hasTriageHeading(text) {
@@ -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 = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control-broadcast.log";
41
- const PORT = Number.parseInt(process.env.PORT || "3333", 10);
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:${PORT}/${q}`];
76
+ const urls = [`http://127.0.0.1:${port}/${q}`];
64
77
  for (const ip of listLanIPv4Addresses()) {
65
- urls.push(`http://${ip}:${PORT}/${q}`);
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 code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], {
104
+ const raw = execFileSync("curl", ["-sf", url], {
73
105
  encoding: "utf8",
74
- timeout: 3000,
75
- }).trim();
76
- return code === "200";
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 false;
112
+ return null;
79
113
  }
80
114
  }
81
115
 
82
- function listeningPids() {
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:${PORT}`, "-sTCP:LISTEN", "-t"], {
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
- const already = listeningPids().length > 0 && urls.some((u) => probeHttp(u));
179
- if (!already) {
180
- if (listeningPids().length > 0) {
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(" Config writes stay loopback-only. Stop: kill the LISTEN pid on this port.");
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
@@ -198,7 +198,25 @@ var KNOWN_SHIPPED_OVERLAY_HASHES = /* @__PURE__ */ new Set([
198
198
  "a8756742197c3a2e1a6d64f4bde2a88db48abb66a0f449d625cd2e1c7b8d0cb4",
199
199
  "6a5f8795a4a26b419f167e249576b798781c95308dc1ea50958351de713231d4",
200
200
  "4009c5e2faf9775d5708cdff0b5f9da80fd3ce390cc42aed0bb94272f6b05b1b",
201
- "a8e070fae187908ef7b2cf2a41605e3079329403b1802b5fc67e83294c96a070"
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",
218
+ "f04fcfe31354d1b09aeb256a17e4aab91c98ea48a5cff25e4a0281af3cfb289f",
219
+ "34e559ad9036d93d9cbc96d394bc2bbeb10ce50ead00d58a914158ae19daa76c"
202
220
  ]);
203
221
 
204
222
  // src/lifecycle/paths.ts
@@ -1072,17 +1090,38 @@ async function findPack(rootDir, packId) {
1072
1090
  }
1073
1091
 
1074
1092
  // src/registry/install.ts
1075
- import { readFile as readFile5 } from "fs/promises";
1093
+ import { readFile as readFile5, readdir as readdir3 } from "fs/promises";
1076
1094
  import path8 from "path";
1095
+ function skillTargetDir(skillPath, skillId) {
1096
+ const category = skillPath.includes("/core/") ? "core" : "community";
1097
+ return path8.posix.join(".cursor", "skills", category, skillId);
1098
+ }
1099
+ async function skillFileTargets(registryRoot, skillPath, skillId) {
1100
+ const targetDir = skillTargetDir(skillPath, skillId);
1101
+ const pair = (rel) => ({
1102
+ sourceRel: path8.posix.join(skillPath, rel),
1103
+ targetRel: path8.posix.join(targetDir, rel)
1104
+ });
1105
+ let companions = [];
1106
+ try {
1107
+ const dirAbs = resolveContained(registryRoot, skillPath);
1108
+ const entries = await readdir3(dirAbs, { withFileTypes: true, recursive: true });
1109
+ companions = entries.filter((entry) => entry.isFile()).map((entry) => {
1110
+ const parent = path8.relative(dirAbs, entry.parentPath ?? dirAbs);
1111
+ return path8.join(parent, entry.name).split(path8.sep).join("/");
1112
+ }).filter((rel) => rel !== "SKILL.md" && !rel.split("/").some((seg) => seg.startsWith("."))).sort();
1113
+ } catch {
1114
+ companions = [];
1115
+ }
1116
+ return [pair("SKILL.md"), ...companions.map(pair)];
1117
+ }
1077
1118
  function targetForMember(member) {
1078
1119
  switch (member.kind) {
1079
- case "skill": {
1080
- const category = member.source.includes("/core/") ? "core" : "community";
1120
+ case "skill":
1081
1121
  return {
1082
1122
  sourceRel: path8.posix.join(member.source, "SKILL.md"),
1083
- targetRel: path8.posix.join(".cursor", "skills", category, member.id, "SKILL.md")
1123
+ targetRel: path8.posix.join(skillTargetDir(member.source, member.id), "SKILL.md")
1084
1124
  };
1085
- }
1086
1125
  case "rule":
1087
1126
  return {
1088
1127
  sourceRel: member.source,
@@ -1123,19 +1162,22 @@ function targetForMember(member) {
1123
1162
  }
1124
1163
  async function installSkill(registryRoot, projectRoot, skill, options = {}) {
1125
1164
  const stats = emptyStats();
1126
- const category = skill.path.includes("/core/") ? "core" : "community";
1127
- const sourceRel = path8.posix.join(skill.path, "SKILL.md");
1128
- const targetRel = path8.posix.join(".cursor", "skills", category, skill.id, "SKILL.md");
1129
1165
  const managedHashes = await loadManagedHashLedger(projectRoot);
1130
- const outcome = await copyRegistryFile(
1166
+ for (const { sourceRel, targetRel } of await skillFileTargets(
1131
1167
  registryRoot,
1132
- projectRoot,
1133
- sourceRel,
1134
- targetRel,
1135
- options.protectedGlobs ?? [],
1136
- { managedHashes, persistManagedHashes: false }
1137
- );
1138
- recordOutcome(stats, targetRel, outcome);
1168
+ skill.path,
1169
+ skill.id
1170
+ )) {
1171
+ const outcome = await copyRegistryFile(
1172
+ registryRoot,
1173
+ projectRoot,
1174
+ sourceRel,
1175
+ targetRel,
1176
+ options.protectedGlobs ?? [],
1177
+ { managedHashes, persistManagedHashes: false }
1178
+ );
1179
+ recordOutcome(stats, targetRel, outcome);
1180
+ }
1139
1181
  await saveManagedHashLedger(projectRoot, managedHashes);
1140
1182
  return stats;
1141
1183
  }
@@ -1169,16 +1211,18 @@ async function installPack(registryRoot, projectRoot, packId, options = {}) {
1169
1211
  const managedHashes = await loadManagedHashLedger(projectRoot);
1170
1212
  const copyOpts = { managedHashes, persistManagedHashes: false };
1171
1213
  for (const member of packManifest.members) {
1172
- const { sourceRel, targetRel } = targetForMember(member);
1173
- const outcome = await copyRegistryFile(
1174
- registryRoot,
1175
- projectRoot,
1176
- sourceRel,
1177
- targetRel,
1178
- protectedGlobs,
1179
- copyOpts
1180
- );
1181
- recordOutcome(stats, targetRel, outcome);
1214
+ const pairs = member.kind === "skill" ? await skillFileTargets(registryRoot, member.source, member.id) : [targetForMember(member)];
1215
+ for (const { sourceRel, targetRel } of pairs) {
1216
+ const outcome = await copyRegistryFile(
1217
+ registryRoot,
1218
+ projectRoot,
1219
+ sourceRel,
1220
+ targetRel,
1221
+ protectedGlobs,
1222
+ copyOpts
1223
+ );
1224
+ recordOutcome(stats, targetRel, outcome);
1225
+ }
1182
1226
  }
1183
1227
  await saveManagedHashLedger(projectRoot, managedHashes);
1184
1228
  return stats;
@@ -1604,8 +1648,10 @@ async function buildRegistryPathMap(registryRoot, manifest) {
1604
1648
  for (const packId of manifest.packs ?? []) {
1605
1649
  const pack = await loadPackManifest(registryRoot, packId);
1606
1650
  for (const member of pack.members) {
1607
- const { sourceRel, targetRel } = packMemberTargets(member);
1608
- map.set(targetRel.split(path9.sep).join("/"), sourceRel.split(path9.sep).join("/"));
1651
+ const pairs = member.kind === "skill" ? await skillFileTargets(registryRoot, member.source, member.id) : [packMemberTargets(member)];
1652
+ for (const { sourceRel, targetRel } of pairs) {
1653
+ map.set(targetRel.split(path9.sep).join("/"), sourceRel.split(path9.sep).join("/"));
1654
+ }
1609
1655
  }
1610
1656
  }
1611
1657
  if ((manifest.skills ?? []).length > 0) {
@@ -1614,25 +1660,28 @@ async function buildRegistryPathMap(registryRoot, manifest) {
1614
1660
  for (const id of manifest.skills ?? []) {
1615
1661
  const skill = pool.find((s) => s.id === id);
1616
1662
  if (!skill) continue;
1617
- const category = skill.path.includes("/core/") ? "core" : "community";
1618
- const sourceRel = path9.posix.join(skill.path, "SKILL.md");
1619
- const targetRel = path9.posix.join(".cursor", "skills", category, skill.id, "SKILL.md");
1620
- map.set(targetRel, sourceRel);
1663
+ for (const { sourceRel, targetRel } of await skillFileTargets(
1664
+ registryRoot,
1665
+ skill.path,
1666
+ skill.id
1667
+ )) {
1668
+ map.set(targetRel, sourceRel);
1669
+ }
1621
1670
  }
1622
1671
  }
1623
1672
  return map;
1624
1673
  }
1625
1674
  function guessRegistryPath(projectRel) {
1626
1675
  const p = projectRel.split(path9.sep).join("/");
1627
- if (p.startsWith(".cursor/skills/") && p.endsWith("/SKILL.md")) {
1676
+ if (p.startsWith(".cursor/skills/")) {
1628
1677
  const rest = p.slice(".cursor/skills/".length);
1629
1678
  const parts = rest.split("/");
1630
- if (parts.length === 3 && (parts[0] === "core" || parts[0] === "community")) {
1679
+ if (parts.length >= 3 && (parts[0] === "core" || parts[0] === "community")) {
1631
1680
  return path9.posix.join("registry/skills", rest);
1632
1681
  }
1633
1682
  const skillId = parts[0];
1634
- if (parts.length === 2 && skillId) {
1635
- return path9.posix.join("registry/skills", "community", skillId, "SKILL.md");
1683
+ if (parts.length >= 2 && skillId) {
1684
+ return path9.posix.join("registry/skills", "community", rest);
1636
1685
  }
1637
1686
  }
1638
1687
  if (p.startsWith(".cursor/rules/")) {
@@ -2735,8 +2784,10 @@ async function diffAgainstRegistry(registryRoot, projectRoot, manifest) {
2735
2784
  for (const packId of manifest.packs ?? []) {
2736
2785
  const pack = await loadPackManifest(registryRoot, packId);
2737
2786
  for (const member of pack.members) {
2738
- const { sourceRel, targetRel } = packMemberTargets(member);
2739
- await pushUnique(sourceRel, targetRel);
2787
+ const pairs = member.kind === "skill" ? await skillFileTargets(registryRoot, member.source, member.id) : [packMemberTargets(member)];
2788
+ for (const { sourceRel, targetRel } of pairs) {
2789
+ await pushUnique(sourceRel, targetRel);
2790
+ }
2740
2791
  }
2741
2792
  }
2742
2793
  if ((manifest.skills ?? []).length > 0) {
@@ -2748,10 +2799,13 @@ async function diffAgainstRegistry(registryRoot, projectRoot, manifest) {
2748
2799
  entries.push({ path: `skill:${id}`, status: "missing-registry" });
2749
2800
  continue;
2750
2801
  }
2751
- const category = skill.path.includes("/core/") ? "core" : "community";
2752
- const sourceRel = path13.posix.join(skill.path, "SKILL.md");
2753
- const targetRel = path13.posix.join(".cursor", "skills", category, skill.id, "SKILL.md");
2754
- await pushUnique(sourceRel, targetRel);
2802
+ for (const { sourceRel, targetRel } of await skillFileTargets(
2803
+ registryRoot,
2804
+ skill.path,
2805
+ skill.id
2806
+ )) {
2807
+ await pushUnique(sourceRel, targetRel);
2808
+ }
2755
2809
  }
2756
2810
  }
2757
2811
  return entries;
@@ -2827,7 +2881,7 @@ import { defineCommand as defineCommand7 } from "citty";
2827
2881
 
2828
2882
  // src/invariants/hooks-health.ts
2829
2883
  import { execFile as execFile2 } from "child_process";
2830
- import { constants as constants2, access as access5, readFile as readFile9, readdir as readdir3, stat as stat2 } from "fs/promises";
2884
+ import { constants as constants2, access as access5, readFile as readFile9, readdir as readdir4, stat as stat2 } from "fs/promises";
2831
2885
  import path14 from "path";
2832
2886
  import { promisify as promisify2 } from "util";
2833
2887
  var execFileAsync2 = promisify2(execFile2);
@@ -2911,7 +2965,7 @@ async function assessGitHooksInstallDrift(rootDir) {
2911
2965
  const installHint = "Install or refresh with: `cp git-hooks/<name> .git/hooks/<name> && chmod +x .git/hooks/<name>` (see git-hooks/README.md)";
2912
2966
  let names = [...GIT_HOOK_CANONICAL_NAMES];
2913
2967
  try {
2914
- const listed = await readdir3(canonicalDir);
2968
+ const listed = await readdir4(canonicalDir);
2915
2969
  const fromDisk = listed.filter(
2916
2970
  (n) => GIT_HOOK_CANONICAL_NAMES.includes(n)
2917
2971
  );
@@ -4756,7 +4810,7 @@ var guardCommand = defineCommand8({
4756
4810
 
4757
4811
  // src/commands/handoff.ts
4758
4812
  import { spawn as spawn4 } from "child_process";
4759
- import { readFile as readFile13, readdir as readdir4, writeFile as writeFile6 } from "fs/promises";
4813
+ import { readFile as readFile13, readdir as readdir5, writeFile as writeFile6 } from "fs/promises";
4760
4814
  import path25 from "path";
4761
4815
  import { defineCommand as defineCommand9 } from "citty";
4762
4816
  function parsePlanFrontmatter(raw) {
@@ -4775,7 +4829,7 @@ function parsePlanFrontmatter(raw) {
4775
4829
  }
4776
4830
  async function findActivePlan(plansDir) {
4777
4831
  if (!await fileExists(plansDir)) return null;
4778
- const files = (await readdir4(plansDir)).filter((f) => f.endsWith(".plan.md")).sort().reverse();
4832
+ const files = (await readdir5(plansDir)).filter((f) => f.endsWith(".plan.md")).sort().reverse();
4779
4833
  for (const file of files) {
4780
4834
  const raw = await readFile13(path25.join(plansDir, file), "utf8");
4781
4835
  const fm = parsePlanFrontmatter(raw);
@@ -6409,7 +6463,7 @@ import { defineCommand as defineCommand13 } from "citty";
6409
6463
 
6410
6464
  // src/invariants/monitors-untriaged.ts
6411
6465
  import { execFile as execFile5 } from "child_process";
6412
- import { readFile as readFile17, readdir as readdir5, stat as stat3 } from "fs/promises";
6466
+ import { readFile as readFile17, readdir as readdir6, stat as stat3 } from "fs/promises";
6413
6467
  import path34 from "path";
6414
6468
  import { promisify as promisify5 } from "util";
6415
6469
 
@@ -6434,7 +6488,7 @@ function hasOpenGaps(content) {
6434
6488
  }
6435
6489
  async function listMonitorFiles(memoryDir) {
6436
6490
  try {
6437
- const names = await readdir5(memoryDir);
6491
+ const names = await readdir6(memoryDir);
6438
6492
  return names.filter((n) => n.startsWith("plan-monitor-") && n.endsWith(".md")).sort();
6439
6493
  } catch {
6440
6494
  return [];
@@ -6842,7 +6896,7 @@ function createPersonaBannerPrinter(persona) {
6842
6896
  }
6843
6897
 
6844
6898
  // src/plan-loop/plan-state.ts
6845
- import { readFile as readFile18, readdir as readdir6 } from "fs/promises";
6899
+ import { readFile as readFile18, readdir as readdir7 } from "fs/promises";
6846
6900
  import path38 from "path";
6847
6901
  function countPendingTodos(raw) {
6848
6902
  const lines = raw.split(/\r?\n/);
@@ -6870,7 +6924,7 @@ function countPendingTodos(raw) {
6870
6924
  }
6871
6925
  async function findActivePlanFile(plansDir) {
6872
6926
  if (!await fileExists(plansDir)) return null;
6873
- const files = (await readdir6(plansDir)).filter((f) => f.endsWith(".plan.md")).sort();
6927
+ const files = (await readdir7(plansDir)).filter((f) => f.endsWith(".plan.md")).sort();
6874
6928
  return files[0] ? path38.join(plansDir, files[0]) : null;
6875
6929
  }
6876
6930
  async function readPlan(planPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dadado/agent-kit-cli",
3
- "version": "5.2.1",
3
+ "version": "5.4.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",