@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.
@@ -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). plan_progress milestones stay on Activity / Checklist.
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 shipped \u00b7 ${brief} \u00b7 ${prBit} \u00b7 ${entry.sha}`
2157
- : `${actor} \u00b7 shipped \u00b7 ${prBit} \u00b7 ${entry.sha}`;
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 Engineering Manager for delivery, else Squad when a plan
2177
- * is present (never the full plan filename), else Platform Engineer.
2178
- * Default software lexicon display masks (resolution kinds unchanged).
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 "Engineering Manager";
2186
- if (plan) return "Squad";
2187
- return "Platform Engineer";
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>
@@ -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 staticPath = resolveStaticPath(path);
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 ROOT = join(__dirname, "..");
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: ROOT,
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(ROOT);
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: ROOT,
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
- for (const ip of listLanIPv4Addresses()) {
211
- console.log(` LAN: http://${ip}:${PORT}/?token=${encodeURIComponent(token)}`);
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
- if (openBrowser(displayUrl)) {
222
- console.log("Opened primary URL in the default browser.");
223
- } else {
224
- console.log("Open a LAN URL above on your phone/tablet browser.");
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