@codedology/code-forge 0.12.0 → 0.14.0

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 (40) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +28 -2
  3. package/docs/reference/blocks.json +354 -1
  4. package/docs/reference/config.md +4 -1
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +10 -1
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +1 -1
  9. package/skill/references/code.md +15 -8
  10. package/skill/references/ledger.md +4 -2
  11. package/skill/references/proof.md +1 -1
  12. package/skill/references/review.md +21 -7
  13. package/skill/references/security.md +5 -3
  14. package/skill/scripts/forge +1 -1
  15. package/src/autopilot/actions.mjs +4 -5
  16. package/src/cli/block.mjs +130 -31
  17. package/src/cli/context.mjs +3 -2
  18. package/src/cli/report.mjs +2 -2
  19. package/src/cli/review-file.mjs +67 -42
  20. package/src/cli/review.mjs +2 -2
  21. package/src/context/pack.mjs +13 -9
  22. package/src/doctor/index.mjs +2 -1
  23. package/src/doctor/local.mjs +19 -0
  24. package/src/doctor/worker-probe.mjs +15 -2
  25. package/src/ledger/export.mjs +1 -1
  26. package/src/ledger/report.mjs +9 -3
  27. package/src/ledger/spend.mjs +3 -3
  28. package/src/proof/lock.mjs +5 -1
  29. package/src/review/accept-hit.mjs +78 -0
  30. package/src/review/agent-waiver.mjs +201 -0
  31. package/src/review/gate-check.mjs +62 -28
  32. package/src/review/reopen.mjs +35 -14
  33. package/src/review/schema-fallback.mjs +3 -3
  34. package/src/state/config-snapshot.mjs +1 -0
  35. package/src/util/file-locks.mjs +118 -0
  36. package/src/util/forbidden.mjs +51 -2
  37. package/src/worker/engine.mjs +2 -2
  38. package/src/worker/loop.mjs +83 -33
  39. package/src/worker/queue.mjs +141 -33
  40. package/src/worker/watch.mjs +4 -2
@@ -275,6 +275,7 @@ export const FORBIDDEN = Object.freeze(
275
275
  * Coder-only entries (B12b, plan §4.9): verbs only a human (through the orchestrator) may run.
276
276
  * They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
277
277
  * always appends them, so every list built for a coder carries them.
278
+ * - `code-forge block accept-hit` (B127) — accepting a false transcript hit is human-only;
278
279
  * - `code-forge block waive` — waiving a review finding is human-only;
279
280
  * - `code-forge block reopen` (B72) — restarting a stopped or lost file's review is human-only;
280
281
  * - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
@@ -324,6 +325,27 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
324
325
  tokens: ['--remove-worktree'],
325
326
  description: "Remove a block's worktree at close (block close --remove-worktree, B62a) — the orchestrator only",
326
327
  },
328
+ {
329
+ id: 'code-forge-block-accept-hit-from-coder',
330
+ kind: 'anyPrefix',
331
+ prefixes: [
332
+ ['code-forge', 'block', 'accept-hit'],
333
+ ['forge', 'block', 'accept-hit'],
334
+ ],
335
+ description: 'Accept a false transcript hit — human-only, confirmed in the human terminal (B127)',
336
+ },
337
+ {
338
+ id: 'code-forge-block-unwaive-from-coder',
339
+ kind: 'anyPrefix',
340
+ prefixes: [['code-forge', 'block', 'unwaive'], ['forge', 'block', 'unwaive']],
341
+ description: 'Undo agent waivers — human-only, confirmed in the human terminal (B124b)',
342
+ },
343
+ {
344
+ id: 'code-forge-block-ack-waivers-from-coder',
345
+ kind: 'anyPrefix',
346
+ prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
347
+ description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
348
+ },
327
349
  ].map(freezeEntry),
328
350
  );
329
351
 
@@ -790,6 +812,10 @@ export function renderForCodex(list = FORBIDDEN) {
790
812
  * `$ cat ~/.code-forge/runs/r.key`, `Write(.code-forge/reviews/x.json)`, a stream-json
791
813
  * `"name":"Write"` followed by the path — is found. It is a grep: a line that merely MENTIONS a
792
814
  * forbidden command is a hit too (fail closed; the block gate reports it as `rule_break`, B5).
815
+ * Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
816
+ * `thread.started`) scan command_execution commands and file_change paths as writes. Other
817
+ * items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
818
+ * is never scanned; non-item events are skipped. Any other log uses the plain-text grep above, unchanged.
793
819
  *
