cawdev-cli 1.0.5-beta → 1.0.6-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/cawdev.mjs CHANGED
@@ -29,8 +29,8 @@ import { attach, urlFrom, valueOf } from './attach.mjs';
29
29
  import { asker, askOne, mintForThisMachine, setUpThisMachine } from './bootstrap.mjs';
30
30
  import { liveSockets, probeSocket, socketPathFor } from './control.mjs';
31
31
  import {
32
- addProject, addWorkspace, readConfigFile, reloadIfRunning, setAcceptsRulesFromConsole, setQuality,
33
- setAgentEnabled,
32
+ addProject, addWorkspace, readConfigFile, reloadIfRunning, removeProject, setAcceptsRulesFromConsole,
33
+ setQuality, setAgentEnabled,
34
34
  } from './configure.mjs';
35
35
  import { restartNeededFor } from '../lib/runner-config.mjs';
36
36
  import { Select } from './select.mjs';
@@ -63,6 +63,8 @@ const USAGE = `
63
63
  cawdev config add-project serve one more project you can write to
64
64
  cawdev config add-workspace another checkout for a project you
65
65
  already serve — R47's concurrency
66
+ cawdev config remove-project <slug> stop serving a project (its checkouts
67
+ stay on disk)
66
68
  cawdev config agent enable|disable <claude|agy|codex>
67
69
  cawdev config rules on|off accept permission rules from the console
68
70
  cawdev config quality on|off <slug> scan that project's branches with
@@ -422,6 +424,7 @@ async function runVersion(url, ink) {
422
424
  /**
423
425
  * `cawdev config` — R283's granular half of the setup walk, on a machine
424
426
  * already configured. A direct subcommand (`add-project`, `add-workspace`,
427
+ * `remove-project <slug>`,
425
428
  * `agent enable|disable <command>`, `rules on|off`, `quality on|off <slug>`,
426
429
  * `show`) or, with none, a
427
430
  * menu — the same `Select` widget everything that offers a choice uses.
@@ -463,6 +466,7 @@ export async function pickAction(ask, say, pick = askOne) {
463
466
  rows: [
464
467
  { id: 'add-project', label: 'Add a project' },
465
468
  { id: 'add-workspace', label: 'Add a workspace to a project' },
469
+ { id: 'remove-project', label: 'Stop serving a project' },
466
470
  { id: 'agent-enable', label: `Enable an agent (${KNOWN_AGENTS.join(' / ')})` },
467
471
  { id: 'agent-disable', label: 'Disable an agent' },
468
472
  { id: 'rules-on', label: 'Accept permission rules from the console' },
@@ -477,6 +481,9 @@ export async function pickAction(ask, say, pick = askOne) {
477
481
  const command = (await ask.line(` Which agent — ${KNOWN_AGENTS.join(', ')}? `)).trim();
478
482
  return ['agent', id === 'agent-enable' ? 'enable' : 'disable', command];
479
483
  }
484
+ if (id === 'remove-project') {
485
+ return ['remove-project', (await ask.line(' Which project (slug)? ')).trim()];
486
+ }
480
487
  if (id === 'rules-on') return ['rules', 'on'];
481
488
  if (id === 'rules-off') return ['rules', 'off'];
482
489
  if (id === 'quality-on' || id === 'quality-off') {
@@ -507,6 +514,14 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
507
514
  await reload(await addWorkspace({ configPath, file, url, session, ask, say, ink }));
508
515
  return;
509
516
  }
517
+ if (verb === 'remove-project') {
518
+ const [slug] = rest;
519
+ if (!slug) {
520
+ throw new Error('Name a project: cawdev config remove-project <slug>');
521
+ }
522
+ await reload(await removeProject({ file, configPath, ask, say, ink, slug }));
523
+ return;
524
+ }
510
525
  if (verb === 'agent') {
511
526
  const [sub, command] = rest;
512
527
  if (!KNOWN_AGENTS.includes(command)) {
@@ -345,6 +345,40 @@ export async function setAgentEnabled({
345
345
  return next;
346
346
  }
347
347
 
348
+ /**
349
+ * Stop serving a project: its entry leaves the config, and nothing else moves.
350
+ * The checkouts stay on disk — they are somebody's working copies, and
351
+ * deleting a directory is not a config change — and the machine's token keeps
352
+ * its grant, which reaches nothing once the project is gone and reaches it
353
+ * again if it is added back. `projects` is read at boot, so the daemon serves
354
+ * it until a restart; the reload after this says so.
355
+ *
356
+ * The case it exists for: a project deleted in the console is still in this
357
+ * file, so the terminal kept offering it and the daemon kept polling for it.
358
+ */
359
+ export async function removeProject({
360
+ file, configPath, slug,
361
+ ask, say, ink = painter(3),
362
+ write = writeRunnerConfig,
363
+ } = {}) {
364
+ const projects = file.projects ?? {};
365
+ if (!slug || !(slug in projects)) {
366
+ throw new Error(`This machine does not serve ${slug || 'that project'}. `
367
+ + `It serves: ${Object.keys(projects).join(', ') || 'nothing yet'}.`);
368
+ }
369
+ const paths = pathsOf(projects[slug]);
370
+ const kept = paths.length ? ` Its checkout${paths.length === 1 ? '' : 's'} stay${paths.length === 1 ? 's' : ''} on disk: ${paths.join(', ')}.` : '';
371
+ if (!await confirm(ask, `Stop serving ${slug} on this machine?${kept}`)) {
372
+ throw new Error('Nothing changed.');
373
+ }
374
+ const { [slug]: _removed, ...rest } = projects;
375
+ const next = { ...file, projects: rest };
376
+ await write(configPath, next);
377
+ say(` ${ink.success('✓')} ${ink.muted(`This machine no longer serves ${slug}.`)}`
378
+ + (paths.length ? ` ${ink.muted(`Delete ${paths.length === 1 ? 'its checkout' : 'its checkouts'} yourself if you no longer want ${paths.length === 1 ? 'it' : 'them'}.`)}` : ''));
379
+ return next;
380
+ }
381
+
348
382
  /** Turn `acceptsRulesFromConsole` on or off — R126. */
349
383
  export async function setAcceptsRulesFromConsole({
350
384
  file, configPath, enabled,
package/runner/input.mjs CHANGED
@@ -136,25 +136,38 @@ export class History {
136
136
  this.draft = '';
137
137
  }
138
138
 
139
- /** Older. Returns null when there is nothing older, so the line is left alone. */
140
- back(current = '') {
139
+ /**
140
+ * Older. Returns null when there is nothing older, so the line is left alone.
141
+ *
142
+ * @param keep which entries this line walks — a command line walks the
143
+ * commands and skips the prompts sent to sessions, which it cannot send
144
+ */
145
+ back(current = '', keep = () => true) {
141
146
  if (this.at === this.entries.length) {
142
147
  this.draft = current;
143
148
  }
144
- if (this.at === 0) {
149
+ let at = this.at;
150
+ do {
151
+ at -= 1;
152
+ } while (at >= 0 && !keep(this.entries[at]));
153
+ if (at < 0) {
145
154
  return null;
146
155
  }
147
- this.at -= 1;
148
- return this.entries[this.at];
156
+ this.at = at;
157
+ return this.entries[at];
149
158
  }
150
159
 
151
160
  /** Newer, ending at the draft you were writing. */
152
- forward() {
161
+ forward(keep = () => true) {
153
162
  if (this.at >= this.entries.length) {
154
163
  return null;
155
164
  }
156
- this.at += 1;
157
- return this.at === this.entries.length ? this.draft : this.entries[this.at];
165
+ let at = this.at;
166
+ do {
167
+ at += 1;
168
+ } while (at < this.entries.length && !keep(this.entries[at]));
169
+ this.at = at;
170
+ return at === this.entries.length ? this.draft : this.entries[at];
158
171
  }
159
172
 
160
173
  reset() {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What this terminal remembers about how you like to work — beside history.mjs
3
+ * and in its shape: one file under ~/.cawdev, keyed by the platform's URL, 0600.
4
+ *
5
+ * One preference so far: the agent `/new` asks for when a command names none
6
+ * (`/agent codex`). Kept here and not on the platform, because it is a choice
7
+ * about THIS terminal — the project's default and your account's live in the
8
+ * console, and this sits in front of them without changing either.
9
+ *
10
+ * @author Claude Opus 5.5 (Claude Code)
11
+ * @author Moez Ben MBarka
12
+ */
13
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ export function prefsFile() {
18
+ return process.env.CAWDEV_PREFS_FILE ?? join(homedir(), '.cawdev', 'terminal.json');
19
+ }
20
+
21
+ function key(url) {
22
+ return String(url).replace(/\/+$/, '');
23
+ }
24
+
25
+ async function readAll() {
26
+ try {
27
+ const parsed = JSON.parse(await readFile(prefsFile(), 'utf8'));
28
+ return parsed && typeof parsed === 'object' ? parsed : {};
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ /** This platform's preferences, `{}` when there are none or the file is unreadable. */
35
+ export async function loadPrefs(url) {
36
+ const mine = (await readAll())[key(url)];
37
+ return mine && typeof mine === 'object' ? mine : {};
38
+ }
39
+
40
+ /** Merges `changes` into this platform's preferences; a null value removes the key. */
41
+ export async function savePrefs(url, changes) {
42
+ const all = await readAll();
43
+ const mine = { ...(all[key(url)] ?? {}) };
44
+ for (const [name, value] of Object.entries(changes)) {
45
+ if (value === null || value === undefined) delete mine[name];
46
+ else mine[name] = value;
47
+ }
48
+ all[key(url)] = mine;
49
+ await mkdir(dirname(prefsFile()), { recursive: true, mode: 0o700 });
50
+ await writeFile(prefsFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
51
+ await chmod(prefsFile(), 0o600).catch(() => undefined);
52
+ }
package/runner/runner.mjs CHANGED
@@ -40,12 +40,13 @@ import {
40
40
  } from '../lib/stage-tools.mjs';
41
41
  import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
42
42
  import { findSecret } from '../lib/secrets.mjs';
43
+ import { BackgroundShells } from '../lib/background-shells.mjs';
43
44
  import { cliVersion, isBelow } from '../lib/version.mjs';
44
45
  import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
45
46
  import {
46
47
  createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
47
48
  } from './auto-workspaces.mjs';
48
- import { agentOfCommand } from '../lib/agents.mjs';
49
+ import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
49
50
  import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
50
51
 
51
52
  // --- configuration -----------------------------------------------------------
@@ -5182,21 +5183,60 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
5182
5183
  * re-derived the plan from a précis would be planning again, which is the one
5183
5184
  * thing the gate exists to stop it doing after somebody approved the first one.
5184
5185
  */
5185
- function stagePrompt(stage, carried) {
5186
+ /** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
5187
+ const CAWDEV_TOOL_TIMEOUT_SEC = 660;
5188
+
5189
+ /**
5190
+ * A decision that is the person's is ASKED, and the stage goes on with the
5191
+ * answer. R19 and R24 (Codex) each met one — a plan the code had moved away
5192
+ * from, a plan whose review left the scope open — and ended the stage saying
5193
+ * so, the run FINISHED with nothing built and the question in a summary
5194
+ * nobody was asked. Claude asked; the wording let Codex stop instead.
5195
+ */
5196
+ const ASK_DONT_STOP = 'If a decision is genuinely the person\'s — a scope the card leaves open, '
5197
+ + 'a plan that no longer matches the code, a trade-off with no right answer — ASK it with '
5198
+ + '`ask_user` (a round of questions if there are several), wait for the answer, and carry on '
5199
+ + 'with it. Never end this stage on an open question: a question left in your report is one '
5200
+ + 'nobody was asked.';
5201
+
5202
+ /**
5203
+ * A stage is ONE turn, and it ends the moment the turn does — so a command left
5204
+ * running in the background is a result lost (background-shells.mjs).
5205
+ */
5206
+ const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every command in the '
5207
+ + 'foreground and wait for it to finish — a build or a test suite left running in the '
5208
+ + 'background is lost, and the stage is run again.';
5209
+
5210
+ /**
5211
+ * @param hasPlan whether the card has a plan of record — the same test
5212
+ * harnessPrompt uses to draw "The plan for this card" (`plan?.body`), so the
5213
+ * instruction and the block it points at cannot disagree
5214
+ */
5215
+ function stagePrompt(stage, carried, hasPlan = true) {
5186
5216
  const asked = {
5187
5217
  PLAN: 'Work out what to do and WRITE THE PLAN. Do not change anything — you have no '
5188
5218
  + 'tool that can, so do not spend turns discovering that. Read what you need, then say, '
5189
5219
  + 'concretely: which files, what change in each, and how you will know it worked. '
5190
5220
  + 'What you report becomes the card\'s plan, and a DIFFERENT session — another '
5191
5221
  + 'process, with none of your context — will be handed it and told to carry it out. '
5192
- + 'Write it for them.',
5222
+ + 'Write it for them — with every decision in it already made. ' + ASK_DONT_STOP,
5193
5223
  VERIFY: 'Check the plan against what is actually there. Does every file it names exist? '
5194
5224
  + 'Does every interface it assumes still look like that? You cannot change anything — '
5195
5225
  + 'say what is wrong with the plan, or say plainly that it holds.',
5196
- IMPLEMENT: 'Do the work in the plan — it is under "The plan for this card" above, '
5197
- + 'written by the plan phase and agreed. If it turns out to be wrong, say so and stop '
5198
- + 'rather than improvising a different change: somebody approved that plan, and a '
5199
- + 'different one has not been approved.',
5226
+ // With a plan, the plan is the task. Without one — `/new dev R21` on a
5227
+ // card nobody planned — the CARD is: the old wording pointed at a plan
5228
+ // section that was not there, and a careful session read "stop rather than
5229
+ // improvise" as "stop", ending R21 with nothing built and nothing to push.
5230
+ IMPLEMENT: (hasPlan
5231
+ ? 'Do the work in the plan — it is under "The plan for this card" above, '
5232
+ + 'written by the plan phase and agreed. Do not quietly improvise a different change: '
5233
+ + 'somebody approved that plan. If it turns out to be wrong or incomplete, ask what to '
5234
+ + 'do instead of stopping — and stop only if the answer is to stop. '
5235
+ : 'This card has no plan, and it does not need one: it was started straight into '
5236
+ + 'development, so the CARD\'s text is the task. Work out what it asks — read the code '
5237
+ + 'it touches first — build it, and commit it on this branch. Never stop because a plan '
5238
+ + 'is missing. ')
5239
+ + ASK_DONT_STOP + ' ' + WAIT_FOR_COMMANDS,
5200
5240
  // R129. Two honest ways to do this stage, and the project chose one. The
5201
5241
  // TESTBOOK wording says "you have no tool that could" for the PLAN
5202
5242
  // prompt's reason: a session that has to discover a refusal spends turns
@@ -5215,7 +5255,8 @@ function stagePrompt(stage, carried) {
5215
5255
  + 'failing test is a result, not a failure of this stage. The branch\'s SonarQube gate '
5216
5256
  + 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
5217
5257
  + 'gate with new vulnerabilities is a finding to report with the output, not a failure '
5218
- + 'to hide.',
5258
+ + 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
5259
+ + 'after this one writes code, and work left uncommitted is work nothing can push.',
5219
5260
  MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
5220
5261
  + 'tried and rejected and why, and anything that turned out to matter and would not be '
5221
5262
  + 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
@@ -6556,8 +6597,23 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
6556
6597
  return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
6557
6598
  };
6558
6599
 
6600
+ const storedRow = (name) => (Array.isArray(stored)
6601
+ ? stored.find((each) => each?.stage === name) : null);
6602
+
6559
6603
  for (let at = 0; at < lifecycle.length; at++) {
6560
6604
  const stage = lifecycle[at];
6605
+ // Where a carried-on run resumes, and what its DONE stages said, is the
6606
+ // platform's (R263, `WorkflowService.resumeFrom`): the claim's lifecycle
6607
+ // starts at the first stage not DONE. What only the daemon knows is WHY
6608
+ // the stage about to run stopped last time — so that is handed to it.
6609
+ const before = storedRow(stage.stage);
6610
+ if (before?.state === 'FAILED' && before.outcome) {
6611
+ // The attempt that stopped, handed to the one about to run.
6612
+ carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
6613
+ + 'This stage is being run again to finish that. Run the commands it left running '
6614
+ + 'again IN THE FOREGROUND — not with run_in_background — wait for each one to end '
6615
+ + 'and read its output, then finish the stage properly.';
6616
+ }
6561
6617
  // R305. What THIS stage's processes have used, across its attempts. Outside
6562
6618
  // the attempt loop on purpose: `run_stage` is one row per stage and keeps
6563
6619
  // the larger reading, so a second attempt after a refused gate must report
@@ -6668,6 +6724,21 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
6668
6724
  return;
6669
6725
  }
6670
6726
 
6727
+ // The turn ended, but with commands it started in the background still
6728
+ // running, or finished and never read. Not done: the result is what the
6729
+ // stage was for. FAILED keeps the checkout and the session (R80), so
6730
+ // /continue picks the run up HERE — the stages behind it stay pending.
6731
+ if (last?.unfinishedShells?.length) {
6732
+ const listed = last.unfinishedShells.map((command) => `\`${command.slice(0, 120)}\``).join(', ');
6733
+ const why = `The ${stage.stage} stage ended with ${last.unfinishedShells.length} command(s) `
6734
+ + `still running in the background, or finished and never read: ${listed}. `
6735
+ + 'Nothing they proved was recorded — /continue runs this stage again and waits for them.';
6736
+ log(` ${why}`);
6737
+ await reportStage(config, run, stage.stage, 'report', { state: 'FAILED', outcome: why });
6738
+ await finish(config, run, 'FAILED', `${why}\n\n${last.text ?? ''}`.trim());
6739
+ return;
6740
+ }
6741
+
6671
6742
  await reportStage(config, run, stage.stage, 'report', {
6672
6743
  state: 'DONE',
6673
6744
  // The PLAN's text IS the plan — the artefact R109 exists to store, and
@@ -7300,7 +7371,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7300
7371
  // sending it again is a repeat wearing a resume's clothes — R69's rule about
7301
7372
  // the opening prompt, applied to the thing that now travels with it.
7302
7373
  writeUserMessage(child, resume?.prompt
7303
- ?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
7374
+ ?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : '')));
7304
7375
 
