@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.
- package/dashboard/README.md +25 -0
- package/dashboard/lib/guards.d.mts +33 -0
- package/dashboard/lib/guards.mjs +132 -4
- package/dashboard/lib/triage-heading.mjs +1 -2
- package/dashboard/start-broadcast.mjs +171 -25
- package/dist/index.js +105 -51
- package/package.json +1 -1
|
@@ -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;
|
package/dashboard/lib/guards.mjs
CHANGED
|
@@ -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>}
|
|
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
|
|
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>}
|
|
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 =
|
|
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
|
@@ -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(
|
|
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
|
|
1166
|
+
for (const { sourceRel, targetRel } of await skillFileTargets(
|
|
1131
1167
|
registryRoot,
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
|
1173
|
-
const
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
|
1608
|
-
|
|
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
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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/")
|
|
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
|
|
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
|
|
1635
|
-
return path9.posix.join("registry/skills", "community",
|
|
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
|
|
2739
|
-
|
|
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
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|