@gleapai/kai-bridge 0.2.9 → 0.6.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/src/deps.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // JS dependencies for fresh session worktrees.
2
+ //
3
+ // A worktree starts without `node_modules`, and installing them cost ~2
4
+ // minutes per repo per session (the Gleap dashboard: 1975 packages) —
5
+ // paid before the agent could run a single test, and again by the preview
6
+ // boot. The primary checkout on the same machine almost always has a
7
+ // `node_modules` built from the SAME lockfile, so we clone it: APFS
8
+ // `clonefile` (cp -c) on macOS and reflinks on Linux make that a few
9
+ // seconds and share the blocks until a file is written. When the lockfile
10
+ // differs (a dependency bump on the base branch the primary hasn't pulled
11
+ // yet) we leave the directory absent and the install path takes over with
12
+ // the lockfile-driven, cache-first command instead of a bare `npm install`.
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
17
+ import { platform } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ /** Lockfiles we recognise, in lookup order, with the package manager they imply. */
21
+ export const LOCKFILES = [
22
+ { name: "package-lock.json", pm: "npm" },
23
+ { name: "npm-shrinkwrap.json", pm: "npm" },
24
+ { name: "pnpm-lock.yaml", pm: "pnpm" },
25
+ { name: "yarn.lock", pm: "yarn" },
26
+ { name: "bun.lockb", pm: "bun" },
27
+ { name: "bun.lock", pm: "bun" },
28
+ ];
29
+
30
+ /** `{ name, pm, hash }` of the first lockfile in `dir`, or null when there is none. */
31
+ export function lockfileFingerprint(dir) {
32
+ for (const { name, pm } of LOCKFILES) {
33
+ const p = join(dir, name);
34
+ if (!existsSync(p)) continue;
35
+ try {
36
+ return { name, pm, hash: createHash("sha256").update(readFileSync(p)).digest("hex") };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * The install command for a service dir when no `node_modules` could be
46
+ * seeded. Lockfile-driven and cache-first where the package manager
47
+ * supports it: `npm ci --prefer-offline` skips the resolution pass that
48
+ * made `npm install` slow on a cold tree, and falls back to `npm install`
49
+ * when the lockfile and package.json disagree (npm ci refuses that).
50
+ */
51
+ export function installCommandFor(dir) {
52
+ const lock = lockfileFingerprint(dir);
53
+ switch (lock?.pm) {
54
+ case "npm":
55
+ return "npm ci --prefer-offline --no-audit --no-fund || npm install --no-audit --no-fund";
56
+ case "pnpm":
57
+ return "pnpm install --frozen-lockfile --prefer-offline";
58
+ case "yarn":
59
+ return "yarn install";
60
+ case "bun":
61
+ return "bun install --frozen-lockfile";
62
+ default:
63
+ return "npm install --no-audit --no-fund";
64
+ }
65
+ }
66
+
67
+ /** Whether `cwd` can take a copy of `primaryPath`'s node_modules; `reason` explains a no. */
68
+ export function canSeedNodeModules({ primaryPath, cwd }) {
69
+ if (!primaryPath || !cwd || primaryPath === cwd) return { ok: false, reason: "same_checkout" };
70
+ if (!existsSync(join(cwd, "package.json"))) return { ok: false, reason: "no_package_json" };
71
+ if (existsSync(join(cwd, "node_modules"))) return { ok: false, reason: "already_present" };
72
+ const src = join(primaryPath, "node_modules");
73
+ let srcStat = null;
74
+ try {
75
+ srcStat = statSync(src);
76
+ } catch {
77
+ /* absent */
78
+ }
79
+ if (!srcStat?.isDirectory()) return { ok: false, reason: "primary_has_none" };
80
+ const a = lockfileFingerprint(primaryPath);
81
+ const b = lockfileFingerprint(cwd);
82
+ if (!a || !b) return { ok: false, reason: "no_lockfile" };
83
+ if (a.name !== b.name || a.hash !== b.hash) return { ok: false, reason: "lockfile_differs" };
84
+ return { ok: true, reason: "lockfile_match", pm: b.pm };
85
+ }
86
+
87
+ /**
88
+ * Copy the primary checkout's `node_modules` into a fresh worktree when
89
+ * the lockfiles match. Returns `{ seeded, reason, ms }`; never throws — a
90
+ * failed or partial copy is removed so the install path still runs.
91
+ *
92
+ * macOS `cp -c` clones via clonefile(2) and falls back to a plain copy on
93
+ * filesystems without it; `-R` without `-L` keeps symlinks as symlinks
94
+ * (`.bin` shims, pnpm's store links — all relative, so they stay valid).
95
+ */
96
+ export function seedNodeModules({ primaryPath, cwd, os = platform(), exec = execFileSync }) {
97
+ const started = Date.now();
98
+ const check = canSeedNodeModules({ primaryPath, cwd });
99
+ if (!check.ok) return { seeded: false, reason: check.reason, ms: 0 };
100
+ const src = join(primaryPath, "node_modules");
101
+ const dst = join(cwd, "node_modules");
102
+ try {
103
+ if (os === "darwin") exec("cp", ["-c", "-R", src, dst], { stdio: "ignore" });
104
+ else if (os === "linux") exec("cp", ["-R", "--reflink=auto", src, dst], { stdio: "ignore" });
105
+ else return { seeded: false, reason: "unsupported_platform", ms: 0 };
106
+ return { seeded: true, reason: check.reason, ms: Date.now() - started };
107
+ } catch (err) {
108
+ // A half-copied tree would make the install path see `node_modules`
109
+ // and skip — never leave one behind.
110
+ rmSync(dst, { recursive: true, force: true });
111
+ return { seeded: false, reason: `copy_failed: ${err?.message || err}`, ms: Date.now() - started };
112
+ }
113
+ }
package/src/executor.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  import { spawn } from "node:child_process";
10
10
  import { copyFileSync, existsSync, mkdirSync } from "node:fs";
11
11
  import { createInterface } from "node:readline";
12
- import { dirname, join } from "node:path";
12
+ import { delimiter, dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
15
15
  import { ambientConfigDir, managedConfigDir } from "./profiles.mjs";
@@ -70,10 +70,22 @@ export function buildRunnerArgs(turn, workDir, profile) {
70
70
  * transcript is read from there too.
71
71
  * gleap-key → keys handed over in `turn.credentials`, isolated state dir.
72
72
  */
73
- export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME) {
73
+ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = null) {
74
74
  const env = { ...process.env, KAI_RUNNER_DEBUG: process.env.KAI_RUNNER_DEBUG || "0", KAI_PERSONA_DIR: join(dirname(RUNNER), "personas") };
75
+ // Host-only values for the runner's MCP bridges (the verify tool's
76
+ // request policy: KAI_VERIFY_*). Never part of the prompt.
77
+ for (const [k, v] of Object.entries(extraEnv || {})) if (v != null && v !== "") env[k] = String(v);
75
78
  // Never leak the Gleap device token into the harness.
76
79
  delete env.KAI_DEVICE_TOKEN;
80
+ // The daemon's own node first on PATH for the runner, the harness and
81
+ // every shell the agent opens. Under launchd PATH is frozen at install
82
+ // time and a stale /usr/local/bin/node (v20 here) beat the nvm node the
83
+ // daemon runs on: `npx vitest` died with ERR_REQUIRE_ESM and the agent
84
+ // spent its build turn hunting for a Node 24 (2026-09-08). Same fix the
85
+ // preview boot got in withDaemonNode.
86
+ const nodeDir = dirname(process.execPath);
87
+ const pathEntries = String(env.PATH || "").split(delimiter).filter(Boolean);
88
+ if (pathEntries[0] !== nodeDir) env.PATH = [nodeDir, ...pathEntries.filter((p) => p !== nodeDir)].join(delimiter);
77
89
  if (profile.kind === "gleap-key") {
78
90
  env.KAI_ACP_STATE_DIR = join(kaiHome, "state", "gleap-key");
79
91
  for (const [k, v] of Object.entries(turn.credentials || {})) env[k] = String(v);
@@ -124,10 +136,10 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME) {
124
136
  * `onSpawn(handle)` hands out `handle.control(obj)` for the runner's stdin
125
137
  * control channel (mid-turn steering).
126
138
  */
127
- export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onSpawn, signal, kaiHome = KAI_HOME }) {
139
+ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onSpawn, signal, kaiHome = KAI_HOME, extraEnv = null }) {
128
140
  return new Promise((resolve) => {
129
141
  const args = buildRunnerArgs(turn, workDir, profile);
130
- const env = buildRunnerEnv(turn, profile, kaiHome);
142
+ const env = buildRunnerEnv(turn, profile, kaiHome, extraEnv);
131
143
  // stdin is the runner's control channel (JSONL: steer / cancel) — see
132
144
  // startControlChannel in runner/acp-runner.mjs. Never closed from
133
145
  // here; the runner exits on its own when the turn ends.
package/src/ports.mjs ADDED
@@ -0,0 +1,134 @@
1
+ // Who owns a port? (kai-bridge 0.5.0)
2
+ //
3
+ // Local mode used to adopt ANY listener on a service's declared port as
4
+ // "your running dev server". A stale `node` from another project on :3000
5
+ // then became the preview and the tester clicked through the wrong app.
6
+ // Adoption now needs proof: the listening pid's working directory must be
7
+ // inside the repo root. `lsof` answers both questions on macOS/Linux
8
+ // (`-iTCP:<port> -sTCP:LISTEN -Fpc` → pid + command, `-p <pid> -d cwd -Fn`
9
+ // → cwd). Windows never adopts (no lsof) — the port is simply busy.
10
+ //
11
+ // The same tool counts ESTABLISHED connections on a port, which is what
12
+ // makes the preview's idle stop mean "nobody is connected" instead of "30
13
+ // minutes since boot".
14
+ //
15
+ // Parsers are pure; the lookups take an injectable `execFile` for tests.
16
+
17
+ import { execFile as nodeExecFile, execFileSync } from "node:child_process";
18
+ import { realpathSync } from "node:fs";
19
+ import { resolve, sep } from "node:path";
20
+
21
+ function run(execFile, cmd, args, { timeoutMs = 4_000 } = {}) {
22
+ return new Promise((resolveP) => {
23
+ try {
24
+ // lsof exits 1 when nothing matched — that is an answer, not a failure.
25
+ execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (_err, stdout) => resolveP(stdout ? String(stdout) : ""));
26
+ } catch {
27
+ resolveP("");
28
+ }
29
+ });
30
+ }
31
+
32
+ /** `lsof -Fpc` output → `[{ pid, command }]` (one entry per process). */
33
+ export function parseLsofListeners(output) {
34
+ const out = [];
35
+ let current = null;
36
+ for (const line of String(output || "").split(/\r?\n/)) {
37
+ if (!line) continue;
38
+ const tag = line[0];
39
+ const value = line.slice(1);
40
+ if (tag === "p") {
41
+ current = { pid: Number(value), command: null };
42
+ if (Number.isInteger(current.pid) && current.pid > 0) out.push(current);
43
+ else current = null;
44
+ } else if (tag === "c" && current) {
45
+ current.command = value;
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** `lsof -p <pid> -d cwd -Fn` output → the cwd path, or null. */
52
+ export function parseLsofCwd(output) {
53
+ let inCwd = false;
54
+ for (const line of String(output || "").split(/\r?\n/)) {
55
+ if (!line) continue;
56
+ if (line[0] === "f") inCwd = line.slice(1) === "cwd";
57
+ else if (line[0] === "n" && inCwd) return line.slice(1) || null;
58
+ }
59
+ // Without the `f` marker (some lsof builds), the first `n` line is the cwd.
60
+ const first = String(output || "")
61
+ .split(/\r?\n/)
62
+ .find((l) => l[0] === "n");
63
+ return first ? first.slice(1) || null : null;
64
+ }
65
+
66
+ /** Number of distinct `p` lines — how many processes matched. */
67
+ export function parseLsofCount(output) {
68
+ return parseLsofListeners(output).length;
69
+ }
70
+
71
+ /**
72
+ * `{ pid, command, cwd }` of the process listening on `port`, or null when
73
+ * nothing listens / the platform has no lsof. Only the first listener is
74
+ * described (a dev server is one process).
75
+ */
76
+ export async function describeListener(port, { execFile = nodeExecFile, platform = process.platform } = {}) {
77
+ if (platform === "win32" || !Number.isInteger(port)) return null;
78
+ const listeners = parseLsofListeners(await run(execFile, "lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"]));
79
+ if (listeners.length === 0) return null;
80
+ const { pid, command } = listeners[0];
81
+ const cwd = parseLsofCwd(await run(execFile, "lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]));
82
+ return { pid, command, cwd };
83
+ }
84
+
85
+ /**
86
+ * Pure: may the daemon adopt this listener as the repo's own dev server?
87
+ * Only when its cwd is the repo root or below it (symlinks resolved when
88
+ * `realpath` can). Windows never adopts.
89
+ */
90
+ export function canAdoptPort({ listener, repoRoot, platform = process.platform, realpath = defaultRealpath, gitCommonDir = defaultGitCommonDir } = {}) {
91
+ if (platform === "win32") return false;
92
+ if (!listener?.cwd || !repoRoot) return false;
93
+ const root = realpath(resolve(repoRoot));
94
+ const cwd = realpath(resolve(listener.cwd));
95
+ if (!root || !cwd) return false;
96
+ if (cwd === root || cwd.startsWith(root.endsWith(sep) ? root : root + sep)) return true;
97
+ // A dev server started from another worktree of the SAME repository is
98
+ // still ours (worktree-heavy workflows): both resolve to one common dir.
99
+ const ours = gitCommonDir(root);
100
+ return !!ours && ours === gitCommonDir(cwd);
101
+ }
102
+
103
+ function defaultGitCommonDir(dir) {
104
+ try {
105
+ const out = execFileSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }).trim();
106
+ return out ? defaultRealpath(out) : null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function defaultRealpath(p) {
113
+ try {
114
+ return realpathSync(p);
115
+ } catch {
116
+ return p;
117
+ }
118
+ }
119
+
120
+ /** ESTABLISHED TCP connections on `port` (0 when none, or on Windows). */
121
+ export async function establishedConnections(port, { execFile = nodeExecFile, platform = process.platform } = {}) {
122
+ if (platform === "win32" || !Number.isInteger(port)) return 0;
123
+ return parseLsofCount(await run(execFile, "lsof", ["-nP", `-iTCP:${port}`, "-sTCP:ESTABLISHED", "-Fp"]));
124
+ }
125
+
126
+ /** `port_busy` detail for the card: "used by node (pid 4242, ~/code/other-app)". */
127
+ export function describeBusyPort(port, listener, { home } = {}) {
128
+ const detail = { port };
129
+ if (listener?.pid) detail.pid = listener.pid;
130
+ if (listener?.command) detail.command = listener.command;
131
+ if (listener?.cwd) detail.cwd = home && listener.cwd.startsWith(home) ? `~${listener.cwd.slice(home.length)}` : listener.cwd;
132
+ const who = listener ? ` by ${listener.command || "another process"}${listener.pid ? ` (pid ${listener.pid}${detail.cwd ? `, ${detail.cwd}` : ""})` : ""}` : "";
133
+ return { detail, message: `Port ${port} is already in use${who}.` };
134
+ }
@@ -0,0 +1,283 @@
1
+ // Structured preview failures (kai-bridge 0.5.0).
2
+ //
3
+ // Every way a preview can fail to boot ends in ONE payload shape the
4
+ // dashboard turns into a diagnosis with a verb the user can take:
5
+ //
6
+ // { status: "error", urls: [], error, errorCode, errorKind, errorRepo?,
7
+ // errorService?, errorDetail?: { pid?, command?, cwd?, dependency?, port?,
8
+ // envKeys?, remote?, cloneCommand? }, hint?, note?, skipped? }
9
+ //
10
+ // `errorKind` routes the failure: `config` → the repo's setup is wrong
11
+ // (the team gets "Configure"); `machine` / `deps` → this device only
12
+ // (Retry). Log tails are classified BEFORE they are truncated, so a Mongo
13
+ // refusing connections on :27017 becomes `dependency_missing` with the
14
+ // dependency named instead of "web did not start — …".
15
+ //
16
+ // Pure module: no I/O beyond what callers inject (`exists`, `read`).
17
+
18
+ export const PREVIEW_ERROR_CODES = new Set([
19
+ "companion_missing",
20
+ "companion_clone_failed",
21
+ "companion_unconfigured",
22
+ "dependency_missing",
23
+ "port_busy",
24
+ "deps_failed",
25
+ "no_dev_config",
26
+ "service_crashed",
27
+ "env_missing",
28
+ "no_browser",
29
+ "idle_stopped",
30
+ "daemon_restarted",
31
+ "other",
32
+ ]);
33
+
34
+ /** Default routing per code (`config` flips the repo to failing; the rest stay per device). */
35
+ export const ERROR_KIND_BY_CODE = {
36
+ companion_missing: "machine",
37
+ companion_clone_failed: "machine",
38
+ companion_unconfigured: "config",
39
+ dependency_missing: "machine",
40
+ port_busy: "machine",
41
+ deps_failed: "deps",
42
+ no_dev_config: "config",
43
+ service_crashed: "machine",
44
+ env_missing: "machine",
45
+ no_browser: "machine",
46
+ idle_stopped: "machine",
47
+ daemon_restarted: "machine",
48
+ other: "machine",
49
+ };
50
+
51
+ const DEPENDENCY_BY_PORT = { 27017: "mongodb", 6379: "redis", 5432: "postgres", 3306: "mysql", 9200: "opensearch", 5672: "rabbitmq", 9092: "kafka" };
52
+
53
+ /** A preview failure the daemon can report without guessing: code + kind + detail travel with it. */
54
+ export class PreviewError extends Error {
55
+ constructor(message, { code = "other", kind, repo, service, detail, hint } = {}) {
56
+ super(message);
57
+ this.name = "PreviewError";
58
+ this.code = PREVIEW_ERROR_CODES.has(code) ? code : "other";
59
+ this.kind = kind ?? ERROR_KIND_BY_CODE[this.code] ?? "machine";
60
+ this.repo = repo ?? null;
61
+ this.service = service ?? null;
62
+ this.detail = detail && typeof detail === "object" ? detail : null;
63
+ this.hint = hint ?? null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Classify a service log (the whole tail, not the last three lines) into
69
+ * `{ code, line, detail }`; null when nothing matches. The FIRST matching
70
+ * rule wins in order of specificity, and `line` is the matching log line
71
+ * (trimmed, ≤ 300 chars) — what the dashboard shows as `error`.
72
+ */
73
+ export function classifyLogTail(text) {
74
+ const lines = String(text || "")
75
+ .split(/\r?\n/)
76
+ .map((l) => l.replace(/\[[0-9;]*m/g, "").trim())
77
+ .filter(Boolean);
78
+ const rules = [
79
+ {
80
+ re: /ECONNREFUSED\s+(?:127\.0\.0\.1|localhost|::1|\[::1\]):(\d{2,5})/i,
81
+ build: (m) => ({ code: "dependency_missing", detail: { dependency: DEPENDENCY_BY_PORT[Number(m[1])] ?? `service on :${m[1]}`, port: Number(m[1]) } }),
82
+ },
83
+ { re: /MongoServerSelectionError|MongoNetworkError|failed to connect to server \[[^\]]*:27017\]/i, build: () => ({ code: "dependency_missing", detail: { dependency: "mongodb", port: 27017 } }) },
84
+ { re: /getaddrinfo ENOTFOUND\s+([\w.-]+)/i, build: (m) => ({ code: "dependency_missing", detail: { dependency: m[1] } }) },
85
+ { re: /EADDRINUSE(?:[^\d]*:(\d{2,5}))?/i, build: (m) => ({ code: "port_busy", detail: m[1] ? { port: Number(m[1]) } : {} }) },
86
+ // zsh names the missing command AFTER the message ("zsh: command not
87
+ // found: pnpm"), bash BEFORE it ("sh: vite: command not found"). The
88
+ // zsh form must be tried first or the shell's own name ("zsh") ends up
89
+ // on the card as the missing tool.
90
+ { re: /command not found:\s*([\w.-]+)/i, build: (m) => ({ code: "deps_failed", detail: { command: m[1] } }) },
91
+ { re: /([\w.-]+): command not found/i, build: (m) => ({ code: "deps_failed", detail: { command: m[1] } }) },
92
+ { re: /'([\w.-]+)' is not recognized as an internal or external command/i, build: (m) => ({ code: "deps_failed", detail: { command: m[1] } }) },
93
+ { re: /Cannot find module '([^']+)'|Error: Cannot find package '([^']+)'/i, build: (m) => ({ code: "deps_failed", detail: { dependency: m[1] || m[2] } }) },
94
+ { re: /ENOSPC/i, build: () => ({ code: "other", detail: { dependency: "disk space" } }) },
95
+ { re: /Missing required env(?:ironment)? (?:var(?:iable)?s?)?[:\s]*([A-Z0-9_,\s]+)?/i, build: (m) => ({ code: "env_missing", detail: { envKeys: (m[1] || "").split(/[,\s]+/).filter((k) => /^[A-Z][A-Z0-9_]*$/.test(k)) } }) },
96
+ { re: /\b(?:process\.env\.)?([A-Z][A-Z0-9_]{2,}) is not defined\b/, build: (m) => ({ code: "env_missing", detail: { envKeys: [m[1]] } }) },
97
+ { re: /\b(?:Missing|undefined) (?:env(?:ironment)? )?(?:variable|var) ([A-Z][A-Z0-9_]{2,})/i, build: (m) => ({ code: "env_missing", detail: { envKeys: [m[1].toUpperCase()] } }) },
98
+ ];
99
+ for (const rule of rules) {
100
+ for (const line of lines) {
101
+ const m = rule.re.exec(line);
102
+ if (!m) continue;
103
+ const built = rule.build(m);
104
+ return { code: built.code, line: line.slice(0, 300), detail: built.detail };
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /** The three-line fallback when nothing classifies (kept short for the card). */
111
+ /**
112
+ * The card's one-line summary of a dead service's log. Stack frames
113
+ * (` at …`) are noise — the line that names the failure is the one
114
+ * with `Error`/`error:`/`failed`/`Cannot`/an errno; take the LAST such
115
+ * line (the fatal one), then a couple of non-frame lines around it.
116
+ */
117
+ export function logTailSummary(text, lines = 3) {
118
+ const all = String(text || "")
119
+ .trim()
120
+ .split(/\r?\n/)
121
+ .map((l) => l.trim())
122
+ .filter(Boolean);
123
+ const isFrame = (l) => /^at\s/.test(l) || /^\s*at\s/.test(l) || /^node:internal/.test(l) || /^\{\s*$|^\}\s*$/.test(l);
124
+ const isError = (l) => /\b(error|err!|failed|cannot|exception|not found|missing|refused|EADDRINUSE|ENOENT|EACCES|ECONNREFUSED)\b/i.test(l) && !isFrame(l);
125
+ const errorIdx = all.map((l, i) => (isError(l) ? i : -1)).filter((i) => i >= 0).pop();
126
+ const base = all.filter((l) => !isFrame(l));
127
+ let picked;
128
+ if (errorIdx !== undefined) {
129
+ const errLine = all[errorIdx];
130
+ const after = all.slice(errorIdx + 1).filter((l) => !isFrame(l)).slice(0, lines - 1);
131
+ picked = [errLine, ...after];
132
+ } else {
133
+ picked = base.slice(-lines);
134
+ }
135
+ if (picked.length === 0) picked = all.slice(-lines);
136
+ return picked.join(" · ").slice(0, 300);
137
+ }
138
+
139
+ /**
140
+ * Build the error payload the daemon posts. `code` defaults to `other`;
141
+ * `kind` defaults from the code table; `urls` is always emptied on error
142
+ * (a failed preview must never keep stale links).
143
+ */
144
+ export function previewErrorPayload({ code = "other", error, kind, repo, service, detail, hint, note, skipped } = {}) {
145
+ const errorCode = PREVIEW_ERROR_CODES.has(code) ? code : "other";
146
+ const payload = {
147
+ status: "error",
148
+ urls: [],
149
+ previews: [],
150
+ error: String(error || "The preview could not be started.").slice(0, 500),
151
+ errorCode,
152
+ errorKind: kind ?? ERROR_KIND_BY_CODE[errorCode] ?? "machine",
153
+ };
154
+ if (repo) payload.errorRepo = String(repo);
155
+ if (service) payload.errorService = String(service);
156
+ if (detail && typeof detail === "object" && Object.keys(detail).length > 0) payload.errorDetail = detail;
157
+ if (hint) payload.hint = String(hint).slice(0, 300);
158
+ if (note) payload.note = String(note).slice(0, 300);
159
+ if (Array.isArray(skipped) && skipped.length > 0) payload.skipped = skipped;
160
+ return payload;
161
+ }
162
+
163
+ /** Any thrown error → payload (a PreviewError keeps its code; anything else is classified from its message). */
164
+ export function toPreviewErrorPayload(err, { repo, service, note, skipped } = {}) {
165
+ if (err instanceof PreviewError) {
166
+ return previewErrorPayload({ code: err.code, kind: err.kind, error: err.message, repo: err.repo ?? repo, service: err.service ?? service, detail: err.detail, hint: err.hint, note, skipped });
167
+ }
168
+ const message = err?.message || String(err);
169
+ const classified = classifyLogTail(message);
170
+ return previewErrorPayload({ code: classified?.code ?? "other", error: message, repo, service, detail: classified?.detail, note, skipped });
171
+ }
172
+
173
+ /**
174
+ * Dev servers that pop a browser tab (`vite --open`, `next dev -o`) must
175
+ * not do so on the daemon's machine: strip `--open`, `--open=<x>` and a
176
+ * standalone `-o` (vite/astro/vitepress spell it that way; `-o <file>`
177
+ * for other tools is untouched because it takes a value).
178
+ */
179
+ export function stripOpenFlag(run) {
180
+ const text = String(run || "");
181
+ const withoutLong = text.replace(/(^|\s)--open(?:=\S+)?(?=\s|$)/g, "$1");
182
+ const shortToo = /\b(vite|astro|vitepress)\b/.test(withoutLong) ? withoutLong.replace(/(^|\s)-o(?=\s|$)/g, "$1") : withoutLong;
183
+ return shortToo.replace(/\s{2,}/g, " ").trim();
184
+ }
185
+
186
+ const MONOREPO_MARKERS = [
187
+ ["turbo.json", "turbo"],
188
+ ["nx.json", "nx"],
189
+ ["lerna.json", "lerna"],
190
+ ["pnpm-workspace.yaml", "pnpm workspaces"],
191
+ ["rush.json", "rush"],
192
+ ];
193
+
194
+ /**
195
+ * A workspace root (turbo/nx/lerna/pnpm -r/yarn workspaces) has no single
196
+ * dev command worth guessing: `npm run dev` there fans out into every
197
+ * package and the preview would point at nothing. Returns `{ hint }` with
198
+ * the tool's name, or null. `pkg` is the parsed root package.json.
199
+ */
200
+ export function detectMonorepo(repoRoot, pkg, { exists } = {}) {
201
+ for (const [file, name] of MONOREPO_MARKERS) {
202
+ if (exists(`${repoRoot}/${file}`)) return { hint: `monorepo (${name})` };
203
+ }
204
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? Object.values(pkg.scripts).join("\n") : "";
205
+ if (/\bpnpm\s+(?:-r|--recursive)\b|\byarn\s+workspaces?\s+(?:run|foreach)\b|\bturbo\s+run\b|\bnx\s+run(?:-many)?\b|\blerna\s+run\b/.test(scripts)) {
206
+ return { hint: "monorepo (workspace scripts)" };
207
+ }
208
+ if (pkg && (Array.isArray(pkg.workspaces) || (pkg.workspaces && typeof pkg.workspaces === "object"))) return { hint: "monorepo (yarn/npm workspaces)" };
209
+ return null;
210
+ }
211
+
212
+ /** dotenv → `{ KEY: value }` (quotes stripped, `export` prefix tolerated, comments skipped). */
213
+ export function parseDotenv(text) {
214
+ const out = {};
215
+ for (const raw of String(text || "").split(/\r?\n/)) {
216
+ const line = raw.trim();
217
+ if (!line || line.startsWith("#")) continue;
218
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/.exec(line);
219
+ if (!m) continue;
220
+ let value = m[2].trim();
221
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
222
+ else value = value.replace(/\s+#.*$/, "");
223
+ out[m[1]] = value;
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * Keys `.env.example` declares that neither the service's `.env*` files nor
230
+ * the process env provide — the hint on `env_missing`. Injected `exists` /
231
+ * `read` keep it pure.
232
+ */
233
+ export function missingEnvKeys(cwd, { exists, read, env = {} } = {}) {
234
+ const example = ["/.env.example", "/.env.sample", "/.env.template"].map((n) => `${cwd}${n}`).find((p) => exists(p));
235
+ if (!example) return [];
236
+ const wanted = Object.keys(parseDotenv(safeRead(read, example)));
237
+ if (wanted.length === 0) return [];
238
+ const present = new Set(Object.keys(env));
239
+ for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
240
+ const p = `${cwd}/${name}`;
241
+ if (!exists(p)) continue;
242
+ for (const k of Object.keys(parseDotenv(safeRead(read, p)))) present.add(k);
243
+ }
244
+ return wanted.filter((k) => !present.has(k));
245
+ }
246
+
247
+ function safeRead(read, path) {
248
+ try {
249
+ return read(path);
250
+ } catch {
251
+ return "";
252
+ }
253
+ }
254
+
255
+ const PLACEHOLDER_RE = /^(?:changeme|change-me|change_me|replace-?me|todo|tbd|xxx+|your[-_ ].*|<[^>]*>|\$\{[^}]*\}|example.*|.*example\.(?:com|org|net).*|placeholder|secret|password|true|false|null|undefined|none|localhost.*|0+|\d+)$/i;
256
+
257
+ /** Is this env value worth redacting (≥ 12 chars, not a placeholder, not a URL/number/bool)? */
258
+ export function isRedactableEnvValue(value) {
259
+ if (typeof value !== "string") return false;
260
+ const text = value.trim();
261
+ if (text.length < 12) return false;
262
+ if (PLACEHOLDER_RE.test(text)) return false;
263
+ // URLs / connection strings: only ones carrying `user:password@` are secrets.
264
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return /\/\/[^/@\s]+:[^/@\s]+@/.test(text);
265
+ if (/^[\d.,\s-]+$/.test(text)) return false;
266
+ if (/^\d{4}-\d{2}-\d{2}/.test(text)) return false;
267
+ return true;
268
+ }
269
+
270
+ /**
271
+ * Every value from the given dotenv texts that looks like a secret (the
272
+ * redaction set grows by these so a leaked `.env` value never reaches the
273
+ * Server in a tool row). Longest first, like `redactionSet`.
274
+ */
275
+ export function envRedactionValues(texts) {
276
+ const values = new Set();
277
+ for (const text of texts || []) {
278
+ for (const value of Object.values(parseDotenv(text))) {
279
+ if (isRedactableEnvValue(value)) values.add(value.trim());
280
+ }
281
+ }
282
+ return new Set([...values].sort((a, b) => b.length - a.length));
283
+ }