7305
7376
  let lastText = '';
7306
7377
  // R305. This child's own last reading, handed back to the walk so the next
@@ -7326,6 +7397,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7326
7397
  // wait for it, which is exactly the shape that loses it when `endSession`
7327
7398
  // closes stdin below.
7328
7399
  const backgrounded = new Set();
7400
+ // Shell commands this child started in the background and never saw end
7401
+ // — the walk will not call a stage done with any of them left (see
7402
+ // background-shells.mjs).
7403
+ const shells = new BackgroundShells();
7329
7404
  // The last of what the CLI said on stderr, for the exit decision below.
7330
7405
  // The usage-limit notice is written there, and a decision made on stdout
7331
7406
  // alone would call every window a crash.
@@ -7487,6 +7562,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7487
7562
  buffer = buffer.slice(newline + 1);
7488
7563
  if (!line) continue;
7489
7564
  const event = safeJson(line);
7565
+ if (event) shells.observe(event);
7490
7566
 
7491
7567
  // i257. Read off the RAW event, before `linesOf` flattens it to a
7492
7568
  // string: `describeCall` (`../lib/tool-line.mjs`) never carries
@@ -7893,7 +7969,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7893
7969
  // gate on it, and carry it into the next stage. A run with no lifecycle
7894
7970
  // resolves with nothing, exactly as it always did.
