dahrk-node 0.1.3 → 0.1.5

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 +1103 -78
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/main.ts
4
- import { existsSync as existsSync4, mkdirSync as mkdirSync6, readFileSync as readFileSync5, realpathSync, writeFileSync as writeFileSync6 } from "fs";
5
- import { randomUUID as randomUUID2 } from "crypto";
6
- import { homedir as homedir2 } from "os";
7
- import { basename, join as join7 } from "path";
4
+ import { existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync5, realpathSync as realpathSync3, writeFileSync as writeFileSync7 } from "fs";
5
+ import { randomUUID as randomUUID3 } from "crypto";
6
+ import { homedir as homedir4 } from "os";
7
+ import { basename, join as join9 } from "path";
8
8
  import { pathToFileURL } from "url";
9
9
 
10
10
  // ../../packages/edge/src/ws-client.ts
@@ -22,11 +22,11 @@ function createMockRunner(runtime) {
22
22
  return {
23
23
  runtime,
24
24
  async runBatch(ctx, onTrace) {
25
- const tool2 = ctx.config.tools?.[0] ?? "shell";
25
+ const tool3 = ctx.config.tools?.[0] ?? "shell";
26
26
  const toolUseId = "mock-tool-1";
27
27
  const events = [
28
28
  { seq: 0, runtime, type: "thought", ts: nowIso(), text: "mock: planning the stage" },
29
- { seq: 1, runtime, type: "action", ts: nowIso(), tool: tool2, toolUseId, input: { note: "mock action" } },
29
+ { seq: 1, runtime, type: "action", ts: nowIso(), tool: tool3, toolUseId, input: { note: "mock action" } },
30
30
  { seq: 2, runtime, type: "observation", ts: nowIso(), toolUseId, output: { ok: true } },
31
31
  { seq: 3, runtime, type: "response", ts: nowIso(), text: "mock: stage complete" }
32
32
  ];
@@ -335,7 +335,7 @@ var ManagedMailbox = class {
335
335
  const v = this.q.shift();
336
336
  if (v !== void 0) return Promise.resolve({ value: v, done: false });
337
337
  if (this.done) return Promise.resolve({ value: void 0, done: true });
338
- return new Promise((resolve) => this.waiters.push(resolve));
338
+ return new Promise((resolve2) => this.waiters.push(resolve2));
339
339
  }
340
340
  };
341
341
  }
@@ -346,7 +346,7 @@ function interactiveIdleWindows(ctx) {
346
346
  return { firstReplyMs: Math.max(firstReplyMs, idleMs), idleMs };
347
347
  }
348
348
  function raceNextTurn(pending, idleMs, signal) {
349
- return new Promise((resolve) => {
349
+ return new Promise((resolve2) => {
350
350
  let settled = false;
351
351
  let timer;
352
352
  function finish(r) {
@@ -354,7 +354,7 @@ function raceNextTurn(pending, idleMs, signal) {
354
354
  settled = true;
355
355
  if (timer) clearTimeout(timer);
356
356
  signal.removeEventListener("abort", onAbort);
357
- resolve(r);
357
+ resolve2(r);
358
358
  }
359
359
  function onAbort() {
360
360
  finish({ kind: "cancelled" });
@@ -371,6 +371,47 @@ function raceNextTurn(pending, idleMs, signal) {
371
371
  );
372
372
  });
373
373
  }
374
+ function createElicitTurnRouter(turns, opts) {
375
+ const conversation = new ManagedMailbox();
376
+ const ref = { settle: null };
377
+ void (async () => {
378
+ try {
379
+ for await (const t of turns) {
380
+ const settle = ref.settle;
381
+ if (settle) settle({ kind: "reply", text: t.text });
382
+ else conversation.push(t);
383
+ }
384
+ } finally {
385
+ conversation.end();
386
+ const settle = ref.settle;
387
+ if (settle) settle({ kind: "cancel" });
388
+ }
389
+ })();
390
+ const ask = (firstReply, onRaise) => {
391
+ if (ref.settle) return Promise.resolve({ kind: "busy" });
392
+ onRaise();
393
+ return new Promise((resolve2) => {
394
+ let settled = false;
395
+ const finish = (o) => {
396
+ if (settled) return;
397
+ settled = true;
398
+ clearTimeout(timer);
399
+ opts.signal.removeEventListener("abort", onAbort);
400
+ ref.settle = null;
401
+ resolve2(o);
402
+ };
403
+ const onAbort = () => finish({ kind: "cancel" });
404
+ const timer = setTimeout(() => finish({ kind: "noreply" }), firstReply ? opts.firstReplyMs : opts.idleMs);
405
+ if (opts.signal.aborted) {
406
+ finish({ kind: "cancel" });
407
+ return;
408
+ }
409
+ opts.signal.addEventListener("abort", onAbort, { once: true });
410
+ ref.settle = finish;
411
+ });
412
+ };
413
+ return { conversation, ask };
414
+ }
374
415
 
375
416
  // ../../packages/executor-worktree/src/stage-complete-tool.ts
376
417
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
@@ -401,6 +442,52 @@ function createStageCompleteTool() {
401
442
  };
402
443
  }
403
444
 
445
+ // ../../packages/executor-worktree/src/ask-user-question-tool.ts
446
+ import { createSdkMcpServer as createSdkMcpServer2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
447
+ import { z as z2 } from "zod";
448
+ var ASK_USER_QUESTION_TOOL_NAME = "mcp__ask__ask_user_question";
449
+ var ASK_USER_QUESTION_ALIAS = "AskUserQuestion";
450
+ var optionSchema = z2.object({
451
+ label: z2.string(),
452
+ description: z2.string().optional().default(""),
453
+ preview: z2.string().optional()
454
+ });
455
+ var questionSchema = z2.object({
456
+ question: z2.string(),
457
+ header: z2.string().optional().default(""),
458
+ options: z2.array(optionSchema).min(1),
459
+ multiSelect: z2.boolean().optional()
460
+ });
461
+ function toElicitQuestion(q) {
462
+ const options = q.options.map((o) => ({ label: o.label, value: o.label }));
463
+ const described = q.options.filter((o) => (o.description ?? "").trim());
464
+ const lines = described.map((o) => `- ${o.label}: ${(o.description ?? "").trim()}`);
465
+ const prompt = lines.length ? `${q.question}
466
+
467
+ ${lines.join("\n")}` : q.question;
468
+ return { prompt, options, ...q.multiSelect ? { multiSelect: true } : {} };
469
+ }
470
+ function createAskUserQuestionTool(deps) {
471
+ const askTool = tool2(
472
+ "ask_user_question",
473
+ "Ask the human a structured multiple-choice question and wait for their selection. Use this when you need the human to choose between options before you can continue.",
474
+ { questions: z2.array(questionSchema).min(1) },
475
+ async (args) => {
476
+ const first = args.questions[0];
477
+ const question = toElicitQuestion(first);
478
+ const prompt = args.questions.length > 1 ? `${question.prompt}
479
+
480
+ (Note: ${args.questions.length} questions were asked at once; answer this one first, then ask the rest.)` : question.prompt;
481
+ const text = await deps.ask({ ...question, prompt });
482
+ return { content: [{ type: "text", text }] };
483
+ }
484
+ );
485
+ return {
486
+ server: createSdkMcpServer2({ name: "ask", version: "0.0.0", tools: [askTool] }),
487
+ allowedToolName: ASK_USER_QUESTION_TOOL_NAME
488
+ };
489
+ }
490
+
404
491
  // ../../packages/executor-worktree/src/claude-adapter.ts
405
492
  var COALESCE_MS = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
406
493
  var MAX_TURNS = Number(process.env.DAHRK_MAX_TURNS ?? process.env.SKAKEL_MAX_TURNS ?? 64);
@@ -412,6 +499,17 @@ var userMsg = (text) => ({
412
499
  message: { role: "user", content: text }
413
500
  });
414
501
  var sessionIdOf = (msg) => "session_id" in msg && typeof msg.session_id === "string" ? msg.session_id : void 0;
502
+ var policyCanUseTool = async (ctx, toolName, input) => {
503
+ const verdict = ctx.authorizeToolUse?.(toolName, input);
504
+ if (verdict?.verdict === "deny") {
505
+ return { behavior: "deny", message: verdict.reason ?? `tool "${toolName}" denied by policy ${verdict.policy}` };
506
+ }
507
+ return { behavior: "allow", updatedInput: input };
508
+ };
509
+ var interactiveCanUseTool = (summarising, stageAllowedToolName, ctx, toolName, input) => summarising && toolName !== stageAllowedToolName ? Promise.resolve({
510
+ behavior: "deny",
511
+ message: "Summarise from the work you just did; reply with the sentence only, no tools."
512
+ }) : policyCanUseTool(ctx, toolName, input);
415
513
  function buildBrokeredMcpServers(ctx) {
416
514
  const servers = ctx.config.mcpServers;
417
515
  if (!servers || servers.length === 0 || !ctx.mcpProxyBaseUrl) return void 0;
@@ -477,7 +575,7 @@ function createClaudeRunner() {
477
575
  // Tools stay allowed by canUseTool below; we do NOT set a restrictive allowedTools here.
478
576
  ...mcpServers ? { mcpServers } : {},
479
577
  // Headless: allow tools to run without an interactive permission prompt (M6 wires policy).
480
- canUseTool: async (_toolName, input) => ({ behavior: "allow", updatedInput: input })
578
+ canUseTool: async (toolName, input) => policyCanUseTool(ctx, toolName, input)
481
579
  };
482
580
  const state = newBufferState();
483
581
  let status = "ok";
@@ -500,6 +598,27 @@ function createClaudeRunner() {
500
598
  async runInteractive(ctx, turns, onTrace) {
501
599
  const emit = makeEmit("claude-code", onTrace);
502
600
  const stageTool = createStageCompleteTool();
601
+ const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
602
+ let awaitingFirstReply = true;
603
+ const router = createElicitTurnRouter(turns, { signal: abortController.signal, firstReplyMs, idleMs });
604
+ const askTool = createAskUserQuestionTool({
605
+ ask: async (question) => {
606
+ const outcome = await router.ask(awaitingFirstReply, () => {
607
+ emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
608
+ ctx.emitElicit?.(question);
609
+ });
610
+ switch (outcome.kind) {
611
+ case "reply":
612
+ return `The user selected: ${outcome.text}`;
613
+ case "busy":
614
+ return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
615
+ case "noreply":
616
+ return "No response from the user; proceed with your best judgement.";
617
+ case "cancel":
618
+ return "The question was cancelled.";
619
+ }
620
+ }
621
+ });
503
622
  const brokered = buildBrokeredMcpServers(ctx);
504
623
  let summarising = false;
505
624
  const options = {
@@ -507,12 +626,17 @@ function createClaudeRunner() {
507
626
  // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
508
627
  // interactive persona still gets it without losing the working-directory context.
509
628
  systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
510
- // Inject the stage-complete exit tool alongside any brokered MCP servers (parity with batch).
511
- mcpServers: { dahrk: stageTool.server, ...brokered ?? {} },
512
- // Auto-approve the exit tool; `allowedTools` is an auto-approve list, not a whitelist, so it
513
- // does not restrict the other tools canUseTool allows.
514
- allowedTools: [stageTool.allowedToolName],
515
- canUseTool: async (toolName, input) => summarising && toolName !== stageTool.allowedToolName ? { behavior: "deny", message: "Summarise from the work you just did; reply with the sentence only, no tools." } : { behavior: "allow", updatedInput: input },
629
+ // Inject the stage-complete exit tool and the AskUserQuestion shadow alongside any brokered
630
+ // MCP servers (parity with batch).
631
+ mcpServers: { dahrk: stageTool.server, ask: askTool.server, ...brokered ?? {} },
632
+ // Redirect the built-in AskUserQuestion to the shadow tool so a structured question surfaces
633
+ // as a Linear elicitation. The redirect is name-only and single-hop; the tool still runs, so
634
+ // this is mapping, not gating (DHK-344 / DHK-223).
635
+ toolAliases: { [ASK_USER_QUESTION_ALIAS]: askTool.allowedToolName },
636
+ // Auto-approve the injected tools; `allowedTools` is an auto-approve list, not a whitelist, so
637
+ // it does not restrict the other tools canUseTool allows.
638
+ allowedTools: [stageTool.allowedToolName, askTool.allowedToolName],
639
+ canUseTool: async (toolName, input) => interactiveCanUseTool(summarising, stageTool.allowedToolName, ctx, toolName, input),
516
640
  maxTurns: MAX_TURNS,
517
641
  includePartialMessages: false
518
642
  };
@@ -522,7 +646,7 @@ function createClaudeRunner() {
522
646
  const q = query({ prompt: mailbox, options });
523
647
  active = q;
524
648
  const it = q[Symbol.asyncIterator]();
525
- const humanIter = turns[Symbol.asyncIterator]();
649
+ const humanIter = router.conversation[Symbol.asyncIterator]();
526
650
  const state = newBufferState();
527
651
  const consumeTurn = async () => {
528
652
  for (; ; ) {
@@ -532,8 +656,6 @@ function createClaudeRunner() {
532
656
  if (res.isResult) return res.responseText;
533
657
  }
534
658
  };
535
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
536
- let awaitingFirstReply = true;
537
659
  let exited = null;
538
660
  let pending = humanIter.next();
539
661
  try {
@@ -1214,6 +1336,7 @@ import { dirname, isAbsolute, join as join2 } from "path";
1214
1336
  var noopLogger = { info: () => {
1215
1337
  }, warn: () => {
1216
1338
  } };
1339
+ var SCRATCH_DIR = ".skakel/scratch";
1217
1340
  function parseOwnerRepo(gitUrl) {
1218
1341
  const m = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(gitUrl.trim());
1219
1342
  return m ? `${m[1]}/${m[2]}` : void 0;
@@ -1222,8 +1345,11 @@ function sanitizeBranchName(name) {
1222
1345
  if (!name) return name;
1223
1346
  return name.replace(/[`~^:?*[\]\\@{}\s]/g, "-").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/\.lock(\/|$)/g, "$1").replace(/^[.\-/]+/, "").replace(/[.\-/]+$/, "").replace(/-{2,}/g, "-");
1224
1347
  }
1348
+ function resolveWorktreesDir(override) {
1349
+ return override ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join2(homedir(), ".dahrk", "worktrees");
1350
+ }
1225
1351
  function createGitService(opts = {}) {
1226
- const worktreesDir = opts.worktreesDir ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join2(homedir(), ".dahrk", "worktrees");
1352
+ const worktreesDir = resolveWorktreesDir(opts.worktreesDir);
1227
1353
  const mirrorsDir = opts.mirrorsDir ?? process.env.DAHRK_MIRRORS_DIR ?? process.env.SKAKEL_MIRRORS_DIR ?? join2(homedir(), ".dahrk", "mirrors");
1228
1354
  const authorName = opts.authorName ?? process.env.DAHRK_GIT_AUTHOR_NAME ?? "Dahrk";
1229
1355
  const authorEmail = opts.authorEmail ?? process.env.DAHRK_GIT_AUTHOR_EMAIL ?? "noreply@dahrk.ai";
@@ -1255,24 +1381,23 @@ function createGitService(opts = {}) {
1255
1381
  return (stderr?.trim() || err?.message || String(e)).split("\n")[0] ?? String(e);
1256
1382
  };
1257
1383
  const excludeScratchLocally = (worktreePath) => {
1258
- const entry = ".skakel/scratch/";
1384
+ const entry = `${SCRATCH_DIR}/`;
1259
1385
  try {
1260
1386
  const rel = git(worktreePath, ["rev-parse", "--git-path", "info/exclude"]).trim();
1261
1387
  const excludePath = isAbsolute(rel) ? rel : join2(worktreePath, rel);
1262
1388
  const existing = existsSync(excludePath) ? readFileSync2(excludePath, "utf-8") : "";
1263
1389
  if (existing.split("\n").some((l) => l.trim() === entry)) return;
1264
1390
  mkdirSync(dirname(excludePath), { recursive: true });
1265
- const sep2 = existing && !existing.endsWith("\n") ? "\n" : "";
1266
- writeFileSync(excludePath, `${existing}${sep2}${entry}
1391
+ const sep3 = existing && !existing.endsWith("\n") ? "\n" : "";
1392
+ writeFileSync(excludePath, `${existing}${sep3}${entry}
1267
1393
  `);
1268
1394
  } catch (e) {
1269
1395
  log2.warn(`could not set worktree scratch exclude at ${worktreePath}: ${e.message}`);
1270
1396
  }
1271
1397
  };
1272
1398
  const commitPending = (worktreePath, message) => {
1273
- const SCRATCH = ".skakel/scratch";
1274
1399
  excludeScratchLocally(worktreePath);
1275
- git(worktreePath, ["rm", "-r", "--cached", "--ignore-unmatch", "--quiet", SCRATCH]);
1400
+ git(worktreePath, ["rm", "-r", "--cached", "--ignore-unmatch", "--quiet", SCRATCH_DIR]);
1276
1401
  git(worktreePath, ["add", "-A", "--", "."]);
1277
1402
  const dirty = !gitOk(worktreePath, ["diff", "--cached", "--quiet"]);
1278
1403
  if (dirty) {
@@ -1327,6 +1452,7 @@ function createGitService(opts = {}) {
1327
1452
  scratchPath: join2(worktreePath, ".skakel", "scratch")
1328
1453
  });
1329
1454
  return {
1455
+ worktreesDir,
1330
1456
  async createWorktree(spec) {
1331
1457
  const { repoId, gitUrl, baseBranch, runId } = spec;
1332
1458
  const branchName = sanitizeBranchName(spec.branch ?? `dahrk/${runId}`);
@@ -1394,6 +1520,11 @@ function createGitService(opts = {}) {
1394
1520
  if (!gitOk(worktreePath, ["merge-base", "HEAD", "FETCH_HEAD"])) {
1395
1521
  return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "diverged" };
1396
1522
  }
1523
+ const delta = git(worktreePath, ["diff", "--name-only", "FETCH_HEAD...HEAD"]).split("\n").map((l) => l.trim()).filter(Boolean);
1524
+ const isScratchPath = (p) => p === SCRATCH_DIR || p.startsWith(`${SCRATCH_DIR}/`) || gitOk(worktreePath, ["check-ignore", "-q", "--no-index", "--", p]);
1525
+ if (!delta.some((p) => !isScratchPath(p))) {
1526
+ return { headSha, pushed: false, nothingToCommit: true, commitsAhead, integration: "noop" };
1527
+ }
1397
1528
  try {
1398
1529
  git(worktreePath, [
1399
1530
  "-c",
@@ -1650,12 +1781,12 @@ function evaluatePolicies(event, rules) {
1650
1781
  }
1651
1782
  return ALLOW;
1652
1783
  }
1653
- function denyToolRule(tool2) {
1784
+ function denyToolRule(tool3) {
1654
1785
  return {
1655
1786
  name: "deny_tool",
1656
1787
  evaluate(event) {
1657
- if (event.kind === "action" && event.tool === tool2) {
1658
- return { verdict: "deny", policy: "deny_tool", reason: `tool "${tool2}" is denied` };
1788
+ if (event.kind === "action" && event.tool === tool3) {
1789
+ return { verdict: "deny", policy: "deny_tool", reason: `tool "${tool3}" is denied` };
1659
1790
  }
1660
1791
  return null;
1661
1792
  }
@@ -1667,7 +1798,7 @@ import { execFileSync as execFileSync3 } from "child_process";
1667
1798
  import { createHash as createHash3 } from "crypto";
1668
1799
  import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
1669
1800
  import { tmpdir as tmpdir2 } from "os";
1670
- import { join as join6 } from "path";
1801
+ import { isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve, sep as sep2 } from "path";
1671
1802
  import { attachedDocBasename as attachedDocBasename2 } from "@dahrk/contracts";
1672
1803
 
1673
1804
  // ../../packages/edge/src/builtins.ts
@@ -1736,7 +1867,15 @@ function buildRules(policies, ctx) {
1736
1867
  const rules = [];
1737
1868
  let stageToolCalls = 0;
1738
1869
  for (const p of policies) {
1739
- if ("write_scope" in p) {
1870
+ if ("read_only" in p && p.read_only) {
1871
+ rules.push({
1872
+ name: "read_only",
1873
+ evaluate(event) {
1874
+ if (event.kind !== "action" || !WRITE_TOOLS.has(event.tool)) return null;
1875
+ return { verdict: "deny", policy: "read_only", reason: `read-only stage: "${event.tool}" is not permitted` };
1876
+ }
1877
+ });
1878
+ } else if ("write_scope" in p) {
1740
1879
  const { branches, repos } = p.write_scope;
1741
1880
  rules.push({
1742
1881
  name: "write_scope",
@@ -1841,11 +1980,11 @@ async function startMcpGateway(opts) {
1841
1980
  if (upstream.body) await pipeline(Readable.fromWeb(upstream.body), res);
1842
1981
  else res.end();
1843
1982
  };
1844
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1983
+ await new Promise((resolve2) => server.listen(0, "127.0.0.1", resolve2));
1845
1984
  const { port } = server.address();
1846
1985
  return {
1847
1986
  baseUrl: `http://127.0.0.1:${port}`,
1848
- stop: () => new Promise((resolve) => server.close(() => resolve()))
1987
+ stop: () => new Promise((resolve2) => server.close(() => resolve2()))
1849
1988
  };
1850
1989
  }
1851
1990
 
@@ -1933,9 +2072,24 @@ var SCRATCH_OUTPUT_DIR = ".skakel/scratch/output";
1933
2072
  function capContent(raw) {
1934
2073
  return raw.length > ARTIFACT_CAP_BYTES ? raw.slice(0, ARTIFACT_CAP_BYTES) : raw;
1935
2074
  }
2075
+ function resolveWorktreeRelativePath(ref, relPath) {
2076
+ if (isAbsolute2(relPath)) return void 0;
2077
+ const root = resolve(ref.worktreePath);
2078
+ const target = resolve(root, relPath);
2079
+ const fromRoot = relative2(root, target);
2080
+ if (fromRoot === "" || fromRoot === ".." || fromRoot.startsWith(`..${sep2}`) || isAbsolute2(fromRoot)) {
2081
+ return void 0;
2082
+ }
2083
+ return target;
2084
+ }
2085
+ function isSafeWorktreeRelativePath(ref, relPath) {
2086
+ return resolveWorktreeRelativePath(ref, relPath) !== void 0;
2087
+ }
1936
2088
  function readEmittedArtifact(ref, relPath) {
2089
+ const path = resolveWorktreeRelativePath(ref, relPath);
2090
+ if (!path) return void 0;
1937
2091
  try {
1938
- const raw = readFileSync4(join6(ref.worktreePath, relPath), "utf8");
2092
+ const raw = readFileSync4(path, "utf8");
1939
2093
  return { path: relPath, content: capContent(raw) };
1940
2094
  } catch {
1941
2095
  return void 0;
@@ -1981,7 +2135,7 @@ function resolveStageArtifact(ref, emitArtifact, handedBack) {
1981
2135
  const declared = readEmittedArtifact(ref, emitArtifact);
1982
2136
  if (declared && declared.content.trim().length > 0) return { artifact: declared, source: "declared-file" };
1983
2137
  }
1984
- if (handedBack && handedBack.content.trim().length > 0) {
2138
+ if (handedBack && handedBack.content.trim().length > 0 && isSafeWorktreeRelativePath(ref, handedBack.path)) {
1985
2139
  return { artifact: { path: handedBack.path, content: capContent(handedBack.content) }, source: "tool-handoff" };
1986
2140
  }
1987
2141
  const scratch = scanScratchOutput(ref, emitArtifact);
@@ -2081,8 +2235,9 @@ function createStageRunner(deps) {
2081
2235
  writeGuidance(ref, job.guidance);
2082
2236
  writeAttachedDocuments(ref, job.attachedDocuments);
2083
2237
  if (job.agentConfig.emitArtifact) {
2238
+ const artifactDir = resolveWorktreeRelativePath(ref, job.agentConfig.emitArtifact);
2084
2239
  const slash = job.agentConfig.emitArtifact.lastIndexOf("/");
2085
- if (slash > 0) {
2240
+ if (artifactDir && slash > 0) {
2086
2241
  try {
2087
2242
  mkdirSync5(join6(ref.worktreePath, job.agentConfig.emitArtifact.slice(0, slash)), { recursive: true });
2088
2243
  } catch {
@@ -2219,20 +2374,50 @@ function createStageRunner(deps) {
2219
2374
  return finish("fail", `${stageId}: denied at stage entry (${entry.policy})`, job.sessionId);
2220
2375
  }
2221
2376
  let denied = false;
2377
+ const authorisedActions = [];
2222
2378
  const runtime = agentConfig.runtime;
2379
+ const actionKey = (tool3, input) => {
2380
+ try {
2381
+ return `${tool3}\0${JSON.stringify(input)}`;
2382
+ } catch {
2383
+ return `${tool3}\0`;
2384
+ }
2385
+ };
2386
+ const policyReason = (verdict) => verdict.reason ?? `tool action denied by ${verdict.policy}`;
2387
+ const recordDeny = (verdict, toolUseId) => {
2388
+ denied = true;
2389
+ const reason = policyReason(verdict);
2390
+ if (toolUseId) {
2391
+ streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "observation", runtime, toolUseId, isError: true, output: { error: reason } }));
2392
+ }
2393
+ streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime, event: "policy-deny", detail: reason }));
2394
+ deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: reason });
2395
+ };
2396
+ const authorizeToolUse = (tool3, input) => {
2397
+ const verdict = evaluatePolicies({ kind: "action", stageId, tool: tool3, input }, rules);
2398
+ if (verdict.verdict === "deny") {
2399
+ recordDeny(verdict);
2400
+ } else {
2401
+ authorisedActions.push(actionKey(tool3, input));
2402
+ }
2403
+ return verdict;
2404
+ };
2223
2405
  const onTrace = (event) => {
2224
2406
  if (event.type === "action") {
2225
- const verdict = evaluatePolicies(
2226
- { kind: "action", stageId, tool: event.tool, input: event.input },
2227
- rules
2228
- );
2229
- if (verdict.verdict === "deny") {
2230
- denied = true;
2231
- streamEvent(writer.append(event));
2232
- streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "observation", runtime, toolUseId: event.toolUseId, isError: true, output: { error: verdict.reason } }));
2233
- streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime, event: "policy-deny", detail: verdict.reason }));
2234
- deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: verdict.reason });
2235
- return;
2407
+ const key = actionKey(event.tool, event.input);
2408
+ const authorised = authorisedActions.indexOf(key);
2409
+ if (authorised >= 0) {
2410
+ authorisedActions.splice(authorised, 1);
2411
+ } else {
2412
+ const verdict = evaluatePolicies(
2413
+ { kind: "action", stageId, tool: event.tool, input: event.input },
2414
+ rules
2415
+ );
2416
+ if (verdict.verdict === "deny") {
2417
+ streamEvent(writer.append(event));
2418
+ recordDeny(verdict, event.toolUseId);
2419
+ return;
2420
+ }
2236
2421
  }
2237
2422
  }
2238
2423
  streamEvent(writer.append(event));
@@ -2260,7 +2445,18 @@ function createStageRunner(deps) {
2260
2445
  ...job.runtimeEnv ? { runtimeEnv: job.runtimeEnv } : {},
2261
2446
  // The adapter persists each runtime-native record under the attempt's raw/ sidecar
2262
2447
  // and stamps the rawRef onto the emitted event.
2263
- writeRaw: writer.writeRaw
2448
+ writeRaw: writer.writeRaw,
2449
+ authorizeToolUse,
2450
+ // Wire the interactive AskUserQuestion elicitation seam (DHK-344): the adapter calls this when
2451
+ // the agent asks a structured question, and the edge relays it to the hub as an `elicit` frame.
2452
+ ...deps.sendElicit ? {
2453
+ emitElicit: (question) => deps.sendElicit({
2454
+ jobId,
2455
+ prompt: question.prompt,
2456
+ options: question.options,
2457
+ ...question.multiSelect ? { multiSelect: true } : {}
2458
+ })
2459
+ } : {}
2264
2460
  };
2265
2461
  const interactive = agentConfig.interaction === "interactive";
2266
2462
  let result;
@@ -2364,6 +2560,18 @@ function createStageRunner(deps) {
2364
2560
  base: job.base,
2365
2561
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
2366
2562
  });
2563
+ if (r.integration === "noop") {
2564
+ return {
2565
+ jobId,
2566
+ status: "ok",
2567
+ branch: job.branch,
2568
+ headSha: r.headSha,
2569
+ pushed: false,
2570
+ nothingToCommit: true,
2571
+ commitsAhead: r.commitsAhead,
2572
+ summary: `no changes to deliver on ${job.branch} - work already present on ${job.base}`
2573
+ };
2574
+ }
2367
2575
  if (r.integration === "conflict") {
2368
2576
  return {
2369
2577
  jobId,
@@ -2442,11 +2650,27 @@ async function startEdgeNode(opts) {
2442
2650
  });
2443
2651
  let ws;
2444
2652
  let heartbeat;
2653
+ let reconnectTimer;
2654
+ let lastPongAt = 0;
2445
2655
  let shuttingDown = false;
2446
2656
  let onFatal;
2447
2657
  const send = (msg) => {
2448
2658
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(encode(msg));
2449
2659
  };
2660
+ const MISSED_PONGS_BEFORE_DEAD = 3;
2661
+ const startHeartbeat = (sock, intervalMs) => {
2662
+ if (heartbeat) clearInterval(heartbeat);
2663
+ lastPongAt = Date.now();
2664
+ heartbeat = setInterval(() => {
2665
+ if (Date.now() - lastPongAt > MISSED_PONGS_BEFORE_DEAD * intervalMs) {
2666
+ log(`EDGE_STALE:${Date.now() - lastPongAt}ms without a pong, terminating socket`);
2667
+ sock.terminate();
2668
+ return;
2669
+ }
2670
+ send({ type: "heartbeat" });
2671
+ if (sock.readyState === WebSocket.OPEN) sock.ping();
2672
+ }, intervalMs);
2673
+ };
2450
2674
  const MAX_RESEND = 100;
2451
2675
  const lastResults = /* @__PURE__ */ new Map();
2452
2676
  const rememberResult = (jobId, frame) => {
@@ -2462,12 +2686,12 @@ async function startEdgeNode(opts) {
2462
2686
  const trace = {
2463
2687
  event: (frame) => send({ type: "trace-event", ...frame }),
2464
2688
  finalised: (frame) => send({ type: "trace-finalised", ...frame }),
2465
- requestBlobUrl: (req) => new Promise((resolve) => {
2689
+ requestBlobUrl: (req) => new Promise((resolve2) => {
2466
2690
  const reqId = `${req.runId}:${req.stageId}:${req.attempt}:${blobReqCounter++}`;
2467
- pendingBlob.set(reqId, resolve);
2691
+ pendingBlob.set(reqId, resolve2);
2468
2692
  send({ type: "blob-put-request", reqId, ...req });
2469
2693
  setTimeout(() => {
2470
- if (pendingBlob.delete(reqId)) resolve({ key: "" });
2694
+ if (pendingBlob.delete(reqId)) resolve2({ key: "" });
2471
2695
  }, 3e4).unref?.();
2472
2696
  })
2473
2697
  };
@@ -2478,6 +2702,9 @@ async function startEdgeNode(opts) {
2478
2702
  ...opts.tenantId ? { tenantId: opts.tenantId } : {},
2479
2703
  rules,
2480
2704
  sendProgress: (progress) => send({ type: "progress", progress }),
2705
+ // DHK-344: relay a mid-interactive-stage AskUserQuestion to the hub as an `elicit` frame; the hub
2706
+ // raises the Linear elicitation and the human's pick returns on the existing `turn` frame.
2707
+ sendElicit: (frame) => send({ type: "elicit", ...frame }),
2481
2708
  trace,
2482
2709
  ...opts.retention ? { retention: opts.retention } : {}
2483
2710
  };
@@ -2489,18 +2716,17 @@ async function startEdgeNode(opts) {
2489
2716
  if (msg.type === "welcome") {
2490
2717
  stageDeps.tenantId = msg.tenantId;
2491
2718
  if (opts.retention === void 0 && msg.retention) stageDeps.retention = msg.retention;
2492
- if (opts.heartbeatMs === void 0 && msg.heartbeatMs > 0) {
2493
- if (heartbeat) clearInterval(heartbeat);
2494
- heartbeat = setInterval(() => send({ type: "heartbeat" }), msg.heartbeatMs);
2719
+ if (opts.heartbeatMs === void 0 && msg.heartbeatMs > 0 && ws) {
2720
+ startHeartbeat(ws, msg.heartbeatMs);
2495
2721
  }
2496
2722
  log(`EDGE_WELCOMED:${msg.name} tenant=${msg.tenantId} credentialMode=${msg.credentialMode}`);
2497
2723
  return;
2498
2724
  }
2499
2725
  if (msg.type === "blob-put-url") {
2500
- const resolve = pendingBlob.get(msg.reqId);
2501
- if (resolve) {
2726
+ const resolve2 = pendingBlob.get(msg.reqId);
2727
+ if (resolve2) {
2502
2728
  pendingBlob.delete(msg.reqId);
2503
- resolve({ key: msg.key, ...msg.url ? { url: msg.url } : {} });
2729
+ resolve2({ key: msg.key, ...msg.url ? { url: msg.url } : {} });
2504
2730
  }
2505
2731
  return;
2506
2732
  }
@@ -2522,6 +2748,12 @@ async function startEdgeNode(opts) {
2522
2748
  log(`PUSH_DUPLICATE:${job2.runId} ${job2.jobId}`);
2523
2749
  return;
2524
2750
  }
2751
+ const cachedPush = lastResults.get(job2.jobId);
2752
+ if (cachedPush) {
2753
+ send(cachedPush);
2754
+ log(`PUSH_REPLAY:${job2.runId} ${job2.jobId}`);
2755
+ return;
2756
+ }
2525
2757
  running.add(job2.jobId);
2526
2758
  log(`PUSH_STARTED:${job2.runId} ${job2.branch}`);
2527
2759
  try {
@@ -2550,6 +2782,12 @@ async function startEdgeNode(opts) {
2550
2782
  log(`JOB_DUPLICATE:${job.stageId} ${job.jobId}`);
2551
2783
  return;
2552
2784
  }
2785
+ const cachedJob = lastResults.get(job.jobId);
2786
+ if (cachedJob) {
2787
+ send(cachedJob);
2788
+ log(`JOB_REPLAY:${job.stageId} ${job.jobId}`);
2789
+ return;
2790
+ }
2553
2791
  running.add(job.jobId);
2554
2792
  log(`JOB_STARTED:${job.stageId} ${job.jobId}`);
2555
2793
  try {
@@ -2586,15 +2824,23 @@ async function startEdgeNode(opts) {
2586
2824
  ...opts.name ? { name: opts.name } : {},
2587
2825
  os: osPlatform(),
2588
2826
  arch: osArch(),
2589
- clientVersion: opts.clientVersion ?? "0.0.0"
2827
+ clientVersion: opts.clientVersion ?? "0.0.0",
2828
+ // Advertise the resolved worktree base so the hub records each run's real worktree location in
2829
+ // the projection instead of an advisory placeholder. Single-sourced from the git service so it
2830
+ // always matches where worktrees actually land.
2831
+ worktreesDir: gitService.worktreesDir
2590
2832
  });
2591
2833
  for (const frame of lastResults.values()) send(frame);
2592
- heartbeat = setInterval(() => send({ type: "heartbeat" }), opts.heartbeatMs ?? 5e3);
2834
+ startHeartbeat(sock, opts.heartbeatMs ?? 5e3);
2593
2835
  });
2594
2836
  sock.on("message", (raw) => void onMessage(raw.toString()));
2837
+ sock.on("pong", () => {
2838
+ lastPongAt = Date.now();
2839
+ });
2595
2840
  sock.on("error", (e) => log(`EDGE_ERROR ${e.message}`));
2596
2841
  sock.on("close", (code, reason) => {
2597
2842
  if (heartbeat) clearInterval(heartbeat);
2843
+ heartbeat = void 0;
2598
2844
  if (isEnrolmentRejection(code)) {
2599
2845
  shuttingDown = true;
2600
2846
  const detail = reason.toString() || "enrolment rejected";
@@ -2603,15 +2849,27 @@ async function startEdgeNode(opts) {
2603
2849
  onFatal?.(new Error(`hub rejected edge enrolment (${code}): ${detail}`));
2604
2850
  return;
2605
2851
  }
2606
- if (!shuttingDown) setTimeout(connect, 500);
2852
+ if (!shuttingDown) reconnectTimer = setTimeout(connect, 500);
2607
2853
  });
2608
2854
  };
2855
+ opts.signal?.addEventListener(
2856
+ "abort",
2857
+ () => {
2858
+ shuttingDown = true;
2859
+ if (heartbeat) clearInterval(heartbeat);
2860
+ heartbeat = void 0;
2861
+ if (reconnectTimer) clearTimeout(reconnectTimer);
2862
+ reconnectTimer = void 0;
2863
+ ws?.close(1e3, "shutting down");
2864
+ },
2865
+ { once: true }
2866
+ );
2609
2867
  connect();
2610
- await new Promise((resolve, reject) => {
2868
+ await new Promise((resolve2, reject) => {
2611
2869
  const t = setInterval(() => {
2612
2870
  if (ws && ws.readyState === WebSocket.OPEN) {
2613
2871
  clearInterval(t);
2614
- resolve();
2872
+ resolve2();
2615
2873
  }
2616
2874
  }, 50);
2617
2875
  onFatal = (err) => {
@@ -2629,11 +2887,11 @@ var PROBES = [
2629
2887
  { runtime: "pi", cmd: "pi" }
2630
2888
  ];
2631
2889
  function probe(cmd, timeoutMs) {
2632
- return new Promise((resolve) => {
2890
+ return new Promise((resolve2) => {
2633
2891
  execFile(cmd, ["--version"], { timeout: timeoutMs }, (err, stdout) => {
2634
- if (err) return resolve(void 0);
2892
+ if (err) return resolve2(void 0);
2635
2893
  const line = stdout.split("\n").map((s) => s.trim()).find(Boolean);
2636
- resolve(line ?? "");
2894
+ resolve2(line ?? "");
2637
2895
  });
2638
2896
  });
2639
2897
  }
@@ -2676,7 +2934,7 @@ function probeHub(opts) {
2676
2934
  clientVersion = "0.0.0",
2677
2935
  timeoutMs = 8e3
2678
2936
  } = opts;
2679
- return new Promise((resolve) => {
2937
+ return new Promise((resolve2) => {
2680
2938
  let settled = false;
2681
2939
  let opened = false;
2682
2940
  let ws;
@@ -2688,7 +2946,7 @@ function probeHub(opts) {
2688
2946
  ws.terminate();
2689
2947
  } catch {
2690
2948
  }
2691
- resolve(result);
2949
+ resolve2(result);
2692
2950
  };
2693
2951
  const timer = setTimeout(
2694
2952
  () => done({ ok: false, reason: "timeout", detail: `no welcome within ${timeoutMs}ms` }),
@@ -2751,7 +3009,7 @@ function probeHub(opts) {
2751
3009
 
2752
3010
  // src/cli.ts
2753
3011
  import { parseArgs } from "util";
2754
- var COMMANDS = /* @__PURE__ */ new Set(["start", "doctor"]);
3012
+ var COMMANDS = /* @__PURE__ */ new Set(["start", "run", "service", "doctor", "update"]);
2755
3013
  var isCommand = (s) => COMMANDS.has(s);
2756
3014
  function parseCli(argv) {
2757
3015
  const [first, ...rest] = argv;
@@ -2769,6 +3027,9 @@ function parseCli(argv) {
2769
3027
  command = first;
2770
3028
  flagArgs = rest;
2771
3029
  }
3030
+ if (command === "run") return parseRun(flagArgs);
3031
+ if (command === "service") return parseService(flagArgs);
3032
+ if (command === "update") return parseUpdate(flagArgs);
2772
3033
  let values;
2773
3034
  try {
2774
3035
  ({ values } = parseArgs({
@@ -2794,6 +3055,93 @@ function parseCli(argv) {
2794
3055
  };
2795
3056
  return { kind: command, flags };
2796
3057
  }
3058
+ function parseRun(flagArgs) {
3059
+ let values;
3060
+ let positionals;
3061
+ try {
3062
+ ({ values, positionals } = parseArgs({
3063
+ args: flagArgs,
3064
+ options: {
3065
+ repo: { type: "string" },
3066
+ token: { type: "string" },
3067
+ "hub-url": { type: "string" },
3068
+ help: { type: "boolean", default: false }
3069
+ },
3070
+ allowPositionals: true
3071
+ }));
3072
+ } catch (e) {
3073
+ return { kind: "error", message: e.message };
3074
+ }
3075
+ if (values.help) return { kind: "help", command: "run" };
3076
+ if (positionals.length === 0) {
3077
+ return { kind: "error", message: "run: missing workflow (e.g. `dahrk run preflight`)" };
3078
+ }
3079
+ if (positionals.length > 1) {
3080
+ return { kind: "error", message: `run: unexpected argument "${positionals[1]}" (one workflow at a time)` };
3081
+ }
3082
+ const flags = {
3083
+ workflow: positionals[0],
3084
+ ...values.repo ? { repo: values.repo } : {},
3085
+ ...values.token ? { token: values.token } : {},
3086
+ ...values["hub-url"] ? { hubUrl: values["hub-url"] } : {}
3087
+ };
3088
+ return { kind: "run", flags };
3089
+ }
3090
+ var SERVICE_ACTIONS = /* @__PURE__ */ new Set(["install", "uninstall"]);
3091
+ var isServiceAction = (s) => SERVICE_ACTIONS.has(s);
3092
+ function parseService(flagArgs) {
3093
+ let values;
3094
+ let positionals;
3095
+ try {
3096
+ ({ values, positionals } = parseArgs({
3097
+ args: flagArgs,
3098
+ options: {
3099
+ token: { type: "string" },
3100
+ name: { type: "string" },
3101
+ "hub-url": { type: "string" },
3102
+ help: { type: "boolean", default: false }
3103
+ },
3104
+ allowPositionals: true
3105
+ }));
3106
+ } catch (e) {
3107
+ return { kind: "error", message: e.message };
3108
+ }
3109
+ if (values.help) return { kind: "help", command: "service" };
3110
+ if (positionals.length === 0) {
3111
+ return { kind: "error", message: "service: missing action (`dahrk service install` or `uninstall`)" };
3112
+ }
3113
+ if (positionals.length > 1) {
3114
+ return { kind: "error", message: `service: unexpected argument "${positionals[1]}" (one action at a time)` };
3115
+ }
3116
+ const action = positionals[0];
3117
+ if (!isServiceAction(action)) {
3118
+ return { kind: "error", message: `service: unknown action "${action}" (expected install or uninstall)` };
3119
+ }
3120
+ const flags = {
3121
+ action,
3122
+ ...values.token ? { token: values.token } : {},
3123
+ ...values.name ? { name: values.name } : {},
3124
+ ...values["hub-url"] ? { hubUrl: values["hub-url"] } : {}
3125
+ };
3126
+ return { kind: "service", flags };
3127
+ }
3128
+ function parseUpdate(flagArgs) {
3129
+ let values;
3130
+ try {
3131
+ ({ values } = parseArgs({
3132
+ args: flagArgs,
3133
+ options: {
3134
+ check: { type: "boolean", default: false },
3135
+ help: { type: "boolean", default: false }
3136
+ },
3137
+ allowPositionals: false
3138
+ }));
3139
+ } catch (e) {
3140
+ return { kind: "error", message: e.message };
3141
+ }
3142
+ if (values.help) return { kind: "help", command: "update" };
3143
+ return { kind: "update", flags: { check: values.check ?? false } };
3144
+ }
2797
3145
  function usage(bin, command) {
2798
3146
  if (command === "start") {
2799
3147
  return [
@@ -2808,6 +3156,40 @@ function usage(bin, command) {
2808
3156
  " --ephemeral Do not persist a node id; mint a throwaway one (CI / one-shot)."
2809
3157
  ].join("\n");
2810
3158
  }
3159
+ if (command === "run") {
3160
+ return [
3161
+ `Usage: ${bin} run <workflow> [options]`,
3162
+ "",
3163
+ "Run a workflow through the engine locally against this node's worktree, streaming stage progress.",
3164
+ "The engine-backed twin of `doctor`: the same run and stages, from the terminal - no Linear, no issue.",
3165
+ "",
3166
+ "Workflows:",
3167
+ " preflight Is this floor sound enough to run? Checks node, repo, and tools, then reports.",
3168
+ "",
3169
+ "Options:",
3170
+ " --repo <path> Repo to inspect (default: the current directory).",
3171
+ " --hub-url <url> Hub WebSocket URL to probe for reachability (or set DAHRK_HUB_URL).",
3172
+ " --token <token> Enrolment token to verify against the hub (or set DAHRK_ENROL_TOKEN)."
3173
+ ].join("\n");
3174
+ }
3175
+ if (command === "service") {
3176
+ return [
3177
+ `Usage: ${bin} service install|uninstall [options]`,
3178
+ "",
3179
+ "Install (or remove) the node as an always-on service that starts on boot and restarts on",
3180
+ "failure - a launchd LaunchAgent on macOS, a systemd user service on Linux. No pm2, no root.",
3181
+ "The node id persisted at ~/.dahrk/node.json means it re-attaches as the same node across reboots.",
3182
+ "",
3183
+ "Actions:",
3184
+ " install Generate and register the service, then start it.",
3185
+ " uninstall Stop, deregister, and remove the service.",
3186
+ "",
3187
+ "Options (install; baked into the service, uninstall ignores them):",
3188
+ " --token <token> Enrolment token (required; or set DAHRK_ENROL_TOKEN).",
3189
+ " --hub-url <url> Hub WebSocket URL (or set DAHRK_HUB_URL).",
3190
+ " --name <name> Display-name override (else the hub assigns one)."
3191
+ ].join("\n");
3192
+ }
2811
3193
  if (command === "doctor") {
2812
3194
  return [
2813
3195
  `Usage: ${bin} doctor [options]`,
@@ -2819,12 +3201,27 @@ function usage(bin, command) {
2819
3201
  " --hub-url <url> Hub WebSocket URL to reach (or set DAHRK_HUB_URL)."
2820
3202
  ].join("\n");
2821
3203
  }
3204
+ if (command === "update") {
3205
+ return [
3206
+ `Usage: ${bin} update [--check]`,
3207
+ "",
3208
+ "Update this client in place to the latest published release. Detects how it was installed",
3209
+ "(npm / Homebrew / curl) and runs the right upgrade, or prints the exact command when it cannot.",
3210
+ "Reports current -> latest, and a no-op when already current.",
3211
+ "",
3212
+ "Options:",
3213
+ " --check Report whether an update is available without applying it (dry run)."
3214
+ ].join("\n");
3215
+ }
2822
3216
  return [
2823
3217
  `Usage: ${bin} <command> [options]`,
2824
3218
  "",
2825
3219
  "Commands:",
2826
3220
  " start Run the edge node (default). Needs a --token and a hub URL.",
3221
+ " run Run a workflow locally (engine-backed), e.g. `run preflight`.",
3222
+ " service Install/uninstall the node as an always-on service (launchd/systemd).",
2827
3223
  " doctor Preflight checks: Node, runtimes, hub reachability, token validity.",
3224
+ " update Update the client to the latest release (or print how for your channel).",
2828
3225
  " version Print the client version.",
2829
3226
  " help Show this help, or `help <command>` for a command's options.",
2830
3227
  "",
@@ -2963,20 +3360,607 @@ async function runDoctor(inputs, deps = {}) {
2963
3360
  return checks.some((c) => c.status === "fail") ? 1 : 0;
2964
3361
  }
2965
3362
 
3363
+ // src/preflight.ts
3364
+ import { execFileSync as execFileSync4 } from "child_process";
3365
+ import { randomUUID as randomUUID2 } from "crypto";
3366
+ import { accessSync, constants as fsConstants, existsSync as existsSync4, readdirSync as readdirSync3, statfsSync } from "fs";
3367
+ import { homedir as homedir2 } from "os";
3368
+ import { join as join7 } from "path";
3369
+ var REPORT_BASE_URL = "https://app.dahrk.ai/r";
3370
+ var LOW_DISK_BYTES = 512 * 1024 * 1024;
3371
+ var PREFLIGHT_STAGES = [
3372
+ { id: "check-node", label: "check node" },
3373
+ { id: "check-repo", label: "check repo" },
3374
+ { id: "check-tools", label: "check tools" },
3375
+ { id: "analyse", label: "analyse" },
3376
+ { id: "report", label: "report" }
3377
+ ];
3378
+ function checkWorktreeRoot(writable2) {
3379
+ return writable2 ? { status: "pass", label: "Worktree root", detail: "writable" } : { status: "fail", label: "Worktree root", detail: "not writable; the node cannot create worktrees here" };
3380
+ }
3381
+ function checkDiskSpace(freeBytes) {
3382
+ if (freeBytes === void 0) {
3383
+ return { status: "warn", label: "Free space", detail: "could not be determined" };
3384
+ }
3385
+ const gib = (freeBytes / (1024 * 1024 * 1024)).toFixed(1);
3386
+ return freeBytes >= LOW_DISK_BYTES ? { status: "pass", label: "Free space", detail: `${gib} GiB` } : { status: "warn", label: "Free space", detail: `only ${gib} GiB free; a clone + worktree may not fit` };
3387
+ }
3388
+ function checkRepo(repo) {
3389
+ if (!repo.isGitRepo) {
3390
+ return {
3391
+ status: "fail",
3392
+ label: "Repository",
3393
+ detail: `${repo.path} is not a git repository${repo.detail ? ` (${repo.detail})` : ""}`
3394
+ };
3395
+ }
3396
+ if (!repo.headResolves) {
3397
+ return { status: "warn", label: "Repository", detail: `${repo.path}: no commits yet (base branch does not resolve)` };
3398
+ }
3399
+ return { status: "pass", label: "Repository", detail: `${repo.path} on ${repo.baseBranch ?? "(detached)"}` };
3400
+ }
3401
+ function checkTools(tools) {
3402
+ const git = tools.git ? { status: "pass", label: "git", detail: "installed" } : { status: "fail", label: "git", detail: "not found; git is required to run a workflow" };
3403
+ const finding = (present, label, missing) => present ? { status: "pass", label, detail: "available" } : { status: "warn", label, detail: missing };
3404
+ return [
3405
+ git,
3406
+ finding(tools.sshKey, "SSH key", "no key or agent identity found; pushing over SSH will fail"),
3407
+ finding(tools.claude, "Claude runtime", "not on PATH; the node will serve no agent stages"),
3408
+ finding(tools.gh, "gh CLI", "not installed; ambient PR opening is unavailable"),
3409
+ finding(tools.docker, "docker", "not present; container stages are unavailable")
3410
+ ];
3411
+ }
3412
+ function asFinding(check) {
3413
+ return check.status === "fail" ? { ...check, status: "warn" } : check;
3414
+ }
3415
+ function synthesise(checks) {
3416
+ const fails = checks.filter((c) => c.status === "fail");
3417
+ const warns = checks.filter((c) => c.status === "warn");
3418
+ const list2 = (cs) => cs.map((c) => `${c.label.toLowerCase()} (${c.detail ?? "finding"})`).join("; ");
3419
+ if (fails.length > 0) {
3420
+ return `The floor is unsound: ${list2(fails)}. Fix ${fails.length === 1 ? "this" : "these"} before running a workflow here.`;
3421
+ }
3422
+ if (warns.length > 0) {
3423
+ return `The floor is sound, with ${warns.length} early warning${warns.length === 1 ? "" : "s"}: ${list2(warns)}. ${warns.length === 1 ? "It does" : "These do"} not block a run, but ${warns.length === 1 ? "is" : "are"} worth addressing.`;
3424
+ }
3425
+ return "The floor is sound. Node, repo, and tools all check out - this host is ready to run a workflow.";
3426
+ }
3427
+ function worst(checks) {
3428
+ if (checks.some((c) => c.status === "fail")) return "fail";
3429
+ if (checks.some((c) => c.status === "warn")) return "warn";
3430
+ return "pass";
3431
+ }
3432
+ function renderStage(index, total, verdict, out) {
3433
+ const head = `[${index}/${total}] ${verdict.label}`;
3434
+ const findings = verdict.checks.filter((c) => c.status !== "pass");
3435
+ if (findings.length === 0) {
3436
+ out(`${head} \u2713`);
3437
+ return;
3438
+ }
3439
+ out(head);
3440
+ for (const f of findings) out(` \u2022 ${f.label}: ${f.detail ?? f.status}`);
3441
+ }
3442
+ async function runPreflight(inputs, deps = {}) {
3443
+ const d = { ...defaultDeps2(), ...deps };
3444
+ const repoPath = inputs.repoPath ?? process.cwd();
3445
+ const total = PREFLIGHT_STAGES.length;
3446
+ const runId = d.newRunId();
3447
+ d.out("dahrk run preflight");
3448
+ d.out("");
3449
+ const host = await d.gatherHost(repoPath);
3450
+ const probe2 = inputs.hubUrl ? await d.probeHub({
3451
+ hubUrl: inputs.hubUrl,
3452
+ ...inputs.token ? { enrolToken: inputs.token } : {},
3453
+ runtimes: host.tools.claude ? ["claude-code"] : [],
3454
+ ...inputs.clientVersion ? { clientVersion: inputs.clientVersion } : {}
3455
+ }) : void 0;
3456
+ const nodeChecks = [
3457
+ checkNode(d.nodeVersion),
3458
+ checkWorktreeRoot(host.worktreeRootWritable),
3459
+ checkDiskSpace(host.freeDiskBytes),
3460
+ asFinding(checkHub(inputs.hubUrl, probe2)),
3461
+ ...inputs.token ? [asFinding(checkToken(true, inputs.hubUrl, probe2))] : []
3462
+ ];
3463
+ const repoChecks = [checkRepo(host.repo)];
3464
+ const toolChecks = checkTools(host.tools);
3465
+ const stageVerdicts = [
3466
+ { label: PREFLIGHT_STAGES[0].label, status: worst(nodeChecks), checks: nodeChecks },
3467
+ { label: PREFLIGHT_STAGES[1].label, status: worst(repoChecks), checks: repoChecks },
3468
+ { label: PREFLIGHT_STAGES[2].label, status: worst(toolChecks), checks: toolChecks }
3469
+ ];
3470
+ stageVerdicts.forEach((v, i) => renderStage(i + 1, total, v, d.out));
3471
+ const allChecks = [...nodeChecks, ...repoChecks, ...toolChecks];
3472
+ const read = synthesise(allChecks);
3473
+ renderStage(4, total, { label: PREFLIGHT_STAGES[3].label, status: "pass", checks: [] }, d.out);
3474
+ renderStage(5, total, { label: PREFLIGHT_STAGES[4].label, status: "pass", checks: [] }, d.out);
3475
+ const failed = allChecks.filter((c) => c.status === "fail").length;
3476
+ const warned = allChecks.filter((c) => c.status === "warn").length;
3477
+ const summary = failed > 0 ? `UNSOUND - ${failed} floor check${failed === 1 ? "" : "s"} failed${warned ? `, ${warned} finding${warned === 1 ? "" : "s"}` : ""}.` : warned > 0 ? `SOUND with ${warned} finding${warned === 1 ? "" : "s"}.` : "SOUND - all checks green.";
3478
+ d.out("");
3479
+ d.out(read);
3480
+ d.out("");
3481
+ d.out(summary);
3482
+ d.out(`Full report: ${REPORT_BASE_URL}/${runId}`);
3483
+ d.out("");
3484
+ d.out("no Linear, no OAuth, no issue. just the machine and the engine.");
3485
+ return failed > 0 ? 1 : 0;
3486
+ }
3487
+ var defaultDeps2 = () => ({
3488
+ nodeVersion: process.versions.node,
3489
+ probeHub,
3490
+ gatherHost: gatherHostFacts,
3491
+ newRunId: () => randomUUID2(),
3492
+ out: (line) => void process.stdout.write(`${line}
3493
+ `)
3494
+ });
3495
+ function commandPresent(cmd) {
3496
+ try {
3497
+ execFileSync4("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" });
3498
+ return true;
3499
+ } catch {
3500
+ return false;
3501
+ }
3502
+ }
3503
+ function sshKeyPresent() {
3504
+ try {
3505
+ const dir = join7(homedir2(), ".ssh");
3506
+ if (existsSync4(dir) && readdirSync3(dir).some((f) => f.endsWith(".pub"))) return true;
3507
+ } catch {
3508
+ }
3509
+ try {
3510
+ execFileSync4("ssh-add", ["-l"], { stdio: "ignore" });
3511
+ return true;
3512
+ } catch {
3513
+ return false;
3514
+ }
3515
+ }
3516
+ function writable(dir) {
3517
+ try {
3518
+ accessSync(dir, fsConstants.W_OK);
3519
+ return true;
3520
+ } catch {
3521
+ return false;
3522
+ }
3523
+ }
3524
+ function worktreeRoot(env) {
3525
+ return env.DAHRK_WORKTREES_DIR ?? join7(env.DAHRK_STATE_DIR ?? join7(homedir2(), ".dahrk"), "worktrees");
3526
+ }
3527
+ function nearestExisting(dir) {
3528
+ let cur = dir;
3529
+ while (!existsSync4(cur)) {
3530
+ const parent = join7(cur, "..");
3531
+ if (parent === cur) break;
3532
+ cur = parent;
3533
+ }
3534
+ return cur;
3535
+ }
3536
+ function freeDiskBytes(dir) {
3537
+ try {
3538
+ const s = statfsSync(nearestExisting(dir));
3539
+ return s.bavail * s.bsize;
3540
+ } catch {
3541
+ return void 0;
3542
+ }
3543
+ }
3544
+ function probeRepo(repoPath) {
3545
+ const git = (args) => execFileSync4("git", ["-C", repoPath, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3546
+ try {
3547
+ if (git(["rev-parse", "--is-inside-work-tree"]) !== "true") {
3548
+ return { path: repoPath, isGitRepo: false, headResolves: false, detail: "not inside a work tree" };
3549
+ }
3550
+ } catch {
3551
+ return { path: repoPath, isGitRepo: false, headResolves: false };
3552
+ }
3553
+ let headResolves = false;
3554
+ let baseBranch;
3555
+ try {
3556
+ git(["rev-parse", "--verify", "HEAD"]);
3557
+ headResolves = true;
3558
+ baseBranch = git(["rev-parse", "--abbrev-ref", "HEAD"]) || void 0;
3559
+ } catch {
3560
+ }
3561
+ return { path: repoPath, isGitRepo: true, headResolves, ...baseBranch ? { baseBranch } : {} };
3562
+ }
3563
+ function gatherHostFacts(repoPath) {
3564
+ const root = worktreeRoot(process.env);
3565
+ return {
3566
+ worktreeRootWritable: writable(nearestExisting(root)),
3567
+ freeDiskBytes: freeDiskBytes(root),
3568
+ tools: {
3569
+ git: commandPresent("git"),
3570
+ sshKey: sshKeyPresent(),
3571
+ claude: commandPresent("claude"),
3572
+ gh: commandPresent("gh"),
3573
+ docker: commandPresent("docker")
3574
+ },
3575
+ repo: probeRepo(repoPath)
3576
+ };
3577
+ }
3578
+
3579
+ // src/service.ts
3580
+ import { execFileSync as execFileSync5 } from "child_process";
3581
+ import { existsSync as existsSync5, mkdirSync as mkdirSync6, realpathSync, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
3582
+ import { homedir as homedir3, platform as osPlatform3, userInfo } from "os";
3583
+ import { join as join8 } from "path";
3584
+ var LAUNCHD_LABEL = "ai.dahrk.node";
3585
+ var SYSTEMD_UNIT = "dahrk-node.service";
3586
+ function detectManager(plat) {
3587
+ if (plat === "darwin") return "launchd";
3588
+ if (plat === "linux") return "systemd";
3589
+ return "unsupported";
3590
+ }
3591
+ function serviceEnv(inputs) {
3592
+ return {
3593
+ DAHRK_ENROL_TOKEN: inputs.token,
3594
+ ...inputs.hubUrl ? { DAHRK_HUB_URL: inputs.hubUrl } : {},
3595
+ ...inputs.name ? { DAHRK_NODE_NAME: inputs.name } : {},
3596
+ ...inputs.pathEnv ? { PATH: inputs.pathEnv } : {}
3597
+ };
3598
+ }
3599
+ function xmlEscape(s) {
3600
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3601
+ }
3602
+ function renderLaunchdPlist(inputs) {
3603
+ const argv = [inputs.nodeBin, inputs.scriptPath, "start"];
3604
+ const env = serviceEnv(inputs);
3605
+ const progArgs = argv.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
3606
+ const envEntries = Object.entries(env).map(([k, v]) => ` <key>${xmlEscape(k)}</key>
3607
+ <string>${xmlEscape(v)}</string>`).join("\n");
3608
+ return `<?xml version="1.0" encoding="UTF-8"?>
3609
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3610
+ <plist version="1.0">
3611
+ <dict>
3612
+ <key>Label</key>
3613
+ <string>${LAUNCHD_LABEL}</string>
3614
+ <key>ProgramArguments</key>
3615
+ <array>
3616
+ ${progArgs}
3617
+ </array>
3618
+ <key>EnvironmentVariables</key>
3619
+ <dict>
3620
+ ${envEntries}
3621
+ </dict>
3622
+ <key>WorkingDirectory</key>
3623
+ <string>${xmlEscape(inputs.homeDir)}</string>
3624
+ <key>RunAtLoad</key>
3625
+ <true/>
3626
+ <key>KeepAlive</key>
3627
+ <true/>
3628
+ <key>ThrottleInterval</key>
3629
+ <integer>10</integer>
3630
+ <key>StandardOutPath</key>
3631
+ <string>${xmlEscape(join8(inputs.logDir, "node.out.log"))}</string>
3632
+ <key>StandardErrorPath</key>
3633
+ <string>${xmlEscape(join8(inputs.logDir, "node.err.log"))}</string>
3634
+ </dict>
3635
+ </plist>
3636
+ `;
3637
+ }
3638
+ function systemdEnvValue(v) {
3639
+ return /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
3640
+ }
3641
+ function renderSystemdUnit(inputs) {
3642
+ const exec = [inputs.nodeBin, inputs.scriptPath, "start"].map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
3643
+ const env = serviceEnv(inputs);
3644
+ const envLines = Object.entries(env).map(([k, v]) => `Environment=${k}=${systemdEnvValue(v)}`).join("\n");
3645
+ return `[Unit]
3646
+ Description=Dahrk node (self-managed edge node)
3647
+ Documentation=https://dahrk.ai/docs
3648
+ After=network-online.target
3649
+ Wants=network-online.target
3650
+
3651
+ [Service]
3652
+ Type=simple
3653
+ ExecStart=${exec}
3654
+ ${envLines}
3655
+ WorkingDirectory=${inputs.homeDir}
3656
+ Restart=on-failure
3657
+ RestartSec=3
3658
+ RestartPreventExitStatus=78
3659
+
3660
+ [Install]
3661
+ WantedBy=default.target
3662
+ `;
3663
+ }
3664
+ function buildPlan(inputs) {
3665
+ if (inputs.manager === "launchd") {
3666
+ const filePath2 = join8(inputs.homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
3667
+ return {
3668
+ manager: "launchd",
3669
+ label: LAUNCHD_LABEL,
3670
+ filePath: filePath2,
3671
+ content: renderLaunchdPlist(inputs),
3672
+ // Unload first so a re-install picks up the rewritten plist; a not-loaded unload is a no-op.
3673
+ installCommands: [
3674
+ { argv: ["launchctl", "unload", filePath2], ignoreFailure: true },
3675
+ { argv: ["launchctl", "load", "-w", filePath2] }
3676
+ ],
3677
+ uninstallCommands: [{ argv: ["launchctl", "unload", "-w", filePath2], ignoreFailure: true }],
3678
+ logHint: `tail -f ${join8(inputs.logDir, "node.err.log")}`
3679
+ };
3680
+ }
3681
+ const filePath = join8(inputs.homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
3682
+ const user = userInfo().username;
3683
+ return {
3684
+ manager: "systemd",
3685
+ label: SYSTEMD_UNIT,
3686
+ filePath,
3687
+ content: renderSystemdUnit(inputs),
3688
+ // Reload to see the new unit, enable+start it, then enable linger so it survives logout / boots
3689
+ // headless. Linger can be denied without privilege on some hosts, so it is best-effort.
3690
+ installCommands: [
3691
+ { argv: ["systemctl", "--user", "daemon-reload"] },
3692
+ { argv: ["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT] },
3693
+ { argv: ["loginctl", "enable-linger", user], ignoreFailure: true }
3694
+ ],
3695
+ uninstallCommands: [
3696
+ { argv: ["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], ignoreFailure: true },
3697
+ { argv: ["systemctl", "--user", "daemon-reload"], ignoreFailure: true }
3698
+ ],
3699
+ logHint: `journalctl --user -u ${SYSTEMD_UNIT} -f`
3700
+ };
3701
+ }
3702
+ function printUnsupported(out) {
3703
+ out("dahrk service is supported on macOS (launchd) and Linux (systemd) only.");
3704
+ out("On other platforms, run the node under a process manager such as pm2 (see the README).");
3705
+ return 1;
3706
+ }
3707
+ function runCommands(commands, d) {
3708
+ for (const cmd of commands) {
3709
+ const code = d.run(cmd.argv);
3710
+ if (code !== 0 && !cmd.ignoreFailure) {
3711
+ d.out(` command failed (${code}): ${cmd.argv.join(" ")}`);
3712
+ return code;
3713
+ }
3714
+ }
3715
+ return 0;
3716
+ }
3717
+ async function runServiceInstall(inputs, deps = {}) {
3718
+ const d = { ...defaultDeps3(), ...deps };
3719
+ d.out("dahrk service install");
3720
+ d.out("");
3721
+ const manager = detectManager(d.platform);
3722
+ if (manager === "unsupported") return printUnsupported(d.out);
3723
+ if (!inputs.token) {
3724
+ d.out("No enrolment token: pass --token <token> or set DAHRK_ENROL_TOKEN.");
3725
+ d.out("Get one at https://app.dahrk.ai.");
3726
+ return 2;
3727
+ }
3728
+ const plan = buildPlan({
3729
+ manager,
3730
+ nodeBin: d.nodeBin,
3731
+ scriptPath: d.scriptPath,
3732
+ token: inputs.token,
3733
+ ...inputs.name ? { name: inputs.name } : {},
3734
+ ...inputs.hubUrl ? { hubUrl: inputs.hubUrl } : {},
3735
+ ...d.pathEnv ? { pathEnv: d.pathEnv } : {},
3736
+ homeDir: d.homeDir,
3737
+ logDir: d.logDir
3738
+ });
3739
+ try {
3740
+ if (manager === "launchd") d.mkdirp(d.logDir);
3741
+ d.mkdirp(dirOf(plan.filePath));
3742
+ d.writeFile(plan.filePath, plan.content);
3743
+ } catch (e) {
3744
+ d.out(`Could not write the service file at ${plan.filePath}: ${e.message}`);
3745
+ return 1;
3746
+ }
3747
+ d.out(`Wrote ${plan.manager} service: ${plan.filePath}`);
3748
+ const code = runCommands(plan.installCommands, d);
3749
+ d.out("");
3750
+ if (code !== 0) {
3751
+ d.out(`The service file is in place but registering it failed (exit ${code}).`);
3752
+ d.out("Fix the reported error and re-run `dahrk service install`, or load it yourself.");
3753
+ return code;
3754
+ }
3755
+ d.out("Installed. The node will start on boot and restart on failure.");
3756
+ d.out(` logs: ${plan.logHint}`);
3757
+ d.out(" uninstall: dahrk service uninstall");
3758
+ return 0;
3759
+ }
3760
+ async function runServiceUninstall(deps = {}) {
3761
+ const d = { ...defaultDeps3(), ...deps };
3762
+ d.out("dahrk service uninstall");
3763
+ d.out("");
3764
+ const manager = detectManager(d.platform);
3765
+ if (manager === "unsupported") return printUnsupported(d.out);
3766
+ const plan = buildPlan({
3767
+ manager,
3768
+ nodeBin: d.nodeBin,
3769
+ scriptPath: d.scriptPath,
3770
+ token: "-",
3771
+ homeDir: d.homeDir,
3772
+ logDir: d.logDir
3773
+ });
3774
+ if (!d.fileExists(plan.filePath)) {
3775
+ d.out(`No service installed (${plan.filePath} not found). Nothing to do.`);
3776
+ return 0;
3777
+ }
3778
+ runCommands(plan.uninstallCommands, d);
3779
+ try {
3780
+ d.removeFile(plan.filePath);
3781
+ } catch (e) {
3782
+ d.out(`Deregistered, but could not delete ${plan.filePath}: ${e.message}`);
3783
+ return 1;
3784
+ }
3785
+ d.out(`Removed ${plan.filePath}. The node will no longer start on boot.`);
3786
+ return 0;
3787
+ }
3788
+ function dirOf(path) {
3789
+ const i = path.lastIndexOf("/");
3790
+ return i <= 0 ? path : path.slice(0, i);
3791
+ }
3792
+ function resolveScriptPath() {
3793
+ const argv1 = process.argv[1] ?? "";
3794
+ try {
3795
+ return realpathSync(argv1);
3796
+ } catch {
3797
+ return argv1;
3798
+ }
3799
+ }
3800
+ var defaultDeps3 = () => ({
3801
+ platform: osPlatform3(),
3802
+ homeDir: homedir3(),
3803
+ nodeBin: process.execPath,
3804
+ scriptPath: resolveScriptPath(),
3805
+ logDir: join8(process.env.DAHRK_STATE_DIR ?? join8(homedir3(), ".dahrk"), "logs"),
3806
+ // Snapshot the operator's PATH at install time so the daemon finds git + the runtime CLIs (Homebrew /
3807
+ // npm-global bins) that a supervisor's minimal PATH would otherwise hide.
3808
+ pathEnv: process.env.PATH,
3809
+ mkdirp: (dir) => void mkdirSync6(dir, { recursive: true }),
3810
+ writeFile: (path, content) => writeFileSync6(path, content),
3811
+ removeFile: (path) => rmSync4(path, { force: true }),
3812
+ fileExists: (path) => existsSync5(path),
3813
+ run: (argv) => {
3814
+ const [cmd, ...args] = argv;
3815
+ try {
3816
+ execFileSync5(cmd, args, { stdio: "inherit" });
3817
+ return 0;
3818
+ } catch (e) {
3819
+ const status = e.status;
3820
+ return typeof status === "number" ? status : 1;
3821
+ }
3822
+ },
3823
+ out: (line) => void process.stdout.write(`${line}
3824
+ `)
3825
+ });
3826
+
3827
+ // src/update.ts
3828
+ import { execFileSync as execFileSync6 } from "child_process";
3829
+ import { realpathSync as realpathSync2 } from "fs";
3830
+ var LATEST_URL = "https://registry.npmjs.org/dahrk-node/latest";
3831
+ var CHANNEL_COMMANDS = {
3832
+ npm: "npm install -g dahrk-node@latest",
3833
+ homebrew: "brew upgrade dahrkai/tap/dahrk",
3834
+ curl: "curl -fsSL https://dahrk.ai/install.sh | sh"
3835
+ };
3836
+ function upgradeCommand(channel) {
3837
+ switch (channel) {
3838
+ case "npm":
3839
+ return { argv: ["npm", "install", "-g", "dahrk-node@latest"], display: CHANNEL_COMMANDS.npm };
3840
+ case "homebrew":
3841
+ return { argv: ["brew", "upgrade", "dahrkai/tap/dahrk"], display: CHANNEL_COMMANDS.homebrew };
3842
+ case "unknown":
3843
+ return null;
3844
+ }
3845
+ }
3846
+ function detectChannel(binPath) {
3847
+ if (!binPath) return "unknown";
3848
+ let resolved = binPath;
3849
+ try {
3850
+ resolved = realpathSync2(binPath);
3851
+ } catch {
3852
+ }
3853
+ if (/[\\/]node_modules[\\/]dahrk-node[\\/]/.test(resolved)) return "npm";
3854
+ if (/[\\/](Cellar|homebrew)[\\/]/.test(resolved)) return "homebrew";
3855
+ return "unknown";
3856
+ }
3857
+ function core(v) {
3858
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim().replace(/^v/, ""));
3859
+ if (!m) return null;
3860
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
3861
+ }
3862
+ function isNewer(latest, current) {
3863
+ const a = core(latest);
3864
+ const b = core(current);
3865
+ if (!a || !b) return false;
3866
+ for (let i = 0; i < 3; i++) {
3867
+ if (a[i] > b[i]) return true;
3868
+ if (a[i] < b[i]) return false;
3869
+ }
3870
+ return false;
3871
+ }
3872
+ function printChannelCommands(out) {
3873
+ out(` npm: ${CHANNEL_COMMANDS.npm}`);
3874
+ out(` Homebrew: ${CHANNEL_COMMANDS.homebrew}`);
3875
+ out(` curl: ${CHANNEL_COMMANDS.curl}`);
3876
+ }
3877
+ async function runUpdate(inputs, deps = {}) {
3878
+ const d = { ...defaultDeps4(), ...deps };
3879
+ const current = inputs.currentVersion;
3880
+ d.out("dahrk update");
3881
+ d.out("");
3882
+ let latest;
3883
+ try {
3884
+ latest = await d.fetchLatest();
3885
+ } catch (e) {
3886
+ d.out(`Could not determine the latest version: ${e.message}`);
3887
+ d.out(`You are on ${current}. See https://www.npmjs.com/package/dahrk-node for releases.`);
3888
+ return 1;
3889
+ }
3890
+ if (!isNewer(latest, current)) {
3891
+ d.out(`Already on the latest version (${current}).`);
3892
+ return 0;
3893
+ }
3894
+ d.out(`Update available: ${current} -> ${latest}`);
3895
+ const channel = detectChannel(d.binPath);
3896
+ const cmd = upgradeCommand(channel);
3897
+ if (inputs.check) {
3898
+ d.out("");
3899
+ if (cmd) {
3900
+ d.out(`Run \`dahrk update\` to upgrade (${channel}: ${cmd.display}).`);
3901
+ } else {
3902
+ d.out("Run `dahrk update`, or upgrade with the command for your install channel:");
3903
+ printChannelCommands(d.out);
3904
+ }
3905
+ return 0;
3906
+ }
3907
+ if (!cmd) {
3908
+ d.out("");
3909
+ d.out("Could not tell how this client was installed. Upgrade with the command for your channel:");
3910
+ printChannelCommands(d.out);
3911
+ return 0;
3912
+ }
3913
+ d.out("");
3914
+ d.out(`Upgrading via ${channel}: ${cmd.display}`);
3915
+ d.out("");
3916
+ const code = d.runUpgrade(cmd.argv);
3917
+ d.out("");
3918
+ if (code === 0) {
3919
+ d.out(`Upgraded to ${latest}. Restart a running node (\`dahrk start\`) to pick it up.`);
3920
+ } else {
3921
+ d.out(`Upgrade exited with code ${code}. Run it yourself to see the error: ${cmd.display}`);
3922
+ }
3923
+ return code;
3924
+ }
3925
+ async function fetchLatestVersion() {
3926
+ const res = await fetch(LATEST_URL, { headers: { accept: "application/json" } });
3927
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
3928
+ const body = await res.json();
3929
+ if (typeof body.version !== "string" || !body.version) throw new Error("registry returned no version");
3930
+ return body.version;
3931
+ }
3932
+ function spawnUpgrade(argv) {
3933
+ const [cmd, ...args] = argv;
3934
+ try {
3935
+ execFileSync6(cmd, args, { stdio: "inherit" });
3936
+ return 0;
3937
+ } catch (e) {
3938
+ const status = e.status;
3939
+ return typeof status === "number" ? status : 1;
3940
+ }
3941
+ }
3942
+ var defaultDeps4 = () => ({
3943
+ binPath: process.argv[1],
3944
+ fetchLatest: fetchLatestVersion,
3945
+ runUpgrade: spawnUpgrade,
3946
+ out: (line) => void process.stdout.write(`${line}
3947
+ `)
3948
+ });
3949
+
2966
3950
  // src/main.ts
2967
- var CLIENT_VERSION = "0.1.3";
3951
+ var CLIENT_VERSION = "0.1.5";
2968
3952
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
2969
3953
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2970
3954
  var RUNTIMES = ["claude-code", "codex", "pi"];
2971
3955
  var isRuntime = (r) => RUNTIMES.includes(r);
2972
3956
  function stateDir(env) {
2973
- return env.DAHRK_STATE_DIR ?? join7(homedir2(), ".dahrk");
3957
+ return env.DAHRK_STATE_DIR ?? join9(homedir4(), ".dahrk");
2974
3958
  }
2975
3959
  function legacyStateDir(env) {
2976
- return env.DAHRK_STATE_DIR ? void 0 : join7(homedir2(), ".skakel");
3960
+ return env.DAHRK_STATE_DIR ? void 0 : join9(homedir4(), ".skakel");
2977
3961
  }
2978
3962
  function readNodeId(file) {
2979
- if (!existsSync4(file)) return void 0;
3963
+ if (!existsSync6(file)) return void 0;
2980
3964
  try {
2981
3965
  const parsed = JSON.parse(readFileSync5(file, "utf8"));
2982
3966
  if (typeof parsed.nodeId === "string" && parsed.nodeId) return parsed.nodeId;
@@ -2986,20 +3970,20 @@ function readNodeId(file) {
2986
3970
  }
2987
3971
  function resolveNodeId(env, opts = {}) {
2988
3972
  if (env.DAHRK_NODE_ID) return env.DAHRK_NODE_ID;
2989
- if (opts.ephemeral) return randomUUID2();
3973
+ if (opts.ephemeral) return randomUUID3();
2990
3974
  const dir = stateDir(env);
2991
- const file = join7(dir, "node.json");
3975
+ const file = join9(dir, "node.json");
2992
3976
  const existing = readNodeId(file);
2993
3977
  if (existing) return existing;
2994
3978
  const legacy = legacyStateDir(env);
2995
3979
  if (legacy) {
2996
- const legacyId = readNodeId(join7(legacy, "node.json"));
3980
+ const legacyId = readNodeId(join9(legacy, "node.json"));
2997
3981
  if (legacyId) return legacyId;
2998
3982
  }
2999
- const nodeId = randomUUID2();
3983
+ const nodeId = randomUUID3();
3000
3984
  try {
3001
- mkdirSync6(dir, { recursive: true });
3002
- writeFileSync6(file, `${JSON.stringify({ nodeId }, null, 2)}
3985
+ mkdirSync7(dir, { recursive: true });
3986
+ writeFileSync7(file, `${JSON.stringify({ nodeId }, null, 2)}
3003
3987
  `);
3004
3988
  } catch (e) {
3005
3989
  console.warn(`could not persist node id to ${file}: ${e.message}`);
@@ -3078,6 +4062,24 @@ async function start(flags) {
3078
4062
  const resolved = { nodeId, runtimes, clientVersion: CLIENT_VERSION };
3079
4063
  await startEdgeNode(buildEdgeOptions(env, resolved));
3080
4064
  }
4065
+ var KNOWN_WORKFLOWS = ["preflight"];
4066
+ async function runWorkflow(flags) {
4067
+ if (flags.workflow !== "preflight") {
4068
+ console.error(`unknown workflow: ${flags.workflow}
4069
+ `);
4070
+ console.error(`Available workflows: ${KNOWN_WORKFLOWS.join(", ")}`);
4071
+ return 2;
4072
+ }
4073
+ const env = applyEnvAliases(process.env);
4074
+ const hubUrl = flags.hubUrl ?? env.DAHRK_HUB_URL;
4075
+ const token = flags.token ?? env.DAHRK_ENROL_TOKEN;
4076
+ return runPreflight({
4077
+ ...flags.repo ? { repoPath: flags.repo } : {},
4078
+ ...hubUrl ? { hubUrl } : {},
4079
+ ...token ? { token } : {},
4080
+ clientVersion: CLIENT_VERSION
4081
+ });
4082
+ }
3081
4083
  async function main() {
3082
4084
  const invoked = basename(process.argv[1] ?? "");
3083
4085
  const bin = !invoked || invoked.startsWith("main.") ? "dahrk" : invoked;
@@ -3104,6 +4106,28 @@ async function main() {
3104
4106
  });
3105
4107
  break;
3106
4108
  }
4109
+ case "run":
4110
+ process.exitCode = await runWorkflow(parsed.flags);
4111
+ break;
4112
+ case "service": {
4113
+ if (parsed.flags.action === "uninstall") {
4114
+ process.exitCode = await runServiceUninstall();
4115
+ break;
4116
+ }
4117
+ const env = applyEnvAliases(process.env);
4118
+ const token = parsed.flags.token ?? env.DAHRK_ENROL_TOKEN;
4119
+ const name = parsed.flags.name ?? env.DAHRK_NODE_NAME;
4120
+ const hubUrl = parsed.flags.hubUrl ?? env.DAHRK_HUB_URL;
4121
+ process.exitCode = await runServiceInstall({
4122
+ ...token ? { token } : {},
4123
+ ...name ? { name } : {},
4124
+ ...hubUrl ? { hubUrl } : {}
4125
+ });
4126
+ break;
4127
+ }
4128
+ case "update":
4129
+ process.exitCode = await runUpdate({ currentVersion: CLIENT_VERSION, check: parsed.flags.check });
4130
+ break;
3107
4131
  case "start":
3108
4132
  await start(parsed.flags);
3109
4133
  break;
@@ -3113,7 +4137,7 @@ var invokedAsEntrypoint = (() => {
3113
4137
  const argv1 = process.argv[1];
3114
4138
  if (!argv1) return false;
3115
4139
  try {
3116
- return pathToFileURL(realpathSync(argv1)).href === import.meta.url;
4140
+ return pathToFileURL(realpathSync3(argv1)).href === import.meta.url;
3117
4141
  } catch {
3118
4142
  return false;
3119
4143
  }
@@ -3126,6 +4150,7 @@ if (invokedAsEntrypoint) {
3126
4150
  }
3127
4151
  export {
3128
4152
  DEFAULT_HUB_URL,
4153
+ KNOWN_WORKFLOWS,
3129
4154
  buildEdgeOptions,
3130
4155
  resolveNodeId,
3131
4156
  resolveRuntimes