@dadado/agent-kit-cli 5.15.1 → 5.16.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.
Files changed (71) hide show
  1. package/dashboard/dashboard-data.mjs +69 -65
  2. package/dashboard/lib/guards.d.mts +5 -0
  3. package/dashboard/lib/guards.mjs +31 -0
  4. package/dashboard/lib/identity-probe.mjs +56 -0
  5. package/dashboard/lib/pipeline-cache.mjs +58 -0
  6. package/dashboard/lib/processes.mjs +71 -0
  7. package/dashboard/serve.mjs +43 -1
  8. package/dashboard/start-broadcast.mjs +2 -12
  9. package/dashboard/start.mjs +2 -13
  10. package/dist/add-7UMSGVWC.js +150 -0
  11. package/dist/chunk-2IPXQFJE.js +253 -0
  12. package/dist/chunk-2YU2H7I6.js +42 -0
  13. package/dist/chunk-4IWG5CVE.js +214 -0
  14. package/dist/chunk-5OXPNVCF.js +106 -0
  15. package/dist/chunk-5VCOWTTP.js +6 -0
  16. package/dist/chunk-6QV2BFGA.js +341 -0
  17. package/dist/chunk-77V75A7O.js +39 -0
  18. package/dist/chunk-7HDQEK6T.js +26 -0
  19. package/dist/chunk-CBCW5R5J.js +484 -0
  20. package/dist/chunk-CGTE7DHN.js +410 -0
  21. package/dist/chunk-DE3LQFSJ.js +18 -0
  22. package/dist/chunk-F6C3GFKV.js +57 -0
  23. package/dist/chunk-HFUH3HVS.js +282 -0
  24. package/dist/chunk-HS7LVVLO.js +74 -0
  25. package/dist/chunk-IHROBTNM.js +135 -0
  26. package/dist/chunk-KD2MYBB6.js +213 -0
  27. package/dist/chunk-KI6ZHBPV.js +332 -0
  28. package/dist/chunk-MAPOQEA2.js +43 -0
  29. package/dist/chunk-NQ7IQ5XT.js +26 -0
  30. package/dist/chunk-OUQZC744.js +1315 -0
  31. package/dist/chunk-OZ4QV6LM.js +1078 -0
  32. package/dist/chunk-PCCQUWE5.js +817 -0
  33. package/dist/chunk-PNFF6OU3.js +273 -0
  34. package/dist/chunk-QTON2GQX.js +203 -0
  35. package/dist/chunk-TFZ2JUXH.js +1081 -0
  36. package/dist/chunk-VGZBARQC.js +318 -0
  37. package/dist/chunk-VS2JD2XC.js +1453 -0
  38. package/dist/chunk-VY7MTPJ2.js +205 -0
  39. package/dist/chunk-WW3YZKHW.js +165 -0
  40. package/dist/chunk-XWPO3VYQ.js +53 -0
  41. package/dist/chunk-XZZAVHH4.js +503 -0
  42. package/dist/chunk-YGBBHKBE.js +82 -0
  43. package/dist/chunk-ZB43YMHW.js +20 -0
  44. package/dist/chunk-ZCTSUQRR.js +874 -0
  45. package/dist/chunk-ZXKZBTXE.js +17 -0
  46. package/dist/contribute-YKK6YCSI.js +367 -0
  47. package/dist/cursor-awareness-KTL2RGKD.js +77 -0
  48. package/dist/dashboard-YSNUGBFY.js +21 -0
  49. package/dist/dashboard-broadcast-M4CMW7RX.js +119 -0
  50. package/dist/diff-YTR7VOAN.js +74 -0
  51. package/dist/doctor-TA2FWREU.js +497 -0
  52. package/dist/dogfood-KKFVRWT6.js +366 -0
  53. package/dist/guard-EHXF76ID.js +530 -0
  54. package/dist/handoff-3UI6VKVG.js +302 -0
  55. package/dist/hook-MUV6Y7UW.js +1074 -0
  56. package/dist/index.js +62 -14068
  57. package/dist/init-TTTKGC4C.js +127 -0
  58. package/dist/install-ZMFJBPOI.js +34 -0
  59. package/dist/mission-control-LJACEVVN.js +89 -0
  60. package/dist/monitors-QV2IS3MP.js +176 -0
  61. package/dist/plan-index-JMC7Z4DW.js +44 -0
  62. package/dist/public-inbound-radar-WP3XF7GS.js +68 -0
  63. package/dist/remote-issues-SZ4HDQL5.js +44 -0
  64. package/dist/run-I3BFGULT.js +243 -0
  65. package/dist/run-plan-FO7CL4M3.js +118 -0
  66. package/dist/scan-NI2SWJ2E.js +33 -0
  67. package/dist/setup-global-JUVMJXJ5.js +340 -0
  68. package/dist/status-MVCUNCWX.js +138 -0
  69. package/dist/update-Q4EBIRUB.js +260 -0
  70. package/dist/validate-V2CW7J4B.js +201 -0
  71. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  // Scans .cursor/plans, HANDOFF, memory, config, git status, terminals, processes