7895
7971
  resolvePromise(stage
7896
- ? { code, signal, text: lastText, turnEnded, usage: childTotals }
7972
+ ? { code, signal, text: lastText, turnEnded, usage: childTotals,
7973
+ unfinishedShells: shells.unfinished() }
7897
7974
  : undefined);
7898
7975
  });
7899
7976
  });
@@ -8188,14 +8265,36 @@ function inlinedSkillsFor(skills) {
8188
8265
  * tool a session delegates through — confirmed end-to-end by hand, a probe
8189
8266
  * agent installed, invoked by name, and its answer returned correctly.
8190
8267
  */
8268
+ /** This machine, as a refusal names it: what it is called, where its config and console are. */
8269
+ function thisMachine(config) {
8270
+ return {
8271
+ name: config.name,
8272
+ acceptsConsoleRules: config.acceptsRulesFromConsole === true,
8273
+ configPath: config.configPath ?? null,
8274
+ consoleUrl: config.url,
8275
+ };
8276
+ }
8277
+
8278
+ /**
8279
+ * A refusal said on the run's own transcript, where the terminal and the run
8280
+ * page show it — the run's summary alone left a window reading "failed" and
8281
+ * nothing else, with the reason one page away.
8282
+ */
8283
+ async function sayOnTheRun(config, run, text) {
8284
+ await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/output`, {
8285
+ method: 'POST',
8286
+ body: { lines: text.split('\n').filter((body) => body.trim()).map((body) => ({ kind: 'ERROR', body })) },
8287
+ }).catch((failure) => log(` could not say it on the run: ${failure.message}`));
8288
+ }
8289
+
8191
8290
  async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
8192
8291
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
8193
8292
  agyCmd, granted, usage = null) {
8194
8293
  if (!granted.everything) {
8195
- const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
8196
- + 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
8197
- + 'on "allow everything" for this machine, or use Claude Code for this run.';
8198
- log(` refusing to spawn Antigravity: ${summary}`);
8294
+ // Antigravity needs it even to read: no per-tool allow-list exists on it.
8295
+ const summary = needsEverything('agy', thisMachine(config), run, { readsWithout: false });
8296
+ log(` refusing to spawn Antigravity: ${summary.split('\n')[0]}`);
8297
+ await sayOnTheRun(config, run, summary);
8199
8298
  await finish(config, run, 'FAILED', summary);
8200
8299
  return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
8201
8300
  }
@@ -8285,7 +8384,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8285
8384
  const promptText = resuming
8286
8385
  ? resume.prompt
8287
8386
  : (promptFor(run) + harness + skillsBlock + expertsBlock
8288
- + (stage ? stagePrompt(stage, carried) : ''));
8387
+ + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
8289
8388
 
8290
8389
  const args = ['-p', promptText, '--output-format', 'stream-json', '--dangerously-skip-permissions'];
8291
8390
  if (run.model) {
@@ -8334,6 +8433,12 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8334
8433
  // Code one does, with no code of its own.
8335
8434
  running.set(run.id, child);
8336
8435
 
8436
+ // What the console can see of the checkout, and the commits as they land —
8437
+ // the same watcher as the Claude Code path. Without it the platform never
8438
+ // hears of this run's commits, so the finish rules (R40) see a run that
8439
+ // wrote nothing and queue no push and no pull request.
8440
+ const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
8441
+
8337
8442
  // stream-json arrives in chunks that split mid-line, same as Claude
8338
8443
  // Code's — buffer until a full line rather than assuming one chunk is
8339
8444
  // one event.
@@ -8430,6 +8535,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8430
8535
  });
8431
8536
 
8432
8537
  child.on('error', async (failure) => {
8538
+ workingCopy.stop();
8433
8539
  running.delete(run.id);
8434
8540
  const summary = `Could not spawn Antigravity: ${failure.message}`;
8435
8541
  await transcript.flush();
@@ -8439,6 +8545,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8439
8545
  });
8440
8546
 
8441
8547
  child.on('close', async (code) => {
8548
+ workingCopy.stop();
8442
8549
  running.delete(run.id);
8443
8550
  await transcript.flush();
8444
8551
  const text = result?.response
@@ -8466,6 +8573,10 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
8466
8573
  if (!stage) {
8467
8574
  await settleActions(config, run, cwd);
8468
8575
  }
8576
+ // The last reading, as on the Claude Code path: after the agent has
8577
+ // stopped and after anything the rules did.
8578
+ await reportCommits(config, run, cwd, baseCommit);
8579
+
8469
8580
  resolvePromise(stage
8470
8581
  ? { code, signal: null, text, turnEnded, usage: childTotals }
8471
8582
  : undefined);
@@ -8535,17 +8646,18 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8535
8646
  skills, stageExperts, readOnlyStage, instincts, briefing, plan, lifecycle, stage, carried,
8536
8647
  codexCmd, granted, shield, usage = null) {
8537
8648
  const failedBeforeSpawn = async (summary) => {
8538
- log(` ${summary}`);
8649
+ log(` ${summary.split('\n')[0]}`);
8650
+ await sayOnTheRun(config, run, summary);
8539
8651
  await finish(config, run, 'FAILED', summary);
8540
8652
  return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
8541
8653
  };
8542
8654
 
8543
8655
  const mayWrite = writesCodeProfile(run) && !readOnlyStage;
8544
8656
  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.)');
8657
+ // It must commit and push, which its sandbox refuses, and there is no
8658
+ // allow-list for its shell — so everything, or nothing. Read-only runs
8659
+ // and stages need nothing.
8660
+ return failedBeforeSpawn(needsEverything('codex', thisMachine(config), run));
8549
8661
  }
8550
8662
  if (mayWrite) {
8551
8663
  log(' !! this machine allows EVERYTHING: this Codex session runs without a sandbox');
@@ -8591,6 +8703,12 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8591
8703
  args: [serverPath],
8592
8704
  envVars: Object.keys(cawdevEnv),
8593
8705
  enabledTools: codexCawdevTools(run, stage, mayWrite),
8706
+ // Codex cuts an MCP call off after 60 s unless told otherwise, and
8707
+ // `ask_user` waits up to ten minutes for a person: a question asked from
8708
+ // Codex died before anybody could answer it, so it did not ask — it
8709
+ // stopped. Longer than ask_user's own wait (mcp/server.mjs), so the
8710
+ // question is answered or handed back as pending, never cut off.
8711
+ toolTimeoutSec: CAWDEV_TOOL_TIMEOUT_SEC,
8594
8712
  });
8595
8713
 
8596
8714
  const cannotDelegate = stageExperts.length
@@ -8612,7 +8730,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8612
8730
  })
8613
8731
  + inlinedSkillsFor(skills)
8614
8732
  + expertsBlockFor([], cannotDelegate)
8615
- + (stage ? stagePrompt(stage, carried) : ''));
8733
+ + (stage ? stagePrompt(stage, carried, Boolean(plan?.body)) : ''));
8616
8734
 
8617
8735
  const model = stage?.model ?? run.model;
8618
8736
  const args = codexArgs({
@@ -8651,6 +8769,13 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8651
8769
  child.cawdevModel = model ?? null;
8652
8770
  // `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
8653
8771
  running.set(run.id, child);
8772
+
8773
+ // What the console can see of the checkout, and the commits as they land —
8774
+ // the same watcher as the Claude Code path. Without it the platform never
8775
+ // hears of this run's commits, so the finish rules (R40) see a run that
8776
+ // wrote nothing and queue no push and no pull request.
8777
+ const workingCopy = watchWorkingCopy(config, run, cwd, baseCommit);
8778
+
8654
8779
  child.stdin.on('error', () => {
8655
8780
  // A CLI that died before reading its prompt says why on stderr.
8656
8781
  });
@@ -8728,6 +8853,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8728
8853
  });
8729
8854
 
8730
8855
  child.on('error', async (failure) => {
8856
+ workingCopy.stop();
8731
8857
  running.delete(run.id);
8732
8858
  const summary = `Could not spawn Codex: ${failure.message}`;
8733
8859
  await transcript.flush();
@@ -8736,6 +8862,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8736
8862
  });
8737
8863
 
8738
8864
  child.on('close', async (code) => {
8865
+ workingCopy.stop();
8739
8866
  running.delete(run.id);
8740
8867
  await transcript.flush();
8741
8868
  const text = failed ?? lastSaid ?? lastError ?? (err.trim() || 'Codex produced no output.');
@@ -8750,6 +8877,10 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
8750
8877
  if (!stage) {
8751
8878
  await settleActions(config, run, cwd);
8752
8879
  }
8880
+ // The last reading, as on the Claude Code path: after the agent has
8881
+ // stopped and after anything the rules did.
8882
+ await reportCommits(config, run, cwd, baseCommit);
8883
+
8753
8884
  resolvePromise(stage
8754
8885
  ? { code, signal: null, text, turnEnded: ended && !failed, usage: childTotals }
8755
8886
  : undefined);