cawdev-cli 1.0.4-beta → 1.0.5-beta

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/runner/runner.mjs CHANGED
@@ -12,12 +12,14 @@
12
12
  // against your working copies; it should be a file you can read first.
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
+ import { createHash } from 'node:crypto';
15
16
  import {
16
17
  access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile,
17
18
  } from 'node:fs/promises';
18
19
  import { homedir, tmpdir } from 'node:os';
19
20
 
20
21
  import { join, resolve } from 'node:path';
22
+ import { changedLines } from './changed-lines.mjs';
21
23
  import { describeTurn, totalsOf } from '../lib/usage.mjs';
22
24
  import { withinCeiling } from '../lib/tool-rules.mjs';
23
25
  import { serveControl } from './control.mjs';
@@ -40,6 +42,11 @@ import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
40
42
  import { findSecret } from '../lib/secrets.mjs';
41
43
  import { cliVersion, isBelow } from '../lib/version.mjs';
42
44
  import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
45
+ import {
46
+ createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
47
+ } from './auto-workspaces.mjs';
48
+ import { agentOfCommand } from '../lib/agents.mjs';
49
+ import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
43
50
 
44
51
  // --- configuration -----------------------------------------------------------
45
52
 
@@ -405,6 +412,13 @@ async function readConfig() {
405
412
  file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
406
413
  /** Whether a run here may reach the operator's browser. Per project too. */
407
414
  browser: file.browser ?? DEFAULTS.browser,
415
+ /**
416
+ * How many checkouts per project this machine may CLONE by itself when a
417
+ * coding run would otherwise wait for a free one — see auto-workspaces.mjs.
418
+ * Absent is none: an actuator that spends disk and a clone's time is
419
+ * switched on, never on by default.
420
+ */
421
+ autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
408
422
  skillCache: file.skillCache ?? DEFAULTS.skillCache,
409
423
  skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
410
424
  idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
@@ -485,6 +499,32 @@ async function readConfig() {
485
499
  * which is the truth rather than "that project already has a run here" standing
486
500
  * in for it.
487
501
  */
502
+ /** `autoWorkspaces` as a count: a whole number from 0, anything else is 0. */
503
+ function autoWorkspacesOf(value) {
504
+ const n = Number(value);
505
+ return Number.isInteger(n) && n > 0 ? n : 0;
506
+ }
507
+
508
+ /**
509
+ * The on-demand checkouts beside each project's first one, joined to its list
510
+ * — at boot, and again on SIGHUP, when one may have been made or dropped by
511
+ * somebody else. A checkout gone from disk leaves the list unless a run holds
512
+ * it; one that appeared joins it. The configured checkouts are never touched.
513
+ */
514
+ async function syncAutoWorkspaces(projects, held = new Set()) {
515
+ for (const [slug, project] of Object.entries(projects)) {
516
+ const first = project.workspaces[0];
517
+ const found = await discoverAutoWorkspaces(first);
518
+ const kept = project.workspaces.filter((path) => !isAutoWorkspace(first, path)
519
+ || found.includes(path) || held.has(path));
520
+ const added = found.filter((path) => !kept.includes(path));
521
+ const dropped = project.workspaces.filter((path) => !kept.includes(path));
522
+ project.workspaces = [...kept, ...added];
523
+ for (const path of added) log(` ${slug}: serving the checkout made on demand at ${path}`);
524
+ for (const path of dropped) log(` ${slug}: ${path} is gone — no longer serving it`);
525
+ }
526
+ }
527
+
488
528
  function normaliseProjects(projects) {
489
529
  const normalised = {};
490
530
  for (const [slug, value] of Object.entries(projects)) {
@@ -550,13 +590,20 @@ function normaliseProjects(projects) {
550
590
  * error saying so; `main` re-runs the scan as the project's MAIN analysis,
551
591
  * which overwrites the project's dashboard with this branch's code and so
552
592
  * makes the platform's project reading describe whichever branch scanned
553
- * last. An operator opts into that; nothing opts in for them.
593
+ * last; `project` — R316 — analyses the branch as its own scratch SonarQube
594
+ * project instead, `<key>:branch:<branch>-<hash>`, overwritten on each run,
595
+ * with "new" meaning a line the branch changed. Both are an operator's own
596
+ * opt-in — nothing opts in for them — and `project` additionally needs the
597
+ * scanner token to hold *Create Projects* on the server, and for the
598
+ * server's default project visibility to not be public (or this publishes
599
+ * the code).
554
600
  */
555
601
  function qualityBlockOf(slug, block) {
556
602
  if (!block) return null;
557
603
  const when = block.whenBranchesRefused ?? 'skip';
558
- if (when !== 'skip' && when !== 'main') {
559
- throw new Error(`Project "${slug}": quality.whenBranchesRefused must be "skip" or "main", not "${when}".`);
604
+ if (when !== 'skip' && when !== 'main' && when !== 'project') {
605
+ throw new Error(
606
+ `Project "${slug}": quality.whenBranchesRefused must be "skip", "main" or "project", not "${when}".`);
560
607
  }
561
608
  return {
562
609
  command: block.command ?? 'sonar-scanner',
@@ -868,6 +915,11 @@ async function readUsage(config) {
868
915
  const allWindows = [];
869
916
 
870
917
  for (const agentCommand of config.agentCommands) {
918
+ // Codex has no `-p /usage`: its `-p` is a PROFILE, and the bare command is
919
+ // an interactive terminal. It reports no windows rather than a guess.
920
+ if (agentOfCommand(agentCommand) === 'codex') {
921
+ continue;
922
+ }
871
923
  const said = await new Promise((resolve) => {
872
924
  let out = '';
873
925
  const child = spawn(agentCommand, ['-p', '/usage', '--output-format', 'text'], {
@@ -897,7 +949,7 @@ async function readUsage(config) {
897
949
  const windows = parseUsage(said);
898
950
  for (const each of windows) {
899
951
  allWindows.push({
900
- provider: agentCommand.endsWith('agy') ? 'agy' : 'claude',
952
+ provider: agentOfCommand(agentCommand),
901
953
  window: each.kind,
902
954
  model: each.model,
903
955
  percentUsed: each.percent,
@@ -1394,9 +1446,13 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
1394
1446
  * It does not reproduce on macOS at any output size, so this is not something
1395
1447
  * a local run will ever warn about. Do not put `exit` back.
1396
1448
  */
1397
- function git(cwd, args) {
1449
+ function git(cwd, args, { timeoutMs = 0 } = {}) {
1398
1450
  return new Promise((resolvePromise, reject) => {
1399
- const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
1451
+ const child = spawn('git', args, {
1452
+ cwd,
1453
+ stdio: ['ignore', 'pipe', 'pipe'],
1454
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
1455
+ });
1400
1456
  let out = '';
1401
1457
  let err = '';
1402
1458
  child.stdout.on('data', (chunk) => (out += chunk));
@@ -1829,11 +1885,12 @@ const SURVEY_FILE_CAP = 20;
1829
1885
  * arrive at the console as the same answer.
1830
1886
  *
1831
1887
  * **The order of this list matters, and it is `project.workspaces` order.**
1832
- * R71 has the console work out which checkout a run would be given by taking
1833
- * the first entry here that nothing is holding — the queue gate's
1834
- * `workspaces.find((path) => !held.has(path))`, read from the other side of
1835
- * the wire. Sorting or de-duplicating this on the way out would move the
1836
- * warning onto a checkout the run is not headed for, and nothing would fail.
1888
+ * R71/i258 has the console work out which checkout a run would be given by
1889
+ * taking the first free entry here whose `dirty` is 0, falling back to the
1890
+ * first free entry if none is known clean — the queue gate's `pickWorkspace`,
1891
+ * read from the other side of the wire. Sorting or de-duplicating this on the
1892
+ * way out would move the warning onto a checkout the run is not headed for,
1893
+ * and nothing would fail.
1837
1894
  */
1838
1895
  /**
1839
1896
  * One porcelain line, split into its status and its path.
@@ -1887,6 +1944,31 @@ async function surveyWorkingCopies(config) {
1887
1944
  return survey;
1888
1945
  }
1889
1946
 
1947
+ /** Bounds the claim-time `git status` probe — see `looksClean`. */
1948
+ const CLEAN_PROBE_MS = 5000;
1949
+
1950
+ /**
1951
+ * Is `path`'s tree clean, read fresh, right now — i258.
1952
+ *
1953
+ * Three-valued on purpose: `true` clean, `false` dirty, `null` could not
1954
+ * tell (no `.git`, a timeout, any failure). `null` is grouped with dirty by
1955
+ * every caller — "I could not look" is not "it was clean" — so a probe that
1956
+ * cannot answer degrades to the old first-free behaviour rather than
1957
+ * starving the queue on a wedged filesystem.
1958
+ *
1959
+ * This does not reuse `surveyWorkingCopies`'s reading. That one runs on the
1960
+ * heartbeat and is not kept anywhere; caching it here would add a second
1961
+ * source of truth for a fact `pickWorkspace` is about to establish anyway,
1962
+ * and a 30-second-old reading reproduces R71/i258 in a smaller window.
1963
+ * `prepareWorkingCopy` runs the identical `git status --porcelain` seconds
1964
+ * after this one.
1965
+ */
1966
+ async function looksClean(path) {
1967
+ const porcelain = await git(path, ['status', '--porcelain'], { timeoutMs: CLEAN_PROBE_MS })
1968
+ .catch(() => null);
1969
+ return porcelain === null ? null : porcelain === '';
1970
+ }
1971
+
1890
1972
  /**
1891
1973
  * What every checkout on this machine holds — R87.
1892
1974
  *
@@ -2329,6 +2411,14 @@ async function surveyGit(config) {
2329
2411
  * show up in the working-copy watcher and in `resetWorkspace`'s `git clean`
2330
2412
  * list — R76's `.codegraph` lesson, see `excludeLocally`. The token goes in
2331
2413
  * the child's ENVIRONMENT and never on its argv, which `ps` would show.
2414
+ *
2415
+ * R316: when the project SAYS its server refuses branches (`run.sonarBranches
2416
+ * === 'REFUSED'`, and only when this machine scans the same server the claim
2417
+ * names — see `trimSlash`), the branch attempt is skipped outright and the
2418
+ * machine's `whenBranchesRefused` runs straight away. Otherwise the branch
2419
+ * attempt still runs first and the refusal is still learnt from its failure,
2420
+ * exactly as before R316 — a project that has not said, or a machine
2421
+ * scanning a different server, gets today's behaviour unchanged.
2332
2422
  */
2333
2423
  async function scanQuality(config, run, cwd, branch) {
2334
2424
  const slug = run.projectSlug;
@@ -2363,6 +2453,14 @@ async function scanQuality(config, run, cwd, branch) {
2363
2453
  return report({ error: `The scanner token is not set: export ${quality.tokenEnv} on this machine.` });
2364
2454
  }
2365
2455
 
2456
+ // R316: the project's own word on whether its server refuses branches is
2457
+ // trusted only when this machine scans THAT server — a machine whose own
2458
+ // `quality.url` names a different one (a LAN hostname the platform cannot
2459
+ // reach) knows nothing about what that server does, and tries the branch
2460
+ // scan first, exactly as if the project had said nothing.
2461
+ const claimSpeaks = !quality.url || trimSlash(quality.url) === trimSlash(run.sonarUrl ?? '');
2462
+ const knownRefused = claimSpeaks && run.sonarBranches === 'REFUSED';
2463
+
2366
2464
  let headSha = null;
2367
2465
  let workDir = null;
2368
2466
  try {
@@ -2379,6 +2477,42 @@ async function scanQuality(config, run, cwd, branch) {
2379
2477
  ];
2380
2478
  const env = { ...process.env, SONAR_TOKEN: token };
2381
2479
 
2480
+ if (knownRefused) {
2481
+ if (quality.whenBranchesRefused === 'skip') {
2482
+ return report({
2483
+ headSha,
2484
+ error: "This project's SonarQube server does not analyse branches (Community Edition, set on the "
2485
+ + 'project). Set "whenBranchesRefused": "project" (a scratch SonarQube project per branch) or '
2486
+ + '"whenBranchesRefused": "main" in this machine\'s quality block.',
2487
+ });
2488
+ }
2489
+ if (quality.whenBranchesRefused === 'project') {
2490
+ // `await`, not `return`: a rejection has to be caught by THIS
2491
+ // function's own try/catch, which `return somePromise` would skip —
2492
+ // control leaves before the promise settles.
2493
+ return await scanAsScratchProject(
2494
+ { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
2495
+ }
2496
+ // 'main', and known refused: no reason to run the branch scan first.
2497
+ const ran = await runSkillCommand(quality.command, scannerArgs(false), { cwd, env, timeoutMs: remaining() });
2498
+ if (!ran.ok) {
2499
+ if (/ENOENT/.test(ran.said)) {
2500
+ return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
2501
+ }
2502
+ return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
2503
+ }
2504
+ const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2505
+ const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
2506
+ const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
2507
+ return await reportBranchReading({
2508
+ ask: askServer, reportTask, settled, key, branch, analysedAs: 'MAIN',
2509
+ warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
2510
+ headSha, report,
2511
+ });
2512
+ }
2513
+
2514
+ // Today's behaviour: try the branch first, and learn what the server is
2515
+ // from a failure.
2382
2516
  let analysedAs = 'BRANCH';
2383
2517
  let warning = null;
2384
2518
  let ran = await runSkillCommand(quality.command, scannerArgs(true),
@@ -2388,11 +2522,18 @@ async function scanQuality(config, run, cwd, branch) {
2388
2522
  return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
2389
2523
  }
2390
2524
  if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
2525
+ if (quality.whenBranchesRefused === 'project') {
2526
+ return await scanAsScratchProject(
2527
+ { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
2528
+ }
2391
2529
  if (quality.whenBranchesRefused !== 'main') {
2392
2530
  return report({
2393
2531
  headSha,
2394
- error: 'This SonarQube server does not analyse branches (Community Edition). '
2395
- + 'Set "whenBranchesRefused": "main" in the quality block to analyse it as the main analysis instead.',
2532
+ error: 'This SonarQube server does not analyse branches (Community Edition). Set '
2533
+ + '"whenBranchesRefused": "project" (a scratch SonarQube project per branch) or '
2534
+ + '"whenBranchesRefused": "main" in the quality block to analyse it as the main analysis instead '
2535
+ + "— or set this project's SonarQube-branches setting under Settings so this is known before the "
2536
+ + 'scan runs.',
2396
2537
  });
2397
2538
  }
2398
2539
  // The operator opted in: once more, as the project's main analysis.
@@ -2407,61 +2548,10 @@ async function scanQuality(config, run, cwd, branch) {
2407
2548
  }
2408
2549
 
2409
2550
  // What the scanner left behind: where the server queued the analysis.
2410
- const task = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2411
- const serverUrl = (task.serverUrl ?? url).replace(/\/$/, '');
2412
- const headers = { authorization: `Bearer ${token}` };
2413
- const ask = async (path) => {
2414
- const response = await fetch(`${serverUrl}${path}`, { headers });
2415
- if (!response.ok) {
2416
- throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
2417
- }
2418
- return response.json();
2419
- };
2420
-
2421
- // The analysis is queued on the server; wait for it to settle.
2422
- let settled = null;
2423
- while (Date.now() < deadline) {
2424
- const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`);
2425
- if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
2426
- settled = current;
2427
- break;
2428
- }
2429
- await new Promise((wake) => setTimeout(wake, 3000));
2430
- }
2431
- if (!settled) {
2432
- return report({ headSha, error: `SonarQube had not finished analysing after ${quality.timeoutSeconds}s.` });
2433
- }
2434
- if (settled.status !== 'SUCCESS') {
2435
- return report({ headSha, error: settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.` });
2436
- }
2437
-
2438
- const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
2439
- const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
2440
- const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
2441
- + `&metricKeys=${metrics}${analysedAs === 'BRANCH' ? `&branch=${encodeURIComponent(branch)}` : ''}`);
2442
- const measure = (metric) => {
2443
- const found = (component?.measures ?? []).find((each) => each.metric === metric);
2444
- // New-code values sit in `period.value` — `periods[0].value` on older servers.
2445
- const value = found?.period?.value ?? found?.periods?.[0]?.value ?? found?.value;
2446
- return value === undefined || value === null || value === '' ? null : Number(value);
2447
- };
2448
- const integer = (metric) => {
2449
- const value = measure(metric);
2450
- return value === null ? null : Math.round(value);
2451
- };
2452
-
2453
- await report({
2454
- headSha,
2455
- gate: projectStatus?.status ?? 'NONE',
2456
- analysedAs,
2457
- newBugs: integer('new_bugs'),
2458
- newVulnerabilities: integer('new_vulnerabilities'),
2459
- newSecurityHotspots: integer('new_security_hotspots'),
2460
- newCodeSmells: integer('new_code_smells'),
2461
- newCoverage: measure('new_coverage'),
2462
- dashboardUrl: task.dashboardUrl ?? null,
2463
- warning,
2464
- });
2551
+ const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2552
+ const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
2553
+ const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
2554
+ await reportBranchReading({ ask: askServer, reportTask, settled, key, branch, analysedAs, warning, headSha, report });
2465
2555
  } catch (failure) {
2466
2556
  await report({ headSha, error: failure.message.split('\n')[0] });
2467
2557
  } finally {
@@ -2471,6 +2561,185 @@ async function scanQuality(config, run, cwd, branch) {
2471
2561
  }
2472
2562
  }
2473
2563
 
2564
+ /** Without a trailing slash, so two URLs that differ only by one still compare equal. */
2565
+ function trimSlash(url) {
2566
+ return url.replace(/\/$/, '');
2567
+ }
2568
+
2569
+ /** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
2570
+ function sonarAsker(serverUrl, token) {
2571
+ return async (path) => {
2572
+ const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
2573
+ if (!response.ok) {
2574
+ throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
2575
+ }
2576
+ return response.json();
2577
+ };
2578
+ }
2579
+
2580
+ /**
2581
+ * Waits for a queued analysis to settle — shared by every scan path, BRANCH,
2582
+ * MAIN and PROJECT alike. Throws rather than reports: every caller runs
2583
+ * inside `scanQuality`'s own try/catch, which turns it into an error reading.
2584
+ */
2585
+ async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
2586
+ let settled = null;
2587
+ while (Date.now() < deadline) {
2588
+ const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
2589
+ if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
2590
+ settled = current;
2591
+ break;
2592
+ }
2593
+ await new Promise((wake) => setTimeout(wake, 3000));
2594
+ }
2595
+ if (!settled) {
2596
+ throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
2597
+ }
2598
+ if (settled.status !== 'SUCCESS') {
2599
+ throw new Error(settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.`);
2600
+ }
2601
+ return settled;
2602
+ }
2603
+
2604
+ /** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
2605
+ async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
2606
+ const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
2607
+ const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
2608
+ const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
2609
+ + `&metricKeys=${metrics}${analysedAs === 'BRANCH' ? `&branch=${encodeURIComponent(branch)}` : ''}`);
2610
+ const measure = (metric) => {
2611
+ const found = (component?.measures ?? []).find((each) => each.metric === metric);
2612
+ // New-code values sit in `period.value` — `periods[0].value` on older servers.
2613
+ const value = found?.period?.value ?? found?.periods?.[0]?.value ?? found?.value;
2614
+ return value === undefined || value === null || value === '' ? null : Number(value);
2615
+ };
2616
+ const integer = (metric) => {
2617
+ const value = measure(metric);
2618
+ return value === null ? null : Math.round(value);
2619
+ };
2620
+
2621
+ await report({
2622
+ headSha,
2623
+ gate: projectStatus?.status ?? 'NONE',
2624
+ analysedAs,
2625
+ newBugs: integer('new_bugs'),
2626
+ newVulnerabilities: integer('new_vulnerabilities'),
2627
+ newSecurityHotspots: integer('new_security_hotspots'),
2628
+ newCodeSmells: integer('new_code_smells'),
2629
+ newCoverage: measure('new_coverage'),
2630
+ dashboardUrl: reportTask.dashboardUrl ?? null,
2631
+ warning,
2632
+ });
2633
+ }
2634
+
2635
+ /**
2636
+ * SonarQube's own pages, `ps=500` at a time, up to the server's `p × ps`
2637
+ * ceiling worth trying — R313's `LIST_PAGES` argument, in the scanner
2638
+ * instead of the reader. `paging.total` (hotspots) or `total` (issues) is
2639
+ * wherever the server put it.
2640
+ */
2641
+ async function pagedSearch(ask, pathWithQuery, itemsKey) {
2642
+ const items = [];
2643
+ for (let page = 1; page <= 20; page += 1) {
2644
+ const body = await ask(`${pathWithQuery}&ps=500&p=${page}`);
2645
+ const list = body[itemsKey] ?? [];
2646
+ items.push(...list);
2647
+ const total = body.paging?.total ?? body.total ?? items.length;
2648
+ if (items.length >= total || list.length === 0) {
2649
+ break;
2650
+ }
2651
+ }
2652
+ return items;
2653
+ }
2654
+
2655
+ /** `sha1(text)`'s first six hex characters — enough to keep a scratch key readable and still collision-free per branch. */
2656
+ function shortHash(text) {
2657
+ return createHash('sha1').update(text).digest('hex').slice(0, 6);
2658
+ }
2659
+
2660
+ /**
2661
+ * The Community Edition fallback that never touches the project's own
2662
+ * dashboard — R316: the branch is analysed as its own scratch SonarQube
2663
+ * project, `<key>:branch:<branch>-<hash>`, overwritten on each run of the
2664
+ * branch. A fresh project has no new-code period to compare against, so
2665
+ * "new" is redefined as "on a line this branch changed since it left its
2666
+ * base" — {@link changedLines} against the merge-base — and the gate is
2667
+ * always `NONE`: a quality gate over a whole scratch project would judge
2668
+ * code this branch did not write.
2669
+ */
2670
+ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report }) {
2671
+ const baseName = run.baseBranch ?? run.defaultBranch ?? 'main';
2672
+ const ref = await firstRef(cwd, [`origin/${baseName}`, baseName]);
2673
+ const mergeBase = ref ? await git(cwd, ['merge-base', 'HEAD', ref]).catch(() => null) : null;
2674
+ if (!mergeBase) {
2675
+ return report({ headSha, error: `could not find where ${branch} left ${baseName}` });
2676
+ }
2677
+
2678
+ const scratchKey = `${key}:branch:${branch.replace(/[^A-Za-z0-9_.-]/g, '_')}-${shortHash(branch)}`;
2679
+ const env = { ...process.env, SONAR_TOKEN: token };
2680
+ const args = [
2681
+ `-Dsonar.host.url=${url}`,
2682
+ `-Dsonar.projectKey=${scratchKey}`,
2683
+ `-Dsonar.projectName=${key} · ${branch}`,
2684
+ `-Dsonar.scm.revision=${headSha}`,
2685
+ `-Dsonar.working.directory=${workDir}`,
2686
+ ];
2687
+ const ran = await runSkillCommand(quality.command, args,
2688
+ { cwd, env, timeoutMs: Math.max(1000, deadline - Date.now()) });
2689
+ if (!ran.ok) {
2690
+ if (/ENOENT/.test(ran.said)) {
2691
+ throw new Error(`${quality.command} is not installed on this machine (spawn ENOENT).`);
2692
+ }
2693
+ throw new Error(ran.said.split('\n')[0] || 'the scanner failed and said nothing');
2694
+ }
2695
+
2696
+ const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2697
+ const ask = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
2698
+ await waitForAnalysis(ask, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
2699
+
2700
+ const changed = await changedLines(cwd, mergeBase);
2701
+ const counted = (component, line) => {
2702
+ const path = component.slice(scratchKey.length + 1);
2703
+ const entry = changed.get(path);
2704
+ if (!entry) return false;
2705
+ if (entry === 'ALL') return true;
2706
+ // A file-level finding (no line) counts when the file itself changed.
2707
+ if (line === undefined || line === null) return true;
2708
+ return entry.has(line);
2709
+ };
2710
+
2711
+ const encodedKey = encodeURIComponent(scratchKey);
2712
+ const issues = await pagedSearch(ask,
2713
+ `/api/issues/search?componentKeys=${encodedKey}&resolved=false&types=BUG,VULNERABILITY,CODE_SMELL`, 'issues');
2714
+ const hotspots = await pagedSearch(ask,
2715
+ `/api/hotspots/search?projectKey=${encodedKey}&status=TO_REVIEW`, 'hotspots');
2716
+
2717
+ let newBugs = 0;
2718
+ let newVulnerabilities = 0;
2719
+ let newCodeSmells = 0;
2720
+ for (const issue of issues) {
2721
+ if (!counted(issue.component, issue.line)) continue;
2722
+ if (issue.type === 'BUG') newBugs += 1;
2723
+ else if (issue.type === 'VULNERABILITY') newVulnerabilities += 1;
2724
+ else if (issue.type === 'CODE_SMELL') newCodeSmells += 1;
2725
+ }
2726
+ const newSecurityHotspots = hotspots.filter((hotspot) => counted(hotspot.component, hotspot.line)).length;
2727
+
2728
+ await report({
2729
+ headSha,
2730
+ gate: 'NONE',
2731
+ analysedAs: 'PROJECT',
2732
+ newBugs,
2733
+ newVulnerabilities,
2734
+ newSecurityHotspots,
2735
+ newCodeSmells,
2736
+ newCoverage: null,
2737
+ dashboardUrl: reportTask.dashboardUrl ?? null,
2738
+ warning: 'This server does not analyse branches; the branch was analysed as its own SonarQube project, '
2739
+ + 'counting problems on the lines it changed. No gate was computed.',
2740
+ });
2741
+ }
2742
+
2474
2743
  /** `key=value` lines — the shape of the scanner's `report-task.txt`. */
2475
2744
  function parseProperties(text) {
2476
2745
  const parsed = {};
@@ -2689,7 +2958,10 @@ at what they might want next.
2689
2958
  ${briefLine(run)}
2690
2959
  If a decision is genuinely theirs, use the cawdev MCP tool \`ask_user\` and wait.
2691
2960
 
2692
- Their instruction:
2961
+ ${run.branch ? `When what they asked is done, commit it on ${run.branch} — work left
2962
+ uncommitted is work nothing can push or propose. Never commit to or push main.
2963
+
2964
+ ` : ''}Their instruction:
2693
2965
 
2694
2966
  ${run.openingPrompt}`;
2695
2967
  }
@@ -3371,13 +3643,35 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
3371
3643
  } else {
3372
3644
  const base = request.baseBranch ?? request.defaultBranch ?? 'main';
3373
3645
  log(` OPENING a pull request for ${branch} → ${base} — asked for from the console`);
3374
- ({ ok, result } = await openPullRequestFor(cwd, {
3375
- branch,
3376
- base,
3377
- title: request.title?.trim() || branch,
3378
- body: `Opened by cawdev for the sprint branch \`${branch}\`${request.title ? ` — ${request.title.trim()}` : ''}.\n\n`
3379
- + `Merging it lands every card of the sprint on ${base}.`,
3380
- }));
3646
+ // A card's branch may never have left this machine — a session whose
3647
+ // project does not push, which is why somebody is asking for the pull
3648
+ // request by hand. A sprint's is on origin already and this is a no-op.
3649
+ const empty = await nothingToPropose(cwd, branch, base);
3650
+ if (empty) {
3651
+ ok = false;
3652
+ result = empty;
3653
+ }
3654
+ const pushed = empty ? { failed: empty } : await pushIfOriginLacks(cwd, branch);
3655
+ if (pushed.failed) {
3656
+ ok = false;
3657
+ result = pushed.failed;
3658
+ } else {
3659
+ ({ ok, result } = await openPullRequestFor(cwd, {
3660
+ branch,
3661
+ base,
3662
+ title: request.title?.trim() || branch,
3663
+ // A sprint's title is its ref and name ("S1 Notifications"); a
3664
+ // card's is "R3 — …" (RunnerController reads either at claim).
3665
+ body: /^S\d+ /.test(request.title ?? '')
3666
+ ? `Opened by cawdev for the sprint branch \`${branch}\` — ${request.title.trim()}.\n\n`
3667
+ + `Merging it lands every card of the sprint on ${base}.`
3668
+ : `Opened by cawdev for \`${branch}\`${request.title ? ` — ${request.title.trim()}` : ''}.`,
3669
+ }));
3670
+ // The URL stays the FIRST line: the platform reads it off there.
3671
+ if (ok && pushed.said) {
3672
+ result = `${result}\n${pushed.said}`;
3673
+ }
3674
+ }
3381
3675
  }
3382
3676
  } else if (request.kind === 'CUT_BRANCH') {
3383
3677
  // R260. Put the sprint's branch on origin, from the default's tip. A branch
@@ -3436,8 +3730,12 @@ function reportWorkspaceRequest(config, request, ok, result, failure = null) {
3436
3730
  * platform holds the tree as it now is by the time the console hears "done".
3437
3731
  */
3438
3732
  function watchWorkspaceRequests(config, { afterTheTreeMoved = null } = {}) {
3439
- const served = new Set(
3440
- Object.values(config.projects).flatMap((project) => project.workspaces),
3733
+ // Read on EVERY request, never captured once: a checkout cloned on demand
3734
+ // (`autoWorkspaces`) joins `project.workspaces` long after this started, and
3735
+ // a set taken at boot refused every request about one — `/pr` on a session
3736
+ // that ran in `saasng-auto-2` came back "This runner does not serve …".
3737
+ const served = () => new Set(
3738
+ Object.values(config.projects).flatMap((project) => project.workspaces ?? []),
3441
3739
  );
3442
3740
 
3443
3741
  const poll = async () => {
@@ -3453,7 +3751,7 @@ function watchWorkspaceRequests(config, { afterTheTreeMoved = null } = {}) {
3453
3751
  // The same guard, for the same reason: this poll is on an interval, so a
3454
3752
  // throw becomes an unhandled rejection and the daemon stops.
3455
3753
  for (const request of Array.isArray(requests) ? requests : []) {
3456
- if (!served.has(request.path)) {
3754
+ if (!served().has(request.path)) {
3457
3755
  await reportWorkspaceRequest(config, request, false,
3458
3756
  `This runner does not serve ${request.path}.`);
3459
3757
  continue;
@@ -3678,6 +3976,35 @@ async function pushBranch(cwd, branch) {
3678
3976
  * An existing pull request is a success, not a conflict — the rule wanted one
3679
3977
  * to exist, and one does.
3680
3978
  */
3979
+ /**
3980
+ * Why there is nothing to open a pull request FOR, or null when there is.
3981
+ *
3982
+ * A branch with no commit beyond its base is not a pull request — GitHub says
3983
+ * "There isn't anything to compare" — and before this `/pr` pushed it anyway
3984
+ * and handed back the compare link, so the person went to GitHub to learn
3985
+ * what this machine already knew. The usual reason is work sitting
3986
+ * UNCOMMITTED in the checkout, so that is counted and said, with where.
3987
+ *
3988
+ * Asked BEFORE anything is pushed, on the OPEN_PR a person asks for (`/pr`).
3989
+ * Not on the `auto_pr` rule: a finished run that committed nothing gets gh's
3990
+ * own refusal there, as it always has. A count that cannot be made (no such
3991
+ * ref, no origin) is not a verdict: null, and the old path decides as it did.
3992
+ */
3993
+ async function nothingToPropose(cwd, branch, base) {
3994
+ const has = (ref) => git(cwd, ['rev-parse', '--verify', '--quiet', ref]).then(() => true, () => false);
3995
+ const tip = (await has(branch)) ? branch : (await has(`origin/${branch}`)) ? `origin/${branch}` : null;
3996
+ const against = base ? `origin/${base}` : 'origin/HEAD';
3997
+ if (!tip || !(await has(against))) return null;
3998
+ const ahead = Number(await git(cwd, ['rev-list', '--count', `${against}..${tip}`]).catch(() => 'x'));
3999
+ if (!Number.isFinite(ahead) || ahead > 0) return null;
4000
+ const dirty = (await git(cwd, ['status', '--porcelain']).catch(() => '')).split('\n').filter(Boolean).length;
4001
+ const onIt = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')) === branch;
4002
+ return `${branch} has no commit beyond ${against.replace(/^origin\//, '')}, so there is nothing to propose.`
4003
+ + (dirty && onIt
4004
+ ? ` ${dirty} file(s) are uncommitted in ${cwd} — commit them, then ask again.`
4005
+ : ' Nothing was pushed.');
4006
+ }
4007
+
3681
4008
  async function openPullRequest(cwd, run, title) {
3682
4009
  const state = await readPushState(cwd, run.branch).catch(() => null);
3683
4010
  if (state === 'NO_REMOTE') {
@@ -3716,6 +4043,51 @@ async function openPullRequest(cwd, run, title) {
3716
4043
  * `--base` only when one is given: with no base the argv is byte for byte what
3717
4044
  * it was before R260 and the host picks its own default.
3718
4045
  */
4046
+ /**
4047
+ * Pushes `branch` to origin when origin does not have it, or has an older
4048
+ * commit of it — and never otherwise.
4049
+ *
4050
+ * Before a pull request is opened for a branch a session wrote on this
4051
+ * machine: `gh pr create --head` names a branch on the HOST, and one that only
4052
+ * exists here fails with a message about the head that says nothing of why.
4053
+ * Only a fast-forward is pushed. Origin ahead, or diverged, is somebody else's
4054
+ * work on the branch, and pushing over it is not what "open the pull request"
4055
+ * asked for — that case goes to gh as it is and gh says what is wrong. Nothing
4056
+ * here moves the checkout: it is `<branch>:<branch>`, not the current HEAD.
4057
+ *
4058
+ * @returns `{ said }` when it pushed, `{}` when there was nothing to push, and
4059
+ * `{ failed }` when a push was needed and git refused it.
4060
+ */
4061
+ async function pushIfOriginLacks(cwd, branch) {
4062
+ const local = await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
4063
+ .catch(() => null);
4064
+ if (!local) {
4065
+ return {};
4066
+ }
4067
+ const listed = await git(cwd, ['ls-remote', '--heads', 'origin', branch]).catch(() => null);
4068
+ if (listed === null) {
4069
+ return {};
4070
+ }
4071
+ const remote = listed.split(/\s+/)[0] || null;
4072
+ if (remote === local) {
4073
+ return {};
4074
+ }
4075
+ if (remote) {
4076
+ const behind = await git(cwd, ['merge-base', '--is-ancestor', remote, local])
4077
+ .then(() => true, () => false);
4078
+ if (!behind) {
4079
+ return {};
4080
+ }
4081
+ }
4082
+ try {
4083
+ await git(cwd, ['push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { timeoutMs: 120_000 });
4084
+ log(` pushed ${branch} to origin first`);
4085
+ return { said: `pushed ${branch} to origin first` };
4086
+ } catch (failure) {
4087
+ return { failed: `Could not push ${branch} before opening its pull request: ${failure.message}` };
4088
+ }
4089
+ }
4090
+
3719
4091
  async function openPullRequestFor(cwd, { branch, base, title, body }) {
3720
4092
  const existing = await findPullRequest(cwd, branch).catch(() => null);
3721
4093
  if (existing && !existing.includes('/compare/')) {
@@ -3730,8 +4102,28 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
3730
4102
  '--body', body,
3731
4103
  ]);
3732
4104
  if (opened.code === 0 && opened.out) {
3733
- // gh prints the URL and nothing else on success.
3734
- return { ok: true, result: opened.out.split('\n').pop().trim() };
4105
+ // gh prints the URL and nothing else on success — and then the host is
4106
+ // asked whether it is really there, because "asked for" is not "made",
4107
+ // and a URL on the card that 404s is worse than an honest failure.
4108
+ const printed = opened.out.split('\n').pop().trim();
4109
+ const confirmed = await findPullRequest(cwd, branch).catch(() => null);
4110
+ if (confirmed && !confirmed.includes('/compare/')) {
4111
+ return { ok: true, result: confirmed };
4112
+ }
4113
+ return {
4114
+ ok: false,
4115
+ result: `gh said it opened ${printed}, but the host does not show a pull request for ${branch}.`,
4116
+ };
4117
+ }
4118
+ // The one failure somebody can fix in a minute, said with the fix — and the
4119
+ // link to open it by hand meanwhile, which `findPullRequest` builds without gh.
4120
+ if (opened.code === -1 && /not installed/.test(opened.err)) {
4121
+ const compare = await findPullRequest(cwd, branch).catch(() => null);
4122
+ return {
4123
+ ok: false,
4124
+ result: `${opened.err} Install it and sign in (brew install gh && gh auth login), `
4125
+ + `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
4126
+ };
3735
4127
  }
3736
4128
  return { ok: false, result: opened.err || opened.out || 'gh pr create failed without saying why.' };
3737
4129
  }
@@ -5343,11 +5735,36 @@ function writeUserMessage(child, text) {
5343
5735
  * and the qualified name is the one thing that must be exact. R147 was five
5344
5736
  * bugs' worth of evidence that a name a session cannot use is a capability it
5345
5737
  * does not have.
5738
+ *
5739
+ * <p>i257. `stillBackgrounded` names the ones that WERE delegated to but only
5740
+ * with a backgrounded `Agent` call — so "has not used it" would be false and
5741
+ * the fix would be wrong: telling a session to call something again that it
5742
+ * already called would read as broken, not as a nudge. Those get the specific
5743
+ * reason instead, because the generic "use it now" is the wrong instruction —
5744
+ * it already did, and doing it the same way again loses the result the same
5745
+ * way.
5346
5746
  */
5347
- function nudgeText(stage, missing) {
5348
- const names = missing.map((each) => `\`${each}\``).join(', ');
5349
- return `cawdev: this project requires ${names}, and this ${stage.stage} stage has not `
5350
- + 'used it.\n\n'
5747
+ function nudgeText(stage, missing, stillBackgrounded = []) {
5748
+ const neverUsed = missing.filter((each) => !stillBackgrounded.includes(each));
5749
+ const clauses = [
5750
+ neverUsed.length
5751
+ ? `has not used ${neverUsed.map((each) => `\`${each}\``).join(', ')}`
5752
+ : null,
5753
+ ].filter(Boolean);
5754
+ const opening = clauses.length
5755
+ ? `cawdev: this project requires ${missing.map((each) => `\`${each}\``).join(', ')}, and `
5756
+ + `this ${stage.stage} stage ${clauses.join(', and ')}.`
5757
+ : `cawdev: this project requires ${missing.map((each) => `\`${each}\``).join(', ')}.`;
5758
+ const backgroundedNames = stillBackgrounded.map((each) => `\`${each}\``).join(', ');
5759
+ const backgroundedNote = stillBackgrounded.length
5760
+ ? `\n\n${backgroundedNames} ${stillBackgrounded.length > 1 ? 'were' : 'was'} delegated to `
5761
+ + 'with `Agent`, but in the background, and its result has not come back. This stage is '
5762
+ + 'one process (R112): your turn ending is indistinguishable, to the daemon, from being '
5763
+ + 'finished, so a backgrounded delegation still in flight when that happens is lost along '
5764
+ + 'with anything gated on it. Call it again with `run_in_background: false` and wait for '
5765
+ + 'it in this same turn before finishing.'
5766
+ : '';
5767
+ return `${opening}${backgroundedNote}\n\n`
5351
5768
  + 'Use it now — `Skill` with `skill: "<name>"`, `Agent` with `subagent_type: "<name>"`, or '
5352
5769
  + 'for an MCP server a call to one of its tools (`mcp__<server>__…`) — and then say what it '
5353
5770
  + 'changed about your answer.\n\n'
@@ -5577,6 +5994,120 @@ function heldWorkspaces() {
5577
5994
  return held;
5578
5995
  }
5579
5996
 
5997
+ /**
5998
+ * Which checkout an offered coding run gets, or why it must keep waiting —
5999
+ * R71/i258.
6000
+ *
6001
+ * In order: a NAMED checkout (`offered.workspace`) is the only answer once
6002
+ * the branch is bound to it — served or busy, nothing else is considered. A
6003
+ * PREFERRED checkout (`offered.preferredWorkspace`) is taken when free,
6004
+ * dirty or not: it is the checkout the branch last ran in, so uncommitted
6005
+ * work there is plausibly that branch's own leftovers, and routing away from
6006
+ * it risks abandoning work rather than protecting it. That is pin-shaped
6007
+ * reasoning, and it is deliberate that cleanliness plays no part in this
6008
+ * rung — do not "fix" it in passing.
6009
+ *
6010
+ * The one exception is `offered.waitForClean` — R270. Once the person has
6011
+ * said "don't touch dirty", the idea that the leftovers are probably this
6012
+ * branch's own no longer applies: the preferred checkout is taken only when
6013
+ * it reads clean, and otherwise the run falls through to the free scan below
6014
+ * exactly as if it had no preference at all.
6015
+ *
6016
+ * Below that, a checkout is free game and cleanliness finally matters: the
6017
+ * first free path that reads CLEAN wins, and only when nothing free is known
6018
+ * clean does the first free path win regardless — today's behaviour,
6019
+ * unchanged, so the existing refusal in `prepareWorkingCopy` and the
6020
+ * console's *Start anyway* flow still work exactly as they did. With
6021
+ * `waitForClean` set, that last fallback does not happen: the run keeps
6022
+ * waiting instead of taking a dirty checkout.
6023
+ *
6024
+ * Cleanliness is read fresh, right here, rather than from
6025
+ * `surveyWorkingCopies`'s heartbeat reading — see `looksClean`. `probed` is a
6026
+ * `Map` the caller creates once per poll pass, so several offers of one
6027
+ * project only ask each checkout once.
6028
+ *
6029
+ * `surveyGit` and `checkMerges` use the same `workspaces.find((path) =>
6030
+ * !held.has(path))` idiom this replaces, and must NOT be unified with this:
6031
+ * they read refs, never the tree, so which checkout they pick is a different
6032
+ * question and dirtiness does not bear on it.
6033
+ */
6034
+ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0) {
6035
+ const pinned = offered.workspace ?? null;
6036
+ if (pinned) {
6037
+ if (!project.workspaces.includes(pinned)) {
6038
+ // Not one of ours. The platform pins a machine as well as a path, so
6039
+ // this means the config changed under a live branch — which is a
6040
+ // thing to say out loud rather than quietly ignore.
6041
+ return { workspace: null, why: `its branch is in ${pinned}, which this machine does not serve` };
6042
+ }
6043
+ if (held.has(pinned)) {
6044
+ return {
6045
+ workspace: null,
6046
+ why: `waiting for ${pinned}, which is busy — the branch has work only that checkout holds`,
6047
+ };
6048
+ }
6049
+ return { workspace: pinned, why: null };
6050
+ }
6051
+
6052
+ const waitForClean = offered.waitForClean === true;
6053
+ const preferred = offered.preferredWorkspace ?? null;
6054
+ if (preferred && project.workspaces.includes(preferred) && !held.has(preferred)) {
6055
+ if (!waitForClean) {
6056
+ return { workspace: preferred, why: null };
6057
+ }
6058
+ if (!probed.has(preferred)) probed.set(preferred, await looksClean(preferred));
6059
+ if (probed.get(preferred) === true) return { workspace: preferred, why: null };
6060
+ }
6061
+
6062
+ const free = project.workspaces.filter((path) => !held.has(path));
6063
+ if (!free.length) {
6064
+ // Every checkout is busy. Where the machine allows it, make one more
6065
+ // rather than wait — the second coding session on a project starting at
6066
+ // once is the whole point of `autoWorkspaces`.
6067
+ const first = project.workspaces[0];
6068
+ const made = project.workspaces.filter((path) => isAutoWorkspace(first, path));
6069
+ if (made.length < autoMax) {
6070
+ try {
6071
+ log(`every checkout of ${slug} is busy — cloning another beside ${first}`);
6072
+ const path = await createAutoWorkspace(first, project.workspaces);
6073
+ project.workspaces.push(path);
6074
+ log(` made ${path} (${made.length + 1} of ${autoMax} on demand)`);
6075
+ return { workspace: path, why: null };
6076
+ } catch (failure) {
6077
+ log(` could not make another checkout of ${slug}: ${failure.message}`);
6078
+ return { workspace: null, why: `no free workspace in ${slug}, and cloning another failed` };
6079
+ }
6080
+ }
6081
+ return {
6082
+ workspace: null,
6083
+ why: autoMax
6084
+ ? `no free workspace in ${slug} (${project.workspaces.length} here, all busy, `
6085
+ + `and the ${autoMax} this machine may make are made)`
6086
+ : `no free workspace in ${slug} (${project.workspaces.length} here, all busy)`,
6087
+ };
6088
+ }
6089
+ for (const path of free) {
6090
+ if (!probed.has(path)) probed.set(path, await looksClean(path));
6091
+ if (probed.get(path) === true) return { workspace: path, why: null };
6092
+ }
6093
+ // Nothing free reads clean — including anything `looksClean` could not read
6094
+ // at all, which groups with dirty rather than clean.
6095
+ if (waitForClean) {
6096
+ // R270: the person said to wait rather than go ahead on top of somebody
6097
+ // else's work. Keep waiting instead of falling back to the old
6098
+ // first-free rule below.
6099
+ return {
6100
+ workspace: null,
6101
+ why: `waiting for a clean checkout in ${slug} — every free one has uncommitted work or `
6102
+ + 'could not be read; stash or commit in one, or wait for a running session to finish',
6103
+ };
6104
+ }
6105
+ // Fall back to the old first-free rule verbatim, so the refusal and *Start
6106
+ // anyway* flow still apply to exactly this workspace.
6107
+ log(`every free checkout in ${slug} has uncommitted work — taking ${free[0]}`);
6108
+ return { workspace: free[0], why: null };
6109
+ }
6110
+
5580
6111
  function noteQueued(run, why) {
5581
6112
  // Once per REASON, not once per run — R70, now that there are two gates a run
5582
6113
  // can be held by. A run refused by the machine's cap and then, when a session
@@ -5618,6 +6149,10 @@ function snapshotRuns() {
5618
6149
  // Which checkout it is in — R47. On a machine serving three, the label
5619
6150
  // alone no longer says where the work is.
5620
6151
  workspace: child.cawdevWorkspace ?? null,
6152
+ // Which CLI, and which model it is on — per STAGE on a walked run,
6153
+ // since each stage is its own process with its own model (R112).
6154
+ agent: child.cawdevAgent ?? null,
6155
+ model: child.cawdevModel ?? null,
5621
6156
  startedAt: child.cawdevStartedAt ?? null,
5622
6157
  });
5623
6158
  }
@@ -5698,6 +6233,19 @@ async function startRun(config, offered, workspace) {
5698
6233
  defaultBranch = claimed.defaultBranch;
5699
6234
  // Whether somebody was shown the uncommitted work and started anyway.
5700
6235
  allowDirty = claimed.allowDirty === true;
6236
+ // Or whether the uncommitted work is THIS BRANCH'S OWN: the platform named
6237
+ // the checkout (R86/R87 — a carried-on run's held workspace, or the next
6238
+ // run of a pinned work item) and it is already on the run's branch. R80
6239
+ // holds a failed run's checkout precisely so its work survives, and
6240
+ // refusing that work as "yours, commit it first" turned every Carry on
6241
+ // into a second failure with nothing in the console able to break the loop.
6242
+ if (!allowDirty && offered.workspace && path && writesCodeProfile(run) && run.branch) {
6243
+ const on = await git(resolve(path), ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '');
6244
+ if (on.trim() === run.branch) {
6245
+ allowDirty = true;
6246
+ log(` ${path} is on ${run.branch} already — its uncommitted work is this branch's, kept`);
6247
+ }
6248
+ }
5701
6249
  // And what this project has decided happens when the run ends — R40. The
5702
6250
  // daemon does not act on this: the platform queues the push, the pull
5703
6251
  // request and the merge as actions and `settleActions` performs them. It is
@@ -5726,6 +6274,9 @@ async function startRun(config, offered, workspace) {
5726
6274
  // reads them off `run` after TEST rather than through another parameter.
5727
6275
  run.sonarProjectKey = claimed.sonarProjectKey ?? null;
5728
6276
  run.sonarUrl = claimed.sonarUrl ?? null;
6277
+ // R316. Whether the project SAYS its server refuses branches — null is
6278
+ // "not sure, try the branch scan first", today's behaviour.
6279
+ run.sonarBranches = claimed.sonarBranches ?? null;
5729
6280
  // R69. Whether this is the same conversation picked back up, and which one.
5730
6281
  // Null on an ordinary claim, which is nearly all of them.
5731
6282
  resume = claimed.resume ?? null;
@@ -6316,8 +6867,16 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6316
6867
  // checked against the real CLI by hand) that it is not this function's
6317
6868
  // Claude-specific machinery with a different binary name; it is
6318
6869
  // `spawnAntigravity`, entirely.
6870
+ // Codex's own shape — a real sandbox and a per-server tool list, but no
6871
+ // allow-list for the shell — is `spawnCodex`, for the same reason.
6872
+ if ((stage?.agent ?? run.agent) === 'codex') {
6873
+ const codexCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'codex') ?? 'codex';
6874
+ return spawnCodex(config, run, runToken, cwd, baseCommit, resume, projectServers,
6875
+ skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
6876
+ codexCmd, granted, shield, usage);
6877
+ }
6319
6878
  if ((stage?.agent ?? run.agent) === 'agy') {
6320
- const agyCmd = config.agentCommands.find((each) => each.endsWith('agy')) ?? 'agy';
6879
+ const agyCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'agy') ?? 'agy';
6321
6880
  return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
6322
6881
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
6323
6882
  agyCmd, granted, usage);
@@ -6654,10 +7213,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6654
7213
  // Reaching here at all means Claude Code answers this run — the branch at
6655
7214
  // the top of this function sent Antigravity to spawnAntigravity instead.
6656
7215
  // Matched against what this machine actually lists (`config.agentCommands`)
6657
- // by the same suffix rule `readUsage` uses the other way around, so a
6658
- // machine whose list is agy-first still gets Claude Code's own command.
6659
- const agentCmd = config.agentCommands.find((each) => !each.endsWith('agy'))
6660
- ?? config.agentCommands[0] ?? 'claude';
7216
+ // by `agentOfCommand`, the rule `readUsage` uses too, so a machine whose list
7217
+ // is agy- or codex-first still gets Claude Code's own command.
7218
+ const agentCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'claude')
7219
+ ?? 'claude';
6661
7220
 
6662
7221
  const args = [
6663
7222
  '--mcp-config',
@@ -6692,6 +7251,11 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6692
7251
  });
6693
7252
 
6694
7253
  child.cawdevProjectSlug = run.projectSlug;
7254
+ // Which CLI and which model — what advanced mode puts in a window's title.
7255
+ // The model asked for now; the one the CLI says it is on replaces it at
7256
+ // `init` (below), which is also the only answer when none was asked.
7257
+ child.cawdevAgent = 'claude';
7258
+ child.cawdevModel = model ?? null;
6695
7259
  // The checkout this child holds, and what the queue serialises on. Null for
6696
7260
  // a run that took none, which is what lets an ASK session run alongside
6697
7261
  // three coding ones.
@@ -6751,6 +7315,17 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6751
7315
  // asked of the platform: the answer is needed at the `result` event, and a
6752
7316
  // question that has to cross the network cannot be answered in time.
6753
7317
  const used = new Set();
7318
+ // i257. A required expert's `Agent(...)` line is written the instant the
7319
+ // call is MADE (R130), not when its result comes back — so `used` alone
7320
+ // cannot tell "delegated and waited" from "delegated and moved on". This
7321
+ // holds the qualified name of every required expert whose most recent
7322
+ // `Agent`/`Task` call was NOT `run_in_background: false` — added on that
7323
+ // call, and removed the moment the same name is called again WITH it. A
7324
+ // stage is one process and gets one turn (R112); a session that ends its
7325
+ // turn with a name still in here dispatched the delegation and did not
7326
+ // wait for it, which is exactly the shape that loses it when `endSession`
7327
+ // closes stdin below.
7328
+ const backgrounded = new Set();
6754
7329
  // The last of what the CLI said on stderr, for the exit decision below.
6755
7330
  // The usage-limit notice is written there, and a decision made on stdout
6756
7331
  // alone would call every window a crash.
@@ -6912,6 +7487,70 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6912
7487
  buffer = buffer.slice(newline + 1);
6913
7488
  if (!line) continue;
6914
7489
  const event = safeJson(line);
7490
+
7491
+ // i257. Read off the RAW event, before `linesOf` flattens it to a
7492
+ // string: `describeCall` (`../lib/tool-line.mjs`) never carries
7493
+ // `run_in_background` into the TOOL line it writes — deliberately not
7494
+ // extended for it, because that line is a parsing contract three
7495
+ // implementations share (`CapabilityUse.java`, `capability-tag.ts`),
7496
+ // and this is a daemon-internal question none of the three need
7497
+ // answered. So this is the one place that can still see the field.
7498
+ // Record-then-decide, ahead of the loop below that reacts to
7499
+ // `result`, so a `result` on this same line always sees this line's
7500
+ // own dispatches first.
7501
+ //
7502
+ // Only tracked for a REQUIRED expert. An optional one backgrounded is
7503
+ // the session's own call and nothing here gates on it.
7504
+ //
7505
+ // Known consequence of not extending the shared line: the platform's
7506
+ // own ledger (`RunCapabilityService.used`, called from
7507
+ // `RunService.appendOutput` on the same TOOL line and read by the run
7508
+ // panel) increments `RunCapability.uses` the instant the line is
7509
+ // written, exactly like `used` below — it has no way to learn a
7510
+ // dispatch was backgrounded, so a stage this nudge caught and that
7511
+ // then corrected itself still reads as an ordinary "required,
7512
+ // delegated to 1×" on the panel afterwards. That is this card's
7513
+ // trade-off, made on purpose: the daemon is the one place that can
7514
+ // still leave with the delegation unfinished, so it is the one place
7515
+ // that has to catch it before it does. Making the panel say so too is
7516
+ // a different card, not a reason to read it as ground truth here.
7517
+ //
7518
+ // The default-background assumption this rests on is not a guess:
7519
+ // i249 and i250 are both a `Agent(...)` call with no
7520
+ // `run_in_background` at all whose result never came back — this
7521
+ // project's own Agent tool backgrounds unless told `false`. If a
7522
+ // spawned CLI's `Agent` were ever synchronous by default, this would
7523
+ // nudge a stage that did nothing wrong — loud, not silent, and capped
7524
+ // at one wasted turn by `child.cawdevNudged` below.
7525
+ //
7526
+ // Keyed `EXPERT:<name>`, matching `mustUse`/`used`: a Set of bare
7527
+ // names would let a REQUIRED skill and a REQUIRED expert that share a
7528
+ // qualified name mark each other's requirement backgrounded.
7529
+ //
7530
+ // The last call to a name wins. A session that is awaited once and
7531
+ // backgrounded again later is reported missing on the second call —
7532
+ // "requirement met" and "something is in flight" are conflated on
7533
+ // purpose, because the second call is the one whose result this
7534
+ // stage's turn is actually at risk of losing.
7535
+ if (event?.type === 'assistant') {
7536
+ for (const part of event.message?.content ?? []) {
7537
+ if (part?.type !== 'tool_use' || (part.name !== 'Agent' && part.name !== 'Task')) {
7538
+ continue;
7539
+ }
7540
+ const who = typeof part.input?.subagent_type === 'string'
7541
+ ? part.input.subagent_type : null;
7542
+ const entry = who ? `EXPERT:${who}` : null;
7543
+ if (!entry || !mustUse.has(entry)) {
7544
+ continue;
7545
+ }
7546
+ if (part.input?.run_in_background === false) {
7547
+ backgrounded.delete(entry);
7548
+ } else {
7549
+ backgrounded.add(entry);
7550
+ }
7551
+ }
7552
+ }
7553
+
6915
7554
  if (event?.type === 'result') {
6916
7555
  if (typeof event.result === 'string') {
6917
7556
  lastText = event.result;
@@ -6977,7 +7616,18 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6977
7616
  // lifecycle never reaches here — `endSession` is deliberately not
6978
7617
  // called for one — so REQUIRED is prompt wording and a console row
6979
7618
  // on ASK, ROADMAP and AUDIT, and nothing more.
6980
- const missing = [...mustUse].filter((entry) => !used.has(entry)).map(nameOf);
7619
+ // i257. `used` alone says "the name appeared in a TOOL line",
7620
+ // which is true the instant a required expert is DISPATCHED —
7621
+ // backgrounded or not. An entry still in `backgrounded` was
7622
+ // delegated but never waited for, so it counts as missing here
7623
+ // too. `stillBackgroundedEntries` keeps the `EXPERT:` prefix so it
7624
+ // can only ever match the Set it was built from; only its NAMES
7625
+ // (`nameOf`) are for display.
7626
+ const stillBackgroundedEntries = [...mustUse].filter((entry) => backgrounded.has(entry));
7627
+ const trulyMissingEntries = [...mustUse].filter((entry) => !used.has(entry));
7628
+ const missing = [...new Set([...trulyMissingEntries, ...stillBackgroundedEntries])]
7629
+ .map(nameOf);
7630
+ const stillBackgrounded = stillBackgroundedEntries.map(nameOf);
6981
7631
  // Whether the input stays open for one more turn. NOT an early
6982
7632
  // return out of this handler, which the obvious shape would be:
6983
7633
  // this is the stdout reader's `while`, and returning from it would
@@ -6988,8 +7638,24 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6988
7638
  // Once per process. A model that has decided cannot be looped:
6989
7639
  // this is a nudge, not an argument.
6990
7640
  child.cawdevNudged = true;
6991
- const line = `cawdev: this stage has not used ${missing.join(', ')}, `
6992
- + 'which this project requires. Asking it once.';
7641
+ // i257. "has not used" is not true of `stillBackgrounded` — it
7642
+ // WAS called, just not waited for — and this line is the half
7643
+ // of the nudge a person actually reads (`nudgeText` below goes
7644
+ // to the child's stdin, never to the transcript). This card
7645
+ // exists because a run once said nothing was done when work
7646
+ // really happened; a nudge that misdescribes its own trigger
7647
+ // would be the same mistake at smaller scale.
7648
+ const trulyMissingNames = trulyMissingEntries.filter((entry) => !backgrounded.has(entry))
7649
+ .map(nameOf);
7650
+ const clauses = [
7651
+ trulyMissingNames.length ? `has not used ${trulyMissingNames.join(', ')}` : null,
7652
+ stillBackgrounded.length
7653
+ ? `delegated to ${stillBackgrounded.join(', ')} only in the background and never `
7654
+ + 'waited for it'
7655
+ : null,
7656
+ ].filter(Boolean);
7657
+ const line = `cawdev: this stage ${clauses.join(', and ')}, which this project `
7658
+ + 'requires. Asking it once.';
6993
7659
  log(` ${line}`);
6994
7660
  transcript.push({ kind: 'SYSTEM', body: line });
6995
7661
  // The return value is load-bearing. It is false when stdin is
@@ -6997,7 +7663,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6997
7663
  // through to `endSession` is what stops a dead session from never
6998
7664
  // being dismissed. That is R22's forty-three minutes arriving
6999
7665
  // through a new door.
7000
- nudged = writeUserMessage(child, nudgeText(stage, missing));
7666
+ nudged = writeUserMessage(child, nudgeText(stage, missing, stillBackgrounded));
7001
7667
  if (nudged) {
7002
7668
  // The turn runs again and the next `result` closes the input.
7003
7669
  // This timer is the whole defence against the one deadlock this
@@ -7022,6 +7688,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7022
7688
  if (event?.type === 'system' && event.subtype === 'init' && event.session_id) {
7023
7689
  reportSessionId(config, run, event.session_id);
7024
7690
  }
7691
+ if (event?.type === 'system' && event.subtype === 'init' && event.model) {
7692
+ child.cawdevModel = event.model;
7693
+ }
7025
7694
  // R76. What the session has consumed, recorded rather than only
7026
7695
  // printed. Caught here for the reason the session id is: the CLI says
7027
7696
  // it on a `result` event and nowhere else.
@@ -7657,6 +8326,8 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7657
8326
  stdio: ['ignore', 'pipe', 'pipe'],
7658
8327
  });
7659
8328
  child.cawdevProjectSlug = run.projectSlug;
8329
+ child.cawdevAgent = 'agy';
8330
+ child.cawdevModel = run.model ?? null;
7660
8331
  // The existing cancellation sweep (`reapCancelled`) reads this map by
7661
8332
  // run id and SIGTERMs whatever is in it — true for any child, so a
7662
8333
  // cancelled Antigravity run stops exactly the way a cancelled Claude
@@ -7802,6 +8473,290 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7802
8473
  });
7803
8474
  }
7804
8475
 
8476
+ /**
8477
+ * The cawdev tools a Codex process may call — the SAME list Claude Code's
8478
+ * `--allowedTools` would hold for this run and stage, without the prefix.
8479
+ *
8480
+ * <p>`enabled_tools` on cawdev's server is the one narrowing this CLI does as
8481
+ * precisely as Claude Code: a tool left out is not offered at all (checked by
8482
+ * hand). So the list is derived from `PROFILE_TOOLS` and `toolsForStage`
8483
+ * rather than chosen here — a second place deciding what a profile may do is
8484
+ * how the two agents would drift. Above all, `toolsForStage` takes `report`
8485
+ * away from every STAGE, whose "done" would end the run in the middle of the
8486
+ * walk.
8487
+ *
8488
+ * <p>Null — every tool the server has — only for a coding run with no
8489
+ * lifecycle, which can only be spawned where the machine allowed everything.
8490
+ */
8491
+ function codexCawdevTools(run, stage, mayWrite) {
8492
+ const profile = PROFILE_TOOLS[run.profile];
8493
+ const profileList = profile ? (typeof profile === 'function' ? profile(run) : profile) : null;
8494
+ if (stage) {
8495
+ return cawdevToolNames(toolsForStage(stage.stage, profileList ?? CAWDEV_TOOLS, stage.testMode,
8496
+ { writesItsOwnCard: writesItsOwnCard(run) }));
8497
+ }
8498
+ if (profileList) {
8499
+ return cawdevToolNames(profileList);
8500
+ }
8501
+ return mayWrite ? null : cawdevToolNames(READ_ONLY_CAWDEV);
8502
+ }
8503
+
8504
+ /**
8505
+ * OpenAI's Codex CLI — one `codex exec` per turn, like Antigravity, and the
8506
+ * command line, the stream and every flag's behaviour are `../lib/codex.mjs`,
8507
+ * checked against the real CLI.
8508
+ *
8509
+ * <p>What it has that Antigravity lacks is a SANDBOX the operating system
8510
+ * enforces, and that decides the permission question:
8511
+ *
8512
+ * <ul>
8513
+ * <li>A session that must change nothing — every profile but the coding
8514
+ * ones, and PLAN, VERIFY and MEMORY of any run — is spawned with
8515
+ * `--sandbox read-only`. Its shell can read and cannot write, and it does
8516
+ * not need the machine to have allowed anything: "cannot" is the kernel's
8517
+ * word here, not the prompt's. Only cawdev's server is attached, narrowed
8518
+ * to the profile's list by `codexCawdevTools`.
8519
+ * <li>A session that writes — CODE, INTERVIEW, MERGE, RELEASE, and their
8520
+ * IMPLEMENT and TEST — has to commit and push, which the workspace sandbox
8521
+ * refuses (`.git` and the network are outside it). There is no allow-list
8522
+ * for the shell on this CLI, so it runs with the sandbox bypassed, and
8523
+ * ONLY where the machine allowed everything (R126) — Antigravity's rule.
8524
+ * </ul>
8525
+ *
8526
+ * <p>Not reached: experts (nothing on this CLI loads cawdev's `agents/*.md`,
8527
+ * so the session is told it cannot delegate rather than shown names it cannot
8528
+ * call), R51's permission prompt and R110's command shield (both live in
8529
+ * `approve`, which Codex never calls) and R22's live prompting (the process
8530
+ * ends with its turn; a follow-up is a resume). Skills are inlined whole, as
8531
+ * for Antigravity. The transcript half of R110 — secrets taken out of a tool
8532
+ * result — does apply, since it runs here and not in the CLI.
8533
+ */
8534
+ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projectServers,
8535
+ skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
8536
+ codexCmd, granted, shield, usage = null) {
8537
+ const failedBeforeSpawn = async (summary) => {
8538
+ log(` ${summary}`);
8539
+ await finish(config, run, 'FAILED', summary);
8540
+ return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
8541
+ };
8542
+
8543
+ const mayWrite = writesCodeProfile(run) && !readOnlyStage;
8544
+ if (mayWrite && !granted.everything) {
8545
+ return failedBeforeSpawn('This machine has not allowed everything, and a Codex session that '
8546
+ + 'writes has no narrower mode — it must commit and push, which its sandbox refuses, and '
8547
+ + 'there is no allow-list for its shell. Turn on "allow everything" for this machine, or use '
8548
+ + 'Claude Code for this run. (Read-only runs and stages need nothing.)');
8549
+ }
8550
+ if (mayWrite) {
8551
+ log(' !! this machine allows EVERYTHING: this Codex session runs without a sandbox');
8552
+ }
8553
+
8554
+ const transcript = new Transcript(config, run);
8555
+ const serverPath = config.mcpServerPath ?? new URL('../mcp/server.mjs', import.meta.url).pathname;
8556
+
8557
+ // The token reaches cawdev's server through `env_vars` — from Codex's own
8558
+ // environment, never its command line — and is blanked for the shell.
8559
+ const cawdevEnv = { CAWDEV_URL: config.url, CAWDEV_TOKEN: runToken, CAWDEV_PROJECT: run.projectSlug };
8560
+ const childEnv = { ...process.env, ...cawdevEnv };
8561
+
8562
+ // By name, so a later server replaces an earlier one whole — the ordering
8563
+ // argument of the Claude Code path: the repository's own first, then R76's
8564
+ // registry, then cawdev's LAST, so nothing can shadow it and take its token.
8565
+ const servers = new Map();
8566
+ if (mayWrite) {
8567
+ for (const [name, server] of Object.entries(await projectMcpServers(cwd))) {
8568
+ if (!server?.command) {
8569
+ transcript.push({ kind: 'SYSTEM',
8570
+ body: `The project's MCP server "${name}" is not a command, and Codex is handed commands only — skipped.` });
8571
+ continue;
8572
+ }
8573
+ servers.set(name, { name, command: server.command, args: server.args, env: server.env });
8574
+ }
8575
+ const { attached, notes } = await resolveSkills(config, run, cwd, baseCommit, projectServers);
8576
+ for (const line of notes) {
8577
+ log(` ${line}`);
8578
+ transcript.push({ kind: 'SYSTEM', body: line });
8579
+ }
8580
+ for (const { skill, local } of attached) {
8581
+ Object.assign(childEnv, local.env ?? {});
8582
+ servers.set(skill.serverName, {
8583
+ name: skill.serverName, command: skill.command, args: skill.args,
8584
+ envVars: Object.keys(local.env ?? {}),
8585
+ });
8586
+ }
8587
+ }
8588
+ servers.set('cawdev', {
8589
+ name: 'cawdev',
8590
+ command: process.execPath,
8591
+ args: [serverPath],
8592
+ envVars: Object.keys(cawdevEnv),
8593
+ enabledTools: codexCawdevTools(run, stage, mayWrite),
8594
+ });
8595
+
8596
+ const cannotDelegate = stageExperts.length
8597
+ ? ["This project has experts, and Codex has no way to be handed them: nothing on this CLI "
8598
+ + "loads cawdev's expert agents. Do the work yourself; they are reached from Claude Code "
8599
+ + 'and Antigravity runs.']
8600
+ : undefined;
8601
+ if (cannotDelegate) {
8602
+ transcript.push({ kind: 'SYSTEM', body: cannotDelegate[0] });
8603
+ }
8604
+
8605
+ const resuming = Boolean(resume?.agentSessionId);
8606
+ const promptText = resuming
8607
+ ? (resume.prompt ?? '')
8608
+ : (promptFor(run)
8609
+ + harnessPrompt({
8610
+ instincts, briefing, experts: [], skills: [], plan, lifecycle,
8611
+ repoConfig: await readRepoConfig(cwd),
8612
+ })
8613
+ + inlinedSkillsFor(skills)
8614
+ + expertsBlockFor([], cannotDelegate)
8615
+ + (stage ? stagePrompt(stage, carried) : ''));
8616
+
8617
+ const model = stage?.model ?? run.model;
8618
+ const args = codexArgs({
8619
+ model,
8620
+ effort: run.effort,
8621
+ sandbox: mayWrite ? 'bypass' : 'read-only',
8622
+ resumeId: resuming ? resume.agentSessionId : null,
8623
+ servers: [...servers.values()],
8624
+ hideFromShell: Object.keys(cawdevEnv),
8625
+ });
8626
+ log(` spawning: ${codexCmd} ${args.join(' ')} <prompt on stdin, ${promptText.length} chars>`);
8627
+
8628
+ return new Promise((resolvePromise) => {
8629
+ // R76 / R305: what the run had already used before this process — see the
8630
+ // same pair on the Claude Code path.
8631
+ const usageBase = usage?.run ?? {
8632
+ tokensIn: Number(run.tokensIn) || 0,
8633
+ tokensOut: Number(run.tokensOut) || 0,
8634
+ };
8635
+ let childTotals = null;
8636
+ let ended = false;
8637
+ let failed = null;
8638
+ let lastError = null;
8639
+ let lastSaid = null;
8640
+ let err = '';
8641
+ let buffer = '';
8642
+ const child = spawn(codexCmd, args, {
8643
+ cwd,
8644
+ // Its own process group — see the same choice on the Claude Code path.
8645
+ detached: true,
8646
+ stdio: ['pipe', 'pipe', 'pipe'],
8647
+ env: childEnv,
8648
+ });
8649
+ child.cawdevProjectSlug = run.projectSlug;
8650
+ child.cawdevAgent = 'codex';
8651
+ child.cawdevModel = model ?? null;
8652
+ // `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
8653
+ running.set(run.id, child);
8654
+ child.stdin.on('error', () => {
8655
+ // A CLI that died before reading its prompt says why on stderr.
8656
+ });
8657
+ child.stdin.end(promptText);
8658
+
8659
+ child.stdout.on('data', (chunk) => {
8660
+ buffer += chunk;
8661
+ let newline;
8662
+ while ((newline = buffer.indexOf('\n')) !== -1) {
8663
+ const line = buffer.slice(0, newline);
8664
+ buffer = buffer.slice(newline + 1);
8665
+ if (!line.trim()) continue;
8666
+ let event;
8667
+ try {
8668
+ event = JSON.parse(line);
8669
+ } catch {
8670
+ transcript.push({ kind: 'SYSTEM', body: line.slice(0, 4000) });
8671
+ continue;
8672
+ }
8673
+ const read = translateCodexEvent(event);
8674
+ if (read.sessionId) {
8675
+ reportSessionId(config, run, read.sessionId);
8676
+ transcript.push({
8677
+ kind: 'SYSTEM',
8678
+ body: `session ${short(read.sessionId)} started${model ? ` on ${model}` : ''}`
8679
+ + (mayWrite ? '' : ' (read-only sandbox)'),
8680
+ });
8681
+ }
8682
+ for (const recorded of read.lines) {
8683
+ // R110's transcript half, as on the Claude Code path: a credential
8684
+ // that came back from a command is replaced before it is stored.
8685
+ if (shield?.blockSecrets !== false && recorded.kind === 'TOOL_RESULT') {
8686
+ const found = findSecret(recorded.body);
8687
+ if (found) {
8688
+ recorded.body = `[cawdev removed ${found.name} from this result] ${found.redacted}`;
8689
+ api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/blocks`, {
8690
+ method: 'POST',
8691
+ body: { kind: 'SECRET', detail: `${found.name}: ${found.redacted}` },
8692
+ }).catch(() => {});
8693
+ }
8694
+ }
8695
+ if (recorded.kind === 'ASSISTANT') {
8696
+ lastSaid = recorded.body;
8697
+ }
8698
+ transcript.push(recorded);
8699
+ }
8700
+ if (read.error) {
8701
+ lastError = read.error;
8702
+ }
8703
+ if (read.failed) {
8704
+ failed = read.failed;
8705
+ }
8706
+ if (read.ended) {
8707
+ ended = true;
8708
+ childTotals = read.usage;
8709
+ reportUsage(config, run, {
8710
+ tokensIn: usageBase.tokensIn + childTotals.input,
8711
+ tokensOut: usageBase.tokensOut + childTotals.output,
8712
+ });
8713
+ if (stage && usage?.stage) {
8714
+ reportStageUsage(config, run, stage.stage, {
8715
+ tokensIn: usage.stage.tokensIn + childTotals.input,
8716
+ tokensOut: usage.stage.tokensOut + childTotals.output,
8717
+ });
8718
+ }
8719
+ }
8720
+ }
8721
+ });
8722
+ child.stderr.on('data', (chunk) => {
8723
+ const text = String(chunk).trim();
8724
+ if (text) {
8725
+ log(` agent stderr: ${text.slice(0, 400)}`);
8726
+ }
8727
+ err += chunk;
8728
+ });
8729
+
8730
+ child.on('error', async (failure) => {
8731
+ running.delete(run.id);
8732
+ const summary = `Could not spawn Codex: ${failure.message}`;
8733
+ await transcript.flush();
8734
+ await finish(config, run, 'FAILED', summary);
8735
+ resolvePromise(stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined);
8736
+ });
8737
+
8738
+ child.on('close', async (code) => {
8739
+ running.delete(run.id);
8740
+ await transcript.flush();
8741
+ const text = failed ?? lastSaid ?? lastError ?? (err.trim() || 'Codex produced no output.');
8742
+ // The agent normally ends the run itself with report(done|blocked); this
8743
+ // only catches a turn that produced nothing report could act on.
8744
+ const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
8745
+ .catch(() => null);
8746
+ if (current?.live && !stage) {
8747
+ await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text);
8748
+ }
8749
+ // R112: not between stages — see the same line on the Claude Code path.
8750
+ if (!stage) {
8751
+ await settleActions(config, run, cwd);
8752
+ }
8753
+ resolvePromise(stage
8754
+ ? { code, signal: null, text, turnEnded: ended && !failed, usage: childTotals }
8755
+ : undefined);
8756
+ });
8757
+ });
8758
+ }
8759
+
7805
8760
  /**
7806
8761
  * Tells the platform what the CLI calls this session — R69.
7807
8762
  *
@@ -7925,6 +8880,14 @@ function capabilities(config) {
7925
8880
  // offered only to a machine that says this, and a machine that does not
7926
8881
  // is told why on the queue rather than handed a base it would ignore.
7927
8882
  baseBranches: true,
8883
+ // R270. This daemon knows an offer can carry `waitForClean`, and honours it
8884
+ // in `pickWorkspace` — taking the preferred checkout only when clean, and
8885
+ // refusing to fall back to a dirty one at all. The same shape as
8886
+ // `baseBranches`: an older daemon that ignored the flag would take the
8887
+ // dirty checkout and fail a run the person explicitly queued to avoid
8888
+ // that, so the platform tells it apart rather than letting one behave
8889
+ // like the other.
8890
+ waitForClean: true,
7928
8891
  });
7929
8892
  }
7930
8893
 
@@ -7944,6 +8907,11 @@ function capabilities(config) {
7944
8907
  async function probePermissionPrompt(config) {
7945
8908
  let allGood = true;
7946
8909
  for (const agentCommand of config.agentCommands) {
8910
+ // A Claude Code flag. Codex never calls a permission prompt — see
8911
+ // `spawnCodex` — and offering it this one would start its terminal UI.
8912
+ if (agentOfCommand(agentCommand) === 'codex') {
8913
+ continue;
8914
+ }
7947
8915
  const said = await new Promise((done) => {
7948
8916
  let text = '';
7949
8917
  let child;
@@ -8003,6 +8971,9 @@ async function main() {
8003
8971
  quiet = attaching;
8004
8972
 
8005
8973
  const config = await readConfig();
8974
+ // The checkouts made on demand by an earlier run of this daemon, found
8975
+ // again by their name — nothing about them is in the config.
8976
+ await syncAutoWorkspaces(config.projects);
8006
8977
  const myVersion = await cliVersion();
8007
8978
 
8008
8979
  // R283. Checked before registering — a daemon too old to be safe should not
@@ -8168,6 +9139,7 @@ async function main() {
8168
9139
  const resolved = {
8169
9140
  acceptsRulesFromConsole: file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
8170
9141
  grantable: file.grantable ?? DEFAULTS.grantable,
9142
+ autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
8171
9143
  agentCommands: file.agentCommands
8172
9144
  ?? (file.agentCommand ? [file.agentCommand] : null)
8173
9145
  ?? DEFAULTS.agentCommands,
@@ -8176,7 +9148,11 @@ async function main() {
8176
9148
  agentCommands: config.agentCommands,
8177
9149
  grantable: config.grantable,
8178
9150
  acceptsRulesFromConsole: config.acceptsRulesFromConsole,
9151
+ autoWorkspaces: config.autoWorkspaces,
8179
9152
  }, resolved);
9153
+ // A checkout made on demand may have been dropped — the attached
9154
+ // terminal's `/drop` deletes the directory and sends this signal.
9155
+ await syncAutoWorkspaces(config.projects, heldWorkspaces());
8180
9156
  Object.assign(config, resolved);
8181
9157
  // R288: the hello describes the current state, not the booted one — an
8182
9158
  // attach opened after a toggle would otherwise offer to turn on what is
@@ -8413,8 +9389,13 @@ async function main() {
8413
9389
  // to lose three hours of work by leaning on the keyboard.
8414
9390
  if (attaching) {
8415
9391
  const { attach } = await import('./attach.mjs');
8416
- void attach(process.argv.slice(2), {
9392
+ const argv = process.argv.slice(2);
9393
+ const typed = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : null;
9394
+ void attach(argv, {
8417
9395
  socketPath: control?.path,
9396
+ // Where the person signs in: `--url`, else what this daemon is called —
9397
+ // `cawdev.mjs`'s `signInUrl` order. `urlFrom` alone never sees the config.
9398
+ url: (typed ?? config.url).replace(/\/+$/, ''),
8418
9399
  // Its own daemon, so quitting means stopping: raised as a signal rather
8419
9400
  // than an exit, so the run goes through the same goodbye and the same
8420
9401
  // termination of children as a Ctrl-C would.
@@ -8447,6 +9428,22 @@ async function main() {
8447
9428
  `/api/runners/${config.runnerId}/queue?wait=${config.pollSeconds}`,
8448
9429
  );
8449
9430
 
9431
+ // Checked again HERE, not only at the top. The poll blocks for up to
9432
+ // pollSeconds, and a Ctrl-C during it flips `stopping` and begins the
9433
+ // goodbye — which is bounded but not instant. A poll that returns inside
9434
+ // that window would otherwise walk on to claim a run and spawn a
9435
+ // detached agent that outlives the exiting daemon, orphaned with a live
9436
+ // claim. The gap after this point is startRun's own: it reads `stopping`
9437
+ // nowhere, and a Ctrl-C between its claim and its spawn is still open.
9438
+ //
9439
+ // Also the loop's exit an analyser can see. The header's condition IS one
9440
+ // at runtime — `stopping` is a closed-over `let` read every iteration —
9441
+ // but its only writer is the signal handler, and i247 (S2189) is what a
9442
+ // loop with no visible way out looks like from outside.
9443
+ if (stopping) {
9444
+ break;
9445
+ }
9446
+
8450
9447
  // ONE gate — R109. There were two; `maxSessions` bounded this machine
8451
9448
  // and is gone, for the reason written where it used to be applied.
8452
9449
  //
@@ -8469,6 +9466,10 @@ async function main() {
8469
9466
  // was when the wait began — and a run that started during the wait was
8470
9467
  // invisible. Two agents went into one checkout that way.
8471
9468
  const held = heldWorkspaces();
9469
+ // One `git status` per checkout per pass, not per offer — i258.
9470
+ // Several offers of one project ask `looksClean` on a shared checkout
9471
+ // only once.
9472
+ const probed = new Map();
8472
9473
 
8473
9474
  let claimable = 0;
8474
9475
  let skipped = 0;
@@ -8527,47 +9528,24 @@ async function main() {
8527
9528
  // the local ref already is. Taken when free, passed over when not: a
8528
9529
  // busy preferred checkout is not worth waiting for, because origin has
8529
9530
  // everything and prepareWorkingCopy brings a stale ref up to it.
9531
+ //
9532
+ // Below pinned and preferred, which checkout a free-for-the-taking
9533
+ // run gets now also weighs whether its tree is clean — R71/i258. The
9534
+ // `await` here can only make `held` more conservative while it waits:
9535
+ // claims are added by this loop alone, sequentially, and a run
9536
+ // finishing only frees a path. The one optimistic case — a heartbeat
9537
+ // adding to `told.heldWorkspaces` mid-loop — is the same window that
9538
+ // already exists between this poll and `startRun`'s own claim.
8530
9539
  let workspace = null;
8531
9540
  if (writes) {
8532
- const pinned = offered.workspace ?? null;
8533
- if (pinned) {
8534
- if (!config.projects[slug].workspaces.includes(pinned)) {
8535
- // Not one of ours. The platform pins a machine as well as a
8536
- // path, so this means the config changed under a live branch —
8537
- // which is a thing to say out loud rather than quietly ignore.
8538
- noteQueued(
8539
- offered.run,
8540
- `its branch is in ${pinned}, which this machine does not serve`,
8541
- );
8542
- skipped += 1;
8543
- continue;
8544
- }
8545
- if (held.has(pinned)) {
8546
- noteQueued(
8547
- offered.run,
8548
- `waiting for ${pinned}, which is busy — the branch has work only that checkout holds`,
8549
- );
8550
- skipped += 1;
8551
- continue;
8552
- }
8553
- workspace = pinned;
8554
- } else {
8555
- const preferred = offered.preferredWorkspace ?? null;
8556
- if (preferred && config.projects[slug].workspaces.includes(preferred) && !held.has(preferred)) {
8557
- workspace = preferred;
8558
- } else {
8559
- workspace = config.projects[slug].workspaces.find((path) => !held.has(path)) ?? null;
8560
- }
8561
- if (!workspace) {
8562
- const total = config.projects[slug].workspaces.length;
8563
- noteQueued(
8564
- offered.run,
8565
- `no free workspace in ${slug} (${total} here, all busy)`,
8566
- );
8567
- skipped += 1;
8568
- continue;
8569
- }
9541
+ const picked = await pickWorkspace(slug, config.projects[slug], offered, held, probed,
9542
+ config.autoWorkspaces);
9543
+ if (picked.why) {
9544
+ noteQueued(offered.run, picked.why);
9545
+ skipped += 1;
9546
+ continue;
8570
9547
  }
9548
+ workspace = picked.workspace;
8571
9549
  }
8572
9550
  if (workspace) {
8573
9551
  held.add(workspace);