@dadado/agent-kit-cli 4.8.8 → 5.0.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/LICENSE +75 -21
- package/README.md +17 -0
- package/dashboard/dashboard-data.mjs +103 -0
- package/dashboard/dashboard.html +504 -171
- package/dashboard/lib/broadcast-share.mjs +251 -0
- package/dashboard/lib/guards.d.mts +10 -0
- package/dashboard/lib/guards.mjs +15 -2
- package/dashboard/lib/open-browser.d.mts +31 -0
- package/dashboard/lib/open-browser.mjs +298 -0
- package/dashboard/lib/semantic-model.mjs +215 -9
- package/dashboard/open.html +213 -0
- package/dashboard/serve.mjs +6 -2
- package/dashboard/start-broadcast.mjs +73 -30
- package/dashboard/start.mjs +21 -25
- package/dist/index.js +1417 -416
- package/package.json +4 -2
|
@@ -24,10 +24,16 @@ export const MONITOR_FEED_CAP = 20;
|
|
|
24
24
|
/** Cap agent_step rows emitted per active plan for the denser Crew feed. */
|
|
25
25
|
export const MONITOR_AGENT_STEP_EMIT_CAP = 12;
|
|
26
26
|
|
|
27
|
+
/** Cap subagent-run rows emitted per snapshot (fs scan bounds live in dashboard-data.mjs). */
|
|
28
|
+
export const MONITOR_SUBAGENT_EMIT_CAP = 8;
|
|
29
|
+
/** Cap plan_review pointer rows emitted per snapshot. */
|
|
30
|
+
export const MONITOR_PLAN_REVIEW_EMIT_CAP = 4;
|
|
31
|
+
|
|
27
32
|
/**
|
|
28
33
|
* Monitor hero curated subset over the semantic activity stream.
|
|
29
34
|
* Live agent steps: run_plan / handoff / delivery plus agent_step (Task/orchestrator
|
|
30
|
-
* to-do steps)
|
|
35
|
+
* to-do steps), subagent (Task worker lifecycle) and plan_review (background
|
|
36
|
+
* mid-batch review pointers). plan_progress milestones stay on Activity / Checklist.
|
|
31
37
|
* Activity (Phase 2) is the superset; inventory kinds are excluded here.
|
|
32
38
|
*/
|
|
33
39
|
export const MONITOR_ACTIVITY_KINDS = Object.freeze([
|
|
@@ -35,6 +41,8 @@ export const MONITOR_ACTIVITY_KINDS = Object.freeze([
|
|
|
35
41
|
"handoff",
|
|
36
42
|
"delivery",
|
|
37
43
|
"agent_step",
|
|
44
|
+
"subagent",
|
|
45
|
+
"plan_review",
|
|
38
46
|
]);
|
|
39
47
|
|
|
40
48
|
/**
|
|
@@ -2152,9 +2160,12 @@ export function formatDeliveryActivity(logLines, { plans = [], limit = MAX_GIT_A
|
|
|
2152
2160
|
const kitAgent = normalizeKitAgentId(agent);
|
|
2153
2161
|
const actor = briefActivityActor(kitAgent, { kind: "delivery", plan: planName });
|
|
2154
2162
|
const prBit = `PR #${entry.pr}`;
|
|
2163
|
+
// Verb `merged` (not `shipped`, retired 2026-08-05): the row is derived from
|
|
2164
|
+
// a merge/squash entry, and `shipped` implied a prod promote /git-staging
|
|
2165
|
+
// never performed.
|
|
2155
2166
|
const label = brief
|
|
2156
|
-
? `${actor} \u00b7
|
|
2157
|
-
: `${actor} \u00b7
|
|
2167
|
+
? `${actor} \u00b7 merged \u00b7 ${brief} \u00b7 ${prBit} \u00b7 ${entry.sha}`
|
|
2168
|
+
: `${actor} \u00b7 merged \u00b7 ${prBit} \u00b7 ${entry.sha}`;
|
|
2158
2169
|
const commitType = parseDeliveryCommitType(brief, { hasPr: entry.pr != null });
|
|
2159
2170
|
events.push({
|
|
2160
2171
|
id: activityId("delivery", ["merge", String(entry.pr)]),
|
|
@@ -2173,18 +2184,23 @@ export function formatDeliveryActivity(logLines, { plans = [], limit = MAX_GIT_A
|
|
|
2173
2184
|
|
|
2174
2185
|
/**
|
|
2175
2186
|
* Actor segment for Monitor return-brief labels.
|
|
2176
|
-
* Kit agent id, else
|
|
2177
|
-
*
|
|
2178
|
-
*
|
|
2187
|
+
* Kit agent id, else `Eng` for delivery, else `SQ` when a plan is present
|
|
2188
|
+
* (never the full plan filename), else `Eng`.
|
|
2189
|
+
*
|
|
2190
|
+
* Short display masks from the operator lexicon (2026-08-05): Engineering
|
|
2191
|
+
* Manager -> Eng, Squad -> SQ, Platform Engineer -> Eng. `Eng` is a documented
|
|
2192
|
+
* collision between the delivery and system fallbacks; the resolution keys
|
|
2193
|
+
* (`orchestrator` / `crew` / `system`) and the row's kind glyph stay distinct.
|
|
2194
|
+
* ADR: decisions/2026-07-27_crew-monitor-vs-plan-monitor-glossary.md.
|
|
2179
2195
|
* @param {string|null|undefined} agent
|
|
2180
2196
|
* @param {{ kind?: string, plan?: string|null }} [opts]
|
|
2181
2197
|
*/
|
|
2182
2198
|
export function briefActivityActor(agent, { kind, plan } = {}) {
|
|
2183
2199
|
const kit = normalizeKitAgentId(agent);
|
|
2184
2200
|
if (kit) return kit;
|
|
2185
|
-
if (kind === "delivery") return "
|
|
2186
|
-
if (plan) return "
|
|
2187
|
-
return "
|
|
2201
|
+
if (kind === "delivery") return "Eng";
|
|
2202
|
+
if (plan) return "SQ";
|
|
2203
|
+
return "Eng";
|
|
2188
2204
|
}
|
|
2189
2205
|
|
|
2190
2206
|
/**
|
|
@@ -2316,6 +2332,191 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) {
|
|
|
2316
2332
|
return events;
|
|
2317
2333
|
}
|
|
2318
2334
|
|
|
2335
|
+
/**
|
|
2336
|
+
* Task subagent transcripts are `<uuid>.jsonl` inside a parent chat's
|
|
2337
|
+
* `subagents/` directory.
|
|
2338
|
+
*/
|
|
2339
|
+
export const SUBAGENT_TRANSCRIPT_FILE_RE = /^([0-9a-fA-F][0-9a-fA-F-]{7,})\.jsonl$/;
|
|
2340
|
+
|
|
2341
|
+
/**
|
|
2342
|
+
* Worker-prompt fields the kit's own dispatch template declares (see
|
|
2343
|
+
* `.cursor/commands/run-plan.md`). Both forms occur in real dispatches: the
|
|
2344
|
+
* bare `To-do id: x` of the plain template and the `- **worker_type:** x` of a
|
|
2345
|
+
* bulleted orchestrator prompt, so the leading list marker and the markdown
|
|
2346
|
+
* emphasis on either side of the colon are optional. The captured value
|
|
2347
|
+
* excludes `*` and a backtick so `**explore**` and `` `explore` `` yield
|
|
2348
|
+
* `explore` rather than the decoration.
|
|
2349
|
+
*/
|
|
2350
|
+
const SUBAGENT_TODO_ID_RE =
|
|
2351
|
+
/^[ \t]*(?:[-*][ \t]*)?\**To-?do id\**[ \t]*[:=][ \t]*\**[ \t]*([^\s*`]+)/im;
|
|
2352
|
+
const SUBAGENT_WORKER_TYPE_RE =
|
|
2353
|
+
/^[ \t]*(?:[-*][ \t]*)?\**(?:worker_type(?:[ \t]*\/[ \t]*subagent_type)?|subagent_type)\**[ \t]*[:=][ \t]*\**[ \t]*([^\s*`]+)/im;
|
|
2354
|
+
|
|
2355
|
+
/** Plain-text content of a transcript entry (user prompt or assistant reply). */
|
|
2356
|
+
function subagentEntryText(entry) {
|
|
2357
|
+
const content = entry?.message?.content;
|
|
2358
|
+
if (typeof content === "string") return content;
|
|
2359
|
+
if (!Array.isArray(content)) return "";
|
|
2360
|
+
const parts = [];
|
|
2361
|
+
for (const c of content) {
|
|
2362
|
+
if (c && c.type === "text" && typeof c.text === "string") parts.push(c.text);
|
|
2363
|
+
}
|
|
2364
|
+
return parts.join("\n");
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* Lifecycle of one Task subagent run, from the two records that carry it: the
|
|
2369
|
+
* dispatch prompt (first entry) and the terminal record (last entry).
|
|
2370
|
+
*
|
|
2371
|
+
* Phase contract: a transcript whose last record is not `turn_ended` is still
|
|
2372
|
+
* `running`; `turn_ended` with `status: "success"` is `done`; any other status
|
|
2373
|
+
* (including `error`) is `failed`. A transcript that is empty or entirely
|
|
2374
|
+
* unparsable yields `null` rather than a phantom running row.
|
|
2375
|
+
*
|
|
2376
|
+
* The fs half (directory layout, recency window, file/byte caps) lives in
|
|
2377
|
+
* `dashboard-data.mjs` next to the agent-prompt scan contract. Transcript paths
|
|
2378
|
+
* live under `$HOME`, never in the repo, so no `sourcePath` is emitted.
|
|
2379
|
+
*
|
|
2380
|
+
* @param {{ id?: string, parentId?: string|null, firstLine?: string, lastLine?: string, modifiedAt?: string|null }} input
|
|
2381
|
+
* @returns {{ id: string, parentId: string|null, phase: 'running'|'done'|'failed', todoId: string|null, workerType: string|null, modifiedAt: string|null }|null}
|
|
2382
|
+
*/
|
|
2383
|
+
export function parseSubagentRun({
|
|
2384
|
+
id,
|
|
2385
|
+
parentId = null,
|
|
2386
|
+
firstLine = "",
|
|
2387
|
+
lastLine = "",
|
|
2388
|
+
modifiedAt = null,
|
|
2389
|
+
} = {}) {
|
|
2390
|
+
const runId = String(id || "").trim();
|
|
2391
|
+
if (!runId) return null;
|
|
2392
|
+
|
|
2393
|
+
let first = null;
|
|
2394
|
+
let last = null;
|
|
2395
|
+
try {
|
|
2396
|
+
first = firstLine ? JSON.parse(firstLine) : null;
|
|
2397
|
+
} catch {
|
|
2398
|
+
first = null;
|
|
2399
|
+
}
|
|
2400
|
+
try {
|
|
2401
|
+
last = lastLine ? JSON.parse(lastLine) : null;
|
|
2402
|
+
} catch {
|
|
2403
|
+
last = null;
|
|
2404
|
+
}
|
|
2405
|
+
if (!first && !last) return null;
|
|
2406
|
+
|
|
2407
|
+
let phase = "running";
|
|
2408
|
+
if (last && last.type === "turn_ended") {
|
|
2409
|
+
phase = last.status === "success" ? "done" : "failed";
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const promptText = first && first.role === "user" ? subagentEntryText(first) : "";
|
|
2413
|
+
const todoMatch = promptText ? SUBAGENT_TODO_ID_RE.exec(promptText) : null;
|
|
2414
|
+
const typeMatch = promptText ? SUBAGENT_WORKER_TYPE_RE.exec(promptText) : null;
|
|
2415
|
+
const rawTodo = todoMatch ? todoMatch[1] : null;
|
|
2416
|
+
const rawType = typeMatch ? typeMatch[1] : null;
|
|
2417
|
+
// The template writes literal placeholders when a field is unset; those are
|
|
2418
|
+
// not identities and must not reach a row.
|
|
2419
|
+
const placeholder = /^(?:<.*>|none|n\/a|-{1,2})$/i;
|
|
2420
|
+
return {
|
|
2421
|
+
id: runId,
|
|
2422
|
+
parentId: parentId ? String(parentId) : null,
|
|
2423
|
+
phase,
|
|
2424
|
+
todoId: rawTodo && !placeholder.test(rawTodo) ? rawTodo : null,
|
|
2425
|
+
workerType: rawType && !placeholder.test(rawType) ? rawType : null,
|
|
2426
|
+
modifiedAt: modifiedAt || null,
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Live Crew Monitor rows for Task subagent runs (start / still running /
|
|
2432
|
+
* complete / failed). Newest first; the caller passes an already-bounded list.
|
|
2433
|
+
*
|
|
2434
|
+
* Deliberately a distinct kind from `agent_step`: `agent_step` is derived from
|
|
2435
|
+
* plan to-do status, so a subagent that runs without flipping a to-do would be
|
|
2436
|
+
* invisible there and a to-do flipped by hand would be misattributed to a
|
|
2437
|
+
* worker. ADR: decisions/2026-07-27_crew-monitor-vs-plan-monitor-glossary.md.
|
|
2438
|
+
*
|
|
2439
|
+
* @param {object[]} runs - `parseSubagentRun` output
|
|
2440
|
+
* @param {{ limit?: number }} [opts]
|
|
2441
|
+
*/
|
|
2442
|
+
export function formatSubagentActivity(runs, { limit = MONITOR_SUBAGENT_EMIT_CAP } = {}) {
|
|
2443
|
+
const events = [];
|
|
2444
|
+
for (const run of runs || []) {
|
|
2445
|
+
if (events.length >= limit) break;
|
|
2446
|
+
if (!run || !run.id) continue;
|
|
2447
|
+
const kitAgent = normalizeKitAgentId(run.workerType);
|
|
2448
|
+
// Display actor is the dispatched worker type whenever the prompt declared
|
|
2449
|
+
// one: a built-in type such as `explore` is a real worker identity even
|
|
2450
|
+
// though it is not a `.cursor/agents/` id. `agent` stays kit-id-only so
|
|
2451
|
+
// downstream attribution is unchanged. `Dev` is the operator-lexicon mask
|
|
2452
|
+
// for Developer / Full-Stack Developer, used when no type was declared.
|
|
2453
|
+
const actor = run.workerType ? truncateStr(String(run.workerType), 24) : "Dev";
|
|
2454
|
+
const shortId = String(run.id).slice(0, 8);
|
|
2455
|
+
const subject = run.todoId || "task";
|
|
2456
|
+
const visible = `${actor} · ${run.phase} · ${subject} · ${shortId}`;
|
|
2457
|
+
events.push({
|
|
2458
|
+
id: activityId("subagent", [run.id, run.phase]),
|
|
2459
|
+
kind: "subagent",
|
|
2460
|
+
at: run.modifiedAt || null,
|
|
2461
|
+
agent: kitAgent,
|
|
2462
|
+
label: truncateStr(visible, MAX_SEMANTIC_LABEL),
|
|
2463
|
+
labelFull: visible,
|
|
2464
|
+
// Transcripts live outside the repo (under $HOME); no repo path to copy.
|
|
2465
|
+
sourcePath: null,
|
|
2466
|
+
refs: { subagent: run.id, parent: run.parentId || null, phase: run.phase, todo: run.todoId },
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
return events;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* Crew Monitor pointer rows for background mid-batch plan reviews.
|
|
2474
|
+
*
|
|
2475
|
+
* The operator cannot otherwise see that a review ran: `plan-monitor-*.md` lands
|
|
2476
|
+
* silently in `.cursor/memory/` and only surfaces once Flight Log / attention
|
|
2477
|
+
* picks it up. These rows say a review exists and whether it is still owed
|
|
2478
|
+
* triage. They are pointers only — Flight Log and the attention inbox keep sole
|
|
2479
|
+
* ownership of triage state and actions, and a row never marks anything
|
|
2480
|
+
* reviewed. Boundary amend recorded in the glossary ADR (2026-08-05).
|
|
2481
|
+
*
|
|
2482
|
+
* @param {object[]} reports - `parseExternalReport` output
|
|
2483
|
+
* @param {object[]} plans - plan records from the snapshot
|
|
2484
|
+
* @param {{ limit?: number }} [opts]
|
|
2485
|
+
*/
|
|
2486
|
+
export function formatPlanReviewActivity(
|
|
2487
|
+
reports,
|
|
2488
|
+
plans,
|
|
2489
|
+
{ limit = MONITOR_PLAN_REVIEW_EMIT_CAP } = {},
|
|
2490
|
+
) {
|
|
2491
|
+
const sorted = (reports || [])
|
|
2492
|
+
.filter((r) => r?.file)
|
|
2493
|
+
.slice()
|
|
2494
|
+
.sort((a, b) => String(b.modifiedAt || "").localeCompare(String(a.modifiedAt || "")));
|
|
2495
|
+
|
|
2496
|
+
const events = [];
|
|
2497
|
+
for (const report of sorted) {
|
|
2498
|
+
if (events.length >= limit) break;
|
|
2499
|
+
const triaged = isReportTriaged(report, plans);
|
|
2500
|
+
// `awaiting` reuses the existing gate verb: the review itself has landed,
|
|
2501
|
+
// what is outstanding is the operator's triage.
|
|
2502
|
+
const verb = triaged ? "done" : "awaiting";
|
|
2503
|
+
// `QA` is the operator-lexicon mask for QA Engineer.
|
|
2504
|
+
const planRef = report.reviewedPlanFile || `${report.slug}.plan.md`;
|
|
2505
|
+
const visible = `QA · ${verb} · review · ${planRef}`;
|
|
2506
|
+
events.push({
|
|
2507
|
+
id: activityId("plan_review", [report.file, triaged ? "triaged" : "open"]),
|
|
2508
|
+
kind: "plan_review",
|
|
2509
|
+
at: report.modifiedAt || null,
|
|
2510
|
+
agent: null,
|
|
2511
|
+
label: truncateStr(visible, MAX_SEMANTIC_LABEL),
|
|
2512
|
+
labelFull: visible,
|
|
2513
|
+
sourcePath: report.path || null,
|
|
2514
|
+
refs: { plan: report.reviewedPlanFile || null, report: report.file, triaged },
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
return events;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2319
2520
|
/**
|
|
2320
2521
|
* Explicit run-plan loop lines in terminal output. Shared detection for the
|
|
2321
2522
|
* Crew feed (formatTerminalRunEvidence) and the busy-outside-plan derivation
|
|
@@ -3562,6 +3763,7 @@ export function buildMissionControlView({
|
|
|
3562
3763
|
deferredCheckIds = [],
|
|
3563
3764
|
agentPrompts = [],
|
|
3564
3765
|
externalReports = [],
|
|
3766
|
+
subagentRuns = [],
|
|
3565
3767
|
dismissedIds = [],
|
|
3566
3768
|
archivedPlanFiles = [],
|
|
3567
3769
|
agents = [],
|
|
@@ -3615,7 +3817,11 @@ export function buildMissionControlView({
|
|
|
3615
3817
|
const activity = mergeActivity([
|
|
3616
3818
|
planEvents.filter((e) => e.kind === "run_plan" || e.kind === "handoff"),
|
|
3617
3819
|
planEvents.filter((e) => e.kind === "agent_step"),
|
|
3820
|
+
// Live Task-worker lifecycle before delivery: a running subagent is the
|
|
3821
|
+
// freshest thing on the board and must not be starved by MAX_ACTIVITY.
|
|
3822
|
+
formatSubagentActivity(subagentRuns),
|
|
3618
3823
|
deliveryEvents,
|
|
3824
|
+
formatPlanReviewActivity(externalReports, plans),
|
|
3619
3825
|
planEvents.filter((e) => e.kind === "plan_progress"),
|
|
3620
3826
|
formatGitActivity(gitLogLines, { excludeShas: supersededShas }),
|
|
3621
3827
|
formatTerminalRunEvidence(terminals),
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="robots" content="noindex,nofollow" />
|
|
7
|
+
<title>Mission Control share</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: dark light;
|
|
11
|
+
--bg: #0f1419;
|
|
12
|
+
--fg: #e7ecf1;
|
|
13
|
+
--muted: #8b9aab;
|
|
14
|
+
--accent: #3b82f6;
|
|
15
|
+
--err: #f87171;
|
|
16
|
+
--card: #1a222c;
|
|
17
|
+
}
|
|
18
|
+
@media (prefers-color-scheme: light) {
|
|
19
|
+
:root {
|
|
20
|
+
--bg: #f4f6f8;
|
|
21
|
+
--fg: #12202e;
|
|
22
|
+
--muted: #5b6b7c;
|
|
23
|
+
--card: #ffffff;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
* {
|
|
27
|
+
box-sizing: border-box;
|
|
28
|
+
}
|
|
29
|
+
body {
|
|
30
|
+
margin: 0;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
|
33
|
+
background: var(--bg);
|
|
34
|
+
color: var(--fg);
|
|
35
|
+
display: grid;
|
|
36
|
+
place-items: center;
|
|
37
|
+
padding: 1.5rem;
|
|
38
|
+
}
|
|
39
|
+
main {
|
|
40
|
+
width: min(28rem, 100%);
|
|
41
|
+
background: var(--card);
|
|
42
|
+
border-radius: 12px;
|
|
43
|
+
padding: 1.5rem 1.35rem;
|
|
44
|
+
box-shadow: 0 12px 40px rgb(0 0 0 / 18%);
|
|
45
|
+
}
|
|
46
|
+
h1 {
|
|
47
|
+
font-size: 1.15rem;
|
|
48
|
+
margin: 0 0 0.5rem;
|
|
49
|
+
font-weight: 650;
|
|
50
|
+
}
|
|
51
|
+
p {
|
|
52
|
+
margin: 0 0 1rem;
|
|
53
|
+
color: var(--muted);
|
|
54
|
+
font-size: 0.95rem;
|
|
55
|
+
line-height: 1.45;
|
|
56
|
+
}
|
|
57
|
+
.err {
|
|
58
|
+
color: var(--err);
|
|
59
|
+
}
|
|
60
|
+
a.btn,
|
|
61
|
+
button.btn {
|
|
62
|
+
display: inline-flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
justify-content: center;
|
|
65
|
+
width: 100%;
|
|
66
|
+
border: 0;
|
|
67
|
+
border-radius: 8px;
|
|
68
|
+
padding: 0.75rem 1rem;
|
|
69
|
+
font: inherit;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
text-decoration: none;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
background: var(--accent);
|
|
74
|
+
color: #fff;
|
|
75
|
+
}
|
|
76
|
+
a.btn[hidden],
|
|
77
|
+
button.btn[hidden] {
|
|
78
|
+
display: none;
|
|
79
|
+
}
|
|
80
|
+
.note {
|
|
81
|
+
margin-top: 1rem;
|
|
82
|
+
font-size: 0.8rem;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<main>
|
|
88
|
+
<h1>Mission Control</h1>
|
|
89
|
+
<p id="status">Opening your local Mission Control…</p>
|
|
90
|
+
<a id="open" class="btn" hidden href="#">Open Mission Control</a>
|
|
91
|
+
<p class="note">
|
|
92
|
+
Cosmetic share link only. You must be on the same trusted LAN (or VPN) as the host.
|
|
93
|
+
Treat the full Share URL as a secret (it embeds the session token in the fragment).
|
|
94
|
+
Only private/loopback LAN targets are accepted.
|
|
95
|
+
</p>
|
|
96
|
+
</main>
|
|
97
|
+
<script>
|
|
98
|
+
(function () {
|
|
99
|
+
var statusEl = document.getElementById("status");
|
|
100
|
+
var openEl = document.getElementById("open");
|
|
101
|
+
|
|
102
|
+
function fail(msg) {
|
|
103
|
+
statusEl.textContent = msg;
|
|
104
|
+
statusEl.className = "err";
|
|
105
|
+
openEl.hidden = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPrivateOrLoopbackHostname(hostname) {
|
|
109
|
+
var host = String(hostname || "")
|
|
110
|
+
.trim()
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/^\[|\]$/g, "");
|
|
113
|
+
if (!host) return false;
|
|
114
|
+
if (host === "localhost" || host.slice(-10) === ".localhost" || host.slice(-6) === ".local") {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
|
|
118
|
+
var m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
119
|
+
if (m) {
|
|
120
|
+
var a = +m[1];
|
|
121
|
+
var b = +m[2];
|
|
122
|
+
var c = +m[3];
|
|
123
|
+
var d = +m[4];
|
|
124
|
+
if ([a, b, c, d].some(function (n) {
|
|
125
|
+
return !Number.isInteger(n) || n < 0 || n > 255;
|
|
126
|
+
})) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (a === 127) return true;
|
|
130
|
+
if (a === 10) return true;
|
|
131
|
+
if (a === 192 && b === 168) return true;
|
|
132
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
133
|
+
if (a === 169 && b === 254) return true;
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if (host.indexOf(":") !== -1) {
|
|
137
|
+
if (host.slice(0, 2) === "fc" || host.slice(0, 2) === "fd") return true;
|
|
138
|
+
if (/^fe[89ab]/.test(host)) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateTarget(url) {
|
|
144
|
+
var raw = typeof url === "string" ? url.trim() : "";
|
|
145
|
+
if (!raw) return { ok: false, error: "invalid-target" };
|
|
146
|
+
var parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = new URL(raw);
|
|
149
|
+
} catch (_) {
|
|
150
|
+
return { ok: false, error: "invalid-target" };
|
|
151
|
+
}
|
|
152
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
153
|
+
return { ok: false, error: "invalid-target" };
|
|
154
|
+
}
|
|
155
|
+
if (!isPrivateOrLoopbackHostname(parsed.hostname)) {
|
|
156
|
+
return { ok: false, error: "non-private-target" };
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, url: raw };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function decodeFragment(fragment) {
|
|
162
|
+
var raw = String(fragment || "").replace(/^#/, "").trim();
|
|
163
|
+
if (!raw) return { ok: false, error: "missing-fragment" };
|
|
164
|
+
var m = /^v1\.([A-Za-z0-9_-]+)$/.exec(raw);
|
|
165
|
+
if (!m) return { ok: false, error: "unsupported-version" };
|
|
166
|
+
try {
|
|
167
|
+
var b64 = m[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
168
|
+
while (b64.length % 4) b64 += "=";
|
|
169
|
+
var json = atob(b64);
|
|
170
|
+
var parsed = JSON.parse(json);
|
|
171
|
+
if (!parsed || parsed.v !== 1 || typeof parsed.u !== "string") {
|
|
172
|
+
return { ok: false, error: "invalid-payload" };
|
|
173
|
+
}
|
|
174
|
+
var target = validateTarget(parsed.u.trim());
|
|
175
|
+
if (!target.ok) return target;
|
|
176
|
+
if (parsed.e != null) {
|
|
177
|
+
var e = Number(parsed.e);
|
|
178
|
+
if (!isFinite(e)) return { ok: false, error: "invalid-expiry" };
|
|
179
|
+
if (Math.floor(Date.now() / 1000) > e) return { ok: false, error: "expired" };
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, url: target.url };
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return { ok: false, error: "invalid-payload" };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
var decoded = decodeFragment(location.hash);
|
|
188
|
+
if (!decoded.ok) {
|
|
189
|
+
var map = {
|
|
190
|
+
"missing-fragment": "This share link is incomplete. Ask the operator to re-run dashboard-broadcast.",
|
|
191
|
+
"unsupported-version": "This share link uses an unsupported format.",
|
|
192
|
+
"invalid-payload": "This share link could not be decoded.",
|
|
193
|
+
"invalid-target": "This share link has an invalid Mission Control URL.",
|
|
194
|
+
"non-private-target": "This share link targets a non-private host and was blocked.",
|
|
195
|
+
expired: "This share link expired. Ask the operator for a fresh link.",
|
|
196
|
+
};
|
|
197
|
+
fail(map[decoded.error] || "Could not open Mission Control.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
openEl.href = decoded.url;
|
|
202
|
+
openEl.hidden = false;
|
|
203
|
+
statusEl.textContent = "Ready. Continue to Mission Control on your LAN.";
|
|
204
|
+
// Auto-navigate only for validated private/loopback targets; button remains if blocked.
|
|
205
|
+
try {
|
|
206
|
+
location.replace(decoded.url);
|
|
207
|
+
} catch (_) {
|
|
208
|
+
/* keep button */
|
|
209
|
+
}
|
|
210
|
+
})();
|
|
211
|
+
</script>
|
|
212
|
+
</body>
|
|
213
|
+
</html>
|
package/dashboard/serve.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync
|
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
11
|
import { dirname, extname, join } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { shareShellTokenRequired } from "./lib/broadcast-share.mjs";
|
|
13
14
|
import {
|
|
14
15
|
DEFAULT_HOST,
|
|
15
16
|
REPO_ROOT_ENV,
|
|
@@ -423,8 +424,10 @@ const server = createServer((req, res) => {
|
|
|
423
424
|
return;
|
|
424
425
|
}
|
|
425
426
|
|
|
427
|
+
// Cosmetic share resolver shell (fragment holds LAN+token client-side).
|
|
428
|
+
// ADR: 2026-08-11_mission-control-broadcast-url-mask.md
|
|
426
429
|
const auth = authorizeMissionControlRequest(req, url, {
|
|
427
|
-
tokenRequired: TOKEN_REQUIRED,
|
|
430
|
+
tokenRequired: shareShellTokenRequired(TOKEN_REQUIRED, req.method || "GET", path),
|
|
428
431
|
expectedToken: AUTH_TOKEN,
|
|
429
432
|
});
|
|
430
433
|
if (!auth.ok) {
|
|
@@ -496,7 +499,8 @@ const server = createServer((req, res) => {
|
|
|
496
499
|
return;
|
|
497
500
|
}
|
|
498
501
|
|
|
499
|
-
const
|
|
502
|
+
const staticLookup = path === "/open" ? "/open.html" : path;
|
|
503
|
+
const staticPath = resolveStaticPath(staticLookup);
|
|
500
504
|
if (!staticPath) {
|
|
501
505
|
res.writeHead(404);
|
|
502
506
|
res.end("Not found");
|
|
@@ -9,9 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
11
11
|
import { existsSync, openSync } from "node:fs";
|
|
12
|
-
import { platform } from "node:os";
|
|
13
12
|
import { dirname, join } from "node:path";
|
|
14
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
+
import {
|
|
15
|
+
buildBroadcastShareUrl,
|
|
16
|
+
resolveShareBase,
|
|
17
|
+
resolveShareShowLan,
|
|
18
|
+
resolveShareTtlSec,
|
|
19
|
+
} from "./lib/broadcast-share.mjs";
|
|
15
20
|
import {
|
|
16
21
|
BROADCAST_TOKEN_ENV,
|
|
17
22
|
escapePerlDoubleQuoted,
|
|
@@ -21,10 +26,15 @@ import {
|
|
|
21
26
|
listLanIPv4Addresses,
|
|
22
27
|
normalizeAuthToken,
|
|
23
28
|
resolveBindHost,
|
|
29
|
+
resolveContextConfigPath,
|
|
30
|
+
resolveSnapshotRepoRoot,
|
|
24
31
|
} from "./lib/guards.mjs";
|
|
32
|
+
import { openBrowser, readPreferredBrowserFromConfig } from "./lib/open-browser.mjs";
|
|
25
33
|
|
|
26
34
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
-
const
|
|
35
|
+
const KIT_ROOT = join(__dirname, "..");
|
|
36
|
+
/** Workspace snapshots / preference config. Defaults to KIT_ROOT. */
|
|
37
|
+
const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT);
|
|
28
38
|
const SERVE = join(__dirname, "serve.mjs");
|
|
29
39
|
const LOG = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control-broadcast.log";
|
|
30
40
|
const PORT = Number.parseInt(process.env.PORT || "3333", 10);
|
|
@@ -97,7 +107,7 @@ function detachStart(env) {
|
|
|
97
107
|
if (hasSetsid()) {
|
|
98
108
|
const out = openSync(LOG, "a");
|
|
99
109
|
const child = spawn("setsid", ["node", SERVE], {
|
|
100
|
-
cwd:
|
|
110
|
+
cwd: KIT_ROOT,
|
|
101
111
|
detached: true,
|
|
102
112
|
stdio: ["ignore", out, out],
|
|
103
113
|
env,
|
|
@@ -107,7 +117,7 @@ function detachStart(env) {
|
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
// Escape @/$ so scoped package paths (node_modules/@scope/...) survive Perl qq.
|
|
110
|
-
const rootEsc = escapePerlDoubleQuoted(
|
|
120
|
+
const rootEsc = escapePerlDoubleQuoted(KIT_ROOT);
|
|
111
121
|
const serveEsc = escapePerlDoubleQuoted(SERVE);
|
|
112
122
|
const logEsc = escapePerlDoubleQuoted(LOG);
|
|
113
123
|
const hostEsc = escapePerlDoubleQuoted(String(env.HOST));
|
|
@@ -129,7 +139,7 @@ function detachStart(env) {
|
|
|
129
139
|
].join(" ");
|
|
130
140
|
|
|
131
141
|
const child = spawn("perl", ["-e", perl], {
|
|
132
|
-
cwd:
|
|
142
|
+
cwd: KIT_ROOT,
|
|
133
143
|
detached: true,
|
|
134
144
|
stdio: "ignore",
|
|
135
145
|
env,
|
|
@@ -148,24 +158,6 @@ async function waitReady(urls) {
|
|
|
148
158
|
return null;
|
|
149
159
|
}
|
|
150
160
|
|
|
151
|
-
function openBrowser(url) {
|
|
152
|
-
const os = platform();
|
|
153
|
-
try {
|
|
154
|
-
if (os === "darwin") {
|
|
155
|
-
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
158
|
-
if (os === "win32") {
|
|
159
|
-
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
160
|
-
return true;
|
|
161
|
-
}
|
|
162
|
-
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
163
|
-
return true;
|
|
164
|
-
} catch {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
161
|
async function main() {
|
|
170
162
|
const { env, host, token } = resolveBroadcastEnv();
|
|
171
163
|
if (isLoopbackBindHost(host)) {
|
|
@@ -203,14 +195,43 @@ async function main() {
|
|
|
203
195
|
console.log(`Mission Control broadcast already listening on port ${PORT}`);
|
|
204
196
|
}
|
|
205
197
|
|
|
198
|
+
const shareBase = resolveShareBase(process.env);
|
|
199
|
+
let shareUrl = null;
|
|
200
|
+
if (shareBase != null) {
|
|
201
|
+
try {
|
|
202
|
+
shareUrl = buildBroadcastShareUrl(displayUrl, {
|
|
203
|
+
base: shareBase,
|
|
204
|
+
ttlSec: resolveShareTtlSec(process.env),
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// Non-RFC1918 primary LAN (Tailscale 100.64/10, public/DMZ) cannot encode into
|
|
208
|
+
// the share fragment allowlist — degrade to LAN/token print instead of exit 1.
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
console.warn(`Share URL skipped (${msg}). Printing LAN/token only.`);
|
|
211
|
+
shareUrl = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const showLan = resolveShareShowLan(process.env);
|
|
215
|
+
const openTarget = shareUrl || displayUrl;
|
|
216
|
+
|
|
206
217
|
console.log("");
|
|
207
218
|
console.log(" Mission Control (LAN broadcast)");
|
|
208
219
|
console.log(` Bind: ${host}:${PORT}`);
|
|
220
|
+
if (shareUrl) {
|
|
221
|
+
console.log(` Share: ${shareUrl}`);
|
|
222
|
+
}
|
|
209
223
|
console.log(` Token: ${token}`);
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
if (showLan || !shareUrl) {
|
|
225
|
+
for (const ip of listLanIPv4Addresses()) {
|
|
226
|
+
console.log(` LAN: http://${ip}:${PORT}/?token=${encodeURIComponent(token)}`);
|
|
227
|
+
}
|
|
228
|
+
console.log(` Local: http://127.0.0.1:${PORT}/?token=${encodeURIComponent(token)}`);
|
|
229
|
+
}
|
|
230
|
+
if (shareUrl) {
|
|
231
|
+
console.log(
|
|
232
|
+
" Share is a cosmetic Mission Kit (or BYO) link; phone must still reach this LAN.",
|
|
233
|
+
);
|
|
212
234
|
}
|
|
213
|
-
console.log(` Local: http://127.0.0.1:${PORT}/?token=${encodeURIComponent(token)}`);
|
|
214
235
|
console.log(" Config writes stay loopback-only. Stop: kill the LISTEN pid on this port.");
|
|
215
236
|
console.log(" Firewall: allow inbound TCP on this port for your LAN profile if needed.");
|
|
216
237
|
console.log("");
|
|
@@ -218,10 +239,32 @@ async function main() {
|
|
|
218
239
|
if (process.env.MISSION_CONTROL_NO_OPEN === "1") {
|
|
219
240
|
return;
|
|
220
241
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
242
|
+
let configValue = null;
|
|
243
|
+
const cfg = resolveContextConfigPath(ROOT, { existsSync });
|
|
244
|
+
if (cfg.ok) {
|
|
245
|
+
configValue = readPreferredBrowserFromConfig(cfg.path);
|
|
246
|
+
}
|
|
247
|
+
const result = openBrowser(openTarget, { configValue });
|
|
248
|
+
if (result.opened) {
|
|
249
|
+
if (result.reason === "preferred-fallback") {
|
|
250
|
+
console.log(
|
|
251
|
+
shareUrl
|
|
252
|
+
? "Preferred browser failed; opened share URL with the OS default."
|
|
253
|
+
: "Preferred browser failed; opened primary URL with the OS default.",
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
console.log(
|
|
257
|
+
shareUrl
|
|
258
|
+
? "Opened share URL in the preferred browser (or OS default)."
|
|
259
|
+
: "Opened primary URL in the preferred browser (or OS default).",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
} else if (result.reason !== "no-open") {
|
|
263
|
+
console.log(
|
|
264
|
+
shareUrl
|
|
265
|
+
? "Open the Share URL above on your phone/tablet browser."
|
|
266
|
+
: "Open a LAN URL above on your phone/tablet browser.",
|
|
267
|
+
);
|
|
225
268
|
}
|
|
226
269
|
}
|
|
227
270
|
|