5
5
  // Outputs JSON to stdout (consumed by dashboard.html)
6
6
 
7
- import { execFileSync, execSync } from "node:child_process";
7
+ import { execFileSync } from "node:child_process";
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join, resolve } from "node:path";
10
10
  import {
@@ -14,6 +14,9 @@ import {
14
14
  resolveSnapshotRepoRoot,
15
15
  truncateStr,
16
16
  } from "./lib/guards.mjs";
17
+ import { projectSlugFromRoot } from "./lib/live-refresh.mjs";
18
+ import { PIPELINE_CACHE_ENV, readCachedPipeline } from "./lib/pipeline-cache.mjs";
19
+ import { selectWorkspaceProcesses } from "./lib/processes.mjs";
17
20
  import {
18
21
  EXTERNAL_REPORT_FILE_RE,
19
22
  FIELD_REPORT_CADENCE_LEDGER_REL,
@@ -46,6 +49,8 @@ import { MAX_TERMINAL_BYTES, buildTerminalSnapshotFields } from "./lib/terminal-
46
49
  const KIT_ROOT = resolve(import.meta.dirname, "..");
47
50
  /** Snapshot root: consumer workspace when MISSION_CONTROL_REPO_ROOT is set, else kit tree. */
48
51
  const ROOT = resolveSnapshotRepoRoot(process.env, KIT_ROOT);
52
+ /** serve.mjs next to this script; its process is in scope even when ROOT is a consumer tree. */
53
+ const SERVE_PATH = resolve(import.meta.dirname, "serve.mjs");
49
54
  const MAX_TERMINALS = 20;
50
55
  const MAX_PROCESSES = 25;
51
56
  const MAX_GIT_GRAPH_LINES = 25;
@@ -140,7 +145,7 @@ const SNAPSHOT = {
140
145
  "Memory records: error count, decision count, recent decisions, recent parsed errors, error-o-meter stats",
141
146
  git: "Git repository state: branch, dirty status, commit, ahead/behind, bounded files[], promotion flow vs staging/main, graph lines, staging hygiene",
142
147
  devops:
143
- "Best-effort DevOps signal: pipeline ({available, runs[], reason?} recent gh run list rows; reason is 'budget' when the shared snapshot budget ran out before gh was attempted, or 'call-failed' when gh was attempted and missing/unauthenticated/timed out/errored; reason is only present when available is false) and deploy ({tags[], changelog} v* git tags + latest CHANGELOG release entry as a 'what shipped recently' proxy, not a live infra poll)",
148
+ "Best-effort DevOps signal: pipeline ({available, runs[], reason?} recent gh run list rows; reason is 'budget' when the shared snapshot budget ran out before gh was attempted, or 'call-failed' when gh was attempted and missing/unauthenticated/timed out/errored; reason is only present when available is false; fetchedAt is the ISO time gh was last attempted, reused from serve.mjs for up to 60s) and deploy ({tags[], changelog} v* git tags + latest CHANGELOG release entry as a 'what shipped recently' proxy, not a live infra poll)",
144
149
  terminals:
145
150
  "Active Cursor terminal sessions with metadata, output line count, and capped lastOutput",
146
151
  processes:
@@ -473,29 +478,19 @@ if (existsSync(memoryDecisionsDir)) {
473
478
 
474
479
  // 6. Git
475
480
  try {
476
- const gitOpts = { cwd: ROOT, encoding: "utf-8", timeout: 5000 };
477
- const branch = execSync("git rev-parse --abbrev-ref HEAD", gitOpts).trim();
481
+ const gitOpts = {
482
+ cwd: ROOT,
483
+ encoding: "utf-8",
484
+ timeout: 5000,
485
+ stdio: ["ignore", "pipe", "ignore"],
486
+ };
487
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], gitOpts).trim();
478
488
  // Only strip the trailing newline(s) here — `git status --short` uses
479
489
  // positional XY status columns, so an unstaged-only row starts with a
480
490
  // literal leading space (e.g. " M path"). A full-string .trim() eats that
481
491
  // leading space on line 1 only, shifting parseGitStatusShort's staged /
482
492
  // unstaged read for the first row.
483
- const status = execSync("git status --short", gitOpts).replace(/\n+$/, "");
484
- const lastCommit = execSync("git log -1 --oneline", gitOpts).trim();
485
- let ahead = 0;
486
- let behind = 0;
487
- try {
488
- ahead =
489
- Number.parseInt(execSync("git rev-list --count origin/main..HEAD", gitOpts).trim(), 10) || 0;
490
- } catch {
491
- /* no upstream */
492
- }
493
- try {
494
- behind =
495
- Number.parseInt(execSync("git rev-list --count HEAD..origin/main", gitOpts).trim(), 10) || 0;
496
- } catch {
497
- /* no upstream */
498
- }
493
+ const status = execFileSync("git", ["status", "--short"], gitOpts).replace(/\n+$/, "");
499
494
 
500
495
  const parsed = parseGitStatusShort(status);
501
496
  let recentLog = [];
@@ -528,6 +523,10 @@ try {
528
523
  vsMain: countDivergence("origin/main...HEAD"),
529
524
  stagingVsMain: countDivergence("origin/main...origin/staging"),
530
525
  };
526
+ // Derived from the calls above instead of three more git spawns.
527
+ const lastCommit = recentLog[0] ?? "";
528
+ const ahead = flow.vsMain?.ahead ?? 0;
529
+ const behind = flow.vsMain?.behind ?? 0;
531
530
 
532
531
  // Readable graph (branch lanes + merges) as pre-rendered text lines.
533
532
  let graphLines = [];
@@ -589,6 +588,11 @@ function collectPipelineRuns() {
589
588
  // above; the budget reserve must cover its own timeout, not just a nominal
590
589
  // buffer (see .cursor/memory/errors/2026-07-26_mission-control-dashboard-data-timeout.md
591
590
  // for why this file's soft-budget guards exist).
591
+ // serve.mjs hands back the last attempted result; reuse it while fresh so a
592
+ // snapshot every few seconds does not re-run the network call. Checked
593
+ // before the budget guard so a fresh cache never reads as "budget".
594
+ const cached = readCachedPipeline(process.env[PIPELINE_CACHE_ENV], Date.now());
595
+ if (cached) return cached;
592
596
  if (!withinSnapshotBudget(GH_RUN_LIST_TIMEOUT_MS + 400)) {
593
597
  // `gh` was never even attempted this snapshot — the shared budget ran out
594
598
  // first. Distinguished from a failed/timed-out call below so the UI does
@@ -597,6 +601,8 @@ function collectPipelineRuns() {
597
601
  // and the residual note this fix landed from) to an auth/install problem.
598
602
  return { available: false, runs: [], reason: "budget" };
599
603
  }
604
+ // fetchedAt marks an attempted call (cacheable); the budget miss above has none.
605
+ const fetchedAt = new Date().toISOString();
600
606
  try {
601
607
  // Trimmed to the fields renderDevopsPipelineCard() / navDevopsDot actually
602
608
  // read (workflow, status, conclusion, branch, event, createdAt). databaseId
@@ -616,7 +622,9 @@ function collectPipelineRuns() {
616
622
  { cwd: ROOT, encoding: "utf-8", timeout: GH_RUN_LIST_TIMEOUT_MS },
617
623
  );
618
624
  const rows = JSON.parse(out);
619
- if (!Array.isArray(rows)) return { available: false, runs: [], reason: "call-failed" };
625
+ if (!Array.isArray(rows)) {
626
+ return { available: false, runs: [], reason: "call-failed", fetchedAt };
627
+ }
620
628
  const runs = rows.slice(0, MAX_PIPELINE_RUNS).map((r) => ({
621
629
  workflow: truncateStr(String(r.workflowName || ""), MAX_PIPELINE_NAME_CHARS),
622
630
  status: String(r.status || ""),
@@ -625,14 +633,14 @@ function collectPipelineRuns() {
625
633
  event: String(r.event || ""),
626
634
  createdAt: r.createdAt || null,
627
635
  }));
628
- return { available: true, runs };
636
+ return { available: true, runs, fetchedAt };
629
637
  } catch {
630
- // gh missing, unauthenticated, its own execSync timeout, or non-repo cwd
638
+ // gh missing, unauthenticated, its own execFileSync timeout, or non-repo cwd
631
639
  // all read the same to the caller: no pipeline signal for this snapshot.
632
640
  // Not further split (e.g. timeout vs auth) — that would need parsing the
633
- // execSync error shape, which is more taxonomy than the empty-state copy
641
+ // execFileSync error shape, which is more taxonomy than the empty-state copy
634
642
  // needs; "call-failed" is enough to stop the UI from guessing "auth".
635
- return { available: false, runs: [], reason: "call-failed" };
643
+ return { available: false, runs: [], reason: "call-failed", fetchedAt };
636
644
  }
637
645
  }
638
646
 
@@ -668,7 +676,12 @@ function parseLatestChangelogEntryForDeploySignal(changelog) {
668
676
 
669
677
  function collectDeploySignal() {
670
678
  if (!withinSnapshotBudget(400)) return { tags: [], changelog: null };
671
- const gitOpts = { cwd: ROOT, encoding: "utf-8", timeout: 5000 };
679
+ const gitOpts = {
680
+ cwd: ROOT,
681
+ encoding: "utf-8",
682
+ timeout: 5000,
683
+ stdio: ["ignore", "pipe", "ignore"],
684
+ };
672
685
  let tags = [];
673
686
  try {
674
687
  // v*-only: an archive/* or other non-release tag under "what shipped
@@ -719,7 +732,7 @@ function collectDeploySignal() {
719
732
  // 7. Terminals (read from Cursor terminal files)
720
733
  const terminalsDir = resolve(process.env.HOME || "~", ".cursor", "projects");
721
734
  // Derive project path from ROOT rather than hardcoding a specific slug
722
- const projectSlug = ROOT.replace(/\//g, "-").replace(/^-/, "");
735
+ const projectSlug = projectSlugFromRoot(ROOT);
723
736
  const terminalProjectPath = join(terminalsDir, projectSlug, "terminals");
724
737
 
725
738
  if (existsSync(terminalProjectPath)) {
@@ -831,38 +844,23 @@ try {
831
844
  if (!withinSnapshotBudget(500)) {
832
845
  SNAPSHOT.processes = [];
833
846
  } else {
834
- const psOutput = execSync("ps -axo pid=,pcpu=,pmem=,etime=,command=", {
847
+ const psOutput = execFileSync("ps", ["-axo", "pid=,pcpu=,pmem=,etime=,command="], {
835
848
  encoding: "utf-8",
836
849
  timeout: 3000,
837
850
  }).trim();
838
851
  if (psOutput) {
839
- const interesting = [];
840
- for (const line of psOutput.split("\n")) {
841
- const trimmed = line.trim();
842
- if (!trimmed) continue;
843
- if (!/node|git|serve\.mjs|dashboard/i.test(trimmed)) continue;
844
- if (/grep|dashboard-data/.test(trimmed)) continue;
845
- const parts = trimmed.split(/\s+/);
846
- const pid = parts[0];
847
- const cpu = parts[1];
848
- const mem = parts[2];
849
- const etime = parts[3];
850
- const cmd = parts.slice(4).join(" ") || "unknown";
851
- let label = "other";
852
- if (cmd.includes("serve.mjs") || cmd.includes("node dashboard")) label = "dashboard-server";
853
- else if (/\bgit\b/.test(cmd)) label = "git";
854
- else if (cmd.includes("node")) label = "node";
855
- interesting.push({
856
- pid,
857
- cpu,
858
- mem,
859
- etime,
860
- command: truncateStr(cmd, MAX_STRING.processCommand),
861
- label,
862
- description: describeProcess({ label, command: cmd, cpu, etime }),
863
- });
864
- if (interesting.length >= MAX_PROCESSES) break;
865
- }
852
+ // Workspace-scoped + redacted (lib/processes.mjs): never ship host-wide argv.
853
+ const interesting = selectWorkspaceProcesses(psOutput, {
854
+ root: ROOT,
855
+ servePath: SERVE_PATH,
856
+ redact: redactTerminalOutput,
857
+ truncate: truncateStr,
858
+ maxCommandChars: MAX_STRING.processCommand,
859
+ maxProcesses: MAX_PROCESSES,
860
+ }).map(({ fullCommand: cmd, ...row }) => {
861
+ const { label, cpu, etime } = row;
862
+ return { ...row, description: describeProcess({ label, command: cmd, cpu, etime }) };
863
+ });
866
864
  SNAPSHOT.processes = interesting;
867
865
  }
868
866
  }
@@ -881,8 +879,9 @@ try {
881
879
  const auditAges = [];
882
880
  const nowEpoch = Math.floor(Date.now() / 1000);
883
881
  try {
884
- const tmuxOut = execSync(
885
- "tmux list-sessions -F '#{session_name} #{session_attached} #{session_created}'",
882
+ const tmuxOut = execFileSync(
883
+ "tmux",
884
+ ["list-sessions", "-F", "#{session_name} #{session_attached} #{session_created}"],
886
885
  { encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] },
887
886
  );
888
887
  for (const line of tmuxOut.split("\n")) {
@@ -897,12 +896,17 @@ try {
897
896
  // tmux missing or no server: fail-open
898
897
  }
899
898
  try {
900
- // `screen -ls` exits 1 while successfully listing, so soften the exit code.
901
- const screenOut = execSync("screen -ls || true", {
902
- encoding: "utf-8",
903
- timeout: 2000,
904
- stdio: ["ignore", "pipe", "ignore"],
905
- });
899
+ // `screen -ls` exits 1 while successfully listing, so keep stdout from the error.
900
+ let screenOut = "";
901
+ try {
902
+ screenOut = execFileSync("screen", ["-ls"], {
903
+ encoding: "utf-8",
904
+ timeout: 2000,
905
+ stdio: ["ignore", "pipe", "ignore"],
906
+ });
907
+ } catch (err) {
908
+ screenOut = typeof err?.stdout === "string" ? err.stdout : "";
909
+ }
906
910
  // The "N Sockets in <dir>." line trails the session list; socket mtime ~ start time.
907
911
  let sockdir = null;
908
912
  for (const line of screenOut.split("\n")) {
@@ -984,7 +988,7 @@ SNAPSHOT.health.status = checks.every((c) => c.ok)
984
988
  function collectAgentPrompts() {
985
989
  if (!withinSnapshotBudget(800)) return [];
986
990
  const projectsDir = resolve(process.env.HOME || "~", ".cursor", "projects");
987
- const slug = ROOT.replace(/\//g, "-").replace(/^-/, "");
991
+ const slug = projectSlugFromRoot(ROOT);
988
992
  const transcriptsDir = join(projectsDir, slug, "agent-transcripts");
989
993
  if (!existsSync(transcriptsDir)) return [];
990
994
 
@@ -1123,7 +1127,7 @@ function collectExternalReports() {
1123
1127
  function collectSubagentRuns() {
1124
1128
  if (!withinSnapshotBudget(700)) return [];
1125
1129
  const projectsDir = resolve(process.env.HOME || "~", ".cursor", "projects");
1126
- const slug = ROOT.replace(/\//g, "-").replace(/^-/, "");
1130
+ const slug = projectSlugFromRoot(ROOT);
1127
1131
  const transcriptsDir = join(projectsDir, slug, "agent-transcripts");
1128
1132
  if (!existsSync(transcriptsDir)) return [];
1129
1133
 
@@ -129,6 +129,11 @@ export function mergeConfigAllowlist(
129
129
  ): Record<string, unknown>;
130
130
  export function allowlistConfig(raw: unknown): Record<string, unknown>;
131
131
  export function isAllowedOrigin(origin: unknown, port: unknown): boolean;
132
+ export function isAllowedHostHeader(
133
+ hostHeader: unknown,
134
+ port: unknown,
135
+ opts?: { broadcast?: boolean; lanAddresses?: string[]; bindHost?: string },
136
+ ): boolean;
132
137
  export function applyCorsHeaders(
133
138
  req: GuardRequest,
134
139
  res: { setHeader: (name: string, value: string) => unknown },
@@ -1072,6 +1072,37 @@ export function isAllowedOrigin(origin, port) {
1072
1072
  }
1073
1073
  }
1074
1074
 
1075
+ /**
1076
+ * DNS-rebinding guard: accept only a Host header naming this server by a
1077
+ * loopback literal (`localhost`, `127.0.0.1`, `[::1]`) on `port`. In broadcast
1078
+ * mode a LAN IPv4 literal of this machine (or the IPv4 bind host) on `port` is
1079
+ * accepted too. A hostname that merely resolves here (rebinding) never is.
1080
+ * @param {unknown} hostHeader
1081
+ * @param {unknown} port
1082
+ * @param {{ broadcast?: boolean, lanAddresses?: string[], bindHost?: string }} [opts]
1083
+ */
1084
+ export function isAllowedHostHeader(hostHeader, port, opts = {}) {
1085
+ if (!hostHeader || typeof hostHeader !== "string") return false;
1086
+ let url;
1087
+ try {
1088
+ url = new URL(`http://${hostHeader.trim()}`);
1089
+ } catch {
1090
+ return false;
1091
+ }
1092
+ if (url.username || url.password || url.pathname !== "/") return false;
1093
+ const resolvedPort = url.port || "80";
1094
+ if (resolvedPort !== String(port)) return false;
1095
+ const name = url.hostname.toLowerCase();
1096
+ if (name === "localhost" || name === "127.0.0.1" || name === "[::1]") return true;
1097
+ if (!opts.broadcast) return false;
1098
+ // The operator-chosen bind host (not a wildcard) names this server by definition.
1099
+ const bind = typeof opts.bindHost === "string" ? opts.bindHost.trim().toLowerCase() : "";
1100
+ if (bind && bind !== "0.0.0.0" && bind !== "::" && bind === name) return true;
1101
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(name)) return false;
1102
+ const lan = opts.lanAddresses ?? listLanIPv4Addresses();
1103
+ return lan.includes(name);
1104
+ }
1105
+
1075
1106
  export function applyCorsHeaders(req, res, port) {
1076
1107
  const origin = req.headers?.origin;
1077
1108
  if (isAllowedOrigin(origin, port)) {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Ask a running Mission Control listener which workspace it serves.
3
+ *
4
+ * `/api/identity` answers without building a snapshot. A listener started by an
5
+ * older kit has no such route (HTTP error or a non-JSON page), so fall back once
6
+ * to the legacy `/dashboard-data.json` probe; otherwise an update would treat
7
+ * its own old server as a foreign process. A timeout never falls back.
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { resolve } from "node:path";
11
+
12
+ const CURL_HTTP_ERROR = 22; // curl -f: server answered with status >= 400
13
+
14
+ /** @param {unknown} root */
15
+ function normalizeRoot(root) {
16
+ return typeof root === "string" && root.trim() ? resolve(root.trim()) : null;
17
+ }
18
+
19
+ /**
20
+ * @param {string} baseUrl e.g. `http://127.0.0.1:4242`
21
+ * @param {string} [query] e.g. `?token=…` (already encoded)
22
+ * @param {{ run?: typeof execFileSync }} [deps]
23
+ * @returns {string | null}
24
+ */
25
+ export function probeListenerRoot(baseUrl, query = "", deps = {}) {
26
+ const run = deps.run ?? execFileSync;
27
+ let raw;
28
+ try {
29
+ raw = run("curl", ["-sf", `${baseUrl}/api/identity${query}`], {
30
+ encoding: "utf8",
31
+ timeout: 2000,
32
+ });
33
+ } catch (err) {
34
+ if (/** @type {{ status?: number }} */ (err)?.status !== CURL_HTTP_ERROR) return null;
35
+ return legacyRoot(run, baseUrl, query);
36
+ }
37
+ try {
38
+ return normalizeRoot(JSON.parse(raw)?.repoRoot);
39
+ } catch {
40
+ return legacyRoot(run, baseUrl, query);
41
+ }
42
+ }
43
+
44
+ /** @param {typeof execFileSync} run @param {string} baseUrl @param {string} query */
45
+ function legacyRoot(run, baseUrl, query) {
46
+ try {
47
+ const raw = run("curl", ["-sf", `${baseUrl}/dashboard-data.json${query}`], {
48
+ encoding: "utf8",
49
+ timeout: 8000,
50
+ maxBuffer: 10 * 1024 * 1024,
51
+ });
52
+ return normalizeRoot(JSON.parse(raw)?.system?.repoRoot);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Cross-snapshot cache for the DevOps pipeline signal (`gh run list`).
3
+ *
4
+ * Mirrors the AGENT_KIT_PREV_INVENTORY hand-off: serve.mjs keeps the last
5
+ * `devops.pipeline` it saw and passes it back via AGENT_KIT_PREV_PIPELINE, so
6
+ * dashboard-data.mjs skips the network call while that result is fresh.
7
+ * Only results carrying `fetchedAt` (gh was actually attempted) are cacheable;
8
+ * a `reason: "budget"` miss never sticks.
9
+ */
10
+
11
+ export const PIPELINE_CACHE_ENV = "AGENT_KIT_PREV_PIPELINE";
12
+ export const PIPELINE_CACHE_TTL_MS = 60_000;
13
+
14
+ /**
15
+ * @param {unknown} pipeline
16
+ * @returns {boolean}
17
+ */
18
+ function isCacheablePipeline(pipeline) {
19
+ return (
20
+ !!pipeline &&
21
+ typeof pipeline === "object" &&
22
+ !Array.isArray(pipeline) &&
23
+ typeof pipeline.available === "boolean" &&
24
+ Array.isArray(pipeline.runs) &&
25
+ typeof pipeline.fetchedAt === "string" &&
26
+ Number.isFinite(Date.parse(pipeline.fetchedAt))
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Cached pipeline from the env blob when younger than `ttlMs`, else null.
32
+ * @param {string | undefined} raw
33
+ * @param {number} nowMs
34
+ * @param {number} [ttlMs]
35
+ * @returns {object | null}
36
+ */
37
+ export function readCachedPipeline(raw, nowMs, ttlMs = PIPELINE_CACHE_TTL_MS) {
38
+ if (!raw || typeof raw !== "string") return null;
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ } catch {
43
+ return null;
44
+ }
45
+ if (!isCacheablePipeline(parsed)) return null;
46
+ const age = nowMs - Date.parse(parsed.fetchedAt);
47
+ return age >= 0 && age < ttlMs ? parsed : null;
48
+ }
49
+
50
+ /**
51
+ * Env blob for the next snapshot from a parsed snapshot, or null to keep the prior one.
52
+ * @param {unknown} snapshot
53
+ * @returns {string | null}
54
+ */
55
+ export function pipelineCacheJsonFromSnapshot(snapshot) {
56
+ const pipeline = snapshot?.devops?.pipeline;
57
+ return isCacheablePipeline(pipeline) ? JSON.stringify(pipeline) : null;
58
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Process-list scoping for Mission Control snapshots.
3
+ *
4
+ * The snapshot ships command lines to every viewer (LAN in broadcast mode), so
5
+ * the list is scoped to this workspace: only commands that mention the
6
+ * snapshot root or the serve.mjs path survive, and each surviving command runs
7
+ * through the caller's secret redactor before truncation.
8
+ */
9
+
10
+ /**
11
+ * True when `cmd` names `needle` as a whole path: the next character must end the
12
+ * path (`/`, whitespace, quote, `:` or end), so `/x/app` does not match `/x/app-other`.
13
+ */
14
+ function mentionsPath(cmd, needle) {
15
+ let from = 0;
16
+ for (;;) {
17
+ const at = cmd.indexOf(needle, from);
18
+ if (at === -1) return false;
19
+ const next = cmd[at + needle.length];
20
+ if (next === undefined || /[\/\s"':]/.test(next)) return true;
21
+ from = at + 1;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Parse `ps -axo pid=,pcpu=,pmem=,etime=,command=` output into scoped rows.
27
+ * Pure: no process spawning, no fs. `command` is redacted then truncated; the
28
+ * returned `fullCommand` is redacted but untruncated (for local narration only).
29
+ *
30
+ * @param {string} psOutput
31
+ * @param {{
32
+ * root: string,
33
+ * servePath: string,
34
+ * redact: (text: string) => string,
35
+ * truncate: (text: string, max: number) => string,
36
+ * maxCommandChars: number,
37
+ * maxProcesses: number,
38
+ * }} opts
39
+ * @returns {Array<{ pid: string, cpu: string, mem: string, etime: string, command: string, fullCommand: string, label: string }>}
40
+ */
41
+ export function selectWorkspaceProcesses(psOutput, opts) {
42
+ const { root, servePath, redact, truncate, maxCommandChars, maxProcesses } = opts;
43
+ const needles = [root, servePath].filter((s) => typeof s === "string" && s.length > 1);
44
+ const rows = [];
45
+ if (needles.length === 0) return rows;
46
+ for (const line of String(psOutput ?? "").split("\n")) {
47
+ const trimmed = line.trim();
48
+ if (!trimmed) continue;
49
+ const parts = trimmed.split(/\s+/);
50
+ const rawCmd = parts.slice(4).join(" ");
51
+ if (!rawCmd) continue;
52
+ if (!needles.some((n) => mentionsPath(rawCmd, n))) continue;
53
+ if (/grep|dashboard-data/.test(rawCmd)) continue;
54
+ const cmd = redact(rawCmd);
55
+ let label = "other";
56
+ if (cmd.includes("serve.mjs") || cmd.includes("node dashboard")) label = "dashboard-server";
57
+ else if (/\bgit\b/.test(cmd)) label = "git";
58
+ else if (cmd.includes("node")) label = "node";
59
+ rows.push({
60
+ pid: parts[0],
61
+ cpu: parts[1],
62
+ mem: parts[2],
63
+ etime: parts[3],
64
+ command: truncate(cmd, maxCommandChars),
65
+ fullCommand: cmd,
66
+ label,
67
+ });
68
+ if (rows.length >= maxProcesses) break;
69
+ }
70
+ return rows;
71
+ }
@@ -18,6 +18,7 @@ import {
18
18
  applyCorsHeaders,
19
19
  authorizeMissionControlRequest,
20
20
  broadcastAuthCookieHeader,
21
+ isAllowedHostHeader,
21
22
  isAllowedOrigin,
22
23
  isLoopbackAddress,
23
24
  listLanIPv4Addresses,
@@ -35,6 +36,7 @@ import {
35
36
  resolveAgentTranscriptsWatchPath,
36
37
  resolveWatchPaths,
37
38
  } from "./lib/live-refresh.mjs";
39
+ import { PIPELINE_CACHE_ENV, pipelineCacheJsonFromSnapshot } from "./lib/pipeline-cache.mjs";
38
40
  import { buildInventoryBaseline } from "./lib/semantic-model.mjs";
39
41
 
40
42
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -53,6 +55,10 @@ const PORT = Number.parseInt(process.env.PORT || "3333", 10);
53
55
  const TOKEN_REQUIRED = authResolved.tokenRequired;
54
56
  const AUTH_TOKEN = authResolved.token;
55
57
  const DASHBOARD_REAL = realpathSync(DASHBOARD_DIR);
58
+ // Resolved once at startup: the Host guard runs on every request, and an
59
+ // os.networkInterfaces() walk per request is wasted work. Restart to pick up
60
+ // a new LAN address (Wi-Fi rejoin).
61
+ const LAN_ADDRESSES = authResolved.broadcast ? listLanIPv4Addresses() : [];
56
62
 
57
63
  const MIME = {
58
64
  ".html": "text/html; charset=utf-8",
@@ -69,6 +75,8 @@ const dataScript = join(__dirname, "dashboard-data.mjs");
69
75
 
70
76
  /** Compact inventory baseline fed back into the next data-script run via env. */
71
77
  let previousInventoryJson = null;
78
+ /** Last attempted devops.pipeline (with fetchedAt); the data script reuses it while fresh. */
79
+ let previousPipelineJson = null;
72
80
 
73
81
  /**
74
82
  * @param {string} payload
@@ -91,6 +99,19 @@ function inventoryBaselineJsonFromPayload(payload) {
91
99
  }
92
100
  }
93
101
 
102
+ /**
103
+ * @param {string} payload
104
+ * @returns {string|null}
105
+ */
106
+ function pipelineCacheJsonFromPayload(payload) {
107
+ try {
108
+ const data = JSON.parse(payload);
109
+ return data && !data.error ? pipelineCacheJsonFromSnapshot(data) : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
94
115
  function setCorsHeaders(req, res) {
95
116
  applyCorsHeaders(req, res, PORT);
96
117
  }
@@ -262,6 +283,7 @@ function runDataScript() {
262
283
  [REPO_ROOT_ENV]: ROOT,
263
284
  // Empty string = cold start (dashboard-data treats falsy as no previous).
264
285
  AGENT_KIT_PREV_INVENTORY: previousInventoryJson || "",
286
+ [PIPELINE_CACHE_ENV]: previousPipelineJson || "",
265
287
  };
266
288
  execFile(
267
289
  process.execPath,
@@ -284,6 +306,8 @@ function runDataScript() {
284
306
  }
285
307
  const baseline = inventoryBaselineJsonFromPayload(stdout);
286
308
  if (baseline) previousInventoryJson = baseline;
309
+ const pipelineCache = pipelineCacheJsonFromPayload(stdout);
310
+ if (pipelineCache) previousPipelineJson = pipelineCache;
287
311
  resolve(stdout);
288
312
  },
289
313
  );
@@ -400,6 +424,18 @@ const server = createServer((req, res) => {
400
424
  const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
401
425
  const path = url.pathname;
402
426
 
427
+ // DNS-rebinding guard: refuse foreign Host headers before any route runs.
428
+ if (
429
+ !isAllowedHostHeader(req.headers?.host, PORT, {
430
+ broadcast: authResolved.broadcast,
431
+ bindHost: HOST,
432
+ lanAddresses: LAN_ADDRESSES,
433
+ })
434
+ ) {
435
+ sendJson(res, 421, { error: "misdirected request: host not allowed" });
436
+ return;
437
+ }
438
+
403
439
  setCorsHeaders(req, res);
404
440
 
405
441
  if (path === "/api/config" && req.method === "OPTIONS") {
@@ -438,6 +474,12 @@ const server = createServer((req, res) => {
438
474
  res.setHeader("Set-Cookie", broadcastAuthCookieHeader(AUTH_TOKEN));
439
475
  }
440
476
 
477
+ // Cheap identity probe for start scripts: no snapshot work, same Host/auth gates.
478
+ if (path === "/api/identity" && req.method === "GET") {
479
+ sendJson(res, 200, { repoRoot: ROOT, tokenGated: TOKEN_REQUIRED });
480
+ return;
481
+ }
482
+
441
483
  // SSE endpoint
442
484
  if (path === "/api/events") {
443
485
  res.writeHead(200, {
@@ -530,7 +572,7 @@ server.listen(PORT, HOST, () => {
530
572
  console.log(` Events: ${url}/api/events (SSE)`);
531
573
  console.log(` Config: ${url}/api/config (PUT/PATCH, loopback)`);
532
574
  } else {
533
- const lan = listLanIPv4Addresses();
575
+ const lan = LAN_ADDRESSES;
534
576
  const tokenQ = AUTH_TOKEN ? `?token=${encodeURIComponent(AUTH_TOKEN)}` : "";
535
577
  console.log(` Bind: ${HOST}:${PORT} (token-gated)`);
536
578
  console.log(` Local: http://127.0.0.1:${PORT}/${tokenQ}`);
@@ -39,6 +39,7 @@ import {
39
39
  resolveContextConfigPath,
40
40
  resolveSnapshotRepoRoot,
41
41
  } from "./lib/guards.mjs";
42
+ import { probeListenerRoot } from "./lib/identity-probe.mjs";
42
43
  import { openBrowser, readPreferredBrowserFromConfig } from "./lib/open-browser.mjs";
43
44
 
44
45
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -99,18 +100,7 @@ function probeHttp(url) {
99
100
 
100
101
  /** Snapshot root reported by a listener that accepts our token, else null. */
101
102
  function snapshotRootAt(port, token) {
102
- const url = `http://127.0.0.1:${port}/dashboard-data.json?token=${encodeURIComponent(token)}`;
103
- try {
104
- const raw = execFileSync("curl", ["-sf", url], {
105
- encoding: "utf8",
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;
111
- } catch {
112
- return null;
113
- }
103
+ return probeListenerRoot(`http://127.0.0.1:${port}`, `?token=${encodeURIComponent(token)}`);
114
104
  }
115
105
 
116
106
  /**
@@ -29,6 +29,7 @@ import {
29
29
  resolveSnapshotRepoRoot,
30
30
  sameRepoRoot,
31
31
  } from "./lib/guards.mjs";
32
+ import { probeListenerRoot } from "./lib/identity-probe.mjs";
32
33
  import { openBrowser, readPreferredBrowserFromConfig } from "./lib/open-browser.mjs";
33
34
 
34
35
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -83,19 +84,7 @@ function listeningPids(port = PORT) {
83
84
 
84
85
  /** @returns {string | null} */
85
86
  function runningSnapshotRoot(port = PORT) {
86
- const dataUrl = `http://${DISPLAY_HOST}:${port}/dashboard-data.json`;
87
- try {
88
- const raw = execFileSync("curl", ["-sf", dataUrl], {
89
- encoding: "utf8",
90
- timeout: 8000,
91
- maxBuffer: 10 * 1024 * 1024,
92
- });
93
- const data = JSON.parse(raw);
94
- const root = data?.system?.repoRoot;
95
- return typeof root === "string" && root.trim() ? resolve(root.trim()) : null;
96
- } catch {
97
- return null;
98
- }
87
+ return probeListenerRoot(`http://${DISPLAY_HOST}:${port}`);
99
88
  }
100
89
 
101
90
  /**