dahrk-node 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import "./chunk-FYS2JH42.js";
2
3
 
3
4
  // src/main.ts
4
5
  import { execFileSync as execFileSync9 } from "child_process";
5
6
  import { existsSync as existsSync14, readFileSync as readFileSync11, realpathSync as realpathSync5 } from "fs";
6
7
  import { randomUUID as randomUUID3 } from "crypto";
7
8
  import { homedir as homedir7, platform as osPlatform4 } from "os";
8
- import { basename as basename2, join as join17 } from "path";
9
+ import { basename as basename2, join as join19 } from "path";
9
10
  import { pathToFileURL } from "url";
10
11
 
11
12
  // ../../packages/edge/src/ws-client.ts
@@ -73,6 +74,13 @@ import {
73
74
  query
74
75
  } from "@anthropic-ai/claude-agent-sdk";
75
76
 
77
+ // ../../packages/executor-worktree/src/response-rule.ts
78
+ function decideResponse(bufferedText, endedOnTool, status) {
79
+ const text = bufferedText.trim();
80
+ if (status !== "ok" || !text || endedOnTool) return {};
81
+ return { responseText: text, event: { type: "response", text } };
82
+ }
83
+
76
84
  // ../../packages/executor-worktree/src/claude-mappers.ts
77
85
  function mapClaudeMessage(msg) {
78
86
  switch (msg.type) {
@@ -167,27 +175,21 @@ function consumeClaudeMessage(msg, state, suppressStageExit) {
167
175
  }
168
176
  if (msg.type === "result") {
169
177
  const status = r.resultStatus === "fail" ? "fail" : "ok";
170
- let responseText;
171
- if (status === "ok" && state.bufferedText && !state.turnEndedOnTool) {
172
- responseText = state.bufferedText;
173
- events.push({ type: "response", text: state.bufferedText });
174
- }
178
+ const decision = decideResponse(state.bufferedText ?? "", state.turnEndedOnTool, status);
179
+ if (decision.event) events.push(decision.event);
175
180
  for (const e of r.events) {
176
181
  if (suppressStageExit && e.type === "state") continue;
177
182
  events.push(e);
178
183
  }
179
184
  state.bufferedText = null;
180
185
  state.turnEndedOnTool = false;
181
- return { events, isResult: true, status, responseText };
186
+ return { events, isResult: true, status, responseText: decision.responseText };
182
187
  }
183
188
  events.push(...r.events);
184
189
  return { events, isResult: false };
185
190
  }
186
191
 
187
- // ../../packages/executor-worktree/src/runner-shared.ts
188
- import { readFileSync } from "fs";
189
- import { join } from "path";
190
- import { attachedDocBasename } from "@dahrk/contracts";
192
+ // ../../packages/executor-worktree/src/runtime-session.ts
191
193
  var SUMMARISE_PROMPT = "This stage is ending. In ONE concise sentence for a human reviewer reading the Linear ticket, state concretely what you DID or FOUND in this stage and the result (for example which functions or files changed, whether the tests pass, or what a review flagged). Be specific and lead with the outcome. Do not mention internal scratch files or a next-stage entry point. Reply with only that sentence.";
192
194
  function makeEmit(runtime, onTrace, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
193
195
  return (event, rawRef) => {
@@ -196,6 +198,11 @@ function makeEmit(runtime, onTrace, now = () => (/* @__PURE__ */ new Date()).toI
196
198
  onTrace(full);
197
199
  };
198
200
  }
201
+
202
+ // ../../packages/executor-worktree/src/prompt-assembly.ts
203
+ import { readFileSync } from "fs";
204
+ import { join } from "path";
205
+ import { attachedDocBasename } from "@dahrk/contracts";
199
206
  function stripFrontmatter(text) {
200
207
  const lines = text.split("\n");
201
208
  if (lines[0]?.trim() !== "---") return text;
@@ -232,7 +239,7 @@ function documentsBlock(ctx) {
232
239
  let budget = MAX_INLINE_DOCS_TOTAL_CHARS;
233
240
  const parts = [];
234
241
  for (const doc of docs) {
235
- const path = `.skakel/scratch/docs/${attachedDocBasename(doc)}.md`;
242
+ const path = `.dahrk/scratch/docs/${attachedDocBasename(doc)}.md`;
236
243
  const cap = Math.max(0, Math.min(MAX_INLINE_DOC_CHARS, budget));
237
244
  const body = doc.content.trim();
238
245
  const truncated = body.length > cap;
@@ -318,6 +325,8 @@ var OPENING_KICKOFF = "Begin now. Using the ticket context and your instructions
318
325
  function interactiveSeedText(ctx, instructionInSystemPrompt) {
319
326
  return instructionInSystemPrompt ? OPENING_KICKOFF : resolveStagePrompt(ctx);
320
327
  }
328
+
329
+ // ../../packages/executor-worktree/src/mailbox.ts
321
330
  var ManagedMailbox = class {
322
331
  q = [];
323
332
  waiters = [];
@@ -343,36 +352,19 @@ var ManagedMailbox = class {
343
352
  };
344
353
  }
345
354
  };
346
- function interactiveIdleWindows(ctx) {
347
- const idleMs = ctx.config.idleMs ?? Number(process.env.DAHRK_INTERACTIVE_IDLE_MS ?? process.env.SKAKEL_INTERACTIVE_IDLE_MS ?? 12e4);
348
- const firstReplyMs = ctx.config.firstReplyMs ?? Number(process.env.DAHRK_INTERACTIVE_FIRST_REPLY_MS ?? process.env.SKAKEL_INTERACTIVE_FIRST_REPLY_MS ?? 6e5);
349
- return { firstReplyMs: Math.max(firstReplyMs, idleMs), idleMs };
350
- }
351
- function raceNextTurn(pending, idleMs, signal) {
352
- return new Promise((resolve3) => {
353
- let settled = false;
354
- let timer;
355
- function finish(r) {
356
- if (settled) return;
357
- settled = true;
358
- if (timer) clearTimeout(timer);
359
- signal.removeEventListener("abort", onAbort);
360
- resolve3(r);
361
- }
362
- function onAbort() {
363
- finish({ kind: "cancelled" });
364
- }
365
- if (signal.aborted) {
366
- finish({ kind: "cancelled" });
367
- return;
368
- }
369
- signal.addEventListener("abort", onAbort);
370
- timer = setTimeout(() => finish({ kind: "idle-timeout" }), idleMs);
371
- pending.then(
372
- (res) => finish(res.done ? { kind: "turns-exhausted" } : { kind: "turn", value: res.value }),
373
- () => finish({ kind: "turns-exhausted" })
374
- );
375
- });
355
+
356
+ // ../../packages/executor-worktree/src/elicit-router.ts
357
+ function elicitOutcomeReply(outcome) {
358
+ switch (outcome.kind) {
359
+ case "reply":
360
+ return `The user selected: ${outcome.text}`;
361
+ case "busy":
362
+ return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
363
+ case "noreply":
364
+ return "No response from the user; proceed with your best judgement.";
365
+ case "cancel":
366
+ return "The question was cancelled.";
367
+ }
376
368
  }
377
369
  function createElicitTurnRouter(turns, opts) {
378
370
  const conversation = new ManagedMailbox();
@@ -416,6 +408,147 @@ function createElicitTurnRouter(turns, opts) {
416
408
  return { conversation, ask };
417
409
  }
418
410
 
411
+ // ../../packages/executor-worktree/src/turn-loop.ts
412
+ function interactiveIdleWindows(ctx) {
413
+ const idleMs = ctx.config.idleMs ?? Number(process.env.DAHRK_INTERACTIVE_IDLE_MS ?? process.env.SKAKEL_INTERACTIVE_IDLE_MS ?? 12e4);
414
+ const firstReplyMs = ctx.config.firstReplyMs ?? Number(process.env.DAHRK_INTERACTIVE_FIRST_REPLY_MS ?? process.env.SKAKEL_INTERACTIVE_FIRST_REPLY_MS ?? 6e5);
415
+ return { firstReplyMs: Math.max(firstReplyMs, idleMs), idleMs };
416
+ }
417
+ function raceNextTurn(pending, idleMs, signal) {
418
+ return new Promise((resolve3) => {
419
+ let settled = false;
420
+ let timer;
421
+ function finish(r) {
422
+ if (settled) return;
423
+ settled = true;
424
+ if (timer) clearTimeout(timer);
425
+ signal.removeEventListener("abort", onAbort);
426
+ resolve3(r);
427
+ }
428
+ function onAbort() {
429
+ finish({ kind: "cancelled" });
430
+ }
431
+ if (signal.aborted) {
432
+ finish({ kind: "cancelled" });
433
+ return;
434
+ }
435
+ signal.addEventListener("abort", onAbort);
436
+ timer = setTimeout(() => finish({ kind: "idle-timeout" }), idleMs);
437
+ pending.then(
438
+ (res) => finish(res.done ? { kind: "turns-exhausted" } : { kind: "turn", value: res.value }),
439
+ () => finish({ kind: "turns-exhausted" })
440
+ );
441
+ });
442
+ }
443
+ var COALESCE_MS = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
444
+ async function runInteractiveLoop(ctx, turns, emit, makeSession, opts) {
445
+ const { signal, cancelled, cancel, instructionInSystemPrompt } = opts;
446
+ const exit = ctx.config.exit ?? "either";
447
+ const wantsTool = exit === "tool" || exit === "either";
448
+ const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
449
+ const router = createElicitTurnRouter(turns, { signal, firstReplyMs, idleMs });
450
+ const humanIter = router.conversation[Symbol.asyncIterator]();
451
+ let awaitingFirstReply = true;
452
+ const ask = async (question) => {
453
+ const outcome = await router.ask(awaitingFirstReply, () => {
454
+ emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
455
+ ctx.emitElicit?.(question);
456
+ });
457
+ return elicitOutcomeReply(outcome);
458
+ };
459
+ const session = makeSession({ emit, ask });
460
+ let toolSummary;
461
+ let artifact;
462
+ let exited = "gate";
463
+ let pending = humanIter.next();
464
+ try {
465
+ const seed = await session.sendTurn(interactiveSeedText(ctx, instructionInSystemPrompt));
466
+ if (seed.stageComplete && wantsTool) {
467
+ exited = "tool";
468
+ toolSummary = seed.summary;
469
+ artifact = seed.artifact;
470
+ }
471
+ for (; ; ) {
472
+ if (exited === "tool") break;
473
+ const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
474
+ awaitingFirstReply = false;
475
+ if (race.kind === "cancelled") {
476
+ exited = "cancelled";
477
+ break;
478
+ }
479
+ if (race.kind === "idle-timeout") {
480
+ exited = "timeout";
481
+ break;
482
+ }
483
+ if (race.kind === "turns-exhausted") {
484
+ exited = "gate";
485
+ break;
486
+ }
487
+ const texts = [race.value.text];
488
+ pending = humanIter.next();
489
+ for (; ; ) {
490
+ const more = await raceNextTurn(pending, COALESCE_MS, signal);
491
+ if (more.kind === "turn") {
492
+ texts.push(more.value.text);
493
+ pending = humanIter.next();
494
+ continue;
495
+ }
496
+ if (more.kind === "cancelled") exited = "cancelled";
497
+ break;
498
+ }
499
+ if (exited === "cancelled") break;
500
+ const tr = await session.sendTurn(texts.join("\n"));
501
+ if (tr.stageComplete && wantsTool) {
502
+ exited = "tool";
503
+ toolSummary = tr.summary;
504
+ artifact = tr.artifact;
505
+ break;
506
+ }
507
+ }
508
+ } catch (e) {
509
+ if (!cancelled()) emit({ type: "error", kind: "runtime_error", message: e.message });
510
+ exited = cancelled() ? "cancelled" : "gate";
511
+ }
512
+ let status = "ok";
513
+ let summary = "";
514
+ if (exited === "tool") {
515
+ summary = toolSummary ?? "(stage marked complete)";
516
+ } else if (exited === "gate") {
517
+ summary = await session.summariseTurn();
518
+ } else if (exited === "timeout") {
519
+ status = "timeout";
520
+ summary = "(stage timed out awaiting input)";
521
+ await cancel();
522
+ } else {
523
+ status = "fail";
524
+ summary = "(stage cancelled)";
525
+ }
526
+ const costUsd = session.cost();
527
+ const sessionId = session.sessionId;
528
+ const outArtifact = status === "ok" ? artifact : void 0;
529
+ return {
530
+ status,
531
+ summary,
532
+ ...sessionId ? { sessionId } : {},
533
+ ...costUsd !== void 0 ? { costUsd } : {},
534
+ ...outArtifact ? { artifact: outArtifact } : {}
535
+ };
536
+ }
537
+ async function runBatchLoop(session, ctx, hooks, opts) {
538
+ let status = "ok";
539
+ try {
540
+ const tr = await session.sendTurn(resolveStagePrompt(ctx));
541
+ if (tr.status) status = tr.status;
542
+ } catch (e) {
543
+ if (!opts.cancelled()) hooks.emit({ type: "error", kind: "runtime_error", message: e.message });
544
+ status = "fail";
545
+ }
546
+ if (opts.cancelled()) status = "fail";
547
+ const costUsd = session.cost();
548
+ const sessionId = session.sessionId;
549
+ return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
550
+ }
551
+
419
552
  // ../../packages/executor-worktree/src/stage-complete-tool.ts
420
553
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
421
554
  import { z } from "zod";
@@ -423,6 +556,10 @@ var STAGE_COMPLETE_TOOL_NAME = "mcp__dahrk__dahrk_stage_complete";
423
556
  function createStageCompleteTool() {
424
557
  let captured = null;
425
558
  let capturedDoc = null;
559
+ const capture = (args) => {
560
+ captured = args.summary;
561
+ if (args.document !== void 0) capturedDoc = args.document;
562
+ };
426
563
  const completeTool = tool(
427
564
  "dahrk_stage_complete",
428
565
  "End the current stage and hand off a one-sentence summary of what was accomplished. When the stage's deliverable is a document (e.g. a specification or report) to be published, pass its full markdown body as `document`; this is the only way an interactive stage can emit a document, since it cannot write files.",
@@ -431,8 +568,7 @@ function createStageCompleteTool() {
431
568
  document: z.string().optional().describe("The full markdown body of the stage's deliverable document, if any.")
432
569
  },
433
570
  async (args) => {
434
- captured = args.summary;
435
- if (args.document !== void 0) capturedDoc = args.document;
571
+ capture(args);
436
572
  return { content: [{ type: "text", text: "Stage marked complete." }] };
437
573
  }
438
574
  );
@@ -441,7 +577,8 @@ function createStageCompleteTool() {
441
577
  allowedToolName: STAGE_COMPLETE_TOOL_NAME,
442
578
  fired: () => captured !== null,
443
579
  summary: () => captured,
444
- document: () => capturedDoc
580
+ document: () => capturedDoc,
581
+ capture
445
582
  };
446
583
  }
447
584
 
@@ -495,15 +632,15 @@ function createAskUserQuestionTool(deps) {
495
632
  }
496
633
 
497
634
  // ../../packages/executor-worktree/src/claude-adapter.ts
498
- var COALESCE_MS = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
499
635
  var MAX_TURNS = Number(process.env.DAHRK_MAX_TURNS ?? process.env.SKAKEL_MAX_TURNS ?? 64);
500
- var HANDED_BACK_ARTIFACT_PATH = ".skakel/scratch/output/document.md";
636
+ var HANDED_BACK_ARTIFACT_PATH = ".dahrk/scratch/output/document.md";
501
637
  var CLAUDE_CODE_SYSTEM_PROMPT = { type: "preset", preset: "claude_code" };
502
638
  var userMsg = (text) => ({
503
639
  type: "user",
504
640
  parent_tool_use_id: null,
505
641
  message: { role: "user", content: text }
506
642
  });
643
+ var defaultAsk = async () => elicitOutcomeReply({ kind: "noreply" });
507
644
  var sessionIdOf = (msg) => "session_id" in msg && typeof msg.session_id === "string" ? msg.session_id : void 0;
508
645
  var policyCanUseTool = async (ctx, toolName, input) => {
509
646
  const verdict2 = ctx.authorizeToolUse?.(toolName, input);
@@ -543,7 +680,25 @@ function runtimeEnvOptions(ctx) {
543
680
  if (!ctx.runtimeEnv) return {};
544
681
  return { env: { ...process.env, ...ctx.runtimeEnv } };
545
682
  }
546
- function createClaudeRunner() {
683
+ var defaultCreateClaudeSession = ({ prompt, options }) => {
684
+ const mailbox = prompt === void 0 ? new ManagedMailbox() : void 0;
685
+ const q = query({ prompt: mailbox ?? prompt, options });
686
+ const it = q[Symbol.asyncIterator]();
687
+ return {
688
+ [Symbol.asyncIterator]: () => it,
689
+ push: (m) => mailbox?.push(m),
690
+ end: () => mailbox?.end(),
691
+ close: () => {
692
+ try {
693
+ q.close();
694
+ } catch {
695
+ }
696
+ },
697
+ interrupt: () => q.interrupt()
698
+ };
699
+ };
700
+ function createClaudeRunner(deps = {}) {
701
+ const createSession = deps.createSession ?? defaultCreateClaudeSession;
547
702
  const abortController = new AbortController();
548
703
  let cancelled = false;
549
704
  let active;
@@ -608,11 +763,86 @@ function createClaudeRunner() {
608
763
  }
609
764
  active = void 0;
610
765
  };
766
+ const makeClaudeRuntimeSession = (ctx, hooks, mode) => {
767
+ const state = newBufferState();
768
+ const it = mode.interactive ? mode.session[Symbol.asyncIterator]() : void 0;
769
+ const consumeInteractiveTurn = async () => {
770
+ for (; ; ) {
771
+ const { value: msg, done } = await it.next();
772
+ if (done) return {};
773
+ const res = handleMessage(msg, hooks.emit, ctx, state, true);
774
+ if (res.isResult) return { status: res.status, responseText: res.responseText };
775
+ }
776
+ };
777
+ return {
778
+ get sessionId() {
779
+ return sessionId;
780
+ },
781
+ async sendTurn(text) {
782
+ if (!mode.interactive) {
783
+ const session = createSession({ prompt: text, options: mode.options });
784
+ active = session;
785
+ let status2;
786
+ try {
787
+ for await (const msg of session) {
788
+ const res = handleMessage(msg, hooks.emit, ctx, state, false);
789
+ if (res.isResult && res.status) status2 = res.status;
790
+ }
791
+ } finally {
792
+ closeActive();
793
+ }
794
+ return { stageComplete: false, ...status2 !== void 0 ? { status: status2 } : {} };
795
+ }
796
+ mode.session.push(userMsg(text));
797
+ const { status, responseText } = await consumeInteractiveTurn();
798
+ const stageComplete = mode.stageTool.fired();
799
+ const doc = stageComplete ? mode.stageTool.document() : null;
800
+ return {
801
+ stageComplete,
802
+ ...stageComplete ? { summary: mode.stageTool.summary() ?? void 0 } : {},
803
+ ...responseText !== void 0 ? { responseText } : {},
804
+ ...status !== void 0 ? { status } : {},
805
+ ...doc !== null ? { artifact: { path: ctx.config.emitArtifact ?? HANDED_BACK_ARTIFACT_PATH, content: doc } } : {}
806
+ };
807
+ },
808
+ async summariseTurn() {
809
+ if (!mode.interactive) return "(no summary produced)";
810
+ mode.summarising.value = true;
811
+ try {
812
+ mode.session.push(userMsg(SUMMARISE_PROMPT));
813
+ const { responseText } = await consumeInteractiveTurn();
814
+ return (responseText ?? "").trim() || "(no summary produced)";
815
+ } catch {
816
+ return "(no summary produced)";
817
+ } finally {
818
+ mode.summarising.value = false;
819
+ }
820
+ },
821
+ cost() {
822
+ return costUsd;
823
+ },
824
+ dispose() {
825
+ if (!mode.interactive) return;
826
+ const iter = it;
827
+ mode.session.end();
828
+ void (async () => {
829
+ try {
830
+ for (; ; ) {
831
+ const { done } = await iter.next();
832
+ if (done) break;
833
+ }
834
+ } catch {
835
+ } finally {
836
+ closeActive();
837
+ }
838
+ })();
839
+ }
840
+ };
841
+ };
611
842
  return {
612
843
  runtime: "claude-code",
613
844
  async runBatch(ctx, onTrace) {
614
- const emit = makeEmit("claude-code", onTrace);
615
- const prompt = resolveStagePrompt(ctx);
845
+ const hooks = { emit: makeEmit("claude-code", onTrace), ask: defaultAsk };
616
846
  const mcpServers = buildBrokeredMcpServers(ctx);
617
847
  const options = {
618
848
  ...baseOptions(ctx),
@@ -623,171 +853,50 @@ function createClaudeRunner() {
623
853
  // Headless: allow tools to run without an interactive permission prompt (M6 wires policy).
624
854
  canUseTool: async (toolName, input) => policyCanUseTool(ctx, toolName, input)
625
855
  };
626
- const state = newBufferState();
627
- let status = "ok";
628
- try {
629
- const q = query({ prompt, options });
630
- active = q;
631
- for await (const msg of q) {
632
- const res = handleMessage(msg, emit, ctx, state, false);
633
- if (res.isResult && res.status) status = res.status;
634
- }
635
- } catch (e) {
636
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
637
- status = "fail";
638
- } finally {
639
- closeActive();
640
- }
641
- if (cancelled) status = "fail";
642
- return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
856
+ const rt = makeClaudeRuntimeSession(ctx, hooks, { interactive: false, options });
857
+ return runBatchLoop(rt, ctx, hooks, { cancelled: () => cancelled });
643
858
  },
644
859
  async runInteractive(ctx, turns, onTrace) {
645
860
  const emit = makeEmit("claude-code", onTrace);
646
861
  const stageTool = createStageCompleteTool();
647
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
648
- let awaitingFirstReply = true;
649
- const router = createElicitTurnRouter(turns, { signal: abortController.signal, firstReplyMs, idleMs });
650
- const askTool = createAskUserQuestionTool({
651
- ask: async (question) => {
652
- const outcome = await router.ask(awaitingFirstReply, () => {
653
- emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
654
- ctx.emitElicit?.(question);
655
- });
656
- switch (outcome.kind) {
657
- case "reply":
658
- return `The user selected: ${outcome.text}`;
659
- case "busy":
660
- return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
661
- case "noreply":
662
- return "No response from the user; proceed with your best judgement.";
663
- case "cancel":
664
- return "The question was cancelled.";
665
- }
666
- }
667
- });
668
- const brokered = buildBrokeredMcpServers(ctx);
669
- let summarising = false;
670
- const options = {
671
- ...baseOptions(ctx),
672
- // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
673
- // interactive persona still gets it without losing the working-directory context.
674
- systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
675
- // Inject the stage-complete exit tool and the AskUserQuestion shadow alongside any brokered
676
- // MCP servers (parity with batch).
677
- mcpServers: { dahrk: stageTool.server, ask: askTool.server, ...brokered ?? {} },
678
- // Redirect the built-in AskUserQuestion to the shadow tool so a structured question surfaces
679
- // as a Linear elicitation. The redirect is name-only and single-hop; the tool still runs, so
680
- // this is mapping, not gating (DHK-344 / DHK-223).
681
- toolAliases: { [ASK_USER_QUESTION_ALIAS]: askTool.allowedToolName },
682
- // Auto-approve the injected tools; `allowedTools` is an auto-approve list, not a whitelist, so
683
- // it does not restrict the other tools canUseTool allows.
684
- allowedTools: [stageTool.allowedToolName, askTool.allowedToolName],
685
- canUseTool: async (toolName, input) => interactiveCanUseTool(summarising, stageTool.allowedToolName, ctx, toolName, input),
686
- maxTurns: MAX_TURNS,
687
- includePartialMessages: false
688
- };
689
- const exit = ctx.config.exit ?? "either";
690
- const wantsTool = exit === "tool" || exit === "either";
691
- const mailbox = new ManagedMailbox();
692
- const q = query({ prompt: mailbox, options });
693
- active = q;
694
- const it = q[Symbol.asyncIterator]();
695
- const humanIter = router.conversation[Symbol.asyncIterator]();
696
- const state = newBufferState();
697
- const consumeTurn = async () => {
698
- for (; ; ) {
699
- const { value: msg, done } = await it.next();
700
- if (done) return void 0;
701
- const res = handleMessage(msg, emit, ctx, state, true);
702
- if (res.isResult) return res.responseText;
703
- }
704
- };
705
- let exited = null;
706
- let pending = humanIter.next();
707
- try {
708
- mailbox.push(userMsg(interactiveSeedText(ctx, hasSystemPrompt(ctx))));
709
- await consumeTurn();
710
- if (stageTool.fired() && wantsTool) exited = "tool";
711
- while (exited === null) {
712
- const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, abortController.signal);
713
- awaitingFirstReply = false;
714
- if (race.kind === "cancelled") {
715
- exited = "cancelled";
716
- break;
717
- }
718
- if (race.kind === "idle-timeout") {
719
- exited = "timeout";
720
- break;
721
- }
722
- if (race.kind === "turns-exhausted") {
723
- exited = "gate";
724
- break;
725
- }
726
- const texts = [race.value.text];
727
- pending = humanIter.next();
728
- for (; ; ) {
729
- const more = await raceNextTurn(pending, COALESCE_MS, abortController.signal);
730
- if (more.kind === "turn") {
731
- texts.push(more.value.text);
732
- pending = humanIter.next();
733
- continue;
734
- }
735
- if (more.kind === "cancelled") exited = "cancelled";
736
- break;
737
- }
738
- if (exited === "cancelled") break;
739
- mailbox.push(userMsg(texts.join("\n")));
740
- await consumeTurn();
741
- if (stageTool.fired() && wantsTool) {
742
- exited = "tool";
743
- break;
744
- }
745
- }
746
- } catch (e) {
747
- if (!cancelled && !exited) emit({ type: "error", kind: "runtime_error", message: e.message });
748
- exited = exited ?? (cancelled ? "cancelled" : "gate");
749
- }
750
- let status = "ok";
751
- let summary = "";
752
- if (exited === "tool") {
753
- summary = stageTool.summary() ?? "(stage marked complete)";
754
- } else if (exited === "gate") {
755
- summarising = true;
756
- try {
757
- mailbox.push(userMsg(SUMMARISE_PROMPT));
758
- const reply = await consumeTurn();
759
- summary = (reply ?? "").trim() || "(no summary produced)";
760
- } catch {
761
- summary = "(no summary produced)";
762
- } finally {
763
- summarising = false;
764
- }
765
- } else if (exited === "timeout") {
766
- status = "timeout";
767
- summary = "(stage timed out awaiting input)";
768
- await this.cancel();
769
- } else {
770
- status = "fail";
771
- summary = "(stage cancelled)";
772
- }
773
- mailbox.end();
774
- try {
775
- for (; ; ) {
776
- const { done } = await it.next();
777
- if (done) break;
778
- }
779
- } catch {
780
- }
781
- closeActive();
782
- const handedBackDoc = status === "ok" ? stageTool.document() : null;
783
- const artifact = handedBackDoc !== null ? { path: ctx.config.emitArtifact ?? HANDED_BACK_ARTIFACT_PATH, content: handedBackDoc } : void 0;
784
- return {
785
- status,
786
- summary,
787
- ...sessionId ? { sessionId } : {},
788
- ...costUsd !== void 0 ? { costUsd } : {},
789
- ...artifact ? { artifact } : {}
862
+ const summarising = { value: false };
863
+ let rt;
864
+ const makeSession = (hooks) => {
865
+ const askTool = createAskUserQuestionTool({ ask: (question) => hooks.ask(question) });
866
+ const brokered = buildBrokeredMcpServers(ctx);
867
+ const options = {
868
+ ...baseOptions(ctx),
869
+ // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
870
+ // interactive persona still gets it without losing the working-directory context.
871
+ systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
872
+ // Inject the stage-complete exit tool and the AskUserQuestion shadow alongside any brokered
873
+ // MCP servers (parity with batch).
874
+ mcpServers: { dahrk: stageTool.server, ask: askTool.server, ...brokered ?? {} },
875
+ // Redirect the built-in AskUserQuestion to the shadow tool so a structured question surfaces
876
+ // as a Linear elicitation. The redirect is name-only and single-hop; the tool still runs, so
877
+ // this is mapping, not gating (DHK-344 / DHK-223).
878
+ toolAliases: { [ASK_USER_QUESTION_ALIAS]: askTool.allowedToolName },
879
+ // Auto-approve the injected tools; `allowedTools` is an auto-approve list, not a whitelist, so
880
+ // it does not restrict the other tools canUseTool allows.
881
+ allowedTools: [stageTool.allowedToolName, askTool.allowedToolName],
882
+ canUseTool: async (toolName, input) => interactiveCanUseTool(summarising.value, stageTool.allowedToolName, ctx, toolName, input),
883
+ maxTurns: MAX_TURNS,
884
+ includePartialMessages: false
885
+ };
886
+ const session = createSession({ options, stageTool });
887
+ active = session;
888
+ rt = makeClaudeRuntimeSession(ctx, hooks, { interactive: true, session, stageTool, summarising });
889
+ return rt;
790
890
  };
891
+ const result = await runInteractiveLoop(ctx, turns, emit, makeSession, {
892
+ signal: abortController.signal,
893
+ cancelled: () => cancelled,
894
+ cancel: () => this.cancel(),
895
+ // Claude carries the stage instruction in its system prompt, so the seed can be a short kickoff.
896
+ instructionInSystemPrompt: hasSystemPrompt(ctx)
897
+ });
898
+ rt?.dispose();
899
+ return result;
791
900
  },
792
901
  async summarise(ctx) {
793
902
  if (!sessionId) return "(no summary: session not established)";
@@ -802,9 +911,9 @@ function createClaudeRunner() {
802
911
  };
803
912
  try {
804
913
  let out2 = "";
805
- const q = query({ prompt: SUMMARISE_PROMPT, options });
806
- active = q;
807
- for await (const msg of q) {
914
+ const session = createSession({ prompt: SUMMARISE_PROMPT, options });
915
+ active = session;
916
+ for await (const msg of session) {
808
917
  const found = sessionIdOf(msg);
809
918
  if (found) sessionId = found;
810
919
  if (msg.type === "result" && msg.subtype === "success") out2 += msg.result;
@@ -828,237 +937,21 @@ function createClaudeRunner() {
828
937
  };
829
938
  }
830
939
 
831
- // ../../packages/executor-worktree/src/codex-adapter.ts
832
- import { Codex } from "@openai/codex-sdk";
940
+ // ../../packages/executor-worktree/src/pi-adapter.ts
941
+ import { join as join4 } from "path";
833
942
 
834
- // ../../packages/executor-worktree/src/codex-mappers.ts
835
- function mapItem(item) {
836
- switch (item.type) {
837
- case "reasoning":
838
- return [{ type: "thought", subtype: "reasoning_text", text: item.text }];
839
- case "agent_message":
840
- return [{ type: "response", text: item.text }];
841
- case "command_execution":
842
- return [
843
- { type: "action", tool: "command", toolUseId: item.id, input: { command: item.command } },
844
- { type: "observation", toolUseId: item.id, output: item.aggregated_output, isError: item.status === "failed" }
845
- ];
846
- case "mcp_tool_call":
847
- return [
848
- { type: "action", tool: `${item.server}/${item.tool}`, toolUseId: item.id, input: item.arguments },
849
- { type: "observation", toolUseId: item.id, output: item.result?.content ?? item.error, isError: Boolean(item.error) }
850
- ];
851
- case "web_search":
852
- return [{ type: "action", tool: "web_search", toolUseId: item.id, input: { query: item.query } }];
853
- case "file_change":
854
- return [{ type: "action", tool: "apply_patch", toolUseId: item.id, input: item.changes }];
855
- case "todo_list":
856
- return [{ type: "thought", text: JSON.stringify(item.items) }];
857
- case "error":
858
- return [{ type: "error", kind: "item_error", message: item.message }];
859
- default:
860
- return [];
861
- }
862
- }
863
- function mapCodexEvent(ev) {
864
- switch (ev.type) {
865
- case "item.completed":
866
- return { events: mapItem(ev.item), recognised: true };
867
- case "turn.completed":
868
- return {
869
- events: [
870
- { type: "state", event: "stage-exit", status: "ok", usage: mapUsage2(ev.usage) }
871
- ],
872
- recognised: true
873
- };
874
- case "turn.failed":
875
- return {
876
- events: [
877
- { type: "error", kind: "turn_failed", message: JSON.stringify(ev.error ?? {}) },
878
- { type: "state", event: "stage-exit", status: "fail" }
879
- ],
880
- recognised: true
881
- };
882
- case "error":
883
- return { events: [{ type: "error", kind: "thread_error", message: ev.message }], recognised: true };
884
- // Lifecycle / interim updates: recognised, captured in raw sidecar, not normalised.
885
- case "thread.started":
886
- case "turn.started":
887
- case "item.started":
888
- case "item.updated":
889
- return { events: [], recognised: true };
890
- default:
891
- return { events: [], recognised: false };
943
+ // ../../packages/executor-worktree/src/pi-mappers.ts
944
+ function parsePiEvent(msg) {
945
+ if (typeof msg !== "object" || msg === null) return null;
946
+ const type = msg.type;
947
+ if (typeof type !== "string") return null;
948
+ if (type === "message_update") {
949
+ const ame = msg.assistantMessageEvent;
950
+ if (typeof ame !== "object" || ame === null) return null;
892
951
  }
952
+ return msg;
893
953
  }
894
954
  function mapUsage2(u) {
895
- return {
896
- input: u?.input_tokens ?? 0,
897
- output: u?.output_tokens ?? 0,
898
- cacheRead: u?.cached_input_tokens ?? 0,
899
- cacheCreate: 0
900
- };
901
- }
902
-
903
- // ../../packages/executor-worktree/src/codex-adapter.ts
904
- var COALESCE_MS2 = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
905
- var CODEX_COST_UNAVAILABLE_NOTE = "codex-adapter: cost reporting unavailable for the Codex runtime (the SDK reports tokens, not price); costUsd left unset, not $0\n";
906
- function warnCostUnavailable(write = (s) => void process.stderr.write(s)) {
907
- write(CODEX_COST_UNAVAILABLE_NOTE);
908
- }
909
- function runtimeEnvOptions2(ctx) {
910
- if (!ctx.runtimeEnv) return {};
911
- const env = {};
912
- for (const [k, v] of Object.entries(process.env)) if (v !== void 0) env[k] = v;
913
- return { env: { ...env, ...ctx.runtimeEnv } };
914
- }
915
- function createCodexRunner() {
916
- const abortController = new AbortController();
917
- const signal = abortController.signal;
918
- let cancelled = false;
919
- let thread;
920
- let sessionId;
921
- const threadOptions = (ctx) => ({
922
- workingDirectory: ctx.workspace.worktreePath,
923
- sandboxMode: "workspace-write",
924
- skipGitRepoCheck: true,
925
- ...ctx.config.model ? { model: ctx.config.model } : {}
926
- });
927
- const openThread = (ctx) => {
928
- const codex = new Codex(runtimeEnvOptions2(ctx));
929
- const t = ctx.sessionId ? codex.resumeThread(ctx.sessionId, threadOptions(ctx)) : codex.startThread(threadOptions(ctx));
930
- thread = t;
931
- return t;
932
- };
933
- const pumpTurn = async (events, emit, ctx, suppressStageExit) => {
934
- let failed = false;
935
- for await (const ev of events) {
936
- const rawRef = ctx.writeRaw?.(ev);
937
- const { events: mapped } = mapCodexEvent(ev);
938
- for (const e of mapped) {
939
- if (suppressStageExit && e.type === "state") continue;
940
- emit(e, rawRef);
941
- }
942
- if (ev.type === "thread.started") sessionId = ev.thread_id;
943
- if (ev.type === "turn.failed") failed = true;
944
- }
945
- return failed;
946
- };
947
- const captureThreadId = (t) => {
948
- if (t.id) sessionId = t.id;
949
- };
950
- return {
951
- runtime: "codex",
952
- async runBatch(ctx, onTrace) {
953
- if (ctx.config.mcpServers && ctx.config.mcpServers.length > 0) {
954
- process.stderr.write("codex-adapter: MCP servers not supported on Codex; ignoring\n");
955
- }
956
- const emit = makeEmit("codex", onTrace);
957
- const t = openThread(ctx);
958
- let status = "ok";
959
- try {
960
- const { events } = await t.runStreamed(resolveStagePrompt(ctx), { signal });
961
- if (await pumpTurn(events, emit, ctx, false)) status = "fail";
962
- } catch (e) {
963
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
964
- status = "fail";
965
- }
966
- captureThreadId(t);
967
- if (cancelled) status = "fail";
968
- warnCostUnavailable();
969
- return { status, ...sessionId ? { sessionId } : {} };
970
- },
971
- async runInteractive(ctx, turns, onTrace) {
972
- const emit = makeEmit("codex", onTrace);
973
- const t = openThread(ctx);
974
- const exit = ctx.config.exit ?? "either";
975
- if (exit === "tool" || exit === "either") {
976
- process.stderr.write("codex-adapter: interactive tool-exit not supported in M4; using gate exit\n");
977
- }
978
- const humanIter = turns[Symbol.asyncIterator]();
979
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
980
- let awaitingFirstReply = true;
981
- let exited = "gate";
982
- let pending = humanIter.next();
983
- try {
984
- const seed = await t.runStreamed(interactiveSeedText(ctx, false), { signal });
985
- await pumpTurn(seed.events, emit, ctx, true);
986
- for (; ; ) {
987
- const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
988
- awaitingFirstReply = false;
989
- if (race.kind === "cancelled") {
990
- exited = "cancelled";
991
- break;
992
- }
993
- if (race.kind === "idle-timeout") {
994
- exited = "timeout";
995
- break;
996
- }
997
- if (race.kind === "turns-exhausted") {
998
- exited = "gate";
999
- break;
1000
- }
1001
- const texts = [race.value.text];
1002
- pending = humanIter.next();
1003
- for (; ; ) {
1004
- const more = await raceNextTurn(pending, COALESCE_MS2, signal);
1005
- if (more.kind === "turn") {
1006
- texts.push(more.value.text);
1007
- pending = humanIter.next();
1008
- continue;
1009
- }
1010
- if (more.kind === "cancelled") exited = "cancelled";
1011
- break;
1012
- }
1013
- if (exited === "cancelled") break;
1014
- const { events } = await t.runStreamed(texts.join("\n"), { signal });
1015
- await pumpTurn(events, emit, ctx, true);
1016
- }
1017
- } catch (e) {
1018
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
1019
- exited = cancelled ? "cancelled" : "gate";
1020
- }
1021
- let status = "ok";
1022
- let summary = "";
1023
- if (exited === "gate") {
1024
- try {
1025
- const turn = await t.run(SUMMARISE_PROMPT, { signal });
1026
- summary = (turn.finalResponse ?? "").trim() || "(no summary produced)";
1027
- } catch {
1028
- summary = "(no summary produced)";
1029
- }
1030
- } else if (exited === "timeout") {
1031
- status = "timeout";
1032
- summary = "(stage timed out awaiting input)";
1033
- await this.cancel();
1034
- } else {
1035
- status = "fail";
1036
- summary = "(stage cancelled)";
1037
- }
1038
- captureThreadId(t);
1039
- warnCostUnavailable();
1040
- return { status, summary, ...sessionId ? { sessionId } : {} };
1041
- },
1042
- async summarise(ctx) {
1043
- if (!thread) return "(no summary: thread not established)";
1044
- try {
1045
- const turn = await thread.run(SUMMARISE_PROMPT, { signal });
1046
- captureThreadId(thread);
1047
- return (turn.finalResponse ?? "").trim() || "(no summary produced)";
1048
- } catch (e) {
1049
- return `(summary unavailable: ${e.message})`;
1050
- }
1051
- },
1052
- async cancel() {
1053
- if (cancelled) return;
1054
- cancelled = true;
1055
- abortController.abort();
1056
- }
1057
- };
1058
- }
1059
-
1060
- // ../../packages/executor-worktree/src/pi-mappers.ts
1061
- function mapUsage3(u) {
1062
955
  return {
1063
956
  input: u?.input ?? 0,
1064
957
  output: u?.output ?? 0,
@@ -1099,7 +992,7 @@ function mapPiEvent(ev) {
1099
992
  const kind = ev.type === "agent_end" ? "agent_error" : "turn_error";
1100
993
  events.push({ type: "error", kind, message: m?.errorMessage ?? m?.stopReason ?? "failed" });
1101
994
  }
1102
- events.push({ type: "state", event: "stage-exit", status, usage: mapUsage3(m?.usage) });
995
+ events.push({ type: "state", event: "stage-exit", status, usage: mapUsage2(m?.usage) });
1103
996
  return { events, recognised: true };
1104
997
  }
1105
998
  // Streamed deltas: owned by the buffered state machine, no discrete event here.
@@ -1154,12 +1047,8 @@ function consumePiEvent(ev, state, suppressStageExit) {
1154
1047
  if (ev.type === "turn_end" || ev.type === "agent_end") {
1155
1048
  const r = mapPiEvent(ev);
1156
1049
  const status = settleStatus(settleMessage(ev));
1157
- let responseText;
1158
- const text = state.bufferedText.trim();
1159
- if (status === "ok" && text && !state.turnEndedOnTool) {
1160
- responseText = text;
1161
- events.push({ type: "response", text });
1162
- }
1050
+ const decision = decideResponse(state.bufferedText, state.turnEndedOnTool, status);
1051
+ if (decision.event) events.push(decision.event);
1163
1052
  if (state.pendingThought) {
1164
1053
  events.push({ type: "thought", subtype: "reasoning_text", text: state.pendingThought });
1165
1054
  }
@@ -1170,162 +1059,162 @@ function consumePiEvent(ev, state, suppressStageExit) {
1170
1059
  state.bufferedText = "";
1171
1060
  state.pendingThought = "";
1172
1061
  state.turnEndedOnTool = false;
1173
- return { events, isResult: true, status, responseText };
1062
+ return { events, isResult: true, status, responseText: decision.responseText };
1174
1063
  }
1175
1064
  return { events, isResult: false };
1176
1065
  }
1177
1066
 
1067
+ // ../../packages/executor-worktree/src/pi-auth.ts
1068
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
1069
+ import { tmpdir as tmpdir2 } from "os";
1070
+ import { join as join3 } from "path";
1071
+ function readAuthHint(ctx) {
1072
+ return ctx.runtimeAuth;
1073
+ }
1074
+ function applyApiKeyAuth(hint2, runtimeEnv, authStorage) {
1075
+ const env = runtimeEnv ?? {};
1076
+ for (const p of hint2?.providers ?? []) {
1077
+ if (p.kind !== "api_key") continue;
1078
+ const value = env[p.envVar];
1079
+ if (value) authStorage.setRuntimeApiKey(p.provider, value);
1080
+ }
1081
+ }
1082
+ function buildAuthJson(hint2) {
1083
+ const entries = [];
1084
+ for (const p of hint2?.providers ?? []) {
1085
+ if (p.kind !== "oauth") continue;
1086
+ entries.push([p.provider, { type: "oauth", ...p.extra, access: p.access, refresh: p.refresh, expires: p.expires }]);
1087
+ }
1088
+ return entries.length ? Object.fromEntries(entries) : void 0;
1089
+ }
1090
+ function buildCustomProviders(hint2) {
1091
+ const providers = {};
1092
+ for (const p of hint2?.providers ?? []) {
1093
+ if (p.kind !== "api_key" || !p.baseUrl) continue;
1094
+ providers[p.provider] = {
1095
+ baseUrl: p.baseUrl,
1096
+ ...p.headers ? { headers: p.headers } : {},
1097
+ ...p.models ? { models: p.models } : {}
1098
+ };
1099
+ }
1100
+ return Object.keys(providers).length ? { providers } : void 0;
1101
+ }
1102
+ function createStageConfigDir() {
1103
+ return mkdtempSync(join3(tmpdir2(), "dahrk-pi-"));
1104
+ }
1105
+ function cleanupStageConfigDir(dir) {
1106
+ rmSync(dir, { recursive: true, force: true });
1107
+ }
1108
+ function writeStageAuthFile(dir, hint2) {
1109
+ const authJson = buildAuthJson(hint2);
1110
+ if (!authJson) return void 0;
1111
+ const path = join3(dir, "auth.json");
1112
+ writeFileSync(path, JSON.stringify(authJson, null, 2));
1113
+ return path;
1114
+ }
1115
+ function writeStageCustomProviders(dir, hint2) {
1116
+ const models = buildCustomProviders(hint2);
1117
+ if (!models) return void 0;
1118
+ const path = join3(dir, "models.json");
1119
+ writeFileSync(path, JSON.stringify(models, null, 2));
1120
+ return path;
1121
+ }
1122
+
1178
1123
  // ../../packages/executor-worktree/src/pi-adapter.ts
1179
- var COALESCE_MS3 = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
1180
1124
  var PI_STAGE_COMPLETE_TOOL = "dahrk_stage_complete";
1181
- function createPiRunner(deps = {}) {
1182
- const createSession = deps.createSession ?? defaultCreatePiSession;
1183
- const abortController = new AbortController();
1184
- const signal = abortController.signal;
1185
- let cancelled = false;
1186
- let session;
1187
- let sessionId;
1188
- const openSession = async (ctx) => {
1189
- if (!session) session = await createSession(ctx);
1190
- return session;
1191
- };
1192
- const captureSessionId = (s) => {
1193
- if (s.sessionId) sessionId = s.sessionId;
1194
- };
1195
- const readSessionCost = (s) => {
1196
- const cost = s.getSessionStats?.()?.cost;
1197
- return typeof cost === "number" ? cost : void 0;
1198
- };
1125
+ var PI_ASK_USER_QUESTION_TOOL = "ask_user_question";
1126
+ function piToolCallDecision(ctx, toolName, input) {
1127
+ const verdict2 = ctx.authorizeToolUse?.(toolName, input);
1128
+ if (verdict2?.verdict === "deny") {
1129
+ return { block: true, reason: verdict2.reason ?? `tool "${toolName}" denied by policy ${verdict2.policy}` };
1130
+ }
1131
+ return void 0;
1132
+ }
1133
+ function registerToolCallGate(s, ctx) {
1134
+ const policyCtx = ctx;
1135
+ s.setToolCallGate?.((toolName, input) => piToolCallDecision(policyCtx, toolName, input));
1136
+ }
1137
+ function buildBrokeredPiMcpServers(ctx) {
1138
+ const servers = ctx.config.mcpServers;
1139
+ if (!servers || servers.length === 0 || !ctx.mcpProxyBaseUrl) return void 0;
1140
+ const entries = {};
1141
+ for (const s of servers) entries[s.id] = { type: s.type, url: `${ctx.mcpProxyBaseUrl}/${s.id}` };
1142
+ return entries;
1143
+ }
1144
+ function createBrokeredMcpExtension(servers) {
1199
1145
  return {
1200
- runtime: "pi",
1201
- async runBatch(ctx, onTrace) {
1202
- const emit = makeEmit("pi", onTrace);
1203
- const s = await openSession(ctx);
1204
- const state = newPiBufferState();
1205
- let status = "ok";
1206
- const unsub = s.subscribe((ev) => {
1207
- const rawRef = ctx.writeRaw?.(ev);
1208
- const r = consumePiEvent(ev, state, false);
1209
- for (const e of r.events) emit(e, rawRef);
1210
- if (r.isResult && r.status) status = r.status;
1211
- });
1212
- try {
1213
- await s.prompt(resolveStagePrompt(ctx));
1214
- } catch (e) {
1215
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
1216
- status = "fail";
1217
- } finally {
1218
- unsub();
1146
+ name: "dahrk-brokered-mcp",
1147
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1148
+ factory: async (pi) => {
1149
+ const { Client } = await import("./client-MZ5GEQAD.js");
1150
+ const { StreamableHTTPClientTransport } = await import("./streamableHttp-S27HYW5J.js");
1151
+ for (const [id, server] of Object.entries(servers)) {
1152
+ try {
1153
+ const client = new Client({ name: `dahrk-${id}`, version: "0.1.0" });
1154
+ await client.connect(new StreamableHTTPClientTransport(new URL(server.url)));
1155
+ const { tools } = await client.listTools();
1156
+ for (const tool3 of tools) {
1157
+ pi.registerTool({
1158
+ name: tool3.name,
1159
+ label: tool3.name,
1160
+ description: tool3.description ?? "",
1161
+ // The MCP inputSchema is a JSON-schema object; Pi validates against it at runtime (its
1162
+ // custom tools use the same plain-object shape - see the injected tools above).
1163
+ parameters: tool3.inputSchema,
1164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1165
+ execute: async (_toolCallId, params) => {
1166
+ const result = await client.callTool({ name: tool3.name, arguments: params ?? {} });
1167
+ return { content: result.content, details: {} };
1168
+ }
1169
+ });
1170
+ }
1171
+ } catch {
1172
+ }
1219
1173
  }
1220
- captureSessionId(s);
1221
- if (cancelled) status = "fail";
1222
- const costUsd = readSessionCost(s);
1223
- return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
1174
+ }
1175
+ };
1176
+ }
1177
+ var defaultAsk2 = async () => elicitOutcomeReply({ kind: "noreply" });
1178
+ function makePiRuntimeSession(s, ctx, hooks, interactive) {
1179
+ return {
1180
+ get sessionId() {
1181
+ return s.sessionId;
1224
1182
  },
1225
- async runInteractive(ctx, turns, onTrace) {
1226
- const emit = makeEmit("pi", onTrace);
1227
- const s = await openSession(ctx);
1183
+ async sendTurn(text) {
1228
1184
  const state = newPiBufferState();
1229
- const exit = ctx.config.exit ?? "either";
1230
- const wantsTool = exit === "tool" || exit === "either";
1231
- let toolFired = false;
1232
- let toolSummary = null;
1185
+ let stageComplete = false;
1186
+ let summary;
1233
1187
  let stageCompleteCallId;
1234
- let lastResponseText;
1188
+ let responseText;
1189
+ let status;
1235
1190
  const unsub = s.subscribe((ev) => {
1236
1191
  const rawRef = ctx.writeRaw?.(ev);
1237
1192
  if (ev.type === "tool_execution_start" && ev.toolName === PI_STAGE_COMPLETE_TOOL) {
1238
- toolFired = true;
1193
+ stageComplete = true;
1239
1194
  stageCompleteCallId = ev.toolCallId;
1240
1195
  const args = ev.args;
1241
- if (args?.summary) toolSummary = args.summary;
1242
- return;
1243
- }
1244
- if (ev.type === "tool_execution_end" && ev.toolCallId === stageCompleteCallId) return;
1245
- const r = consumePiEvent(ev, state, true);
1246
- for (const e of r.events) emit(e, rawRef);
1247
- if (r.responseText) lastResponseText = r.responseText;
1248
- });
1249
- const humanIter = turns[Symbol.asyncIterator]();
1250
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
1251
- let awaitingFirstReply = true;
1252
- let exited = "gate";
1253
- let pending = humanIter.next();
1254
- try {
1255
- await s.prompt(interactiveSeedText(ctx, false));
1256
- if (toolFired && wantsTool) exited = "tool";
1257
- for (; ; ) {
1258
- if (exited === "tool") break;
1259
- const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
1260
- awaitingFirstReply = false;
1261
- if (race.kind === "cancelled") {
1262
- exited = "cancelled";
1263
- break;
1264
- }
1265
- if (race.kind === "idle-timeout") {
1266
- exited = "timeout";
1267
- break;
1268
- }
1269
- if (race.kind === "turns-exhausted") {
1270
- exited = "gate";
1271
- break;
1272
- }
1273
- const texts = [race.value.text];
1274
- pending = humanIter.next();
1275
- for (; ; ) {
1276
- const more = await raceNextTurn(pending, COALESCE_MS3, signal);
1277
- if (more.kind === "turn") {
1278
- texts.push(more.value.text);
1279
- pending = humanIter.next();
1280
- continue;
1281
- }
1282
- if (more.kind === "cancelled") exited = "cancelled";
1283
- break;
1284
- }
1285
- if (exited === "cancelled") break;
1286
- await s.prompt(texts.join("\n"));
1287
- if (toolFired && wantsTool) {
1288
- exited = "tool";
1289
- break;
1290
- }
1291
- }
1292
- } catch (e) {
1293
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
1294
- exited = cancelled ? "cancelled" : "gate";
1295
- }
1296
- let status = "ok";
1297
- let summary = "";
1298
- if (exited === "tool") {
1299
- summary = toolSummary ?? "(stage marked complete)";
1300
- } else if (exited === "gate") {
1301
- lastResponseText = void 0;
1302
- try {
1303
- await s.prompt(SUMMARISE_PROMPT);
1304
- summary = (lastResponseText ?? "").trim() || "(no summary produced)";
1305
- } catch {
1306
- summary = "(no summary produced)";
1196
+ if (args?.summary) summary = args.summary;
1197
+ return;
1307
1198
  }
1308
- } else if (exited === "timeout") {
1309
- status = "timeout";
1310
- summary = "(stage timed out awaiting input)";
1311
- await this.cancel();
1312
- } else {
1313
- status = "fail";
1314
- summary = "(stage cancelled)";
1199
+ if (ev.type === "tool_execution_end" && ev.toolCallId === stageCompleteCallId) return;
1200
+ const r = consumePiEvent(ev, state, interactive);
1201
+ for (const e of r.events) hooks.emit(e, rawRef);
1202
+ if (r.responseText) responseText = r.responseText;
1203
+ if (r.isResult && r.status) status = r.status;
1204
+ });
1205
+ try {
1206
+ await s.prompt(text);
1207
+ } finally {
1208
+ unsub();
1315
1209
  }
1316
- unsub();
1317
- captureSessionId(s);
1318
- const costUsd = readSessionCost(s);
1319
1210
  return {
1320
- status,
1321
- summary,
1322
- ...sessionId ? { sessionId } : {},
1323
- ...costUsd !== void 0 ? { costUsd } : {}
1211
+ stageComplete,
1212
+ ...summary !== void 0 ? { summary } : {},
1213
+ ...responseText !== void 0 ? { responseText } : {},
1214
+ ...status !== void 0 ? { status } : {}
1324
1215
  };
1325
1216
  },
1326
- async summarise(ctx) {
1327
- if (!session) return "(no summary: session not established)";
1328
- const s = session;
1217
+ async summariseTurn() {
1329
1218
  if (s.agent) s.agent.state.tools = [];
1330
1219
  const state = newPiBufferState();
1331
1220
  let out2;
@@ -1335,7 +1224,6 @@ function createPiRunner(deps = {}) {
1335
1224
  });
1336
1225
  try {
1337
1226
  await s.prompt(SUMMARISE_PROMPT);
1338
- captureSessionId(s);
1339
1227
  return (out2 ?? "").trim() || "(no summary produced)";
1340
1228
  } catch (e) {
1341
1229
  return `(summary unavailable: ${e.message})`;
@@ -1343,6 +1231,72 @@ function createPiRunner(deps = {}) {
1343
1231
  unsub();
1344
1232
  }
1345
1233
  },
1234
+ cost() {
1235
+ const cost = s.getSessionStats?.()?.cost;
1236
+ return typeof cost === "number" ? cost : void 0;
1237
+ },
1238
+ dispose() {
1239
+ s.dispose();
1240
+ }
1241
+ };
1242
+ }
1243
+ function createPiRunner(deps = {}) {
1244
+ const createSession = deps.createSession ?? defaultCreatePiSession;
1245
+ const abortController = new AbortController();
1246
+ const signal = abortController.signal;
1247
+ let cancelled = false;
1248
+ let session;
1249
+ const openSession = async (ctx) => {
1250
+ if (!session) session = await createSession(ctx);
1251
+ return session;
1252
+ };
1253
+ let sessionDisposed = false;
1254
+ const disposeSession = () => {
1255
+ if (sessionDisposed || !session) return;
1256
+ sessionDisposed = true;
1257
+ try {
1258
+ session.dispose();
1259
+ } catch {
1260
+ }
1261
+ };
1262
+ return {
1263
+ runtime: "pi",
1264
+ async runBatch(ctx, onTrace) {
1265
+ const hooks = { emit: makeEmit("pi", onTrace), ask: defaultAsk2 };
1266
+ const s = await openSession(ctx);
1267
+ registerToolCallGate(s, ctx);
1268
+ const rt = makePiRuntimeSession(s, ctx, hooks, false);
1269
+ const result = await runBatchLoop(rt, ctx, hooks, { cancelled: () => cancelled });
1270
+ if (result.status !== "ok") disposeSession();
1271
+ return result;
1272
+ },
1273
+ async runInteractive(ctx, turns, onTrace) {
1274
+ const emit = makeEmit("pi", onTrace);
1275
+ const s = await openSession(ctx);
1276
+ registerToolCallGate(s, ctx);
1277
+ const makeSession = (hooks) => {
1278
+ s.setAskUserQuestionHandler?.((questions) => askQuestionsSequentially(questions, (q) => hooks.ask(q)));
1279
+ return makePiRuntimeSession(s, ctx, hooks, true);
1280
+ };
1281
+ const result = await runInteractiveLoop(ctx, turns, emit, makeSession, {
1282
+ signal,
1283
+ cancelled: () => cancelled,
1284
+ cancel: () => this.cancel(),
1285
+ instructionInSystemPrompt: false
1286
+ });
1287
+ disposeSession();
1288
+ return result;
1289
+ },
1290
+ async summarise(ctx) {
1291
+ if (!session) return "(no summary: session not established)";
1292
+ const rt = makePiRuntimeSession(session, ctx, { emit: () => {
1293
+ }, ask: defaultAsk2 }, true);
1294
+ try {
1295
+ return await rt.summariseTurn();
1296
+ } finally {
1297
+ disposeSession();
1298
+ }
1299
+ },
1346
1300
  async cancel() {
1347
1301
  if (cancelled) return;
1348
1302
  cancelled = true;
@@ -1351,23 +1305,31 @@ function createPiRunner(deps = {}) {
1351
1305
  await session?.abort();
1352
1306
  } catch {
1353
1307
  }
1354
- try {
1355
- session?.dispose();
1356
- } catch {
1357
- }
1308
+ disposeSession();
1358
1309
  }
1359
1310
  };
1360
1311
  }
1361
1312
  async function defaultCreatePiSession(ctx) {
1362
1313
  const spec = "@earendil-works/pi-coding-agent";
1363
1314
  const mod = await import(spec);
1364
- const { AuthStorage, ModelRegistry, SessionManager, createAgentSession, defineTool, resolveCliModel } = mod;
1365
- const authStorage = AuthStorage.create();
1366
- for (const [key, value] of Object.entries(ctx.runtimeEnv ?? {})) {
1367
- const provider = PROVIDER_BY_ENV[key];
1368
- if (provider) authStorage.setRuntimeApiKey(provider, value);
1369
- }
1370
- const modelRegistry = ModelRegistry.create(authStorage);
1315
+ const {
1316
+ AuthStorage,
1317
+ DefaultResourceLoader,
1318
+ ModelRegistry,
1319
+ SessionManager,
1320
+ SettingsManager,
1321
+ createAgentSession,
1322
+ defineTool,
1323
+ getAgentDir,
1324
+ resolveCliModel
1325
+ } = mod;
1326
+ const hint2 = readAuthHint(ctx);
1327
+ const configDir = createStageConfigDir();
1328
+ writeStageAuthFile(configDir, hint2);
1329
+ writeStageCustomProviders(configDir, hint2);
1330
+ const authStorage = AuthStorage.create(join4(configDir, "auth.json"));
1331
+ applyApiKeyAuth(hint2, ctx.runtimeEnv, authStorage);
1332
+ const modelRegistry = ModelRegistry.create(authStorage, join4(configDir, "models.json"));
1371
1333
  let model;
1372
1334
  if (ctx.config.model) {
1373
1335
  const resolved = resolveCliModel({ cliModel: ctx.config.model, modelRegistry });
@@ -1382,22 +1344,89 @@ async function defaultCreatePiSession(ctx) {
1382
1344
  parameters: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
1383
1345
  execute: async () => ({ content: [{ type: "text", text: "Stage marked complete." }], details: {} })
1384
1346
  });
1347
+ let askHandler;
1348
+ const askUserQuestion = defineTool({
1349
+ name: PI_ASK_USER_QUESTION_TOOL,
1350
+ label: "Ask the user a question",
1351
+ description: "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.",
1352
+ parameters: {
1353
+ type: "object",
1354
+ properties: {
1355
+ questions: {
1356
+ type: "array",
1357
+ items: {
1358
+ type: "object",
1359
+ properties: {
1360
+ question: { type: "string" },
1361
+ options: {
1362
+ type: "array",
1363
+ items: {
1364
+ type: "object",
1365
+ properties: { label: { type: "string" }, description: { type: "string" } },
1366
+ required: ["label"]
1367
+ }
1368
+ },
1369
+ multiSelect: { type: "boolean" }
1370
+ },
1371
+ required: ["question", "options"]
1372
+ }
1373
+ }
1374
+ },
1375
+ required: ["questions"]
1376
+ },
1377
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1378
+ execute: async (_toolCallId, params) => {
1379
+ const text = askHandler ? await askHandler(params.questions) : elicitOutcomeReply({ kind: "noreply" });
1380
+ return { content: [{ type: "text", text }], details: {} };
1381
+ }
1382
+ });
1383
+ let toolCallGate;
1384
+ const toolGateExtension = {
1385
+ name: "dahrk-tool-gate",
1386
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1387
+ factory: (pi) => {
1388
+ pi.on("tool_call", (event) => toolCallGate?.(event.toolName, event.input));
1389
+ }
1390
+ };
1391
+ const cwd = ctx.workspace.worktreePath;
1392
+ const agentDir = getAgentDir();
1393
+ const settingsManager = SettingsManager.create(cwd, agentDir);
1394
+ const brokeredMcp = buildBrokeredPiMcpServers(ctx);
1395
+ const extensionFactories = brokeredMcp ? [toolGateExtension, createBrokeredMcpExtension(brokeredMcp)] : [toolGateExtension];
1396
+ const resourceLoader = new DefaultResourceLoader({
1397
+ cwd,
1398
+ agentDir,
1399
+ settingsManager,
1400
+ extensionFactories
1401
+ });
1402
+ await resourceLoader.reload();
1385
1403
  const { session } = await createAgentSession({
1386
1404
  sessionManager: SessionManager.inMemory(ctx.workspace.worktreePath),
1387
1405
  authStorage,
1388
1406
  modelRegistry,
1389
- cwd: ctx.workspace.worktreePath,
1390
- customTools: [stageComplete],
1407
+ settingsManager,
1408
+ resourceLoader,
1409
+ cwd,
1410
+ customTools: [stageComplete, askUserQuestion],
1391
1411
  ...model ? { model } : {}
1392
1412
  });
1393
- return session;
1413
+ const piSession = session;
1414
+ const innerDispose = piSession.dispose.bind(piSession);
1415
+ piSession.dispose = () => {
1416
+ try {
1417
+ innerDispose();
1418
+ } finally {
1419
+ cleanupStageConfigDir(configDir);
1420
+ }
1421
+ };
1422
+ piSession.setAskUserQuestionHandler = (handler) => {
1423
+ askHandler = handler;
1424
+ };
1425
+ piSession.setToolCallGate = (gate) => {
1426
+ toolCallGate = gate;
1427
+ };
1428
+ return piSession;
1394
1429
  }
1395
- var PROVIDER_BY_ENV = {
1396
- ANTHROPIC_API_KEY: "anthropic",
1397
- OPENAI_API_KEY: "openai",
1398
- GEMINI_API_KEY: "google",
1399
- GOOGLE_API_KEY: "google"
1400
- };
1401
1430
  function modelFamily(id) {
1402
1431
  const last = id.split(".").pop() ?? id;
1403
1432
  return last.replace(/-v\d+:\d+$/, "").toLowerCase();
@@ -1410,15 +1439,294 @@ function pickAuthedModel(resolved, available) {
1410
1439
  return available.find((m) => modelFamily(m.id) === family) ?? resolved;
1411
1440
  }
1412
1441
 
1442
+ // ../../packages/executor-worktree/src/pi-container.ts
1443
+ import { spawn as nodeSpawn } from "child_process";
1444
+
1445
+ // ../../packages/executor-worktree/src/pi-rpc-client.ts
1446
+ import { StringDecoder } from "string_decoder";
1447
+ function createLineDecoder() {
1448
+ const decoder = new StringDecoder("utf8");
1449
+ let buffer = "";
1450
+ const drain = () => {
1451
+ const lines = [];
1452
+ for (; ; ) {
1453
+ const nl = buffer.indexOf("\n");
1454
+ if (nl === -1) break;
1455
+ let line = buffer.slice(0, nl);
1456
+ buffer = buffer.slice(nl + 1);
1457
+ if (line.endsWith("\r")) line = line.slice(0, -1);
1458
+ lines.push(line);
1459
+ }
1460
+ return lines;
1461
+ };
1462
+ return {
1463
+ push(chunk) {
1464
+ buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
1465
+ return drain();
1466
+ },
1467
+ end() {
1468
+ buffer += decoder.end();
1469
+ const lines = drain();
1470
+ if (buffer.length > 0) {
1471
+ const last = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
1472
+ buffer = "";
1473
+ lines.push(last);
1474
+ }
1475
+ return lines;
1476
+ }
1477
+ };
1478
+ }
1479
+ function deferred() {
1480
+ let resolve3;
1481
+ let reject;
1482
+ const promise = new Promise((res, rej) => {
1483
+ resolve3 = res;
1484
+ reject = rej;
1485
+ });
1486
+ return { promise, resolve: resolve3, reject };
1487
+ }
1488
+ var isResponse = (msg) => typeof msg === "object" && msg !== null && msg.type === "response";
1489
+ var PiRpcSession = class {
1490
+ #sessionId;
1491
+ #listeners = [];
1492
+ /** Command responses awaited by id (correlated via the optional `id` field). */
1493
+ #pendingResponses = /* @__PURE__ */ new Map();
1494
+ /** The in-flight `prompt()` resolver, settled on the next `agent_end`. */
1495
+ #pendingAgentEnd;
1496
+ #reqCounter = 0;
1497
+ #disposed = false;
1498
+ #child;
1499
+ #kill;
1500
+ constructor(child, options = {}) {
1501
+ this.#child = child;
1502
+ this.#kill = options.kill;
1503
+ const decoder = createLineDecoder();
1504
+ const onData = (chunk) => {
1505
+ for (const line of decoder.push(chunk)) if (line.length > 0) this.#onLine(line);
1506
+ };
1507
+ child.stdout?.on("data", onData);
1508
+ child.stdout?.on("end", () => {
1509
+ for (const line of decoder.end()) if (line.length > 0) this.#onLine(line);
1510
+ });
1511
+ }
1512
+ get sessionId() {
1513
+ return this.#sessionId;
1514
+ }
1515
+ subscribe(listener) {
1516
+ this.#listeners.push(listener);
1517
+ return () => {
1518
+ this.#listeners = this.#listeners.filter((l) => l !== listener);
1519
+ };
1520
+ }
1521
+ async prompt(text) {
1522
+ if (this.#disposed) throw new Error("pi rpc session disposed");
1523
+ const agentEnd = deferred();
1524
+ this.#pendingAgentEnd = agentEnd;
1525
+ const ack = await this.#send("prompt", { message: text });
1526
+ if (ack.success === false) {
1527
+ this.#pendingAgentEnd = void 0;
1528
+ throw new Error(ack.error ?? "prompt rejected");
1529
+ }
1530
+ await agentEnd.promise;
1531
+ }
1532
+ async abort() {
1533
+ if (this.#disposed) return;
1534
+ await this.#send("abort", {});
1535
+ }
1536
+ /** Best-effort refresh of the resume token from `get_state`; swallows a failed lookup. */
1537
+ async getState() {
1538
+ if (this.#disposed) return;
1539
+ try {
1540
+ const res = await this.#send("get_state", {});
1541
+ const id = res.data?.sessionId;
1542
+ if (typeof id === "string" && id) this.#sessionId = id;
1543
+ } catch {
1544
+ }
1545
+ }
1546
+ dispose() {
1547
+ if (this.#disposed) return;
1548
+ this.#disposed = true;
1549
+ try {
1550
+ this.#child.stdin?.end();
1551
+ } catch {
1552
+ }
1553
+ const err = new Error("pi rpc session disposed");
1554
+ for (const d of this.#pendingResponses.values()) d.reject(err);
1555
+ this.#pendingResponses.clear();
1556
+ if (this.#pendingAgentEnd) {
1557
+ this.#pendingAgentEnd.reject(err);
1558
+ this.#pendingAgentEnd = void 0;
1559
+ }
1560
+ if (this.#kill) {
1561
+ const kill = this.#kill;
1562
+ this.#kill = void 0;
1563
+ void kill();
1564
+ }
1565
+ }
1566
+ /** Write a command as one LF-terminated JSON line and await its correlated response. */
1567
+ #send(type, fields) {
1568
+ const id = `req-${++this.#reqCounter}`;
1569
+ const d = deferred();
1570
+ this.#pendingResponses.set(id, d);
1571
+ try {
1572
+ this.#child.stdin?.write(`${JSON.stringify({ id, type, ...fields })}
1573
+ `);
1574
+ } catch (e) {
1575
+ this.#pendingResponses.delete(id);
1576
+ d.reject(e);
1577
+ }
1578
+ return d.promise;
1579
+ }
1580
+ #onLine(line) {
1581
+ let msg;
1582
+ try {
1583
+ msg = JSON.parse(line);
1584
+ } catch {
1585
+ return;
1586
+ }
1587
+ if (isResponse(msg)) {
1588
+ const id = msg.id;
1589
+ if (typeof id === "string") {
1590
+ const pending = this.#pendingResponses.get(id);
1591
+ if (pending) {
1592
+ this.#pendingResponses.delete(id);
1593
+ pending.resolve(msg);
1594
+ }
1595
+ }
1596
+ return;
1597
+ }
1598
+ const ev = parsePiEvent(msg);
1599
+ if (!ev) return;
1600
+ for (const l of [...this.#listeners]) l(ev);
1601
+ if (ev.type === "agent_end" && this.#pendingAgentEnd) {
1602
+ const d = this.#pendingAgentEnd;
1603
+ this.#pendingAgentEnd = void 0;
1604
+ d.resolve();
1605
+ }
1606
+ }
1607
+ };
1608
+
1609
+ // ../../packages/executor-worktree/src/pi-container.ts
1610
+ var DEFAULT_IMAGE = process.env.DAHRK_PI_IMAGE ?? process.env.SKAKEL_PI_IMAGE ?? "dahrk/pi:latest";
1611
+ var _seq = 0;
1612
+ function createContainerPiSession(opts = {}) {
1613
+ const {
1614
+ image = DEFAULT_IMAGE,
1615
+ scratchDir: optsScratchDir,
1616
+ spawn: spawnFn = nodeSpawn,
1617
+ onStderr = (line) => void process.stderr.write(`pi-container: ${line}
1618
+ `)
1619
+ } = opts;
1620
+ return async (ctx) => {
1621
+ const containerName = `dahrk-pi-${Date.now()}-${++_seq}`;
1622
+ const resolvedScratchDir = optsScratchDir ?? ctx.workspace.scratchPath;
1623
+ const mountArgs = resolvedScratchDir ? ["-v", `${resolvedScratchDir}:/dahrk/scratch`] : [];
1624
+ const envArgs = [];
1625
+ for (const [k, v] of Object.entries(ctx.runtimeEnv ?? {})) {
1626
+ envArgs.push("-e", `${k}=${v}`);
1627
+ }
1628
+ const child = spawnFn(
1629
+ "docker",
1630
+ ["run", "-i", "--rm", "--name", containerName, ...mountArgs, ...envArgs, image, "pi", "--mode", "rpc"],
1631
+ { stdio: ["pipe", "pipe", "pipe"] }
1632
+ );
1633
+ child.stderr?.setEncoding("utf8");
1634
+ let stderrBuf = "";
1635
+ child.stderr?.on("data", (chunk) => {
1636
+ stderrBuf += chunk;
1637
+ const lines = stderrBuf.split("\n");
1638
+ stderrBuf = lines.pop() ?? "";
1639
+ for (const line of lines) if (line.trim()) onStderr(line);
1640
+ });
1641
+ child.stderr?.on("end", () => {
1642
+ if (stderrBuf.trim()) onStderr(stderrBuf);
1643
+ stderrBuf = "";
1644
+ });
1645
+ const killContainer = () => new Promise((resolve3) => {
1646
+ const killer = spawnFn("docker", ["kill", containerName], { stdio: "ignore" });
1647
+ killer.on("exit", () => resolve3());
1648
+ killer.on("error", () => resolve3());
1649
+ });
1650
+ return new PiRpcSession(child, { kill: killContainer });
1651
+ };
1652
+ }
1653
+ function createIsolatedPiRunner(opts = {}) {
1654
+ return createPiRunner({ createSession: createContainerPiSession(opts) });
1655
+ }
1656
+
1413
1657
  // ../../packages/executor-worktree/src/git-service.ts
1414
1658
  import { execFileSync } from "child_process";
1415
- import { existsSync, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
1416
- import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
1417
- import { basename, dirname, isAbsolute, join as join3 } from "path";
1659
+ import { existsSync, mkdirSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1660
+ import { homedir as homedir2, tmpdir as tmpdir3 } from "os";
1661
+ import { basename, dirname, isAbsolute, join as join5 } from "path";
1662
+
1663
+ // ../../packages/executor-worktree/src/footprint.ts
1664
+ function resolveRenamePath(raw) {
1665
+ const brace = /\{(.*?) => (.*?)\}/;
1666
+ if (brace.test(raw)) {
1667
+ return raw.replace(brace, (_m, _old, next) => next).replace(/\/{2,}/g, "/");
1668
+ }
1669
+ const arrow2 = raw.indexOf(" => ");
1670
+ if (arrow2 !== -1) return raw.slice(arrow2 + " => ".length);
1671
+ return raw;
1672
+ }
1673
+ function parseNumstat(raw) {
1674
+ const entries = [];
1675
+ for (const line of raw.split("\n")) {
1676
+ if (line.trim() === "") continue;
1677
+ const firstTab = line.indexOf(" ");
1678
+ const secondTab = line.indexOf(" ", firstTab + 1);
1679
+ if (firstTab === -1 || secondTab === -1) continue;
1680
+ const addedRaw = line.slice(0, firstTab);
1681
+ const removedRaw = line.slice(firstTab + 1, secondTab);
1682
+ const pathRaw = line.slice(secondTab + 1);
1683
+ const binary = addedRaw === "-" && removedRaw === "-";
1684
+ entries.push({
1685
+ path: resolveRenamePath(pathRaw),
1686
+ added: binary ? 0 : Number.parseInt(addedRaw, 10) || 0,
1687
+ removed: binary ? 0 : Number.parseInt(removedRaw, 10) || 0,
1688
+ binary
1689
+ });
1690
+ }
1691
+ return entries;
1692
+ }
1693
+ function topLevel(path) {
1694
+ const slash = path.indexOf("/");
1695
+ return slash === -1 ? path : path.slice(0, slash);
1696
+ }
1697
+ function deriveFootprint(entries, opts) {
1698
+ let added = 0;
1699
+ let removed = 0;
1700
+ const scopeSet = /* @__PURE__ */ new Set();
1701
+ const allPaths = [];
1702
+ for (const e of entries) {
1703
+ added += e.added;
1704
+ removed += e.removed;
1705
+ scopeSet.add(topLevel(e.path));
1706
+ allPaths.push(e.path);
1707
+ }
1708
+ const changedPathsTruncated = allPaths.length > opts.cap;
1709
+ return {
1710
+ numstat: { files: entries.length, added, removed },
1711
+ scope: [...scopeSet].sort(),
1712
+ changedPaths: changedPathsTruncated ? allPaths.slice(0, opts.cap) : allPaths,
1713
+ changedPathsTruncated
1714
+ };
1715
+ }
1716
+
1717
+ // ../../packages/executor-worktree/src/git-service.ts
1418
1718
  var noopLogger = { info: () => {
1419
1719
  }, warn: () => {
1420
1720
  } };
1421
- var SCRATCH_DIR = ".skakel/scratch";
1721
+ var SCRATCH_DIR = ".dahrk/scratch";
1722
+ var CHANGED_PATHS_CAP = 100;
1723
+ var DEFAULT_MERGE_RESOLVE_RULES = [
1724
+ { path: "pnpm-lock.yaml", strategy: "theirs" },
1725
+ { path: "package-lock.json", strategy: "theirs" },
1726
+ { path: "yarn.lock", strategy: "theirs" },
1727
+ { path: "CHANGELOG.md", strategy: "union" },
1728
+ { path: "CHANGELOG.internal.md", strategy: "union" }
1729
+ ];
1422
1730
  function parseOwnerRepo(gitUrl) {
1423
1731
  const m = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(gitUrl.trim());
1424
1732
  return m ? `${m[1]}/${m[2]}` : void 0;
@@ -1428,10 +1736,10 @@ function sanitizeBranchName(name) {
1428
1736
  return name.replace(/[`~^:?*[\]\\@{}\s]/g, "-").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/\.lock(\/|$)/g, "$1").replace(/^[.\-/]+/, "").replace(/[.\-/]+$/, "").replace(/-{2,}/g, "-");
1429
1737
  }
1430
1738
  function resolveWorktreesDir(override) {
1431
- return override ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join3(homedir2(), ".dahrk", "worktrees");
1739
+ return override ?? process.env.DAHRK_WORKTREES_DIR ?? process.env.SKAKEL_WORKTREES_DIR ?? join5(homedir2(), ".dahrk", "worktrees");
1432
1740
  }
1433
1741
  function resolveMirrorsDir(override) {
1434
- return override ?? process.env.DAHRK_MIRRORS_DIR ?? process.env.SKAKEL_MIRRORS_DIR ?? join3(homedir2(), ".dahrk", "mirrors");
1742
+ return override ?? process.env.DAHRK_MIRRORS_DIR ?? process.env.SKAKEL_MIRRORS_DIR ?? join5(homedir2(), ".dahrk", "mirrors");
1435
1743
  }
1436
1744
  function createGitService(opts = {}) {
1437
1745
  const worktreesDir = resolveWorktreesDir(opts.worktreesDir);
@@ -1467,15 +1775,17 @@ function createGitService(opts = {}) {
1467
1775
  return (stderr?.trim() || err?.message || String(e)).split("\n")[0] ?? String(e);
1468
1776
  };
1469
1777
  const excludeScratchLocally = (worktreePath) => {
1470
- const entry = `${SCRATCH_DIR}/`;
1778
+ const entries = [`${SCRATCH_DIR}/`];
1471
1779
  try {
1472
1780
  const rel = git(worktreePath, ["rev-parse", "--git-path", "info/exclude"]).trim();
1473
- const excludePath = isAbsolute(rel) ? rel : join3(worktreePath, rel);
1781
+ const excludePath = isAbsolute(rel) ? rel : join5(worktreePath, rel);
1474
1782
  const existing = existsSync(excludePath) ? readFileSync2(excludePath, "utf-8") : "";
1475
- if (existing.split("\n").some((l) => l.trim() === entry)) return;
1783
+ const lines = existing.split("\n").map((l) => l.trim());
1784
+ const toAdd = entries.filter((e) => !lines.includes(e));
1785
+ if (toAdd.length === 0) return;
1476
1786
  mkdirSync(dirname(excludePath), { recursive: true });
1477
1787
  const sep4 = existing && !existing.endsWith("\n") ? "\n" : "";
1478
- writeFileSync(excludePath, `${existing}${sep4}${entry}
1788
+ writeFileSync2(excludePath, `${existing}${sep4}${toAdd.join("\n")}
1479
1789
  `);
1480
1790
  } catch (e) {
1481
1791
  log.warn(`could not set worktree scratch exclude at ${worktreePath}: ${e.message}`);
@@ -1499,13 +1809,53 @@ function createGitService(opts = {}) {
1499
1809
  }
1500
1810
  return { headSha: git(worktreePath, ["rev-parse", "HEAD"]).trim(), dirty };
1501
1811
  };
1812
+ const unmergedPaths = (worktreePath) => git(worktreePath, ["diff", "--name-only", "--diff-filter=U"]).split("\n").map((l) => l.trim()).filter(Boolean);
1813
+ const unionMerge = (worktreePath, p) => {
1814
+ const stage = (n) => {
1815
+ try {
1816
+ return git(worktreePath, ["show", `:${n}:${p}`]);
1817
+ } catch {
1818
+ return "";
1819
+ }
1820
+ };
1821
+ const dir = mkdtempSync2(join5(tmpdir3(), "dahrk-union-"));
1822
+ try {
1823
+ const ours = join5(dir, "ours");
1824
+ const base = join5(dir, "base");
1825
+ const theirs = join5(dir, "theirs");
1826
+ writeFileSync2(ours, stage(2));
1827
+ writeFileSync2(base, stage(1));
1828
+ writeFileSync2(theirs, stage(3));
1829
+ const merged = git(worktreePath, ["merge-file", "-p", "--union", ours, base, theirs]);
1830
+ writeFileSync2(join5(worktreePath, p), merged);
1831
+ } finally {
1832
+ rmSync2(dir, { recursive: true, force: true });
1833
+ }
1834
+ };
1835
+ const preResolveConflicts = (worktreePath, rules) => {
1836
+ gitOk2(worktreePath, ["rerere"]);
1837
+ for (const p of unmergedPaths(worktreePath)) {
1838
+ const rule = rules.find((r) => r.path === p || basename(p) === r.path);
1839
+ if (!rule) continue;
1840
+ try {
1841
+ if (rule.strategy === "union") {
1842
+ unionMerge(worktreePath, p);
1843
+ } else {
1844
+ git(worktreePath, ["checkout", `--${rule.strategy}`, "--", p]);
1845
+ }
1846
+ git(worktreePath, ["add", "--", p]);
1847
+ } catch (e) {
1848
+ log.warn(`pre-resolve of ${p} (${rule.strategy}) failed: ${e.message}`);
1849
+ }
1850
+ }
1851
+ };
1502
1852
  const setupAuth = (token) => {
1503
- const dir = mkdtempSync(join3(tmpdir2(), "dahrk-cred-"));
1504
- const script = join3(dir, "askpass.sh");
1505
- writeFileSync(script, '#!/bin/sh\nprintf "%s" "$DAHRK_GIT_TOKEN"\n', { mode: 448 });
1853
+ const dir = mkdtempSync2(join5(tmpdir3(), "dahrk-cred-"));
1854
+ const script = join5(dir, "askpass.sh");
1855
+ writeFileSync2(script, '#!/bin/sh\nprintf "%s" "$DAHRK_GIT_TOKEN"\n', { mode: 448 });
1506
1856
  return {
1507
1857
  env: { ...process.env, GIT_ASKPASS: script, DAHRK_GIT_TOKEN: token, GIT_TERMINAL_PROMPT: "0" },
1508
- cleanup: () => rmSync(dir, { recursive: true, force: true })
1858
+ cleanup: () => rmSync2(dir, { recursive: true, force: true })
1509
1859
  };
1510
1860
  };
1511
1861
  const netEnv = (authEnv) => authEnv ?? { ...process.env, GIT_TERMINAL_PROMPT: "0" };
@@ -1518,7 +1868,7 @@ function createGitService(opts = {}) {
1518
1868
  cleanup: () => auth?.cleanup()
1519
1869
  };
1520
1870
  };
1521
- const mirrorPathFor = (repoId) => join3(mirrorsDir, sanitizeBranchName(repoId));
1871
+ const mirrorPathFor = (repoId) => join5(mirrorsDir, sanitizeBranchName(repoId));
1522
1872
  const listWorktrees = (mirror) => {
1523
1873
  let out2;
1524
1874
  try {
@@ -1543,7 +1893,7 @@ function createGitService(opts = {}) {
1543
1893
  } catch (e) {
1544
1894
  log.warn(`git worktree remove failed for ${worktreePath}: ${e.message}`);
1545
1895
  }
1546
- rmSync(worktreePath, { recursive: true, force: true });
1896
+ rmSync2(worktreePath, { recursive: true, force: true });
1547
1897
  gitOk2(mirror, ["worktree", "prune"]);
1548
1898
  };
1549
1899
  const TRACKING_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
@@ -1630,18 +1980,19 @@ function createGitService(opts = {}) {
1630
1980
  baseBranch: spec.baseBranch,
1631
1981
  branch: sanitizeBranchName(spec.branch ?? `dahrk/${spec.runId}`),
1632
1982
  worktreePath,
1633
- scratchPath: join3(worktreePath, ".skakel", "scratch")
1983
+ scratchPath: join5(worktreePath, SCRATCH_DIR)
1634
1984
  });
1635
1985
  return {
1636
1986
  worktreesDir,
1637
1987
  async createWorktree(spec) {
1638
1988
  const { repoId, gitUrl, baseBranch, runId } = spec;
1639
1989
  const branchName = sanitizeBranchName(spec.branch ?? `dahrk/${runId}`);
1640
- const worktreePath = join3(worktreesDir, runId);
1990
+ const worktreePath = join5(worktreesDir, runId);
1641
1991
  mkdirSync(worktreesDir, { recursive: true });
1642
1992
  if (existsSync(worktreePath) && gitOk2(worktreePath, ["rev-parse", "--git-dir"])) {
1643
1993
  log.info(`reusing existing worktree at ${worktreePath}`);
1644
- mkdirSync(join3(worktreePath, ".skakel", "scratch"), { recursive: true });
1994
+ mkdirSync(join5(worktreePath, SCRATCH_DIR), { recursive: true });
1995
+ excludeScratchLocally(worktreePath);
1645
1996
  return refFor(spec, worktreePath);
1646
1997
  }
1647
1998
  const auth = spec.credentialToken ? setupAuth(spec.credentialToken) : void 0;
@@ -1677,7 +2028,8 @@ function createGitService(opts = {}) {
1677
2028
  if (!gitOk2(worktreePath, ["rev-parse", "--verify", "-q", "HEAD"])) {
1678
2029
  throw new Error(`base '${baseBranch}' did not materialise into ${worktreePath} (unborn HEAD)`);
1679
2030
  }
1680
- mkdirSync(join3(worktreePath, ".skakel", "scratch"), { recursive: true });
2031
+ mkdirSync(join5(worktreePath, SCRATCH_DIR), { recursive: true });
2032
+ excludeScratchLocally(worktreePath);
1681
2033
  return refFor(spec, worktreePath);
1682
2034
  },
1683
2035
  async commitAndPush(ref, opts2) {
@@ -1700,6 +2052,7 @@ function createGitService(opts = {}) {
1700
2052
  const { remote, authEnv, cleanup } = resolveRemoteAuth(ref.gitUrl, opts2.credentialToken);
1701
2053
  let pushed = false;
1702
2054
  let integration;
2055
+ let footprint;
1703
2056
  try {
1704
2057
  let fetched = false;
1705
2058
  if (opts2.base) {
@@ -1719,12 +2072,22 @@ function createGitService(opts = {}) {
1719
2072
  if (!delta.some((p) => !isScratchPath(p))) {
1720
2073
  return { headSha, pushed: false, nothingToCommit: true, commitsAhead, integration: "noop" };
1721
2074
  }
2075
+ const numstatEntries = parseNumstat(git(worktreePath, ["diff", "--numstat", "FETCH_HEAD...HEAD"])).filter(
2076
+ (e) => !isScratchPath(e.path)
2077
+ );
2078
+ if (numstatEntries.length > 0) {
2079
+ footprint = deriveFootprint(numstatEntries, { cap: CHANGED_PATHS_CAP });
2080
+ }
1722
2081
  try {
1723
2082
  git(worktreePath, [
1724
2083
  "-c",
1725
2084
  `user.name=${authorName}`,
1726
2085
  "-c",
1727
2086
  `user.email=${authorEmail}`,
2087
+ "-c",
2088
+ "rerere.enabled=true",
2089
+ "-c",
2090
+ "rerere.autoupdate=true",
1728
2091
  "merge",
1729
2092
  "--no-edit",
1730
2093
  "FETCH_HEAD"
@@ -1734,15 +2097,30 @@ function createGitService(opts = {}) {
1734
2097
  } catch (mergeErr) {
1735
2098
  const inMerge = gitOk2(worktreePath, ["rev-parse", "--verify", "-q", "MERGE_HEAD"]);
1736
2099
  if (inMerge) {
1737
- const conflictFiles = git(worktreePath, ["diff", "--name-only", "--diff-filter=U"]).split("\n").map((l) => l.trim()).filter(Boolean);
1738
- git(worktreePath, ["merge", "--abort"]);
1739
- return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "conflict", conflictFiles };
1740
- }
1741
- const msg = mergeErr.message;
1742
- if (/unrelated histories|refusing to merge/i.test(msg)) {
1743
- return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "diverged" };
2100
+ preResolveConflicts(worktreePath, opts2.mergeResolve ?? DEFAULT_MERGE_RESOLVE_RULES);
2101
+ const conflictFiles = unmergedPaths(worktreePath);
2102
+ if (conflictFiles.length === 0) {
2103
+ git(worktreePath, [
2104
+ "-c",
2105
+ `user.name=${authorName}`,
2106
+ "-c",
2107
+ `user.email=${authorEmail}`,
2108
+ "commit",
2109
+ "--no-edit"
2110
+ ]);
2111
+ integration = "clean";
2112
+ headSha = git(worktreePath, ["rev-parse", "HEAD"]).trim();
2113
+ } else {
2114
+ git(worktreePath, ["merge", "--abort"]);
2115
+ return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "conflict", conflictFiles, ...footprint ? { footprint } : {} };
2116
+ }
2117
+ } else {
2118
+ const msg = mergeErr.message;
2119
+ if (/unrelated histories|refusing to merge/i.test(msg)) {
2120
+ return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "diverged" };
2121
+ }
2122
+ throw mergeErr;
1744
2123
  }
1745
- throw mergeErr;
1746
2124
  }
1747
2125
  }
1748
2126
  git(worktreePath, ["push", remote, `HEAD:refs/heads/${branch}`], netEnv(authEnv));
@@ -1750,7 +2128,7 @@ function createGitService(opts = {}) {
1750
2128
  } finally {
1751
2129
  cleanup();
1752
2130
  }
1753
- return { headSha, pushed, nothingToCommit: !dirty, commitsAhead, ...integration ? { integration } : {} };
2131
+ return { headSha, pushed, nothingToCommit: !dirty, commitsAhead, ...integration ? { integration } : {}, ...footprint ? { footprint } : {} };
1754
2132
  },
1755
2133
  async backupPush(ref, opts2) {
1756
2134
  const { worktreePath } = ref;
@@ -1805,10 +2183,10 @@ function createGitService(opts = {}) {
1805
2183
  return void 0;
1806
2184
  }
1807
2185
  };
1808
- const tmp = mkdtempSync(join3(tmpdir2(), "dahrk-pr-"));
1809
- const bodyFile = join3(tmp, "body.md");
2186
+ const tmp = mkdtempSync2(join5(tmpdir3(), "dahrk-pr-"));
2187
+ const bodyFile = join5(tmp, "body.md");
1810
2188
  try {
1811
- writeFileSync(bodyFile, opts2.body ?? "");
2189
+ writeFileSync2(bodyFile, opts2.body ?? "");
1812
2190
  try {
1813
2191
  gh(worktreePath, [
1814
2192
  "pr",
@@ -1830,7 +2208,7 @@ function createGitService(opts = {}) {
1830
2208
  }
1831
2209
  return readBack() ?? { prError: "gh pr create reported success but no PR was found" };
1832
2210
  } finally {
1833
- rmSync(tmp, { recursive: true, force: true });
2211
+ rmSync2(tmp, { recursive: true, force: true });
1834
2212
  }
1835
2213
  },
1836
2214
  async teardownWorktree(ref) {
@@ -1841,16 +2219,20 @@ function createGitService(opts = {}) {
1841
2219
 
1842
2220
  // ../../packages/executor-worktree/src/worktree-reaper.ts
1843
2221
  import { execFileSync as execFileSync2 } from "child_process";
1844
- import { existsSync as existsSync2, readdirSync, realpathSync, rmSync as rmSync2, statSync } from "fs";
1845
- import { join as join4 } from "path";
2222
+ import { existsSync as existsSync2, readdirSync, realpathSync, rmSync as rmSync3, statSync } from "fs";
2223
+ import { join as join6 } from "path";
1846
2224
  var MINUTE = 6e4;
1847
2225
  var HOUR = 60 * MINUTE;
2226
+ var DAY = 24 * HOUR;
1848
2227
  var DEFAULTS = {
1849
2228
  // Deliberately non-optional defaults. "No policy configured" used to mean "never collect anything",
1850
2229
  // which is precisely how the disk reached 65 GB. Absent config must mean sane collection, not none.
1851
2230
  maxRuns: 20,
1852
2231
  maxIdleMs: 6 * HOUR,
1853
- activityGraceMs: 30 * MINUTE
2232
+ activityGraceMs: 30 * MINUTE,
2233
+ // 14 days: long enough that an operator who missed the park log can still recover unpushed work, but
2234
+ // bounded so parked tips cannot pile up for ever. Cited verbatim in docs/logging.md - keep in step.
2235
+ salvageTtlMs: 14 * DAY
1854
2236
  };
1855
2237
  var noop = { info: () => {
1856
2238
  }, warn: () => {
@@ -1872,7 +2254,7 @@ var canonical = (p) => {
1872
2254
  }
1873
2255
  };
1874
2256
  function lastUsedMs(worktreePath) {
1875
- const candidates = [join4(worktreePath, ".skakel", "scratch", "state.json"), worktreePath];
2257
+ const candidates = [join6(worktreePath, ".dahrk", "scratch", "state.json"), worktreePath];
1876
2258
  let newest = 0;
1877
2259
  for (const p of candidates) {
1878
2260
  try {
@@ -1891,11 +2273,43 @@ function registeredWorktrees(mirror) {
1891
2273
  }
1892
2274
  return out2.split("\n").filter((l) => l.startsWith("worktree ")).map((l) => canonical(l.slice(9).trim())).filter((p) => p && p !== canonical(mirror));
1893
2275
  }
2276
+ function salvageParkedMs(mirror, ref) {
2277
+ try {
2278
+ return statSync(join6(mirror, ref)).mtimeMs;
2279
+ } catch {
2280
+ }
2281
+ try {
2282
+ const ts = gitOut(mirror, ["log", "-g", "--format=%ct", "-1", ref]).trim();
2283
+ if (ts) return Number(ts) * 1e3;
2284
+ } catch {
2285
+ }
2286
+ return void 0;
2287
+ }
2288
+ function sweepSalvageRefs(mirror, ttlMs, dryRun, now, log) {
2289
+ let refs;
2290
+ try {
2291
+ refs = gitOut(mirror, ["for-each-ref", "--format=%(refname)", "refs/dahrk/salvage/"]).split("\n").map((s) => s.trim()).filter(Boolean);
2292
+ } catch {
2293
+ return 0;
2294
+ }
2295
+ let parked = 0;
2296
+ for (const ref of refs) {
2297
+ const at = salvageParkedMs(mirror, ref);
2298
+ const expired = at !== void 0 && now - at > ttlMs;
2299
+ if (expired && !dryRun) {
2300
+ if (!gitOk(mirror, ["update-ref", "-d", ref])) log.warn(`reaper: could not expire salvage ref ${ref}`);
2301
+ continue;
2302
+ }
2303
+ if (expired) log.info(`reaper (dry-run): would expire salvage ref ${ref}`);
2304
+ parked++;
2305
+ }
2306
+ return parked;
2307
+ }
1894
2308
  function createWorktreeReaper(opts) {
1895
2309
  const log = opts.logger ?? noop;
1896
2310
  const mirrors = () => {
1897
2311
  try {
1898
- return readdirSync(opts.mirrorsDir).map((d) => join4(opts.mirrorsDir, d)).filter((m) => gitOk(m, ["rev-parse", "--git-dir"]));
2312
+ return readdirSync(opts.mirrorsDir).map((d) => join6(opts.mirrorsDir, d)).filter((m) => gitOk(m, ["rev-parse", "--git-dir"]));
1899
2313
  } catch {
1900
2314
  return [];
1901
2315
  }
@@ -1909,8 +2323,9 @@ function createWorktreeReaper(opts) {
1909
2323
  const maxRuns = policy.maxRuns ?? DEFAULTS.maxRuns;
1910
2324
  const maxIdleMs = policy.maxIdleMs ?? DEFAULTS.maxIdleMs;
1911
2325
  const graceMs = policy.activityGraceMs ?? DEFAULTS.activityGraceMs;
2326
+ const salvageTtlMs = policy.salvageTtlMs ?? DEFAULTS.salvageTtlMs;
1912
2327
  const dryRun = policy.dryRun ?? false;
1913
- const report = { scanned: 0, reaped: [], skipped: 0, errors: [] };
2328
+ const report = { scanned: 0, reaped: [], skipped: 0, salvagedRefs: 0, errors: [] };
1914
2329
  const now = Date.now();
1915
2330
  const registered = /* @__PURE__ */ new Map();
1916
2331
  for (const m of mirrors()) {
@@ -1919,7 +2334,7 @@ function createWorktreeReaper(opts) {
1919
2334
  }
1920
2335
  const onDisk = (() => {
1921
2336
  try {
1922
- return readdirSync(opts.worktreesDir).map((d) => canonical(join4(opts.worktreesDir, d)));
2337
+ return readdirSync(opts.worktreesDir).map((d) => canonical(join6(opts.worktreesDir, d)));
1923
2338
  } catch {
1924
2339
  return [];
1925
2340
  }
@@ -1962,7 +2377,7 @@ function createWorktreeReaper(opts) {
1962
2377
  if (d.mirror) {
1963
2378
  gitOk(d.mirror, ["worktree", "remove", "--force", d.path]);
1964
2379
  }
1965
- rmSync2(d.path, { recursive: true, force: true });
2380
+ rmSync3(d.path, { recursive: true, force: true });
1966
2381
  if (d.mirror) gitOk(d.mirror, ["worktree", "prune"]);
1967
2382
  report.reaped.push({ runId: d.runId, path: d.path, reason: d.reason });
1968
2383
  } catch (e) {
@@ -1972,6 +2387,14 @@ function createWorktreeReaper(opts) {
1972
2387
  if (report.reaped.length) {
1973
2388
  log.info(`reaper: reaped ${report.reaped.length} worktree(s), skipped ${report.skipped}`);
1974
2389
  }
2390
+ for (const m of registered.keys()) {
2391
+ report.salvagedRefs += sweepSalvageRefs(m, salvageTtlMs, dryRun, now, log);
2392
+ }
2393
+ if (report.salvagedRefs) {
2394
+ log.info(
2395
+ `reaper: ${report.salvagedRefs} salvage ref(s) parked, expire ${Math.round(salvageTtlMs / DAY)}d after parking`
2396
+ );
2397
+ }
1975
2398
  return report;
1976
2399
  }
1977
2400
  };
@@ -1979,20 +2402,20 @@ function createWorktreeReaper(opts) {
1979
2402
 
1980
2403
  // ../../packages/executor-worktree/src/trace-writer.ts
1981
2404
  import { createHash } from "crypto";
1982
- import { appendFileSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1983
- import { join as join5 } from "path";
2405
+ import { appendFileSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
2406
+ import { join as join7 } from "path";
1984
2407
  var DEFAULT_SPILL_BYTES = 8192;
1985
2408
  function createTraceWriter(scratchPath, meta, opts = {}) {
1986
2409
  const spillBytes = opts.spillBytes ?? DEFAULT_SPILL_BYTES;
1987
- const dir = join5(scratchPath, "traces", meta.stageId, `attempt-${meta.attempt}`);
1988
- mkdirSync2(join5(dir, "blobs"), { recursive: true });
1989
- mkdirSync2(join5(dir, "raw"), { recursive: true });
1990
- const tracePath = join5(dir, "trace.jsonl");
1991
- const metaPath = join5(dir, "meta.json");
2410
+ const dir = join7(scratchPath, "traces", meta.stageId, `attempt-${meta.attempt}`);
2411
+ mkdirSync2(join7(dir, "blobs"), { recursive: true });
2412
+ mkdirSync2(join7(dir, "raw"), { recursive: true });
2413
+ const tracePath = join7(dir, "trace.jsonl");
2414
+ const metaPath = join7(dir, "meta.json");
1992
2415
  let current = { ...meta };
1993
2416
  let nextSeq = 0;
1994
2417
  let rawCount = 0;
1995
- writeFileSync2(metaPath, JSON.stringify(current, null, 2));
2418
+ writeFileSync3(metaPath, JSON.stringify(current, null, 2));
1996
2419
  const tooBig = (value) => {
1997
2420
  const s = typeof value === "string" ? value : JSON.stringify(value ?? "");
1998
2421
  return s.length > spillBytes;
@@ -2000,8 +2423,8 @@ function createTraceWriter(scratchPath, meta, opts = {}) {
2000
2423
  const spillValue = (value) => {
2001
2424
  const data = typeof value === "string" ? value : JSON.stringify(value);
2002
2425
  const sha = createHash("sha256").update(data).digest("hex");
2003
- writeFileSync2(join5(dir, "blobs", sha), data);
2004
- return join5("blobs", sha);
2426
+ writeFileSync3(join7(dir, "blobs", sha), data);
2427
+ return join7("blobs", sha);
2005
2428
  };
2006
2429
  const spill = (event) => {
2007
2430
  if (event.type === "thought" && event.text !== void 0 && tooBig(event.text)) {
@@ -2031,13 +2454,13 @@ function createTraceWriter(scratchPath, meta, opts = {}) {
2031
2454
  return written;
2032
2455
  },
2033
2456
  writeRaw(record) {
2034
- const rel = join5("raw", `${rawCount++}.json`);
2035
- writeFileSync2(join5(dir, rel), JSON.stringify(record, null, 2));
2457
+ const rel = join7("raw", `${rawCount++}.json`);
2458
+ writeFileSync3(join7(dir, rel), JSON.stringify(record, null, 2));
2036
2459
  return rel;
2037
2460
  },
2038
2461
  finalise(patch = {}) {
2039
2462
  current = { ...current, ...patch };
2040
- writeFileSync2(metaPath, JSON.stringify(current, null, 2));
2463
+ writeFileSync3(metaPath, JSON.stringify(current, null, 2));
2041
2464
  },
2042
2465
  count() {
2043
2466
  return nextSeq;
@@ -2047,13 +2470,13 @@ function createTraceWriter(scratchPath, meta, opts = {}) {
2047
2470
 
2048
2471
  // ../../packages/executor-worktree/src/pack-cache.ts
2049
2472
  import { createHash as createHash2 } from "crypto";
2050
- import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readdirSync as readdirSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
2051
- import { dirname as dirname2, join as join6, relative, sep } from "path";
2473
+ import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync3, readdirSync as readdirSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
2474
+ import { dirname as dirname2, join as join8, relative, sep } from "path";
2052
2475
  function readManifestFiles(dir) {
2053
2476
  const out2 = [];
2054
2477
  const walk = (cur) => {
2055
2478
  for (const entry of readdirSync2(cur, { withFileTypes: true })) {
2056
- const abs = join6(cur, entry.name);
2479
+ const abs = join8(cur, entry.name);
2057
2480
  if (entry.isDirectory()) walk(abs);
2058
2481
  else out2.push(relative(dir, abs).split(sep).join("/"));
2059
2482
  }
@@ -2063,8 +2486,8 @@ function readManifestFiles(dir) {
2063
2486
  }
2064
2487
 
2065
2488
  // ../../packages/executor-worktree/src/overlay.ts
2066
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
2067
- import { dirname as dirname3, join as join7 } from "path";
2489
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync5 } from "fs";
2490
+ import { dirname as dirname3, join as join9 } from "path";
2068
2491
  function sameBytes(dest, bytes) {
2069
2492
  try {
2070
2493
  return readFileSync3(dest).equals(bytes);
@@ -2076,16 +2499,16 @@ async function overlayComponents(opts) {
2076
2499
  const { worktreePath, runtime, components, cache } = opts;
2077
2500
  const result = { written: [], skippedRepoLocal: [], warnings: [] };
2078
2501
  for (const ref of components) {
2079
- if (runtime === "codex") {
2502
+ if (runtime !== "claude-code") {
2080
2503
  result.warnings.push(
2081
- `codex runtime: ${ref.kind} \`${ref.name}@${ref.version}\` not materialised; inline into the prompt or use Claude`
2504
+ `${runtime} runtime: ${ref.kind} \`${ref.name}@${ref.version}\` not materialised; inline into the prompt or use Claude`
2082
2505
  );
2083
2506
  continue;
2084
2507
  }
2085
2508
  const { dir } = await cache.materialise(ref);
2086
2509
  for (const relPath of readManifestFiles(dir)) {
2087
- const src = join7(dir, relPath);
2088
- const dest = join7(worktreePath, relPath);
2510
+ const src = join9(dir, relPath);
2511
+ const dest = join9(worktreePath, relPath);
2089
2512
  const bytes = readFileSync3(src);
2090
2513
  if (existsSync4(dest)) {
2091
2514
  if (sameBytes(dest, bytes)) continue;
@@ -2093,27 +2516,21 @@ async function overlayComponents(opts) {
2093
2516
  continue;
2094
2517
  }
2095
2518
  mkdirSync4(dirname3(dest), { recursive: true });
2096
- writeFileSync4(dest, bytes);
2519
+ writeFileSync5(dest, bytes);
2097
2520
  result.written.push(relPath);
2098
2521
  }
2099
2522
  }
2100
2523
  return result;
2101
2524
  }
2102
2525
 
2103
- // ../../packages/executor-worktree/src/pi-container.ts
2104
- import { spawn as nodeSpawn } from "child_process";
2105
-
2106
- // ../../packages/executor-worktree/src/pi-rpc-client.ts
2107
- import { StringDecoder } from "string_decoder";
2108
-
2109
- // ../../packages/executor-worktree/src/pi-container.ts
2110
- var DEFAULT_IMAGE = process.env.DAHRK_PI_IMAGE ?? process.env.SKAKEL_PI_IMAGE ?? "dahrk/pi:latest";
2111
-
2112
2526
  // ../../packages/executor-worktree/src/index.ts
2113
2527
  function makeRunner(runtime) {
2114
2528
  if ((process.env.DAHRK_RUNNER ?? process.env.SKAKEL_RUNNER ?? "real") === "mock") return createMockRunner(runtime);
2115
- if (runtime === "pi") return createPiRunner();
2116
- return runtime === "codex" ? createCodexRunner() : createClaudeRunner();
2529
+ if (runtime === "pi") return piContainerIsolationRequired() ? createIsolatedPiRunner() : createPiRunner();
2530
+ return createClaudeRunner();
2531
+ }
2532
+ function piContainerIsolationRequired() {
2533
+ return (process.env.DAHRK_PI_ISOLATION ?? process.env.SKAKEL_PI_ISOLATION) === "container";
2117
2534
  }
2118
2535
 
2119
2536
  // ../../packages/edge/src/health.ts
@@ -2172,8 +2589,8 @@ function collectHealth(inputs) {
2172
2589
  }
2173
2590
 
2174
2591
  // ../../packages/edge/src/job-ledger.ts
2175
- import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, renameSync, unlinkSync, writeFileSync as writeFileSync5 } from "fs";
2176
- import { dirname as dirname4, join as join8 } from "path";
2592
+ import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, renameSync, unlinkSync, writeFileSync as writeFileSync6 } from "fs";
2593
+ import { dirname as dirname4, join as join10 } from "path";
2177
2594
  var FILE_MODE = 384;
2178
2595
  var DIR_MODE = 448;
2179
2596
  function nullJobLedger() {
@@ -2202,7 +2619,7 @@ function fileJobLedger(file, warn = console.warn) {
2202
2619
  const tmp = `${file}.${process.pid}.tmp`;
2203
2620
  try {
2204
2621
  mkdirSync5(dirname4(file), { recursive: true, mode: DIR_MODE });
2205
- writeFileSync5(tmp, `${JSON.stringify(entries, null, 2)}
2622
+ writeFileSync6(tmp, `${JSON.stringify(entries, null, 2)}
2206
2623
  `, { mode: FILE_MODE });
2207
2624
  chmodSync(tmp, FILE_MODE);
2208
2625
  renameSync(tmp, file);
@@ -2237,7 +2654,7 @@ function announceableJobs(entries) {
2237
2654
  return out2;
2238
2655
  }
2239
2656
  function jobLedgerFile(stateDir2) {
2240
- return join8(stateDir2, "jobs.json");
2657
+ return join10(stateDir2, "jobs.json");
2241
2658
  }
2242
2659
 
2243
2660
  // ../../packages/edge/src/log-shipper.ts
@@ -2340,8 +2757,8 @@ function ceilingFromEnv(env) {
2340
2757
  }
2341
2758
 
2342
2759
  // ../../packages/edge/src/logger.ts
2343
- import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync6, openSync, renameSync as renameSync2, rmSync as rmSync4, statSync as statSync2, writeSync } from "fs";
2344
- import { join as join9 } from "path";
2760
+ import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync6, openSync, renameSync as renameSync2, rmSync as rmSync5, statSync as statSync2, writeSync } from "fs";
2761
+ import { join as join11 } from "path";
2345
2762
  import pino from "pino";
2346
2763
 
2347
2764
  // ../../packages/edge/src/redact.ts
@@ -2465,7 +2882,7 @@ var RotatingFile = class {
2465
2882
  closeSync(this.fd);
2466
2883
  this.fd = void 0;
2467
2884
  const oldest = `${this.path}.${this.maxFiles}`;
2468
- if (existsSync6(oldest)) rmSync4(oldest, { force: true });
2885
+ if (existsSync6(oldest)) rmSync5(oldest, { force: true });
2469
2886
  for (let i = this.maxFiles - 1; i >= 1; i--) {
2470
2887
  const from = `${this.path}.${i}`;
2471
2888
  if (existsSync6(from)) renameSync2(from, `${this.path}.${i + 1}`);
@@ -2531,7 +2948,7 @@ function createNodeLogger(opts = {}) {
2531
2948
  if (opts.dir && fileLevel !== "silent") {
2532
2949
  try {
2533
2950
  mkdirSync6(opts.dir, { recursive: true, mode: 448 });
2534
- const file = new RotatingFile(join9(opts.dir, "node.jsonl"), opts.maxBytes ?? 10 * 1024 * 1024, opts.maxFiles ?? 5);
2951
+ const file = new RotatingFile(join11(opts.dir, "node.jsonl"), opts.maxBytes ?? 10 * 1024 * 1024, opts.maxFiles ?? 5);
2535
2952
  streams.push({ level: fileLevel, stream: file });
2536
2953
  } catch (e) {
2537
2954
  process.stderr.write(`dahrk: file logging disabled (${e.message})
@@ -2589,9 +3006,9 @@ function denyToolRule(tool3) {
2589
3006
  // ../../packages/edge/src/stage-runner.ts
2590
3007
  import { execFileSync as execFileSync5 } from "child_process";
2591
3008
  import { createHash as createHash3 } from "crypto";
2592
- import { mkdirSync as mkdirSync7, readdirSync as readdirSync3, readFileSync as readFileSync5, rmSync as rmSync5, writeFileSync as writeFileSync6 } from "fs";
2593
- import { tmpdir as tmpdir4 } from "os";
2594
- import { isAbsolute as isAbsolute3, join as join11, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
3009
+ import { mkdirSync as mkdirSync7, readdirSync as readdirSync3, readFileSync as readFileSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "fs";
3010
+ import { tmpdir as tmpdir5 } from "os";
3011
+ import { isAbsolute as isAbsolute3, join as join13, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
2595
3012
  import { attachedDocBasename as attachedDocBasename2 } from "@dahrk/contracts";
2596
3013
 
2597
3014
  // ../../packages/edge/src/builtins.ts
@@ -2600,8 +3017,8 @@ import { execFileSync as execFileSync4 } from "child_process";
2600
3017
  // ../../packages/edge/src/fs-roots.ts
2601
3018
  import { execFileSync as execFileSync3 } from "child_process";
2602
3019
  import { existsSync as existsSync7, realpathSync as realpathSync2 } from "fs";
2603
- import { homedir as homedir3, tmpdir as tmpdir3 } from "os";
2604
- import { dirname as dirname5, isAbsolute as isAbsolute2, join as join10, relative as relative2, resolve, sep as sep2 } from "path";
3020
+ import { homedir as homedir3, tmpdir as tmpdir4 } from "os";
3021
+ import { dirname as dirname5, isAbsolute as isAbsolute2, join as join12, relative as relative2, resolve, sep as sep2 } from "path";
2605
3022
  function isUnder(root, target) {
2606
3023
  const rel = relative2(resolve(root), resolve(target));
2607
3024
  return rel === "" || !rel.startsWith(`..${sep2}`) && rel !== ".." && !isAbsolute2(rel);
@@ -2617,7 +3034,7 @@ function realish(p) {
2617
3034
  while (head !== dirname5(head)) {
2618
3035
  if (existsSync7(head)) {
2619
3036
  try {
2620
- return join10(realpathSync2.native(head), ...tail.reverse());
3037
+ return join12(realpathSync2.native(head), ...tail.reverse());
2621
3038
  } catch {
2622
3039
  return p;
2623
3040
  }
@@ -2667,7 +3084,7 @@ function computeFsRoots(opts) {
2667
3084
  // Every git command in the worktree reads and writes here.
2668
3085
  ...[gitCommonDir(opts.worktreePath)].filter((p) => Boolean(p)).map(realish),
2669
3086
  // Scratch space: `mkdtemp`, git's temp files, and an agent tidying a throwaway dir it created.
2670
- ...[tmpdir3(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders"].map(realish),
3087
+ ...[tmpdir4(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders"].map(realish),
2671
3088
  // The safe I/O sinks. `2>/dev/null` is on a third of the shell commands real stages run; treating
2672
3089
  // it as a write outside the worktree would deny most of a normal build. The raw devices are NOT
2673
3090
  // here, so `> /dev/sda` still fails confinement (as well as shell_guard's device-write regex).
@@ -2686,26 +3103,26 @@ function computeFsRoots(opts) {
2686
3103
  "/System",
2687
3104
  "/proc",
2688
3105
  "/sys",
2689
- join10(home, ".gitconfig"),
2690
- join10(home, ".config"),
2691
- join10(home, ".npmrc"),
2692
- join10(home, ".cache"),
2693
- join10(home, "Library", "Caches"),
2694
- join10(home, "Library", "pnpm"),
2695
- join10(home, ".local", "share"),
2696
- join10(home, ".nvm"),
2697
- join10(home, ".volta"),
2698
- join10(home, ".asdf"),
2699
- join10(home, ".cargo"),
2700
- join10(home, ".rustup"),
3106
+ join12(home, ".gitconfig"),
3107
+ join12(home, ".config"),
3108
+ join12(home, ".npmrc"),
3109
+ join12(home, ".cache"),
3110
+ join12(home, "Library", "Caches"),
3111
+ join12(home, "Library", "pnpm"),
3112
+ join12(home, ".local", "share"),
3113
+ join12(home, ".nvm"),
3114
+ join12(home, ".volta"),
3115
+ join12(home, ".asdf"),
3116
+ join12(home, ".cargo"),
3117
+ join12(home, ".rustup"),
2701
3118
  ...splitRoots(process.env.DAHRK_FS_EXTRA_READ_ROOTS).map((p) => realish(resolve(p)))
2702
3119
  ].map(realish);
2703
3120
  const deny = [
2704
- join10(home, ".ssh"),
2705
- join10(home, ".aws"),
2706
- join10(home, ".gnupg"),
2707
- join10(home, ".config", "gcloud"),
2708
- join10(home, "Library", "Keychains"),
3121
+ join12(home, ".ssh"),
3122
+ join12(home, ".aws"),
3123
+ join12(home, ".gnupg"),
3124
+ join12(home, ".config", "gcloud"),
3125
+ join12(home, "Library", "Keychains"),
2709
3126
  "/Volumes",
2710
3127
  "/etc/shadow",
2711
3128
  "/etc/sudoers"
@@ -3251,6 +3668,77 @@ async function startMcpGateway(opts) {
3251
3668
  };
3252
3669
  }
3253
3670
 
3671
+ // ../../packages/edge/src/push-outcome.ts
3672
+ function resolveDeliverOutcome(r, job, pr) {
3673
+ const { jobId, branch, base } = job;
3674
+ if (r.integration === "noop") {
3675
+ return {
3676
+ jobId,
3677
+ status: "ok",
3678
+ branch,
3679
+ headSha: r.headSha,
3680
+ pushed: false,
3681
+ nothingToCommit: true,
3682
+ commitsAhead: r.commitsAhead,
3683
+ summary: `no changes to deliver on ${branch} - work already present on ${base}`
3684
+ };
3685
+ }
3686
+ if (r.integration === "conflict") {
3687
+ return {
3688
+ jobId,
3689
+ status: "ok",
3690
+ branch,
3691
+ headSha: r.headSha,
3692
+ pushed: false,
3693
+ nothingToCommit: r.nothingToCommit,
3694
+ commitsAhead: r.commitsAhead,
3695
+ integration: "conflict",
3696
+ ...r.conflictFiles ? { conflictFiles: r.conflictFiles } : {},
3697
+ ...r.footprint ?? {},
3698
+ summary: `base advanced; merge conflict on ${branch} (manual merge needed)`
3699
+ };
3700
+ }
3701
+ if (r.integration === "diverged") {
3702
+ return {
3703
+ jobId,
3704
+ status: "fail",
3705
+ branch,
3706
+ headSha: r.headSha,
3707
+ pushed: false,
3708
+ nothingToCommit: r.nothingToCommit,
3709
+ commitsAhead: r.commitsAhead,
3710
+ summary: `branch history diverged from ${base}; cannot auto-integrate on ${branch} (the branch likely needs rebuilding from ${base})`
3711
+ };
3712
+ }
3713
+ return {
3714
+ jobId,
3715
+ status: "ok",
3716
+ branch,
3717
+ headSha: r.headSha,
3718
+ pushed: r.pushed,
3719
+ nothingToCommit: r.nothingToCommit,
3720
+ commitsAhead: r.commitsAhead,
3721
+ ...r.integration ? { integration: r.integration } : {},
3722
+ ...pr?.prUrl ? { prUrl: pr.prUrl } : {},
3723
+ ...pr?.prNumber !== void 0 ? { prNumber: pr.prNumber } : {},
3724
+ ...pr?.prError ? { prError: pr.prError } : {},
3725
+ ...r.footprint ?? {},
3726
+ summary: r.nothingToCommit ? `no changes to commit; ${r.pushed ? "branch pushed" : "nothing pushed"}` : `committed ${r.headSha.slice(0, 7)} and pushed ${branch}`
3727
+ };
3728
+ }
3729
+ function resolveBackupOutcome(r, job) {
3730
+ return {
3731
+ jobId: job.jobId,
3732
+ status: "ok",
3733
+ branch: job.branch,
3734
+ headSha: r.headSha,
3735
+ pushed: r.pushed,
3736
+ nothingToCommit: r.nothingToCommit,
3737
+ wipRef: r.wipRef,
3738
+ summary: `backup: preserved ${r.headSha.slice(0, 7)} on ${r.wipRef} (no base merge, no PR)`
3739
+ };
3740
+ }
3741
+
3254
3742
  // ../../packages/edge/src/stage-runner.ts
3255
3743
  var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
3256
3744
  var PREVIEW = 500;
@@ -3290,7 +3778,7 @@ var attemptOf = (jobId) => {
3290
3778
  };
3291
3779
  var digest = (value) => `sha256:${createHash3("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16)}`;
3292
3780
  function writeScratchState(ref, job, attempt, status) {
3293
- const statePath = join11(ref.scratchPath, "state.json");
3781
+ const statePath = join13(ref.scratchPath, "state.json");
3294
3782
  let state;
3295
3783
  try {
3296
3784
  state = JSON.parse(readFileSync5(statePath, "utf8"));
@@ -3299,23 +3787,23 @@ function writeScratchState(ref, job, attempt, status) {
3299
3787
  }
3300
3788
  state.stages[job.stageId] = { currentAttempt: attempt, status };
3301
3789
  mkdirSync7(ref.scratchPath, { recursive: true });
3302
- writeFileSync6(statePath, JSON.stringify(state, null, 2));
3790
+ writeFileSync7(statePath, JSON.stringify(state, null, 2));
3303
3791
  }
3304
3792
  function writeIssueContext(ref, issueContext) {
3305
3793
  if (issueContext === void 0) return;
3306
3794
  try {
3307
3795
  mkdirSync7(ref.scratchPath, { recursive: true });
3308
- writeFileSync6(join11(ref.scratchPath, "issue.md"), issueContext);
3796
+ writeFileSync7(join13(ref.scratchPath, "issue.md"), issueContext);
3309
3797
  } catch {
3310
3798
  }
3311
3799
  }
3312
3800
  function writeAttachedDocuments(ref, docs) {
3313
3801
  if (!docs || docs.length === 0) return;
3314
3802
  try {
3315
- const dir = join11(ref.scratchPath, "docs");
3803
+ const dir = join13(ref.scratchPath, "docs");
3316
3804
  mkdirSync7(dir, { recursive: true });
3317
3805
  for (const doc of docs) {
3318
- writeFileSync6(join11(dir, `${attachedDocBasename2(doc)}.md`), doc.content);
3806
+ writeFileSync7(join13(dir, `${attachedDocBasename2(doc)}.md`), doc.content);
3319
3807
  }
3320
3808
  } catch {
3321
3809
  }
@@ -3334,12 +3822,12 @@ function writeGuidance(ref, guidance) {
3334
3822
  if (!guidance || guidance.length === 0) return;
3335
3823
  try {
3336
3824
  mkdirSync7(ref.scratchPath, { recursive: true });
3337
- writeFileSync6(join11(ref.scratchPath, "guidance.md"), renderGuidanceMarkdown(guidance));
3825
+ writeFileSync7(join13(ref.scratchPath, "guidance.md"), renderGuidanceMarkdown(guidance));
3338
3826
  } catch {
3339
3827
  }
3340
3828
  }
3341
3829
  var ARTIFACT_CAP_BYTES = 64 * 1024;
3342
- var SCRATCH_OUTPUT_DIR = ".skakel/scratch/output";
3830
+ var SCRATCH_OUTPUT_DIR = ".dahrk/scratch/output";
3343
3831
  function capContent(raw) {
3344
3832
  return raw.length > ARTIFACT_CAP_BYTES ? raw.slice(0, ARTIFACT_CAP_BYTES) : raw;
3345
3833
  }
@@ -3367,14 +3855,14 @@ function readEmittedArtifact(ref, relPath) {
3367
3855
  }
3368
3856
  }
3369
3857
  function scanScratchOutput(ref, preferRel) {
3858
+ const preferBase = preferRel?.split("/").pop();
3370
3859
  try {
3371
- const names = readdirSync3(join11(ref.worktreePath, SCRATCH_OUTPUT_DIR)).filter(
3860
+ const names = readdirSync3(join13(ref.worktreePath, SCRATCH_OUTPUT_DIR)).filter(
3372
3861
  (n) => n.toLowerCase().endsWith(".md")
3373
3862
  );
3374
3863
  if (names.length === 0) return void 0;
3375
- const preferBase = preferRel?.split("/").pop();
3376
3864
  const pick = preferBase && names.includes(preferBase) ? preferBase : names[0];
3377
- const raw = readFileSync5(join11(ref.worktreePath, SCRATCH_OUTPUT_DIR, pick), "utf8");
3865
+ const raw = readFileSync5(join13(ref.worktreePath, SCRATCH_OUTPUT_DIR, pick), "utf8");
3378
3866
  if (raw.trim().length === 0) return void 0;
3379
3867
  return { path: `${SCRATCH_OUTPUT_DIR}/${pick}`, content: capContent(raw) };
3380
3868
  } catch {
@@ -3394,7 +3882,7 @@ function scanChangedMarkdown(ref) {
3394
3882
  );
3395
3883
  for (const rel of rels) {
3396
3884
  try {
3397
- const raw = readFileSync5(join11(ref.worktreePath, rel), "utf8");
3885
+ const raw = readFileSync5(join13(ref.worktreePath, rel), "utf8");
3398
3886
  if (raw.trim().length > 0) return { path: rel, content: capContent(raw) };
3399
3887
  } catch {
3400
3888
  }
@@ -3415,6 +3903,9 @@ function resolveStageArtifact(ref, emitArtifact, handedBack) {
3415
3903
  if (changed) return { artifact: changed, source: "changed-file" };
3416
3904
  return void 0;
3417
3905
  }
3906
+ function runtimeUsesMcpGateway(runtime) {
3907
+ return runtime === "claude-code" || runtime === "pi";
3908
+ }
3418
3909
  function createStageRunner(deps) {
3419
3910
  const worktrees = /* @__PURE__ */ new Map();
3420
3911
  const log = deps.logger ?? createNodeLogger({ level: "silent" });
@@ -3437,7 +3928,7 @@ function createStageRunner(deps) {
3437
3928
  if (ref) {
3438
3929
  if (scratchOnly.has(runId)) {
3439
3930
  try {
3440
- rmSync5(ref.worktreePath, { recursive: true, force: true });
3931
+ rmSync6(ref.worktreePath, { recursive: true, force: true });
3441
3932
  } catch (e) {
3442
3933
  log.warn({ err: e, runId, path: ref.worktreePath }, "teardown: could not remove scratch dir");
3443
3934
  }
@@ -3517,9 +4008,9 @@ function createStageRunner(deps) {
3517
4008
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
3518
4009
  });
3519
4010
  } else {
3520
- const base = deps.scratchRoot ?? join11(tmpdir4(), "dahrk", "scratch");
3521
- const worktreePath = join11(base, runId);
3522
- const scratchPath = join11(worktreePath, ".skakel", "scratch");
4011
+ const base = deps.scratchRoot ?? join13(tmpdir5(), "dahrk", "scratch");
4012
+ const worktreePath = join13(base, runId);
4013
+ const scratchPath = join13(worktreePath, ".dahrk", "scratch");
3523
4014
  mkdirSync7(scratchPath, { recursive: true });
3524
4015
  ref = { repoId: "", gitUrl: "", repo: "", baseBranch: "", worktreePath, scratchPath };
3525
4016
  scratchOnly.add(runId);
@@ -3535,7 +4026,7 @@ function createStageRunner(deps) {
3535
4026
  const slash = job.agentConfig.emitArtifact.lastIndexOf("/");
3536
4027
  if (artifactDir && slash > 0) {
3537
4028
  try {
3538
- mkdirSync7(join11(ref.worktreePath, job.agentConfig.emitArtifact.slice(0, slash)), { recursive: true });
4029
+ mkdirSync7(join13(ref.worktreePath, job.agentConfig.emitArtifact.slice(0, slash)), { recursive: true });
3539
4030
  } catch {
3540
4031
  }
3541
4032
  }
@@ -3563,8 +4054,8 @@ function createStageRunner(deps) {
3563
4054
  if (!sink) return;
3564
4055
  const base = { tenantId: job.tenantId, runId, stageId, attempt };
3565
4056
  try {
3566
- for (const name of readdirSync3(join11(writer.dir, "blobs"))) {
3567
- const bytes = readFileSync5(join11(writer.dir, "blobs", name));
4057
+ for (const name of readdirSync3(join13(writer.dir, "blobs"))) {
4058
+ const bytes = readFileSync5(join13(writer.dir, "blobs", name));
3568
4059
  const { url } = await sink.requestBlobUrl({
3569
4060
  ...base,
3570
4061
  sha256: name,
@@ -3578,7 +4069,7 @@ function createStageRunner(deps) {
3578
4069
  }
3579
4070
  let archiveKey;
3580
4071
  try {
3581
- const bytes = readFileSync5(join11(writer.dir, "trace.jsonl"));
4072
+ const bytes = readFileSync5(join13(writer.dir, "trace.jsonl"));
3582
4073
  const sha = createHash3("sha256").update(bytes).digest("hex");
3583
4074
  const { key, url } = await sink.requestBlobUrl({
3584
4075
  ...base,
@@ -3675,6 +4166,7 @@ function createStageRunner(deps) {
3675
4166
  return finish("fail", `${stageId}: denied at stage entry (${entry.policy})`, job.sessionId);
3676
4167
  }
3677
4168
  let denied = false;
4169
+ const surfacedDenyReasons = /* @__PURE__ */ new Set();
3678
4170
  let escapedUnblocked = false;
3679
4171
  const authorisedActions = [];
3680
4172
  const runtime = agentConfig.runtime;
@@ -3693,7 +4185,11 @@ function createStageRunner(deps) {
3693
4185
  streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "observation", runtime, toolUseId, isError: true, output: { error: reason } }));
3694
4186
  }
3695
4187
  streamEvent(writer.append({ seq: 0, ts: nowIso2(), type: "state", runtime, event: "policy-deny", detail: reason }));
3696
- deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: reason });
4188
+ const surfaceKey = `${verdict2.policy}\0${reason}`;
4189
+ if (!surfacedDenyReasons.has(surfaceKey)) {
4190
+ surfacedDenyReasons.add(surfaceKey);
4191
+ deps.sendProgress({ jobId, kind: "error", ts: nowIso2(), text: reason });
4192
+ }
3697
4193
  };
3698
4194
  const authorizeToolUse = (tool3, input) => {
3699
4195
  const verdict2 = evaluatePolicies({ kind: "action", stageId, tool: tool3, input }, rules);
@@ -3704,7 +4200,10 @@ function createStageRunner(deps) {
3704
4200
  }
3705
4201
  return verdict2;
3706
4202
  };
4203
+ let bumpStall = () => {
4204
+ };
3707
4205
  const onTrace = (event) => {
4206
+ bumpStall();
3708
4207
  if (event.type === "action") {
3709
4208
  const key = actionKey(event.tool, event.input);
3710
4209
  const authorised = authorisedActions.indexOf(key);
@@ -3727,7 +4226,7 @@ function createStageRunner(deps) {
3727
4226
  if (event.type !== "state") deps.sendProgress({ jobId, kind: event.type, ts: event.ts, ...previewOf(event) });
3728
4227
  };
3729
4228
  const mcpServers = agentConfig.mcpServers;
3730
- if (mcpServers && mcpServers.length > 0 && runtime === "claude-code") {
4229
+ if (mcpServers && mcpServers.length > 0 && runtimeUsesMcpGateway(runtime)) {
3731
4230
  gateway = await startMcpGateway({ servers: mcpServers, creds: job.brokeredCreds ?? {} });
3732
4231
  }
3733
4232
  const runner = deps.makeRunner(runtime);
@@ -3769,6 +4268,20 @@ function createStageRunner(deps) {
3769
4268
  timedOut = true;
3770
4269
  void runner.cancel();
3771
4270
  }, killMs) : void 0;
4271
+ let stalled = false;
4272
+ let stallTimer;
4273
+ const stallSource = agentConfig.stallMs ?? Number(process.env.DAHRK_BATCH_STALL_MS ?? process.env.SKAKEL_BATCH_STALL_MS ?? 3e5);
4274
+ const stallMs = interactive ? 0 : Math.max(0, Math.floor(stallSource));
4275
+ if (stallMs > 0) {
4276
+ bumpStall = () => {
4277
+ if (stallTimer) clearTimeout(stallTimer);
4278
+ stallTimer = setTimeout(() => {
4279
+ stalled = true;
4280
+ void runner.cancel();
4281
+ }, stallMs);
4282
+ };
4283
+ bumpStall();
4284
+ }
3772
4285
  try {
3773
4286
  if (interactive) {
3774
4287
  const mailbox = new ManagedMailbox();
@@ -3779,8 +4292,9 @@ function createStageRunner(deps) {
3779
4292
  }
3780
4293
  } finally {
3781
4294
  if (killTimer) clearTimeout(killTimer);
4295
+ if (stallTimer) clearTimeout(stallTimer);
3782
4296
  }
3783
- let status = timedOut ? "timeout" : result.status;
4297
+ let status = timedOut || stalled ? "timeout" : result.status;
3784
4298
  if (status === "ok" && escapedUnblocked) {
3785
4299
  status = "fail";
3786
4300
  const msg = `stage reached outside the run's worktree and the ${runtime} runtime could not block it before it ran`;
@@ -3799,6 +4313,9 @@ function createStageRunner(deps) {
3799
4313
  }
3800
4314
  }
3801
4315
  let summary = result.summary ?? `${stageId}: ${status}`;
4316
+ if (stalled && !timedOut) {
4317
+ summary = `${stageId}: stalled (no output for ${Math.round(stallMs / 1e3)}s)`;
4318
+ }
3802
4319
  if (!interactive && status === "ok") {
3803
4320
  try {
3804
4321
  summary = await runner.summarise(ctx);
@@ -3837,17 +4354,7 @@ function createStageRunner(deps) {
3837
4354
  branch: job.branch,
3838
4355
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
3839
4356
  });
3840
- const result = {
3841
- jobId,
3842
- status: "ok",
3843
- branch: job.branch,
3844
- headSha: r.headSha,
3845
- pushed: r.pushed,
3846
- nothingToCommit: r.nothingToCommit,
3847
- wipRef: r.wipRef,
3848
- summary: `backup: preserved ${r.headSha.slice(0, 7)} on ${r.wipRef} (no base merge, no PR)`
3849
- };
3850
- return result;
4357
+ return resolveBackupOutcome(r, { jobId, branch: job.branch });
3851
4358
  } catch (e) {
3852
4359
  return { jobId, status: "fail", branch: job.branch, summary: `backup push failed: ${e.message}` };
3853
4360
  }
@@ -3872,64 +4379,13 @@ function createStageRunner(deps) {
3872
4379
  base: job.base,
3873
4380
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
3874
4381
  });
3875
- if (r.integration === "noop") {
3876
- return {
3877
- jobId,
3878
- status: "ok",
3879
- branch: job.branch,
3880
- headSha: r.headSha,
3881
- pushed: false,
3882
- nothingToCommit: true,
3883
- commitsAhead: r.commitsAhead,
3884
- summary: `no changes to deliver on ${job.branch} - work already present on ${job.base}`
3885
- };
3886
- }
3887
- if (r.integration === "conflict") {
3888
- return {
3889
- jobId,
3890
- status: "ok",
3891
- branch: job.branch,
3892
- headSha: r.headSha,
3893
- pushed: false,
3894
- nothingToCommit: r.nothingToCommit,
3895
- commitsAhead: r.commitsAhead,
3896
- integration: "conflict",
3897
- ...r.conflictFiles ? { conflictFiles: r.conflictFiles } : {},
3898
- summary: `base advanced; merge conflict on ${job.branch} (manual merge needed)`
3899
- };
3900
- }
3901
- if (r.integration === "diverged") {
3902
- return {
3903
- jobId,
3904
- status: "fail",
3905
- branch: job.branch,
3906
- headSha: r.headSha,
3907
- pushed: false,
3908
- nothingToCommit: r.nothingToCommit,
3909
- commitsAhead: r.commitsAhead,
3910
- summary: `branch history diverged from ${job.base}; cannot auto-integrate on ${job.branch} (the branch likely needs rebuilding from ${job.base})`
3911
- };
3912
- }
3913
4382
  const pr = job.openPr && r.pushed ? await deps.gitService.openPrAmbient(ref, {
3914
4383
  branch: job.branch,
3915
4384
  base: job.base,
3916
4385
  title: job.openPr.title,
3917
4386
  body: job.openPr.body
3918
4387
  }) : void 0;
3919
- return {
3920
- jobId,
3921
- status: "ok",
3922
- branch: job.branch,
3923
- headSha: r.headSha,
3924
- pushed: r.pushed,
3925
- nothingToCommit: r.nothingToCommit,
3926
- commitsAhead: r.commitsAhead,
3927
- ...r.integration ? { integration: r.integration } : {},
3928
- ...pr?.prUrl ? { prUrl: pr.prUrl } : {},
3929
- ...pr?.prNumber !== void 0 ? { prNumber: pr.prNumber } : {},
3930
- ...pr?.prError ? { prError: pr.prError } : {},
3931
- summary: r.nothingToCommit ? `no changes to commit; ${r.pushed ? "branch pushed" : "nothing pushed"}` : `committed ${r.headSha.slice(0, 7)} and pushed ${job.branch}`
3932
- };
4388
+ return resolveDeliverOutcome(r, { jobId, branch: job.branch, base: job.base }, pr);
3933
4389
  } catch (e) {
3934
4390
  return { jobId, status: "fail", summary: `push failed: ${e.message}` };
3935
4391
  }
@@ -4040,6 +4496,17 @@ async function startEdgeNode(opts) {
4040
4496
  if (oldest !== void 0) lastResults.delete(oldest);
4041
4497
  }
4042
4498
  };
4499
+ const ackedCancels = /* @__PURE__ */ new Map();
4500
+ const ackCancel = (jobId) => {
4501
+ const frame = { type: "cancel-ack", jobId };
4502
+ ackedCancels.delete(jobId);
4503
+ ackedCancels.set(jobId, frame);
4504
+ if (ackedCancels.size > MAX_RESEND) {
4505
+ const oldest = ackedCancels.keys().next().value;
4506
+ if (oldest !== void 0) ackedCancels.delete(oldest);
4507
+ }
4508
+ send(frame);
4509
+ };
4043
4510
  const pendingBlob = /* @__PURE__ */ new Map();
4044
4511
  let blobReqCounter = 0;
4045
4512
  const trace = {
@@ -4105,10 +4572,10 @@ async function startEdgeNode(opts) {
4105
4572
  await reconcileInterruptedJobs();
4106
4573
  void stageRunner.reapWorktrees().then((r) => {
4107
4574
  counters.worktreeCount = Math.max(r.scanned - r.reaped.length, 0);
4108
- if (r.reaped.length) {
4575
+ if (r.reaped.length || r.salvagedRefs) {
4109
4576
  log.info(
4110
- { reaped: r.reaped, scanned: r.scanned, skipped: r.skipped },
4111
- `EDGE_REAPED:${r.reaped.length} worktrees (scanned ${r.scanned}, skipped ${r.skipped})`
4577
+ { reaped: r.reaped, scanned: r.scanned, skipped: r.skipped, salvagedRefs: r.salvagedRefs },
4578
+ `EDGE_REAPED:${r.reaped.length} worktrees (scanned ${r.scanned}, skipped ${r.skipped}, salvaged ${r.salvagedRefs})`
4112
4579
  );
4113
4580
  }
4114
4581
  for (const e of r.errors) log.warn({ reapError: e }, `EDGE_REAP_ERROR:${e}`);
@@ -4220,6 +4687,7 @@ async function startEdgeNode(opts) {
4220
4687
  if (msg.type === "cancel") {
4221
4688
  log.info({ jobId: msg.jobId }, `JOB_CANCEL:${msg.jobId}`);
4222
4689
  stageRunner.cancel(msg.jobId);
4690
+ ackCancel(msg.jobId);
4223
4691
  return;
4224
4692
  }
4225
4693
  if (msg.type === "turn") {
@@ -4351,6 +4819,7 @@ async function startEdgeNode(opts) {
4351
4819
  log.info({ hubUrl: opts.hubUrl, nodeId, connectCount }, "EDGE_CONNECTED");
4352
4820
  sendHello();
4353
4821
  for (const frame of lastResults.values()) send(frame);
4822
+ for (const frame of ackedCancels.values()) send(frame);
4354
4823
  startHeartbeat(sock, opts.heartbeatMs ?? 5e3);
4355
4824
  });
4356
4825
  sock.on("message", (raw) => void onMessage(raw.toString()));
@@ -4454,7 +4923,6 @@ async function startEdgeNode(opts) {
4454
4923
  import { execFile } from "child_process";
4455
4924
  var PROBES = [
4456
4925
  { runtime: "claude-code", cmd: "claude" },
4457
- { runtime: "codex", cmd: "codex" },
4458
4926
  { runtime: "pi", cmd: "pi" }
4459
4927
  ];
4460
4928
  var DEFAULT_TIMEOUT_MS = 5e3;
@@ -5092,8 +5560,8 @@ function usage(bin, command) {
5092
5560
  }
5093
5561
 
5094
5562
  // src/diagnose.ts
5095
- import { existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync7 } from "fs";
5096
- import { join as join12 } from "path";
5563
+ import { existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync8 } from "fs";
5564
+ import { join as join14 } from "path";
5097
5565
 
5098
5566
  // src/ui.ts
5099
5567
  import { styleText } from "util";
@@ -5208,7 +5676,7 @@ async function buildBundle(deps) {
5208
5676
  if (deps.exists(deps.crashDir)) {
5209
5677
  for (const name of deps.listDir(deps.crashDir).filter((f) => f.endsWith(".json")).sort()) {
5210
5678
  try {
5211
- crashes.push(JSON.parse(deps.readFile(join12(deps.crashDir, name))));
5679
+ crashes.push(JSON.parse(deps.readFile(join14(deps.crashDir, name))));
5212
5680
  } catch (e) {
5213
5681
  warnings.push(`could not read crash record ${name} (${e.message})`);
5214
5682
  }
@@ -5269,14 +5737,14 @@ var defaultDiagnoseDeps = (paths, clientVersion, doctor) => ({
5269
5737
  listDir: (p) => readdirSync4(p),
5270
5738
  exists: (p) => existsSync8(p),
5271
5739
  writeFile: (p, content) => {
5272
- const dir = join12(p, "..");
5740
+ const dir = join14(p, "..");
5273
5741
  if (!existsSync8(dir)) mkdirSync8(dir, { recursive: true });
5274
- writeFileSync7(p, content, { mode: 384 });
5742
+ writeFileSync8(p, content, { mode: 384 });
5275
5743
  },
5276
5744
  out
5277
5745
  });
5278
5746
  function defaultBundlePath(cwd, now) {
5279
- return join12(cwd, `dahrk-diagnose-${now.toISOString().replace(/[:.]/g, "-")}.json`);
5747
+ return join14(cwd, `dahrk-diagnose-${now.toISOString().replace(/[:.]/g, "-")}.json`);
5280
5748
  }
5281
5749
 
5282
5750
  // src/doctor.ts
@@ -5471,7 +5939,8 @@ function driftOf(stored, repo) {
5471
5939
  const drift = {};
5472
5940
  if (typeof stored.defaultBranch === "string" && stored.defaultBranch !== repo.defaultBranch) drift.branch = stored.defaultBranch;
5473
5941
  if (typeof stored.name === "string" && stored.name !== repo.name) drift.name = stored.name;
5474
- return drift.branch || drift.name ? drift : void 0;
5942
+ if (typeof stored.gitUrl === "string" && stored.gitUrl !== repo.gitUrl) drift.gitUrl = stored.gitUrl;
5943
+ return drift.branch || drift.name || drift.gitUrl ? drift : void 0;
5475
5944
  }
5476
5945
  async function registerRepo(deps, args) {
5477
5946
  const url = `${args.base}/config/api/repositories`;
@@ -5495,7 +5964,7 @@ async function registerRepo(deps, args) {
5495
5964
  }
5496
5965
 
5497
5966
  // src/lock.ts
5498
- import { existsSync as existsSync9, mkdirSync as mkdirSync9, readFileSync as readFileSync7, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
5967
+ import { existsSync as existsSync9, mkdirSync as mkdirSync9, readFileSync as readFileSync7, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "fs";
5499
5968
  import { dirname as dirname6 } from "path";
5500
5969
  function parseLock(content) {
5501
5970
  if (!content) return void 0;
@@ -5535,9 +6004,9 @@ var defaultLockDeps = (file) => ({
5535
6004
  },
5536
6005
  writeFile: (path, content) => {
5537
6006
  if (!existsSync9(dirname6(path))) mkdirSync9(dirname6(path), { recursive: true, mode: 448 });
5538
- writeFileSync8(path, content);
6007
+ writeFileSync9(path, content);
5539
6008
  },
5540
- removeFile: (path) => rmSync6(path, { force: true }),
6009
+ removeFile: (path) => rmSync7(path, { force: true }),
5541
6010
  isAlive
5542
6011
  });
5543
6012
 
@@ -5645,13 +6114,13 @@ var defaultLogsDeps = (files, jsonlFile) => ({
5645
6114
  });
5646
6115
 
5647
6116
  // src/process-safety.ts
5648
- import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync9 } from "fs";
5649
- import { join as join13 } from "path";
6117
+ import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync10 } from "fs";
6118
+ import { join as join15 } from "path";
5650
6119
  function writeCrashRecord(dir, record) {
5651
6120
  try {
5652
6121
  mkdirSync10(dir, { recursive: true, mode: 448 });
5653
- const file = join13(dir, `${record.at.replace(/[:.]/g, "-")}.json`);
5654
- writeFileSync9(file, `${JSON.stringify(record, null, 2)}
6122
+ const file = join15(dir, `${record.at.replace(/[:.]/g, "-")}.json`);
6123
+ writeFileSync10(file, `${JSON.stringify(record, null, 2)}
5655
6124
  `, { mode: 384 });
5656
6125
  return file;
5657
6126
  } catch {
@@ -5708,7 +6177,7 @@ import { execFileSync as execFileSync6 } from "child_process";
5708
6177
  import { randomUUID as randomUUID2 } from "crypto";
5709
6178
  import { accessSync, constants as fsConstants, existsSync as existsSync11, readdirSync as readdirSync5, statfsSync as statfsSync2 } from "fs";
5710
6179
  import { homedir as homedir4 } from "os";
5711
- import { join as join14 } from "path";
6180
+ import { join as join16 } from "path";
5712
6181
  var REPORT_BASE_URL = "https://app.dahrk.ai/r";
5713
6182
  var LOW_DISK_BYTES = 512 * 1024 * 1024;
5714
6183
  var PREFLIGHT_STAGES = [
@@ -5847,7 +6316,7 @@ function commandPresent(cmd) {
5847
6316
  }
5848
6317
  function sshKeyPresent() {
5849
6318
  try {
5850
- const dir = join14(homedir4(), ".ssh");
6319
+ const dir = join16(homedir4(), ".ssh");
5851
6320
  if (existsSync11(dir) && readdirSync5(dir).some((f) => f.endsWith(".pub"))) return true;
5852
6321
  } catch {
5853
6322
  }
@@ -5867,12 +6336,12 @@ function writable(dir) {
5867
6336
  }
5868
6337
  }
5869
6338
  function worktreeRoot(env) {
5870
- return env.DAHRK_WORKTREES_DIR ?? join14(env.DAHRK_STATE_DIR ?? join14(homedir4(), ".dahrk"), "worktrees");
6339
+ return env.DAHRK_WORKTREES_DIR ?? join16(env.DAHRK_STATE_DIR ?? join16(homedir4(), ".dahrk"), "worktrees");
5871
6340
  }
5872
6341
  function nearestExisting(dir) {
5873
6342
  let cur = dir;
5874
6343
  while (!existsSync11(cur)) {
5875
- const parent = join14(cur, "..");
6344
+ const parent = join16(cur, "..");
5876
6345
  if (parent === cur) break;
5877
6346
  cur = parent;
5878
6347
  }
@@ -5934,14 +6403,14 @@ function gatherHostFacts(repoPath) {
5934
6403
 
5935
6404
  // src/service.ts
5936
6405
  import { execFileSync as execFileSync7 } from "child_process";
5937
- import { chmodSync as chmodSync3, existsSync as existsSync13, mkdirSync as mkdirSync12, readFileSync as readFileSync10, realpathSync as realpathSync3, rmSync as rmSync7, writeFileSync as writeFileSync11 } from "fs";
6406
+ import { chmodSync as chmodSync3, existsSync as existsSync13, mkdirSync as mkdirSync12, readFileSync as readFileSync10, realpathSync as realpathSync3, rmSync as rmSync8, writeFileSync as writeFileSync12 } from "fs";
5938
6407
  import { homedir as homedir6, platform as osPlatform3, userInfo } from "os";
5939
- import { join as join16 } from "path";
6408
+ import { join as join18 } from "path";
5940
6409
 
5941
6410
  // src/state.ts
5942
- import { chmodSync as chmodSync2, existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, writeFileSync as writeFileSync10 } from "fs";
6411
+ import { chmodSync as chmodSync2, existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, writeFileSync as writeFileSync11 } from "fs";
5943
6412
  import { homedir as homedir5 } from "os";
5944
- import { join as join15 } from "path";
6413
+ import { join as join17 } from "path";
5945
6414
  var RUNTIMES = ["claude-code", "codex", "pi"];
5946
6415
  var isRuntime = (v) => RUNTIMES.includes(v);
5947
6416
  var STRING_FIELDS = ["nodeId", "enrolToken", "name", "tenantId", "updateCheckedAt", "updateLatest"];
@@ -5949,29 +6418,29 @@ var isDesired = (v) => v === "running" || v === "stopped";
5949
6418
  var FILE_MODE2 = 384;
5950
6419
  var DIR_MODE2 = 448;
5951
6420
  function stateDir(env) {
5952
- return env.DAHRK_STATE_DIR ?? join15(homedir5(), ".dahrk");
6421
+ return env.DAHRK_STATE_DIR ?? join17(homedir5(), ".dahrk");
5953
6422
  }
5954
6423
  function legacyStateDir(env) {
5955
- return env.DAHRK_STATE_DIR ? void 0 : join15(homedir5(), ".skakel");
6424
+ return env.DAHRK_STATE_DIR ? void 0 : join17(homedir5(), ".skakel");
5956
6425
  }
5957
6426
  function stateFile(env) {
5958
- return join15(stateDir(env), "node.json");
6427
+ return join17(stateDir(env), "node.json");
5959
6428
  }
5960
6429
  function logDir(env) {
5961
- return join15(stateDir(env), "logs");
6430
+ return join17(stateDir(env), "logs");
5962
6431
  }
5963
6432
  function logFiles(env) {
5964
6433
  const dir = logDir(env);
5965
- return { out: join15(dir, "node.out.log"), err: join15(dir, "node.err.log") };
6434
+ return { out: join17(dir, "node.out.log"), err: join17(dir, "node.err.log") };
5966
6435
  }
5967
6436
  function jsonlLogFile(env) {
5968
- return join15(logDir(env), "node.jsonl");
6437
+ return join17(logDir(env), "node.jsonl");
5969
6438
  }
5970
6439
  function crashDir(env) {
5971
- return join15(logDir(env), "crashes");
6440
+ return join17(logDir(env), "crashes");
5972
6441
  }
5973
6442
  function lockFile(env) {
5974
- return join15(stateDir(env), "node.pid");
6443
+ return join17(stateDir(env), "node.pid");
5975
6444
  }
5976
6445
  function setDesired(env, desired) {
5977
6446
  writeState(env, { desired });
@@ -6001,7 +6470,7 @@ function writeState(env, patch) {
6001
6470
  try {
6002
6471
  mkdirSync11(dir, { recursive: true, mode: DIR_MODE2 });
6003
6472
  const next = { ...readState(file), ...patch };
6004
- writeFileSync10(file, `${JSON.stringify(next, null, 2)}
6473
+ writeFileSync11(file, `${JSON.stringify(next, null, 2)}
6005
6474
  `, { mode: FILE_MODE2 });
6006
6475
  chmodSync2(file, FILE_MODE2);
6007
6476
  } catch (e) {
@@ -6082,9 +6551,9 @@ ${envEntries}
6082
6551
  <key>ThrottleInterval</key>
6083
6552
  <integer>10</integer>
6084
6553
  <key>StandardOutPath</key>
6085
- <string>${xmlEscape(join16(inputs.logDir, "node.out.log"))}</string>
6554
+ <string>${xmlEscape(join18(inputs.logDir, "node.out.log"))}</string>
6086
6555
  <key>StandardErrorPath</key>
6087
- <string>${xmlEscape(join16(inputs.logDir, "node.err.log"))}</string>
6556
+ <string>${xmlEscape(join18(inputs.logDir, "node.err.log"))}</string>
6088
6557
  </dict>
6089
6558
  </plist>
6090
6559
  `;
@@ -6107,8 +6576,8 @@ Type=simple
6107
6576
  ExecStart=${exec}
6108
6577
  ${envLines}
6109
6578
  WorkingDirectory=${inputs.homeDir}
6110
- StandardOutput=append:${join16(inputs.logDir, "node.out.log")}
6111
- StandardError=append:${join16(inputs.logDir, "node.err.log")}
6579
+ StandardOutput=append:${join18(inputs.logDir, "node.out.log")}
6580
+ StandardError=append:${join18(inputs.logDir, "node.err.log")}
6112
6581
  Restart=on-failure
6113
6582
  RestartSec=3
6114
6583
  RestartPreventExitStatus=78
@@ -6119,7 +6588,7 @@ WantedBy=default.target
6119
6588
  }
6120
6589
  function buildPlan(inputs) {
6121
6590
  if (inputs.manager === "launchd") {
6122
- const filePath2 = join16(inputs.homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
6591
+ const filePath2 = join18(inputs.homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
6123
6592
  return {
6124
6593
  manager: "launchd",
6125
6594
  label: LAUNCHD_LABEL,
@@ -6136,7 +6605,7 @@ function buildPlan(inputs) {
6136
6605
  logHint: "dahrk logs -f"
6137
6606
  };
6138
6607
  }
6139
- const filePath = join16(inputs.homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
6608
+ const filePath = join18(inputs.homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
6140
6609
  const user = userInfo().username;
6141
6610
  return {
6142
6611
  manager: "systemd",
@@ -6166,7 +6635,7 @@ function unitIsCurrent(plan, onDisk) {
6166
6635
  return onDisk === plan.content;
6167
6636
  }
6168
6637
  function unitPath(manager, homeDir) {
6169
- return manager === "launchd" ? join16(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`) : join16(homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
6638
+ return manager === "launchd" ? join18(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`) : join18(homeDir, ".config", "systemd", "user", SYSTEMD_UNIT);
6170
6639
  }
6171
6640
  function statusCommand(manager) {
6172
6641
  return manager === "launchd" ? ["launchctl", "list", LAUNCHD_LABEL] : ["systemctl", "--user", "show", SYSTEMD_UNIT, "-p", "ActiveState", "-p", "MainPID"];
@@ -6468,7 +6937,7 @@ var defaultDeps3 = () => ({
6468
6937
  // no reason for it to be world-readable. `writeFileSync`'s `mode` applies only when it CREATES the file,
6469
6938
  // so chmod explicitly too - re-installing over a unit an older client left at 0644 must tighten it.
6470
6939
  writeFile: (path, content) => {
6471
- writeFileSync11(path, content, { mode: UNIT_FILE_MODE });
6940
+ writeFileSync12(path, content, { mode: UNIT_FILE_MODE });
6472
6941
  chmodSync3(path, UNIT_FILE_MODE);
6473
6942
  },
6474
6943
  readFile: (path) => {
@@ -6478,7 +6947,7 @@ var defaultDeps3 = () => ({
6478
6947
  return void 0;
6479
6948
  }
6480
6949
  },
6481
- removeFile: (path) => rmSync7(path, { force: true }),
6950
+ removeFile: (path) => rmSync8(path, { force: true }),
6482
6951
  fileExists: (path) => existsSync13(path),
6483
6952
  // Captured, never inherited. A supervisor's stderr is ours to relay when it matters, not to leak
6484
6953
  // unconditionally: `runCommands` decides who needs to read it.
@@ -6919,7 +7388,7 @@ async function runStatus(inputs, deps) {
6919
7388
  }
6920
7389
 
6921
7390
  // src/main.ts
6922
- var CLIENT_VERSION = "0.1.20";
7391
+ var CLIENT_VERSION = "0.1.22";
6923
7392
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
6924
7393
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
6925
7394
  var RUNTIMES2 = ["claude-code", "codex", "pi"];
@@ -6931,7 +7400,7 @@ function resolveNodeId(env, opts = {}) {
6931
7400
  if (existing) return existing;
6932
7401
  const legacy = legacyStateDir(env);
6933
7402
  if (legacy) {
6934
- const legacyId = readState(join17(legacy, "node.json")).nodeId;
7403
+ const legacyId = readState(join19(legacy, "node.json")).nodeId;
6935
7404
  if (legacyId) return legacyId;
6936
7405
  }
6937
7406
  const nodeId = randomUUID3();
@@ -7312,6 +7781,7 @@ async function runRepoAdd(flags) {
7312
7781
  const bits = [];
7313
7782
  if (result.drift.branch) bits.push(`branch ${result.drift.branch} (this repo is on ${repo.defaultBranch})`);
7314
7783
  if (result.drift.name) bits.push(`name ${result.drift.name}`);
7784
+ if (result.drift.gitUrl) bits.push(`URL ${result.drift.gitUrl} (this repo registers as ${repo.gitUrl})`);
7315
7785
  out(hint(`The hub's stored record differs - ${bits.join(", ")} - and was left unchanged.`));
7316
7786
  }
7317
7787
  return 0;