dahrk-node 0.1.4 → 0.1.6

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 +509 -43
  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 existsSync5, mkdirSync as mkdirSync6, readFileSync as readFileSync5, realpathSync as realpathSync2, writeFileSync as writeFileSync6 } from "fs";
4
+ import { existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync5, realpathSync as realpathSync3, writeFileSync as writeFileSync7 } from "fs";
5
5
  import { randomUUID as randomUUID3 } from "crypto";
6
- import { homedir as homedir3 } from "os";
7
- import { basename, join as join8 } from "path";
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
  ];
@@ -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);
@@ -419,6 +506,10 @@ var policyCanUseTool = async (ctx, toolName, input) => {
419
506
  }
420
507
  return { behavior: "allow", updatedInput: input };
421
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);
422
513
  function buildBrokeredMcpServers(ctx) {
423
514
  const servers = ctx.config.mcpServers;
424
515
  if (!servers || servers.length === 0 || !ctx.mcpProxyBaseUrl) return void 0;
@@ -507,6 +598,27 @@ function createClaudeRunner() {
507
598
  async runInteractive(ctx, turns, onTrace) {
508
599
  const emit = makeEmit("claude-code", onTrace);
509
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
+ });
510
622
  const brokered = buildBrokeredMcpServers(ctx);
511
623
  let summarising = false;
512
624
  const options = {
@@ -514,12 +626,17 @@ function createClaudeRunner() {
514
626
  // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
515
627
  // interactive persona still gets it without losing the working-directory context.
516
628
  systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
517
- // Inject the stage-complete exit tool alongside any brokered MCP servers (parity with batch).
518
- mcpServers: { dahrk: stageTool.server, ...brokered ?? {} },
519
- // Auto-approve the exit tool; `allowedTools` is an auto-approve list, not a whitelist, so it
520
- // does not restrict the other tools canUseTool allows.
521
- allowedTools: [stageTool.allowedToolName],
522
- 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." } : policyCanUseTool(ctx, toolName, 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),
523
640
  maxTurns: MAX_TURNS,
524
641
  includePartialMessages: false
525
642
  };
@@ -529,7 +646,7 @@ function createClaudeRunner() {
529
646
  const q = query({ prompt: mailbox, options });
530
647
  active = q;
531
648
  const it = q[Symbol.asyncIterator]();
532
- const humanIter = turns[Symbol.asyncIterator]();
649
+ const humanIter = router.conversation[Symbol.asyncIterator]();
533
650
  const state = newBufferState();
534
651
  const consumeTurn = async () => {
535
652
  for (; ; ) {
@@ -539,8 +656,6 @@ function createClaudeRunner() {
539
656
  if (res.isResult) return res.responseText;
540
657
  }
541
658
  };
542
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
543
- let awaitingFirstReply = true;
544
659
  let exited = null;
545
660
  let pending = humanIter.next();
546
661
  try {
@@ -1230,8 +1345,11 @@ function sanitizeBranchName(name) {
1230
1345
  if (!name) return name;
1231
1346
  return name.replace(/[`~^:?*[\]\\@{}\s]/g, "-").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/\.lock(\/|$)/g, "$1").replace(/^[.\-/]+/, "").replace(/[.\-/]+$/, "").replace(/-{2,}/g, "-");
1232
1347
  }
1348
+ function resolveWorktreesDir(override) {
1349
+ return override ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join2(homedir(), ".dahrk", "worktrees");
1350
+ }
1233
1351
  function createGitService(opts = {}) {
1234
- const worktreesDir = opts.worktreesDir ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join2(homedir(), ".dahrk", "worktrees");
1352
+ const worktreesDir = resolveWorktreesDir(opts.worktreesDir);
1235
1353
  const mirrorsDir = opts.mirrorsDir ?? process.env.DAHRK_MIRRORS_DIR ?? process.env.SKAKEL_MIRRORS_DIR ?? join2(homedir(), ".dahrk", "mirrors");
1236
1354
  const authorName = opts.authorName ?? process.env.DAHRK_GIT_AUTHOR_NAME ?? "Dahrk";
1237
1355
  const authorEmail = opts.authorEmail ?? process.env.DAHRK_GIT_AUTHOR_EMAIL ?? "noreply@dahrk.ai";
@@ -1334,6 +1452,7 @@ function createGitService(opts = {}) {
1334
1452
  scratchPath: join2(worktreePath, ".skakel", "scratch")
1335
1453
  });
1336
1454
  return {
1455
+ worktreesDir,
1337
1456
  async createWorktree(spec) {
1338
1457
  const { repoId, gitUrl, baseBranch, runId } = spec;
1339
1458
  const branchName = sanitizeBranchName(spec.branch ?? `dahrk/${runId}`);
@@ -1662,12 +1781,12 @@ function evaluatePolicies(event, rules) {
1662
1781
  }
1663
1782
  return ALLOW;
1664
1783
  }
1665
- function denyToolRule(tool2) {
1784
+ function denyToolRule(tool3) {
1666
1785
  return {
1667
1786
  name: "deny_tool",
1668
1787
  evaluate(event) {
1669
- if (event.kind === "action" && event.tool === tool2) {
1670
- 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` };
1671
1790
  }
1672
1791
  return null;
1673
1792
  }
@@ -1748,7 +1867,15 @@ function buildRules(policies, ctx) {
1748
1867
  const rules = [];
1749
1868
  let stageToolCalls = 0;
1750
1869
  for (const p of policies) {
1751
- 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) {
1752
1879
  const { branches, repos } = p.write_scope;
1753
1880
  rules.push({
1754
1881
  name: "write_scope",
@@ -2249,11 +2376,11 @@ function createStageRunner(deps) {
2249
2376
  let denied = false;
2250
2377
  const authorisedActions = [];
2251
2378
  const runtime = agentConfig.runtime;
2252
- const actionKey = (tool2, input) => {
2379
+ const actionKey = (tool3, input) => {
2253
2380
  try {
2254
- return `${tool2}\0${JSON.stringify(input)}`;
2381
+ return `${tool3}\0${JSON.stringify(input)}`;
2255
2382
  } catch {
2256
- return `${tool2}\0`;
2383
+ return `${tool3}\0`;
2257
2384
  }
2258
2385
  };
2259
2386
  const policyReason = (verdict) => verdict.reason ?? `tool action denied by ${verdict.policy}`;
@@ -2266,12 +2393,12 @@ function createStageRunner(deps) {
2266
2393
  streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime, event: "policy-deny", detail: reason }));
2267
2394
  deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: reason });
2268
2395
  };
2269
- const authorizeToolUse = (tool2, input) => {
2270
- const verdict = evaluatePolicies({ kind: "action", stageId, tool: tool2, input }, rules);
2396
+ const authorizeToolUse = (tool3, input) => {
2397
+ const verdict = evaluatePolicies({ kind: "action", stageId, tool: tool3, input }, rules);
2271
2398
  if (verdict.verdict === "deny") {
2272
2399
  recordDeny(verdict);
2273
2400
  } else {
2274
- authorisedActions.push(actionKey(tool2, input));
2401
+ authorisedActions.push(actionKey(tool3, input));
2275
2402
  }
2276
2403
  return verdict;
2277
2404
  };
@@ -2319,7 +2446,17 @@ function createStageRunner(deps) {
2319
2446
  // The adapter persists each runtime-native record under the attempt's raw/ sidecar
2320
2447
  // and stamps the rawRef onto the emitted event.
2321
2448
  writeRaw: writer.writeRaw,
2322
- authorizeToolUse
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
+ } : {}
2323
2460
  };
2324
2461
  const interactive = agentConfig.interaction === "interactive";
2325
2462
  let result;
@@ -2565,6 +2702,9 @@ async function startEdgeNode(opts) {
2565
2702
  ...opts.tenantId ? { tenantId: opts.tenantId } : {},
2566
2703
  rules,
2567
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 }),
2568
2708
  trace,
2569
2709
  ...opts.retention ? { retention: opts.retention } : {}
2570
2710
  };
@@ -2684,7 +2824,11 @@ async function startEdgeNode(opts) {
2684
2824
  ...opts.name ? { name: opts.name } : {},
2685
2825
  os: osPlatform(),
2686
2826
  arch: osArch(),
2687
- 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
2688
2832
  });
2689
2833
  for (const frame of lastResults.values()) send(frame);
2690
2834
  startHeartbeat(sock, opts.heartbeatMs ?? 5e3);
@@ -2865,7 +3009,7 @@ function probeHub(opts) {
2865
3009
 
2866
3010
  // src/cli.ts
2867
3011
  import { parseArgs } from "util";
2868
- var COMMANDS = /* @__PURE__ */ new Set(["start", "run", "doctor", "update"]);
3012
+ var COMMANDS = /* @__PURE__ */ new Set(["start", "run", "service", "doctor", "update"]);
2869
3013
  var isCommand = (s) => COMMANDS.has(s);
2870
3014
  function parseCli(argv) {
2871
3015
  const [first, ...rest] = argv;
@@ -2884,6 +3028,7 @@ function parseCli(argv) {
2884
3028
  flagArgs = rest;
2885
3029
  }
2886
3030
  if (command === "run") return parseRun(flagArgs);
3031
+ if (command === "service") return parseService(flagArgs);
2887
3032
  if (command === "update") return parseUpdate(flagArgs);
2888
3033
  let values;
2889
3034
  try {
@@ -2942,6 +3087,44 @@ function parseRun(flagArgs) {
2942
3087
  };
2943
3088
  return { kind: "run", flags };
2944
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
+ }
2945
3128
  function parseUpdate(flagArgs) {
2946
3129
  let values;
2947
3130
  try {
@@ -2989,6 +3172,24 @@ function usage(bin, command) {
2989
3172
  " --token <token> Enrolment token to verify against the hub (or set DAHRK_ENROL_TOKEN)."
2990
3173
  ].join("\n");
2991
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
+ }
2992
3193
  if (command === "doctor") {
2993
3194
  return [
2994
3195
  `Usage: ${bin} doctor [options]`,
@@ -3018,6 +3219,7 @@ function usage(bin, command) {
3018
3219
  "Commands:",
3019
3220
  " start Run the edge node (default). Needs a --token and a hub URL.",
3020
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).",
3021
3223
  " doctor Preflight checks: Node, runtimes, hub reachability, token validity.",
3022
3224
  " update Update the client to the latest release (or print how for your channel).",
3023
3225
  " version Print the client version.",
@@ -3374,9 +3576,257 @@ function gatherHostFacts(repoPath) {
3374
3576
  };
3375
3577
  }
3376
3578
 
3377
- // src/update.ts
3579
+ // src/service.ts
3378
3580
  import { execFileSync as execFileSync5 } from "child_process";
3379
- import { realpathSync } from "fs";
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";
3380
3830
  var LATEST_URL = "https://registry.npmjs.org/dahrk-node/latest";
3381
3831
  var CHANNEL_COMMANDS = {
3382
3832
  npm: "npm install -g dahrk-node@latest",
@@ -3397,7 +3847,7 @@ function detectChannel(binPath) {
3397
3847
  if (!binPath) return "unknown";
3398
3848
  let resolved = binPath;
3399
3849
  try {
3400
- resolved = realpathSync(binPath);
3850
+ resolved = realpathSync2(binPath);
3401
3851
  } catch {
3402
3852
  }
3403
3853
  if (/[\\/]node_modules[\\/]dahrk-node[\\/]/.test(resolved)) return "npm";
@@ -3425,7 +3875,7 @@ function printChannelCommands(out) {
3425
3875
  out(` curl: ${CHANNEL_COMMANDS.curl}`);
3426
3876
  }
3427
3877
  async function runUpdate(inputs, deps = {}) {
3428
- const d = { ...defaultDeps3(), ...deps };
3878
+ const d = { ...defaultDeps4(), ...deps };
3429
3879
  const current = inputs.currentVersion;
3430
3880
  d.out("dahrk update");
3431
3881
  d.out("");
@@ -3482,14 +3932,14 @@ async function fetchLatestVersion() {
3482
3932
  function spawnUpgrade(argv) {
3483
3933
  const [cmd, ...args] = argv;
3484
3934
  try {
3485
- execFileSync5(cmd, args, { stdio: "inherit" });
3935
+ execFileSync6(cmd, args, { stdio: "inherit" });
3486
3936
  return 0;
3487
3937
  } catch (e) {
3488
3938
  const status = e.status;
3489
3939
  return typeof status === "number" ? status : 1;
3490
3940
  }
3491
3941
  }
3492
- var defaultDeps3 = () => ({
3942
+ var defaultDeps4 = () => ({
3493
3943
  binPath: process.argv[1],
3494
3944
  fetchLatest: fetchLatestVersion,
3495
3945
  runUpgrade: spawnUpgrade,
@@ -3498,19 +3948,19 @@ var defaultDeps3 = () => ({
3498
3948
  });
3499
3949
 
3500
3950
  // src/main.ts
3501
- var CLIENT_VERSION = "0.1.4";
3951
+ var CLIENT_VERSION = "0.1.6";
3502
3952
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
3503
3953
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3504
3954
  var RUNTIMES = ["claude-code", "codex", "pi"];
3505
3955
  var isRuntime = (r) => RUNTIMES.includes(r);
3506
3956
  function stateDir(env) {
3507
- return env.DAHRK_STATE_DIR ?? join8(homedir3(), ".dahrk");
3957
+ return env.DAHRK_STATE_DIR ?? join9(homedir4(), ".dahrk");
3508
3958
  }
3509
3959
  function legacyStateDir(env) {
3510
- return env.DAHRK_STATE_DIR ? void 0 : join8(homedir3(), ".skakel");
3960
+ return env.DAHRK_STATE_DIR ? void 0 : join9(homedir4(), ".skakel");
3511
3961
  }
3512
3962
  function readNodeId(file) {
3513
- if (!existsSync5(file)) return void 0;
3963
+ if (!existsSync6(file)) return void 0;
3514
3964
  try {
3515
3965
  const parsed = JSON.parse(readFileSync5(file, "utf8"));
3516
3966
  if (typeof parsed.nodeId === "string" && parsed.nodeId) return parsed.nodeId;
@@ -3522,18 +3972,18 @@ function resolveNodeId(env, opts = {}) {
3522
3972
  if (env.DAHRK_NODE_ID) return env.DAHRK_NODE_ID;
3523
3973
  if (opts.ephemeral) return randomUUID3();
3524
3974
  const dir = stateDir(env);
3525
- const file = join8(dir, "node.json");
3975
+ const file = join9(dir, "node.json");
3526
3976
  const existing = readNodeId(file);
3527
3977
  if (existing) return existing;
3528
3978
  const legacy = legacyStateDir(env);
3529
3979
  if (legacy) {
3530
- const legacyId = readNodeId(join8(legacy, "node.json"));
3980
+ const legacyId = readNodeId(join9(legacy, "node.json"));
3531
3981
  if (legacyId) return legacyId;
3532
3982
  }
3533
3983
  const nodeId = randomUUID3();
3534
3984
  try {
3535
- mkdirSync6(dir, { recursive: true });
3536
- writeFileSync6(file, `${JSON.stringify({ nodeId }, null, 2)}
3985
+ mkdirSync7(dir, { recursive: true });
3986
+ writeFileSync7(file, `${JSON.stringify({ nodeId }, null, 2)}
3537
3987
  `);
3538
3988
  } catch (e) {
3539
3989
  console.warn(`could not persist node id to ${file}: ${e.message}`);
@@ -3659,6 +4109,22 @@ async function main() {
3659
4109
  case "run":
3660
4110
  process.exitCode = await runWorkflow(parsed.flags);
3661
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
+ }
3662
4128
  case "update":
3663
4129
  process.exitCode = await runUpdate({ currentVersion: CLIENT_VERSION, check: parsed.flags.check });
3664
4130
  break;
@@ -3671,7 +4137,7 @@ var invokedAsEntrypoint = (() => {
3671
4137
  const argv1 = process.argv[1];
3672
4138
  if (!argv1) return false;
3673
4139
  try {
3674
- return pathToFileURL(realpathSync2(argv1)).href === import.meta.url;
4140
+ return pathToFileURL(realpathSync3(argv1)).href === import.meta.url;
3675
4141
  } catch {
3676
4142
  return false;
3677
4143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dahrk-node",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "exports": "./dist/main.js",
35
35
  "dependencies": {
36
36
  "@anthropic-ai/claude-agent-sdk": "0.3.183",
37
- "@dahrk/contracts": "^0.1.0",
37
+ "@dahrk/contracts": "^0.1.2",
38
38
  "@openai/codex-sdk": "0.141.0",
39
39
  "ws": "^8.18.0",
40
40
  "zod": "^3.23.8"