794
820
  * Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
795
821
  * tokens (a `write` path entry hits when a write command starts before a token naming the path);
@@ -801,6 +827,17 @@ export function renderForCodex(list = FORBIDDEN) {
801
827
  * @returns {{id: string, line: number}[]}
802
828
  */
803
829
  export function scanTranscript(text, list = FORBIDDEN) {
830
+ const lines = String(text).split('\n');
831
+ /** @type {any[] | null} */
832
+ let events = null;
833
+ try {
834
+ const parsed = lines.map((line) => line.trim() === '' ? undefined : JSON.parse(line));
835
+ const nonEmpty = parsed.filter((event) => event !== undefined);
836
+ if (nonEmpty[0]?.type === 'thread.started' && nonEmpty.every((event) =>
837
+ event !== null && typeof event === 'object' && !Array.isArray(event) && typeof event.type === 'string')) events = parsed;
838
+ } catch {
839
+ // One bad JSONL line makes the entire transcript a plain-text, fail-closed grep.
840
+ }
804
841
  const commandEntries = list.filter((e) => e.kind === 'prefix' || e.kind === 'commandFlag' || e.kind === 'anyPrefix');
805
842
  const firstWords = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'node']);
806
843
  for (const e of commandEntries) {
@@ -811,9 +848,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
811
848
  const writeStarts = new Set([...WRITE_VERBS, ...WRAPPERS, 'sed', 'perl', 'git']);
812
849
  /** @type {{id: string, line: number}[]} */
813
850
  const hits = [];
814
- String(text)
815
- .split('\n')
851
+ lines
816
852
  .forEach((lineText, index) => {
853
+ if (events !== null) {
854
+ const event = events[index];
855
+ if (!event?.type.startsWith('item.') || !event.item) return;
856
+ const item = event.item;
857
+ if (item.type === 'agent_message' || item.type === 'reasoning') return;
858
+ // a command that is not a string is scanned with the rest of its item (fail closed), its output left out
859
+ if (item.type === 'command_execution') lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
860
+ else if (item.type === 'file_change') {
861
+ lineText = (Array.isArray(item.changes) ? item.changes : [])
862
+ .filter((change) => typeof change?.path === 'string')
863
+ .map((change) => `Write(${JSON.stringify(change.path)})`).join(' ');
864
+ } else lineText = JSON.stringify(item);
865
+ }
817
866
  const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
818
867
  const ids = new Set();
819
868
  let firstWrite = -1;
@@ -107,7 +107,7 @@ import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
107
107
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
108
108
  import { verifyRow } from '../state/signer.mjs';
109
109
  import { redact } from '../util/redact.mjs';
110
- import { keyedLock } from '../util/locks.mjs';
110
+ import { withLock } from '../util/file-locks.mjs';
111
111
  import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
112
  import { assertAnswers } from './queue.mjs';
113
113
  import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
@@ -536,7 +536,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
536
536
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
537
537
  const readRows = ctx.readRows;
538
538
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
539
- const prepared = await keyedLock(blockLockKey(ctx.runId, ticket.block), async () => {
539
+ const prepared = await withLock(blockLockKey(ctx.runId, ticket.block), async () => {
540
540
  let rows;
541
541
  try {
542
542
  rows = (await readRows()).filter((r) => r?.run === ctx.runId);
@@ -21,8 +21,9 @@
21
21
  * snapshots (or none) keeps the config the worker booted with.
22
22
  *
23
23
  * Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 3, 1–16) tickets at
24
- * once, oldest first; when one ends the next pending one starts. Two tickets for the same
25
- * (block, file) never overlap (a file lock; the later one stays pending without taking a slot),
24
+ * once, oldest first (or fewest running per block with `review.fair_share`); when one ends the
25
+ * next pending one starts. Two tickets for the same (block, file) never overlap (a file lock;
26
+ * the later one stays pending without taking a slot),
26
27
  * and the engine takes the block's budget row and L3 rung under a block lock (`engine.mjs`). A
27
28
  * throw in one ticket is that ticket's `unavailable` result and never stops the others. `stop()`
28
29
  * starts no new ticket and waits for the running ones. With 1 the worker runs exactly as before.
@@ -49,13 +50,13 @@ import { runRecordPath } from '../state/paths.mjs';
49
50
  import { readRun, reattachWorker } from '../state/run.mjs';
50
51
  import { loadKey, signRow } from '../state/signer.mjs';
51
52
  import { logWarning } from '../util/error-log.mjs';
52
- import { keyedLock } from '../util/locks.mjs';
53
+ import { withLock, tryLock } from '../util/file-locks.mjs';
53
54
  import { redact, writeSafe } from '../util/redact.mjs';
54
55
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
55
56
  import { openFindings } from '../review/gate-check.mjs';
56
57
  import { countLines, RouteError, routeTicket } from '../review/route.mjs';
57
58
  import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
58
- import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
59
+ import { announceWorker, beatWorker, HEARTBEAT_MS, isDone, liveWorker, pendingTickets, readTicket, retractWorker, workerRegistry, writeResult } from './queue.mjs';
59
60
  import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
60
61
  import { watchTick } from './watch.mjs';
61
62
 
@@ -234,6 +235,7 @@ export function recordedVerdict(result) {
234
235
  * @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
235
236
  * @property {typeof spawnSession} [spawn]
236
237
  * @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
238
+ * @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
237
239
  * @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
238
240
  * @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
239
241
  * @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
@@ -277,6 +279,8 @@ export async function createWorker(opts, deps = {}) {
277
279
  let stopping = false;
278
280
  /** @type {ReturnType<typeof setInterval> | null} */
279
281
  let heartbeat = null;
282
+ /** @type {ReturnType<typeof announceWorker> | null} */
283
+ let self = null;
280
284
  /** @type {(() => void) | null} */
281
285
  let wake = null;
282
286
  /** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
@@ -416,7 +420,7 @@ export async function createWorker(opts, deps = {}) {
416
420
  // read, advanced and saved by one ticket at a time; the later one waits here. The key
417
421
  // carries the run so two workers in one process (tests) never share a lock.
418
422
  const t = ticket;
419
- result = await keyedLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
423
+ result = await withLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
420
424
  }
421
425
  // Stopping: a ticket whose session our own stop killed ends `unavailable` — leave it queued
422
426
  // (no done marker) for the next worker instead of recording the kill as a failure. A ticket
@@ -532,50 +536,50 @@ export async function createWorker(opts, deps = {}) {
532
536
  }
533
537
 
534
538
  /**
535
- * B42: how many tickets run at once — `review.parallel_tickets` of the snapshot in force (so a
536
- * `run reload` resizes the pool at the next drain), else of the boot config; anything but an
537
- * integer 1–16 is the default.
538
- * @returns {Promise<number>}
539
+ * Pool settings from the snapshot in force, else the boot config. A run reload applies at the
540
+ * next drain; anything but an integer 1–16 uses the default size.
541
+ * @returns {Promise<{limit: number, fairShare: boolean}>}
539
542
  */
540
- async function poolSize() {
543
+ async function poolSettings() {
541
544
  let current = cfg;
542
545
  try {
543
546
  current = await configFor('');
544
547
  } catch {
545
- // an unreadable record: the boot config's size (each ticket still fails closed on its own)
548
+ // an unreadable record: the boot settings (each ticket still fails closed on its own)
546
549
  }
547
- return parallelTickets(current);
550
+ return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true };
548
551
  }
549
552
 
550
553
  /**
551
- * The (block, file) key of each ticket read so far, by id: a written ticket never changes (its
552
- * id is its content's hash), so it is read once for scheduling, however many wake-ups it waits.
554
+ * The block and (block, file) key of each ticket read so far, by id. A written ticket never
555
+ * changes (its id is its content's hash), so scheduling reads it once across wake-ups.
553
556
  * Dropped once the ticket is done.
554
- * @type {Map<string, string>}
557
+ * @type {Map<string, {key: string, block: string}>}
555
558
  */
556
559
  const fileKeys = new Map();
557
560
 
558
561
  /**
559
- * The (block, file) a pending ticket names, or null when it cannot be read (it is started
562
+ * The block and file-lock key a pending ticket names, or null when unreadable (it is started
560
563
  * anyway and refused by `processTicket`; a null is not cached). Only a scheduling hint:
561
564
  * `processTicket` takes the file lock on the ticket it actually read.
562
- * @param {string} id @returns {string | null}
565
+ * @param {string} id @returns {{key: string, block: string} | null}
563
566
  */
564
567
  function fileKeyOf(id) {
565
568
  const known = fileKeys.get(id);
566
569
  if (known !== undefined) return known;
567
570
  try {
568
571
  const t = loadTicket(repoRoot, id);
569
- const key = fileLockKey(t.block, t.file);
570
- fileKeys.set(id, key);
571
- return key;
572
+ const file = { key: fileLockKey(t.block, t.file), block: t.block };
573
+ fileKeys.set(id, file);
574
+ return file;
572
575
  } catch {
573
576
  return null;
574
577
  }
575
578
  }
576
579
 
577
580
  /**
578
- * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; when
581
+ * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; with
582
+ * fair share, each slot goes to the block with fewest running tickets, ties oldest first. When
579
583
  * one finishes, the queue is re-read and the next pending ticket starts (no batch barrier; the
580
584
  * queue is also re-read every `pollMs` while tickets run, so a new ticket fills a free slot).
581
585
  * A ticket whose (block, file) is already running is left pending (it does not take a slot)
@@ -600,11 +604,14 @@ export async function createWorker(opts, deps = {}) {
600
604
  }
601
605
  /** @type {number | null} */
602
606
  let limit = null;
607
+ let fairShare = false;
603
608
  let done = 0;
604
609
  /** @type {Map<string, Promise<void>>} */
605
610
  const running = new Map();
606
611
  /** @type {Set<string>} the (block, file) keys of the running tickets */
607
612
  const busy = new Set();
613
+ /** @type {Map<string, number>} running tickets per block */
614
+ const runningByBlock = new Map();
608
615
  /** @type {Set<string>} */
609
616
  const started = new Set();
610
617
  for (;;) {
@@ -630,25 +637,54 @@ export async function createWorker(opts, deps = {}) {
630
637
  writeSafe(warnStream, `worker: WARN ${text}\n`);
631
638
  try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
632
639
  }
633
- for (const id of pendingTickets(repoRoot)) {
634
- if (poisoned.has(id) || started.has(id)) continue;
635
- if (limit === null) limit = await poolSize();
636
- if (running.size >= limit || stopping) break;
637
- const fileKey = fileKeyOf(id);
638
- if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
640
+ const pending = pendingTickets(repoRoot).filter((id) => !poisoned.has(id) && !started.has(id));
641
+ if (pending.length > 0 && limit === null) ({ limit, fairShare } = await poolSettings());
642
+ // Cache even unreadable hints for this tick: ranking never reads a ticket twice.
643
+ /** @type {Map<string, ReturnType<typeof fileKeyOf>>} */
644
+ const hints = new Map();
645
+ while (pending.length > 0 && running.size < limit && !stopping) {
646
+ let next = -1;
647
+ let fewest = Infinity;
648
+ for (let i = 0; i < pending.length; i += 1) {
649
+ const id = pending[i];
650
+ if (!hints.has(id)) hints.set(id, fileKeyOf(id));
651
+ const file = hints.get(id);
652
+ if (file && busy.has(file.key)) continue; // waits without a slot
653
+ const count = fairShare && file ? runningByBlock.get(file.block) ?? 0 : 0;
654
+ if (count < fewest) {
655
+ next = i;
656
+ fewest = count;
657
+ }
658
+ if (count === 0) break; // best possible count; ties stay oldest first
659
+ }
660
+ if (next === -1) break;
661
+ const [id] = pending.splice(next, 1);
662
+ const claim = await tryLock(`ticket\0${runId}\0${id}`);
663
+ if (!claim.ok) continue; // another process owns it; no pool slot is taken
664
+ // Acquisition awaited a process probe: another worker may have completed this ticket
665
+ // since the pending snapshot, or stop() may have arrived while we waited.
666
+ if (stopping || isDone(repoRoot, id)) {
667
+ claim.release();
668
+ continue;
669
+ }
670
+ const file = hints.get(id);
671
+ const fileKey = file?.key ?? null;
639
672
  started.add(id);
640
673
  activeTickets.add(id);
641
674
  if (fileKey !== null) busy.add(fileKey);
675
+ if (file) runningByBlock.set(file.block, (runningByBlock.get(file.block) ?? 0) + 1);
642
676
  const slot = runOne(id)
643
677
  .then((n) => {
644
678
  done += n;
645
679
  })
646
680
  .finally(() => {
681
+ claim.release(); // the done marker is published before the claim is released
647
682
  // the slot is freed on every exit path (`runOne` never rejects)
648
683
  running.delete(id);
649
684
  activeTickets.delete(id);
650
685
  fileKeys.delete(id);
651
686
  if (fileKey !== null) busy.delete(fileKey);
687
+ if (file) runningByBlock.set(file.block, runningByBlock.get(file.block) - 1);
652
688
  });
653
689
  running.set(id, slot);
654
690
  }
@@ -684,7 +720,8 @@ export async function createWorker(opts, deps = {}) {
684
720
  * must still see the worker as live the whole time.
685
721
  */
686
722
  function announce() {
687
- const self = announceWorker(repoRoot, { pid: process.pid, run: runId });
723
+ if (self !== null) return; // boot already announced under the lock
724
+ self = announceWorker(repoRoot, { pid: process.pid, run: runId });
688
725
  stopBeating();
689
726
  const timer = setInterval(() => {
690
727
  try {
@@ -749,12 +786,16 @@ export async function createWorker(opts, deps = {}) {
749
786
  /** Withdraw this process's announcement (`worker.json`) if it is still ours. */
750
787
  function release() {
751
788
  stopBeating();
752
- retractWorker(repoRoot, process.pid);
789
+ if (self !== null) retractWorker(repoRoot, self.pid, self.start_time);
790
+ self = null;
753
791
  }
754
792
 
755
- return { processTicket, drain, run, once, stop, release, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
793
+ return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
756
794
  }
757
795
 
796
+ /** This block permits one registered worker per queue. */
797
+ const WORKER_LIMIT = 1;
798
+
758
799
  /**
759
800
  * Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
760
801
  * process), run key; refuses when another live worker already serves this queue.
@@ -777,13 +818,22 @@ export async function bootWorker(opts, deps = {}) {
777
818
  if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
778
819
  cfg = loaded.config;
779
820
  }
780
- const other = liveWorker(repoRoot);
781
- if (other && other.pid !== process.pid) throw new WorkerError('worker-running', `worker pid ${other.pid} already serves this queue`);
782
821
  const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
783
822
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
784
823
  setRunRoot(runRootDir);
785
824
  const key = await loadKey(opts.runId);
786
825
  const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
826
+ deps.hooks?.beforeLock?.();
827
+ await withLock(`worker-boot:${repoRoot}`, async () => {
828
+ const entries = workerRegistry(repoRoot, { prune: true });
829
+ let count = entries.filter((entry) => entry.live).length;
830
+ // Older workers have only worker.json; do not boot over a live legacy owner.
831
+ const legacy = liveWorker(repoRoot);
832
+ if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
833
+ await deps.hooks?.afterCount?.(count);
834
+ if (count >= WORKER_LIMIT) throw new WorkerError('worker-running', 'a live or unreadable worker entry already serves this queue');
835
+ worker.announce();
836
+ });
787
837
  return { ...worker, repoRoot, runRootDir };
788
838
  }
789
839
 
@@ -826,7 +876,7 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
826
876
  child.unref();
827
877
  const deadline = Date.now() + timeoutMs;
828
878
  while (Date.now() < deadline) {
829
- if (typeof pid === 'number' && liveWorker(repoRoot)?.pid === pid) return { pid, repoRoot };
879
+ if (typeof pid === 'number' && liveWorker(repoRoot, { pid, registeredOnly: true })) return { pid, repoRoot };
830
880
  if (child.exitCode !== null || child.signalCode !== null) break;
831
881
  await new Promise((resolve) => setTimeout(resolve, 50));
832
882
  }