dahrk-node 0.1.8 → 0.1.9

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 (2) hide show
  1. package/dist/main.js +1871 -501
  2. package/package.json +2 -1
package/dist/main.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/main.ts
4
- import { execFileSync as execFileSync7 } from "child_process";
5
- import { existsSync as existsSync7, realpathSync as realpathSync3 } from "fs";
4
+ import { execFileSync as execFileSync8 } from "child_process";
5
+ import { existsSync as existsSync12, realpathSync as realpathSync4 } from "fs";
6
6
  import { randomUUID as randomUUID3 } from "crypto";
7
7
  import { homedir as homedir5, platform as osPlatform4 } from "os";
8
- import { basename, join as join10 } from "path";
8
+ import { basename as basename2, join as join14 } from "path";
9
9
  import { pathToFileURL } from "url";
10
10
 
11
11
  // ../../packages/edge/src/ws-client.ts
@@ -1333,7 +1333,7 @@ var PROVIDER_BY_ENV = {
1333
1333
  import { execFileSync } from "child_process";
1334
1334
  import { existsSync, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
1335
1335
  import { homedir, tmpdir } from "os";
1336
- import { dirname, isAbsolute, join as join2 } from "path";
1336
+ import { basename, dirname, isAbsolute, join as join2 } from "path";
1337
1337
  var noopLogger = { info: () => {
1338
1338
  }, warn: () => {
1339
1339
  } };
@@ -1349,12 +1349,16 @@ function sanitizeBranchName(name) {
1349
1349
  function resolveWorktreesDir(override) {
1350
1350
  return override ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join2(homedir(), ".dahrk", "worktrees");
1351
1351
  }
1352
+ function resolveMirrorsDir(override) {
1353
+ return override ?? process.env.DAHRK_MIRRORS_DIR ?? process.env.SKAKEL_MIRRORS_DIR ?? join2(homedir(), ".dahrk", "mirrors");
1354
+ }
1352
1355
  function createGitService(opts = {}) {
1353
1356
  const worktreesDir = resolveWorktreesDir(opts.worktreesDir);
1354
- const mirrorsDir = opts.mirrorsDir ?? process.env.DAHRK_MIRRORS_DIR ?? process.env.SKAKEL_MIRRORS_DIR ?? join2(homedir(), ".dahrk", "mirrors");
1357
+ const mirrorsDir = resolveMirrorsDir(opts.mirrorsDir);
1355
1358
  const authorName = opts.authorName ?? process.env.DAHRK_GIT_AUTHOR_NAME ?? "Dahrk";
1356
1359
  const authorEmail = opts.authorEmail ?? process.env.DAHRK_GIT_AUTHOR_EMAIL ?? "noreply@dahrk.ai";
1357
- const log2 = opts.logger ?? noopLogger;
1360
+ const isBusy = opts.isBusy;
1361
+ const log = opts.logger ?? noopLogger;
1358
1362
  const git = (cwd, args, env) => execFileSync("git", args, {
1359
1363
  cwd,
1360
1364
  stdio: ["pipe", "pipe", "pipe"],
@@ -1366,7 +1370,7 @@ function createGitService(opts = {}) {
1366
1370
  encoding: "utf-8",
1367
1371
  ...env ? { env } : {}
1368
1372
  });
1369
- const gitOk = (cwd, args) => {
1373
+ const gitOk2 = (cwd, args) => {
1370
1374
  try {
1371
1375
  git(cwd, args);
1372
1376
  return true;
@@ -1393,14 +1397,14 @@ function createGitService(opts = {}) {
1393
1397
  writeFileSync(excludePath, `${existing}${sep3}${entry}
1394
1398
  `);
1395
1399
  } catch (e) {
1396
- log2.warn(`could not set worktree scratch exclude at ${worktreePath}: ${e.message}`);
1400
+ log.warn(`could not set worktree scratch exclude at ${worktreePath}: ${e.message}`);
1397
1401
  }
1398
1402
  };
1399
1403
  const commitPending = (worktreePath, message) => {
1400
1404
  excludeScratchLocally(worktreePath);
1401
1405
  git(worktreePath, ["rm", "-r", "--cached", "--ignore-unmatch", "--quiet", SCRATCH_DIR]);
1402
1406
  git(worktreePath, ["add", "-A", "--", "."]);
1403
- const dirty = !gitOk(worktreePath, ["diff", "--cached", "--quiet"]);
1407
+ const dirty = !gitOk2(worktreePath, ["diff", "--cached", "--quiet"]);
1404
1408
  if (dirty) {
1405
1409
  git(worktreePath, [
1406
1410
  "-c",
@@ -1426,22 +1430,108 @@ function createGitService(opts = {}) {
1426
1430
  const netEnv = (authEnv) => authEnv ?? { ...process.env, GIT_TERMINAL_PROMPT: "0" };
1427
1431
  const withTokenUser = (gitUrl) => /^https:\/\/[^@/]+@/.test(gitUrl) ? gitUrl : gitUrl.replace(/^https:\/\//, "https://x-access-token@");
1428
1432
  const mirrorPathFor = (repoId) => join2(mirrorsDir, sanitizeBranchName(repoId));
1433
+ const listWorktrees = (mirror) => {
1434
+ let out;
1435
+ try {
1436
+ out = git(mirror, ["worktree", "list", "--porcelain"]);
1437
+ } catch {
1438
+ return [];
1439
+ }
1440
+ const entries = [];
1441
+ let path = "";
1442
+ for (const line of out.split("\n")) {
1443
+ if (line.startsWith("worktree ")) path = line.slice(9).trim();
1444
+ else if (line.startsWith("branch ")) {
1445
+ const branch = line.slice(7).trim().replace(/^refs\/heads\//, "");
1446
+ if (path) entries.push({ path, branch });
1447
+ } else if (line.trim() === "") path = "";
1448
+ }
1449
+ return entries;
1450
+ };
1451
+ const teardownWorktreePath = (mirror, worktreePath) => {
1452
+ try {
1453
+ git(mirror, ["worktree", "remove", "--force", worktreePath]);
1454
+ } catch (e) {
1455
+ log.warn(`git worktree remove failed for ${worktreePath}: ${e.message}`);
1456
+ }
1457
+ rmSync(worktreePath, { recursive: true, force: true });
1458
+ gitOk2(mirror, ["worktree", "prune"]);
1459
+ };
1460
+ const TRACKING_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
1461
+ const migrateMirrorConfig = (mirror, repoId) => {
1462
+ const isMirror = gitOk2(mirror, ["config", "--get", "remote.origin.mirror"]);
1463
+ const refspecs = (() => {
1464
+ try {
1465
+ return git(mirror, ["config", "--get-all", "remote.origin.fetch"]).split("\n").map((s) => s.trim());
1466
+ } catch {
1467
+ return [];
1468
+ }
1469
+ })();
1470
+ if (!isMirror && refspecs.length === 1 && refspecs[0] === TRACKING_REFSPEC) return;
1471
+ log.info(`mirror ${repoId}: migrating to the tracking-refspec layout (was mirror=${isMirror})`);
1472
+ for (const args of [
1473
+ ["config", "--unset-all", "remote.origin.mirror"],
1474
+ ["config", "--unset-all", "remote.origin.push"]
1475
+ ]) {
1476
+ gitOk2(mirror, args);
1477
+ }
1478
+ git(mirror, ["config", "--replace-all", "remote.origin.fetch", TRACKING_REFSPEC]);
1479
+ };
1480
+ const salvageOrphanedTip = (mirror, branchName, start2) => {
1481
+ try {
1482
+ if (!gitOk2(mirror, ["rev-parse", "--verify", "-q", `refs/heads/${branchName}`])) return;
1483
+ if (gitOk2(mirror, ["merge-base", "--is-ancestor", `refs/heads/${branchName}`, start2])) return;
1484
+ const sha = git(mirror, ["rev-parse", `refs/heads/${branchName}`]).trim();
1485
+ const ref = `refs/dahrk/salvage/${branchName}/${sha.slice(0, 12)}`;
1486
+ git(mirror, ["update-ref", ref, sha]);
1487
+ log.warn(`parked orphaned tip of ${branchName} (${sha.slice(0, 8)}) at ${ref} before reset`);
1488
+ } catch (e) {
1489
+ log.warn(`could not salvage tip of ${branchName}: ${e.message}`);
1490
+ }
1491
+ };
1492
+ const gcShadowHeads = (mirror) => {
1493
+ let heads;
1494
+ try {
1495
+ heads = git(mirror, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]).split("\n").map((s) => s.trim()).filter(Boolean);
1496
+ } catch {
1497
+ return;
1498
+ }
1499
+ const held = new Set(listWorktrees(mirror).map((w) => w.branch));
1500
+ const ownHead = (() => {
1501
+ try {
1502
+ return git(mirror, ["symbolic-ref", "--quiet", "--short", "HEAD"]).trim();
1503
+ } catch {
1504
+ return "";
1505
+ }
1506
+ })();
1507
+ for (const h of heads) {
1508
+ if (h === ownHead || held.has(h)) continue;
1509
+ if (!gitOk2(mirror, ["rev-parse", "--verify", "-q", `refs/remotes/origin/${h}`])) continue;
1510
+ if (!gitOk2(mirror, ["merge-base", "--is-ancestor", `refs/heads/${h}`, `refs/remotes/origin/${h}`])) continue;
1511
+ gitOk2(mirror, ["branch", "-D", h]);
1512
+ }
1513
+ };
1429
1514
  const ensureMirror = (repoId, gitUrl, authEnv) => {
1430
1515
  const mirror = mirrorPathFor(repoId);
1431
- if (existsSync(mirror) && gitOk(mirror, ["rev-parse", "--git-dir"])) {
1432
- log2.info(`refreshing mirror ${repoId}`);
1516
+ if (existsSync(mirror) && gitOk2(mirror, ["rev-parse", "--git-dir"])) {
1517
+ log.info(`refreshing mirror ${repoId}`);
1433
1518
  try {
1434
- git(mirror, ["remote", "update", "--prune"], netEnv(authEnv));
1519
+ migrateMirrorConfig(mirror, repoId);
1520
+ git(mirror, ["fetch", "--prune", "origin"], netEnv(authEnv));
1521
+ gcShadowHeads(mirror);
1435
1522
  return { mirror, refreshed: true };
1436
1523
  } catch (e) {
1437
- log2.warn(`mirror remote update failed for ${repoId}: ${e.message}`);
1524
+ log.warn(`mirror fetch failed for ${repoId}: ${e.message}`);
1438
1525
  return { mirror, refreshed: false };
1439
1526
  }
1440
1527
  }
1441
- mkdirSync(mirrorsDir, { recursive: true });
1528
+ mkdirSync(mirror, { recursive: true });
1442
1529
  const cloneUrl = authEnv ? withTokenUser(gitUrl) : gitUrl;
1443
- log2.info(`cloning mirror ${repoId} from ${gitUrl}`);
1444
- gitBare(["clone", "--mirror", cloneUrl, mirror], netEnv(authEnv));
1530
+ log.info(`cloning mirror ${repoId} from ${gitUrl}`);
1531
+ gitBare(["init", "--bare", "--quiet", mirror]);
1532
+ git(mirror, ["remote", "add", "origin", cloneUrl]);
1533
+ git(mirror, ["config", "--replace-all", "remote.origin.fetch", TRACKING_REFSPEC]);
1534
+ git(mirror, ["fetch", "--prune", "origin"], netEnv(authEnv));
1445
1535
  return { mirror, refreshed: true };
1446
1536
  };
1447
1537
  const refFor = (spec, worktreePath) => ({
@@ -1459,28 +1549,42 @@ function createGitService(opts = {}) {
1459
1549
  const branchName = sanitizeBranchName(spec.branch ?? `dahrk/${runId}`);
1460
1550
  const worktreePath = join2(worktreesDir, runId);
1461
1551
  mkdirSync(worktreesDir, { recursive: true });
1462
- if (existsSync(worktreePath) && gitOk(worktreePath, ["rev-parse", "--git-dir"])) {
1463
- log2.info(`reusing existing worktree at ${worktreePath}`);
1552
+ if (existsSync(worktreePath) && gitOk2(worktreePath, ["rev-parse", "--git-dir"])) {
1553
+ log.info(`reusing existing worktree at ${worktreePath}`);
1464
1554
  mkdirSync(join2(worktreePath, ".skakel", "scratch"), { recursive: true });
1465
1555
  return refFor(spec, worktreePath);
1466
1556
  }
1467
1557
  const auth = spec.credentialToken ? setupAuth(spec.credentialToken) : void 0;
1468
1558
  try {
1469
1559
  const { mirror, refreshed } = ensureMirror(repoId, gitUrl, auth?.env);
1470
- if (gitOk(mirror, ["rev-parse", "--verify", branchName])) {
1471
- git(mirror, ["worktree", "add", "--force", worktreePath, branchName]);
1472
- } else {
1473
- if (!refreshed) {
1474
- log2.info(`mirror refresh failed; fetching base ${baseBranch} before branching ${branchName}`);
1475
- git(mirror, ["fetch", "origin", `+refs/heads/${baseBranch}:refs/heads/${baseBranch}`], netEnv(auth?.env));
1560
+ gitOk2(mirror, ["worktree", "prune"]);
1561
+ for (const w of listWorktrees(mirror)) {
1562
+ if (w.branch !== branchName || w.path === worktreePath) continue;
1563
+ const holder = basename(w.path);
1564
+ if (isBusy?.(holder)) {
1565
+ throw new Error(`branch ${branchName} is held by in-flight run ${holder} (${w.path})`);
1476
1566
  }
1477
- log2.info(`creating worktree at ${worktreePath} from ${baseBranch} on ${branchName}`);
1478
- git(mirror, ["worktree", "add", "-b", branchName, worktreePath, baseBranch]);
1567
+ log.warn(`clearing stale worktree ${w.path} which still claims ${branchName}`);
1568
+ teardownWorktreePath(mirror, w.path);
1569
+ }
1570
+ const remoteBranch = `refs/remotes/origin/${branchName}`;
1571
+ const remoteBase = `refs/remotes/origin/${baseBranch}`;
1572
+ if (!refreshed) {
1573
+ log.info(`mirror refresh failed; fetching base ${baseBranch} before branching ${branchName}`);
1574
+ git(mirror, ["fetch", "origin", `+refs/heads/${baseBranch}:${remoteBase}`], netEnv(auth?.env));
1575
+ }
1576
+ const seed = spec.seedRef && gitOk2(mirror, ["rev-parse", "--verify", "-q", spec.seedRef]) ? spec.seedRef : void 0;
1577
+ const start2 = seed ?? (gitOk2(mirror, ["rev-parse", "--verify", "-q", remoteBranch]) ? remoteBranch : remoteBase);
1578
+ if (!gitOk2(mirror, ["rev-parse", "--verify", "-q", start2])) {
1579
+ throw new Error(`start point '${start2}' does not resolve in mirror ${repoId}`);
1479
1580
  }
1581
+ salvageOrphanedTip(mirror, branchName, start2);
1582
+ log.info(`creating worktree at ${worktreePath} from ${start2} on ${branchName}`);
1583
+ git(mirror, ["worktree", "add", "--force", "-B", branchName, worktreePath, start2]);
1480
1584
  } finally {
1481
1585
  auth?.cleanup();
1482
1586
  }
1483
- if (!gitOk(worktreePath, ["rev-parse", "--verify", "-q", "HEAD"])) {
1587
+ if (!gitOk2(worktreePath, ["rev-parse", "--verify", "-q", "HEAD"])) {
1484
1588
  throw new Error(`base '${baseBranch}' did not materialise into ${worktreePath} (unborn HEAD)`);
1485
1589
  }
1486
1590
  mkdirSync(join2(worktreePath, ".skakel", "scratch"), { recursive: true });
@@ -1488,14 +1592,14 @@ function createGitService(opts = {}) {
1488
1592
  },
1489
1593
  async commitAndPush(ref, opts2) {
1490
1594
  const { worktreePath } = ref;
1491
- if (!existsSync(worktreePath) || !gitOk(worktreePath, ["rev-parse", "--git-dir"])) {
1595
+ if (!existsSync(worktreePath) || !gitOk2(worktreePath, ["rev-parse", "--git-dir"])) {
1492
1596
  throw new Error(`worktree missing for push: ${worktreePath}`);
1493
1597
  }
1494
1598
  const branch = sanitizeBranchName(opts2.branch);
1495
1599
  const { headSha: committedSha, dirty } = commitPending(worktreePath, opts2.message);
1496
1600
  let headSha = committedSha;
1497
1601
  let commitsAhead = 0;
1498
- for (const baseRef of [opts2.base, `origin/${opts2.base}`, `refs/heads/${opts2.base}`]) {
1602
+ for (const baseRef of ["FETCH_HEAD", `origin/${opts2.base}`, opts2.base, `refs/heads/${opts2.base}`]) {
1499
1603
  if (!baseRef) continue;
1500
1604
  try {
1501
1605
  commitsAhead = Number.parseInt(git(worktreePath, ["rev-list", "--count", `${baseRef}..HEAD`]).trim(), 10) || 0;
@@ -1514,15 +1618,15 @@ function createGitService(opts = {}) {
1514
1618
  git(worktreePath, ["fetch", remote, opts2.base], netEnv(auth?.env));
1515
1619
  fetched = true;
1516
1620
  } catch (e) {
1517
- log2.warn(`base fetch failed for ${opts2.base}; skipping push-time integration: ${e.message}`);
1621
+ log.warn(`base fetch failed for ${opts2.base}; skipping push-time integration: ${e.message}`);
1518
1622
  }
1519
1623
  }
1520
1624
  if (fetched) {
1521
- if (!gitOk(worktreePath, ["merge-base", "HEAD", "FETCH_HEAD"])) {
1625
+ if (!gitOk2(worktreePath, ["merge-base", "HEAD", "FETCH_HEAD"])) {
1522
1626
  return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "diverged" };
1523
1627
  }
1524
1628
  const delta = git(worktreePath, ["diff", "--name-only", "FETCH_HEAD...HEAD"]).split("\n").map((l) => l.trim()).filter(Boolean);
1525
- const isScratchPath = (p) => p === SCRATCH_DIR || p.startsWith(`${SCRATCH_DIR}/`) || gitOk(worktreePath, ["check-ignore", "-q", "--no-index", "--", p]);
1629
+ const isScratchPath = (p) => p === SCRATCH_DIR || p.startsWith(`${SCRATCH_DIR}/`) || gitOk2(worktreePath, ["check-ignore", "-q", "--no-index", "--", p]);
1526
1630
  if (!delta.some((p) => !isScratchPath(p))) {
1527
1631
  return { headSha, pushed: false, nothingToCommit: true, commitsAhead, integration: "noop" };
1528
1632
  }
@@ -1539,7 +1643,7 @@ function createGitService(opts = {}) {
1539
1643
  integration = "clean";
1540
1644
  headSha = git(worktreePath, ["rev-parse", "HEAD"]).trim();
1541
1645
  } catch (mergeErr) {
1542
- const inMerge = gitOk(worktreePath, ["rev-parse", "--verify", "-q", "MERGE_HEAD"]);
1646
+ const inMerge = gitOk2(worktreePath, ["rev-parse", "--verify", "-q", "MERGE_HEAD"]);
1543
1647
  if (inMerge) {
1544
1648
  const conflictFiles = git(worktreePath, ["diff", "--name-only", "--diff-filter=U"]).split("\n").map((l) => l.trim()).filter(Boolean);
1545
1649
  git(worktreePath, ["merge", "--abort"]);
@@ -1561,7 +1665,7 @@ function createGitService(opts = {}) {
1561
1665
  },
1562
1666
  async backupPush(ref, opts2) {
1563
1667
  const { worktreePath } = ref;
1564
- if (!existsSync(worktreePath) || !gitOk(worktreePath, ["rev-parse", "--git-dir"])) {
1668
+ if (!existsSync(worktreePath) || !gitOk2(worktreePath, ["rev-parse", "--git-dir"])) {
1565
1669
  throw new Error(`worktree missing for backup push: ${worktreePath}`);
1566
1670
  }
1567
1671
  const wipRef = sanitizeBranchName(opts2.branch);
@@ -1618,18 +1722,145 @@ function createGitService(opts = {}) {
1618
1722
  }
1619
1723
  },
1620
1724
  async teardownWorktree(ref) {
1621
- if (!existsSync(ref.worktreePath)) return;
1622
- const mirror = mirrorPathFor(ref.repoId);
1623
- try {
1624
- git(mirror, ["worktree", "remove", "--force", ref.worktreePath]);
1625
- } catch (e) {
1626
- log2.warn(`git worktree remove failed for ${ref.worktreePath}: ${e.message}`);
1725
+ teardownWorktreePath(mirrorPathFor(ref.repoId), ref.worktreePath);
1726
+ }
1727
+ };
1728
+ }
1729
+
1730
+ // ../../packages/executor-worktree/src/worktree-reaper.ts
1731
+ import { execFileSync as execFileSync2 } from "child_process";
1732
+ import { existsSync as existsSync2, readdirSync, realpathSync, rmSync as rmSync2, statSync } from "fs";
1733
+ import { join as join3 } from "path";
1734
+ var MINUTE = 6e4;
1735
+ var HOUR = 60 * MINUTE;
1736
+ var DEFAULTS = {
1737
+ // Deliberately non-optional defaults. "No policy configured" used to mean "never collect anything",
1738
+ // which is precisely how the disk reached 65 GB. Absent config must mean sane collection, not none.
1739
+ maxRuns: 20,
1740
+ maxIdleMs: 6 * HOUR,
1741
+ activityGraceMs: 30 * MINUTE
1742
+ };
1743
+ var noop = { info: () => {
1744
+ }, warn: () => {
1745
+ } };
1746
+ var gitOk = (cwd, args) => {
1747
+ try {
1748
+ execFileSync2("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
1749
+ return true;
1750
+ } catch {
1751
+ return false;
1752
+ }
1753
+ };
1754
+ var gitOut = (cwd, args) => execFileSync2("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8" });
1755
+ var canonical = (p) => {
1756
+ try {
1757
+ return realpathSync(p);
1758
+ } catch {
1759
+ return p;
1760
+ }
1761
+ };
1762
+ function lastUsedMs(worktreePath) {
1763
+ const candidates = [join3(worktreePath, ".skakel", "scratch", "state.json"), worktreePath];
1764
+ let newest = 0;
1765
+ for (const p of candidates) {
1766
+ try {
1767
+ newest = Math.max(newest, statSync(p).mtimeMs);
1768
+ } catch {
1769
+ }
1770
+ }
1771
+ return newest;
1772
+ }
1773
+ function registeredWorktrees(mirror) {
1774
+ let out;
1775
+ try {
1776
+ out = gitOut(mirror, ["worktree", "list", "--porcelain"]);
1777
+ } catch {
1778
+ return [];
1779
+ }
1780
+ return out.split("\n").filter((l) => l.startsWith("worktree ")).map((l) => canonical(l.slice(9).trim())).filter((p) => p && p !== canonical(mirror));
1781
+ }
1782
+ function createWorktreeReaper(opts) {
1783
+ const log = opts.logger ?? noop;
1784
+ const mirrors = () => {
1785
+ try {
1786
+ return readdirSync(opts.mirrorsDir).map((d) => join3(opts.mirrorsDir, d)).filter((m) => gitOk(m, ["rev-parse", "--git-dir"]));
1787
+ } catch {
1788
+ return [];
1789
+ }
1790
+ };
1791
+ const ownerOf = (worktreePath, all) => {
1792
+ for (const [mirror, paths] of all) if (paths.includes(worktreePath)) return mirror;
1793
+ return void 0;
1794
+ };
1795
+ return {
1796
+ async reap(policy = {}) {
1797
+ const maxRuns = policy.maxRuns ?? DEFAULTS.maxRuns;
1798
+ const maxIdleMs = policy.maxIdleMs ?? DEFAULTS.maxIdleMs;
1799
+ const graceMs = policy.activityGraceMs ?? DEFAULTS.activityGraceMs;
1800
+ const dryRun = policy.dryRun ?? false;
1801
+ const report = { scanned: 0, reaped: [], skipped: 0, errors: [] };
1802
+ const now = Date.now();
1803
+ const registered = /* @__PURE__ */ new Map();
1804
+ for (const m of mirrors()) {
1805
+ gitOk(m, ["worktree", "prune"]);
1806
+ registered.set(m, registeredWorktrees(m));
1627
1807
  }
1628
- rmSync(ref.worktreePath, { recursive: true, force: true });
1629
- try {
1630
- git(mirror, ["worktree", "prune"]);
1631
- } catch {
1808
+ const onDisk = (() => {
1809
+ try {
1810
+ return readdirSync(opts.worktreesDir).map((d) => canonical(join3(opts.worktreesDir, d)));
1811
+ } catch {
1812
+ return [];
1813
+ }
1814
+ })();
1815
+ const candidates = [.../* @__PURE__ */ new Set([...onDisk, ...[...registered.values()].flat()])];
1816
+ const entries = [];
1817
+ for (const path of candidates) {
1818
+ report.scanned++;
1819
+ const runId = path.split("/").pop() ?? path;
1820
+ if (opts.isBusy?.(runId)) {
1821
+ report.skipped++;
1822
+ continue;
1823
+ }
1824
+ const idleMs = now - lastUsedMs(path);
1825
+ if (idleMs < graceMs) {
1826
+ report.skipped++;
1827
+ continue;
1828
+ }
1829
+ const broken = !existsSync2(path) || !gitOk(path, ["rev-parse", "--verify", "-q", "HEAD"]);
1830
+ entries.push({ path, runId, idleMs, broken, ...ownerOf(path, registered) ? { mirror: ownerOf(path, registered) } : {} });
1831
+ }
1832
+ entries.sort((a, b) => b.idleMs - a.idleMs);
1833
+ const keep = entries.filter((e) => !e.broken && e.idleMs <= maxIdleMs);
1834
+ const doomed = [];
1835
+ for (const e of entries) {
1836
+ if (e.broken) doomed.push({ ...e, reason: "broken" });
1837
+ else if (e.idleMs > maxIdleMs) doomed.push({ ...e, reason: "idle" });
1838
+ }
1839
+ const survivors = keep.filter((e) => !doomed.some((d) => d.path === e.path));
1840
+ for (const e of survivors.slice(0, Math.max(0, survivors.length - maxRuns))) {
1841
+ doomed.push({ ...e, reason: "over-count" });
1632
1842
  }
1843
+ for (const d of doomed) {
1844
+ if (dryRun) {
1845
+ log.info(`reaper (dry-run): would reap ${d.runId} (${d.reason}, idle ${Math.round(d.idleMs / MINUTE)}m)`);
1846
+ report.reaped.push({ runId: d.runId, path: d.path, reason: d.reason });
1847
+ continue;
1848
+ }
1849
+ try {
1850
+ if (d.mirror) {
1851
+ gitOk(d.mirror, ["worktree", "remove", "--force", d.path]);
1852
+ }
1853
+ rmSync2(d.path, { recursive: true, force: true });
1854
+ if (d.mirror) gitOk(d.mirror, ["worktree", "prune"]);
1855
+ report.reaped.push({ runId: d.runId, path: d.path, reason: d.reason });
1856
+ } catch (e) {
1857
+ report.errors.push(`${d.runId}: ${e.message}`);
1858
+ }
1859
+ }
1860
+ if (report.reaped.length) {
1861
+ log.info(`reaper: reaped ${report.reaped.length} worktree(s), skipped ${report.skipped}`);
1862
+ }
1863
+ return report;
1633
1864
  }
1634
1865
  };
1635
1866
  }
@@ -1637,15 +1868,15 @@ function createGitService(opts = {}) {
1637
1868
  // ../../packages/executor-worktree/src/trace-writer.ts
1638
1869
  import { createHash } from "crypto";
1639
1870
  import { appendFileSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1640
- import { join as join3 } from "path";
1871
+ import { join as join4 } from "path";
1641
1872
  var DEFAULT_SPILL_BYTES = 8192;
1642
1873
  function createTraceWriter(scratchPath, meta, opts = {}) {
1643
1874
  const spillBytes = opts.spillBytes ?? DEFAULT_SPILL_BYTES;
1644
- const dir = join3(scratchPath, "traces", meta.stageId, `attempt-${meta.attempt}`);
1645
- mkdirSync2(join3(dir, "blobs"), { recursive: true });
1646
- mkdirSync2(join3(dir, "raw"), { recursive: true });
1647
- const tracePath = join3(dir, "trace.jsonl");
1648
- const metaPath = join3(dir, "meta.json");
1875
+ const dir = join4(scratchPath, "traces", meta.stageId, `attempt-${meta.attempt}`);
1876
+ mkdirSync2(join4(dir, "blobs"), { recursive: true });
1877
+ mkdirSync2(join4(dir, "raw"), { recursive: true });
1878
+ const tracePath = join4(dir, "trace.jsonl");
1879
+ const metaPath = join4(dir, "meta.json");
1649
1880
  let current = { ...meta };
1650
1881
  let nextSeq = 0;
1651
1882
  let rawCount = 0;
@@ -1657,8 +1888,8 @@ function createTraceWriter(scratchPath, meta, opts = {}) {
1657
1888
  const spillValue = (value) => {
1658
1889
  const data = typeof value === "string" ? value : JSON.stringify(value);
1659
1890
  const sha = createHash("sha256").update(data).digest("hex");
1660
- writeFileSync2(join3(dir, "blobs", sha), data);
1661
- return join3("blobs", sha);
1891
+ writeFileSync2(join4(dir, "blobs", sha), data);
1892
+ return join4("blobs", sha);
1662
1893
  };
1663
1894
  const spill = (event) => {
1664
1895
  if (event.type === "thought" && event.text !== void 0 && tooBig(event.text)) {
@@ -1688,8 +1919,8 @@ function createTraceWriter(scratchPath, meta, opts = {}) {
1688
1919
  return written;
1689
1920
  },
1690
1921
  writeRaw(record) {
1691
- const rel = join3("raw", `${rawCount++}.json`);
1692
- writeFileSync2(join3(dir, rel), JSON.stringify(record, null, 2));
1922
+ const rel = join4("raw", `${rawCount++}.json`);
1923
+ writeFileSync2(join4(dir, rel), JSON.stringify(record, null, 2));
1693
1924
  return rel;
1694
1925
  },
1695
1926
  finalise(patch = {}) {
@@ -1704,13 +1935,13 @@ function createTraceWriter(scratchPath, meta, opts = {}) {
1704
1935
 
1705
1936
  // ../../packages/executor-worktree/src/pack-cache.ts
1706
1937
  import { createHash as createHash2 } from "crypto";
1707
- import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readdirSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
1708
- import { dirname as dirname2, join as join4, relative, sep } from "path";
1938
+ import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readdirSync as readdirSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
1939
+ import { dirname as dirname2, join as join5, relative, sep } from "path";
1709
1940
  function readManifestFiles(dir) {
1710
1941
  const out = [];
1711
1942
  const walk = (cur) => {
1712
- for (const entry of readdirSync(cur, { withFileTypes: true })) {
1713
- const abs = join4(cur, entry.name);
1943
+ for (const entry of readdirSync2(cur, { withFileTypes: true })) {
1944
+ const abs = join5(cur, entry.name);
1714
1945
  if (entry.isDirectory()) walk(abs);
1715
1946
  else out.push(relative(dir, abs).split(sep).join("/"));
1716
1947
  }
@@ -1720,8 +1951,8 @@ function readManifestFiles(dir) {
1720
1951
  }
1721
1952
 
1722
1953
  // ../../packages/executor-worktree/src/overlay.ts
1723
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
1724
- import { dirname as dirname3, join as join5 } from "path";
1954
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
1955
+ import { dirname as dirname3, join as join6 } from "path";
1725
1956
  function sameBytes(dest, bytes) {
1726
1957
  try {
1727
1958
  return readFileSync3(dest).equals(bytes);
@@ -1741,10 +1972,10 @@ async function overlayComponents(opts) {
1741
1972
  }
1742
1973
  const { dir } = await cache.materialise(ref);
1743
1974
  for (const relPath of readManifestFiles(dir)) {
1744
- const src = join5(dir, relPath);
1745
- const dest = join5(worktreePath, relPath);
1975
+ const src = join6(dir, relPath);
1976
+ const dest = join6(worktreePath, relPath);
1746
1977
  const bytes = readFileSync3(src);
1747
- if (existsSync3(dest)) {
1978
+ if (existsSync4(dest)) {
1748
1979
  if (sameBytes(dest, bytes)) continue;
1749
1980
  result.skippedRepoLocal.push(relPath);
1750
1981
  continue;
@@ -1773,6 +2004,230 @@ function makeRunner(runtime) {
1773
2004
  return runtime === "codex" ? createCodexRunner() : createClaudeRunner();
1774
2005
  }
1775
2006
 
2007
+ // ../../packages/edge/src/logger.ts
2008
+ import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync5, openSync, renameSync, rmSync as rmSync4, statSync as statSync2, writeSync } from "fs";
2009
+ import { join as join7 } from "path";
2010
+ import pino from "pino";
2011
+
2012
+ // ../../packages/edge/src/redact.ts
2013
+ var SENSITIVE_KEY_PATTERNS = [
2014
+ "token",
2015
+ "secret",
2016
+ "password",
2017
+ "passwd",
2018
+ "apikey",
2019
+ "api_key",
2020
+ "authorization",
2021
+ "auth_header",
2022
+ "cookie",
2023
+ "private_key",
2024
+ "privatekey",
2025
+ "client_secret",
2026
+ "clientsecret",
2027
+ "refresh_token",
2028
+ "access_token",
2029
+ "bearer",
2030
+ "credential",
2031
+ "enroltoken",
2032
+ "enrol_token"
2033
+ ];
2034
+ var REDACTED = "[REDACTED]";
2035
+ var MAX_DEPTH = 8;
2036
+ var KEY_ALLOWLIST = /* @__PURE__ */ new Set(["credentialmode"]);
2037
+ function isSensitiveKey(key) {
2038
+ const lower = key.toLowerCase();
2039
+ if (KEY_ALLOWLIST.has(lower)) return false;
2040
+ return SENSITIVE_KEY_PATTERNS.some((p) => lower.includes(p));
2041
+ }
2042
+ var INLINE_TOKEN_RE = /\b(github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|lin_(?:api|oauth)_[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16})\b/g;
2043
+ var URL_CREDENTIAL_RE = /(\b[a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
2044
+ var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
2045
+ function scrubString(s) {
2046
+ return s.replace(URL_CREDENTIAL_RE, `$1$2:${REDACTED}@`).replace(INLINE_TOKEN_RE, REDACTED).replace(JWT_RE, REDACTED).replace(/Bearer\s+[A-Za-z0-9._\-+/=]{16,}/gi, `Bearer ${REDACTED}`).replace(/([?&](?:token|access_token|api_key|key|secret)=)[^&\s"']+/gi, `$1${REDACTED}`);
2047
+ }
2048
+ function looksLikeWholeToken(s) {
2049
+ if (s.length < 20 || /\s/.test(s)) return false;
2050
+ return /^(gh[pousr]|github_pat)_/i.test(s) || /^xox[abprs]-/i.test(s) || /^glpat-/i.test(s) || /^lin_(api|oauth)_/i.test(s) || /^sk-/i.test(s);
2051
+ }
2052
+ function scrubValue(value, depth = 0) {
2053
+ if (depth > MAX_DEPTH) return REDACTED;
2054
+ if (value === null || value === void 0) return value;
2055
+ if (typeof value === "string") {
2056
+ return looksLikeWholeToken(value) ? REDACTED : scrubString(value);
2057
+ }
2058
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return value;
2059
+ if (value instanceof Error) {
2060
+ const out = new Error(scrubString(value.message));
2061
+ out.name = value.name;
2062
+ if (value.stack) out.stack = scrubString(value.stack);
2063
+ return out;
2064
+ }
2065
+ if (Array.isArray(value)) return value.map((v) => scrubValue(v, depth + 1));
2066
+ if (typeof value === "object") {
2067
+ const out = {};
2068
+ for (const [k, v] of Object.entries(value)) {
2069
+ out[k] = isSensitiveKey(k) ? REDACTED : scrubValue(v, depth + 1);
2070
+ }
2071
+ return out;
2072
+ }
2073
+ return value;
2074
+ }
2075
+
2076
+ // ../../packages/edge/src/logger.ts
2077
+ var LEVELS = ["trace", "debug", "info", "warn", "error", "fatal", "silent"];
2078
+ var isLevel = (v) => v !== void 0 && LEVELS.includes(v);
2079
+ function levelFromEnv(env) {
2080
+ const v = env.DAHRK_LOG_LEVEL?.toLowerCase();
2081
+ return isLevel(v) ? v : "info";
2082
+ }
2083
+ function fileLevelFromEnv(env) {
2084
+ const off = env.DAHRK_LOG_FILE?.toLowerCase();
2085
+ if (off === "0" || off === "off" || off === "false") return "off";
2086
+ const v = env.DAHRK_LOG_FILE_LEVEL?.toLowerCase();
2087
+ return isLevel(v) ? v : "debug";
2088
+ }
2089
+ var RotatingFile = class {
2090
+ constructor(path, maxBytes, maxFiles) {
2091
+ this.path = path;
2092
+ this.maxBytes = maxBytes;
2093
+ this.maxFiles = maxFiles;
2094
+ this.open();
2095
+ }
2096
+ path;
2097
+ maxBytes;
2098
+ maxFiles;
2099
+ fd;
2100
+ size = 0;
2101
+ failed = false;
2102
+ open() {
2103
+ try {
2104
+ this.fd = openSync(this.path, "a");
2105
+ this.size = existsSync5(this.path) ? statSync2(this.path).size : 0;
2106
+ } catch (e) {
2107
+ this.disable(e);
2108
+ }
2109
+ }
2110
+ /** Report once, then go quiet. A logger that spams stderr about being unable to log is worse than one
2111
+ * that silently degrades. */
2112
+ disable(e) {
2113
+ if (!this.failed) {
2114
+ this.failed = true;
2115
+ process.stderr.write(`dahrk: file logging disabled (${e.message})
2116
+ `);
2117
+ }
2118
+ if (this.fd !== void 0) {
2119
+ try {
2120
+ closeSync(this.fd);
2121
+ } catch {
2122
+ }
2123
+ }
2124
+ this.fd = void 0;
2125
+ }
2126
+ /** node.jsonl -> node.jsonl.1, .1 -> .2, ... dropping the oldest. */
2127
+ rotate() {
2128
+ if (this.fd === void 0) return;
2129
+ try {
2130
+ closeSync(this.fd);
2131
+ this.fd = void 0;
2132
+ const oldest = `${this.path}.${this.maxFiles}`;
2133
+ if (existsSync5(oldest)) rmSync4(oldest, { force: true });
2134
+ for (let i = this.maxFiles - 1; i >= 1; i--) {
2135
+ const from = `${this.path}.${i}`;
2136
+ if (existsSync5(from)) renameSync(from, `${this.path}.${i + 1}`);
2137
+ }
2138
+ if (existsSync5(this.path)) renameSync(this.path, `${this.path}.1`);
2139
+ this.open();
2140
+ } catch (e) {
2141
+ this.disable(e);
2142
+ }
2143
+ }
2144
+ write(line) {
2145
+ if (this.failed) return;
2146
+ if (this.fd === void 0) return;
2147
+ try {
2148
+ const buf = Buffer.from(line, "utf8");
2149
+ if (this.size > 0 && this.size + buf.length > this.maxBytes) {
2150
+ this.rotate();
2151
+ if (this.fd === void 0) return;
2152
+ }
2153
+ writeSync(this.fd, buf);
2154
+ this.size += buf.length;
2155
+ } catch (e) {
2156
+ this.disable(e);
2157
+ }
2158
+ }
2159
+ };
2160
+ var humanSink = {
2161
+ write(chunk) {
2162
+ let msg;
2163
+ try {
2164
+ msg = JSON.parse(chunk).msg ?? "";
2165
+ } catch {
2166
+ msg = chunk.trimEnd();
2167
+ }
2168
+ if (!msg) return;
2169
+ try {
2170
+ process.stdout.write(`${msg}
2171
+ `);
2172
+ } catch {
2173
+ }
2174
+ }
2175
+ };
2176
+ var pipeGuardInstalled = false;
2177
+ function guardBrokenPipe() {
2178
+ if (pipeGuardInstalled) return;
2179
+ pipeGuardInstalled = true;
2180
+ const ignoreEpipe = (err) => {
2181
+ if (err.code !== "EPIPE") throw err;
2182
+ };
2183
+ process.stdout.on("error", ignoreEpipe);
2184
+ process.stderr.on("error", ignoreEpipe);
2185
+ }
2186
+ function lowest(a, b) {
2187
+ const rank = (l) => LEVELS.indexOf(l);
2188
+ return rank(a) <= rank(b) ? a : b;
2189
+ }
2190
+ function createNodeLogger(opts = {}) {
2191
+ guardBrokenPipe();
2192
+ const level = opts.level ?? "info";
2193
+ const fileLevel = opts.fileLevel ?? "debug";
2194
+ const streams = [];
2195
+ if (level !== "silent") streams.push({ level, stream: humanSink });
2196
+ if (opts.dir && fileLevel !== "silent") {
2197
+ try {
2198
+ mkdirSync5(opts.dir, { recursive: true, mode: 448 });
2199
+ const file = new RotatingFile(join7(opts.dir, "node.jsonl"), opts.maxBytes ?? 10 * 1024 * 1024, opts.maxFiles ?? 5);
2200
+ streams.push({ level: fileLevel, stream: file });
2201
+ } catch (e) {
2202
+ process.stderr.write(`dahrk: file logging disabled (${e.message})
2203
+ `);
2204
+ }
2205
+ }
2206
+ const base = streams.length === 0 ? "silent" : opts.dir ? lowest(level, fileLevel) : level;
2207
+ return pino(
2208
+ {
2209
+ level: base,
2210
+ base: opts.base ?? {},
2211
+ // ISO time reads better than epoch millis in a support bundle a human is scanning.
2212
+ timestamp: pino.stdTimeFunctions.isoTime,
2213
+ hooks: {
2214
+ logMethod(args, method) {
2215
+ method.apply(this, scrubValue(args));
2216
+ }
2217
+ }
2218
+ },
2219
+ pino.multistream(streams)
2220
+ );
2221
+ }
2222
+ function createNodeLoggerFromEnv(env, dir, base) {
2223
+ const fileLevel = fileLevelFromEnv(env);
2224
+ return createNodeLogger({
2225
+ level: levelFromEnv(env),
2226
+ ...fileLevel === "off" ? {} : { dir, fileLevel },
2227
+ ...base ? { base } : {}
2228
+ });
2229
+ }
2230
+
1776
2231
  // ../../packages/edge/src/policy.ts
1777
2232
  var ALLOW = { verdict: "allow", policy: "none" };
1778
2233
  function evaluatePolicies(event, rules) {
@@ -1795,15 +2250,15 @@ function denyToolRule(tool3) {
1795
2250
  }
1796
2251
 
1797
2252
  // ../../packages/edge/src/stage-runner.ts
1798
- import { execFileSync as execFileSync3 } from "child_process";
2253
+ import { execFileSync as execFileSync4 } from "child_process";
1799
2254
  import { createHash as createHash3 } from "crypto";
1800
- import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
2255
+ import { mkdirSync as mkdirSync6, readdirSync as readdirSync3, readFileSync as readFileSync4, rmSync as rmSync5, writeFileSync as writeFileSync5 } from "fs";
1801
2256
  import { tmpdir as tmpdir2 } from "os";
1802
- import { isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve, sep as sep2 } from "path";
2257
+ import { isAbsolute as isAbsolute2, join as join8, relative as relative2, resolve, sep as sep2 } from "path";
1803
2258
  import { attachedDocBasename as attachedDocBasename2 } from "@dahrk/contracts";
1804
2259
 
1805
2260
  // ../../packages/edge/src/builtins.ts
1806
- import { execFileSync as execFileSync2 } from "child_process";
2261
+ import { execFileSync as execFileSync3 } from "child_process";
1807
2262
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
1808
2263
  "Write",
1809
2264
  "Edit",
@@ -1845,7 +2300,7 @@ function isDangerousRm(cmd) {
1845
2300
  }
1846
2301
  function currentBranch(worktreePath) {
1847
2302
  try {
1848
- return execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath }).toString().trim();
2303
+ return execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath }).toString().trim();
1849
2304
  } catch {
1850
2305
  return "";
1851
2306
  }
@@ -2020,7 +2475,7 @@ var attemptOf = (jobId) => {
2020
2475
  };
2021
2476
  var digest = (value) => `sha256:${createHash3("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16)}`;
2022
2477
  function writeScratchState(ref, job, attempt, status) {
2023
- const statePath = join6(ref.scratchPath, "state.json");
2478
+ const statePath = join8(ref.scratchPath, "state.json");
2024
2479
  let state;
2025
2480
  try {
2026
2481
  state = JSON.parse(readFileSync4(statePath, "utf8"));
@@ -2028,24 +2483,24 @@ function writeScratchState(ref, job, attempt, status) {
2028
2483
  state = { runId: job.runId, tenantId: job.tenantId, stages: {} };
2029
2484
  }
2030
2485
  state.stages[job.stageId] = { currentAttempt: attempt, status };
2031
- mkdirSync5(ref.scratchPath, { recursive: true });
2486
+ mkdirSync6(ref.scratchPath, { recursive: true });
2032
2487
  writeFileSync5(statePath, JSON.stringify(state, null, 2));
2033
2488
  }
2034
2489
  function writeIssueContext(ref, issueContext) {
2035
2490
  if (issueContext === void 0) return;
2036
2491
  try {
2037
- mkdirSync5(ref.scratchPath, { recursive: true });
2038
- writeFileSync5(join6(ref.scratchPath, "issue.md"), issueContext);
2492
+ mkdirSync6(ref.scratchPath, { recursive: true });
2493
+ writeFileSync5(join8(ref.scratchPath, "issue.md"), issueContext);
2039
2494
  } catch {
2040
2495
  }
2041
2496
  }
2042
2497
  function writeAttachedDocuments(ref, docs) {
2043
2498
  if (!docs || docs.length === 0) return;
2044
2499
  try {
2045
- const dir = join6(ref.scratchPath, "docs");
2046
- mkdirSync5(dir, { recursive: true });
2500
+ const dir = join8(ref.scratchPath, "docs");
2501
+ mkdirSync6(dir, { recursive: true });
2047
2502
  for (const doc of docs) {
2048
- writeFileSync5(join6(dir, `${attachedDocBasename2(doc)}.md`), doc.content);
2503
+ writeFileSync5(join8(dir, `${attachedDocBasename2(doc)}.md`), doc.content);
2049
2504
  }
2050
2505
  } catch {
2051
2506
  }
@@ -2063,8 +2518,8 @@ ${lines.join("\n")}
2063
2518
  function writeGuidance(ref, guidance) {
2064
2519
  if (!guidance || guidance.length === 0) return;
2065
2520
  try {
2066
- mkdirSync5(ref.scratchPath, { recursive: true });
2067
- writeFileSync5(join6(ref.scratchPath, "guidance.md"), renderGuidanceMarkdown(guidance));
2521
+ mkdirSync6(ref.scratchPath, { recursive: true });
2522
+ writeFileSync5(join8(ref.scratchPath, "guidance.md"), renderGuidanceMarkdown(guidance));
2068
2523
  } catch {
2069
2524
  }
2070
2525
  }
@@ -2098,13 +2553,13 @@ function readEmittedArtifact(ref, relPath) {
2098
2553
  }
2099
2554
  function scanScratchOutput(ref, preferRel) {
2100
2555
  try {
2101
- const names = readdirSync2(join6(ref.worktreePath, SCRATCH_OUTPUT_DIR)).filter(
2556
+ const names = readdirSync3(join8(ref.worktreePath, SCRATCH_OUTPUT_DIR)).filter(
2102
2557
  (n) => n.toLowerCase().endsWith(".md")
2103
2558
  );
2104
2559
  if (names.length === 0) return void 0;
2105
2560
  const preferBase = preferRel?.split("/").pop();
2106
2561
  const pick = preferBase && names.includes(preferBase) ? preferBase : names[0];
2107
- const raw = readFileSync4(join6(ref.worktreePath, SCRATCH_OUTPUT_DIR, pick), "utf8");
2562
+ const raw = readFileSync4(join8(ref.worktreePath, SCRATCH_OUTPUT_DIR, pick), "utf8");
2108
2563
  if (raw.trim().length === 0) return void 0;
2109
2564
  return { path: `${SCRATCH_OUTPUT_DIR}/${pick}`, content: capContent(raw) };
2110
2565
  } catch {
@@ -2114,7 +2569,7 @@ function scanScratchOutput(ref, preferRel) {
2114
2569
  function scanChangedMarkdown(ref) {
2115
2570
  const git = (args) => {
2116
2571
  try {
2117
- return execFileSync3("git", ["-C", ref.worktreePath, ...args], { encoding: "utf8" }).split("\n").map((s) => s.trim()).filter(Boolean);
2572
+ return execFileSync4("git", ["-C", ref.worktreePath, ...args], { encoding: "utf8" }).split("\n").map((s) => s.trim()).filter(Boolean);
2118
2573
  } catch {
2119
2574
  return [];
2120
2575
  }
@@ -2124,7 +2579,7 @@ function scanChangedMarkdown(ref) {
2124
2579
  );
2125
2580
  for (const rel of rels) {
2126
2581
  try {
2127
- const raw = readFileSync4(join6(ref.worktreePath, rel), "utf8");
2582
+ const raw = readFileSync4(join8(ref.worktreePath, rel), "utf8");
2128
2583
  if (raw.trim().length > 0) return { path: rel, content: capContent(raw) };
2129
2584
  } catch {
2130
2585
  }
@@ -2147,22 +2602,34 @@ function resolveStageArtifact(ref, emitArtifact, handedBack) {
2147
2602
  }
2148
2603
  function createStageRunner(deps) {
2149
2604
  const worktrees = /* @__PURE__ */ new Map();
2605
+ const log = deps.logger ?? createNodeLogger({ level: "silent" });
2150
2606
  const active = /* @__PURE__ */ new Map();
2151
2607
  const turnQueues = /* @__PURE__ */ new Map();
2152
2608
  const runToolCalls = /* @__PURE__ */ new Map();
2153
2609
  const lastUsed = /* @__PURE__ */ new Map();
2154
2610
  const inFlight = /* @__PURE__ */ new Map();
2611
+ const isBusy = (runId) => (inFlight.get(runId) ?? 0) > 0;
2612
+ const reaperDryRun = process.env.DAHRK_REAPER_DRY_RUN === "1";
2613
+ const reaper = createWorktreeReaper({
2614
+ worktreesDir: deps.gitService.worktreesDir,
2615
+ mirrorsDir: resolveMirrorsDir(),
2616
+ isBusy,
2617
+ logger: { info: (m) => console.log(`REAPER ${m}`), warn: (m) => console.warn(`REAPER ${m}`) }
2618
+ });
2155
2619
  const scratchOnly = /* @__PURE__ */ new Set();
2156
2620
  const teardownRun = async (runId) => {
2157
2621
  const ref = worktrees.get(runId);
2158
2622
  if (ref) {
2159
2623
  if (scratchOnly.has(runId)) {
2160
2624
  try {
2161
- rmSync3(ref.worktreePath, { recursive: true, force: true });
2162
- } catch {
2625
+ rmSync5(ref.worktreePath, { recursive: true, force: true });
2626
+ } catch (e) {
2627
+ log.warn({ err: e, runId, path: ref.worktreePath }, "teardown: could not remove scratch dir");
2163
2628
  }
2164
2629
  } else {
2165
- await deps.gitService.teardownWorktree(ref).catch(() => void 0);
2630
+ await deps.gitService.teardownWorktree(ref).catch((e) => {
2631
+ log.warn({ err: e, runId, path: ref.worktreePath }, "teardown: could not remove worktree");
2632
+ });
2166
2633
  }
2167
2634
  }
2168
2635
  worktrees.delete(runId);
@@ -2172,6 +2639,11 @@ function createStageRunner(deps) {
2172
2639
  };
2173
2640
  const applyRetention = async (keepRunId) => {
2174
2641
  const policy = deps.retention;
2642
+ await reaper.reap({
2643
+ ...policy?.maxRuns !== void 0 ? { maxRuns: policy.maxRuns } : {},
2644
+ ...policy?.maxAgeMs !== void 0 ? { maxIdleMs: policy.maxAgeMs } : {},
2645
+ ...reaperDryRun ? { dryRun: true } : {}
2646
+ }).catch(() => void 0);
2175
2647
  if (!policy) return;
2176
2648
  const prunable = (id) => id !== keepRunId && (inFlight.get(id) ?? 0) === 0;
2177
2649
  const now = Date.now();
@@ -2191,6 +2663,10 @@ function createStageRunner(deps) {
2191
2663
  }
2192
2664
  };
2193
2665
  return {
2666
+ isBusy,
2667
+ async reapWorktrees() {
2668
+ return reaper.reap(reaperDryRun ? { dryRun: true } : {});
2669
+ },
2194
2670
  async runJob(job) {
2195
2671
  const { stageId, jobId, runId, agentConfig } = job;
2196
2672
  const attempt = attemptOf(jobId);
@@ -2206,299 +2682,307 @@ function createStageRunner(deps) {
2206
2682
  return { jobId, status: "fail", summary: `edge does not serve repo "${job.workspaceRef.repoId}"` };
2207
2683
  }
2208
2684
  inFlight.set(runId, (inFlight.get(runId) ?? 0) + 1);
2209
- lastUsed.set(runId, Date.now());
2210
- let ref = worktrees.get(runId);
2211
- if (!ref) {
2212
- if (job.workspaceRef) {
2213
- ref = await deps.gitService.createWorktree({
2214
- repoId: job.workspaceRef.repoId,
2215
- gitUrl: job.workspaceRef.gitUrl,
2216
- baseBranch: job.workspaceRef.baseBranch,
2217
- runId,
2218
- repo: job.workspaceRef.repo,
2219
- // Stable per-issue branch (continuation); absent = legacy dahrk/<runId>.
2220
- ...job.workspaceRef.branch ? { branch: job.workspaceRef.branch } : {},
2221
- // Brokered git credential for this job; absent on ambient nodes (host creds used).
2222
- ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
2223
- });
2224
- } else {
2225
- const base = deps.scratchRoot ?? join6(tmpdir2(), "dahrk", "scratch");
2226
- const worktreePath = join6(base, runId);
2227
- const scratchPath = join6(worktreePath, ".skakel", "scratch");
2228
- mkdirSync5(scratchPath, { recursive: true });
2229
- ref = { repoId: "", gitUrl: "", repo: "", baseBranch: "", worktreePath, scratchPath };
2230
- scratchOnly.add(runId);
2685
+ try {
2686
+ lastUsed.set(runId, Date.now());
2687
+ let ref = worktrees.get(runId);
2688
+ if (!ref) {
2689
+ if (job.workspaceRef) {
2690
+ ref = await deps.gitService.createWorktree({
2691
+ repoId: job.workspaceRef.repoId,
2692
+ gitUrl: job.workspaceRef.gitUrl,
2693
+ baseBranch: job.workspaceRef.baseBranch,
2694
+ runId,
2695
+ repo: job.workspaceRef.repo,
2696
+ // Stable per-issue branch (continuation); absent = legacy dahrk/<runId>.
2697
+ ...job.workspaceRef.branch ? { branch: job.workspaceRef.branch } : {},
2698
+ // Re-enter from work a prior backup push preserved, rather than starting over from the base
2699
+ // (DHK-264). Carried on the contract but never plumbed through to the git service until now.
2700
+ ...job.workspaceRef.seedRef ? { seedRef: job.workspaceRef.seedRef } : {},
2701
+ // Brokered git credential for this job; absent on ambient nodes (host creds used).
2702
+ ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
2703
+ });
2704
+ } else {
2705
+ const base = deps.scratchRoot ?? join8(tmpdir2(), "dahrk", "scratch");
2706
+ const worktreePath = join8(base, runId);
2707
+ const scratchPath = join8(worktreePath, ".skakel", "scratch");
2708
+ mkdirSync6(scratchPath, { recursive: true });
2709
+ ref = { repoId: "", gitUrl: "", repo: "", baseBranch: "", worktreePath, scratchPath };
2710
+ scratchOnly.add(runId);
2711
+ }
2712
+ worktrees.set(runId, ref);
2231
2713
  }
2232
- worktrees.set(runId, ref);
2233
- }
2234
- writeScratchState(ref, job, attempt, "in-flight");
2235
- writeIssueContext(ref, job.issueContext);
2236
- writeGuidance(ref, job.guidance);
2237
- writeAttachedDocuments(ref, job.attachedDocuments);
2238
- if (job.agentConfig.emitArtifact) {
2239
- const artifactDir = resolveWorktreeRelativePath(ref, job.agentConfig.emitArtifact);
2240
- const slash = job.agentConfig.emitArtifact.lastIndexOf("/");
2241
- if (artifactDir && slash > 0) {
2714
+ writeScratchState(ref, job, attempt, "in-flight");
2715
+ writeIssueContext(ref, job.issueContext);
2716
+ writeGuidance(ref, job.guidance);
2717
+ writeAttachedDocuments(ref, job.attachedDocuments);
2718
+ if (job.agentConfig.emitArtifact) {
2719
+ const artifactDir = resolveWorktreeRelativePath(ref, job.agentConfig.emitArtifact);
2720
+ const slash = job.agentConfig.emitArtifact.lastIndexOf("/");
2721
+ if (artifactDir && slash > 0) {
2722
+ try {
2723
+ mkdirSync6(join8(ref.worktreePath, job.agentConfig.emitArtifact.slice(0, slash)), { recursive: true });
2724
+ } catch {
2725
+ }
2726
+ }
2727
+ }
2728
+ let gateway;
2729
+ const meta = {
2730
+ tenantId: job.tenantId,
2731
+ runId,
2732
+ stageId,
2733
+ jobId,
2734
+ attempt,
2735
+ runtime: agentConfig.runtime,
2736
+ model: agentConfig.model,
2737
+ sessionId: job.sessionId,
2738
+ configDigest: digest(agentConfig),
2739
+ startedAt: nowIso2()
2740
+ };
2741
+ const writer = createTraceWriter(ref.scratchPath, meta);
2742
+ const streamEvent = (e) => deps.trace?.event({ runId, stageId, attempt, tenantId: job.tenantId, event: e });
2743
+ streamEvent(
2744
+ writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "attempt-start" })
2745
+ );
2746
+ const shipFinalTrace = async (finalMeta) => {
2747
+ const sink = deps.trace;
2748
+ if (!sink) return;
2749
+ const base = { tenantId: job.tenantId, runId, stageId, attempt };
2242
2750
  try {
2243
- mkdirSync5(join6(ref.worktreePath, job.agentConfig.emitArtifact.slice(0, slash)), { recursive: true });
2751
+ for (const name of readdirSync3(join8(writer.dir, "blobs"))) {
2752
+ const bytes = readFileSync4(join8(writer.dir, "blobs", name));
2753
+ const { url } = await sink.requestBlobUrl({
2754
+ ...base,
2755
+ sha256: name,
2756
+ size: bytes.length,
2757
+ contentType: "application/octet-stream",
2758
+ slot: "blob"
2759
+ });
2760
+ if (url) await putBytes(url, bytes, "application/octet-stream");
2761
+ }
2244
2762
  } catch {
2245
2763
  }
2246
- }
2247
- }
2248
- let gateway;
2249
- const meta = {
2250
- tenantId: job.tenantId,
2251
- runId,
2252
- stageId,
2253
- jobId,
2254
- attempt,
2255
- runtime: agentConfig.runtime,
2256
- model: agentConfig.model,
2257
- sessionId: job.sessionId,
2258
- configDigest: digest(agentConfig),
2259
- startedAt: nowIso2()
2260
- };
2261
- const writer = createTraceWriter(ref.scratchPath, meta);
2262
- const streamEvent = (e) => deps.trace?.event({ runId, stageId, attempt, tenantId: job.tenantId, event: e });
2263
- streamEvent(
2264
- writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "attempt-start" })
2265
- );
2266
- const shipFinalTrace = async (finalMeta) => {
2267
- const sink = deps.trace;
2268
- if (!sink) return;
2269
- const base = { tenantId: job.tenantId, runId, stageId, attempt };
2270
- try {
2271
- for (const name of readdirSync2(join6(writer.dir, "blobs"))) {
2272
- const bytes = readFileSync4(join6(writer.dir, "blobs", name));
2273
- const { url } = await sink.requestBlobUrl({
2764
+ let archiveKey;
2765
+ try {
2766
+ const bytes = readFileSync4(join8(writer.dir, "trace.jsonl"));
2767
+ const sha = createHash3("sha256").update(bytes).digest("hex");
2768
+ const { key, url } = await sink.requestBlobUrl({
2274
2769
  ...base,
2275
- sha256: name,
2770
+ sha256: sha,
2276
2771
  size: bytes.length,
2277
- contentType: "application/octet-stream",
2278
- slot: "blob"
2772
+ contentType: "application/x-ndjson",
2773
+ slot: "archive"
2279
2774
  });
2280
- if (url) await putBytes(url, bytes, "application/octet-stream");
2775
+ if (url) await putBytes(url, bytes, "application/x-ndjson");
2776
+ archiveKey = key;
2777
+ } catch {
2281
2778
  }
2282
- } catch {
2283
- }
2284
- let archiveKey;
2285
- try {
2286
- const bytes = readFileSync4(join6(writer.dir, "trace.jsonl"));
2287
- const sha = createHash3("sha256").update(bytes).digest("hex");
2288
- const { key, url } = await sink.requestBlobUrl({
2289
- ...base,
2290
- sha256: sha,
2291
- size: bytes.length,
2292
- contentType: "application/x-ndjson",
2293
- slot: "archive"
2294
- });
2295
- if (url) await putBytes(url, bytes, "application/x-ndjson");
2296
- archiveKey = key;
2297
- } catch {
2298
- }
2299
- sink.finalised({ ...base, meta: finalMeta, eventCount: writer.count(), ...archiveKey ? { archiveKey } : {} });
2300
- };
2301
- const finish = async (status2, summary2, sessionId, costUsd, handedBackDoc) => {
2302
- active.delete(jobId);
2303
- turnQueues.delete(jobId);
2304
- await gateway?.stop().catch(() => void 0);
2305
- gateway = void 0;
2306
- streamEvent(
2307
- writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "stage-exit", status: status2 })
2308
- );
2309
- const endedAt = nowIso2();
2310
- writer.finalise({ status: status2, endedAt, ...sessionId ? { sessionId } : {} });
2311
- writeScratchState(ref, job, attempt, status2);
2312
- const finalMeta = {
2313
- ...meta,
2314
- status: status2,
2315
- endedAt,
2316
- ...sessionId ? { sessionId } : {},
2317
- ...costUsd !== void 0 ? { costUsd } : {}
2779
+ sink.finalised({ ...base, meta: finalMeta, eventCount: writer.count(), ...archiveKey ? { archiveKey } : {} });
2318
2780
  };
2319
- await shipFinalTrace(finalMeta).catch(() => void 0);
2320
- lastUsed.set(runId, Date.now());
2321
- inFlight.set(runId, Math.max(0, (inFlight.get(runId) ?? 1) - 1));
2322
- await applyRetention(runId).catch(() => void 0);
2323
- const wantsArtifact = agentConfig.emitArtifact !== void 0 || handedBackDoc !== void 0;
2324
- const resolved = status2 === "ok" && wantsArtifact ? resolveStageArtifact(ref, agentConfig.emitArtifact, handedBackDoc) : void 0;
2325
- if (status2 === "ok" && wantsArtifact) {
2326
- const detail = resolved ? `source=${resolved.source} path=${resolved.artifact.path} bytes=${resolved.artifact.content.length}` : "no document resolved (declared path, tool handoff, and scratch/changed-file scans all empty)";
2781
+ const finish = async (status2, summary2, sessionId, costUsd, handedBackDoc) => {
2782
+ active.delete(jobId);
2783
+ turnQueues.delete(jobId);
2784
+ await gateway?.stop().catch((e) => log.warn({ err: e, jobId }, "mcp gateway: stop failed"));
2785
+ gateway = void 0;
2327
2786
  streamEvent(
2328
- writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "artifact", detail })
2787
+ writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "stage-exit", status: status2 })
2329
2788
  );
2330
- }
2331
- return {
2332
- jobId,
2333
- status: status2,
2334
- summary: summary2,
2335
- ...sessionId ? { sessionId } : {},
2336
- ...costUsd !== void 0 ? { costUsd } : {},
2337
- ...resolved ? { artifact: resolved.artifact } : {}
2338
- };
2339
- };
2340
- if (deps.packCache && job.provision && job.provision.length > 0) {
2341
- try {
2342
- const overlay = await overlayComponents({
2343
- worktreePath: ref.worktreePath,
2344
- runtime: agentConfig.runtime,
2345
- components: job.provision,
2346
- cache: deps.packCache
2347
- });
2348
- const detail = `provision: ${overlay.written.length} written, ${overlay.skippedRepoLocal.length} repo-local, ${overlay.warnings.length} warning(s)`;
2349
- streamEvent(
2350
- writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "provision", detail })
2789
+ const endedAt = nowIso2();
2790
+ writer.finalise({ status: status2, endedAt, ...sessionId ? { sessionId } : {} });
2791
+ writeScratchState(ref, job, attempt, status2);
2792
+ const finalMeta = {
2793
+ ...meta,
2794
+ status: status2,
2795
+ endedAt,
2796
+ ...sessionId ? { sessionId } : {},
2797
+ ...costUsd !== void 0 ? { costUsd } : {}
2798
+ };
2799
+ await shipFinalTrace(finalMeta).catch(
2800
+ (e) => log.error({ err: e, runId, stageId: job.stageId, jobId, attempt }, "trace: failed to ship the final trace to the hub")
2351
2801
  );
2352
- const noteText = overlay.warnings.length > 0 ? `${detail}; ${overlay.warnings.join("; ")}` : detail;
2353
- deps.sendProgress({ jobId, kind: "observation", ts: nowIso2(), text: noteText });
2354
- } catch (e) {
2355
- const msg = `component provisioning failed: ${e.message}`;
2356
- writer.append({ seq: 0, ts: nowIso2(), type: "error", runtime: agentConfig.runtime, kind: "provision-failed", message: msg });
2357
- deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: msg });
2358
- return finish("fail", `${stageId}: ${msg}`, job.sessionId);
2802
+ lastUsed.set(runId, Date.now());
2803
+ await applyRetention(runId).catch((e) => log.warn({ err: e, runId }, "retention: pass failed"));
2804
+ const wantsArtifact = agentConfig.emitArtifact !== void 0 || handedBackDoc !== void 0;
2805
+ const resolved = status2 === "ok" && wantsArtifact ? resolveStageArtifact(ref, agentConfig.emitArtifact, handedBackDoc) : void 0;
2806
+ if (status2 === "ok" && wantsArtifact) {
2807
+ const detail = resolved ? `source=${resolved.source} path=${resolved.artifact.path} bytes=${resolved.artifact.content.length}` : "no document resolved (declared path, tool handoff, and scratch/changed-file scans all empty)";
2808
+ streamEvent(
2809
+ writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "artifact", detail })
2810
+ );
2811
+ }
2812
+ return {
2813
+ jobId,
2814
+ status: status2,
2815
+ summary: summary2,
2816
+ ...sessionId ? { sessionId } : {},
2817
+ ...costUsd !== void 0 ? { costUsd } : {},
2818
+ ...resolved ? { artifact: resolved.artifact } : {}
2819
+ };
2820
+ };
2821
+ if (deps.packCache && job.provision && job.provision.length > 0) {
2822
+ try {
2823
+ const overlay = await overlayComponents({
2824
+ worktreePath: ref.worktreePath,
2825
+ runtime: agentConfig.runtime,
2826
+ components: job.provision,
2827
+ cache: deps.packCache
2828
+ });
2829
+ const detail = `provision: ${overlay.written.length} written, ${overlay.skippedRepoLocal.length} repo-local, ${overlay.warnings.length} warning(s)`;
2830
+ streamEvent(
2831
+ writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "provision", detail })
2832
+ );
2833
+ const noteText = overlay.warnings.length > 0 ? `${detail}; ${overlay.warnings.join("; ")}` : detail;
2834
+ deps.sendProgress({ jobId, kind: "observation", ts: nowIso2(), text: noteText });
2835
+ } catch (e) {
2836
+ const msg = `component provisioning failed: ${e.message}`;
2837
+ writer.append({ seq: 0, ts: nowIso2(), type: "error", runtime: agentConfig.runtime, kind: "provision-failed", message: msg });
2838
+ deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: msg });
2839
+ return finish("fail", `${stageId}: ${msg}`, job.sessionId);
2840
+ }
2359
2841
  }
2360
- }
2361
- let counter = runToolCalls.get(runId);
2362
- if (!counter) {
2363
- counter = { count: 0 };
2364
- runToolCalls.set(runId, counter);
2365
- }
2366
- const jobRules = buildRules(job.policies ?? [], {
2367
- worktreePath: ref.worktreePath,
2368
- repoName: job.workspaceRef?.repo ?? "",
2369
- runToolCalls: counter
2370
- });
2371
- const rules = [...jobRules, ...deps.rules];
2372
- const entry = evaluatePolicies({ kind: "stage-entry", stageId }, rules);
2373
- if (entry.verdict === "deny") {
2374
- writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "policy-deny", detail: entry.reason });
2375
- return finish("fail", `${stageId}: denied at stage entry (${entry.policy})`, job.sessionId);
2376
- }
2377
- let denied = false;
2378
- const authorisedActions = [];
2379
- const runtime = agentConfig.runtime;
2380
- const actionKey = (tool3, input) => {
2381
- try {
2382
- return `${tool3}\0${JSON.stringify(input)}`;
2383
- } catch {
2384
- return `${tool3}\0`;
2842
+ let counter = runToolCalls.get(runId);
2843
+ if (!counter) {
2844
+ counter = { count: 0 };
2845
+ runToolCalls.set(runId, counter);
2385
2846
  }
2386
- };
2387
- const policyReason = (verdict) => verdict.reason ?? `tool action denied by ${verdict.policy}`;
2388
- const recordDeny = (verdict, toolUseId) => {
2389
- denied = true;
2390
- const reason = policyReason(verdict);
2391
- if (toolUseId) {
2392
- streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "observation", runtime, toolUseId, isError: true, output: { error: reason } }));
2847
+ const jobRules = buildRules(job.policies ?? [], {
2848
+ worktreePath: ref.worktreePath,
2849
+ repoName: job.workspaceRef?.repo ?? "",
2850
+ runToolCalls: counter
2851
+ });
2852
+ const rules = [...jobRules, ...deps.rules];
2853
+ const entry = evaluatePolicies({ kind: "stage-entry", stageId }, rules);
2854
+ if (entry.verdict === "deny") {
2855
+ writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime: agentConfig.runtime, event: "policy-deny", detail: entry.reason });
2856
+ return finish("fail", `${stageId}: denied at stage entry (${entry.policy})`, job.sessionId);
2393
2857
  }
2394
- streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime, event: "policy-deny", detail: reason }));
2395
- deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: reason });
2396
- };
2397
- const authorizeToolUse = (tool3, input) => {
2398
- const verdict = evaluatePolicies({ kind: "action", stageId, tool: tool3, input }, rules);
2399
- if (verdict.verdict === "deny") {
2400
- recordDeny(verdict);
2401
- } else {
2402
- authorisedActions.push(actionKey(tool3, input));
2858
+ let denied = false;
2859
+ const authorisedActions = [];
2860
+ const runtime = agentConfig.runtime;
2861
+ const actionKey = (tool3, input) => {
2862
+ try {
2863
+ return `${tool3}\0${JSON.stringify(input)}`;
2864
+ } catch {
2865
+ return `${tool3}\0`;
2866
+ }
2867
+ };
2868
+ const policyReason = (verdict) => verdict.reason ?? `tool action denied by ${verdict.policy}`;
2869
+ const recordDeny = (verdict, toolUseId) => {
2870
+ denied = true;
2871
+ const reason = policyReason(verdict);
2872
+ if (toolUseId) {
2873
+ streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "observation", runtime, toolUseId, isError: true, output: { error: reason } }));
2874
+ }
2875
+ streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime, event: "policy-deny", detail: reason }));
2876
+ deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: reason });
2877
+ };
2878
+ const authorizeToolUse = (tool3, input) => {
2879
+ const verdict = evaluatePolicies({ kind: "action", stageId, tool: tool3, input }, rules);
2880
+ if (verdict.verdict === "deny") {
2881
+ recordDeny(verdict);
2882
+ } else {
2883
+ authorisedActions.push(actionKey(tool3, input));
2884
+ }
2885
+ return verdict;
2886
+ };
2887
+ const onTrace = (event) => {
2888
+ if (event.type === "action") {
2889
+ const key = actionKey(event.tool, event.input);
2890
+ const authorised = authorisedActions.indexOf(key);
2891
+ if (authorised >= 0) {
2892
+ authorisedActions.splice(authorised, 1);
2893
+ } else {
2894
+ const verdict = evaluatePolicies(
2895
+ { kind: "action", stageId, tool: event.tool, input: event.input },
2896
+ rules
2897
+ );
2898
+ if (verdict.verdict === "deny") {
2899
+ streamEvent(writer.append(event));
2900
+ recordDeny(verdict, event.toolUseId);
2901
+ return;
2902
+ }
2903
+ }
2904
+ }
2905
+ streamEvent(writer.append(event));
2906
+ if (event.type !== "state") deps.sendProgress({ jobId, kind: event.type, ts: event.ts, ...previewOf(event) });
2907
+ };
2908
+ const mcpServers = agentConfig.mcpServers;
2909
+ if (mcpServers && mcpServers.length > 0 && runtime === "claude-code") {
2910
+ gateway = await startMcpGateway({ servers: mcpServers, creds: job.brokeredCreds ?? {} });
2403
2911
  }
2404
- return verdict;
2405
- };
2406
- const onTrace = (event) => {
2407
- if (event.type === "action") {
2408
- const key = actionKey(event.tool, event.input);
2409
- const authorised = authorisedActions.indexOf(key);
2410
- if (authorised >= 0) {
2411
- authorisedActions.splice(authorised, 1);
2912
+ const runner = deps.makeRunner(runtime);
2913
+ active.set(jobId, runner);
2914
+ const ctx = {
2915
+ config: agentConfig,
2916
+ workspace: ref,
2917
+ sessionId: job.sessionId,
2918
+ ...job.issueContext !== void 0 ? { issueContext: job.issueContext } : {},
2919
+ ...job.guidance !== void 0 ? { guidance: job.guidance } : {},
2920
+ ...job.gateFeedback !== void 0 ? { gateFeedback: job.gateFeedback } : {},
2921
+ ...job.attachedDocuments !== void 0 ? { attachedDocuments: job.attachedDocuments } : {},
2922
+ ...gateway ? { mcpProxyBaseUrl: gateway.baseUrl } : {},
2923
+ // brokered inference env for a managed node (no operator login). The runtime adapter
2924
+ // (Pi) / container executor apply it as the inference process env, so the raw key is
2925
+ // never surfaced to the agent's own tool calls. Absent on ambient nodes; inert for the Claude/
2926
+ // Codex adapters, which use ambient inference.
2927
+ ...job.runtimeEnv ? { runtimeEnv: job.runtimeEnv } : {},
2928
+ // The adapter persists each runtime-native record under the attempt's raw/ sidecar
2929
+ // and stamps the rawRef onto the emitted event.
2930
+ writeRaw: writer.writeRaw,
2931
+ authorizeToolUse,
2932
+ // Wire the interactive AskUserQuestion elicitation seam (DHK-344): the adapter calls this when
2933
+ // the agent asks a structured question, and the edge relays it to the hub as an `elicit` frame.
2934
+ ...deps.sendElicit ? {
2935
+ emitElicit: (question) => deps.sendElicit({
2936
+ jobId,
2937
+ prompt: question.prompt,
2938
+ options: question.options,
2939
+ ...question.multiSelect ? { multiSelect: true } : {}
2940
+ })
2941
+ } : {}
2942
+ };
2943
+ const interactive = agentConfig.interaction === "interactive";
2944
+ let result;
2945
+ let timedOut = false;
2946
+ const killMs = Math.max(0, Math.floor((job.timeout ?? 0) * 1e3));
2947
+ const killTimer = killMs > 0 ? setTimeout(() => {
2948
+ timedOut = true;
2949
+ void runner.cancel();
2950
+ }, killMs) : void 0;
2951
+ try {
2952
+ if (interactive) {
2953
+ const mailbox = new ManagedMailbox();
2954
+ turnQueues.set(jobId, mailbox);
2955
+ result = await runner.runInteractive(ctx, mailbox, onTrace);
2412
2956
  } else {
2413
- const verdict = evaluatePolicies(
2414
- { kind: "action", stageId, tool: event.tool, input: event.input },
2415
- rules
2416
- );
2417
- if (verdict.verdict === "deny") {
2418
- streamEvent(writer.append(event));
2419
- recordDeny(verdict, event.toolUseId);
2420
- return;
2957
+ result = await runner.runBatch(ctx, onTrace);
2958
+ }
2959
+ } finally {
2960
+ if (killTimer) clearTimeout(killTimer);
2961
+ }
2962
+ let status = timedOut ? "timeout" : result.status;
2963
+ if (status === "ok" && job.hooks && job.hooks.length > 0) {
2964
+ for (const cmd of job.hooks) {
2965
+ try {
2966
+ execFileSync4("sh", ["-c", cmd], { cwd: ref.worktreePath, stdio: ["pipe", "pipe", "pipe"] });
2967
+ } catch (e) {
2968
+ status = "fail";
2969
+ writer.append({ seq: 0, ts: nowIso2(), type: "error", runtime, kind: "hook-failed", message: `hook "${cmd}" failed: ${e.message}` });
2970
+ break;
2421
2971
  }
2422
2972
  }
2423
2973
  }
2424
- streamEvent(writer.append(event));
2425
- if (event.type !== "state") deps.sendProgress({ jobId, kind: event.type, ts: event.ts, ...previewOf(event) });
2426
- };
2427
- const mcpServers = agentConfig.mcpServers;
2428
- if (mcpServers && mcpServers.length > 0 && runtime === "claude-code") {
2429
- gateway = await startMcpGateway({ servers: mcpServers, creds: job.brokeredCreds ?? {} });
2430
- }
2431
- const runner = deps.makeRunner(runtime);
2432
- active.set(jobId, runner);
2433
- const ctx = {
2434
- config: agentConfig,
2435
- workspace: ref,
2436
- sessionId: job.sessionId,
2437
- ...job.issueContext !== void 0 ? { issueContext: job.issueContext } : {},
2438
- ...job.guidance !== void 0 ? { guidance: job.guidance } : {},
2439
- ...job.gateFeedback !== void 0 ? { gateFeedback: job.gateFeedback } : {},
2440
- ...job.attachedDocuments !== void 0 ? { attachedDocuments: job.attachedDocuments } : {},
2441
- ...gateway ? { mcpProxyBaseUrl: gateway.baseUrl } : {},
2442
- // brokered inference env for a managed node (no operator login). The runtime adapter
2443
- // (Pi) / container executor apply it as the inference process env, so the raw key is
2444
- // never surfaced to the agent's own tool calls. Absent on ambient nodes; inert for the Claude/
2445
- // Codex adapters, which use ambient inference.
2446
- ...job.runtimeEnv ? { runtimeEnv: job.runtimeEnv } : {},
2447
- // The adapter persists each runtime-native record under the attempt's raw/ sidecar
2448
- // and stamps the rawRef onto the emitted event.
2449
- writeRaw: writer.writeRaw,
2450
- authorizeToolUse,
2451
- // Wire the interactive AskUserQuestion elicitation seam (DHK-344): the adapter calls this when
2452
- // the agent asks a structured question, and the edge relays it to the hub as an `elicit` frame.
2453
- ...deps.sendElicit ? {
2454
- emitElicit: (question) => deps.sendElicit({
2455
- jobId,
2456
- prompt: question.prompt,
2457
- options: question.options,
2458
- ...question.multiSelect ? { multiSelect: true } : {}
2459
- })
2460
- } : {}
2461
- };
2462
- const interactive = agentConfig.interaction === "interactive";
2463
- let result;
2464
- let timedOut = false;
2465
- const killMs = Math.max(0, Math.floor((job.timeout ?? 0) * 1e3));
2466
- const killTimer = killMs > 0 ? setTimeout(() => {
2467
- timedOut = true;
2468
- void runner.cancel();
2469
- }, killMs) : void 0;
2470
- try {
2471
- if (interactive) {
2472
- const mailbox = new ManagedMailbox();
2473
- turnQueues.set(jobId, mailbox);
2474
- result = await runner.runInteractive(ctx, mailbox, onTrace);
2475
- } else {
2476
- result = await runner.runBatch(ctx, onTrace);
2974
+ let summary = result.summary ?? `${stageId}: ${status}`;
2975
+ if (!interactive && status === "ok") {
2976
+ try {
2977
+ summary = await runner.summarise(ctx);
2978
+ } catch {
2979
+ }
2477
2980
  }
2981
+ if (denied) summary += "\n\n(note: one or more tool actions were blocked by a deny-only policy guard.)";
2982
+ return finish(status, summary, result.sessionId ?? job.sessionId, result.costUsd, result.artifact);
2478
2983
  } finally {
2479
- if (killTimer) clearTimeout(killTimer);
2480
- }
2481
- let status = timedOut ? "timeout" : result.status;
2482
- if (status === "ok" && job.hooks && job.hooks.length > 0) {
2483
- for (const cmd of job.hooks) {
2484
- try {
2485
- execFileSync3("sh", ["-c", cmd], { cwd: ref.worktreePath, stdio: ["pipe", "pipe", "pipe"] });
2486
- } catch (e) {
2487
- status = "fail";
2488
- writer.append({ seq: 0, ts: nowIso2(), type: "error", runtime, kind: "hook-failed", message: `hook "${cmd}" failed: ${e.message}` });
2489
- break;
2490
- }
2491
- }
2492
- }
2493
- let summary = result.summary ?? `${stageId}: ${status}`;
2494
- if (!interactive && status === "ok") {
2495
- try {
2496
- summary = await runner.summarise(ctx);
2497
- } catch {
2498
- }
2984
+ inFlight.set(runId, Math.max(0, (inFlight.get(runId) ?? 1) - 1));
2499
2985
  }
2500
- if (denied) summary += "\n\n(note: one or more tool actions were blocked by a deny-only policy guard.)";
2501
- return finish(status, summary, result.sessionId ?? job.sessionId, result.costUsd, result.artifact);
2502
2986
  },
2503
2987
  async runPush(job) {
2504
2988
  const { runId, jobId } = job;
@@ -2641,19 +3125,27 @@ function createStageRunner(deps) {
2641
3125
 
2642
3126
  // ../../packages/edge/src/ws-client.ts
2643
3127
  var ENROLMENT_REJECTED_EXIT_CODE = 78;
2644
- var log = (line) => void process.stdout.write(`${line}
2645
- `);
2646
3128
  async function startEdgeNode(opts) {
3129
+ const log = opts.logger ?? createNodeLogger({ level: levelFromEnv(process.env) });
2647
3130
  const rules = opts.denyTool ? [denyToolRule(opts.denyTool)] : [];
3131
+ const gitLog = log.child({ component: "git" });
3132
+ const gitLogger = {
3133
+ info: (msg) => gitLog.debug(msg),
3134
+ warn: (msg) => gitLog.warn(msg)
3135
+ };
3136
+ let stageRunnerRef;
2648
3137
  const gitService = createGitService({
2649
3138
  worktreesDir: opts.worktreesDir,
2650
- mirrorsDir: opts.mirrorsDir
3139
+ mirrorsDir: opts.mirrorsDir,
3140
+ isBusy: (runId) => stageRunnerRef?.isBusy(runId) ?? false,
3141
+ logger: gitLogger
2651
3142
  });
2652
3143
  let ws;
2653
3144
  let heartbeat;
2654
3145
  let reconnectTimer;
2655
3146
  let lastPongAt = 0;
2656
3147
  let shuttingDown = false;
3148
+ let connectCount = 0;
2657
3149
  let onFatal;
2658
3150
  const send = (msg) => {
2659
3151
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(encode(msg));
@@ -2663,8 +3155,9 @@ async function startEdgeNode(opts) {
2663
3155
  if (heartbeat) clearInterval(heartbeat);
2664
3156
  lastPongAt = Date.now();
2665
3157
  heartbeat = setInterval(() => {
2666
- if (Date.now() - lastPongAt > MISSED_PONGS_BEFORE_DEAD * intervalMs) {
2667
- log(`EDGE_STALE:${Date.now() - lastPongAt}ms without a pong, terminating socket`);
3158
+ const sincePongMs = Date.now() - lastPongAt;
3159
+ if (sincePongMs > MISSED_PONGS_BEFORE_DEAD * intervalMs) {
3160
+ log.warn({ sincePongMs, intervalMs }, `EDGE_STALE:${sincePongMs}ms without a pong, terminating socket`);
2668
3161
  sock.terminate();
2669
3162
  return;
2670
3163
  }
@@ -2699,6 +3192,7 @@ async function startEdgeNode(opts) {
2699
3192
  const stageDeps = {
2700
3193
  gitService,
2701
3194
  makeRunner,
3195
+ logger: log,
2702
3196
  ...opts.servesRepoIds ? { servesRepoIds: opts.servesRepoIds } : {},
2703
3197
  ...opts.tenantId ? { tenantId: opts.tenantId } : {},
2704
3198
  rules,
@@ -2710,6 +3204,16 @@ async function startEdgeNode(opts) {
2710
3204
  ...opts.retention ? { retention: opts.retention } : {}
2711
3205
  };
2712
3206
  const stageRunner = createStageRunner(stageDeps);
3207
+ stageRunnerRef = stageRunner;
3208
+ void stageRunner.reapWorktrees().then((r) => {
3209
+ if (r.reaped.length) {
3210
+ log.info(
3211
+ { reaped: r.reaped, scanned: r.scanned, skipped: r.skipped },
3212
+ `EDGE_REAPED:${r.reaped.length} worktrees (scanned ${r.scanned}, skipped ${r.skipped})`
3213
+ );
3214
+ }
3215
+ for (const e of r.errors) log.warn({ reapError: e }, `EDGE_REAP_ERROR:${e}`);
3216
+ }).catch((e) => log.warn({ err: e }, `EDGE_REAP_ERROR:${e.message}`));
2713
3217
  const nodeId = opts.nodeId ?? randomUUID();
2714
3218
  const running = /* @__PURE__ */ new Set();
2715
3219
  const onMessage = async (raw) => {
@@ -2720,7 +3224,10 @@ async function startEdgeNode(opts) {
2720
3224
  if (opts.heartbeatMs === void 0 && msg.heartbeatMs > 0 && ws) {
2721
3225
  startHeartbeat(ws, msg.heartbeatMs);
2722
3226
  }
2723
- log(`EDGE_WELCOMED:${msg.name} tenant=${msg.tenantId} credentialMode=${msg.credentialMode}`);
3227
+ log.info(
3228
+ { name: msg.name, tenantId: msg.tenantId, credentialMode: msg.credentialMode, heartbeatMs: msg.heartbeatMs },
3229
+ `EDGE_WELCOMED:${msg.name} tenant=${msg.tenantId} credentialMode=${msg.credentialMode}`
3230
+ );
2724
3231
  try {
2725
3232
  opts.onEnrolled?.({
2726
3233
  name: msg.name,
@@ -2728,7 +3235,7 @@ async function startEdgeNode(opts) {
2728
3235
  credentialMode: msg.credentialMode
2729
3236
  });
2730
3237
  } catch (e) {
2731
- log(`EDGE_ENROL_PERSIST_FAILED ${e.message}`);
3238
+ log.warn({ err: e }, `EDGE_ENROL_PERSIST_FAILED ${e.message}`);
2732
3239
  }
2733
3240
  return;
2734
3241
  }
@@ -2741,7 +3248,7 @@ async function startEdgeNode(opts) {
2741
3248
  return;
2742
3249
  }
2743
3250
  if (msg.type === "cancel") {
2744
- log(`JOB_CANCEL:${msg.jobId}`);
3251
+ log.info({ jobId: msg.jobId }, `JOB_CANCEL:${msg.jobId}`);
2745
3252
  stageRunner.cancel(msg.jobId);
2746
3253
  return;
2747
3254
  }
@@ -2749,29 +3256,34 @@ async function startEdgeNode(opts) {
2749
3256
  if (msg.end === "cancel") stageRunner.cancel(msg.jobId);
2750
3257
  else if (msg.end === "complete") stageRunner.endTurns(msg.jobId);
2751
3258
  else if (msg.turn) stageRunner.enqueueTurn(msg.jobId, msg.turn);
2752
- log(`JOB_TURN:${msg.jobId}${msg.end ? `:${msg.end}` : ""}`);
3259
+ log.info({ jobId: msg.jobId, ...msg.end ? { end: msg.end } : {} }, `JOB_TURN:${msg.jobId}${msg.end ? `:${msg.end}` : ""}`);
2753
3260
  return;
2754
3261
  }
2755
3262
  if (msg.type === "push") {
2756
3263
  const { job: job2 } = msg;
3264
+ const pushLog = log.child({ runId: job2.runId, jobId: job2.jobId, branch: job2.branch });
2757
3265
  if (running.has(job2.jobId)) {
2758
- log(`PUSH_DUPLICATE:${job2.runId} ${job2.jobId}`);
3266
+ pushLog.warn({}, `PUSH_DUPLICATE:${job2.runId} ${job2.jobId}`);
2759
3267
  return;
2760
3268
  }
2761
3269
  const cachedPush = lastResults.get(job2.jobId);
2762
3270
  if (cachedPush) {
2763
3271
  send(cachedPush);
2764
- log(`PUSH_REPLAY:${job2.runId} ${job2.jobId}`);
3272
+ pushLog.info({}, `PUSH_REPLAY:${job2.runId} ${job2.jobId}`);
2765
3273
  return;
2766
3274
  }
2767
3275
  running.add(job2.jobId);
2768
- log(`PUSH_STARTED:${job2.runId} ${job2.branch}`);
3276
+ const pushStartedAt = Date.now();
3277
+ pushLog.info({}, `PUSH_STARTED:${job2.runId} ${job2.branch}`);
2769
3278
  try {
2770
3279
  const result = await stageRunner.runPush(job2);
2771
3280
  const frame = { type: "push-result", awakeableId: job2.awakeableId, result };
2772
3281
  rememberResult(job2.jobId, frame);
2773
3282
  send(frame);
2774
- log(`PUSH_DONE:${job2.runId} ${result.status}${result.status !== "ok" ? ` - ${result.summary}` : ""}`);
3283
+ pushLog.info(
3284
+ { status: result.status, summary: result.summary, durationMs: Date.now() - pushStartedAt },
3285
+ `PUSH_DONE:${job2.runId} ${result.status}${result.status !== "ok" ? ` - ${result.summary}` : ""}`
3286
+ );
2775
3287
  } catch (e) {
2776
3288
  const frame = {
2777
3289
  type: "push-result",
@@ -2780,7 +3292,7 @@ async function startEdgeNode(opts) {
2780
3292
  };
2781
3293
  rememberResult(job2.jobId, frame);
2782
3294
  send(frame);
2783
- log(`PUSH_ERROR:${job2.runId} ${e.message}`);
3295
+ pushLog.error({ err: e, durationMs: Date.now() - pushStartedAt }, `PUSH_ERROR:${job2.runId} ${e.message}`);
2784
3296
  } finally {
2785
3297
  running.delete(job2.jobId);
2786
3298
  }
@@ -2788,24 +3300,36 @@ async function startEdgeNode(opts) {
2788
3300
  }
2789
3301
  if (msg.type !== "job") return;
2790
3302
  const { job } = msg;
3303
+ const jobLog = log.child({
3304
+ runId: job.runId,
3305
+ stageId: job.stageId,
3306
+ jobId: job.jobId,
3307
+ ...job.tenantId ? { tenantId: job.tenantId } : {},
3308
+ ...job.agentConfig?.runtime ? { runtime: job.agentConfig.runtime } : {},
3309
+ ...job.agentConfig?.model ? { model: job.agentConfig.model } : {}
3310
+ });
2791
3311
  if (running.has(job.jobId)) {
2792
- log(`JOB_DUPLICATE:${job.stageId} ${job.jobId}`);
3312
+ jobLog.warn({}, `JOB_DUPLICATE:${job.stageId} ${job.jobId}`);
2793
3313
  return;
2794
3314
  }
2795
3315
  const cachedJob = lastResults.get(job.jobId);
2796
3316
  if (cachedJob) {
2797
3317
  send(cachedJob);
2798
- log(`JOB_REPLAY:${job.stageId} ${job.jobId}`);
3318
+ jobLog.info({}, `JOB_REPLAY:${job.stageId} ${job.jobId}`);
2799
3319
  return;
2800
3320
  }
2801
3321
  running.add(job.jobId);
2802
- log(`JOB_STARTED:${job.stageId} ${job.jobId}`);
3322
+ const startedAt = Date.now();
3323
+ jobLog.info({}, `JOB_STARTED:${job.stageId} ${job.jobId}`);
2803
3324
  try {
2804
3325
  const result = await stageRunner.runJob(job);
2805
3326
  const frame = { type: "result", awakeableId: job.awakeableId, result };
2806
3327
  rememberResult(job.jobId, frame);
2807
3328
  send(frame);
2808
- log(`JOB_DONE:${job.stageId} ${result.status}`);
3329
+ jobLog.info(
3330
+ { status: result.status, costUsd: result.costUsd, summary: result.summary, durationMs: Date.now() - startedAt },
3331
+ `JOB_DONE:${job.stageId} ${result.status}`
3332
+ );
2809
3333
  } catch (e) {
2810
3334
  const frame = {
2811
3335
  type: "result",
@@ -2814,7 +3338,7 @@ async function startEdgeNode(opts) {
2814
3338
  };
2815
3339
  rememberResult(job.jobId, frame);
2816
3340
  send(frame);
2817
- log(`JOB_ERROR:${job.stageId} ${e.message}`);
3341
+ jobLog.error({ err: e, durationMs: Date.now() - startedAt }, `JOB_ERROR:${job.stageId} ${e.message}`);
2818
3342
  } finally {
2819
3343
  running.delete(job.jobId);
2820
3344
  }
@@ -2823,7 +3347,8 @@ async function startEdgeNode(opts) {
2823
3347
  const sock = new WebSocket(opts.hubUrl);
2824
3348
  ws = sock;
2825
3349
  sock.on("open", () => {
2826
- log("EDGE_CONNECTED");
3350
+ connectCount++;
3351
+ log.info({ hubUrl: opts.hubUrl, nodeId, connectCount }, "EDGE_CONNECTED");
2827
3352
  send({
2828
3353
  type: "hello",
2829
3354
  enrolToken: opts.enrolToken ?? "",
@@ -2847,19 +3372,22 @@ async function startEdgeNode(opts) {
2847
3372
  sock.on("pong", () => {
2848
3373
  lastPongAt = Date.now();
2849
3374
  });
2850
- sock.on("error", (e) => log(`EDGE_ERROR ${e.message}`));
3375
+ sock.on("error", (e) => log.error({ err: e, hubUrl: opts.hubUrl }, `EDGE_ERROR ${e.message}`));
2851
3376
  sock.on("close", (code, reason) => {
2852
3377
  if (heartbeat) clearInterval(heartbeat);
2853
3378
  heartbeat = void 0;
2854
3379
  if (isEnrolmentRejection(code)) {
2855
3380
  shuttingDown = true;
2856
3381
  const detail = reason.toString() || "enrolment rejected";
2857
- log(`EDGE_REJECTED:${code} ${detail}`);
3382
+ log.error({ closeCode: code, detail, fatal: true }, `EDGE_REJECTED:${code} ${detail}`);
2858
3383
  process.exitCode = ENROLMENT_REJECTED_EXIT_CODE;
2859
3384
  onFatal?.(new Error(`hub rejected edge enrolment (${code}): ${detail}`));
2860
3385
  return;
2861
3386
  }
2862
- if (!shuttingDown) reconnectTimer = setTimeout(connect, 500);
3387
+ if (!shuttingDown) {
3388
+ log.warn({ closeCode: code, reason: reason.toString(), connectCount }, `EDGE_DISCONNECTED:${code} reconnecting in 500ms`);
3389
+ reconnectTimer = setTimeout(connect, 500);
3390
+ }
2863
3391
  });
2864
3392
  };
2865
3393
  opts.signal?.addEventListener(
@@ -3019,7 +3547,19 @@ function probeHub(opts) {
3019
3547
 
3020
3548
  // src/cli.ts
3021
3549
  import { parseArgs } from "util";
3022
- var COMMANDS = /* @__PURE__ */ new Set(["start", "run", "service", "doctor", "status", "update"]);
3550
+ var DEFAULT_LOG_LINES = 200;
3551
+ var COMMANDS = /* @__PURE__ */ new Set([
3552
+ "start",
3553
+ "stop",
3554
+ "restart",
3555
+ "logs",
3556
+ "diagnose",
3557
+ "run",
3558
+ "service",
3559
+ "doctor",
3560
+ "status",
3561
+ "update"
3562
+ ]);
3023
3563
  var isCommand = (s) => COMMANDS.has(s);
3024
3564
  function parseCli(argv) {
3025
3565
  const [first, ...rest] = argv;
@@ -3040,6 +3580,8 @@ function parseCli(argv) {
3040
3580
  if (command === "run") return parseRun(flagArgs);
3041
3581
  if (command === "service") return parseService(flagArgs);
3042
3582
  if (command === "update") return parseUpdate(flagArgs);
3583
+ if (command === "logs") return parseLogs(flagArgs);
3584
+ if (command === "diagnose") return parseDiagnose(flagArgs);
3043
3585
  let values;
3044
3586
  try {
3045
3587
  ({ values } = parseArgs({
@@ -3049,6 +3591,7 @@ function parseCli(argv) {
3049
3591
  name: { type: "string" },
3050
3592
  "hub-url": { type: "string" },
3051
3593
  ephemeral: { type: "boolean", default: false },
3594
+ foreground: { type: "boolean", default: false },
3052
3595
  help: { type: "boolean", default: false }
3053
3596
  },
3054
3597
  allowPositionals: false
@@ -3057,14 +3600,74 @@ function parseCli(argv) {
3057
3600
  return { kind: "error", message: e.message };
3058
3601
  }
3059
3602
  if (values.help) return { kind: "help", command };
3603
+ const ephemeral = values.ephemeral ?? false;
3060
3604
  const flags = {
3061
3605
  ...values.token ? { token: values.token } : {},
3062
3606
  ...values.name ? { name: values.name } : {},
3063
3607
  ...values["hub-url"] ? { hubUrl: values["hub-url"] } : {},
3064
- ephemeral: values.ephemeral ?? false
3608
+ ephemeral,
3609
+ // An ephemeral node mints a throwaway id and persists nothing, so there is nothing coherent to hand to
3610
+ // a supervisor that restarts it on boot. It is a foreground node by definition.
3611
+ foreground: (values.foreground ?? false) || ephemeral
3065
3612
  };
3613
+ if (command === "stop") return { kind: "stop" };
3066
3614
  return { kind: command, flags };
3067
3615
  }
3616
+ var LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"];
3617
+ function parseLogs(flagArgs) {
3618
+ let values;
3619
+ try {
3620
+ ({ values } = parseArgs({
3621
+ args: flagArgs,
3622
+ options: {
3623
+ follow: { type: "boolean", short: "f", default: false },
3624
+ lines: { type: "string", short: "n" },
3625
+ level: { type: "string" },
3626
+ run: { type: "string" },
3627
+ json: { type: "boolean", default: false },
3628
+ help: { type: "boolean", default: false }
3629
+ },
3630
+ allowPositionals: false
3631
+ }));
3632
+ } catch (e) {
3633
+ return { kind: "error", message: e.message };
3634
+ }
3635
+ if (values.help) return { kind: "help", command: "logs" };
3636
+ const lines = values.lines === void 0 ? DEFAULT_LOG_LINES : Number(values.lines);
3637
+ if (!Number.isInteger(lines) || lines < 0) {
3638
+ return { kind: "error", message: `logs: --lines must be a non-negative whole number (got "${values.lines}")` };
3639
+ }
3640
+ if (values.level !== void 0 && !LOG_LEVELS.includes(values.level)) {
3641
+ return { kind: "error", message: `logs: --level must be one of ${LOG_LEVELS.join(", ")} (got "${values.level}")` };
3642
+ }
3643
+ return {
3644
+ kind: "logs",
3645
+ flags: {
3646
+ lines,
3647
+ follow: values.follow ?? false,
3648
+ ...values.level ? { level: values.level } : {},
3649
+ ...values.run ? { run: values.run } : {},
3650
+ json: values.json ?? false
3651
+ }
3652
+ };
3653
+ }
3654
+ function parseDiagnose(flagArgs) {
3655
+ let values;
3656
+ try {
3657
+ ({ values } = parseArgs({
3658
+ args: flagArgs,
3659
+ options: {
3660
+ out: { type: "string" },
3661
+ help: { type: "boolean", default: false }
3662
+ },
3663
+ allowPositionals: false
3664
+ }));
3665
+ } catch (e) {
3666
+ return { kind: "error", message: e.message };
3667
+ }
3668
+ if (values.help) return { kind: "help", command: "diagnose" };
3669
+ return { kind: "diagnose", flags: { ...values.out ? { out: values.out } : {} } };
3670
+ }
3068
3671
  function parseRun(flagArgs) {
3069
3672
  let values;
3070
3673
  let positionals;
@@ -3157,16 +3760,96 @@ function usage(bin, command) {
3157
3760
  return [
3158
3761
  `Usage: ${bin} start [--token <token>] [options]`,
3159
3762
  "",
3160
- "Run the edge node: dial the hub over WebSocket and serve Jobs in git worktrees.",
3763
+ "Make this node run, and keep running: install it as an always-on service (launchd / systemd), start",
3764
+ "it, and return. It restarts on failure and comes back after a reboot. Running it again is a no-op if",
3765
+ "the node is already up, so it is safe to repeat.",
3161
3766
  "",
3162
3767
  "A token is needed only to enrol. Once the hub accepts it, it is cached in ~/.dahrk/node.json and",
3163
3768
  "every later `start` re-attaches without one. Pass --token again to re-enrol (rotated token, new pool).",
3164
3769
  "",
3770
+ "Once it is running:",
3771
+ ` ${bin} logs -f Watch what it is doing.`,
3772
+ ` ${bin} status Is it enrolled, and is it up?`,
3773
+ ` ${bin} stop Stop it (it stays stopped until the next \`start\`).`,
3774
+ "",
3165
3775
  "Options:",
3166
3776
  " --token <token> Enrolment token (first run only; or set DAHRK_ENROL_TOKEN).",
3167
3777
  " --hub-url <url> Hub WebSocket URL (or set DAHRK_HUB_URL).",
3168
3778
  " --name <name> Display-name override (else the hub assigns one).",
3169
- " --ephemeral Do not persist (or read) node id and token; mint a throwaway id (CI / one-shot)."
3779
+ " --foreground Run the node in THIS terminal and block, instead of as a service. For",
3780
+ " containers, pm2, CI, or just watching one work. Ctrl-C stops it. Same thing as",
3781
+ " setting DAHRK_FOREGROUND=1, which is easier to set in a Dockerfile or pm2 config.",
3782
+ " --ephemeral Do not persist (or read) node id and token; mint a throwaway id (CI / one-shot).",
3783
+ " Implies --foreground: a node with no persistent identity has nothing to daemonise.",
3784
+ "",
3785
+ "Only one node may run at a time on a host - they would share this machine's node id and race each",
3786
+ "other for Jobs - so a second `start` refuses rather than dialling the hub twice."
3787
+ ].join("\n");
3788
+ }
3789
+ if (command === "stop") {
3790
+ return [
3791
+ `Usage: ${bin} stop`,
3792
+ "",
3793
+ "Stop the node. It stays stopped across reboots until you run `dahrk start` again - a stop that",
3794
+ "quietly undid itself at the next boot would not be a stop.",
3795
+ "",
3796
+ "The service stays installed, so starting it again is instant. To remove it entirely, use",
3797
+ "`dahrk service uninstall`. To stop a node running in a terminal (--foreground), press Ctrl-C."
3798
+ ].join("\n");
3799
+ }
3800
+ if (command === "restart") {
3801
+ return [
3802
+ `Usage: ${bin} restart [options]`,
3803
+ "",
3804
+ "Stop the node and start it again. Picks up a new client version, a rotated token, or a runtime you",
3805
+ "have just installed (the node detects runtimes at boot, so a fresh `claude` on PATH needs a restart).",
3806
+ "",
3807
+ "Options:",
3808
+ " --token <token> Re-enrol with a new token (or set DAHRK_ENROL_TOKEN).",
3809
+ " --hub-url <url> Hub WebSocket URL (or set DAHRK_HUB_URL).",
3810
+ " --name <name> Display-name override (else the hub assigns one)."
3811
+ ].join("\n");
3812
+ }
3813
+ if (command === "logs") {
3814
+ return [
3815
+ `Usage: ${bin} logs [-f] [-n <lines>] [--level <l>] [--run <id>] [--json]`,
3816
+ "",
3817
+ "Show what the node has been doing: its connect / welcome handshake, the Jobs it has run, and anything",
3818
+ "it has crashed on.",
3819
+ "",
3820
+ "There are two logs, and this reads whichever you ask for. On its own it tails the transcript the",
3821
+ "service captured (~/.dahrk/logs/node.out.log and node.err.log) - the same lines the node printed.",
3822
+ "Pass --level, --run or --json and it reads node.jsonl instead: the node's own structured log, which",
3823
+ "is the one carrying levels, timestamps, correlation ids and full error stacks. It is written at",
3824
+ "debug even when the terminal is not, so the detail for an incident is already there afterwards.",
3825
+ "",
3826
+ "Options:",
3827
+ " -f, --follow Keep watching as new lines arrive (Ctrl-C to stop).",
3828
+ " -n, --lines <n> Lines of history to show first (default 200).",
3829
+ " --level <level> Only this level and above: trace, debug, info, warn, error, fatal.",
3830
+ " --run <runId> Only this run. The runId is the hub's - so `logs --run <id>` and the hub's",
3831
+ " view of the same run describe the same thing from both ends.",
3832
+ " --json Print the raw JSON records (for jq) rather than a rendered line.",
3833
+ "",
3834
+ "A node run with --foreground prints to its terminal, but it still writes node.jsonl - so --run and",
3835
+ "--level work for it too."
3836
+ ].join("\n");
3837
+ }
3838
+ if (command === "diagnose") {
3839
+ return [
3840
+ `Usage: ${bin} diagnose [--out <path>]`,
3841
+ "",
3842
+ "Write a support bundle: everything needed to debug this node, in one file you can read.",
3843
+ "",
3844
+ "It collects this node's id, name, tenant, version and host; the doctor's verdict; the tail of the",
3845
+ "structured log; and every crash record. Secrets are stripped and the enrolment token is not",
3846
+ "included. Your source code, prompts and issue content are not included.",
3847
+ "",
3848
+ "Nothing is uploaded. There is no flag to upload it. The file is written locally so that you can open",
3849
+ "it, read every line, and decide for yourself whether to send it on.",
3850
+ "",
3851
+ "Options:",
3852
+ " --out <path> Write the bundle here (default: ./dahrk-diagnose-<timestamp>.json)."
3170
3853
  ].join("\n");
3171
3854
  }
3172
3855
  if (command === "run") {
@@ -3242,19 +3925,139 @@ function usage(bin, command) {
3242
3925
  `Usage: ${bin} <command> [options]`,
3243
3926
  "",
3244
3927
  "Commands:",
3245
- " start Run the edge node (default). Needs a --token to enrol; cached thereafter.",
3928
+ " start Run the node, and keep it running (installs the service). --token to enrol, once.",
3929
+ " stop Stop the node. It stays stopped until the next `start`.",
3930
+ " restart Stop it and start it again.",
3931
+ " logs Show what the node is doing (-f to follow, --run <id> to narrow to one run).",
3932
+ " diagnose Write a support bundle you can read, and send on if you choose. Uploads nothing.",
3933
+ " status Is this node enrolled, and is it up? (local, dials nothing)",
3246
3934
  " run Run a workflow locally (engine-backed), e.g. `run preflight`.",
3247
- " service Install/uninstall the node as an always-on service (launchd/systemd).",
3248
- " status Is this node enrolled, and is the service running? (local, dials nothing)",
3249
3935
  " doctor Preflight checks: Node, runtimes, hub reachability, token validity.",
3936
+ " service Install/uninstall the always-on service by hand (`start` does this for you).",
3250
3937
  " update Update the client to the latest release (or print how for your channel).",
3251
3938
  " version Print the client version.",
3252
3939
  " help Show this help, or `help <command>` for a command's options.",
3253
3940
  "",
3254
- `Run \`${bin} help <command>\` for command-specific options.`
3941
+ `Run \`${bin} help <command>\` for command-specific options.`,
3942
+ `To run a node in this terminal instead of as a service: \`${bin} start --foreground\`.`
3255
3943
  ].join("\n");
3256
3944
  }
3257
3945
 
3946
+ // src/diagnose.ts
3947
+ import { existsSync as existsSync6, mkdirSync as mkdirSync7, readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync3, writeFileSync as writeFileSync6 } from "fs";
3948
+ import { join as join9 } from "path";
3949
+ var BUNDLE_LOG_LINES = 2e3;
3950
+ function tailJsonl(raw, n) {
3951
+ const records = [];
3952
+ for (const line of raw.split("\n")) {
3953
+ if (!line.trim()) continue;
3954
+ try {
3955
+ records.push(JSON.parse(line));
3956
+ } catch {
3957
+ }
3958
+ }
3959
+ return n > 0 ? records.slice(-n) : records;
3960
+ }
3961
+ async function buildBundle(deps) {
3962
+ const warnings = [];
3963
+ let state = {};
3964
+ if (deps.exists(deps.stateFile)) {
3965
+ try {
3966
+ const parsed = JSON.parse(deps.readFile(deps.stateFile));
3967
+ const { enrolToken: _dropped, ...rest } = parsed;
3968
+ state = rest;
3969
+ } catch (e) {
3970
+ warnings.push(`could not read node state (${e.message})`);
3971
+ }
3972
+ } else {
3973
+ warnings.push("no node.json - this node has never enrolled");
3974
+ }
3975
+ let log = [];
3976
+ if (deps.exists(deps.jsonlFile)) {
3977
+ try {
3978
+ log = tailJsonl(deps.readFile(deps.jsonlFile), BUNDLE_LOG_LINES);
3979
+ } catch (e) {
3980
+ warnings.push(`could not read node.jsonl (${e.message})`);
3981
+ }
3982
+ } else {
3983
+ warnings.push("no node.jsonl - the node has not run since structured logging was added");
3984
+ }
3985
+ const crashes = [];
3986
+ if (deps.exists(deps.crashDir)) {
3987
+ for (const name of deps.listDir(deps.crashDir).filter((f) => f.endsWith(".json")).sort()) {
3988
+ try {
3989
+ crashes.push(JSON.parse(deps.readFile(join9(deps.crashDir, name))));
3990
+ } catch (e) {
3991
+ warnings.push(`could not read crash record ${name} (${e.message})`);
3992
+ }
3993
+ }
3994
+ }
3995
+ let doctor;
3996
+ if (deps.doctor) {
3997
+ try {
3998
+ doctor = await deps.doctor();
3999
+ } catch (e) {
4000
+ warnings.push(`doctor failed (${e.message})`);
4001
+ }
4002
+ }
4003
+ const bundle = {
4004
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4005
+ clientVersion: deps.clientVersion,
4006
+ node: {
4007
+ state,
4008
+ platform: process.platform,
4009
+ arch: process.arch,
4010
+ nodeVersion: process.version
4011
+ },
4012
+ ...doctor ? { doctor } : {},
4013
+ log,
4014
+ crashes,
4015
+ warnings
4016
+ };
4017
+ return scrubValue(bundle);
4018
+ }
4019
+ async function runDiagnose(deps) {
4020
+ const bundle = await buildBundle(deps);
4021
+ try {
4022
+ deps.writeFile(deps.outFile, `${JSON.stringify(bundle, null, 2)}
4023
+ `);
4024
+ } catch (e) {
4025
+ deps.out(`Could not write the bundle: ${e.message}`);
4026
+ return 1;
4027
+ }
4028
+ deps.out(`Wrote a support bundle to ${deps.outFile}`);
4029
+ deps.out("");
4030
+ deps.out(`It contains: this node's id, name and tenant; its version and host; ${bundle.log.length} log`);
4031
+ deps.out(`lines; and ${bundle.crashes.length} crash record(s). Secrets are stripped and the enrolment`);
4032
+ deps.out("token is not in it. Your source code, prompts and issue content are not in it.");
4033
+ deps.out("");
4034
+ deps.out("Nothing has been sent anywhere. Open the file, read it, and send it on only if you are happy to.");
4035
+ if (bundle.warnings.length > 0) {
4036
+ deps.out("");
4037
+ deps.out("Note:");
4038
+ for (const w of bundle.warnings) deps.out(` - ${w}`);
4039
+ }
4040
+ return 0;
4041
+ }
4042
+ var defaultDiagnoseDeps = (paths, clientVersion, doctor) => ({
4043
+ ...paths,
4044
+ clientVersion,
4045
+ ...doctor ? { doctor } : {},
4046
+ readFile: (p) => readFileSync5(p, "utf8"),
4047
+ listDir: (p) => readdirSync4(p),
4048
+ exists: (p) => existsSync6(p),
4049
+ writeFile: (p, content) => {
4050
+ const dir = join9(p, "..");
4051
+ if (!existsSync6(dir)) mkdirSync7(dir, { recursive: true });
4052
+ writeFileSync6(p, content, { mode: 384 });
4053
+ },
4054
+ out: (line) => void process.stdout.write(`${line}
4055
+ `)
4056
+ });
4057
+ function defaultBundlePath(cwd, now) {
4058
+ return join9(cwd, `dahrk-diagnose-${now.toISOString().replace(/[:.]/g, "-")}.json`);
4059
+ }
4060
+
3258
4061
  // src/doctor.ts
3259
4062
  var MIN_NODE_MAJOR = 22;
3260
4063
  var TAG = { pass: "[PASS]", warn: "[WARN]", fail: "[FAIL]" };
@@ -3386,12 +4189,218 @@ async function runDoctor(inputs, deps = {}) {
3386
4189
  return checks.some((c) => c.status === "fail") ? 1 : 0;
3387
4190
  }
3388
4191
 
4192
+ // src/lock.ts
4193
+ import { existsSync as existsSync7, mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "fs";
4194
+ import { dirname as dirname4 } from "path";
4195
+ function parseLock(content) {
4196
+ if (!content) return void 0;
4197
+ const pid = Number(content.trim());
4198
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
4199
+ }
4200
+ function acquireLock(deps) {
4201
+ const held = parseLock(deps.readFile(deps.file));
4202
+ if (held !== void 0 && held !== deps.pid && deps.isAlive(held)) {
4203
+ return { ok: false, heldBy: held };
4204
+ }
4205
+ deps.writeFile(deps.file, `${deps.pid}
4206
+ `);
4207
+ return { ok: true, release: () => deps.removeFile(deps.file) };
4208
+ }
4209
+ function isAlive(pid) {
4210
+ try {
4211
+ process.kill(pid, 0);
4212
+ return true;
4213
+ } catch (e) {
4214
+ return e.code === "EPERM";
4215
+ }
4216
+ }
4217
+ var defaultLockDeps = (file) => ({
4218
+ file,
4219
+ pid: process.pid,
4220
+ readFile: (path) => {
4221
+ try {
4222
+ return readFileSync6(path, "utf8");
4223
+ } catch {
4224
+ return void 0;
4225
+ }
4226
+ },
4227
+ writeFile: (path, content) => {
4228
+ if (!existsSync7(dirname4(path))) mkdirSync8(dirname4(path), { recursive: true, mode: 448 });
4229
+ writeFileSync7(path, content);
4230
+ },
4231
+ removeFile: (path) => rmSync6(path, { force: true }),
4232
+ isAlive
4233
+ });
4234
+
4235
+ // src/logs.ts
4236
+ import { spawn } from "child_process";
4237
+ import { copyFileSync, existsSync as existsSync8, readFileSync as readFileSync7, statSync as statSync4, truncateSync } from "fs";
4238
+ var MAX_LOG_BYTES = 10 * 1024 * 1024;
4239
+ function logsCommand(inputs) {
4240
+ return [
4241
+ "tail",
4242
+ "-n",
4243
+ String(inputs.lines),
4244
+ ...inputs.follow ? ["-f"] : [],
4245
+ ...inputs.files
4246
+ ];
4247
+ }
4248
+ var LEVEL_RANK = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 };
4249
+ var levelName = (n) => Object.entries(LEVEL_RANK).find(([, rank]) => rank === n)?.[0] ?? String(n);
4250
+ function parseRecords(raw) {
4251
+ const out = [];
4252
+ for (const line of raw.split("\n")) {
4253
+ if (!line.trim()) continue;
4254
+ try {
4255
+ out.push(JSON.parse(line));
4256
+ } catch {
4257
+ }
4258
+ }
4259
+ return out;
4260
+ }
4261
+ function filterRecords(records, q) {
4262
+ const min = q.level ? LEVEL_RANK[q.level] ?? 0 : 0;
4263
+ const matched = records.filter((r) => r.level >= min && (q.run === void 0 || r.runId === q.run));
4264
+ return q.lines > 0 ? matched.slice(-q.lines) : matched;
4265
+ }
4266
+ function renderRecord(r) {
4267
+ const where = [r.runId, r.stageId].filter(Boolean).join("/");
4268
+ const scope = where ? ` [${where}]` : r.component ? ` [${r.component}]` : "";
4269
+ const head = `${r.time} ${levelName(r.level).toUpperCase().padEnd(5)}${scope} ${r.msg}`;
4270
+ const lines = [head];
4271
+ if (r.err?.stack) lines.push(...r.err.stack.split("\n").map((l) => ` ${l}`));
4272
+ return lines;
4273
+ }
4274
+ async function runLogs(inputs, deps) {
4275
+ if (inputs.level !== void 0 || inputs.run !== void 0 || inputs.json) {
4276
+ return runStructuredLogs(inputs, deps);
4277
+ }
4278
+ const files = [deps.files.out, deps.files.err].filter((f) => deps.fileExists(f));
4279
+ if (files.length === 0) {
4280
+ deps.out("No logs yet - this node has not run under the service.");
4281
+ deps.out("Start it with `dahrk start`, or check what it thinks it is doing with `dahrk status`.");
4282
+ deps.out("");
4283
+ deps.out("(A node run in a terminal with `--foreground` logs to that terminal, not to a file.)");
4284
+ return 0;
4285
+ }
4286
+ return deps.run(logsCommand({ files, lines: inputs.lines, follow: inputs.follow }));
4287
+ }
4288
+ async function runStructuredLogs(inputs, deps) {
4289
+ if (!deps.fileExists(deps.jsonlFile)) {
4290
+ deps.out("No structured log yet - this node has not run since structured logging was added.");
4291
+ deps.out("Start it with `dahrk start`; it writes ~/.dahrk/logs/node.jsonl from the first boot.");
4292
+ return 0;
4293
+ }
4294
+ if (inputs.follow) {
4295
+ deps.out("(--follow on the structured log streams it unfiltered; pipe it through jq to narrow it:)");
4296
+ deps.out(` tail -f ${deps.jsonlFile} | jq -c 'select(.runId == "<runId>")'`);
4297
+ deps.out("");
4298
+ return deps.run(["tail", "-n", String(inputs.lines), "-f", deps.jsonlFile]);
4299
+ }
4300
+ const records = filterRecords(parseRecords(deps.readFile(deps.jsonlFile)), {
4301
+ level: inputs.level,
4302
+ run: inputs.run,
4303
+ lines: inputs.lines
4304
+ });
4305
+ if (records.length === 0) {
4306
+ const narrowed = [inputs.run ? `run ${inputs.run}` : void 0, inputs.level ? `level ${inputs.level}+` : void 0].filter(Boolean).join(" at ");
4307
+ deps.out(narrowed ? `No records for ${narrowed}.` : "No records.");
4308
+ return 0;
4309
+ }
4310
+ for (const r of records) {
4311
+ if (inputs.json) deps.out(JSON.stringify(r));
4312
+ else for (const line of renderRecord(r)) deps.out(line);
4313
+ }
4314
+ return 0;
4315
+ }
4316
+ function rotateIfLarge(file, maxBytes = MAX_LOG_BYTES) {
4317
+ try {
4318
+ if (!existsSync8(file) || statSync4(file).size <= maxBytes) return;
4319
+ copyFileSync(file, `${file}.1`);
4320
+ truncateSync(file, 0);
4321
+ } catch {
4322
+ }
4323
+ }
4324
+ var defaultLogsDeps = (files, jsonlFile) => ({
4325
+ files,
4326
+ jsonlFile,
4327
+ fileExists: (path) => existsSync8(path),
4328
+ readFile: (path) => readFileSync7(path, "utf8"),
4329
+ run: (argv) => new Promise((resolve2) => {
4330
+ const [cmd, ...args] = argv;
4331
+ const child = spawn(cmd, args, { stdio: "inherit" });
4332
+ child.on("error", () => resolve2(1));
4333
+ child.on("close", (code) => resolve2(code ?? 0));
4334
+ }),
4335
+ out: (line) => void process.stdout.write(`${line}
4336
+ `)
4337
+ });
4338
+
4339
+ // src/process-safety.ts
4340
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync8 } from "fs";
4341
+ import { join as join10 } from "path";
4342
+ function writeCrashRecord(dir, record) {
4343
+ try {
4344
+ mkdirSync9(dir, { recursive: true, mode: 448 });
4345
+ const file = join10(dir, `${record.at.replace(/[:.]/g, "-")}.json`);
4346
+ writeFileSync8(file, `${JSON.stringify(record, null, 2)}
4347
+ `, { mode: 384 });
4348
+ return file;
4349
+ } catch {
4350
+ return void 0;
4351
+ }
4352
+ }
4353
+ function toRecord(kind, reason, opts) {
4354
+ const err = reason instanceof Error ? reason : void 0;
4355
+ return {
4356
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4357
+ kind,
4358
+ name: err?.name ?? typeof reason,
4359
+ message: err?.message ?? String(reason),
4360
+ ...err?.stack ? { stack: err.stack } : {},
4361
+ clientVersion: opts.clientVersion,
4362
+ nodeVersion: process.version,
4363
+ platform: process.platform,
4364
+ arch: process.arch,
4365
+ uptimeSec: Math.round(process.uptime()),
4366
+ ...opts.activeJobIds ? { activeJobIds: opts.activeJobIds() } : {}
4367
+ };
4368
+ }
4369
+ function makeCrashHandlers(opts) {
4370
+ const handle = (kind, reason) => {
4371
+ const record = toRecord(kind, reason, opts);
4372
+ const file = opts.crashDir ? writeCrashRecord(opts.crashDir, record) : void 0;
4373
+ opts.logger.error(
4374
+ { err: reason, kind, ...file ? { crashFile: file } : {}, activeJobIds: record.activeJobIds },
4375
+ `NODE_CRASH:${kind} ${record.name}: ${record.message}`
4376
+ );
4377
+ };
4378
+ return {
4379
+ onUnhandledRejection: (reason) => handle("unhandledRejection", reason),
4380
+ onUncaughtException: (err) => {
4381
+ handle("uncaughtException", err);
4382
+ if (opts.exitOnCrash) process.exit(1);
4383
+ }
4384
+ };
4385
+ }
4386
+ function installProcessSafetyNet(opts) {
4387
+ const { onUnhandledRejection, onUncaughtException } = makeCrashHandlers(opts);
4388
+ process.on("unhandledRejection", onUnhandledRejection);
4389
+ process.on("uncaughtException", onUncaughtException);
4390
+ return {
4391
+ uninstall: () => {
4392
+ process.off("unhandledRejection", onUnhandledRejection);
4393
+ process.off("uncaughtException", onUncaughtException);
4394
+ }
4395
+ };
4396
+ }
4397
+
3389
4398
  // src/preflight.ts
3390
- import { execFileSync as execFileSync4 } from "child_process";
4399
+ import { execFileSync as execFileSync5 } from "child_process";
3391
4400
  import { randomUUID as randomUUID2 } from "crypto";
3392
- import { accessSync, constants as fsConstants, existsSync as existsSync4, readdirSync as readdirSync3, statfsSync } from "fs";
4401
+ import { accessSync, constants as fsConstants, existsSync as existsSync9, readdirSync as readdirSync5, statfsSync } from "fs";
3393
4402
  import { homedir as homedir2 } from "os";
3394
- import { join as join7 } from "path";
4403
+ import { join as join11 } from "path";
3395
4404
  var REPORT_BASE_URL = "https://app.dahrk.ai/r";
3396
4405
  var LOW_DISK_BYTES = 512 * 1024 * 1024;
3397
4406
  var PREFLIGHT_STAGES = [
@@ -3520,7 +4529,7 @@ var defaultDeps2 = () => ({
3520
4529
  });
3521
4530
  function commandPresent(cmd) {
3522
4531
  try {
3523
- execFileSync4("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" });
4532
+ execFileSync5("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" });
3524
4533
  return true;
3525
4534
  } catch {
3526
4535
  return false;
@@ -3528,12 +4537,12 @@ function commandPresent(cmd) {
3528
4537
  }
3529
4538
  function sshKeyPresent() {
3530
4539
  try {
3531
- const dir = join7(homedir2(), ".ssh");
3532
- if (existsSync4(dir) && readdirSync3(dir).some((f) => f.endsWith(".pub"))) return true;
4540
+ const dir = join11(homedir2(), ".ssh");
4541
+ if (existsSync9(dir) && readdirSync5(dir).some((f) => f.endsWith(".pub"))) return true;
3533
4542
  } catch {
3534
4543
  }
3535
4544
  try {
3536
- execFileSync4("ssh-add", ["-l"], { stdio: "ignore" });
4545
+ execFileSync5("ssh-add", ["-l"], { stdio: "ignore" });
3537
4546
  return true;
3538
4547
  } catch {
3539
4548
  return false;
@@ -3548,12 +4557,12 @@ function writable(dir) {
3548
4557
  }
3549
4558
  }
3550
4559
  function worktreeRoot(env) {
3551
- return env.DAHRK_WORKTREES_DIR ?? join7(env.DAHRK_STATE_DIR ?? join7(homedir2(), ".dahrk"), "worktrees");
4560
+ return env.DAHRK_WORKTREES_DIR ?? join11(env.DAHRK_STATE_DIR ?? join11(homedir2(), ".dahrk"), "worktrees");
3552
4561
  }
3553
4562
  function nearestExisting(dir) {
3554
4563
  let cur = dir;
3555
- while (!existsSync4(cur)) {
3556
- const parent = join7(cur, "..");
4564
+ while (!existsSync9(cur)) {
4565
+ const parent = join11(cur, "..");
3557
4566
  if (parent === cur) break;
3558
4567
  cur = parent;
3559
4568
  }
@@ -3568,7 +4577,7 @@ function freeDiskBytes(dir) {
3568
4577
  }
3569
4578
  }
3570
4579
  function probeRepo(repoPath) {
3571
- const git = (args) => execFileSync4("git", ["-C", repoPath, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4580
+ const git = (args) => execFileSync5("git", ["-C", repoPath, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3572
4581
  try {
3573
4582
  if (git(["rev-parse", "--is-inside-work-tree"]) !== "true") {
3574
4583
  return { path: repoPath, isGitRepo: false, headResolves: false, detail: "not inside a work tree" };
@@ -3603,10 +4612,97 @@ function gatherHostFacts(repoPath) {
3603
4612
  }
3604
4613
 
3605
4614
  // src/service.ts
3606
- import { execFileSync as execFileSync5 } from "child_process";
3607
- import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync6, realpathSync, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
3608
- import { homedir as homedir3, platform as osPlatform3, userInfo } from "os";
3609
- import { join as join8 } from "path";
4615
+ import { execFileSync as execFileSync6 } from "child_process";
4616
+ import { chmodSync as chmodSync2, existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync7, writeFileSync as writeFileSync10 } from "fs";
4617
+ import { homedir as homedir4, platform as osPlatform3, userInfo } from "os";
4618
+ import { join as join13 } from "path";
4619
+
4620
+ // src/state.ts
4621
+ import { chmodSync, existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync8, writeFileSync as writeFileSync9 } from "fs";
4622
+ import { homedir as homedir3 } from "os";
4623
+ import { join as join12 } from "path";
4624
+ var STRING_FIELDS = ["nodeId", "enrolToken", "name", "tenantId", "updateCheckedAt", "updateLatest"];
4625
+ var isDesired = (v) => v === "running" || v === "stopped";
4626
+ var FILE_MODE = 384;
4627
+ var DIR_MODE = 448;
4628
+ function stateDir(env) {
4629
+ return env.DAHRK_STATE_DIR ?? join12(homedir3(), ".dahrk");
4630
+ }
4631
+ function legacyStateDir(env) {
4632
+ return env.DAHRK_STATE_DIR ? void 0 : join12(homedir3(), ".skakel");
4633
+ }
4634
+ function stateFile(env) {
4635
+ return join12(stateDir(env), "node.json");
4636
+ }
4637
+ function logDir(env) {
4638
+ return join12(stateDir(env), "logs");
4639
+ }
4640
+ function logFiles(env) {
4641
+ const dir = logDir(env);
4642
+ return { out: join12(dir, "node.out.log"), err: join12(dir, "node.err.log") };
4643
+ }
4644
+ function jsonlLogFile(env) {
4645
+ return join12(logDir(env), "node.jsonl");
4646
+ }
4647
+ function crashDir(env) {
4648
+ return join12(logDir(env), "crashes");
4649
+ }
4650
+ function lockFile(env) {
4651
+ return join12(stateDir(env), "node.pid");
4652
+ }
4653
+ function setDesired(env, desired) {
4654
+ writeState(env, { desired });
4655
+ }
4656
+ function readState(file) {
4657
+ if (!existsSync10(file)) return {};
4658
+ try {
4659
+ const parsed = JSON.parse(readFileSync8(file, "utf8"));
4660
+ const state = {};
4661
+ for (const key of STRING_FIELDS) {
4662
+ const value = parsed[key];
4663
+ if (typeof value === "string" && value) state[key] = value;
4664
+ }
4665
+ if (isDesired(parsed["desired"])) state.desired = parsed["desired"];
4666
+ return state;
4667
+ } catch {
4668
+ return {};
4669
+ }
4670
+ }
4671
+ function writeState(env, patch) {
4672
+ const dir = stateDir(env);
4673
+ const file = stateFile(env);
4674
+ try {
4675
+ mkdirSync10(dir, { recursive: true, mode: DIR_MODE });
4676
+ const next = { ...readState(file), ...patch };
4677
+ writeFileSync9(file, `${JSON.stringify(next, null, 2)}
4678
+ `, { mode: FILE_MODE });
4679
+ chmodSync(file, FILE_MODE);
4680
+ } catch (e) {
4681
+ console.warn(`could not persist node state to ${file}: ${e.message}`);
4682
+ }
4683
+ }
4684
+ function readPersistedToken(env) {
4685
+ return readState(stateFile(env)).enrolToken;
4686
+ }
4687
+ function persistEnrolment(env, enrolment) {
4688
+ const { token, name, tenantId } = enrolment;
4689
+ if (!token) return;
4690
+ const current = readState(stateFile(env));
4691
+ const unchanged = current.enrolToken === token && (name === void 0 || current.name === name) && (tenantId === void 0 || current.tenantId === tenantId);
4692
+ if (unchanged) return;
4693
+ writeState(env, {
4694
+ enrolToken: token,
4695
+ ...name ? { name } : {},
4696
+ ...tenantId ? { tenantId } : {}
4697
+ });
4698
+ }
4699
+ function resolveEnrolToken(env, opts = {}) {
4700
+ if (env.DAHRK_ENROL_TOKEN) return env.DAHRK_ENROL_TOKEN;
4701
+ if (opts.ephemeral) return void 0;
4702
+ return readPersistedToken(env);
4703
+ }
4704
+
4705
+ // src/service.ts
3610
4706
  var UNIT_FILE_MODE = 384;
3611
4707
  var LAUNCHD_LABEL = "ai.dahrk.node";
3612
4708
  var SYSTEMD_UNIT = "dahrk-node.service";
@@ -3615,9 +4711,13 @@ function detectManager(plat) {
3615
4711
  if (plat === "linux") return "systemd";
3616
4712
  return "unsupported";
3617
4713
  }
4714
+ function serviceArgv(inputs) {
4715
+ return [inputs.nodeBin, inputs.scriptPath, "start", "--foreground"];
4716
+ }
3618
4717
  function serviceEnv(inputs) {
3619
4718
  return {
3620
4719
  DAHRK_ENROL_TOKEN: inputs.token,
4720
+ DAHRK_SUPERVISED: "1",
3621
4721
  ...inputs.hubUrl ? { DAHRK_HUB_URL: inputs.hubUrl } : {},
3622
4722
  ...inputs.name ? { DAHRK_NODE_NAME: inputs.name } : {},
3623
4723
  ...inputs.pathEnv ? { PATH: inputs.pathEnv } : {}
@@ -3627,7 +4727,7 @@ function xmlEscape(s) {
3627
4727
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3628
4728
  }
3629
4729
  function renderLaunchdPlist(inputs) {
3630
- const argv = [inputs.nodeBin, inputs.scriptPath, "start"];
4730
+ const argv = serviceArgv(inputs);
3631
4731
  const env = serviceEnv(inputs);
3632
4732
  const progArgs = argv.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
3633
4733
  const envEntries = Object.entries(env).map(([k, v]) => ` <key>${xmlEscape(k)}</key>
@@ -3655,9 +4755,9 @@ ${envEntries}
3655
4755
  <key>ThrottleInterval</key>
3656
4756
  <integer>10</integer>
3657
4757
  <key>StandardOutPath</key>
3658
- <string>${xmlEscape(join8(inputs.logDir, "node.out.log"))}</string>
4758
+ <string>${xmlEscape(join13(inputs.logDir, "node.out.log"))}</string>
3659
4759
  <key>StandardErrorPath</key>
3660
- <string>${xmlEscape(join8(inputs.logDir, "node.err.log"))}</string>
4760
+ <string>${xmlEscape(join13(inputs.logDir, "node.err.log"))}</string>
3661
4761
  </dict>
3662
4762
  </plist>
3663
4763
  `;
@@ -3666,7 +4766,7 @@ function systemdEnvValue(v) {
3666
4766
  return /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
3667
4767
  }
3668
4768
  function renderSystemdUnit(inputs) {
3669
- const exec = [inputs.nodeBin, inputs.scriptPath, "start"].map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
4769
+ const exec = serviceArgv(inputs).map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
3670
4770
  const env = serviceEnv(inputs);
3671
4771
  const envLines = Object.entries(env).map(([k, v]) => `Environment=${k}=${systemdEnvValue(v)}`).join("\n");
3672
4772
  return `[Unit]
@@ -3680,6 +4780,8 @@ Type=simple
3680
4780
  ExecStart=${exec}
3681
4781
  ${envLines}
3682
4782
  WorkingDirectory=${inputs.homeDir}
4783
+ StandardOutput=append:${join13(inputs.logDir, "node.out.log")}
4784
+ StandardError=append:${join13(inputs.logDir, "node.err.log")}
3683
4785
  Restart=on-failure
3684
4786
  RestartSec=3
3685
4787
  RestartPreventExitStatus=78
@@ -3690,22 +4792,24 @@ WantedBy=default.target
3690
4792
  }
3691
4793
  function buildPlan(inputs) {
3692
4794
  if (inputs.manager === "launchd") {
3693
- const filePath2 = join8(inputs.homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
4795
+ const filePath2 = join13(inputs.homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
3694
4796
  return {
3695
4797
  manager: "launchd",
3696
4798
  label: LAUNCHD_LABEL,
3697
4799
  filePath: filePath2,
3698
4800
  content: renderLaunchdPlist(inputs),
3699
- // Unload first so a re-install picks up the rewritten plist; a not-loaded unload is a no-op.
4801
+ // Unload first so a re-install picks up the rewritten plist; a not-loaded unload is a no-op. The
4802
+ // `-w` on load also clears the "disabled" flag a previous `dahrk stop` set, so start-after-stop works.
3700
4803
  installCommands: [
3701
4804
  { argv: ["launchctl", "unload", filePath2], ignoreFailure: true },
3702
4805
  { argv: ["launchctl", "load", "-w", filePath2] }
3703
4806
  ],
4807
+ stopCommands: [{ argv: ["launchctl", "unload", "-w", filePath2], ignoreFailure: true }],
3704
4808
  uninstallCommands: [{ argv: ["launchctl", "unload", "-w", filePath2], ignoreFailure: true }],
3705
- logHint: `tail -f ${join8(inputs.logDir, "node.err.log")}`
4809
+ logHint: "dahrk logs -f"
3706
4810
  };
3707
4811
  }
3708
- const filePath = join8(inputs.homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
4812
+ const filePath = join13(inputs.homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
3709
4813
  const user = userInfo().username;
3710
4814
  return {
3711
4815
  manager: "systemd",
@@ -3719,15 +4823,23 @@ function buildPlan(inputs) {
3719
4823
  { argv: ["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT] },
3720
4824
  { argv: ["loginctl", "enable-linger", user], ignoreFailure: true }
3721
4825
  ],
4826
+ // `disable`, not `stop`: a stopped-but-enabled unit comes straight back at the next boot, which is not
4827
+ // what anyone means by `dahrk stop`. `dahrk start` re-enables it (`enable --now` above).
4828
+ stopCommands: [
4829
+ { argv: ["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], ignoreFailure: true }
4830
+ ],
3722
4831
  uninstallCommands: [
3723
4832
  { argv: ["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], ignoreFailure: true },
3724
4833
  { argv: ["systemctl", "--user", "daemon-reload"], ignoreFailure: true }
3725
4834
  ],
3726
- logHint: `journalctl --user -u ${SYSTEMD_UNIT} -f`
4835
+ logHint: "dahrk logs -f"
3727
4836
  };
3728
4837
  }
4838
+ function unitIsCurrent(plan, onDisk) {
4839
+ return onDisk === plan.content;
4840
+ }
3729
4841
  function unitPath(manager, homeDir) {
3730
- return manager === "launchd" ? join8(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`) : join8(homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
4842
+ return manager === "launchd" ? join13(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`) : join13(homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
3731
4843
  }
3732
4844
  function statusCommand(manager) {
3733
4845
  return manager === "launchd" ? ["launchctl", "list", LAUNCHD_LABEL] : ["systemctl", "--user", "show", SYSTEMD_UNIT, "-p", "ActiveState", "-p", "MainPID"];
@@ -3782,7 +4894,7 @@ async function runServiceInstall(inputs, deps = {}) {
3782
4894
  logDir: d.logDir
3783
4895
  });
3784
4896
  try {
3785
- if (manager === "launchd") d.mkdirp(d.logDir);
4897
+ d.mkdirp(d.logDir);
3786
4898
  d.mkdirp(dirOf(plan.filePath));
3787
4899
  d.writeFile(plan.filePath, plan.content);
3788
4900
  } catch (e) {
@@ -3802,6 +4914,88 @@ async function runServiceInstall(inputs, deps = {}) {
3802
4914
  d.out(" uninstall: dahrk service uninstall");
3803
4915
  return 0;
3804
4916
  }
4917
+ function availabilityCommand(manager) {
4918
+ return manager === "systemd" ? ["systemctl", "--user", "show", "-p", "Version"] : void 0;
4919
+ }
4920
+ async function runNodeStart(inputs, deps = {}) {
4921
+ const d = { ...defaultDeps3(), ...deps };
4922
+ const manager = detectManager(d.platform);
4923
+ if (manager === "unsupported") {
4924
+ return { kind: "foreground", reason: "no supported supervisor on this platform (launchd / systemd)" };
4925
+ }
4926
+ const probeCmd = availabilityCommand(manager);
4927
+ if (probeCmd && d.capture(probeCmd).code !== 0) {
4928
+ return { kind: "foreground", reason: "no systemd user session on this host (a container, typically)" };
4929
+ }
4930
+ const filePath = unitPath(manager, d.homeDir);
4931
+ const unitExists = d.fileExists(filePath);
4932
+ if (!inputs.token && !unitExists) {
4933
+ d.out("No enrolment token: pass --token <token> or set DAHRK_ENROL_TOKEN.");
4934
+ d.out("Get one at https://app.dahrk.ai.");
4935
+ return { kind: "error", code: 2 };
4936
+ }
4937
+ const plan = buildPlan({
4938
+ manager,
4939
+ nodeBin: d.nodeBin,
4940
+ scriptPath: d.scriptPath,
4941
+ token: inputs.token ?? "-",
4942
+ ...inputs.name ? { name: inputs.name } : {},
4943
+ ...inputs.hubUrl ? { hubUrl: inputs.hubUrl } : {},
4944
+ ...d.pathEnv ? { pathEnv: d.pathEnv } : {},
4945
+ homeDir: d.homeDir,
4946
+ logDir: d.logDir
4947
+ });
4948
+ const canRender = inputs.token !== void 0;
4949
+ const current = canRender && unitExists && unitIsCurrent(plan, d.readFile(filePath));
4950
+ const probe2 = unitExists ? d.capture(statusCommand(manager)) : { code: 1, stdout: "" };
4951
+ const status = parseServiceStatus(manager, unitExists, probe2);
4952
+ if (current && status.running) {
4953
+ d.out(`Node is already running${status.pid ? ` (pid ${status.pid})` : ""}.`);
4954
+ d.out(` logs: ${plan.logHint}`);
4955
+ return { kind: "running", code: 0 };
4956
+ }
4957
+ if (canRender && !current) {
4958
+ try {
4959
+ d.mkdirp(d.logDir);
4960
+ d.mkdirp(dirOf(plan.filePath));
4961
+ d.writeFile(plan.filePath, plan.content);
4962
+ } catch (e) {
4963
+ d.out(`Could not write the service file at ${plan.filePath}: ${e.message}`);
4964
+ return { kind: "error", code: 1 };
4965
+ }
4966
+ d.out(unitExists ? `Updated ${plan.manager} service: ${plan.filePath}` : `Installed ${plan.manager} service: ${plan.filePath}`);
4967
+ }
4968
+ const code = runCommands(plan.installCommands, d);
4969
+ if (code !== 0) {
4970
+ d.out(`The service file is in place but starting it failed (exit ${code}).`);
4971
+ return { kind: "error", code };
4972
+ }
4973
+ d.out("Node is running. It will start on boot and restart on failure.");
4974
+ d.out(` logs: ${plan.logHint}`);
4975
+ d.out(" stop: dahrk stop");
4976
+ return { kind: "running", code: 0 };
4977
+ }
4978
+ async function runNodeStop(deps = {}) {
4979
+ const d = { ...defaultDeps3(), ...deps };
4980
+ const manager = detectManager(d.platform);
4981
+ if (manager === "unsupported") return printUnsupported(d.out);
4982
+ const plan = buildPlan({
4983
+ manager,
4984
+ nodeBin: d.nodeBin,
4985
+ scriptPath: d.scriptPath,
4986
+ token: "-",
4987
+ homeDir: d.homeDir,
4988
+ logDir: d.logDir
4989
+ });
4990
+ if (!d.fileExists(plan.filePath)) {
4991
+ d.out("No service installed, so there is nothing to stop.");
4992
+ d.out("If you are running a node in a terminal (`dahrk start --foreground`), stop it with Ctrl-C.");
4993
+ return 0;
4994
+ }
4995
+ runCommands(plan.stopCommands, d);
4996
+ d.out("Node stopped. It will stay stopped across reboots until you run `dahrk start`.");
4997
+ return 0;
4998
+ }
3805
4999
  async function runServiceUninstall(deps = {}) {
3806
5000
  const d = { ...defaultDeps3(), ...deps };
3807
5001
  d.out("dahrk service uninstall");
@@ -3837,7 +5031,7 @@ function dirOf(path) {
3837
5031
  function resolveScriptPath() {
3838
5032
  const argv1 = process.argv[1] ?? "";
3839
5033
  try {
3840
- return realpathSync(argv1);
5034
+ return realpathSync2(argv1);
3841
5035
  } catch {
3842
5036
  return argv1;
3843
5037
  }
@@ -3851,49 +5045,69 @@ function stableNodeBin(execPath, realpath) {
3851
5045
  }
3852
5046
  var realpathOrUndefined = (p) => {
3853
5047
  try {
3854
- return realpathSync(p);
5048
+ return realpathSync2(p);
3855
5049
  } catch {
3856
5050
  return void 0;
3857
5051
  }
3858
5052
  };
3859
5053
  var defaultDeps3 = () => ({
3860
5054
  platform: osPlatform3(),
3861
- homeDir: homedir3(),
5055
+ homeDir: homedir4(),
3862
5056
  // Not `process.execPath` raw: that is the versioned Homebrew Cellar path, which the next
3863
5057
  // `brew upgrade node` deletes out from under the unit. See `stableNodeBin`.
3864
5058
  nodeBin: stableNodeBin(process.execPath, realpathOrUndefined),
3865
5059
  scriptPath: resolveScriptPath(),
3866
- logDir: join8(process.env.DAHRK_STATE_DIR ?? join8(homedir3(), ".dahrk"), "logs"),
5060
+ logDir: logDir(process.env),
3867
5061
  // Snapshot the operator's PATH at install time so the daemon finds git + the runtime CLIs (Homebrew /
3868
5062
  // npm-global bins) that a supervisor's minimal PATH would otherwise hide.
3869
5063
  pathEnv: process.env.PATH,
3870
- mkdirp: (dir) => void mkdirSync6(dir, { recursive: true }),
5064
+ mkdirp: (dir) => void mkdirSync11(dir, { recursive: true }),
3871
5065
  // The unit's environment block carries the enrolment token, so the file is a secret: write it
3872
5066
  // owner-only. `writeFileSync`'s `mode` applies only when it CREATES the file, so chmod explicitly
3873
5067
  // too - re-installing over a unit an older client left at 0644 must tighten it, not keep it.
3874
5068
  writeFile: (path, content) => {
3875
- writeFileSync6(path, content, { mode: UNIT_FILE_MODE });
3876
- chmodSync(path, UNIT_FILE_MODE);
5069
+ writeFileSync10(path, content, { mode: UNIT_FILE_MODE });
5070
+ chmodSync2(path, UNIT_FILE_MODE);
5071
+ },
5072
+ readFile: (path) => {
5073
+ try {
5074
+ return readFileSync9(path, "utf8");
5075
+ } catch {
5076
+ return void 0;
5077
+ }
3877
5078
  },
3878
- removeFile: (path) => rmSync4(path, { force: true }),
3879
- fileExists: (path) => existsSync5(path),
5079
+ removeFile: (path) => rmSync7(path, { force: true }),
5080
+ fileExists: (path) => existsSync11(path),
3880
5081
  run: (argv) => {
3881
5082
  const [cmd, ...args] = argv;
3882
5083
  try {
3883
- execFileSync5(cmd, args, { stdio: "inherit" });
5084
+ execFileSync6(cmd, args, { stdio: "inherit" });
3884
5085
  return 0;
3885
5086
  } catch (e) {
3886
5087
  const status = e.status;
3887
5088
  return typeof status === "number" ? status : 1;
3888
5089
  }
3889
5090
  },
5091
+ capture: (argv) => {
5092
+ const [cmd, ...args] = argv;
5093
+ try {
5094
+ const stdout = execFileSync6(cmd, args, {
5095
+ encoding: "utf8",
5096
+ stdio: ["ignore", "pipe", "ignore"]
5097
+ });
5098
+ return { code: 0, stdout };
5099
+ } catch (e) {
5100
+ const status = e.status;
5101
+ return { code: typeof status === "number" ? status : 1, stdout: "" };
5102
+ }
5103
+ },
3890
5104
  out: (line) => void process.stdout.write(`${line}
3891
5105
  `)
3892
5106
  });
3893
5107
 
3894
5108
  // src/update.ts
3895
- import { execFileSync as execFileSync6 } from "child_process";
3896
- import { realpathSync as realpathSync2 } from "fs";
5109
+ import { execFileSync as execFileSync7 } from "child_process";
5110
+ import { realpathSync as realpathSync3 } from "fs";
3897
5111
  var LATEST_URL = "https://registry.npmjs.org/dahrk-node/latest";
3898
5112
  var CHANNEL_COMMANDS = {
3899
5113
  npm: "npm install -g dahrk-node@latest",
@@ -3914,7 +5128,7 @@ function detectChannel(binPath) {
3914
5128
  if (!binPath) return "unknown";
3915
5129
  let resolved = binPath;
3916
5130
  try {
3917
- resolved = realpathSync2(binPath);
5131
+ resolved = realpathSync3(binPath);
3918
5132
  } catch {
3919
5133
  }
3920
5134
  if (/[\\/]node_modules[\\/]dahrk-node[\\/]/.test(resolved)) return "npm";
@@ -3989,8 +5203,11 @@ async function runUpdate(inputs, deps = {}) {
3989
5203
  }
3990
5204
  return code;
3991
5205
  }
3992
- async function fetchLatestVersion() {
3993
- const res = await fetch(LATEST_URL, { headers: { accept: "application/json" } });
5206
+ async function fetchLatestVersion(signal) {
5207
+ const res = await fetch(LATEST_URL, {
5208
+ headers: { accept: "application/json" },
5209
+ ...signal ? { signal } : {}
5210
+ });
3994
5211
  if (!res.ok) throw new Error(`registry responded ${res.status}`);
3995
5212
  const body = await res.json();
3996
5213
  if (typeof body.version !== "string" || !body.version) throw new Error("registry returned no version");
@@ -3999,7 +5216,7 @@ async function fetchLatestVersion() {
3999
5216
  function spawnUpgrade(argv) {
4000
5217
  const [cmd, ...args] = argv;
4001
5218
  try {
4002
- execFileSync6(cmd, args, { stdio: "inherit" });
5219
+ execFileSync7(cmd, args, { stdio: "inherit" });
4003
5220
  return 0;
4004
5221
  } catch (e) {
4005
5222
  const status = e.status;
@@ -4014,68 +5231,56 @@ var defaultDeps4 = () => ({
4014
5231
  `)
4015
5232
  });
4016
5233
 
4017
- // src/state.ts
4018
- import { chmodSync as chmodSync2, existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync7 } from "fs";
4019
- import { homedir as homedir4 } from "os";
4020
- import { join as join9 } from "path";
4021
- var STRING_FIELDS = ["nodeId", "enrolToken", "name", "tenantId"];
4022
- var FILE_MODE = 384;
4023
- var DIR_MODE = 448;
4024
- function stateDir(env) {
4025
- return env.DAHRK_STATE_DIR ?? join9(homedir4(), ".dahrk");
4026
- }
4027
- function legacyStateDir(env) {
4028
- return env.DAHRK_STATE_DIR ? void 0 : join9(homedir4(), ".skakel");
4029
- }
4030
- function stateFile(env) {
4031
- return join9(stateDir(env), "node.json");
4032
- }
4033
- function readState(file) {
4034
- if (!existsSync6(file)) return {};
4035
- try {
4036
- const parsed = JSON.parse(readFileSync5(file, "utf8"));
4037
- const state = {};
4038
- for (const key of STRING_FIELDS) {
4039
- const value = parsed[key];
4040
- if (typeof value === "string" && value) state[key] = value;
4041
- }
4042
- return state;
4043
- } catch {
4044
- return {};
5234
+ // src/update-check.ts
5235
+ var DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1e3;
5236
+ var FETCH_TIMEOUT_MS = 1500;
5237
+ function checkSuppressed(env) {
5238
+ return Boolean(env.DAHRK_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
5239
+ }
5240
+ function checkIntervalMs(env) {
5241
+ const raw = env.DAHRK_UPDATE_CHECK_INTERVAL_MS;
5242
+ if (raw === void 0) return DEFAULT_INTERVAL_MS;
5243
+ const n = Number(raw);
5244
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_INTERVAL_MS;
5245
+ }
5246
+ function shouldCheck(now, checkedAt, intervalMs, env) {
5247
+ if (checkSuppressed(env)) return false;
5248
+ if (!checkedAt) return true;
5249
+ const last = Date.parse(checkedAt);
5250
+ if (!Number.isFinite(last)) return true;
5251
+ return now - last >= intervalMs || last > now;
5252
+ }
5253
+ function jitterMs(random, spreadMs = 60 * 60 * 1e3) {
5254
+ return Math.floor(random * spreadMs);
5255
+ }
5256
+ function renderUpdateNotice(u) {
5257
+ const cmd = upgradeCommand(u.channel);
5258
+ const how = cmd ? ` (${u.channel}: ${cmd.display})` : " (run `dahrk update`)";
5259
+ return `update available: ${u.current} -> ${u.latest}${how}`;
5260
+ }
5261
+ function renderUpdateLogLine(u) {
5262
+ return `UPDATE_AVAILABLE:${u.latest} current=${u.current}`;
5263
+ }
5264
+ async function checkForUpdate(currentVersion, deps) {
5265
+ if (checkSuppressed(deps.env)) return void 0;
5266
+ const state = deps.readState();
5267
+ if (!shouldCheck(deps.now(), state.updateCheckedAt, checkIntervalMs(deps.env), deps.env)) {
5268
+ return cachedUpdate(state, currentVersion, deps.binPath);
4045
5269
  }
4046
- }
4047
- function writeState(env, patch) {
4048
- const dir = stateDir(env);
4049
- const file = stateFile(env);
5270
+ let latest;
4050
5271
  try {
4051
- mkdirSync7(dir, { recursive: true, mode: DIR_MODE });
4052
- const next = { ...readState(file), ...patch };
4053
- writeFileSync7(file, `${JSON.stringify(next, null, 2)}
4054
- `, { mode: FILE_MODE });
4055
- chmodSync2(file, FILE_MODE);
4056
- } catch (e) {
4057
- console.warn(`could not persist node state to ${file}: ${e.message}`);
5272
+ latest = await deps.fetchLatest(AbortSignal.timeout(FETCH_TIMEOUT_MS));
5273
+ } catch {
5274
+ return void 0;
4058
5275
  }
5276
+ deps.saveResult({ updateCheckedAt: new Date(deps.now()).toISOString(), updateLatest: latest });
5277
+ if (!isNewer(latest, currentVersion)) return void 0;
5278
+ return { current: currentVersion, latest, channel: detectChannel(deps.binPath) };
4059
5279
  }
4060
- function readPersistedToken(env) {
4061
- return readState(stateFile(env)).enrolToken;
4062
- }
4063
- function persistEnrolment(env, enrolment) {
4064
- const { token, name, tenantId } = enrolment;
4065
- if (!token) return;
4066
- const current = readState(stateFile(env));
4067
- const unchanged = current.enrolToken === token && (name === void 0 || current.name === name) && (tenantId === void 0 || current.tenantId === tenantId);
4068
- if (unchanged) return;
4069
- writeState(env, {
4070
- enrolToken: token,
4071
- ...name ? { name } : {},
4072
- ...tenantId ? { tenantId } : {}
4073
- });
4074
- }
4075
- function resolveEnrolToken(env, opts = {}) {
4076
- if (env.DAHRK_ENROL_TOKEN) return env.DAHRK_ENROL_TOKEN;
4077
- if (opts.ephemeral) return void 0;
4078
- return readPersistedToken(env);
5280
+ function cachedUpdate(state, currentVersion, binPath) {
5281
+ const latest = state.updateLatest;
5282
+ if (!latest || !isNewer(latest, currentVersion)) return void 0;
5283
+ return { current: currentVersion, latest, channel: detectChannel(binPath) };
4079
5284
  }
4080
5285
 
4081
5286
  // src/status.ts
@@ -4092,7 +5297,12 @@ function renderStatus(f) {
4092
5297
  lines.push(bullet("Enrolled", "no - run `dahrk start --token <token>` once to enrol"));
4093
5298
  }
4094
5299
  lines.push(bullet("Node id", nodeId ?? "not yet minted (first `dahrk start` mints one)"));
4095
- lines.push(bullet("Client", f.clientVersion));
5300
+ lines.push(
5301
+ bullet(
5302
+ "Client",
5303
+ f.update ? `${f.clientVersion} (update available: ${f.update.latest} - run \`dahrk update\`)` : f.clientVersion
5304
+ )
5305
+ );
4096
5306
  lines.push(bullet("Hub", f.hubUrl));
4097
5307
  lines.push(
4098
5308
  bullet(
@@ -4101,19 +5311,24 @@ function renderStatus(f) {
4101
5311
  )
4102
5312
  );
4103
5313
  if (!f.service) {
4104
- lines.push(bullet("Service", "not supported on this host (run `dahrk start` under pm2 instead)"));
5314
+ lines.push(bullet("Node", "no supervisor on this host - run `dahrk start --foreground` (or under pm2)"));
4105
5315
  } else if (!f.service.installed) {
4106
- lines.push(bullet("Service", "not installed - run `dahrk service install` to run on boot"));
5316
+ lines.push(bullet("Node", "not installed - run `dahrk start` to run it always-on"));
4107
5317
  } else if (f.service.running) {
4108
5318
  const pid = f.service.pid ? ` (pid ${f.service.pid})` : "";
4109
- lines.push(bullet("Service", `running${pid}`));
5319
+ lines.push(bullet("Node", `running${pid}`));
5320
+ } else if (f.state.desired === "stopped") {
5321
+ lines.push(bullet("Node", "stopped - run `dahrk start` to bring it back"));
4110
5322
  } else {
4111
- lines.push(bullet("Service", "INSTALLED BUT NOT RUNNING - it is failing to start or crash-looping"));
5323
+ lines.push(bullet("Node", "INSTALLED BUT NOT RUNNING - it is failing to start or crash-looping"));
4112
5324
  if (f.logHint) lines.push(bullet("", `check the logs: ${f.logHint}`));
4113
5325
  }
4114
5326
  lines.push("", `State file: ${f.stateFile}`);
4115
5327
  return lines;
4116
5328
  }
5329
+ function isUnhealthy(f) {
5330
+ return Boolean(f.service?.installed && !f.service.running && f.state.desired !== "stopped");
5331
+ }
4117
5332
  async function runStatus(inputs, deps) {
4118
5333
  const manager = detectManager(deps.platform);
4119
5334
  let service;
@@ -4123,24 +5338,27 @@ async function runStatus(inputs, deps) {
4123
5338
  const exists = deps.fileExists(unit);
4124
5339
  const probe2 = exists ? deps.capture(statusCommand(manager)) : { code: 1, stdout: "" };
4125
5340
  service = parseServiceStatus(manager, exists, probe2);
4126
- logHint = manager === "launchd" ? `tail -f ${deps.homeDir}/.dahrk/logs/node.err.log` : "journalctl --user -u dahrk-node.service -f";
5341
+ logHint = "dahrk logs -f";
4127
5342
  }
5343
+ const state = readState(stateFile(deps.env));
5344
+ const update = checkSuppressed(deps.env) ? void 0 : cachedUpdate(state, inputs.clientVersion, deps.binPath);
4128
5345
  const facts = {
4129
5346
  clientVersion: inputs.clientVersion,
4130
5347
  hubUrl: inputs.hubUrl,
4131
5348
  stateFile: stateFile(deps.env),
4132
- state: readState(stateFile(deps.env)),
5349
+ state,
4133
5350
  envToken: Boolean(deps.env.DAHRK_ENROL_TOKEN),
4134
5351
  runtimes: await deps.detectRuntimes(),
4135
5352
  ...service ? { service } : {},
4136
- ...logHint ? { logHint } : {}
5353
+ ...logHint ? { logHint } : {},
5354
+ ...update ? { update } : {}
4137
5355
  };
4138
5356
  for (const line of renderStatus(facts)) deps.out(line);
4139
- return service?.installed && !service.running ? 1 : 0;
5357
+ return isUnhealthy(facts) ? 1 : 0;
4140
5358
  }
4141
5359
 
4142
5360
  // src/main.ts
4143
- var CLIENT_VERSION = "0.1.8";
5361
+ var CLIENT_VERSION = "0.1.9";
4144
5362
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
4145
5363
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
4146
5364
  var RUNTIMES = ["claude-code", "codex", "pi"];
@@ -4152,7 +5370,7 @@ function resolveNodeId(env, opts = {}) {
4152
5370
  if (existing) return existing;
4153
5371
  const legacy = legacyStateDir(env);
4154
5372
  if (legacy) {
4155
- const legacyId = readState(join10(legacy, "node.json")).nodeId;
5373
+ const legacyId = readState(join14(legacy, "node.json")).nodeId;
4156
5374
  if (legacyId) return legacyId;
4157
5375
  }
4158
5376
  const nodeId = randomUUID3();
@@ -4219,9 +5437,48 @@ function envWithFlags(env, flags) {
4219
5437
  if (flags.hubUrl) merged.DAHRK_HUB_URL = flags.hubUrl;
4220
5438
  return merged;
4221
5439
  }
5440
+ function wantsForeground(env, flags) {
5441
+ return flags.foreground || env.DAHRK_FOREGROUND === "1" || env.DAHRK_SUPERVISED === "1";
5442
+ }
4222
5443
  async function start(flags) {
4223
5444
  const env = envWithFlags(process.env, flags);
5445
+ if (wantsForeground(env, flags)) return startForeground(env, flags);
5446
+ await offerUpdate(env);
5447
+ const outcome = await runNodeStart({
5448
+ ...resolveEnrolToken(env) ? { token: resolveEnrolToken(env) } : {},
5449
+ ...env.DAHRK_NODE_NAME ? { name: env.DAHRK_NODE_NAME } : {},
5450
+ ...env.DAHRK_HUB_URL ? { hubUrl: env.DAHRK_HUB_URL } : {}
5451
+ });
5452
+ if (outcome.kind === "error") return outcome.code;
5453
+ if (outcome.kind === "running") {
5454
+ setDesired(env, "running");
5455
+ return 0;
5456
+ }
5457
+ console.warn(`${outcome.reason}; running the node in this terminal instead.`);
5458
+ console.warn("Use `dahrk start --foreground` (or DAHRK_FOREGROUND=1) to ask for this explicitly.");
5459
+ return startForeground(env, flags);
5460
+ }
5461
+ async function startForeground(env, flags) {
5462
+ if (!flags.ephemeral) {
5463
+ const lock = acquireLock(defaultLockDeps(lockFile(env)));
5464
+ if (!lock.ok) {
5465
+ console.error(`A node is already running on this host (pid ${lock.heldBy}).`);
5466
+ console.error("Follow it with `dahrk logs -f`, or stop it with `dahrk stop` first.");
5467
+ return 1;
5468
+ }
5469
+ process.on("exit", lock.release);
5470
+ }
5471
+ const files = logFiles(env);
5472
+ rotateIfLarge(files.out);
5473
+ rotateIfLarge(files.err);
4224
5474
  const nodeId = resolveNodeId(env, { ephemeral: flags.ephemeral });
5475
+ const logger = createNodeLoggerFromEnv(env, logDir(env), { nodeId, clientVersion: CLIENT_VERSION });
5476
+ installProcessSafetyNet({
5477
+ logger,
5478
+ crashDir: crashDir(env),
5479
+ clientVersion: CLIENT_VERSION,
5480
+ ...env.DAHRK_CRASH_EXIT === "1" ? { exitOnCrash: true } : {}
5481
+ });
4225
5482
  const token = resolveEnrolToken(env, { ephemeral: flags.ephemeral });
4226
5483
  if (token) env.DAHRK_ENROL_TOKEN = token;
4227
5484
  const runtimes = await resolveRuntimes(env);
@@ -4230,26 +5487,77 @@ async function start(flags) {
4230
5487
  "no agent runtimes detected on this host (claude/codex/pi not on PATH); the node will advertise none and serve no Jobs. Install a runtime or set DAHRK_RUNTIMES to override. Run `dahrk doctor` to check."
4231
5488
  );
4232
5489
  }
5490
+ if (!flags.ephemeral) setDesired(env, "running");
5491
+ scheduleUpdateChecks(env);
4233
5492
  const resolved = { nodeId, runtimes, clientVersion: CLIENT_VERSION };
4234
5493
  const persist = token !== void 0 && !flags.ephemeral;
4235
5494
  await startEdgeNode({
4236
5495
  ...buildEdgeOptions(env, resolved),
5496
+ logger,
4237
5497
  ...persist ? {
4238
5498
  onEnrolled: (welcome) => persistEnrolment(env, { token, name: welcome.name, tenantId: welcome.tenantId })
4239
5499
  } : {}
4240
5500
  });
5501
+ return 0;
5502
+ }
5503
+ async function stop(env) {
5504
+ const code = await runNodeStop();
5505
+ if (code === 0) setDesired(env, "stopped");
5506
+ return code;
5507
+ }
5508
+ function updateCheckDeps(env) {
5509
+ return {
5510
+ env,
5511
+ now: () => Date.now(),
5512
+ binPath: process.argv[1],
5513
+ readState: () => readState(stateFile(env)),
5514
+ saveResult: (patch) => writeState(env, patch),
5515
+ fetchLatest: fetchLatestVersion
5516
+ };
5517
+ }
5518
+ var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
5519
+ async function confirm(question) {
5520
+ const { createInterface } = await import("readline/promises");
5521
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5522
+ try {
5523
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
5524
+ return answer === "" || answer === "y" || answer === "yes";
5525
+ } finally {
5526
+ rl.close();
5527
+ }
5528
+ }
5529
+ async function offerUpdate(env) {
5530
+ const available = await checkForUpdate(CLIENT_VERSION, updateCheckDeps(env));
5531
+ if (!available) return;
5532
+ console.log(renderUpdateNotice(available));
5533
+ if (!isInteractive()) return;
5534
+ if (!await confirm("update now?")) return;
5535
+ const code = await runUpdate({ currentVersion: CLIENT_VERSION, check: false });
5536
+ if (code !== 0) console.warn("Update failed; starting the node on the current version anyway.");
5537
+ }
5538
+ function scheduleUpdateChecks(env) {
5539
+ if (checkSuppressed(env)) return;
5540
+ const deps = updateCheckDeps(env);
5541
+ const tick = async () => {
5542
+ const available = await checkForUpdate(CLIENT_VERSION, deps);
5543
+ if (available) process.stdout.write(`${renderUpdateLogLine(available)}
5544
+ `);
5545
+ };
5546
+ setTimeout(() => void tick(), jitterMs(Math.random())).unref();
5547
+ setInterval(() => void tick(), checkIntervalMs(env)).unref();
4241
5548
  }
4242
5549
  function statusDeps(env) {
4243
5550
  return {
4244
5551
  platform: osPlatform4(),
4245
5552
  homeDir: homedir5(),
4246
5553
  env,
5554
+ binPath: process.argv[1],
4247
5555
  detectRuntimes: () => resolveRuntimes(env),
4248
- fileExists: (path) => existsSync7(path),
5556
+ fileExists: (path) => existsSync12(path),
4249
5557
  capture: (argv) => {
4250
5558
  const [cmd, ...args] = argv;
4251
5559
  try {
4252
- const stdout = execFileSync7(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
5560
+ const stdout = execFileSync8(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
4253
5561
  return { code: 0, stdout };
4254
5562
  } catch (e) {
4255
5563
  const status = e.status;
@@ -4279,7 +5587,7 @@ async function runWorkflow(flags) {
4279
5587
  });
4280
5588
  }
4281
5589
  async function main() {
4282
- const invoked = basename(process.argv[1] ?? "");
5590
+ const invoked = basename2(process.argv[1] ?? "");
4283
5591
  const bin = !invoked || invoked.startsWith("main.") ? "dahrk" : invoked;
4284
5592
  const parsed = parseCli(process.argv.slice(2));
4285
5593
  switch (parsed.kind) {
@@ -4337,22 +5645,84 @@ async function main() {
4337
5645
  process.exitCode = await runUpdate({ currentVersion: CLIENT_VERSION, check: parsed.flags.check });
4338
5646
  break;
4339
5647
  case "start":
4340
- await start(parsed.flags);
5648
+ process.exitCode = await start(parsed.flags);
4341
5649
  break;
5650
+ case "stop":
5651
+ process.exitCode = await stop(applyEnvAliases(process.env));
5652
+ break;
5653
+ case "restart": {
5654
+ const env = envWithFlags(process.env, parsed.flags);
5655
+ const code = await stop(env);
5656
+ if (code !== 0) {
5657
+ process.exitCode = code;
5658
+ break;
5659
+ }
5660
+ process.exitCode = await start(parsed.flags);
5661
+ break;
5662
+ }
5663
+ case "logs": {
5664
+ const env = applyEnvAliases(process.env);
5665
+ process.exitCode = await runLogs(parsed.flags, defaultLogsDeps(logFiles(env), jsonlLogFile(env)));
5666
+ break;
5667
+ }
5668
+ case "diagnose": {
5669
+ const env = applyEnvAliases(process.env);
5670
+ const doctorLines = [];
5671
+ const doctor = async () => {
5672
+ await runDoctor(
5673
+ {
5674
+ ...resolveEnrolToken(env) ? { token: resolveEnrolToken(env) } : {},
5675
+ hubUrl: env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL
5676
+ },
5677
+ { out: (line) => void doctorLines.push(line) }
5678
+ );
5679
+ return doctorLines;
5680
+ };
5681
+ process.exitCode = await runDiagnose(
5682
+ defaultDiagnoseDeps(
5683
+ {
5684
+ stateFile: stateFile(env),
5685
+ jsonlFile: jsonlLogFile(env),
5686
+ crashDir: crashDir(env),
5687
+ outFile: parsed.flags.out ?? defaultBundlePath(process.cwd(), /* @__PURE__ */ new Date())
5688
+ },
5689
+ CLIENT_VERSION,
5690
+ doctor
5691
+ )
5692
+ );
5693
+ break;
5694
+ }
4342
5695
  }
4343
5696
  }
4344
5697
  var invokedAsEntrypoint = (() => {
4345
5698
  const argv1 = process.argv[1];
4346
5699
  if (!argv1) return false;
4347
5700
  try {
4348
- return pathToFileURL(realpathSync3(argv1)).href === import.meta.url;
5701
+ return pathToFileURL(realpathSync4(argv1)).href === import.meta.url;
4349
5702
  } catch {
4350
5703
  return false;
4351
5704
  }
4352
5705
  })();
4353
5706
  if (invokedAsEntrypoint) {
4354
5707
  main().catch((err) => {
5708
+ const enrolmentRejected = process.exitCode === ENROLMENT_REJECTED_EXIT_CODE;
4355
5709
  console.error(err instanceof Error ? err.message : String(err));
5710
+ if (!enrolmentRejected) {
5711
+ if (err instanceof Error && err.stack) console.error(err.stack);
5712
+ const env = applyEnvAliases(process.env);
5713
+ writeCrashRecord(crashDir(env), {
5714
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5715
+ kind: "uncaughtException",
5716
+ name: err instanceof Error ? err.name : typeof err,
5717
+ message: err instanceof Error ? err.message : String(err),
5718
+ ...err instanceof Error && err.stack ? { stack: err.stack } : {},
5719
+ clientVersion: CLIENT_VERSION,
5720
+ nodeVersion: process.version,
5721
+ platform: process.platform,
5722
+ arch: process.arch,
5723
+ uptimeSec: Math.round(process.uptime())
5724
+ });
5725
+ }
4356
5726
  process.exit(process.exitCode || 1);
4357
5727
  });
4358
5728
  }