dahrk-node 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +668 -457
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -74,6 +74,13 @@ import {
74
74
  query
75
75
  } from "@anthropic-ai/claude-agent-sdk";
76
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
+
77
84
  // ../../packages/executor-worktree/src/claude-mappers.ts
78
85
  function mapClaudeMessage(msg) {
79
86
  switch (msg.type) {
@@ -168,27 +175,21 @@ function consumeClaudeMessage(msg, state, suppressStageExit) {
168
175
  }
169
176
  if (msg.type === "result") {
170
177
  const status = r.resultStatus === "fail" ? "fail" : "ok";
171
- let responseText;
172
- if (status === "ok" && state.bufferedText && !state.turnEndedOnTool) {
173
- responseText = state.bufferedText;
174
- events.push({ type: "response", text: state.bufferedText });
175
- }
178
+ const decision = decideResponse(state.bufferedText ?? "", state.turnEndedOnTool, status);
179
+ if (decision.event) events.push(decision.event);
176
180
  for (const e of r.events) {
177
181
  if (suppressStageExit && e.type === "state") continue;
178
182
  events.push(e);
179
183
  }
180
184
  state.bufferedText = null;
181
185
  state.turnEndedOnTool = false;
182
- return { events, isResult: true, status, responseText };
186
+ return { events, isResult: true, status, responseText: decision.responseText };
183
187
  }
184
188
  events.push(...r.events);
185
189
  return { events, isResult: false };
186
190
  }
187
191
 
188
- // ../../packages/executor-worktree/src/runner-shared.ts
189
- import { readFileSync } from "fs";
190
- import { join } from "path";
191
- import { attachedDocBasename } from "@dahrk/contracts";
192
+ // ../../packages/executor-worktree/src/runtime-session.ts
192
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.";
193
194
  function makeEmit(runtime, onTrace, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
194
195
  return (event, rawRef) => {
@@ -197,6 +198,11 @@ function makeEmit(runtime, onTrace, now = () => (/* @__PURE__ */ new Date()).toI
197
198
  onTrace(full);
198
199
  };
199
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";
200
206
  function stripFrontmatter(text) {
201
207
  const lines = text.split("\n");
202
208
  if (lines[0]?.trim() !== "---") return text;
@@ -319,6 +325,8 @@ var OPENING_KICKOFF = "Begin now. Using the ticket context and your instructions
319
325
  function interactiveSeedText(ctx, instructionInSystemPrompt) {
320
326
  return instructionInSystemPrompt ? OPENING_KICKOFF : resolveStagePrompt(ctx);
321
327
  }
328
+
329
+ // ../../packages/executor-worktree/src/mailbox.ts
322
330
  var ManagedMailbox = class {
323
331
  q = [];
324
332
  waiters = [];
@@ -344,36 +352,19 @@ var ManagedMailbox = class {
344
352
  };
345
353
  }
346
354
  };
347
- function interactiveIdleWindows(ctx) {
348
- const idleMs = ctx.config.idleMs ?? Number(process.env.DAHRK_INTERACTIVE_IDLE_MS ?? process.env.SKAKEL_INTERACTIVE_IDLE_MS ?? 12e4);
349
- const firstReplyMs = ctx.config.firstReplyMs ?? Number(process.env.DAHRK_INTERACTIVE_FIRST_REPLY_MS ?? process.env.SKAKEL_INTERACTIVE_FIRST_REPLY_MS ?? 6e5);
350
- return { firstReplyMs: Math.max(firstReplyMs, idleMs), idleMs };
351
- }
352
- function raceNextTurn(pending, idleMs, signal) {
353
- return new Promise((resolve3) => {
354
- let settled = false;
355
- let timer;
356
- function finish(r) {
357
- if (settled) return;
358
- settled = true;
359
- if (timer) clearTimeout(timer);
360
- signal.removeEventListener("abort", onAbort);
361
- resolve3(r);
362
- }
363
- function onAbort() {
364
- finish({ kind: "cancelled" });
365
- }
366
- if (signal.aborted) {
367
- finish({ kind: "cancelled" });
368
- return;
369
- }
370
- signal.addEventListener("abort", onAbort);
371
- timer = setTimeout(() => finish({ kind: "idle-timeout" }), idleMs);
372
- pending.then(
373
- (res) => finish(res.done ? { kind: "turns-exhausted" } : { kind: "turn", value: res.value }),
374
- () => finish({ kind: "turns-exhausted" })
375
- );
376
- });
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
+ }
377
368
  }
378
369
  function createElicitTurnRouter(turns, opts) {
379
370
  const conversation = new ManagedMailbox();
@@ -417,6 +408,165 @@ function createElicitTurnRouter(turns, opts) {
417
408
  return { conversation, ask };
418
409
  }
419
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
+ function classifyRuntimeError(message) {
538
+ const m = message.toLowerCase();
539
+ const transient = m.includes("stream idle timeout") || m.includes("partial response received") || m.includes("overloaded") || /\b529\b/.test(m) || m.includes("rate limit") || m.includes("rate_limit") || m.includes("too many requests") || /\b429\b/.test(m) || m.includes("gateway timeout") || m.includes("bad gateway") || m.includes("service unavailable") || m.includes("internal server error") || /\b50[0234]\b/.test(m) || m.includes("econnreset") || m.includes("connection reset") || m.includes("socket hang up") || m.includes("connection error");
540
+ return transient ? "external" : void 0;
541
+ }
542
+ async function runBatchLoop(session, ctx, hooks, opts) {
543
+ let status = "ok";
544
+ let failureClass;
545
+ let summary;
546
+ try {
547
+ const tr = await session.sendTurn(resolveStagePrompt(ctx));
548
+ if (tr.status) status = tr.status;
549
+ } catch (e) {
550
+ const message = e.message;
551
+ if (!opts.cancelled()) {
552
+ hooks.emit({ type: "error", kind: "runtime_error", message });
553
+ failureClass = classifyRuntimeError(message);
554
+ if (failureClass) summary = `upstream API transient: ${message}`;
555
+ }
556
+ status = "fail";
557
+ }
558
+ if (opts.cancelled()) status = "fail";
559
+ const costUsd = session.cost();
560
+ const sessionId = session.sessionId;
561
+ return {
562
+ status,
563
+ ...summary !== void 0 ? { summary } : {},
564
+ ...failureClass ? { failureClass } : {},
565
+ ...sessionId ? { sessionId } : {},
566
+ ...costUsd !== void 0 ? { costUsd } : {}
567
+ };
568
+ }
569
+
420
570
  // ../../packages/executor-worktree/src/stage-complete-tool.ts
421
571
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
422
572
  import { z } from "zod";
@@ -424,6 +574,10 @@ var STAGE_COMPLETE_TOOL_NAME = "mcp__dahrk__dahrk_stage_complete";
424
574
  function createStageCompleteTool() {
425
575
  let captured = null;
426
576
  let capturedDoc = null;
577
+ const capture = (args) => {
578
+ captured = args.summary;
579
+ if (args.document !== void 0) capturedDoc = args.document;
580
+ };
427
581
  const completeTool = tool(
428
582
  "dahrk_stage_complete",
429
583
  "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.",
@@ -432,8 +586,7 @@ function createStageCompleteTool() {
432
586
  document: z.string().optional().describe("The full markdown body of the stage's deliverable document, if any.")
433
587
  },
434
588
  async (args) => {
435
- captured = args.summary;
436
- if (args.document !== void 0) capturedDoc = args.document;
589
+ capture(args);
437
590
  return { content: [{ type: "text", text: "Stage marked complete." }] };
438
591
  }
439
592
  );
@@ -442,7 +595,8 @@ function createStageCompleteTool() {
442
595
  allowedToolName: STAGE_COMPLETE_TOOL_NAME,
443
596
  fired: () => captured !== null,
444
597
  summary: () => captured,
445
- document: () => capturedDoc
598
+ document: () => capturedDoc,
599
+ capture
446
600
  };
447
601
  }
448
602
 
@@ -496,7 +650,6 @@ function createAskUserQuestionTool(deps) {
496
650
  }
497
651
 
498
652
  // ../../packages/executor-worktree/src/claude-adapter.ts
499
- var COALESCE_MS = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
500
653
  var MAX_TURNS = Number(process.env.DAHRK_MAX_TURNS ?? process.env.SKAKEL_MAX_TURNS ?? 64);
501
654
  var HANDED_BACK_ARTIFACT_PATH = ".dahrk/scratch/output/document.md";
502
655
  var CLAUDE_CODE_SYSTEM_PROMPT = { type: "preset", preset: "claude_code" };
@@ -505,6 +658,7 @@ var userMsg = (text) => ({
505
658
  parent_tool_use_id: null,
506
659
  message: { role: "user", content: text }
507
660
  });
661
+ var defaultAsk = async () => elicitOutcomeReply({ kind: "noreply" });
508
662
  var sessionIdOf = (msg) => "session_id" in msg && typeof msg.session_id === "string" ? msg.session_id : void 0;
509
663
  var policyCanUseTool = async (ctx, toolName, input) => {
510
664
  const verdict2 = ctx.authorizeToolUse?.(toolName, input);
@@ -544,7 +698,25 @@ function runtimeEnvOptions(ctx) {
544
698
  if (!ctx.runtimeEnv) return {};
545
699
  return { env: { ...process.env, ...ctx.runtimeEnv } };
546
700
  }
547
- function createClaudeRunner() {
701
+ var defaultCreateClaudeSession = ({ prompt, options }) => {
702
+ const mailbox = prompt === void 0 ? new ManagedMailbox() : void 0;
703
+ const q = query({ prompt: mailbox ?? prompt, options });
704
+ const it = q[Symbol.asyncIterator]();
705
+ return {
706
+ [Symbol.asyncIterator]: () => it,
707
+ push: (m) => mailbox?.push(m),
708
+ end: () => mailbox?.end(),
709
+ close: () => {
710
+ try {
711
+ q.close();
712
+ } catch {
713
+ }
714
+ },
715
+ interrupt: () => q.interrupt()
716
+ };
717
+ };
718
+ function createClaudeRunner(deps = {}) {
719
+ const createSession = deps.createSession ?? defaultCreateClaudeSession;
548
720
  const abortController = new AbortController();
549
721
  let cancelled = false;
550
722
  let active;
@@ -609,11 +781,86 @@ function createClaudeRunner() {
609
781
  }
610
782
  active = void 0;
611
783
  };
784
+ const makeClaudeRuntimeSession = (ctx, hooks, mode) => {
785
+ const state = newBufferState();
786
+ const it = mode.interactive ? mode.session[Symbol.asyncIterator]() : void 0;
787
+ const consumeInteractiveTurn = async () => {
788
+ for (; ; ) {
789
+ const { value: msg, done } = await it.next();
790
+ if (done) return {};
791
+ const res = handleMessage(msg, hooks.emit, ctx, state, true);
792
+ if (res.isResult) return { status: res.status, responseText: res.responseText };
793
+ }
794
+ };
795
+ return {
796
+ get sessionId() {
797
+ return sessionId;
798
+ },
799
+ async sendTurn(text) {
800
+ if (!mode.interactive) {
801
+ const session = createSession({ prompt: text, options: mode.options });
802
+ active = session;
803
+ let status2;
804
+ try {
805
+ for await (const msg of session) {
806
+ const res = handleMessage(msg, hooks.emit, ctx, state, false);
807
+ if (res.isResult && res.status) status2 = res.status;
808
+ }
809
+ } finally {
810
+ closeActive();
811
+ }
812
+ return { stageComplete: false, ...status2 !== void 0 ? { status: status2 } : {} };
813
+ }
814
+ mode.session.push(userMsg(text));
815
+ const { status, responseText } = await consumeInteractiveTurn();
816
+ const stageComplete = mode.stageTool.fired();
817
+ const doc = stageComplete ? mode.stageTool.document() : null;
818
+ return {
819
+ stageComplete,
820
+ ...stageComplete ? { summary: mode.stageTool.summary() ?? void 0 } : {},
821
+ ...responseText !== void 0 ? { responseText } : {},
822
+ ...status !== void 0 ? { status } : {},
823
+ ...doc !== null ? { artifact: { path: ctx.config.emitArtifact ?? HANDED_BACK_ARTIFACT_PATH, content: doc } } : {}
824
+ };
825
+ },
826
+ async summariseTurn() {
827
+ if (!mode.interactive) return "(no summary produced)";
828
+ mode.summarising.value = true;
829
+ try {
830
+ mode.session.push(userMsg(SUMMARISE_PROMPT));
831
+ const { responseText } = await consumeInteractiveTurn();
832
+ return (responseText ?? "").trim() || "(no summary produced)";
833
+ } catch {
834
+ return "(no summary produced)";
835
+ } finally {
836
+ mode.summarising.value = false;
837
+ }
838
+ },
839
+ cost() {
840
+ return costUsd;
841
+ },
842
+ dispose() {
843
+ if (!mode.interactive) return;
844
+ const iter = it;
845
+ mode.session.end();
846
+ void (async () => {
847
+ try {
848
+ for (; ; ) {
849
+ const { done } = await iter.next();
850
+ if (done) break;
851
+ }
852
+ } catch {
853
+ } finally {
854
+ closeActive();
855
+ }
856
+ })();
857
+ }
858
+ };
859
+ };
612
860
  return {
613
861
  runtime: "claude-code",
614
862
  async runBatch(ctx, onTrace) {
615
- const emit = makeEmit("claude-code", onTrace);
616
- const prompt = resolveStagePrompt(ctx);
863
+ const hooks = { emit: makeEmit("claude-code", onTrace), ask: defaultAsk };
617
864
  const mcpServers = buildBrokeredMcpServers(ctx);
618
865
  const options = {
619
866
  ...baseOptions(ctx),
@@ -624,171 +871,50 @@ function createClaudeRunner() {
624
871
  // Headless: allow tools to run without an interactive permission prompt (M6 wires policy).
625
872
  canUseTool: async (toolName, input) => policyCanUseTool(ctx, toolName, input)
626
873
  };
627
- const state = newBufferState();
628
- let status = "ok";
629
- try {
630
- const q = query({ prompt, options });
631
- active = q;
632
- for await (const msg of q) {
633
- const res = handleMessage(msg, emit, ctx, state, false);
634
- if (res.isResult && res.status) status = res.status;
635
- }
636
- } catch (e) {
637
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
638
- status = "fail";
639
- } finally {
640
- closeActive();
641
- }
642
- if (cancelled) status = "fail";
643
- return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
874
+ const rt = makeClaudeRuntimeSession(ctx, hooks, { interactive: false, options });
875
+ return runBatchLoop(rt, ctx, hooks, { cancelled: () => cancelled });
644
876
  },
645
877
  async runInteractive(ctx, turns, onTrace) {
646
878
  const emit = makeEmit("claude-code", onTrace);
647
879
  const stageTool = createStageCompleteTool();
648
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
649
- let awaitingFirstReply = true;
650
- const router = createElicitTurnRouter(turns, { signal: abortController.signal, firstReplyMs, idleMs });
651
- const askTool = createAskUserQuestionTool({
652
- ask: async (question) => {
653
- const outcome = await router.ask(awaitingFirstReply, () => {
654
- emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
655
- ctx.emitElicit?.(question);
656
- });
657
- switch (outcome.kind) {
658
- case "reply":
659
- return `The user selected: ${outcome.text}`;
660
- case "busy":
661
- return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
662
- case "noreply":
663
- return "No response from the user; proceed with your best judgement.";
664
- case "cancel":
665
- return "The question was cancelled.";
666
- }
667
- }
668
- });
669
- const brokered = buildBrokeredMcpServers(ctx);
670
- let summarising = false;
671
- const options = {
672
- ...baseOptions(ctx),
673
- // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
674
- // interactive persona still gets it without losing the working-directory context.
675
- systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
676
- // Inject the stage-complete exit tool and the AskUserQuestion shadow alongside any brokered
677
- // MCP servers (parity with batch).
678
- mcpServers: { dahrk: stageTool.server, ask: askTool.server, ...brokered ?? {} },
679
- // Redirect the built-in AskUserQuestion to the shadow tool so a structured question surfaces
680
- // as a Linear elicitation. The redirect is name-only and single-hop; the tool still runs, so
681
- // this is mapping, not gating (DHK-344 / DHK-223).
682
- toolAliases: { [ASK_USER_QUESTION_ALIAS]: askTool.allowedToolName },
683
- // Auto-approve the injected tools; `allowedTools` is an auto-approve list, not a whitelist, so
684
- // it does not restrict the other tools canUseTool allows.
685
- allowedTools: [stageTool.allowedToolName, askTool.allowedToolName],
686
- canUseTool: async (toolName, input) => interactiveCanUseTool(summarising, stageTool.allowedToolName, ctx, toolName, input),
687
- maxTurns: MAX_TURNS,
688
- includePartialMessages: false
689
- };
690
- const exit = ctx.config.exit ?? "either";
691
- const wantsTool = exit === "tool" || exit === "either";
692
- const mailbox = new ManagedMailbox();
693
- const q = query({ prompt: mailbox, options });
694
- active = q;
695
- const it = q[Symbol.asyncIterator]();
696
- const humanIter = router.conversation[Symbol.asyncIterator]();
697
- const state = newBufferState();
698
- const consumeTurn = async () => {
699
- for (; ; ) {
700
- const { value: msg, done } = await it.next();
701
- if (done) return void 0;
702
- const res = handleMessage(msg, emit, ctx, state, true);
703
- if (res.isResult) return res.responseText;
704
- }
705
- };
706
- let exited = null;
707
- let pending = humanIter.next();
708
- try {
709
- mailbox.push(userMsg(interactiveSeedText(ctx, hasSystemPrompt(ctx))));
710
- await consumeTurn();
711
- if (stageTool.fired() && wantsTool) exited = "tool";
712
- while (exited === null) {
713
- const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, abortController.signal);
714
- awaitingFirstReply = false;
715
- if (race.kind === "cancelled") {
716
- exited = "cancelled";
717
- break;
718
- }
719
- if (race.kind === "idle-timeout") {
720
- exited = "timeout";
721
- break;
722
- }
723
- if (race.kind === "turns-exhausted") {
724
- exited = "gate";
725
- break;
726
- }
727
- const texts = [race.value.text];
728
- pending = humanIter.next();
729
- for (; ; ) {
730
- const more = await raceNextTurn(pending, COALESCE_MS, abortController.signal);
731
- if (more.kind === "turn") {
732
- texts.push(more.value.text);
733
- pending = humanIter.next();
734
- continue;
735
- }
736
- if (more.kind === "cancelled") exited = "cancelled";
737
- break;
738
- }
739
- if (exited === "cancelled") break;
740
- mailbox.push(userMsg(texts.join("\n")));
741
- await consumeTurn();
742
- if (stageTool.fired() && wantsTool) {
743
- exited = "tool";
744
- break;
745
- }
746
- }
747
- } catch (e) {
748
- if (!cancelled && !exited) emit({ type: "error", kind: "runtime_error", message: e.message });
749
- exited = exited ?? (cancelled ? "cancelled" : "gate");
750
- }
751
- let status = "ok";
752
- let summary = "";
753
- if (exited === "tool") {
754
- summary = stageTool.summary() ?? "(stage marked complete)";
755
- } else if (exited === "gate") {
756
- summarising = true;
757
- try {
758
- mailbox.push(userMsg(SUMMARISE_PROMPT));
759
- const reply = await consumeTurn();
760
- summary = (reply ?? "").trim() || "(no summary produced)";
761
- } catch {
762
- summary = "(no summary produced)";
763
- } finally {
764
- summarising = false;
765
- }
766
- } else if (exited === "timeout") {
767
- status = "timeout";
768
- summary = "(stage timed out awaiting input)";
769
- await this.cancel();
770
- } else {
771
- status = "fail";
772
- summary = "(stage cancelled)";
773
- }
774
- mailbox.end();
775
- try {
776
- for (; ; ) {
777
- const { done } = await it.next();
778
- if (done) break;
779
- }
780
- } catch {
781
- }
782
- closeActive();
783
- const handedBackDoc = status === "ok" ? stageTool.document() : null;
784
- const artifact = handedBackDoc !== null ? { path: ctx.config.emitArtifact ?? HANDED_BACK_ARTIFACT_PATH, content: handedBackDoc } : void 0;
785
- return {
786
- status,
787
- summary,
788
- ...sessionId ? { sessionId } : {},
789
- ...costUsd !== void 0 ? { costUsd } : {},
790
- ...artifact ? { artifact } : {}
880
+ const summarising = { value: false };
881
+ let rt;
882
+ const makeSession = (hooks) => {
883
+ const askTool = createAskUserQuestionTool({ ask: (question) => hooks.ask(question) });
884
+ const brokered = buildBrokeredMcpServers(ctx);
885
+ const options = {
886
+ ...baseOptions(ctx),
887
+ // Keep the cwd-anchoring preset; fold the stage instruction in via `append` so the
888
+ // interactive persona still gets it without losing the working-directory context.
889
+ systemPrompt: hasSystemPrompt(ctx) ? { type: "preset", preset: "claude_code", append: resolveStagePrompt(ctx) } : CLAUDE_CODE_SYSTEM_PROMPT,
890
+ // Inject the stage-complete exit tool and the AskUserQuestion shadow alongside any brokered
891
+ // MCP servers (parity with batch).
892
+ mcpServers: { dahrk: stageTool.server, ask: askTool.server, ...brokered ?? {} },
893
+ // Redirect the built-in AskUserQuestion to the shadow tool so a structured question surfaces
894
+ // as a Linear elicitation. The redirect is name-only and single-hop; the tool still runs, so
895
+ // this is mapping, not gating (DHK-344 / DHK-223).
896
+ toolAliases: { [ASK_USER_QUESTION_ALIAS]: askTool.allowedToolName },
897
+ // Auto-approve the injected tools; `allowedTools` is an auto-approve list, not a whitelist, so
898
+ // it does not restrict the other tools canUseTool allows.
899
+ allowedTools: [stageTool.allowedToolName, askTool.allowedToolName],
900
+ canUseTool: async (toolName, input) => interactiveCanUseTool(summarising.value, stageTool.allowedToolName, ctx, toolName, input),
901
+ maxTurns: MAX_TURNS,
902
+ includePartialMessages: false
903
+ };
904
+ const session = createSession({ options, stageTool });
905
+ active = session;
906
+ rt = makeClaudeRuntimeSession(ctx, hooks, { interactive: true, session, stageTool, summarising });
907
+ return rt;
791
908
  };
909
+ const result = await runInteractiveLoop(ctx, turns, emit, makeSession, {
910
+ signal: abortController.signal,
911
+ cancelled: () => cancelled,
912
+ cancel: () => this.cancel(),
913
+ // Claude carries the stage instruction in its system prompt, so the seed can be a short kickoff.
914
+ instructionInSystemPrompt: hasSystemPrompt(ctx)
915
+ });
916
+ rt?.dispose();
917
+ return result;
792
918
  },
793
919
  async summarise(ctx) {
794
920
  if (!sessionId) return "(no summary: session not established)";
@@ -803,9 +929,9 @@ function createClaudeRunner() {
803
929
  };
804
930
  try {
805
931
  let out2 = "";
806
- const q = query({ prompt: SUMMARISE_PROMPT, options });
807
- active = q;
808
- for await (const msg of q) {
932
+ const session = createSession({ prompt: SUMMARISE_PROMPT, options });
933
+ active = session;
934
+ for await (const msg of session) {
809
935
  const found = sessionIdOf(msg);
810
936
  if (found) sessionId = found;
811
937
  if (msg.type === "result" && msg.subtype === "success") out2 += msg.result;
@@ -833,6 +959,16 @@ function createClaudeRunner() {
833
959
  import { join as join4 } from "path";
834
960
 
835
961
  // ../../packages/executor-worktree/src/pi-mappers.ts
962
+ function parsePiEvent(msg) {
963
+ if (typeof msg !== "object" || msg === null) return null;
964
+ const type = msg.type;
965
+ if (typeof type !== "string") return null;
966
+ if (type === "message_update") {
967
+ const ame = msg.assistantMessageEvent;
968
+ if (typeof ame !== "object" || ame === null) return null;
969
+ }
970
+ return msg;
971
+ }
836
972
  function mapUsage2(u) {
837
973
  return {
838
974
  input: u?.input ?? 0,
@@ -929,12 +1065,8 @@ function consumePiEvent(ev, state, suppressStageExit) {
929
1065
  if (ev.type === "turn_end" || ev.type === "agent_end") {
930
1066
  const r = mapPiEvent(ev);
931
1067
  const status = settleStatus(settleMessage(ev));
932
- let responseText;
933
- const text = state.bufferedText.trim();
934
- if (status === "ok" && text && !state.turnEndedOnTool) {
935
- responseText = text;
936
- events.push({ type: "response", text });
937
- }
1068
+ const decision = decideResponse(state.bufferedText, state.turnEndedOnTool, status);
1069
+ if (decision.event) events.push(decision.event);
938
1070
  if (state.pendingThought) {
939
1071
  events.push({ type: "thought", subtype: "reasoning_text", text: state.pendingThought });
940
1072
  }
@@ -945,7 +1077,7 @@ function consumePiEvent(ev, state, suppressStageExit) {
945
1077
  state.bufferedText = "";
946
1078
  state.pendingThought = "";
947
1079
  state.turnEndedOnTool = false;
948
- return { events, isResult: true, status, responseText };
1080
+ return { events, isResult: true, status, responseText: decision.responseText };
949
1081
  }
950
1082
  return { events, isResult: false };
951
1083
  }
@@ -1007,7 +1139,6 @@ function writeStageCustomProviders(dir, hint2) {
1007
1139
  }
1008
1140
 
1009
1141
  // ../../packages/executor-worktree/src/pi-adapter.ts
1010
- var COALESCE_MS2 = Number(process.env.DAHRK_COALESCE_MS ?? process.env.SKAKEL_COALESCE_MS ?? 40);
1011
1142
  var PI_STAGE_COMPLETE_TOOL = "dahrk_stage_complete";
1012
1143
  var PI_ASK_USER_QUESTION_TOOL = "ask_user_question";
1013
1144
  function piToolCallDecision(ctx, toolName, input) {
@@ -1061,20 +1192,82 @@ function createBrokeredMcpExtension(servers) {
1061
1192
  }
1062
1193
  };
1063
1194
  }
1195
+ var defaultAsk2 = async () => elicitOutcomeReply({ kind: "noreply" });
1196
+ function makePiRuntimeSession(s, ctx, hooks, interactive) {
1197
+ return {
1198
+ get sessionId() {
1199
+ return s.sessionId;
1200
+ },
1201
+ async sendTurn(text) {
1202
+ const state = newPiBufferState();
1203
+ let stageComplete = false;
1204
+ let summary;
1205
+ let stageCompleteCallId;
1206
+ let responseText;
1207
+ let status;
1208
+ const unsub = s.subscribe((ev) => {
1209
+ const rawRef = ctx.writeRaw?.(ev);
1210
+ if (ev.type === "tool_execution_start" && ev.toolName === PI_STAGE_COMPLETE_TOOL) {
1211
+ stageComplete = true;
1212
+ stageCompleteCallId = ev.toolCallId;
1213
+ const args = ev.args;
1214
+ if (args?.summary) summary = args.summary;
1215
+ return;
1216
+ }
1217
+ if (ev.type === "tool_execution_end" && ev.toolCallId === stageCompleteCallId) return;
1218
+ const r = consumePiEvent(ev, state, interactive);
1219
+ for (const e of r.events) hooks.emit(e, rawRef);
1220
+ if (r.responseText) responseText = r.responseText;
1221
+ if (r.isResult && r.status) status = r.status;
1222
+ });
1223
+ try {
1224
+ await s.prompt(text);
1225
+ } finally {
1226
+ unsub();
1227
+ }
1228
+ return {
1229
+ stageComplete,
1230
+ ...summary !== void 0 ? { summary } : {},
1231
+ ...responseText !== void 0 ? { responseText } : {},
1232
+ ...status !== void 0 ? { status } : {}
1233
+ };
1234
+ },
1235
+ async summariseTurn() {
1236
+ if (s.agent) s.agent.state.tools = [];
1237
+ const state = newPiBufferState();
1238
+ let out2;
1239
+ const unsub = s.subscribe((ev) => {
1240
+ const r = consumePiEvent(ev, state, true);
1241
+ if (r.responseText) out2 = r.responseText;
1242
+ });
1243
+ try {
1244
+ await s.prompt(SUMMARISE_PROMPT);
1245
+ return (out2 ?? "").trim() || "(no summary produced)";
1246
+ } catch (e) {
1247
+ return `(summary unavailable: ${e.message})`;
1248
+ } finally {
1249
+ unsub();
1250
+ }
1251
+ },
1252
+ cost() {
1253
+ const cost = s.getSessionStats?.()?.cost;
1254
+ return typeof cost === "number" ? cost : void 0;
1255
+ },
1256
+ dispose() {
1257
+ s.dispose();
1258
+ }
1259
+ };
1260
+ }
1064
1261
  function createPiRunner(deps = {}) {
1065
1262
  const createSession = deps.createSession ?? defaultCreatePiSession;
1066
1263
  const abortController = new AbortController();
1067
1264
  const signal = abortController.signal;
1068
1265
  let cancelled = false;
1069
1266
  let session;
1070
- let sessionId;
1071
1267
  const openSession = async (ctx) => {
1072
1268
  if (!session) session = await createSession(ctx);
1073
1269
  return session;
1074
1270
  };
1075
- const captureSessionId = (s) => {
1076
- if (s.sessionId) sessionId = s.sessionId;
1077
- };
1078
1271
  let sessionDisposed = false;
1079
1272
  const disposeSession = () => {
1080
1273
  if (sessionDisposed || !session) return;
@@ -1084,178 +1277,41 @@ function createPiRunner(deps = {}) {
1084
1277
  } catch {
1085
1278
  }
1086
1279
  };
1087
- const readSessionCost = (s) => {
1088
- const cost = s.getSessionStats?.()?.cost;
1089
- return typeof cost === "number" ? cost : void 0;
1090
- };
1091
1280
  return {
1092
1281
  runtime: "pi",
1093
1282
  async runBatch(ctx, onTrace) {
1094
- const emit = makeEmit("pi", onTrace);
1283
+ const hooks = { emit: makeEmit("pi", onTrace), ask: defaultAsk2 };
1095
1284
  const s = await openSession(ctx);
1096
1285
  registerToolCallGate(s, ctx);
1097
- const state = newPiBufferState();
1098
- let status = "ok";
1099
- const unsub = s.subscribe((ev) => {
1100
- const rawRef = ctx.writeRaw?.(ev);
1101
- const r = consumePiEvent(ev, state, false);
1102
- for (const e of r.events) emit(e, rawRef);
1103
- if (r.isResult && r.status) status = r.status;
1104
- });
1105
- try {
1106
- await s.prompt(resolveStagePrompt(ctx));
1107
- } catch (e) {
1108
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
1109
- status = "fail";
1110
- } finally {
1111
- unsub();
1112
- }
1113
- captureSessionId(s);
1114
- if (cancelled) status = "fail";
1115
- const costUsd = readSessionCost(s);
1116
- if (status !== "ok") disposeSession();
1117
- return { status, ...sessionId ? { sessionId } : {}, ...costUsd !== void 0 ? { costUsd } : {} };
1286
+ const rt = makePiRuntimeSession(s, ctx, hooks, false);
1287
+ const result = await runBatchLoop(rt, ctx, hooks, { cancelled: () => cancelled });
1288
+ if (result.status !== "ok") disposeSession();
1289
+ return result;
1118
1290
  },
1119
1291
  async runInteractive(ctx, turns, onTrace) {
1120
1292
  const emit = makeEmit("pi", onTrace);
1121
1293
  const s = await openSession(ctx);
1122
1294
  registerToolCallGate(s, ctx);
1123
- const state = newPiBufferState();
1124
- const exit = ctx.config.exit ?? "either";
1125
- const wantsTool = exit === "tool" || exit === "either";
1126
- let toolFired = false;
1127
- let toolSummary = null;
1128
- let stageCompleteCallId;
1129
- let lastResponseText;
1130
- const unsub = s.subscribe((ev) => {
1131
- const rawRef = ctx.writeRaw?.(ev);
1132
- if (ev.type === "tool_execution_start" && ev.toolName === PI_STAGE_COMPLETE_TOOL) {
1133
- toolFired = true;
1134
- stageCompleteCallId = ev.toolCallId;
1135
- const args = ev.args;
1136
- if (args?.summary) toolSummary = args.summary;
1137
- return;
1138
- }
1139
- if (ev.type === "tool_execution_end" && ev.toolCallId === stageCompleteCallId) return;
1140
- const r = consumePiEvent(ev, state, true);
1141
- for (const e of r.events) emit(e, rawRef);
1142
- if (r.responseText) lastResponseText = r.responseText;
1143
- });
1144
- const { firstReplyMs, idleMs } = interactiveIdleWindows(ctx);
1145
- const router = createElicitTurnRouter(turns, { signal, firstReplyMs, idleMs });
1146
- const humanIter = router.conversation[Symbol.asyncIterator]();
1147
- let awaitingFirstReply = true;
1148
- const elicitCtx = ctx;
1149
- const ask = async (question) => {
1150
- const outcome = await router.ask(awaitingFirstReply, () => {
1151
- emit({ type: "elicitation", prompt: question.prompt, signal: "select", options: question.options });
1152
- elicitCtx.emitElicit?.(question);
1153
- });
1154
- switch (outcome.kind) {
1155
- case "reply":
1156
- return `The user selected: ${outcome.text}`;
1157
- case "busy":
1158
- return "Only one question can be asked at a time; wait for the current one to be answered, then ask again.";
1159
- case "noreply":
1160
- return "No response from the user; proceed with your best judgement.";
1161
- case "cancel":
1162
- return "The question was cancelled.";
1163
- }
1295
+ const makeSession = (hooks) => {
1296
+ s.setAskUserQuestionHandler?.((questions) => askQuestionsSequentially(questions, (q) => hooks.ask(q)));
1297
+ return makePiRuntimeSession(s, ctx, hooks, true);
1164
1298
  };
1165
- s.setAskUserQuestionHandler?.((questions) => askQuestionsSequentially(questions, ask));
1166
- let exited = "gate";
1167
- let pending = humanIter.next();
1168
- try {
1169
- await s.prompt(interactiveSeedText(ctx, false));
1170
- if (toolFired && wantsTool) exited = "tool";
1171
- for (; ; ) {
1172
- if (exited === "tool") break;
1173
- const race = await raceNextTurn(pending, awaitingFirstReply ? firstReplyMs : idleMs, signal);
1174
- awaitingFirstReply = false;
1175
- if (race.kind === "cancelled") {
1176
- exited = "cancelled";
1177
- break;
1178
- }
1179
- if (race.kind === "idle-timeout") {
1180
- exited = "timeout";
1181
- break;
1182
- }
1183
- if (race.kind === "turns-exhausted") {
1184
- exited = "gate";
1185
- break;
1186
- }
1187
- const texts = [race.value.text];
1188
- pending = humanIter.next();
1189
- for (; ; ) {
1190
- const more = await raceNextTurn(pending, COALESCE_MS2, signal);
1191
- if (more.kind === "turn") {
1192
- texts.push(more.value.text);
1193
- pending = humanIter.next();
1194
- continue;
1195
- }
1196
- if (more.kind === "cancelled") exited = "cancelled";
1197
- break;
1198
- }
1199
- if (exited === "cancelled") break;
1200
- await s.prompt(texts.join("\n"));
1201
- if (toolFired && wantsTool) {
1202
- exited = "tool";
1203
- break;
1204
- }
1205
- }
1206
- } catch (e) {
1207
- if (!cancelled) emit({ type: "error", kind: "runtime_error", message: e.message });
1208
- exited = cancelled ? "cancelled" : "gate";
1209
- }
1210
- let status = "ok";
1211
- let summary = "";
1212
- if (exited === "tool") {
1213
- summary = toolSummary ?? "(stage marked complete)";
1214
- } else if (exited === "gate") {
1215
- lastResponseText = void 0;
1216
- try {
1217
- await s.prompt(SUMMARISE_PROMPT);
1218
- summary = (lastResponseText ?? "").trim() || "(no summary produced)";
1219
- } catch {
1220
- summary = "(no summary produced)";
1221
- }
1222
- } else if (exited === "timeout") {
1223
- status = "timeout";
1224
- summary = "(stage timed out awaiting input)";
1225
- await this.cancel();
1226
- } else {
1227
- status = "fail";
1228
- summary = "(stage cancelled)";
1229
- }
1230
- unsub();
1231
- captureSessionId(s);
1232
- const costUsd = readSessionCost(s);
1299
+ const result = await runInteractiveLoop(ctx, turns, emit, makeSession, {
1300
+ signal,
1301
+ cancelled: () => cancelled,
1302
+ cancel: () => this.cancel(),
1303
+ instructionInSystemPrompt: false
1304
+ });
1233
1305
  disposeSession();
1234
- return {
1235
- status,
1236
- summary,
1237
- ...sessionId ? { sessionId } : {},
1238
- ...costUsd !== void 0 ? { costUsd } : {}
1239
- };
1306
+ return result;
1240
1307
  },
1241
1308
  async summarise(ctx) {
1242
1309
  if (!session) return "(no summary: session not established)";
1243
- const s = session;
1244
- if (s.agent) s.agent.state.tools = [];
1245
- const state = newPiBufferState();
1246
- let out2;
1247
- const unsub = s.subscribe((ev) => {
1248
- const r = consumePiEvent(ev, state, true);
1249
- if (r.responseText) out2 = r.responseText;
1250
- });
1310
+ const rt = makePiRuntimeSession(session, ctx, { emit: () => {
1311
+ }, ask: defaultAsk2 }, true);
1251
1312
  try {
1252
- await s.prompt(SUMMARISE_PROMPT);
1253
- captureSessionId(s);
1254
- return (out2 ?? "").trim() || "(no summary produced)";
1255
- } catch (e) {
1256
- return `(summary unavailable: ${e.message})`;
1313
+ return await rt.summariseTurn();
1257
1314
  } finally {
1258
- unsub();
1259
1315
  disposeSession();
1260
1316
  }
1261
1317
  },
@@ -1296,8 +1352,11 @@ async function defaultCreatePiSession(ctx) {
1296
1352
  if (ctx.config.model) {
1297
1353
  const resolved = resolveCliModel({ cliModel: ctx.config.model, modelRegistry });
1298
1354
  if (!resolved?.error) {
1299
- model = pickAuthedModel(resolved?.model, modelRegistry.getAvailable());
1355
+ model = pickAuthedModel(resolved?.model, modelRegistry.getAvailable(), hint2?.defaultModel);
1300
1356
  }
1357
+ } else if (hint2?.defaultModel) {
1358
+ const resolved = resolveCliModel({ cliModel: hint2.defaultModel, modelRegistry });
1359
+ if (!resolved?.error) model = pickAuthedModel(resolved?.model, modelRegistry.getAvailable());
1301
1360
  }
1302
1361
  const stageComplete = defineTool({
1303
1362
  name: PI_STAGE_COMPLETE_TOOL,
@@ -1338,7 +1397,7 @@ async function defaultCreatePiSession(ctx) {
1338
1397
  },
1339
1398
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1340
1399
  execute: async (_toolCallId, params) => {
1341
- const text = askHandler ? await askHandler(params.questions) : "No response from the user; proceed with your best judgement.";
1400
+ const text = askHandler ? await askHandler(params.questions) : elicitOutcomeReply({ kind: "noreply" });
1342
1401
  return { content: [{ type: "text", text }], details: {} };
1343
1402
  }
1344
1403
  });
@@ -1393,12 +1452,20 @@ function modelFamily(id) {
1393
1452
  const last = id.split(".").pop() ?? id;
1394
1453
  return last.replace(/-v\d+:\d+$/, "").toLowerCase();
1395
1454
  }
1396
- function pickAuthedModel(resolved, available) {
1455
+ function pickAuthedModel(resolved, available, fallbackModel) {
1397
1456
  if (!resolved || !available?.length) return resolved;
1398
1457
  const providers = new Set(available.map((m) => m.provider));
1399
1458
  if (providers.has(resolved.provider)) return resolved;
1400
1459
  const family = modelFamily(resolved.id);
1401
- return available.find((m) => modelFamily(m.id) === family) ?? resolved;
1460
+ const sameFamily = available.find((m) => modelFamily(m.id) === family);
1461
+ if (sameFamily) return sameFamily;
1462
+ if (fallbackModel) {
1463
+ const byId = available.find((m) => m.id === fallbackModel);
1464
+ if (byId) return byId;
1465
+ const byFamily = available.find((m) => modelFamily(m.id) === modelFamily(fallbackModel));
1466
+ if (byFamily) return byFamily;
1467
+ }
1468
+ return resolved;
1402
1469
  }
1403
1470
 
1404
1471
  // ../../packages/executor-worktree/src/pi-container.ts
@@ -1557,7 +1624,8 @@ var PiRpcSession = class {
1557
1624
  }
1558
1625
  return;
1559
1626
  }
1560
- const ev = msg;
1627
+ const ev = parsePiEvent(msg);
1628
+ if (!ev) return;
1561
1629
  for (const l of [...this.#listeners]) l(ev);
1562
1630
  if (ev.type === "agent_end" && this.#pendingAgentEnd) {
1563
1631
  const d = this.#pendingAgentEnd;
@@ -1620,10 +1688,67 @@ import { execFileSync } from "child_process";
1620
1688
  import { existsSync, mkdirSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1621
1689
  import { homedir as homedir2, tmpdir as tmpdir3 } from "os";
1622
1690
  import { basename, dirname, isAbsolute, join as join5 } from "path";
1691
+
1692
+ // ../../packages/executor-worktree/src/footprint.ts
1693
+ function resolveRenamePath(raw) {
1694
+ const brace = /\{(.*?) => (.*?)\}/;
1695
+ if (brace.test(raw)) {
1696
+ return raw.replace(brace, (_m, _old, next) => next).replace(/\/{2,}/g, "/");
1697
+ }
1698
+ const arrow2 = raw.indexOf(" => ");
1699
+ if (arrow2 !== -1) return raw.slice(arrow2 + " => ".length);
1700
+ return raw;
1701
+ }
1702
+ function parseNumstat(raw) {
1703
+ const entries = [];
1704
+ for (const line of raw.split("\n")) {
1705
+ if (line.trim() === "") continue;
1706
+ const firstTab = line.indexOf(" ");
1707
+ const secondTab = line.indexOf(" ", firstTab + 1);
1708
+ if (firstTab === -1 || secondTab === -1) continue;
1709
+ const addedRaw = line.slice(0, firstTab);
1710
+ const removedRaw = line.slice(firstTab + 1, secondTab);
1711
+ const pathRaw = line.slice(secondTab + 1);
1712
+ const binary = addedRaw === "-" && removedRaw === "-";
1713
+ entries.push({
1714
+ path: resolveRenamePath(pathRaw),
1715
+ added: binary ? 0 : Number.parseInt(addedRaw, 10) || 0,
1716
+ removed: binary ? 0 : Number.parseInt(removedRaw, 10) || 0,
1717
+ binary
1718
+ });
1719
+ }
1720
+ return entries;
1721
+ }
1722
+ function topLevel(path) {
1723
+ const slash = path.indexOf("/");
1724
+ return slash === -1 ? path : path.slice(0, slash);
1725
+ }
1726
+ function deriveFootprint(entries, opts) {
1727
+ let added = 0;
1728
+ let removed = 0;
1729
+ const scopeSet = /* @__PURE__ */ new Set();
1730
+ const allPaths = [];
1731
+ for (const e of entries) {
1732
+ added += e.added;
1733
+ removed += e.removed;
1734
+ scopeSet.add(topLevel(e.path));
1735
+ allPaths.push(e.path);
1736
+ }
1737
+ const changedPathsTruncated = allPaths.length > opts.cap;
1738
+ return {
1739
+ numstat: { files: entries.length, added, removed },
1740
+ scope: [...scopeSet].sort(),
1741
+ changedPaths: changedPathsTruncated ? allPaths.slice(0, opts.cap) : allPaths,
1742
+ changedPathsTruncated
1743
+ };
1744
+ }
1745
+
1746
+ // ../../packages/executor-worktree/src/git-service.ts
1623
1747
  var noopLogger = { info: () => {
1624
1748
  }, warn: () => {
1625
1749
  } };
1626
1750
  var SCRATCH_DIR = ".dahrk/scratch";
1751
+ var CHANGED_PATHS_CAP = 100;
1627
1752
  var DEFAULT_MERGE_RESOLVE_RULES = [
1628
1753
  { path: "pnpm-lock.yaml", strategy: "theirs" },
1629
1754
  { path: "package-lock.json", strategy: "theirs" },
@@ -1820,6 +1945,17 @@ function createGitService(opts = {}) {
1820
1945
  }
1821
1946
  git(mirror, ["config", "--replace-all", "remote.origin.fetch", TRACKING_REFSPEC]);
1822
1947
  };
1948
+ const reconcileMirrorUrl = (mirror, repoId, gitUrl, authEnv) => {
1949
+ const expected = authEnv ? withTokenUser(gitUrl) : gitUrl;
1950
+ let current = "";
1951
+ try {
1952
+ current = git(mirror, ["config", "--get", "remote.origin.url"]).trim();
1953
+ } catch {
1954
+ }
1955
+ if (current === expected) return;
1956
+ log.info(`mirror ${repoId}: origin url drifted (${current} -> ${expected}); updating in place`);
1957
+ git(mirror, ["remote", "set-url", "origin", expected]);
1958
+ };
1823
1959
  const salvageOrphanedTip = (mirror, branchName, start2) => {
1824
1960
  try {
1825
1961
  if (!gitOk2(mirror, ["rev-parse", "--verify", "-q", `refs/heads/${branchName}`])) return;
@@ -1860,6 +1996,7 @@ function createGitService(opts = {}) {
1860
1996
  log.info(`refreshing mirror ${repoId}`);
1861
1997
  try {
1862
1998
  migrateMirrorConfig(mirror, repoId);
1999
+ reconcileMirrorUrl(mirror, repoId, gitUrl, authEnv);
1863
2000
  git(mirror, ["fetch", "--prune", "origin"], netEnv(authEnv));
1864
2001
  gcShadowHeads(mirror);
1865
2002
  return { mirror, refreshed: true };
@@ -1956,6 +2093,7 @@ function createGitService(opts = {}) {
1956
2093
  const { remote, authEnv, cleanup } = resolveRemoteAuth(ref.gitUrl, opts2.credentialToken);
1957
2094
  let pushed = false;
1958
2095
  let integration;
2096
+ let footprint;
1959
2097
  try {
1960
2098
  let fetched = false;
1961
2099
  if (opts2.base) {
@@ -1975,6 +2113,12 @@ function createGitService(opts = {}) {
1975
2113
  if (!delta.some((p) => !isScratchPath(p))) {
1976
2114
  return { headSha, pushed: false, nothingToCommit: true, commitsAhead, integration: "noop" };
1977
2115
  }
2116
+ const numstatEntries = parseNumstat(git(worktreePath, ["diff", "--numstat", "FETCH_HEAD...HEAD"])).filter(
2117
+ (e) => !isScratchPath(e.path)
2118
+ );
2119
+ if (numstatEntries.length > 0) {
2120
+ footprint = deriveFootprint(numstatEntries, { cap: CHANGED_PATHS_CAP });
2121
+ }
1978
2122
  try {
1979
2123
  git(worktreePath, [
1980
2124
  "-c",
@@ -2009,7 +2153,7 @@ function createGitService(opts = {}) {
2009
2153
  headSha = git(worktreePath, ["rev-parse", "HEAD"]).trim();
2010
2154
  } else {
2011
2155
  git(worktreePath, ["merge", "--abort"]);
2012
- return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "conflict", conflictFiles };
2156
+ return { headSha, pushed: false, nothingToCommit: !dirty, commitsAhead, integration: "conflict", conflictFiles, ...footprint ? { footprint } : {} };
2013
2157
  }
2014
2158
  } else {
2015
2159
  const msg = mergeErr.message;
@@ -2025,7 +2169,7 @@ function createGitService(opts = {}) {
2025
2169
  } finally {
2026
2170
  cleanup();
2027
2171
  }
2028
- return { headSha, pushed, nothingToCommit: !dirty, commitsAhead, ...integration ? { integration } : {} };
2172
+ return { headSha, pushed, nothingToCommit: !dirty, commitsAhead, ...integration ? { integration } : {}, ...footprint ? { footprint } : {} };
2029
2173
  },
2030
2174
  async backupPush(ref, opts2) {
2031
2175
  const { worktreePath } = ref;
@@ -2120,12 +2264,16 @@ import { existsSync as existsSync2, readdirSync, realpathSync, rmSync as rmSync3
2120
2264
  import { join as join6 } from "path";
2121
2265
  var MINUTE = 6e4;
2122
2266
  var HOUR = 60 * MINUTE;
2267
+ var DAY = 24 * HOUR;
2123
2268
  var DEFAULTS = {
2124
2269
  // Deliberately non-optional defaults. "No policy configured" used to mean "never collect anything",
2125
2270
  // which is precisely how the disk reached 65 GB. Absent config must mean sane collection, not none.
2126
2271
  maxRuns: 20,
2127
2272
  maxIdleMs: 6 * HOUR,
2128
- activityGraceMs: 30 * MINUTE
2273
+ activityGraceMs: 30 * MINUTE,
2274
+ // 14 days: long enough that an operator who missed the park log can still recover unpushed work, but
2275
+ // bounded so parked tips cannot pile up for ever. Cited verbatim in docs/logging.md - keep in step.
2276
+ salvageTtlMs: 14 * DAY
2129
2277
  };
2130
2278
  var noop = { info: () => {
2131
2279
  }, warn: () => {
@@ -2166,6 +2314,38 @@ function registeredWorktrees(mirror) {
2166
2314
  }
2167
2315
  return out2.split("\n").filter((l) => l.startsWith("worktree ")).map((l) => canonical(l.slice(9).trim())).filter((p) => p && p !== canonical(mirror));
2168
2316
  }
2317
+ function salvageParkedMs(mirror, ref) {
2318
+ try {
2319
+ return statSync(join6(mirror, ref)).mtimeMs;
2320
+ } catch {
2321
+ }
2322
+ try {
2323
+ const ts = gitOut(mirror, ["log", "-g", "--format=%ct", "-1", ref]).trim();
2324
+ if (ts) return Number(ts) * 1e3;
2325
+ } catch {
2326
+ }
2327
+ return void 0;
2328
+ }
2329
+ function sweepSalvageRefs(mirror, ttlMs, dryRun, now, log) {
2330
+ let refs;
2331
+ try {
2332
+ refs = gitOut(mirror, ["for-each-ref", "--format=%(refname)", "refs/dahrk/salvage/"]).split("\n").map((s) => s.trim()).filter(Boolean);
2333
+ } catch {
2334
+ return 0;
2335
+ }
2336
+ let parked = 0;
2337
+ for (const ref of refs) {
2338
+ const at = salvageParkedMs(mirror, ref);
2339
+ const expired = at !== void 0 && now - at > ttlMs;
2340
+ if (expired && !dryRun) {
2341
+ if (!gitOk(mirror, ["update-ref", "-d", ref])) log.warn(`reaper: could not expire salvage ref ${ref}`);
2342
+ continue;
2343
+ }
2344
+ if (expired) log.info(`reaper (dry-run): would expire salvage ref ${ref}`);
2345
+ parked++;
2346
+ }
2347
+ return parked;
2348
+ }
2169
2349
  function createWorktreeReaper(opts) {
2170
2350
  const log = opts.logger ?? noop;
2171
2351
  const mirrors = () => {
@@ -2184,8 +2364,9 @@ function createWorktreeReaper(opts) {
2184
2364
  const maxRuns = policy.maxRuns ?? DEFAULTS.maxRuns;
2185
2365
  const maxIdleMs = policy.maxIdleMs ?? DEFAULTS.maxIdleMs;
2186
2366
  const graceMs = policy.activityGraceMs ?? DEFAULTS.activityGraceMs;
2367
+ const salvageTtlMs = policy.salvageTtlMs ?? DEFAULTS.salvageTtlMs;
2187
2368
  const dryRun = policy.dryRun ?? false;
2188
- const report = { scanned: 0, reaped: [], skipped: 0, errors: [] };
2369
+ const report = { scanned: 0, reaped: [], skipped: 0, salvagedRefs: 0, errors: [] };
2189
2370
  const now = Date.now();
2190
2371
  const registered = /* @__PURE__ */ new Map();
2191
2372
  for (const m of mirrors()) {
@@ -2247,6 +2428,14 @@ function createWorktreeReaper(opts) {
2247
2428
  if (report.reaped.length) {
2248
2429
  log.info(`reaper: reaped ${report.reaped.length} worktree(s), skipped ${report.skipped}`);
2249
2430
  }
2431
+ for (const m of registered.keys()) {
2432
+ report.salvagedRefs += sweepSalvageRefs(m, salvageTtlMs, dryRun, now, log);
2433
+ }
2434
+ if (report.salvagedRefs) {
2435
+ log.info(
2436
+ `reaper: ${report.salvagedRefs} salvage ref(s) parked, expire ${Math.round(salvageTtlMs / DAY)}d after parking`
2437
+ );
2438
+ }
2250
2439
  return report;
2251
2440
  }
2252
2441
  };
@@ -3520,6 +3709,77 @@ async function startMcpGateway(opts) {
3520
3709
  };
3521
3710
  }
3522
3711
 
3712
+ // ../../packages/edge/src/push-outcome.ts
3713
+ function resolveDeliverOutcome(r, job, pr) {
3714
+ const { jobId, branch, base } = job;
3715
+ if (r.integration === "noop") {
3716
+ return {
3717
+ jobId,
3718
+ status: "ok",
3719
+ branch,
3720
+ headSha: r.headSha,
3721
+ pushed: false,
3722
+ nothingToCommit: true,
3723
+ commitsAhead: r.commitsAhead,
3724
+ summary: `no changes to deliver on ${branch} - work already present on ${base}`
3725
+ };
3726
+ }
3727
+ if (r.integration === "conflict") {
3728
+ return {
3729
+ jobId,
3730
+ status: "ok",
3731
+ branch,
3732
+ headSha: r.headSha,
3733
+ pushed: false,
3734
+ nothingToCommit: r.nothingToCommit,
3735
+ commitsAhead: r.commitsAhead,
3736
+ integration: "conflict",
3737
+ ...r.conflictFiles ? { conflictFiles: r.conflictFiles } : {},
3738
+ ...r.footprint ?? {},
3739
+ summary: `base advanced; merge conflict on ${branch} (manual merge needed)`
3740
+ };
3741
+ }
3742
+ if (r.integration === "diverged") {
3743
+ return {
3744
+ jobId,
3745
+ status: "fail",
3746
+ branch,
3747
+ headSha: r.headSha,
3748
+ pushed: false,
3749
+ nothingToCommit: r.nothingToCommit,
3750
+ commitsAhead: r.commitsAhead,
3751
+ summary: `branch history diverged from ${base}; cannot auto-integrate on ${branch} (the branch likely needs rebuilding from ${base})`
3752
+ };
3753
+ }
3754
+ return {
3755
+ jobId,
3756
+ status: "ok",
3757
+ branch,
3758
+ headSha: r.headSha,
3759
+ pushed: r.pushed,
3760
+ nothingToCommit: r.nothingToCommit,
3761
+ commitsAhead: r.commitsAhead,
3762
+ ...r.integration ? { integration: r.integration } : {},
3763
+ ...pr?.prUrl ? { prUrl: pr.prUrl } : {},
3764
+ ...pr?.prNumber !== void 0 ? { prNumber: pr.prNumber } : {},
3765
+ ...pr?.prError ? { prError: pr.prError } : {},
3766
+ ...r.footprint ?? {},
3767
+ summary: r.nothingToCommit ? `no changes to commit; ${r.pushed ? "branch pushed" : "nothing pushed"}` : `committed ${r.headSha.slice(0, 7)} and pushed ${branch}`
3768
+ };
3769
+ }
3770
+ function resolveBackupOutcome(r, job) {
3771
+ return {
3772
+ jobId: job.jobId,
3773
+ status: "ok",
3774
+ branch: job.branch,
3775
+ headSha: r.headSha,
3776
+ pushed: r.pushed,
3777
+ nothingToCommit: r.nothingToCommit,
3778
+ wipRef: r.wipRef,
3779
+ summary: `backup: preserved ${r.headSha.slice(0, 7)} on ${r.wipRef} (no base merge, no PR)`
3780
+ };
3781
+ }
3782
+
3523
3783
  // ../../packages/edge/src/stage-runner.ts
3524
3784
  var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
3525
3785
  var PREVIEW = 500;
@@ -3865,7 +4125,7 @@ function createStageRunner(deps) {
3865
4125
  }
3866
4126
  sink.finalised({ ...base, meta: finalMeta, eventCount: writer.count(), ...archiveKey ? { archiveKey } : {} });
3867
4127
  };
3868
- const finish = async (status2, summary2, sessionId, costUsd, handedBackDoc) => {
4128
+ const finish = async (status2, summary2, sessionId, costUsd, handedBackDoc, failureClass2) => {
3869
4129
  active.delete(jobId);
3870
4130
  turnQueues.delete(jobId);
3871
4131
  await gateway?.stop().catch((e) => log.warn({ err: e, jobId }, "mcp gateway: stop failed"));
@@ -3902,7 +4162,8 @@ function createStageRunner(deps) {
3902
4162
  summary: summary2,
3903
4163
  ...sessionId ? { sessionId } : {},
3904
4164
  ...costUsd !== void 0 ? { costUsd } : {},
3905
- ...resolved ? { artifact: resolved.artifact } : {}
4165
+ ...resolved ? { artifact: resolved.artifact } : {},
4166
+ ...failureClass2 ? { failureClass: failureClass2 } : {}
3906
4167
  };
3907
4168
  };
3908
4169
  if (deps.packCache && job.provision && job.provision.length > 0) {
@@ -4026,6 +4287,14 @@ function createStageRunner(deps) {
4026
4287
  // never surfaced to the agent's own tool calls. Absent on ambient nodes; inert for the Claude/
4027
4288
  // Codex adapters, which use ambient inference.
4028
4289
  ...job.runtimeEnv ? { runtimeEnv: job.runtimeEnv } : {},
4290
+ // The brokered auth-profile hint (DHK-509/511): WHICH provider each piece of the inference
4291
+ // auth above belongs to, plus the model fallback. The adapter applies nothing for a provider
4292
+ // the hint does not name, so without this passthrough `runtimeEnv` arrives and is ignored -
4293
+ // a managed node then has no inference auth at all and falls through to whatever provider the
4294
+ // runtime defaults to. Read through a narrow cast because the field is not in the published
4295
+ // `@dahrk/contracts` (^0.4.0) yet; this line and `readAuthHint` are the only two seams, and
4296
+ // both drop the cast when contracts ships.
4297
+ ...job.runtimeAuth ? { runtimeAuth: job.runtimeAuth } : {},
4029
4298
  // The adapter persists each runtime-native record under the attempt's raw/ sidecar
4030
4299
  // and stamps the rawRef onto the emitted event.
4031
4300
  writeRaw: writer.writeRaw,
@@ -4104,7 +4373,8 @@ function createStageRunner(deps) {
4104
4373
  }
4105
4374
  }
4106
4375
  if (denied) summary += "\n\n(note: one or more tool actions were blocked by a deny-only policy guard.)";
4107
- return finish(status, summary, result.sessionId ?? job.sessionId, result.costUsd, result.artifact);
4376
+ const failureClass = timedOut || stalled ? "harness" : result.failureClass;
4377
+ return finish(status, summary, result.sessionId ?? job.sessionId, result.costUsd, result.artifact, failureClass);
4108
4378
  } finally {
4109
4379
  inFlight.set(runId, Math.max(0, (inFlight.get(runId) ?? 1) - 1));
4110
4380
  }
@@ -4135,17 +4405,7 @@ function createStageRunner(deps) {
4135
4405
  branch: job.branch,
4136
4406
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
4137
4407
  });
4138
- const result = {
4139
- jobId,
4140
- status: "ok",
4141
- branch: job.branch,
4142
- headSha: r.headSha,
4143
- pushed: r.pushed,
4144
- nothingToCommit: r.nothingToCommit,
4145
- wipRef: r.wipRef,
4146
- summary: `backup: preserved ${r.headSha.slice(0, 7)} on ${r.wipRef} (no base merge, no PR)`
4147
- };
4148
- return result;
4408
+ return resolveBackupOutcome(r, { jobId, branch: job.branch });
4149
4409
  } catch (e) {
4150
4410
  return { jobId, status: "fail", branch: job.branch, summary: `backup push failed: ${e.message}` };
4151
4411
  }
@@ -4170,64 +4430,13 @@ function createStageRunner(deps) {
4170
4430
  base: job.base,
4171
4431
  ...job.workspaceRef.credentialToken ? { credentialToken: job.workspaceRef.credentialToken } : {}
4172
4432
  });
4173
- if (r.integration === "noop") {
4174
- return {
4175
- jobId,
4176
- status: "ok",
4177
- branch: job.branch,
4178
- headSha: r.headSha,
4179
- pushed: false,
4180
- nothingToCommit: true,
4181
- commitsAhead: r.commitsAhead,
4182
- summary: `no changes to deliver on ${job.branch} - work already present on ${job.base}`
4183
- };
4184
- }
4185
- if (r.integration === "conflict") {
4186
- return {
4187
- jobId,
4188
- status: "ok",
4189
- branch: job.branch,
4190
- headSha: r.headSha,
4191
- pushed: false,
4192
- nothingToCommit: r.nothingToCommit,
4193
- commitsAhead: r.commitsAhead,
4194
- integration: "conflict",
4195
- ...r.conflictFiles ? { conflictFiles: r.conflictFiles } : {},
4196
- summary: `base advanced; merge conflict on ${job.branch} (manual merge needed)`
4197
- };
4198
- }
4199
- if (r.integration === "diverged") {
4200
- return {
4201
- jobId,
4202
- status: "fail",
4203
- branch: job.branch,
4204
- headSha: r.headSha,
4205
- pushed: false,
4206
- nothingToCommit: r.nothingToCommit,
4207
- commitsAhead: r.commitsAhead,
4208
- summary: `branch history diverged from ${job.base}; cannot auto-integrate on ${job.branch} (the branch likely needs rebuilding from ${job.base})`
4209
- };
4210
- }
4211
4433
  const pr = job.openPr && r.pushed ? await deps.gitService.openPrAmbient(ref, {
4212
4434
  branch: job.branch,
4213
4435
  base: job.base,
4214
4436
  title: job.openPr.title,
4215
4437
  body: job.openPr.body
4216
4438
  }) : void 0;
4217
- return {
4218
- jobId,
4219
- status: "ok",
4220
- branch: job.branch,
4221
- headSha: r.headSha,
4222
- pushed: r.pushed,
4223
- nothingToCommit: r.nothingToCommit,
4224
- commitsAhead: r.commitsAhead,
4225
- ...r.integration ? { integration: r.integration } : {},
4226
- ...pr?.prUrl ? { prUrl: pr.prUrl } : {},
4227
- ...pr?.prNumber !== void 0 ? { prNumber: pr.prNumber } : {},
4228
- ...pr?.prError ? { prError: pr.prError } : {},
4229
- summary: r.nothingToCommit ? `no changes to commit; ${r.pushed ? "branch pushed" : "nothing pushed"}` : `committed ${r.headSha.slice(0, 7)} and pushed ${job.branch}`
4230
- };
4439
+ return resolveDeliverOutcome(r, { jobId, branch: job.branch, base: job.base }, pr);
4231
4440
  } catch (e) {
4232
4441
  return { jobId, status: "fail", summary: `push failed: ${e.message}` };
4233
4442
  }
@@ -4414,10 +4623,10 @@ async function startEdgeNode(opts) {
4414
4623
  await reconcileInterruptedJobs();
4415
4624
  void stageRunner.reapWorktrees().then((r) => {
4416
4625
  counters.worktreeCount = Math.max(r.scanned - r.reaped.length, 0);
4417
- if (r.reaped.length) {
4626
+ if (r.reaped.length || r.salvagedRefs) {
4418
4627
  log.info(
4419
- { reaped: r.reaped, scanned: r.scanned, skipped: r.skipped },
4420
- `EDGE_REAPED:${r.reaped.length} worktrees (scanned ${r.scanned}, skipped ${r.skipped})`
4628
+ { reaped: r.reaped, scanned: r.scanned, skipped: r.skipped, salvagedRefs: r.salvagedRefs },
4629
+ `EDGE_REAPED:${r.reaped.length} worktrees (scanned ${r.scanned}, skipped ${r.skipped}, salvaged ${r.salvagedRefs})`
4421
4630
  );
4422
4631
  }
4423
4632
  for (const e of r.errors) log.warn({ reapError: e }, `EDGE_REAP_ERROR:${e}`);
@@ -5781,7 +5990,8 @@ function driftOf(stored, repo) {
5781
5990
  const drift = {};
5782
5991
  if (typeof stored.defaultBranch === "string" && stored.defaultBranch !== repo.defaultBranch) drift.branch = stored.defaultBranch;
5783
5992
  if (typeof stored.name === "string" && stored.name !== repo.name) drift.name = stored.name;
5784
- return drift.branch || drift.name ? drift : void 0;
5993
+ if (typeof stored.gitUrl === "string" && stored.gitUrl !== repo.gitUrl) drift.gitUrl = stored.gitUrl;
5994
+ return drift.branch || drift.name || drift.gitUrl ? drift : void 0;
5785
5995
  }
5786
5996
  async function registerRepo(deps, args) {
5787
5997
  const url = `${args.base}/config/api/repositories`;
@@ -7229,7 +7439,7 @@ async function runStatus(inputs, deps) {
7229
7439
  }
7230
7440
 
7231
7441
  // src/main.ts
7232
- var CLIENT_VERSION = "0.1.21";
7442
+ var CLIENT_VERSION = "0.1.23";
7233
7443
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
7234
7444
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
7235
7445
  var RUNTIMES2 = ["claude-code", "codex", "pi"];
@@ -7622,6 +7832,7 @@ async function runRepoAdd(flags) {
7622
7832
  const bits = [];
7623
7833
  if (result.drift.branch) bits.push(`branch ${result.drift.branch} (this repo is on ${repo.defaultBranch})`);
7624
7834
  if (result.drift.name) bits.push(`name ${result.drift.name}`);
7835
+ if (result.drift.gitUrl) bits.push(`URL ${result.drift.gitUrl} (this repo registers as ${repo.gitUrl})`);
7625
7836
  out(hint(`The hub's stored record differs - ${bits.join(", ")} - and was left unchanged.`));
7626
7837
  }
7627
7838
  return 0;