dahrk-node 0.1.14 → 0.1.16

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 +625 -246
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/main.ts
4
4
  import { execFileSync as execFileSync9 } from "child_process";
5
- import { existsSync as existsSync14, realpathSync as realpathSync5 } from "fs";
5
+ import { existsSync as existsSync14, readFileSync as readFileSync11, realpathSync as realpathSync5 } from "fs";
6
6
  import { randomUUID as randomUUID3 } from "crypto";
7
7
  import { homedir as homedir7, platform as osPlatform4 } from "os";
8
8
  import { basename as basename2, join as join17 } from "path";
@@ -506,9 +506,9 @@ var userMsg = (text) => ({
506
506
  });
507
507
  var sessionIdOf = (msg) => "session_id" in msg && typeof msg.session_id === "string" ? msg.session_id : void 0;
508
508
  var policyCanUseTool = async (ctx, toolName, input) => {
509
- const verdict = ctx.authorizeToolUse?.(toolName, input);
510
- if (verdict?.verdict === "deny") {
511
- return { behavior: "deny", message: verdict.reason ?? `tool "${toolName}" denied by policy ${verdict.policy}` };
509
+ const verdict2 = ctx.authorizeToolUse?.(toolName, input);
510
+ if (verdict2?.verdict === "deny") {
511
+ return { behavior: "deny", message: verdict2.reason ?? `tool "${toolName}" denied by policy ${verdict2.policy}` };
512
512
  }
513
513
  return { behavior: "allow", updatedInput: input };
514
514
  };
@@ -801,15 +801,15 @@ function createClaudeRunner() {
801
801
  })
802
802
  };
