@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.
- package/dashboard/dashboard-data.mjs +69 -65
- package/dashboard/lib/guards.d.mts +5 -0
- package/dashboard/lib/guards.mjs +31 -0
- package/dashboard/lib/identity-probe.mjs +56 -0
- package/dashboard/lib/pipeline-cache.mjs +58 -0
- package/dashboard/lib/processes.mjs +71 -0
- package/dashboard/serve.mjs +43 -1
- package/dashboard/start-broadcast.mjs +2 -12
- package/dashboard/start.mjs +2 -13
- package/dist/add-7UMSGVWC.js +150 -0
- package/dist/chunk-2IPXQFJE.js +253 -0
- package/dist/chunk-2YU2H7I6.js +42 -0
- package/dist/chunk-4IWG5CVE.js +214 -0
- package/dist/chunk-5OXPNVCF.js +106 -0
- package/dist/chunk-5VCOWTTP.js +6 -0
- package/dist/chunk-6QV2BFGA.js +341 -0
- package/dist/chunk-77V75A7O.js +39 -0
- package/dist/chunk-7HDQEK6T.js +26 -0
- package/dist/chunk-CBCW5R5J.js +484 -0
- package/dist/chunk-CGTE7DHN.js +410 -0
- package/dist/chunk-DE3LQFSJ.js +18 -0
- package/dist/chunk-F6C3GFKV.js +57 -0
- package/dist/chunk-HFUH3HVS.js +282 -0
- package/dist/chunk-HS7LVVLO.js +74 -0
- package/dist/chunk-IHROBTNM.js +135 -0
- package/dist/chunk-KD2MYBB6.js +213 -0
- package/dist/chunk-KI6ZHBPV.js +332 -0
- package/dist/chunk-MAPOQEA2.js +43 -0
- package/dist/chunk-NQ7IQ5XT.js +26 -0
- package/dist/chunk-OUQZC744.js +1315 -0
- package/dist/chunk-OZ4QV6LM.js +1078 -0
- package/dist/chunk-PCCQUWE5.js +817 -0
- package/dist/chunk-PNFF6OU3.js +273 -0
- package/dist/chunk-QTON2GQX.js +203 -0
- package/dist/chunk-TFZ2JUXH.js +1081 -0
- package/dist/chunk-VGZBARQC.js +318 -0
- package/dist/chunk-VS2JD2XC.js +1453 -0
- package/dist/chunk-VY7MTPJ2.js +205 -0
- package/dist/chunk-WW3YZKHW.js +165 -0
- package/dist/chunk-XWPO3VYQ.js +53 -0
- package/dist/chunk-XZZAVHH4.js +503 -0
- package/dist/chunk-YGBBHKBE.js +82 -0
- package/dist/chunk-ZB43YMHW.js +20 -0
- package/dist/chunk-ZCTSUQRR.js +874 -0
- package/dist/chunk-ZXKZBTXE.js +17 -0
- package/dist/contribute-YKK6YCSI.js +367 -0
- package/dist/cursor-awareness-KTL2RGKD.js +77 -0
- package/dist/dashboard-YSNUGBFY.js +21 -0
- package/dist/dashboard-broadcast-M4CMW7RX.js +119 -0
- package/dist/diff-YTR7VOAN.js +74 -0
- package/dist/doctor-TA2FWREU.js +497 -0
- package/dist/dogfood-KKFVRWT6.js +366 -0
- package/dist/guard-EHXF76ID.js +530 -0
- package/dist/handoff-3UI6VKVG.js +302 -0
- package/dist/hook-MUV6Y7UW.js +1074 -0
- package/dist/index.js +62 -14068
- package/dist/init-TTTKGC4C.js +127 -0
- package/dist/install-ZMFJBPOI.js +34 -0
- package/dist/mission-control-LJACEVVN.js +89 -0
- package/dist/monitors-QV2IS3MP.js +176 -0
- package/dist/plan-index-JMC7Z4DW.js +44 -0
- package/dist/public-inbound-radar-WP3XF7GS.js +68 -0
- package/dist/remote-issues-SZ4HDQL5.js +44 -0
- package/dist/run-I3BFGULT.js +243 -0
- package/dist/run-plan-FO7CL4M3.js +118 -0
- package/dist/scan-NI2SWJ2E.js +33 -0
- package/dist/setup-global-JUVMJXJ5.js +340 -0
- package/dist/status-MVCUNCWX.js +138 -0
- package/dist/update-Q4EBIRUB.js +260 -0
- package/dist/validate-V2CW7J4B.js +201 -0
- 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
|
|
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 = {
|
|
477
|
-
|
|
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 =
|
|
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))
|
|
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
|
|
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
|
-
//
|
|
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 = {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
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 =
|
|
885
|
-
"tmux
|
|
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
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
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
|
|
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 },
|
package/dashboard/lib/guards.mjs
CHANGED
|
@@ -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
|
+
}
|
package/dashboard/serve.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
/**
|
package/dashboard/start.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|