803
803
  try {
804
- let out = "";
804
+ let out2 = "";
805
805
  const q = query({ prompt: SUMMARISE_PROMPT, options });
806
806
  active = q;
807
807
  for await (const msg of q) {
808
808
  const found = sessionIdOf(msg);
809
809
  if (found) sessionId = found;
810
- if (msg.type === "result" && msg.subtype === "success") out += msg.result;
810
+ if (msg.type === "result" && msg.subtype === "success") out2 += msg.result;
811
811
  }
812
- return out.trim() || "(no summary produced)";
812
+ return out2.trim() || "(no summary produced)";
813
813
  } catch (e) {
814
814
  return `(summary unavailable: ${e.message})`;
815
815
  } finally {
@@ -1311,15 +1311,15 @@ function createPiRunner(deps = {}) {
1311
1311
  const s = session;
1312
1312
  if (s.agent) s.agent.state.tools = [];
1313
1313
  const state = newPiBufferState();
1314
- let out;
1314
+ let out2;
1315
1315
  const unsub = s.subscribe((ev) => {
1316
1316
  const r = consumePiEvent(ev, state, true);
1317
- if (r.responseText) out = r.responseText;
1317
+ if (r.responseText) out2 = r.responseText;
1318
1318
  });
1319
1319
  try {
1320
1320
  await s.prompt(SUMMARISE_PROMPT);
1321
1321
  captureSessionId(s);
1322
- return (out ?? "").trim() || "(no summary produced)";
1322
+ return (out2 ?? "").trim() || "(no summary produced)";
1323
1323
  } catch (e) {
1324
1324
  return `(summary unavailable: ${e.message})`;
1325
1325
  } finally {
@@ -1354,7 +1354,9 @@ async function defaultCreatePiSession(ctx) {
1354
1354
  let model;
1355
1355
  if (ctx.config.model) {
1356
1356
  const resolved = resolveCliModel({ cliModel: ctx.config.model, modelRegistry });
1357
- if (!resolved?.error) model = resolved?.model;
1357
+ if (!resolved?.error) {
1358
+ model = pickAuthedModel(resolved?.model, modelRegistry.getAvailable());
1359
+ }
1358
1360
  }
1359
1361
  const stageComplete = defineTool({
1360
1362
  name: PI_STAGE_COMPLETE_TOOL,
@@ -1379,6 +1381,17 @@ var PROVIDER_BY_ENV = {
1379
1381
  GEMINI_API_KEY: "google",
1380
1382
  GOOGLE_API_KEY: "google"
1381
1383
  };
1384
+ function modelFamily(id) {
1385
+ const last = id.split(".").pop() ?? id;
1386
+ return last.replace(/-v\d+:\d+$/, "").toLowerCase();
1387
+ }
1388
+ function pickAuthedModel(resolved, available) {
1389
+ if (!resolved || !available?.length) return resolved;
1390
+ const providers = new Set(available.map((m) => m.provider));
1391
+ if (providers.has(resolved.provider)) return resolved;
1392
+ const family = modelFamily(resolved.id);
1393
+ return available.find((m) => modelFamily(m.id) === family) ?? resolved;
1394
+ }
1382
1395
 
1383
1396
  // ../../packages/executor-worktree/src/git-service.ts
1384
1397
  import { execFileSync } from "child_process";
@@ -1482,15 +1495,15 @@ function createGitService(opts = {}) {
1482
1495
  const withTokenUser = (gitUrl) => /^https:\/\/[^@/]+@/.test(gitUrl) ? gitUrl : gitUrl.replace(/^https:\/\//, "https://x-access-token@");
1483
1496
  const mirrorPathFor = (repoId) => join3(mirrorsDir, sanitizeBranchName(repoId));
1484
1497
  const listWorktrees = (mirror) => {
1485
- let out;
1498
+ let out2;
1486
1499
  try {
1487
- out = git(mirror, ["worktree", "list", "--porcelain"]);
1500
+ out2 = git(mirror, ["worktree", "list", "--porcelain"]);
1488
1501
  } catch {
1489
1502
  return [];
1490
1503
  }
1491
1504
  const entries = [];
1492
1505
  let path = "";
1493
- for (const line of out.split("\n")) {
1506
+ for (const line of out2.split("\n")) {
1494
1507
  if (line.startsWith("worktree ")) path = line.slice(9).trim();
1495
1508
  else if (line.startsWith("branch ")) {
1496
1509
  const branch = line.slice(7).trim().replace(/^refs\/heads\//, "");
@@ -1848,13 +1861,13 @@ function lastUsedMs(worktreePath) {
1848
1861
  return newest;
1849
1862
  }
1850
1863
  function registeredWorktrees(mirror) {
1851
- let out;
1864
+ let out2;
1852
1865
  try {
1853
- out = gitOut(mirror, ["worktree", "list", "--porcelain"]);
1866
+ out2 = gitOut(mirror, ["worktree", "list", "--porcelain"]);
1854
1867
  } catch {
1855
1868
  return [];
1856
1869
  }
1857
- return out.split("\n").filter((l) => l.startsWith("worktree ")).map((l) => canonical(l.slice(9).trim())).filter((p) => p && p !== canonical(mirror));
1870
+ return out2.split("\n").filter((l) => l.startsWith("worktree ")).map((l) => canonical(l.slice(9).trim())).filter((p) => p && p !== canonical(mirror));
1858
1871
  }
1859
1872
  function createWorktreeReaper(opts) {
1860
1873
  const log = opts.logger ?? noop;
@@ -2015,16 +2028,16 @@ import { createHash as createHash2 } from "crypto";
2015
2028
  import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readdirSync as readdirSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
2016
2029
  import { dirname as dirname2, join as join6, relative, sep } from "path";
2017
2030
  function readManifestFiles(dir) {
2018
- const out = [];
2031
+ const out2 = [];
2019
2032
  const walk = (cur) => {
2020
2033
  for (const entry of readdirSync2(cur, { withFileTypes: true })) {
2021
2034
  const abs = join6(cur, entry.name);
2022
2035
  if (entry.isDirectory()) walk(abs);
2023
- else out.push(relative(dir, abs).split(sep).join("/"));
2036
+ else out2.push(relative(dir, abs).split(sep).join("/"));
2024
2037
  }
2025
2038
  };
2026
2039
  walk(dir);
2027
- return out.sort();
2040
+ return out2.sort();
2028
2041
  }
2029
2042
 
2030
2043
  // ../../packages/executor-worktree/src/overlay.ts
@@ -2195,11 +2208,11 @@ function fileJobLedger(file, warn = console.warn) {
2195
2208
  };
2196
2209
  }
2197
2210
  function announceableJobs(entries) {
2198
- const out = [];
2211
+ const out2 = [];
2199
2212
  for (const e of entries) {
2200
- if (e.payloadVersion) out.push({ jobId: e.jobId, payloadVersion: e.payloadVersion });
2213
+ if (e.payloadVersion) out2.push({ jobId: e.jobId, payloadVersion: e.payloadVersion });
2201
2214
  }
2202
- return out;
2215
+ return out2;
2203
2216
  }
2204
2217
  function jobLedgerFile(stateDir2) {
2205
2218
  return join8(stateDir2, "jobs.json");
@@ -2357,18 +2370,18 @@ function scrubValue(value, depth = 0) {
2357
2370
  }
2358
2371
  if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return value;
2359
2372
  if (value instanceof Error) {
2360
- const out = new Error(scrubString(value.message));
2361
- out.name = value.name;
2362
- if (value.stack) out.stack = scrubString(value.stack);
2363
- return out;
2373
+ const out2 = new Error(scrubString(value.message));
2374
+ out2.name = value.name;
2375
+ if (value.stack) out2.stack = scrubString(value.stack);
2376
+ return out2;
2364
2377
  }
2365
2378
  if (Array.isArray(value)) return value.map((v) => scrubValue(v, depth + 1));
2366
2379
  if (typeof value === "object") {
2367
- const out = {};
2380
+ const out2 = {};
2368
2381
  for (const [k, v] of Object.entries(value)) {
2369
- out[k] = isSensitiveKey(k) ? REDACTED : scrubValue(v, depth + 1);
2382
+ out2[k] = isSensitiveKey(k) ? REDACTED : scrubValue(v, depth + 1);
2370
2383
  }
2371
- return out;
2384
+ return out2;
2372
2385
  }
2373
2386
  return value;
2374
2387
  }
@@ -2602,11 +2615,11 @@ function withinRoots(raw, roots, need) {
2602
2615
  }
2603
2616
  function gitCommonDir(worktreePath) {
2604
2617
  try {
2605
- const out = execFileSync3("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
2618
+ const out2 = execFileSync3("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
2606
2619
  cwd: worktreePath,
2607
2620
  stdio: ["ignore", "pipe", "ignore"]
2608
2621
  }).toString().trim();
2609
- return out || void 0;
2622
+ return out2 || void 0;
2610
2623
  } catch {
2611
2624
  return void 0;
2612
2625
  }
@@ -2615,8 +2628,8 @@ var pnpmStoreCache;
2615
2628
  function pnpmStore() {
2616
2629
  if (pnpmStoreCache) return pnpmStoreCache.path;
2617
2630
  try {
2618
- const out = execFileSync3("pnpm", ["store", "path"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 }).toString().trim();
2619
- pnpmStoreCache = { path: out || void 0 };
2631
+ const out2 = execFileSync3("pnpm", ["store", "path"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 }).toString().trim();
2632
+ pnpmStoreCache = { path: out2 || void 0 };
2620
2633
  } catch {
2621
2634
  pnpmStoreCache = { path: void 0 };
2622
2635
  }
@@ -2912,12 +2925,12 @@ function scanCommand(cmd, roots, cwd = roots.cwd) {
2912
2925
  }
2913
2926
  plain.push(t);
2914
2927
  }
2915
- const out = scanSimple(plain, roots, cwdNow);
2916
- if (out.kind === "cd") {
2917
- cwdNow = expandPath(out.to, cwdNow);
2928
+ const out2 = scanSimple(plain, roots, cwdNow);
2929
+ if (out2.kind === "cd") {
2930
+ cwdNow = expandPath(out2.to, cwdNow);
2918
2931
  continue;
2919
2932
  }
2920
- if (out.kind !== "ok") return out;
2933
+ if (out2.kind !== "ok") return out2;
2921
2934
  }
2922
2935
  return { kind: "ok" };
2923
2936
  }
@@ -2988,14 +3001,14 @@ var PATH_WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "N
2988
3001
  function pathsIn(input) {
2989
3002
  if (!input || typeof input !== "object") return [];
2990
3003
  const o = input;
2991
- const out = [];
3004
+ const out2 = [];
2992
3005
  for (const f of PATH_FIELDS) {
2993
- if (typeof o[f] === "string" && o[f]) out.push(o[f]);
3006
+ if (typeof o[f] === "string" && o[f]) out2.push(o[f]);
2994
3007
  }
2995
3008
  if (o.changes && typeof o.changes === "object") {
2996
- out.push(...Object.keys(o.changes));
3009
+ out2.push(...Object.keys(o.changes));
2997
3010
  }
2998
- return out;
3011
+ return out2;
2999
3012
  }
3000
3013
  function fsConfineRule(roots) {
3001
3014
  return {
@@ -3587,10 +3600,10 @@ function createStageRunner(deps) {
3587
3600
  return `${tool3}\0`;
3588
3601
  }
3589
3602
  };
3590
- const policyReason = (verdict) => verdict.reason ?? `tool action denied by ${verdict.policy}`;
3591
- const recordDeny = (verdict, toolUseId) => {
3603
+ const policyReason = (verdict2) => verdict2.reason ?? `tool action denied by ${verdict2.policy}`;
3604
+ const recordDeny = (verdict2, toolUseId) => {
3592
3605
  denied = true;
3593
- const reason = policyReason(verdict);
3606
+ const reason = policyReason(verdict2);
3594
3607
  if (toolUseId) {
3595
3608
  streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "observation", runtime, toolUseId, isError: true, output: { error: reason } }));
3596
3609
  }
@@ -3598,13 +3611,13 @@ function createStageRunner(deps) {
3598
3611
  deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: reason });
3599
3612
  };
3600
3613
  const authorizeToolUse = (tool3, input) => {
3601
- const verdict = evaluatePolicies({ kind: "action", stageId, tool: tool3, input }, rules);
3602
- if (verdict.verdict === "deny") {
3603
- recordDeny(verdict);
3614
+ const verdict2 = evaluatePolicies({ kind: "action", stageId, tool: tool3, input }, rules);
3615
+ if (verdict2.verdict === "deny") {
3616
+ recordDeny(verdict2);
3604
3617
  } else {
3605
3618
  authorisedActions.push(actionKey(tool3, input));
3606
3619
  }
3607
- return verdict;
3620
+ return verdict2;
3608
3621
  };
3609
3622
  const onTrace = (event) => {
3610
3623
  if (event.type === "action") {
@@ -3613,14 +3626,14 @@ function createStageRunner(deps) {
3613
3626
  if (authorised >= 0) {
3614
3627
  authorisedActions.splice(authorised, 1);
3615
3628
  } else {
3616
- const verdict = evaluatePolicies(
3629
+ const verdict2 = evaluatePolicies(
3617
3630
  { kind: "action", stageId, tool: event.tool, input: event.input },
3618
3631
  rules
3619
3632
  );
3620
- if (verdict.verdict === "deny") {
3633
+ if (verdict2.verdict === "deny") {
3621
3634
  streamEvent(writer.append(event));
3622
- recordDeny(verdict, event.toolUseId);
3623
- if (verdict.policy === "fs_confine" && runtime !== "claude-code") escapedUnblocked = true;
3635
+ recordDeny(verdict2, event.toolUseId);
3636
+ if (verdict2.policy === "fs_confine" && runtime !== "claude-code") escapedUnblocked = true;
3624
3637
  return;
3625
3638
  }
3626
3639
  }
@@ -4486,6 +4499,10 @@ function parseCli(argv) {
4486
4499
  "hub-url": { type: "string" },
4487
4500
  ephemeral: { type: "boolean", default: false },
4488
4501
  foreground: { type: "boolean", default: false },
4502
+ // `stop` / `restart`: take the node down even though it has work in flight.
4503
+ force: { type: "boolean", default: false },
4504
+ // `status`: machine-readable output.
4505
+ json: { type: "boolean", default: false },
4489
4506
  help: { type: "boolean", default: false }
4490
4507
  },
4491
4508
  allowPositionals: false
@@ -4495,6 +4512,7 @@ function parseCli(argv) {
4495
4512
  }
4496
4513
  if (values.help) return { kind: "help", command };
4497
4514
  const ephemeral = values.ephemeral ?? false;
4515
+ const force = values.force ?? false;
4498
4516
  const flags = {
4499
4517
  ...values.token ? { token: values.token } : {},
4500
4518
  ...values.name ? { name: values.name } : {},
@@ -4504,7 +4522,9 @@ function parseCli(argv) {
4504
4522
  // a supervisor that restarts it on boot. It is a foreground node by definition.
4505
4523
  foreground: (values.foreground ?? false) || ephemeral
4506
4524
  };
4507
- if (command === "stop") return { kind: "stop" };
4525
+ if (command === "stop") return { kind: "stop", force };
4526
+ if (command === "restart") return { kind: "restart", flags, force };
4527
+ if (command === "status") return { kind: "status", flags: { ...flags, json: values.json ?? false } };
4508
4528
  return { kind: command, flags };
4509
4529
  }
4510
4530
  var LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"];
@@ -4639,6 +4659,7 @@ function parseUpdate(flagArgs) {
4639
4659
  args: flagArgs,
4640
4660
  options: {
4641
4661
  check: { type: "boolean", default: false },
4662
+ verbose: { type: "boolean", default: false },
4642
4663
  help: { type: "boolean", default: false }
4643
4664
  },
4644
4665
  allowPositionals: false
@@ -4647,7 +4668,7 @@ function parseUpdate(flagArgs) {
4647
4668
  return { kind: "error", message: e.message };
4648
4669
  }
4649
4670
  if (values.help) return { kind: "help", command: "update" };
4650
- return { kind: "update", flags: { check: values.check ?? false } };
4671
+ return { kind: "update", flags: { check: values.check ?? false, verbose: values.verbose ?? false } };
4651
4672
  }
4652
4673
  function usage(bin, command) {
4653
4674
  if (command === "start") {
@@ -4682,26 +4703,39 @@ function usage(bin, command) {
4682
4703
  }
4683
4704
  if (command === "stop") {
4684
4705
  return [
4685
- `Usage: ${bin} stop`,
4706
+ `Usage: ${bin} stop [--force]`,
4686
4707
  "",
4687
4708
  "Stop the node. It stays stopped across reboots until you run `dahrk start` again - a stop that",
4688
4709
  "quietly undid itself at the next boot would not be a stop.",
4689
4710
  "",
4711
+ "A node that is running a stage refuses to stop, because stopping it would kill work that costs real",
4712
+ "agent time. It lists what is in flight and leaves the node up; --force stops it anyway.",
4713
+ "",
4690
4714
  "The service stays installed, so starting it again is instant. To remove it entirely, use",
4691
- "`dahrk service uninstall`. To stop a node running in a terminal (--foreground), press Ctrl-C."
4715
+ "`dahrk service uninstall`. To stop a node running in a terminal (--foreground), press Ctrl-C.",
4716
+ "",
4717
+ "Options:",
4718
+ " --force Stop even if the node has stages in flight (this kills them)."
4692
4719
  ].join("\n");
4693
4720
  }
4694
4721
  if (command === "restart") {
4695
4722
  return [
4696
4723
  `Usage: ${bin} restart [options]`,
4697
4724
  "",
4698
- "Stop the node and start it again. Picks up a new client version, a rotated token, or a runtime you",
4699
- "have just installed (the node detects runtimes at boot, so a fresh `claude` on PATH needs a restart).",
4725
+ "Stop the node and start it again, then report its status. Picks up a new client version, a rotated",
4726
+ "token, or a runtime you have just installed (the node detects runtimes at boot, so a fresh `claude`",
4727
+ "on PATH needs a restart).",
4728
+ "",
4729
+ "This is what picks up a `dahrk update`: a running node keeps executing the build it started with, so",
4730
+ "`dahrk start` on it does nothing at all.",
4731
+ "",
4732
+ "Like `stop`, it refuses on a node with stages in flight rather than killing them; --force overrides.",
4700
4733
  "",
4701
4734
  "Options:",
4702
4735
  " --token <token> Re-enrol with a new token (or set DAHRK_ENROL_TOKEN).",
4703
4736
  " --hub-url <url> Hub WebSocket URL (or set DAHRK_HUB_URL).",
4704
- " --name <name> Display-name override (else the hub assigns one)."
4737
+ " --name <name> Display-name override (else the hub assigns one).",
4738
+ " --force Restart even if the node has stages in flight (this kills them)."
4705
4739
  ].join("\n");
4706
4740
  }
4707
4741
  if (command === "logs") {
@@ -4793,26 +4827,38 @@ function usage(bin, command) {
4793
4827
  }
4794
4828
  if (command === "status") {
4795
4829
  return [
4796
- `Usage: ${bin} status`,
4830
+ `Usage: ${bin} status [--json]`,
4831
+ "",
4832
+ "Report this node's local state: whether it is running (and how), whether it is enrolled and as whom,",
4833
+ "its node id, the runtimes it can serve with their versions, and what it is working on right now.",
4834
+ "",
4835
+ "Local only - it dials nothing, so it is instant and works offline. That is also why the hub line says",
4836
+ "when the node was LAST known to be connected (from its log) rather than claiming it is connected now,",
4837
+ "which this command cannot know without dialling. Use `doctor` for that: it checks the hub is",
4838
+ "reachable and the token still valid.",
4797
4839
  "",
4798
- "Report this node's local state: whether it is enrolled (and as whom), its node id, the runtimes",
4799
- "it can serve, and whether the always-on service is installed and actually running.",
4840
+ "Exits non-zero only when the node is installed but not running and nobody asked for that (i.e. it is",
4841
+ "crash-looping), so it works as a health check in a script. A node you deliberately stopped is not a",
4842
+ "failure.",
4800
4843
  "",
4801
- "Local only - it dials nothing, so it is instant and works offline. Use `doctor` to check the hub",
4802
- "is reachable and the token still valid. Exits non-zero only when the service is installed but",
4803
- "not running, so it works as a health check in a script."
4844
+ "Options:",
4845
+ " --json Print the facts as JSON (for a script) instead of the report."
4804
4846
  ].join("\n");
4805
4847
  }
4806
4848
  if (command === "update") {
4807
4849
  return [
4808
- `Usage: ${bin} update [--check]`,
4850
+ `Usage: ${bin} update [--check] [--verbose]`,
4809
4851
  "",
4810
4852
  "Update this client in place to the latest published release. Detects how it was installed",
4811
4853
  "(npm / Homebrew / curl) and runs the right upgrade, or prints the exact command when it cannot.",
4812
4854
  "Reports current -> latest, and a no-op when already current.",
4813
4855
  "",
4856
+ "A node that is already running keeps executing the OLD build until it is restarted, so if one is up",
4857
+ "this offers to restart it for you (and tells you to run `dahrk restart` when there is nobody to ask).",
4858
+ "",
4814
4859
  "Options:",
4815
- " --check Report whether an update is available without applying it (dry run)."
4860
+ " --check Report whether an update is available without applying it (dry run).",
4861
+ " --verbose Show the package manager's own output, which is hidden unless the upgrade fails."
4816
4862
  ].join("\n");
4817
4863
  }
4818
4864
  return [
@@ -4821,10 +4867,10 @@ function usage(bin, command) {
4821
4867
  "Commands:",
4822
4868
  " start Run the node, and keep it running (installs the service). --token to enrol, once.",
4823
4869
  " stop Stop the node. It stays stopped until the next `start`.",
4824
- " restart Stop it and start it again.",
4870
+ " restart Stop it and start it again. This is what picks up a `dahrk update`.",
4825
4871
  " logs Show what the node is doing (-f to follow, --run <id> to narrow to one run).",
4826
4872
  " diagnose Write a support bundle you can read, and send on if you choose. Uploads nothing.",
4827
- " status Is this node enrolled, and is it up? (local, dials nothing)",
4873
+ " status Is it up, is it enrolled, what is it working on? (local, dials nothing)",
4828
4874
  " run Run a workflow locally (engine-backed), e.g. `run preflight`.",
4829
4875
  " doctor Preflight checks: Node, runtimes, hub reachability, token validity.",
4830
4876
  " service Install/uninstall the always-on service by hand (`start` does this for you).",
@@ -4840,6 +4886,80 @@ function usage(bin, command) {
4840
4886
  // src/diagnose.ts
4841
4887
  import { existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync7 } from "fs";
4842
4888
  import { join as join12 } from "path";
4889
+
4890
+ // src/ui.ts
4891
+ import { styleText } from "util";
4892
+ function detectCapabilities(env = process.env, isTty = Boolean(process.stdout.isTTY)) {
4893
+ const forced = env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0";
4894
+ const colour = forced || isTty && env.NO_COLOR === void 0 && env.TERM !== "dumb";
4895
+ const locale = `${env.LC_ALL ?? ""}${env.LC_CTYPE ?? ""}${env.LANG ?? ""}`;
4896
+ const unicode = process.platform !== "win32" && (locale === "" || /UTF-?8/i.test(locale));
4897
+ return { colour, unicode };
4898
+ }
4899
+ var caps = detectCapabilities();
4900
+ var paint = (style, text) => (
4901
+ // `validateStream: false` because our own gate above has already decided; the option is ignored by the
4902
+ // Node 22.0-22.7 signature, which had no stream check at all, so this is correct on every supported Node.
4903
+ caps.colour ? styleText(style, text, { validateStream: false }) : text
4904
+ );
4905
+ var dim = (s) => paint("dim", s);
4906
+ var bold = (s) => paint("bold", s);
4907
+ var green = (s) => paint("green", s);
4908
+ var red = (s) => paint("red", s);
4909
+ function amber(s) {
4910
+ if (!caps.colour) return s;
4911
+ const depth = process.stdout.getColorDepth?.() ?? 4;
4912
+ return depth >= 24 ? `\x1B[38;2;245;165;36m${s}\x1B[39m` : paint("yellow", s);
4913
+ }
4914
+ var GLYPH = {
4915
+ ok: { unicode: "\u2714", ascii: "OK" },
4916
+ warn: { unicode: "\u25B2", ascii: "!" },
4917
+ fail: { unicode: "\u2716", ascii: "x" },
4918
+ info: { unicode: "\u2022", ascii: "-" }
4919
+ };
4920
+ var COLOUR = {
4921
+ ok: green,
4922
+ warn: amber,
4923
+ fail: red,
4924
+ info: dim
4925
+ };
4926
+ var symbol = (level) => COLOUR[level](caps.unicode ? GLYPH[level].unicode : GLYPH[level].ascii);
4927
+ var arrow = () => caps.unicode ? "\u2192" : "->";
4928
+ var verdict = (level, text) => ` ${symbol(level)} ${bold(text)}`;
4929
+ var LABEL_WIDTH = 11;
4930
+ function kv(label, value) {
4931
+ const gutter = label ? dim(label.padEnd(LABEL_WIDTH)) : " ".repeat(LABEL_WIDTH);
4932
+ return ` ${gutter}${value}`;
4933
+ }
4934
+ var hint = (text) => ` ${dim(text)}`;
4935
+ var hints = (pairs) => hint(pairs.map(([label, cmd]) => `${label}: ${cmd}`).join(" "));
4936
+ var out = (line) => void process.stdout.write(`${line}
4937
+ `);
4938
+ var stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
4939
+ function humanDuration(ms) {
4940
+ const s = Math.max(0, Math.round(ms / 1e3));
4941
+ if (s < 60) return `${s}s`;
4942
+ const m = Math.floor(s / 60);
4943
+ if (m < 60) return `${m}m`;
4944
+ const h = Math.floor(m / 60);
4945
+ if (h < 24) return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
4946
+ const d = Math.floor(h / 24);
4947
+ return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
4948
+ }
4949
+ var ago = (ms) => ms < 5e3 ? "just now" : `${humanDuration(ms)} ago`;
4950
+ var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
4951
+ async function confirm(question) {
4952
+ const { createInterface } = await import("readline/promises");
4953
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4954
+ try {
4955
+ const answer = (await rl.question(` ${question} ${dim("[Y/n]")} `)).trim().toLowerCase();
4956
+ return answer === "" || answer === "y" || answer === "yes";
4957
+ } finally {
4958
+ rl.close();
4959
+ }
4960
+ }
4961
+
4962
+ // src/diagnose.ts
4843
4963
  var BUNDLE_LOG_LINES = 2e3;
4844
4964
  function tailJsonl(raw, n) {
4845
4965
  const records = [];
@@ -4945,8 +5065,7 @@ var defaultDiagnoseDeps = (paths, clientVersion, doctor) => ({
4945
5065
  if (!existsSync8(dir)) mkdirSync8(dir, { recursive: true });
4946
5066
  writeFileSync7(p, content, { mode: 384 });
4947
5067
  },
4948
- out: (line) => void process.stdout.write(`${line}
4949
- `)
5068
+ out
4950
5069
  });
4951
5070
  function defaultBundlePath(cwd, now) {
4952
5071
  return join12(cwd, `dahrk-diagnose-${now.toISOString().replace(/[:.]/g, "-")}.json`);
@@ -4954,7 +5073,7 @@ function defaultBundlePath(cwd, now) {
4954
5073
 
4955
5074
  // src/doctor.ts
4956
5075
  var MIN_NODE_MAJOR = 22;
4957
- var TAG = { pass: "[PASS]", warn: "[WARN]", fail: "[FAIL]" };
5076
+ var LEVEL = { pass: "ok", warn: "warn", fail: "fail" };
4958
5077
  function checkNode(nodeVersion) {
4959
5078
  const major = Number.parseInt(nodeVersion.replace(/^v/, "").split(".")[0] ?? "", 10);
4960
5079
  if (Number.isNaN(major)) {
@@ -5052,16 +5171,15 @@ function checkToken(tokenPresent, hubUrl, probe2) {
5052
5171
  function formatReport(checks) {
5053
5172
  const failed = checks.filter((c) => c.status === "fail").length;
5054
5173
  const warned = checks.filter((c) => c.status === "warn").length;
5055
- const lines = checks.map((c) => `${TAG[c.status]} ${c.label}${c.detail ? `: ${c.detail}` : ""}`);
5056
- const summary = failed > 0 ? `FAIL - ${failed} check${failed === 1 ? "" : "s"} failed${warned ? `, ${warned} warning${warned === 1 ? "" : "s"}` : ""}.` : warned > 0 ? `PASS with ${warned} warning${warned === 1 ? "" : "s"}.` : "PASS - all checks green.";
5057
- return ["dahrk doctor", "", ...lines, "", summary].join("\n");
5174
+ const lines = checks.map((c) => ` ${symbol(LEVEL[c.status])} ${c.label}${c.detail ? `: ${dim(c.detail)}` : ""}`);
5175
+ const summary = failed > 0 ? verdict("fail", `${failed} check${failed === 1 ? "" : "s"} failed${warned ? `, ${warned} warning${warned === 1 ? "" : "s"}` : ""}.`) : warned > 0 ? verdict("warn", `Passed with ${warned} warning${warned === 1 ? "" : "s"}.`) : verdict("ok", "All checks green.");
5176
+ return ["", ...lines, "", summary].join("\n");
5058
5177
  }
5059
5178
  var defaultDeps = () => ({
5060
5179
  nodeVersion: process.versions.node,
5061
5180
  probeRuntimes: probeRuntimeStatuses,
5062
5181
  probeHub,
5063
- out: (line) => void process.stdout.write(`${line}
5064
- `)
5182
+ out
5065
5183
  });
5066
5184
  async function runDoctor(inputs, deps = {}) {
5067
5185
  const d = { ...defaultDeps(), ...deps };
@@ -5146,15 +5264,15 @@ function logsCommand(inputs) {
5146
5264
  var LEVEL_RANK = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 };
5147
5265
  var levelName = (n) => Object.entries(LEVEL_RANK).find(([, rank]) => rank === n)?.[0] ?? String(n);
5148
5266
  function parseRecords(raw) {
5149
- const out = [];
5267
+ const out2 = [];
5150
5268
  for (const line of raw.split("\n")) {
5151
5269
  if (!line.trim()) continue;
5152
5270
  try {
5153
- out.push(JSON.parse(line));
5271
+ out2.push(JSON.parse(line));
5154
5272
  } catch {
5155
5273
  }
5156
5274
  }
5157
- return out;
5275
+ return out2;
5158
5276
  }
5159
5277
  function filterRecords(records, q) {
5160
5278
  const min = q.level ? LEVEL_RANK[q.level] ?? 0 : 0;
@@ -5230,8 +5348,7 @@ var defaultLogsDeps = (files, jsonlFile) => ({
5230
5348
  child.on("error", () => resolve3(1));
5231
5349
  child.on("close", (code) => resolve3(code ?? 0));
5232
5350
  }),
5233
- out: (line) => void process.stdout.write(`${line}
5234
- `)
5351
+ out
5235
5352
  });
5236
5353
 
5237
5354
  // src/process-safety.ts
@@ -5362,15 +5479,18 @@ function worst(checks) {
5362
5479
  if (checks.some((c) => c.status === "warn")) return "warn";
5363
5480
  return "pass";
5364
5481
  }
5365
- function renderStage(index, total, verdict, out) {
5366
- const head = `[${index}/${total}] ${verdict.label}`;
5367
- const findings = verdict.checks.filter((c) => c.status !== "pass");
5482
+ function renderStage(index, total, stage, out2) {
5483
+ const head = ` ${dim(`[${index}/${total}]`)} ${stage.label}`;
5484
+ const findings = stage.checks.filter((c) => c.status !== "pass");
5368
5485
  if (findings.length === 0) {
5369
- out(`${head} \u2713`);
5486
+ out2(`${head} ${symbol("ok")}`);
5370
5487
  return;
5371
5488
  }
5372
- out(head);
5373
- for (const f of findings) out(` \u2022 ${f.label}: ${f.detail ?? f.status}`);
5489
+ out2(head);
5490
+ for (const f of findings) {
5491
+ const level = f.status === "fail" ? "fail" : "warn";
5492
+ out2(` ${symbol(level)} ${f.label}: ${dim(f.detail ?? f.status)}`);
5493
+ }
5374
5494
  }
5375
5495
  async function runPreflight(inputs, deps = {}) {
5376
5496
  const d = { ...defaultDeps2(), ...deps };
@@ -5422,8 +5542,7 @@ var defaultDeps2 = () => ({
5422
5542
  probeHub,
5423
5543
  gatherHost: gatherHostFacts,
5424
5544
  newRunId: () => randomUUID2(),
5425
- out: (line) => void process.stdout.write(`${line}
5426
- `)
5545
+ out
5427
5546
  });
5428
5547
  function commandPresent(cmd) {
5429
5548
  try {
@@ -5760,16 +5879,18 @@ function parseServiceStatus(manager, unitExists, probe2) {
5760
5879
  const running = active === "active";
5761
5880
  return running && pid > 0 ? { installed: true, running: true, pid } : { installed: true, running };
5762
5881
  }
5763
- function printUnsupported(out) {
5764
- out("dahrk service is supported on macOS (launchd) and Linux (systemd) only.");
5765
- out("On other platforms, run the node under a process manager such as pm2 (see the README).");
5882
+ function printUnsupported(out2) {
5883
+ out2(verdict("fail", "dahrk service is supported on macOS (launchd) and Linux (systemd) only."));
5884
+ out2("");
5885
+ out2(hint("On other platforms, run the node under a process manager such as pm2 (see the README)."));
5766
5886
  return 1;
5767
5887
  }
5768
5888
  function runCommands(commands, d) {
5769
5889
  for (const cmd of commands) {
5770
- const code = d.run(cmd.argv);
5890
+ const { code, output } = d.run(cmd.argv);
5771
5891
  if (code !== 0 && !cmd.ignoreFailure) {
5772
- d.out(` command failed (${code}): ${cmd.argv.join(" ")}`);
5892
+ d.out(verdict("fail", `command failed (${code}): ${cmd.argv.join(" ")}`));
5893
+ for (const line of output.split("\n")) if (line.trim()) d.out(` ${dim(line)}`);
5773
5894
  return code;
5774
5895
  }
5775
5896
  }
@@ -5777,13 +5898,13 @@ function runCommands(commands, d) {
5777
5898
  }
5778
5899
  async function runServiceInstall(inputs, deps = {}) {
5779
5900
  const d = { ...defaultDeps3(), ...deps };
5780
- d.out("dahrk service install");
5781
5901
  d.out("");
5782
5902
  const manager = detectManager(d.platform);
5783
5903
  if (manager === "unsupported") return printUnsupported(d.out);
5784
5904
  if (!inputs.token) {
5785
- d.out("No enrolment token: pass --token <token> or set DAHRK_ENROL_TOKEN.");
5786
- d.out("Get one at https://app.dahrk.ai.");
5905
+ d.out(verdict("fail", "No enrolment token."));
5906
+ d.out("");
5907
+ d.out(hint("Pass --token <token>, or set DAHRK_ENROL_TOKEN. Get one at https://app.dahrk.ai."));
5787
5908
  return 2;
5788
5909
  }
5789
5910
  const plan = buildPlan({
@@ -5802,26 +5923,41 @@ async function runServiceInstall(inputs, deps = {}) {
5802
5923
  d.mkdirp(dirOf(plan.filePath));
5803
5924
  d.writeFile(plan.filePath, plan.content);
5804
5925
  } catch (e) {
5805
- d.out(`Could not write the service file at ${plan.filePath}: ${e.message}`);
5926
+ d.out(verdict("fail", `Could not write the service file at ${plan.filePath}`));
5927
+ d.out("");
5928
+ d.out(hint(e.message));
5806
5929
  return 1;
5807
5930
  }
5808
- d.out(`Wrote ${plan.manager} service: ${plan.filePath}`);
5809
5931
  const code = runCommands(plan.installCommands, d);
5810
- d.out("");
5811
5932
  if (code !== 0) {
5812
- d.out(`The service file is in place but registering it failed (exit ${code}).`);
5813
- d.out("Fix the reported error and re-run `dahrk service install`, or load it yourself.");
5933
+ d.out("");
5934
+ d.out(hint(`The service file is in place at ${plan.filePath}. Fix the error above and re-run.`));
5814
5935
  return code;
5815
5936
  }
5816
- d.out("Installed. The node will start on boot and restart on failure.");
5817
- d.out(` logs: ${plan.logHint}`);
5818
- d.out(" uninstall: dahrk service uninstall");
5937
+ d.out(verdict("ok", "Installed. The node starts on boot and restarts on failure."));
5938
+ d.out("");
5939
+ d.out(kv("unit", plan.filePath));
5940
+ d.out("");
5941
+ d.out(hints([["logs", plan.logHint], ["uninstall", "dahrk service uninstall"]]));
5819
5942
  return 0;
5820
5943
  }
5821
5944
  function availabilityCommand(manager) {
5822
5945
  return manager === "systemd" ? ["systemctl", "--user", "show", "-p", "Version"] : void 0;
5823
5946
  }
5824
- async function runNodeStart(inputs, deps = {}) {
5947
+ function liveNodePid(d) {
5948
+ const manager = detectManager(d.platform);
5949
+ if (manager !== "unsupported") {
5950
+ const unit = unitPath(manager, d.homeDir);
5951
+ if (d.fileExists(unit)) {
5952
+ const status = parseServiceStatus(manager, true, d.capture(statusCommand(manager)));
5953
+ if (status.running && status.pid) return status.pid;
5954
+ }
5955
+ }
5956
+ const held = parseLock(d.readFile(d.lockFile));
5957
+ return held !== void 0 && d.isAlive(held) ? held : void 0;
5958
+ }
5959
+ var nodeIsRunning = (deps = {}) => liveNodePid({ ...defaultDeps3(), ...deps }) !== void 0;
5960
+ async function runNodeStart(inputs, deps = {}, opts = {}) {
5825
5961
  const d = { ...defaultDeps3(), ...deps };
5826
5962
  const manager = detectManager(d.platform);
5827
5963
  if (manager === "unsupported") {
@@ -5834,8 +5970,9 @@ async function runNodeStart(inputs, deps = {}) {
5834
5970
  const filePath = unitPath(manager, d.homeDir);
5835
5971
  const unitExists = d.fileExists(filePath);
5836
5972
  if (!inputs.token && !unitExists) {
5837
- d.out("No enrolment token: pass --token <token> or set DAHRK_ENROL_TOKEN.");
5838
- d.out("Get one at https://app.dahrk.ai.");
5973
+ d.out(verdict("fail", "No enrolment token."));
5974
+ d.out("");
5975
+ d.out(hint("Pass --token <token>, or set DAHRK_ENROL_TOKEN. Get one at https://app.dahrk.ai."));
5839
5976
  return { kind: "error", code: 2 };
5840
5977
  }
5841
5978
  const plan = buildPlan({
@@ -5851,12 +5988,10 @@ async function runNodeStart(inputs, deps = {}) {
5851
5988
  });
5852
5989
  const canRender = inputs.token !== void 0;
5853
5990
  const current = canRender && unitExists && unitIsCurrent(plan, d.readFile(filePath));
5854
- const probe2 = unitExists ? d.capture(statusCommand(manager)) : { code: 1, stdout: "" };
5855
- const status = parseServiceStatus(manager, unitExists, probe2);
5856
- if (current && status.running) {
5857
- d.out(`Node is already running${status.pid ? ` (pid ${status.pid})` : ""}.`);
5858
- d.out(` logs: ${plan.logHint}`);
5859
- return { kind: "running", code: 0 };
5991
+ if (!opts.alwaysLoad) {
5992
+ const probe2 = unitExists ? d.capture(statusCommand(manager)) : { code: 1, stdout: "" };
5993
+ const status = parseServiceStatus(manager, unitExists, probe2);
5994
+ if (current && status.running) return { kind: "running", code: 0, already: true };
5860
5995
  }
5861
5996
  if (canRender && !current) {
5862
5997
  try {
@@ -5867,28 +6002,65 @@ async function runNodeStart(inputs, deps = {}) {
5867
6002
  d.out(`Could not write the service file at ${plan.filePath}: ${e.message}`);
5868
6003
  return { kind: "error", code: 1 };
5869
6004
  }
5870
- d.out(unitExists ? `Updated ${plan.manager} service: ${plan.filePath}` : `Installed ${plan.manager} service: ${plan.filePath}`);
6005
+ d.out(hint(unitExists ? `Updated the ${plan.manager} service.` : `Installed the ${plan.manager} service.`));
5871
6006
  }
5872
6007
  const code = runCommands(plan.installCommands, d);
5873
6008
  if (code !== 0) {
5874
- d.out(`The service file is in place but starting it failed (exit ${code}).`);
6009
+ d.out(hint(`The service file is in place at ${plan.filePath}, but starting it failed.`));
5875
6010
  return { kind: "error", code };
5876
6011
  }
5877
- d.out("Node is running. It will start on boot and restart on failure.");
5878
- d.out(` logs: ${plan.logHint}`);
5879
- d.out(" stop: dahrk stop");
5880
- return { kind: "running", code: 0 };
6012
+ return { kind: "running", code: 0, already: false };
6013
+ }
6014
+ async function runNodeRestart(inputs, deps = {}) {
6015
+ const d = { ...defaultDeps3(), ...deps };
6016
+ const manager = detectManager(d.platform);
6017
+ if (manager === "unsupported") {
6018
+ return { kind: "foreground", reason: "no supported supervisor on this platform (launchd / systemd)" };
6019
+ }
6020
+ const busy = guardBusy(d, inputs.force ?? false, "restart");
6021
+ if (busy) return { kind: "error", code: busy };
6022
+ const plan = buildPlan({
6023
+ manager,
6024
+ nodeBin: d.nodeBin,
6025
+ scriptPath: d.scriptPath,
6026
+ token: "-",
6027
+ homeDir: d.homeDir,
6028
+ logDir: d.logDir
6029
+ });
6030
+ if (d.fileExists(plan.filePath)) runCommands(plan.stopCommands, d);
6031
+ return runNodeStart(inputs, deps, { alwaysLoad: true });
6032
+ }
6033
+ function guardBusy(d, force, verb) {
6034
+ const live = liveNodePid(d);
6035
+ const jobs = live === void 0 ? [] : d.inFlightJobs().filter((j) => j.nodePid === live);
6036
+ if (jobs.length === 0) return void 0;
6037
+ if (force) {
6038
+ d.out(verdict("warn", `Forcing ${verb} with ${jobs.length} job(s) in flight.`));
6039
+ return void 0;
6040
+ }
6041
+ d.out(verdict("warn", `This node is running ${jobs.length} job(s); \`${verb}\` would kill them.`));
6042
+ d.out("");
6043
+ for (const j of jobs) {
6044
+ const where = j.stageId ? `${j.runId} / ${j.stageId}` : j.runId;
6045
+ d.out(kv("", `${where} ${dim(humanDuration(Date.now() - j.startedAt))}`));
6046
+ }
6047
+ d.out("");
6048
+ d.out(hint(`Wait for them to finish, or \`dahrk ${verb} --force\` to interrupt them anyway.`));
6049
+ return BUSY_NODE;
5881
6050
  }
6051
+ var BUSY_NODE = 4;
5882
6052
  var STOP_FOREIGN_NODE = 3;
5883
6053
  function foreignNodePid(lock, servicePid, isAlive2) {
5884
6054
  const held = parseLock(lock);
5885
6055
  if (held === void 0 || held === servicePid) return void 0;
5886
6056
  return isAlive2(held) ? held : void 0;
5887
6057
  }
5888
- async function runNodeStop(deps = {}) {
6058
+ async function runNodeStop(inputs = {}, deps = {}) {
5889
6059
  const d = { ...defaultDeps3(), ...deps };
5890
6060
  const manager = detectManager(d.platform);
5891
6061
  if (manager === "unsupported") return printUnsupported(d.out);
6062
+ const busy = guardBusy(d, inputs.force ?? false, "stop");
6063
+ if (busy) return busy;
5892
6064
  const plan = buildPlan({
5893
6065
  manager,
5894
6066
  nodeBin: d.nodeBin,
@@ -5898,28 +6070,31 @@ async function runNodeStop(deps = {}) {
5898
6070
  logDir: d.logDir
5899
6071
  });
5900
6072
  if (!d.fileExists(plan.filePath)) {
5901
- d.out("No service installed, so there is nothing to stop.");
5902
- d.out("If you are running a node in a terminal (`dahrk start --foreground`), stop it with Ctrl-C.");
6073
+ d.out(verdict("info", "No service installed, so there is nothing to stop."));
6074
+ d.out("");
6075
+ d.out(hint("A node running in a terminal (`dahrk start --foreground`) stops with Ctrl-C."));
5903
6076
  return reportForeignNode(d, void 0) ?? 0;
5904
6077
  }
5905
6078
  const servicePid = parseServiceStatus(manager, true, d.capture(statusCommand(manager))).pid;
5906
6079
  runCommands(plan.stopCommands, d);
5907
- d.out("Node stopped. It will stay stopped across reboots until you run `dahrk start`.");
6080
+ d.out(verdict("ok", "Node stopped."));
6081
+ d.out("");
6082
+ d.out(hint("It stays stopped across reboots until you run `dahrk start`."));
5908
6083
  return reportForeignNode(d, servicePid) ?? 0;
5909
6084
  }
5910
6085
  function reportForeignNode(d, servicePid) {
5911
6086
  const pid = foreignNodePid(d.readFile(d.lockFile), servicePid, d.isAlive);
5912
6087
  if (pid === void 0) return void 0;
5913
6088
  d.out("");
5914
- d.out(`WARNING: a node is STILL RUNNING on this host (pid ${pid}). \`dahrk stop\` cannot stop it.`);
5915
- d.out("Something else is supervising it: pm2, a container, or `dahrk start --foreground` in a terminal.");
5916
- d.out("It is not a stray copy - it holds this node's identity, so it is still connected to the hub and");
5917
- d.out("still taking Jobs. Stop it where it was started (e.g. `pm2 delete dahrk-node`), or kill the pid.");
6089
+ d.out(verdict("warn", `A node is STILL RUNNING on this host (pid ${pid}). \`dahrk stop\` cannot stop it.`));
6090
+ d.out("");
6091
+ d.out(hint("Something else supervises it: pm2, a container, or `dahrk start --foreground` in a terminal."));
6092
+ d.out(hint("It is not a stray copy - it holds this node's identity, so it is still connected to the hub"));
6093
+ d.out(hint("and still taking Jobs. Stop it where it was started, or kill the pid."));
5918
6094
  return STOP_FOREIGN_NODE;
5919
6095
  }
5920
6096
  async function runServiceUninstall(deps = {}) {
5921
6097
  const d = { ...defaultDeps3(), ...deps };
5922
- d.out("dahrk service uninstall");
5923
6098
  d.out("");
5924
6099
  const manager = detectManager(d.platform);
5925
6100
  if (manager === "unsupported") return printUnsupported(d.out);
@@ -5932,17 +6107,19 @@ async function runServiceUninstall(deps = {}) {
5932
6107
  logDir: d.logDir
5933
6108
  });
5934
6109
  if (!d.fileExists(plan.filePath)) {
5935
- d.out(`No service installed (${plan.filePath} not found). Nothing to do.`);
6110
+ d.out(verdict("info", "No service installed. Nothing to do."));
5936
6111
  return 0;
5937
6112
  }
5938
6113
  runCommands(plan.uninstallCommands, d);
5939
6114
  try {
5940
6115
  d.removeFile(plan.filePath);
5941
6116
  } catch (e) {
5942
- d.out(`Deregistered, but could not delete ${plan.filePath}: ${e.message}`);
6117
+ d.out(verdict("warn", `Deregistered, but could not delete ${plan.filePath}`));
6118
+ d.out("");
6119
+ d.out(hint(e.message));
5943
6120
  return 1;
5944
6121
  }
5945
- d.out(`Removed ${plan.filePath}. The node will no longer start on boot.`);
6122
+ d.out(verdict("ok", "Removed. The node will no longer start on boot."));
5946
6123
  return 0;
5947
6124
  }
5948
6125
  function dirOf(path) {
@@ -5981,6 +6158,10 @@ var defaultDeps3 = () => ({
5981
6158
  logDir: logDir(process.env),
5982
6159
  lockFile: lockFile(process.env),
5983
6160
  isAlive,
6161
+ // Read-only here, and best-effort by construction: a missing or corrupt ledger reads as "no jobs", which
6162
+ // degrades the guard to today's behaviour rather than blocking a stop the operator needs.
6163
+ inFlightJobs: () => fileJobLedger(jobLedgerFile(stateDir(process.env)), () => {
6164
+ }).all(),
5984
6165
  // Snapshot the operator's PATH at install time so the daemon finds git + the runtime CLIs (Homebrew /
5985
6166
  // npm-global bins) that a supervisor's minimal PATH would otherwise hide.
5986
6167
  pathEnv: process.env.PATH,
@@ -6001,14 +6182,21 @@ var defaultDeps3 = () => ({
6001
6182
  },
6002
6183
  removeFile: (path) => rmSync7(path, { force: true }),
6003
6184
  fileExists: (path) => existsSync13(path),
6185
+ // Captured, never inherited. A supervisor's stderr is ours to relay when it matters, not to leak
6186
+ // unconditionally: `runCommands` decides who needs to read it.
6004
6187
  run: (argv) => {
6005
6188
  const [cmd, ...args] = argv;
6006
6189
  try {
6007
- execFileSync7(cmd, args, { stdio: "inherit" });
6008
- return 0;
6190
+ const stdout = execFileSync7(cmd, args, {
6191
+ encoding: "utf8",
6192
+ stdio: ["ignore", "pipe", "pipe"]
6193
+ });
6194
+ return { code: 0, output: stdout };
6009
6195
  } catch (e) {
6010
6196
  const status = e.status;
6011
- return typeof status === "number" ? status : 1;
6197
+ const { stdout, stderr } = e;
6198
+ const output = `${stdout?.toString() ?? ""}${stderr?.toString() ?? ""}`;
6199
+ return { code: typeof status === "number" ? status : 1, output };
6012
6200
  }
6013
6201
  },
6014
6202
  capture: (argv) => {
@@ -6024,8 +6212,7 @@ var defaultDeps3 = () => ({
6024
6212
  return { code: typeof status === "number" ? status : 1, stdout: "" };
6025
6213
  }
6026
6214
  },
6027
- out: (line) => void process.stdout.write(`${line}
6028
- `)
6215
+ out
6029
6216
  });
6030
6217
 
6031
6218
  // src/update.ts
@@ -6073,58 +6260,80 @@ function isNewer(latest, current) {
6073
6260
  }
6074
6261
  return false;
6075
6262
  }
6076
- function printChannelCommands(out) {
6077
- out(` npm: ${CHANNEL_COMMANDS.npm}`);
6078
- out(` Homebrew: ${CHANNEL_COMMANDS.homebrew}`);
6079
- out(` curl: ${CHANNEL_COMMANDS.curl}`);
6263
+ function printChannelCommands(out2) {
6264
+ out2(kv("npm", CHANNEL_COMMANDS.npm));
6265
+ out2(kv("Homebrew", CHANNEL_COMMANDS.homebrew));
6266
+ out2(kv("curl", CHANNEL_COMMANDS.curl));
6080
6267
  }
6081
6268
  async function runUpdate(inputs, deps = {}) {
6082
6269
  const d = { ...defaultDeps4(), ...deps };
6083
6270
  const current = inputs.currentVersion;
6084
- d.out("dahrk update");
6085
6271
  d.out("");
6086
6272
  let latest;
6087
6273
  try {
6088
6274
  latest = await d.fetchLatest();
6089
6275
  } catch (e) {
6090
- d.out(`Could not determine the latest version: ${e.message}`);
6091
- d.out(`You are on ${current}. See https://www.npmjs.com/package/dahrk-node for releases.`);
6276
+ d.out(verdict("fail", `Could not determine the latest version: ${e.message}`));
6277
+ d.out("");
6278
+ d.out(hint(`You are on ${current}. See https://www.npmjs.com/package/dahrk-node for releases.`));
6092
6279
  return 1;
6093
6280
  }
6281
+ d.saveResult({ updateCheckedAt: new Date(d.now()).toISOString(), updateLatest: latest });
6094
6282
  if (!isNewer(latest, current)) {
6095
- d.out(`Already on the latest version (${current}).`);
6283
+ d.out(verdict("ok", `Already on the latest version (${current}).`));
6096
6284
  return 0;
6097
6285
  }
6098
- d.out(`Update available: ${current} -> ${latest}`);
6286
+ d.out(verdict("warn", `Update available: ${current} ${arrow()} ${latest}`));
6099
6287
  const channel = detectChannel(d.binPath);
6100
6288
  const cmd = upgradeCommand(channel);
6101
6289
  if (inputs.check) {
6102
6290
  d.out("");
6103
6291
  if (cmd) {
6104
- d.out(`Run \`dahrk update\` to upgrade (${channel}: ${cmd.display}).`);
6292
+ d.out(hint(`Run \`dahrk update\` to upgrade (${channel}: ${cmd.display}).`));
6105
6293
  } else {
6106
- d.out("Run `dahrk update`, or upgrade with the command for your install channel:");
6294
+ d.out(hint("Run `dahrk update`, or upgrade with the command for your install channel:"));
6107
6295
  printChannelCommands(d.out);
6108
6296
  }
6109
6297
  return 0;
6110
6298
  }
6111
6299
  if (!cmd) {
6112
6300
  d.out("");
6113
- d.out("Could not tell how this client was installed. Upgrade with the command for your channel:");
6301
+ d.out(hint("Could not tell how this client was installed. Upgrade with the command for your channel:"));
6114
6302
  printChannelCommands(d.out);
6115
6303
  return 0;
6116
6304
  }
6117
6305
  d.out("");
6118
- d.out(`Upgrading via ${channel}: ${cmd.display}`);
6306
+ d.out(hint(`Upgrading via ${channel}: ${cmd.display}`));
6307
+ const { code, output } = d.runUpgrade(cmd.argv);
6119
6308
  d.out("");
6120
- const code = d.runUpgrade(cmd.argv);
6309
+ if (code !== 0) {
6310
+ d.out(verdict("fail", `Upgrade failed (exit ${code}).`));
6311
+ for (const line of output.split("\n")) if (line.trim()) d.out(` ${dim(line)}`);
6312
+ d.out("");
6313
+ d.out(hint(`Run it yourself to retry: ${cmd.display}`));
6314
+ return code;
6315
+ }
6316
+ if (inputs.verbose) {
6317
+ for (const line of output.split("\n")) if (line.trim()) d.out(` ${dim(line)}`);
6318
+ }
6319
+ d.out(verdict("ok", `Upgraded to ${latest}.`));
6320
+ await offerRestart(d);
6321
+ return 0;
6322
+ }
6323
+ async function offerRestart(d) {
6324
+ if (!d.nodeRunning()) return;
6325
+ if (!d.interactive()) {
6326
+ d.out(hint("A node is running on the old build. Run `dahrk restart` to pick this up."));
6327
+ return;
6328
+ }
6121
6329
  d.out("");
6122
- if (code === 0) {
6123
- d.out(`Upgraded to ${latest}. Restart a running node (\`dahrk start\`) to pick it up.`);
6124
- } else {
6125
- d.out(`Upgrade exited with code ${code}. Run it yourself to see the error: ${cmd.display}`);
6330
+ if (!await d.confirm("A node is running on the old build. Restart it now?")) {
6331
+ d.out(hint("Left running on the old build. Run `dahrk restart` when you are ready."));
6332
+ return;
6126
6333
  }
6127
- return code;
6334
+ const code = await d.restart();
6335
+ if (code !== 0) d.out(hint("The node is still on the old build. Run `dahrk restart` once it is free."));
6336
+ else d.out(verdict("ok", "Node restarted on the new build."));
6128
6337
  }
6129
6338
  async function fetchLatestVersion(signal) {
6130
6339
  const res = await fetch(LATEST_URL, {
@@ -6139,23 +6348,45 @@ async function fetchLatestVersion(signal) {
6139
6348
  function spawnUpgrade(argv) {
6140
6349
  const [cmd, ...args] = argv;
6141
6350
  try {
6142
- execFileSync8(cmd, args, { stdio: "inherit" });
6143
- return 0;
6351
+ const stdout = execFileSync8(cmd, args, {
6352
+ encoding: "utf8",
6353
+ stdio: ["ignore", "pipe", "pipe"]
6354
+ });
6355
+ return { code: 0, output: stdout };
6144
6356
  } catch (e) {
6145
6357
  const status = e.status;
6146
- return typeof status === "number" ? status : 1;
6358
+ const { stdout, stderr } = e;
6359
+ return {
6360
+ code: typeof status === "number" ? status : 1,
6361
+ output: `${stdout?.toString() ?? ""}${stderr?.toString() ?? ""}`
6362
+ };
6147
6363
  }
6148
6364
  }
6149
6365
  var defaultDeps4 = () => ({
6150
6366
  binPath: process.argv[1],
6151
6367
  fetchLatest: fetchLatestVersion,
6152
6368
  runUpgrade: spawnUpgrade,
6153
- out: (line) => void process.stdout.write(`${line}
6154
- `)
6369
+ // The same cache the daemon's periodic check writes (`updateCheckDeps` in main.ts), so both writers agree
6370
+ // on where "what the registry last said" lives and `status` has one place to read it from.
6371
+ saveResult: (patch) => writeState(process.env, patch),
6372
+ now: () => Date.now(),
6373
+ nodeRunning: nodeIsRunning,
6374
+ interactive: isInteractive,
6375
+ confirm,
6376
+ // No inputs: the installed unit already carries this node's token and overrides in its env block, so a
6377
+ // restart re-registers exactly what was there before, with the new client on disk behind it. `desired` is
6378
+ // untouched on purpose - the node was running, and it still is.
6379
+ restart: async () => {
6380
+ const outcome = await runNodeRestart({});
6381
+ if (outcome.kind === "running") return 0;
6382
+ return outcome.kind === "error" ? outcome.code : 1;
6383
+ },
6384
+ out
6155
6385
  });
6156
6386
 
6157
6387
  // src/update-check.ts
6158
- var DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1e3;
6388
+ var DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1e3;
6389
+ var STALE_AFTER_INTERVALS = 4;
6159
6390
  var FETCH_TIMEOUT_MS = 1500;
6160
6391
  function checkSuppressed(env) {
6161
6392
  return Boolean(env.DAHRK_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
@@ -6188,7 +6419,8 @@ async function checkForUpdate(currentVersion, deps) {
6188
6419
  if (checkSuppressed(deps.env)) return void 0;
6189
6420
  const state = deps.readState();
6190
6421
  if (!shouldCheck(deps.now(), state.updateCheckedAt, checkIntervalMs(deps.env), deps.env)) {
6191
- return cachedUpdate(state, currentVersion, deps.binPath);
6422
+ const cached = cachedUpdate(state, currentVersion, deps.binPath);
6423
+ return cached.kind === "available" ? cached : void 0;
6192
6424
  }
6193
6425
  let latest;
6194
6426
  try {
@@ -6200,88 +6432,179 @@ async function checkForUpdate(currentVersion, deps) {
6200
6432
  if (!isNewer(latest, currentVersion)) return void 0;
6201
6433
  return { current: currentVersion, latest, channel: detectChannel(deps.binPath) };
6202
6434
  }
6435
+ var isStale = (checkedAt, now, intervalMs) => now - checkedAt >= intervalMs * STALE_AFTER_INTERVALS;
6203
6436
  function cachedUpdate(state, currentVersion, binPath) {
6204
- const latest = state.updateLatest;
6205
- if (!latest || !isNewer(latest, currentVersion)) return void 0;
6206
- return { current: currentVersion, latest, channel: detectChannel(binPath) };
6437
+ const { updateLatest: latest, updateCheckedAt } = state;
6438
+ const checkedAt = updateCheckedAt ? Date.parse(updateCheckedAt) : NaN;
6439
+ if (!latest || !Number.isFinite(checkedAt)) return { kind: "unknown" };
6440
+ return isNewer(latest, currentVersion) ? { kind: "available", checkedAt, current: currentVersion, latest, channel: detectChannel(binPath) } : { kind: "current", checkedAt };
6207
6441
  }
6208
6442
 
6209
6443
  // src/status.ts
6210
- var bullet = (label, value) => ` ${label.padEnd(12)}${value}`;
6444
+ function presenceVerdict(f) {
6445
+ switch (f.presence.kind) {
6446
+ case "running": {
6447
+ const pid = f.presence.pid ? ` (pid ${f.presence.pid})` : "";
6448
+ return { level: "ok", text: `Node running${pid}` };
6449
+ }
6450
+ case "foreign":
6451
+ return { level: "ok", text: `Node running under another supervisor (pid ${f.presence.pid})` };
6452
+ case "stopped":
6453
+ return { level: "warn", text: "Node stopped" };
6454
+ case "not-installed":
6455
+ return { level: "warn", text: "Node not installed" };
6456
+ case "crashed":
6457
+ return { level: "fail", text: "Node is installed but NOT running - it is failing to start or crash-looping" };
6458
+ case "no-supervisor":
6459
+ return { level: "warn", text: "No supervisor on this host" };
6460
+ }
6461
+ }
6462
+ function presenceHints(f) {
6463
+ switch (f.presence.kind) {
6464
+ case "running":
6465
+ case "foreign":
6466
+ return [hints([["logs", "dahrk logs -f"], ["stop", "dahrk stop"]])];
6467
+ case "stopped":
6468
+ return [hints([["start", "dahrk start"]])];
6469
+ case "not-installed":
6470
+ return [hints([["start", "dahrk start"]])];
6471
+ case "crashed":
6472
+ return [hints([["logs", "dahrk logs -f"], ["check", "dahrk doctor"], ["report", "dahrk diagnose"]])];
6473
+ case "no-supervisor":
6474
+ return [hints([["run it here", "dahrk start --foreground"]])];
6475
+ }
6476
+ }
6211
6477
  function renderStatus(f) {
6212
- const lines = ["dahrk status", ""];
6478
+ const v = presenceVerdict(f);
6479
+ const lines = ["", verdict(v.level, v.text)];
6480
+ if (f.update?.kind === "available") {
6481
+ lines.push(
6482
+ verdict("warn", `Update available: ${f.clientVersion} ${arrow()} ${f.update.latest}`) + ` ${dim("run `dahrk update`")}`
6483
+ );
6484
+ }
6485
+ lines.push("");
6213
6486
  const { nodeId, name, tenantId, enrolToken } = f.state;
6214
6487
  if (enrolToken) {
6215
- lines.push(bullet("Enrolled", name ? `yes, as ${name}` : "yes"));
6216
- if (tenantId) lines.push(bullet("Tenant", tenantId));
6488
+ const who = name ? name : "yes";
6489
+ lines.push(kv("Enrolled", tenantId ? `${who} ${dim("\xB7")} ${tenantId}` : who));
6217
6490
  } else if (f.envToken) {
6218
- lines.push(bullet("Enrolled", "yes, via DAHRK_ENROL_TOKEN (caches on the next successful start)"));
6491
+ lines.push(kv("Enrolled", "via DAHRK_ENROL_TOKEN (caches on the next successful start)"));
6219
6492
  } else {
6220
- lines.push(bullet("Enrolled", "no - run `dahrk start --token <token>` once to enrol"));
6493
+ lines.push(kv("Enrolled", `no ${dim("run `dahrk start --token <token>` once to enrol")}`));
6221
6494
  }
6222
- lines.push(bullet("Node id", nodeId ?? "not yet minted (first `dahrk start` mints one)"));
6223
- lines.push(
6224
- bullet(
6225
- "Client",
6226
- f.update ? `${f.clientVersion} (update available: ${f.update.latest} - run \`dahrk update\`)` : f.clientVersion
6227
- )
6228
- );
6229
- lines.push(bullet("Hub", f.hubUrl));
6495
+ lines.push(kv("Node id", nodeId ?? dim("not yet minted (first `dahrk start` mints one)")));
6496
+ lines.push(kv("Client", `${f.clientVersion}${currency(f)}`));
6497
+ const conn = f.connection ? ` ${dim(`(${f.connection.event} ${ago(f.now - f.connection.at)}${f.connection.detail ? `, ${f.connection.detail}` : ""})`)}` : "";
6498
+ lines.push(kv("Hub", `${f.hubUrl}${conn}`));
6499
+ const installed = f.runtimes.filter((r) => r.installed);
6230
6500
  lines.push(
6231
- bullet(
6501
+ kv(
6232
6502
  "Runtimes",
6233
- f.runtimes.length > 0 ? f.runtimes.join(", ") : "none detected - this node will serve no Jobs (install claude / codex / pi)"
6503
+ installed.length > 0 ? installed.map((r) => r.version ? `${r.runtime} ${dim(r.version)}` : r.runtime).join(", ") : `${dim("none detected - this node will serve no Jobs (install claude / codex / pi)")}`
6234
6504
  )
6235
6505
  );
6236
- if (!f.service) {
6237
- lines.push(bullet("Node", "no supervisor on this host - run `dahrk start --foreground` (or under pm2)"));
6238
- } else if (!f.service.installed) {
6239
- lines.push(bullet("Node", "not installed - run `dahrk start` to run it always-on"));
6240
- } else if (f.service.running) {
6241
- const pid = f.service.pid ? ` (pid ${f.service.pid})` : "";
6242
- lines.push(bullet("Node", `running${pid}`));
6243
- } else if (f.state.desired === "stopped") {
6244
- lines.push(bullet("Node", "stopped - run `dahrk start` to bring it back"));
6506
+ if (f.jobs.length === 0) {
6507
+ lines.push(kv("Work", dim("idle")));
6245
6508
  } else {
6246
- lines.push(bullet("Node", "INSTALLED BUT NOT RUNNING - it is failing to start or crash-looping"));
6247
- if (f.logHint) lines.push(bullet("", `check the logs: ${f.logHint}`));
6509
+ const [first, ...rest] = f.jobs;
6510
+ lines.push(kv("Work", jobLine(first, f.now)));
6511
+ for (const j of rest) lines.push(kv("", jobLine(j, f.now)));
6248
6512
  }
6249
- lines.push("", `State file: ${f.stateFile}`);
6513
+ lines.push(...presenceHints(f).flatMap((h) => ["", h]));
6514
+ lines.push("", dim(` State file: ${f.stateFile}`));
6250
6515
  return lines;
6251
6516
  }
6517
+ function currency(f) {
6518
+ const u = f.update;
6519
+ if (!u) return "";
6520
+ if (u.kind === "unknown") {
6521
+ return ` ${dim("update status unknown - run `dahrk update --check`")}`;
6522
+ }
6523
+ const age = ago(f.now - u.checkedAt);
6524
+ if (isStale(u.checkedAt, f.now, f.updateIntervalMs)) {
6525
+ return ` ${dim(`as of ${age} - run \`dahrk update --check\` to refresh`)}`;
6526
+ }
6527
+ if (u.kind === "available") return ` ${dim(`(checked ${age})`)}`;
6528
+ return ` ${symbol("ok")} ${dim(`up to date (checked ${age})`)}`;
6529
+ }
6530
+ function jobLine(j, now) {
6531
+ const where = j.stageId ? `${j.runId} ${dim("/")} ${j.stageId}` : `${j.runId} ${dim(`(${j.kind})`)}`;
6532
+ return `${where} ${dim(humanDuration(now - j.startedAt))}`;
6533
+ }
6252
6534
  function isUnhealthy(f) {
6253
- return Boolean(f.service?.installed && !f.service.running && f.state.desired !== "stopped");
6535
+ return f.presence.kind === "crashed";
6254
6536
  }
6255
- async function runStatus(inputs, deps) {
6537
+ function resolvePresence(service, lockedPid, desired) {
6538
+ if (service?.running) return { kind: "running", ...service.pid ? { pid: service.pid } : {} };
6539
+ if (lockedPid !== void 0) return { kind: "foreign", pid: lockedPid };
6540
+ if (!service) return { kind: "no-supervisor" };
6541
+ if (!service.installed) return { kind: "not-installed" };
6542
+ return desired === "stopped" ? { kind: "stopped" } : { kind: "crashed" };
6543
+ }
6544
+ async function gatherFacts(inputs, deps) {
6256
6545
  const manager = detectManager(deps.platform);
6257
6546
  let service;
6258
- let logHint;
6259
6547
  if (manager !== "unsupported") {
6260
6548
  const unit = unitPath(manager, deps.homeDir);
6261
6549
  const exists = deps.fileExists(unit);
6262
6550
  const probe2 = exists ? deps.capture(statusCommand(manager)) : { code: 1, stdout: "" };
6263
6551
  service = parseServiceStatus(manager, exists, probe2);
6264
- logHint = "dahrk logs -f";
6265
6552
  }
6266
6553
  const state = readState(stateFile(deps.env));
6554
+ const lockedPid = deps.lockedPid();
6555
+ const presence = resolvePresence(service, lockedPid, state.desired);
6556
+ const livePid = presence.kind === "running" || presence.kind === "foreign" ? presence.pid : void 0;
6557
+ const jobs = livePid === void 0 ? [] : deps.jobs().filter((j) => j.nodePid === livePid);
6267
6558
  const update = checkSuppressed(deps.env) ? void 0 : cachedUpdate(state, inputs.clientVersion, deps.binPath);
6268
- const facts = {
6559
+ const connection = deps.connection();
6560
+ return {
6269
6561
  clientVersion: inputs.clientVersion,
6270
6562
  hubUrl: inputs.hubUrl,
6271
6563
  stateFile: stateFile(deps.env),
6272
6564
  state,
6273
6565
  envToken: Boolean(deps.env.DAHRK_ENROL_TOKEN),
6274
- runtimes: await deps.detectRuntimes(),
6566
+ runtimes: await deps.probeRuntimes(),
6567
+ presence,
6568
+ jobs,
6569
+ updateIntervalMs: checkIntervalMs(deps.env),
6570
+ now: deps.now(),
6275
6571
  ...service ? { service } : {},
6276
- ...logHint ? { logHint } : {},
6572
+ ...connection ? { connection } : {},
6277
6573
  ...update ? { update } : {}
6278
6574
  };
6575
+ }
6576
+ var CONNECTION_MARKERS = [
6577
+ { prefix: "EDGE_WELCOMED", event: "welcomed", detail: false },
6578
+ { prefix: "EDGE_CONNECTED", event: "connected", detail: false },
6579
+ { prefix: "EDGE_DISCONNECTED", event: "disconnected", detail: true },
6580
+ { prefix: "EDGE_STALE", event: "went stale", detail: false }
6581
+ ];
6582
+ function lastConnection(records) {
6583
+ for (let i = records.length - 1; i >= 0; i--) {
6584
+ const r = records[i];
6585
+ if (typeof r?.msg !== "string") continue;
6586
+ const marker = CONNECTION_MARKERS.find((m) => r.msg === m.prefix || r.msg.startsWith(`${m.prefix}:`));
6587
+ if (!marker) continue;
6588
+ const at = typeof r.time === "string" ? Date.parse(r.time) : typeof r.time === "number" ? r.time : NaN;
6589
+ if (Number.isNaN(at)) continue;
6590
+ const detail = marker.detail ? r.msg.slice(marker.prefix.length + 1).split(/\s+/)[0] ?? "" : "";
6591
+ return { event: marker.event, at, ...detail ? { detail } : {} };
6592
+ }
6593
+ return void 0;
6594
+ }
6595
+ async function runStatus(inputs, deps) {
6596
+ const facts = await gatherFacts(inputs, deps);
6597
+ if (inputs.json) {
6598
+ const { enrolToken: _omitted, ...state } = facts.state;
6599
+ deps.out(JSON.stringify({ ...facts, state, healthy: !isUnhealthy(facts) }, null, 2));
6600
+ return isUnhealthy(facts) ? 1 : 0;
6601
+ }
6279
6602
  for (const line of renderStatus(facts)) deps.out(line);
6280
6603
  return isUnhealthy(facts) ? 1 : 0;
6281
6604
  }
6282
6605
 
6283
6606
  // src/main.ts
6284
- var CLIENT_VERSION = "0.1.14";
6607
+ var CLIENT_VERSION = "0.1.16";
6285
6608
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
6286
6609
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
6287
6610
  var RUNTIMES2 = ["claude-code", "codex", "pi"];
@@ -6367,20 +6690,40 @@ async function start(flags) {
6367
6690
  const env = envWithFlags(process.env, flags);
6368
6691
  if (wantsForeground(env, flags)) return startForeground(env, flags);
6369
6692
  await offerUpdate(env);
6370
- const outcome = await runNodeStart({
6371
- ...resolveEnrolToken(env) ? { token: resolveEnrolToken(env) } : {},
6372
- ...env.DAHRK_NODE_NAME ? { name: env.DAHRK_NODE_NAME } : {},
6373
- ...env.DAHRK_HUB_URL ? { hubUrl: env.DAHRK_HUB_URL } : {}
6374
- });
6693
+ const outcome = await runNodeStart(serviceInputs(env));
6375
6694
  if (outcome.kind === "error") return outcome.code;
6376
6695
  if (outcome.kind === "running") {
6377
6696
  setDesired(env, "running");
6697
+ await printStatus(env);
6378
6698
  return 0;
6379
6699
  }
6380
- console.warn(`${outcome.reason}; running the node in this terminal instead.`);
6381
- console.warn("Use `dahrk start --foreground` (or DAHRK_FOREGROUND=1) to ask for this explicitly.");
6700
+ out("");
6701
+ out(verdict("warn", `${outcome.reason}; running the node in this terminal instead.`));
6702
+ out("");
6703
+ out(hint("Use `dahrk start --foreground` (or DAHRK_FOREGROUND=1) to ask for this explicitly."));
6382
6704
  return startForeground(env, flags);
6383
6705
  }
6706
+ var serviceInputs = (env) => ({
6707
+ ...resolveEnrolToken(env) ? { token: resolveEnrolToken(env) } : {},
6708
+ ...env.DAHRK_NODE_NAME ? { name: env.DAHRK_NODE_NAME } : {},
6709
+ ...env.DAHRK_HUB_URL ? { hubUrl: env.DAHRK_HUB_URL } : {}
6710
+ });
6711
+ async function restart(flags, force) {
6712
+ const env = envWithFlags(process.env, flags);
6713
+ out("");
6714
+ out(hint("Restarting the node..."));
6715
+ const outcome = await runNodeRestart({ ...serviceInputs(env), force });
6716
+ if (outcome.kind === "error") return outcome.code;
6717
+ if (outcome.kind === "foreground") {
6718
+ out(verdict("warn", `${outcome.reason}; there is no service to restart.`));
6719
+ out("");
6720
+ out(hint("A node running in a terminal is restarted by stopping it (Ctrl-C) and starting it again."));
6721
+ return 1;
6722
+ }
6723
+ setDesired(env, "running");
6724
+ await printStatus(env);
6725
+ return 0;
6726
+ }
6384
6727
  async function startForeground(env, flags) {
6385
6728
  if (!flags.ephemeral) {
6386
6729
  const lock = acquireLock(defaultLockDeps(lockFile(env)));
@@ -6446,11 +6789,14 @@ async function startForeground(env, flags) {
6446
6789
  });
6447
6790
  return 0;
6448
6791
  }
6449
- async function stop(env) {
6450
- const code = await runNodeStop();
6792
+ async function stop(env, force) {
6793
+ const code = await runNodeStop({ force });
6451
6794
  if (code === 0 || code === STOP_FOREIGN_NODE) setDesired(env, "stopped");
6452
6795
  return code;
6453
6796
  }
6797
+ var updateStateDeps = (env) => ({
6798
+ saveResult: (patch) => writeState(env, patch)
6799
+ });
6454
6800
  function updateCheckDeps(env) {
6455
6801
  return {
6456
6802
  env,
@@ -6461,25 +6807,15 @@ function updateCheckDeps(env) {
6461
6807
  fetchLatest: fetchLatestVersion
6462
6808
  };
6463
6809
  }
6464
- var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
6465
- async function confirm(question) {
6466
- const { createInterface } = await import("readline/promises");
6467
- const rl = createInterface({ input: process.stdin, output: process.stdout });
6468
- try {
6469
- const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
6470
- return answer === "" || answer === "y" || answer === "yes";
6471
- } finally {
6472
- rl.close();
6473
- }
6474
- }
6475
6810
  async function offerUpdate(env) {
6476
6811
  const available = await checkForUpdate(CLIENT_VERSION, updateCheckDeps(env));
6477
6812
  if (!available) return;
6478
- console.log(renderUpdateNotice(available));
6813
+ out("");
6814
+ out(renderUpdateNotice(available));
6479
6815
  if (!isInteractive()) return;
6480
- if (!await confirm("update now?")) return;
6481
- const code = await runUpdate({ currentVersion: CLIENT_VERSION, check: false });
6482
- if (code !== 0) console.warn("Update failed; starting the node on the current version anyway.");
6816
+ if (!await confirm("Update now?")) return;
6817
+ const code = await runUpdate({ currentVersion: CLIENT_VERSION, check: false }, updateStateDeps(env));
6818
+ if (code !== 0) out(hint("Update failed; starting the node on the current version anyway."));
6483
6819
  }
6484
6820
  function scheduleUpdateChecks(env) {
6485
6821
  if (checkSuppressed(env)) return;
@@ -6498,7 +6834,15 @@ function statusDeps(env) {
6498
6834
  homeDir: homedir7(),
6499
6835
  env,
6500
6836
  binPath: process.argv[1],
6501
- detectRuntimes: () => resolveRuntimes(env),
6837
+ // The rich probe, with versions - the same one `doctor` runs. An explicit DAHRK_RUNTIMES override still
6838
+ // wins, and is reported as installed-without-a-version, because a pinned runtime was never probed.
6839
+ probeRuntimes: async () => {
6840
+ const override = list(env.DAHRK_RUNTIMES).filter(isRuntime2);
6841
+ if (override.length > 0) {
6842
+ return RUNTIMES2.map((r) => ({ runtime: r, cmd: r, installed: override.includes(r) }));
6843
+ }
6844
+ return probeRuntimeStatuses();
6845
+ },
6502
6846
  fileExists: (path) => existsSync14(path),
6503
6847
  capture: (argv) => {
6504
6848
  const [cmd, ...args] = argv;
@@ -6510,10 +6854,35 @@ function statusDeps(env) {
6510
6854
  return { code: typeof status === "number" ? status : 1, stdout: "" };
6511
6855
  }
6512
6856
  },
6513
- out: (line) => void process.stdout.write(`${line}
6514
- `)
6857
+ lockedPid: () => {
6858
+ const held = parseLock(readFileOrUndefined(lockFile(env)));
6859
+ return held !== void 0 && isAlive(held) ? held : void 0;
6860
+ },
6861
+ // Best-effort by construction: a missing or unreadable ledger reads as "no jobs in flight", which shows
6862
+ // an idle node rather than failing the whole report over a file that only exists while work is running.
6863
+ jobs: () => fileJobLedger(jobLedgerFile(stateDir(env)), () => {
6864
+ }).all(),
6865
+ connection: () => {
6866
+ const raw = readFileOrUndefined(jsonlLogFile(env));
6867
+ return raw ? lastConnection(parseRecords(raw)) : void 0;
6868
+ },
6869
+ now: () => Date.now(),
6870
+ out
6515
6871
  };
6516
6872
  }
6873
+ function readFileOrUndefined(path) {
6874
+ try {
6875
+ return readFileSync11(path, "utf8");
6876
+ } catch {
6877
+ return void 0;
6878
+ }
6879
+ }
6880
+ async function printStatus(env) {
6881
+ return runStatus(
6882
+ { clientVersion: CLIENT_VERSION, hubUrl: env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL },
6883
+ statusDeps(env)
6884
+ );
6885
+ }
6517
6886
  var KNOWN_WORKFLOWS = ["preflight"];
6518
6887
  async function runWorkflow(flags) {
6519
6888
  if (flags.workflow !== "preflight") {
@@ -6523,7 +6892,7 @@ async function runWorkflow(flags) {
6523
6892
  return 2;
6524
6893
  }
6525
6894
  const env = applyEnvAliases(process.env);
6526
- const hubUrl = flags.hubUrl ?? env.DAHRK_HUB_URL;
6895
+ const hubUrl = flags.hubUrl ?? env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL;
6527
6896
  const token = flags.token ?? resolveEnrolToken(env);
6528
6897
  return runPreflight({
6529
6898
  ...flags.repo ? { repoPath: flags.repo } : {},
@@ -6552,7 +6921,10 @@ async function main() {
6552
6921
  case "doctor": {
6553
6922
  const env = envWithFlags(process.env, parsed.flags);
6554
6923
  process.exitCode = await runDoctor({
6555
- hubUrl: env.DAHRK_HUB_URL,
6924
+ // The same default the node itself dials. Without this, `dahrk doctor` on a stock install FAILED
6925
+ // with "no hub URL configured" while the node was connected to that very hub - it was reporting
6926
+ // that an env var was unset, not that anything was wrong.
6927
+ hubUrl: env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL,
6556
6928
  // Same resolution as `start`, so doctor checks the token the node would actually present:
6557
6929
  // the flag/env if given, else the one cached by the last successful enrolment.
6558
6930
  token: resolveEnrolToken(env),
@@ -6563,7 +6935,11 @@ async function main() {
6563
6935
  case "status": {
6564
6936
  const env = envWithFlags(process.env, parsed.flags);
6565
6937
  process.exitCode = await runStatus(
6566
- { clientVersion: CLIENT_VERSION, hubUrl: env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL },
6938
+ {
6939
+ clientVersion: CLIENT_VERSION,
6940
+ hubUrl: env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL,
6941
+ json: parsed.flags.json
6942
+ },
6567
6943
  statusDeps(env)
6568
6944
  );
6569
6945
  break;
@@ -6588,24 +6964,24 @@ async function main() {
6588
6964
  break;
6589
6965
  }
6590
6966
  case "update":
6591
- process.exitCode = await runUpdate({ currentVersion: CLIENT_VERSION, check: parsed.flags.check });
6967
+ process.exitCode = await runUpdate(
6968
+ {
6969
+ currentVersion: CLIENT_VERSION,
6970
+ check: parsed.flags.check,
6971
+ verbose: parsed.flags.verbose
6972
+ },
6973
+ updateStateDeps(applyEnvAliases(process.env))
6974
+ );
6592
6975
  break;
6593
6976
  case "start":
6594
6977
  process.exitCode = await start(parsed.flags);
6595
6978
  break;
6596
6979
  case "stop":
6597
- process.exitCode = await stop(applyEnvAliases(process.env));
6980
+ process.exitCode = await stop(applyEnvAliases(process.env), parsed.force);
6598
6981
  break;
6599
- case "restart": {
6600
- const env = envWithFlags(process.env, parsed.flags);
6601
- const code = await stop(env);
6602
- if (code !== 0) {
6603
- process.exitCode = code;
6604
- break;
6605
- }
6606
- process.exitCode = await start(parsed.flags);
6982
+ case "restart":
6983
+ process.exitCode = await restart(parsed.flags, parsed.force);
6607
6984
  break;
6608
- }
6609
6985
  case "logs": {
6610
6986
  const env = applyEnvAliases(process.env);
6611
6987
  process.exitCode = await runLogs(parsed.flags, defaultLogsDeps(logFiles(env), jsonlLogFile(env)));
@@ -6620,7 +6996,10 @@ async function main() {
6620
6996
  ...resolveEnrolToken(env) ? { token: resolveEnrolToken(env) } : {},
6621
6997
  hubUrl: env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL
6622
6998
  },
6623
- { out: (line) => void doctorLines.push(line) }
6999
+ // Stripped: stdout is a TTY here (the operator is watching), so the doctor correctly colours its
7000
+ // report - but this copy is going into a JSON file that someone will open in an editor and paste
7001
+ // into an issue, and escape codes have no business being in it.
7002
+ { out: (line) => void doctorLines.push(stripAnsi(line)) }
6624
7003
  );
6625
7004
  return doctorLines;
6626
7005
  };