cawdev-cli 0.9.0 → 1.0.0-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
@@ -15,7 +15,7 @@ import { spawn } from 'node:child_process';
15
15
  import {
16
16
  access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile,
17
17
  } from 'node:fs/promises';
18
- import { tmpdir } from 'node:os';
18
+ import { homedir, tmpdir } from 'node:os';
19
19
 
20
20
  import { join, resolve } from 'node:path';
21
21
  import { describeTurn, totalsOf } from '../lib/usage.mjs';
@@ -27,7 +27,9 @@ import { codeMapOf } from '../lib/code-map.mjs';
27
27
  import { usageLimitOf } from '../lib/usage-limit.mjs';
28
28
  import { TranscriptBatch } from '../lib/transcript-batch.mjs';
29
29
  import { parseUsage } from '../lib/usage-report.mjs';
30
- import { qualified, writeRunPlugin } from '../lib/run-plugin.mjs';
30
+ import {
31
+ ANTIGRAVITY_EXPERTS_PLUGIN, qualified, writeAntigravityExpertsPlugin, writeRunPlugin,
32
+ } from '../lib/run-plugin.mjs';
31
33
  import { AI_CONFIG, harnessPrompt, readRepoConfig } from '../lib/harness-prompt.mjs';
32
34
  import { loadToken, storedUrls } from './token-store.mjs';
33
35
  import {
@@ -35,6 +37,7 @@ import {
35
37
  } from '../lib/stage-tools.mjs';
36
38
  import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
37
39
  import { findSecret } from '../lib/secrets.mjs';
40
+ import { cliVersion, isBelow } from '../lib/version.mjs';
38
41
 
39
42
  // --- configuration -----------------------------------------------------------
40
43
 
@@ -72,7 +75,7 @@ const DEFAULTS = {
72
75
  * cure. A default that is simply the thing this daemon exists to run costs
73
76
  * nothing and cannot be missing.
74
77
  */
75
- agentCommand: 'claude',
78
+ agentCommands: ['claude'],
76
79
  /**
77
80
  * Verified against Claude Code 2.1.247.
78
81
  *
@@ -337,15 +340,22 @@ const DEFAULTS = {
337
340
  usageSeconds: 600,
338
341
  };
339
342
 
340
- async function readConfig() {
343
+ /** The `--config` path this process was started with, or the default. */
344
+ function configPathFromArgv() {
341
345
  const index = process.argv.indexOf('--config');
342
- const path = index === -1 ? 'runner.config.json' : process.argv[index + 1];
346
+ return index === -1 ? 'runner.config.json' : process.argv[index + 1];
347
+ }
348
+
349
+ async function readConfig() {
350
+ const path = configPathFromArgv();
343
351
 
344
352
  let file = {};
353
+ let fromFile = false;
345
354
  try {
346
355
  file = JSON.parse(await readFile(path, 'utf8'));
356
+ fromFile = true;
347
357
  } catch (failure) {
348
- if (index !== -1) {
358
+ if (process.argv.includes('--config')) {
349
359
  throw new Error(`Could not read ${path}: ${failure.message}`);
350
360
  }
351
361
  // No config file is fine when everything comes from the environment.
@@ -369,7 +379,7 @@ async function readConfig() {
369
379
  */
370
380
  token: process.env.CAWDEV_TOKEN ?? file.token ?? (await loadToken(url)),
371
381
  name: process.env.CAWDEV_RUNNER_NAME ?? file.name ?? DEFAULTS.name,
372
- agentCommand: process.env.CAWDEV_AGENT_COMMAND ?? file.agentCommand ?? DEFAULTS.agentCommand,
382
+ agentCommands: file.agentCommands ?? (file.agentCommand ? [file.agentCommand] : null) ?? (process.env.CAWDEV_AGENT_COMMAND ? process.env.CAWDEV_AGENT_COMMAND.split(',').map(s => s.trim()) : null) ?? DEFAULTS.agentCommands,
373
383
  // Which projects this runner serves, and where their working copies are.
374
384
  projects: normaliseProjects(file.projects ?? {}),
375
385
  /**
@@ -391,6 +401,14 @@ async function readConfig() {
391
401
  idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
392
402
  usageSeconds: file.usageSeconds ?? DEFAULTS.usageSeconds,
393
403
  sessionExitSeconds: file.sessionExitSeconds ?? DEFAULTS.sessionExitSeconds,
404
+ /**
405
+ * The FILE this daemon booted from, absolute, or null when everything
406
+ * came from the environment — R288. Published on the control socket so
407
+ * the attached terminal's `c` key edits the config this process is
408
+ * actually running on and not one it guessed at. Absolute because the
409
+ * daemon's cwd is not the terminal's: `cawdev` starts it detached.
410
+ */
411
+ configPath: fromFile ? resolve(path) : null,
394
412
  };
395
413
 
396
414
  if (!config.token) {
@@ -783,50 +801,59 @@ async function readUsage(config) {
783
801
  if (!config.usageSeconds || !config.runnerId) {
784
802
  return;
785
803
  }
786
- const said = await new Promise((resolve) => {
787
- let out = '';
788
- const child = spawn(config.agentCommand, ['-p', '/usage', '--output-format', 'text'], {
789
- stdio: ['ignore', 'pipe', 'pipe'],
790
- });
791
- // Bounded, because this runs on a timer for the life of the daemon: a CLI
792
- // that hangs here must not accumulate a process per tick.
793
- const giveUp = setTimeout(() => {
794
- try {
795
- child.kill('SIGTERM');
796
- } catch { /* already gone */ }
797
- resolve('');
798
- }, 60_000);
799
- giveUp.unref?.();
800
- child.stdout.on('data', (chunk) => (out += chunk));
801
- child.stderr.on('data', (chunk) => (out += chunk));
802
- child.on('error', () => {
803
- clearTimeout(giveUp);
804
- resolve('');
805
- });
806
- child.on('close', () => {
807
- clearTimeout(giveUp);
808
- resolve(out);
804
+
805
+ const allWindows = [];
806
+
807
+ for (const agentCommand of config.agentCommands) {
808
+ const said = await new Promise((resolve) => {
809
+ let out = '';
810
+ const child = spawn(agentCommand, ['-p', '/usage', '--output-format', 'text'], {
811
+ stdio: ['ignore', 'pipe', 'pipe'],
812
+ });
813
+ // Bounded, because this runs on a timer for the life of the daemon: a CLI
814
+ // that hangs here must not accumulate a process per tick.
815
+ const giveUp = setTimeout(() => {
816
+ try {
817
+ child.kill('SIGTERM');
818
+ } catch { /* already gone */ }
819
+ resolve('');
820
+ }, 60_000);
821
+ giveUp.unref?.();
822
+ child.stdout.on('data', (chunk) => (out += chunk));
823
+ child.stderr.on('data', (chunk) => (out += chunk));
824
+ child.on('error', () => {
825
+ clearTimeout(giveUp);
826
+ resolve('');
827
+ });
828
+ child.on('close', () => {
829
+ clearTimeout(giveUp);
830
+ resolve(out);
831
+ });
809
832
  });
810
- });
811
833
 
812
- const windows = parseUsage(said);
813
- if (!windows.length) {
834
+ const windows = parseUsage(said);
835
+ for (const each of windows) {
836
+ allWindows.push({
837
+ provider: agentCommand.endsWith('agy') ? 'agy' : 'claude',
838
+ window: each.kind,
839
+ model: each.model,
840
+ percentUsed: each.percent,
841
+ // The counts stay null. The CLI gives a percentage and no totals, and
842
+ // inventing `used: 25, limit: 100` would put units on a page that the
843
+ // provider never stated — R20's rule, one level down.
844
+ used: null,
845
+ limit: null,
846
+ resetsAt: each.resetsAt ? each.resetsAt.toISOString() : null,
847
+ });
848
+ }
849
+ }
850
+
851
+ if (!allWindows.length) {
814
852
  return;
815
853
  }
816
854
  await api(config, `/api/runners/${config.runnerId}/limits`, {
817
855
  method: 'POST',
818
- body: windows.map((each) => ({
819
- provider: 'claude',
820
- window: each.kind,
821
- model: each.model,
822
- percentUsed: each.percent,
823
- // The counts stay null. The CLI gives a percentage and no totals, and
824
- // inventing `used: 25, limit: 100` would put units on a page that the
825
- // provider never stated — R20's rule, one level down.
826
- used: null,
827
- limit: null,
828
- resetsAt: each.resetsAt ? each.resetsAt.toISOString() : null,
829
- })),
856
+ body: allWindows,
830
857
  }).catch((failure) => log(` could not report usage: ${failure.message}`));
831
858
  }
832
859
 
@@ -5888,6 +5915,40 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
5888
5915
  // narrowed by what the laptop already allows, and this is the laptop being
5889
5916
  // told — by its own operator, through a page it opted in to — to allow more.
5890
5917
  const granted = await machineRules(config);
5918
+
5919
+ // R147. Which of the run's experts THIS process may reach — moved ahead of
5920
+ // the agy branch below (R279) so BOTH spawn paths narrow the same way: a
5921
+ // read-only stage is handed only the experts that can change nothing.
5922
+ // `readOnlyExpert` judges by the frontmatter the plugin is written from, so
5923
+ // the plugin loaded, the delegation tool allowed, and the list the session
5924
+ // is told about all agree. Everything else, and every process with no
5925
+ // lifecycle, gets them all.
5926
+ const readOnlyStage = Boolean(stage) && ['PLAN', 'VERIFY', 'MEMORY'].includes(stage.stage);
5927
+ const stageExperts = readOnlyStage ? expertAgents.filter(readOnlyExpert) : expertAgents;
5928
+ // R161. From `stageExperts` and NEVER from `expertAgents` — see the note
5929
+ // above; an expert a read-only stage cannot reach must not be required here.
5930
+ const cannotDelegate = readOnlyStage && expertAgents.length && !stageExperts.length
5931
+ ? [`The ${stage.stage} stage holds nothing that can change anything, and none of this `
5932
+ + "project's experts is read-only, so none can be reached from it. They are reached "
5933
+ + 'from IMPLEMENT and TEST.']
5934
+ : undefined;
5935
+
5936
+ // Which CLI answers this run — R276's follow-on made real. A staged run
5937
+ // carries the resolved choice on its stage (`stage.agent`, from
5938
+ // `WorkflowService.forRun`); anything else carries it on the run itself
5939
+ // now (`run.agent`, from `RunService.agentFor`) — both fall back to Claude
5940
+ // Code, exactly as every run did before either existed. Antigravity's
5941
+ // shape differs enough (no --mcp-config, no --allowedTools, no --resume —
5942
+ // checked against the real CLI by hand) that it is not this function's
5943
+ // Claude-specific machinery with a different binary name; it is
5944
+ // `spawnAntigravity`, entirely.
5945
+ if ((stage?.agent ?? run.agent) === 'agy') {
5946
+ const agyCmd = config.agentCommands.find((each) => each.endsWith('agy')) ?? 'agy';
5947
+ return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
5948
+ skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
5949
+ agyCmd, granted);
5950
+ }
5951
+
5891
5952
  const ceiling = [
5892
5953
  ...(config.grantable ?? []),
5893
5954
  ...(config.projects[run.projectSlug]?.grantable ?? []),
@@ -5992,13 +6053,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
5992
6053
  };
5993
6054
  await writeFile(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2));
5994
6055
 
5995
- // R147. Which of the run's experts THIS process may reach. A read-only stage
5996
- // is handed only the experts that can change nothing `readOnlyExpert`
5997
- // judges by the frontmatter the plugin below is written from — so the plugin
5998
- // it loads, the `Agent` it is allowed and the list it is told about all
5999
- // agree. Everything else, and every process with no lifecycle, gets them all.
6000
- const readOnlyStage = Boolean(stage) && ['PLAN', 'VERIFY', 'MEMORY'].includes(stage.stage);
6001
- const stageExperts = readOnlyStage ? expertAgents.filter(readOnlyExpert) : expertAgents;
6056
+ // R147/R279. `readOnlyStage` and `stageExperts` are computed once, ahead of
6057
+ // the agy branch above, so both spawn paths narrow the same way.
6002
6058
 
6003
6059
  // R161. What this project said had to be used, by the name a transcript line
6004
6060
  // carries.
@@ -6151,11 +6207,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6151
6207
  description: each.description ?? each.name ?? each.key,
6152
6208
  required: each.mode === 'REQUIRED',
6153
6209
  })),
6154
- cannotDelegate: readOnlyStage && expertAgents.length && !stageExperts.length
6155
- ? [`The ${stage.stage} stage holds nothing that can change anything, and none of this `
6156
- + "project's experts is read-only, so none can be reached from it. They are reached "
6157
- + 'from IMPLEMENT and TEST.']
6158
- : undefined,
6210
+ cannotDelegate,
6159
6211
  // R124. Null on everything but an implementation phase whose card has been
6160
6212
  // planned, which the PLATFORM decides — the runner does not work out which
6161
6213
  // runs deserve a plan, it carries the one it was handed.
@@ -6196,6 +6248,14 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6196
6248
  writesItsOwnCard: writesItsOwnCard(run) })]
6197
6249
  : null;
6198
6250
 
6251
+ // Reaching here at all means Claude Code answers this run — the branch at
6252
+ // the top of this function sent Antigravity to spawnAntigravity instead.
6253
+ // Matched against what this machine actually lists (`config.agentCommands`)
6254
+ // by the same suffix rule `readUsage` uses the other way around, so a
6255
+ // machine whose list is agy-first still gets Claude Code's own command.
6256
+ const agentCmd = config.agentCommands.find((each) => !each.endsWith('agy'))
6257
+ ?? config.agentCommands[0] ?? 'claude';
6258
+
6199
6259
  const args = [
6200
6260
  '--mcp-config',
6201
6261
  mcpConfigPath,
@@ -6210,10 +6270,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6210
6270
  skills)]
6211
6271
  : argsForProfile(agentArgs, run.profile, run, expertAgents, skills))),
6212
6272
  ];
6213
- log(` spawning: ${config.agentCommand} ${args.join(' ')} (prompt on stdin)`);
6273
+ log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
6214
6274
 
6215
6275
  return new Promise((resolvePromise) => {
6216
- const child = spawn(config.agentCommand, args, {
6276
+ const child = spawn(agentCmd, args, {
6217
6277
  cwd,
6218
6278
  // Its own process group, so cancelling can take down the whole tree
6219
6279
  // rather than leaving orphaned children behind.
@@ -6751,6 +6811,556 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6751
6811
  });
6752
6812
  }
6753
6813
 
6814
+ // --- Antigravity ('agy') ------------------------------------------------------
6815
+
6816
+ // Serializes agy's global registry mutations — MCP servers AND, since R279,
6817
+ // installed plugins. Claude Code's --mcp-config and --plugin-dir are each a
6818
+ // file or directory this daemon writes and hands to one process; agy has no
6819
+ // per-invocation equivalent for either, only two machine-wide registries
6820
+ // (`agy mcp add/remove` and `agy plugin install/uninstall`, both under
6821
+ // ~/.gemini) shared by every agy call on this machine. Both are
6822
+ // re-registered before each spawn — the MCP token is fresh per run, and a
6823
+ // different project may have turned different experts on — and ONE lock
6824
+ // serializes both, because they are the same kind of hazard: two concurrent
6825
+ // agy spawns racing on the same machine-wide state, v1's answer to a problem
6826
+ // Claude Code's own design does not have.
6827
+ let agyRegistryLock = Promise.resolve();
6828
+ function withAgyRegistryLock(work) {
6829
+ const next = agyRegistryLock.then(work, work);
6830
+ agyRegistryLock = next.then(() => {}, () => {});
6831
+ return next;
6832
+ }
6833
+
6834
+ /** One `agy` subcommand, run to completion. Not a session — just its exit and output. */
6835
+ function runAgyCli(agyCmd, args) {
6836
+ return new Promise((resolve) => {
6837
+ let out = '';
6838
+ const child = spawn(agyCmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
6839
+ child.stdout.on('data', (chunk) => (out += chunk));
6840
+ child.stderr.on('data', (chunk) => (out += chunk));
6841
+ child.on('error', () => resolve({ code: -1, out }));
6842
+ child.on('close', (code) => resolve({ code, out }));
6843
+ });
6844
+ }
6845
+
6846
+ /**
6847
+ * What Antigravity currently has registered, read rather than asked for:
6848
+ * `agy mcp list` has no machine-readable form, and the registry is one
6849
+ * small JSON file at a fixed, undocumented path. Read-only, and tolerant of
6850
+ * that path changing under a future CLI version — an empty list here costs
6851
+ * a stale entry left behind, never a wrong removal.
6852
+ */
6853
+ async function currentAntigravityMcpNames() {
6854
+ try {
6855
+ const raw = await readFile(join(homedir(), '.gemini', 'config', 'mcp_config.json'), 'utf8');
6856
+ return Object.keys(JSON.parse(raw)?.mcpServers ?? {});
6857
+ } catch {
6858
+ return [];
6859
+ }
6860
+ }
6861
+
6862
+ /**
6863
+ * Registers cawdev's own MCP server, this checkout's own (its `.mcp.json`),
6864
+ * and R76's registry entries this run was handed — R278, the same three
6865
+ * Claude Code's `--mcp-config` carries. R105's skills are NOT here: those
6866
+ * are not servers, see `inlinedSkillsFor`. Experts are NOT here either, but
6867
+ * for the opposite reason R278 first claimed — that card said agy "has
6868
+ * nothing shaped like `--plugin-dir`, only its own, unrelated subagent
6869
+ * system", and that was wrong, unverified when it was written. Checked by
6870
+ * hand for R279: `agy plugin install <directory>` reads the very same
6871
+ * `agents/*.md` shape, and `invoke_subagent` really does reach one — see
6872
+ * `registerAntigravityExperts`, which does for experts what this does for
6873
+ * servers, under the same lock.
6874
+ *
6875
+ * Every name but `cawdev` itself is prefixed `cawdev-server-` — on two
6876
+ * sides of the same reason: this must never touch an MCP server Dali
6877
+ * registered with agy himself for something unrelated, and a stale entry
6878
+ * an EARLIER run left behind (a different project, a different registry)
6879
+ * has to be findable by that same prefix so it can be removed before the
6880
+ * new set goes in. Confirmed by hand that this is safe to do at all: a
6881
+ * registered server inherits the daemon's own environment (`PATH`, `HOME`,
6882
+ * ...) same as any spawned child, so `--env` only has to carry what a
6883
+ * server adds on top of that — not `...process.env` the way the
6884
+ * file-based `--mcp-config` write does.
6885
+ */
6886
+ function registerAntigravityMcp(agyCmd, {
6887
+ url, token, projectSlug, cwd, serverPath, repoServers, attachedServers,
6888
+ }) {
6889
+ return withAgyRegistryLock(async () => {
6890
+ const wanted = new Map();
6891
+ wanted.set('cawdev', {
6892
+ command: process.execPath,
6893
+ args: [serverPath],
6894
+ env: {
6895
+ CAWDEV_URL: url, CAWDEV_TOKEN: token, CAWDEV_PROJECT: projectSlug, CAWDEV_WORKSPACE: cwd,
6896
+ },
6897
+ });
6898
+ // This checkout's own `.mcp.json`.
6899
+ for (const [name, def] of Object.entries(repoServers ?? {})) {
6900
+ wanted.set(`cawdev-server-${name}`, {
6901
+ command: def.command, args: def.args ?? [], env: def.env ?? {},
6902
+ });
6903
+ }
6904
+ // R76's registry, via `resolveSkills` — `skill` here is what that
6905
+ // function calls its own parameter, from a rename that came after it
6906
+ // was written; the shape is `RunMcpServerView`, a server, not a SKILL.md.
6907
+ for (const { skill, local } of attachedServers ?? []) {
6908
+ wanted.set(`cawdev-server-${skill.key}`, {
6909
+ command: skill.command, args: skill.args, env: local.env ?? {},
6910
+ });
6911
+ }
6912
+
6913
+ const stale = (await currentAntigravityMcpNames()).filter((name) => (
6914
+ name === 'cawdev' || name.startsWith('cawdev-server-')
6915
+ ) && !wanted.has(name));
6916
+ for (const name of stale) {
6917
+ await runAgyCli(agyCmd, ['mcp', 'remove', name]);
6918
+ }
6919
+
6920
+ let ok = true;
6921
+ for (const [name, def] of wanted) {
6922
+ // Removed first — agy mcp add UPDATES an existing name in place, but
6923
+ // a run's token changes every time and "updates" is exactly the kind
6924
+ // of word worth not trusting without having watched it happen.
6925
+ await runAgyCli(agyCmd, ['mcp', 'remove', name]);
6926
+ // Flags before <name> or agy silently absorbs them into the spawned
6927
+ // command's own argv instead of its env — confirmed by hand: the same
6928
+ // --env flags placed after the name landed in mcp_config.json's
6929
+ // `args`, never in an `env` object. `agy mcp add --help`'s own note
6930
+ // ("Flags must come before <name>") says this; it just doesn't reject
6931
+ // the wrong order, it mis-files it.
6932
+ const envArgs = Object.entries(def.env).flatMap(([key, value]) => ['--env', `${key}=${value}`]);
6933
+ const added = await runAgyCli(agyCmd,
6934
+ ['mcp', 'add', ...envArgs, name, def.command, ...def.args]);
6935
+ if (added.code !== 0) {
6936
+ ok = false;
6937
+ }
6938
+ }
6939
+ return ok;
6940
+ });
6941
+ }
6942
+
6943
+ /**
6944
+ * What Antigravity currently has installed, read the same way
6945
+ * `currentAntigravityMcpNames` reads its registrations: `agy plugin list`
6946
+ * has no machine-readable form either, and `import_manifest.json` is the
6947
+ * file it renders from (confirmed by hand: installing and listing a probe
6948
+ * plugin round-tripped through exactly this file). Tolerant of the same
6949
+ * failure for the same reason — an empty list here costs a stale plugin
6950
+ * left behind, never a wrong uninstall.
6951
+ */
6952
+ async function currentAntigravityPluginNames() {
6953
+ try {
6954
+ const raw = await readFile(join(homedir(), '.gemini', 'config', 'import_manifest.json'), 'utf8');
6955
+ const imports = JSON.parse(raw)?.imports;
6956
+ return Array.isArray(imports) ? imports.map((each) => each.name).filter(Boolean) : [];
6957
+ } catch {
6958
+ return [];
6959
+ }
6960
+ }
6961
+
6962
+ /**
6963
+ * Installs this run's experts as an Antigravity plugin — R279, doing for
6964
+ * `agents/*.md` what `registerAntigravityMcp` does for servers, and under
6965
+ * the SAME lock: `agy plugin install` is machine-wide state exactly like
6966
+ * `agy mcp add` is, one project's experts at a time on this machine.
6967
+ *
6968
+ * <p>Always removes whatever `ANTIGRAVITY_EXPERTS_PLUGIN` currently holds
6969
+ * first — never trusting `agy plugin install` to update a name in place,
6970
+ * the same caution `registerAntigravityMcp` already takes with `agy mcp
6971
+ * add`, unverified here and not worth finding out the hard way — then
6972
+ * installs fresh only when this run was handed experts at all. Takes
6973
+ * `stageExperts`, never raw `expertAgents`: R147's read-only-stage
6974
+ * narrowing has to hold for Antigravity exactly as it does for Claude Code,
6975
+ * and the caller is where that narrowing already happened.
6976
+ *
6977
+ * <p>Failing to install is NOT failed the way a failed MCP registration is
6978
+ * — an expert is optional delegation, not plumbing a report/ask_user call
6979
+ * depends on, so the caller logs it and carries on without one rather than
6980
+ * failing the run.
6981
+ */
6982
+ function registerAntigravityExperts(agyCmd, { expertAgents }) {
6983
+ return withAgyRegistryLock(async () => {
6984
+ const stale = (await currentAntigravityPluginNames())
6985
+ .filter((name) => name === ANTIGRAVITY_EXPERTS_PLUGIN);
6986
+ for (const name of stale) {
6987
+ await runAgyCli(agyCmd, ['plugin', 'uninstall', name]);
6988
+ }
6989
+ if (!expertAgents.length) {
6990
+ return true;
6991
+ }
6992
+ const pluginDir = await mkdtemp(join(tmpdir(), 'cawdev-agy-experts-'));
6993
+ try {
6994
+ const root = await writeAntigravityExpertsPlugin(pluginDir, expertAgents);
6995
+ const installed = await runAgyCli(agyCmd, ['plugin', 'install', root]);
6996
+ return installed.code === 0;
6997
+ } finally {
6998
+ await rm(pluginDir, { recursive: true, force: true });
6999
+ }
7000
+ });
7001
+ }
7002
+
7003
+ /**
7004
+ * The prompt's half of R279 — telling the session which experts it has and
7005
+ * how to reach one, the way `harnessPrompt`'s own experts section does for
7006
+ * Claude Code. NOT folded into `harnessPrompt` itself: that function's
7007
+ * wording ("delegate with the `Agent` tool, `subagent_type` exactly as
7008
+ * written") names a Claude Code tool, and agy's is `invoke_subagent` — the
7009
+ * same split `inlinedSkillsFor` already makes for skills, for the same
7010
+ * reason.
7011
+ *
7012
+ * <p>`experts` here must already be `stageExperts` — narrowed for a
7013
+ * read-only stage — and, when nothing was installed for this run
7014
+ * (`registerAntigravityExperts` failed, or there was nothing to install),
7015
+ * an empty list: advertising an expert the plugin step could not actually
7016
+ * install would tell the session it can reach something it cannot.
7017
+ */
7018
+ function expertsBlockFor(experts, cannotDelegate) {
7019
+ const list = Array.isArray(experts) ? experts : [];
7020
+ if (!list.length) {
7021
+ return cannotDelegate?.length ? `\n\n## Experts\n\n${cannotDelegate.join(' ')}` : '';
7022
+ }
7023
+ const must = list.filter((each) => each.mode === 'REQUIRED');
7024
+ const may = list.filter((each) => each.mode !== 'REQUIRED');
7025
+ const entry = (each) => `- **${each.key}** — ${each.description ?? each.name ?? each.key}`;
7026
+ const section = (heading, rows) => rows.length
7027
+ ? `## ${heading}\n\n${rows.map(entry).join('\n')}`
7028
+ : '';
7029
+ return '\n\n' + [
7030
+ section('Experts you MUST use — delegate with `invoke_subagent`, naming it exactly as '
7031
+ + 'written below, before this stage is finished', must),
7032
+ section("Experts — delegate with `invoke_subagent` whenever one's description fits the "
7033
+ + 'step you are on', may),
7034
+ ].filter(Boolean).join('\n\n');
7035
+ }
7036
+
7037
+ /**
7038
+ * R105's skills, whole, for a CLI with no `Skill` tool to fetch one lazily.
7039
+ *
7040
+ * <p>Claude Code sees a name and a description in its harness prompt and
7041
+ * loads the `body` — the actual SKILL.md instructions — only if the model
7042
+ * invokes it, through a plugin directory this agent has no equivalent way
7043
+ * to be handed. There is nothing to invoke here, so the body is INLINED
7044
+ * up front instead: every skill this run was handed, whether or not the
7045
+ * model ends up needing it. Larger prompt, no lazy loading — the honest
7046
+ * trade for a CLI with no mechanism to defer it.
7047
+ */
7048
+ function inlinedSkillsFor(skills) {
7049
+ const list = Array.isArray(skills) ? skills : [];
7050
+ if (!list.length) {
7051
+ return '';
7052
+ }
7053
+ const must = list.filter((each) => each.mode === 'REQUIRED');
7054
+ const may = list.filter((each) => each.mode !== 'REQUIRED');
7055
+ const entry = (each) => `### ${each.name ?? each.key}\n\n${each.description ?? ''}\n\n${each.body ?? ''}`
7056
+ .trim();
7057
+ const section = (heading, rows) => rows.length
7058
+ ? `## ${heading}\n\n${rows.map(entry).join('\n\n')}`
7059
+ : '';
7060
+ return '\n\n' + [
7061
+ section('Skills you MUST use — this project requires each of these; use one before this '
7062
+ + 'stage is finished', must),
7063
+ section('Skills — use one when its description fits the work', may),
7064
+ ].filter(Boolean).join('\n\n');
7065
+ }
7066
+
7067
+ /**
7068
+ * Antigravity's shape — one-shot per turn, not a long-lived process.
7069
+ *
7070
+ * Checked against the real CLI by hand rather than guessed at: no
7071
+ * `--mcp-config` (`flags provided but not defined`), no `--allowedTools` (no
7072
+ * per-tool allow-list exists at all), no `--resume <id>` (`--conversation
7073
+ * <id>` instead). `--mode plan` denies MCP tool calls outright — including
7074
+ * calls to cawdev's own `report`/`ask_user` — so even a read-only run needs
7075
+ * `--dangerously-skip-permissions` just to talk to the platform. There is no
7076
+ * narrower option on this CLI today.
7077
+ *
7078
+ * So: this refuses to run at all unless the machine has granted "everything"
7079
+ * — R126, the same switch Claude Code's `bypassPermissions` already stands
7080
+ * behind — and every call that does run gets `--dangerously-skip-permissions`
7081
+ * unconditionally, coding or not. No per-project tool-rule narrowing, and no
7082
+ * live mid-run prompting (R22's "keep stdin open" assumes a process that
7083
+ * stays up between turns; agy's `-p` exits after one). A STAGE is already
7084
+ * one process per R112, so stages fit this shape without changing it — a
7085
+ * follow-up typed into a RUNNING session does not reach it yet, and that gap
7086
+ * is not silently papered over: `sendPrompt` still only writes to a live
7087
+ * child's stdin, which this never opens.
7088
+ *
7089
+ * R278: this checkout's own MCP servers and R76's registry ARE reached now,
7090
+ * the way `registerAntigravityMcp` describes, and R105's skills are
7091
+ * inlined whole (`inlinedSkillsFor`) rather than reached at all — there is
7092
+ * nothing on this CLI shaped like Claude Code's `Skill` tool to fetch one
7093
+ * lazily.
7094
+ *
7095
+ * R279: expert agents ARE reached too, now — R278's claim that agy "has
7096
+ * nothing shaped like `--plugin-dir`" was wrong, and unverified when it was
7097
+ * written. `agy plugin install <directory>` reads the same `agents/*.md`
7098
+ * shape Claude Code's plugin does; `registerAntigravityExperts` installs
7099
+ * this run's `stageExperts` under it, and `invoke_subagent` is the real
7100
+ * tool a session delegates through — confirmed end-to-end by hand, a probe
7101
+ * agent installed, invoked by name, and its answer returned correctly.
7102
+ */
7103
+ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
7104
+ skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
7105
+ agyCmd, granted) {
7106
+ if (!granted.everything) {
7107
+ const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
7108
+ + 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
7109
+ + 'on "allow everything" for this machine, or use Claude Code for this run.';
7110
+ log(` refusing to spawn Antigravity: ${summary}`);
7111
+ await finish(config, run, 'FAILED', summary);
7112
+ return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
7113
+ }
7114
+
7115
+ // Created here rather than down with the spawn — the notes below (what
7116
+ // attached, what a project asked for that this machine refused, what an
7117
+ // index build said) belong on the run the moment they are known, same as
7118
+ // Claude Code's path: a person watching should not have to wait for the
7119
+ // one-shot call to finish before finding out something never loaded.
7120
+ const transcript = new Transcript(config, run);
7121
+
7122
+ // Two DIFFERENT things cawdev calls capabilities, reached two different
7123
+ // ways even for Claude Code — R278 gives Antigravity both, each the way
7124
+ // its own shape allows:
7125
+ //
7126
+ // `projectServers` (R76's registry, `claimed.mcpServers` — the parameter
7127
+ // is misleadingly named after an old rename, not this checkout's own
7128
+ // `.mcp.json`) and `repoServers` (that actual file, read fresh) are both
7129
+ // real MCP SERVERS — a command line cawdev already knows how to run.
7130
+ // `resolveSkills` (despite its name — R76 called these "skills" before
7131
+ // R105 split the word) turns the registry half into the same shape.
7132
+ // Both go through `registerAntigravityMcp`, the same way cawdev's own
7133
+ // server does.
7134
+ //
7135
+ // `skills` (R105, `claimed.skills`) is NOT a server at all — it is a
7136
+ // SKILL.md's `body`, markdown Claude Code loads lazily through its own
7137
+ // `Skill` tool when the model invokes it by name, bundled into a plugin
7138
+ // directory this agent has no equivalent way to be handed
7139
+ // (`--plugin-dir`, same gap experts have). There is nothing to invoke
7140
+ // here to fetch it later, so it is not offered as something reachable —
7141
+ // it is INLINED below, in full, up front.
7142
+ const serverPath = config.mcpServerPath ?? new URL('../mcp/server.mjs', import.meta.url).pathname;
7143
+ const repoServers = await projectMcpServers(cwd);
7144
+ const { attached: attachedServers, notes: serverNotes } =
7145
+ await resolveSkills(config, run, cwd, baseCommit, projectServers);
7146
+ if (attachedServers.length) {
7147
+ log(` loading ${attachedServers.length} MCP server(s)`);
7148
+ }
7149
+ for (const line of serverNotes) {
7150
+ log(` ${line}`);
7151
+ transcript.push({ kind: 'SYSTEM', body: line });
7152
+ }
7153
+ const registered = await registerAntigravityMcp(agyCmd, {
7154
+ url: config.url, token: runToken, projectSlug: run.projectSlug, cwd, serverPath,
7155
+ repoServers, attachedServers,
7156
+ });
7157
+ if (!registered) {
7158
+ const summary = 'Could not register this session\'s MCP servers with Antigravity (`agy mcp '
7159
+ + 'add` failed) — without cawdev\'s own this session cannot report, ask a question, or '
7160
+ + 'read the roadmap.';
7161
+ log(` ${summary}`);
7162
+ await finish(config, run, 'FAILED', summary);
7163
+ return stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined;
7164
+ }
7165
+
7166
+ // R279. Unlike the MCP registration above, a failed expert install does
7167
+ // NOT fail the run — an expert is optional delegation, not the plumbing a
7168
+ // report/ask_user call depends on. `usableExperts` drops to empty so the
7169
+ // prompt below never advertises an expert that was not actually
7170
+ // installed; `stageExperts` itself already carries R147's read-only-stage
7171
+ // narrowing from the caller.
7172
+ if (stageExperts.length) {
7173
+ log(` loading ${stageExperts.length} expert(s)`);
7174
+ }
7175
+ const expertsRegistered = await registerAntigravityExperts(agyCmd, { expertAgents: stageExperts });
7176
+ if (!expertsRegistered && stageExperts.length) {
7177
+ const note = 'Could not install this project\'s experts into Antigravity (`agy plugin '
7178
+ + 'install` failed) — continuing without delegation.';
7179
+ log(` ${note}`);
7180
+ transcript.push({ kind: 'SYSTEM', body: note });
7181
+ }
7182
+ const usableExperts = expertsRegistered ? stageExperts : [];
7183
+
7184
+ // Same assembly as Claude Code's — promptFor, harnessPrompt, stagePrompt
7185
+ // are all agent-agnostic. Both experts and skills are kept OUT of
7186
+ // harnessPrompt's own `experts`/`skills` params and handled entirely
7187
+ // below instead — its wording ("the `Agent` tool", "the `Skill` tool")
7188
+ // names tools this CLI does not have. `expertsBlockFor` and
7189
+ // `inlinedSkillsFor` are agy's own wording for the same two ideas.
7190
+ const harness = harnessPrompt({
7191
+ instincts, briefing, experts: [], skills: [], plan, lifecycle,
7192
+ repoConfig: await readRepoConfig(cwd),
7193
+ });
7194
+ const skillsBlock = inlinedSkillsFor(skills);
7195
+ const expertsBlock = expertsBlockFor(usableExperts, cannotDelegate);
7196
+ const resuming = Boolean(resume?.agentSessionId);
7197
+ const promptText = resuming
7198
+ ? resume.prompt
7199
+ : (promptFor(run) + harness + skillsBlock + expertsBlock
7200
+ + (stage ? stagePrompt(stage, carried) : ''));
7201
+
7202
+ const args = ['-p', promptText, '--output-format', 'stream-json', '--dangerously-skip-permissions'];
7203
+ if (run.model) {
7204
+ args.push('--model', run.model);
7205
+ }
7206
+ if (resuming) {
7207
+ args.push('--conversation', resume.agentSessionId);
7208
+ }
7209
+
7210
+ log(` spawning: ${agyCmd} -p <prompt omitted, ${promptText.length} chars> --output-format `
7211
+ + `stream-json --dangerously-skip-permissions${resuming ? ` --conversation ${resume.agentSessionId}` : ''}`);
7212
+
7213
+ // The same live view Claude Code gets — none of this existed before, and a
7214
+ // run that writes nothing here is a run the console and the CLI's attach
7215
+ // both draw as a black box until the one-shot call finally returns.
7216
+ // Confirmed by hand against the real CLI: `--output-format stream-json`
7217
+ // gives `init` (once, carries the conversation id), `step_update` (an
7218
+ // `agent_response` step's `text_delta` is an INCREMENTAL chunk to append,
7219
+ // not the text so far; a `tool` step is ACTIVE with the call and DONE with
7220
+ // `tool_info.output`), and one final `result` — the same shape
7221
+ // `--output-format json` returns whole, just arriving a piece at a time.
7222
+
7223
+ return new Promise((resolvePromise) => {
7224
+ let result = null;
7225
+ let err = '';
7226
+ let buffer = '';
7227
+ const child = spawn(agyCmd, args, {
7228
+ cwd,
7229
+ // Its own process group — see the same choice on the Claude Code path.
7230
+ detached: true,
7231
+ stdio: ['ignore', 'pipe', 'pipe'],
7232
+ });
7233
+ child.cawdevProjectSlug = run.projectSlug;
7234
+ // The existing cancellation sweep (`reapCancelled`) reads this map by
7235
+ // run id and SIGTERMs whatever is in it — true for any child, so a
7236
+ // cancelled Antigravity run stops exactly the way a cancelled Claude
7237
+ // Code one does, with no code of its own.
7238
+ running.set(run.id, child);
7239
+
7240
+ // stream-json arrives in chunks that split mid-line, same as Claude
7241
+ // Code's — buffer until a full line rather than assuming one chunk is
7242
+ // one event.
7243
+ child.stdout.on('data', (chunk) => {
7244
+ buffer += chunk;
7245
+ let newline;
7246
+ while ((newline = buffer.indexOf('\n')) !== -1) {
7247
+ const line = buffer.slice(0, newline);
7248
+ buffer = buffer.slice(newline + 1);
7249
+ if (!line.trim()) continue;
7250
+ let event;
7251
+ try {
7252
+ event = JSON.parse(line);
7253
+ } catch {
7254
+ // A stray non-protocol line on stdout — shown rather than dropped,
7255
+ // the same call `linesOf` makes for Claude Code's unparseable ones.
7256
+ transcript.push({ kind: 'SYSTEM', body: line.slice(0, 4000) });
7257
+ continue;
7258
+ }
7259
+ if (event.event === 'init') {
7260
+ if (event.conversation_id) {
7261
+ reportSessionId(config, run, event.conversation_id);
7262
+ }
7263
+ transcript.push({
7264
+ kind: 'SYSTEM',
7265
+ body: `session ${short(event.conversation_id)} started`
7266
+ + (run.model ? ` on ${run.model}` : ''),
7267
+ });
7268
+ } else if (event.event === 'step_update') {
7269
+ const step = event.step_update ?? {};
7270
+ if (step.step_type === 'agent_response' && step.text_delta) {
7271
+ transcript.push({ kind: 'ASSISTANT', body: step.text_delta });
7272
+ } else if (step.step_type === 'tool' && step.state === 'ACTIVE') {
7273
+ transcript.push({
7274
+ kind: 'TOOL',
7275
+ body: `${step.tool_name}(${JSON.stringify(step.tool_info?.parameters ?? {})})`,
7276
+ });
7277
+ } else if (step.step_type === 'tool' && step.state === 'DONE') {
7278
+ transcript.push({
7279
+ kind: 'TOOL_RESULT',
7280
+ body: String(step.tool_info?.output ?? '(no output)').slice(0, 4000),
7281
+ });
7282
+ } else if (step.step_type === 'subagent' && step.state === 'ACTIVE') {
7283
+ // R279. Delegation is its OWN step_type, `subagent` — confirmed
7284
+ // by hand it is not `tool`, which is why this needed its own
7285
+ // branch rather than falling into the one above by accident.
7286
+ // No `tool_info` here, only `subagent_info.subagents[]`; the
7287
+ // sub-conversation's own transcript is a local file this
7288
+ // process never reads, so the parent's own narration (an
7289
+ // ordinary `agent_response`) is what carries the answer back to
7290
+ // this transcript, same as it already did in the run that
7291
+ // proved this end to end.
7292
+ const who = (step.subagent_info?.subagents ?? [])
7293
+ .map((each) => each.type_name).filter(Boolean).join(', ') || 'a subagent';
7294
+ const prompt = step.subagent_info?.subagents?.[0]?.initial_prompt ?? '';
7295
+ transcript.push({ kind: 'TOOL', body: `invoke_subagent(${who}): ${prompt}` });
7296
+ } else if (step.step_type === 'subagent' && step.state === 'DONE') {
7297
+ const who = (step.subagent_info?.subagents ?? [])
7298
+ .map((each) => each.type_name).filter(Boolean).join(', ') || 'a subagent';
7299
+ transcript.push({ kind: 'TOOL_RESULT', body: `${who} finished` });
7300
+ }
7301
+ // user_input steps carry no text of their own — the prompt is
7302
+ // already shown as "What it was asked", from run.openingPrompt.
7303
+ } else if (event.event === 'result') {
7304
+ result = event.result;
7305
+ if (result?.usage) {
7306
+ reportUsage(config, run, {
7307
+ tokensIn: Number(result.usage.input_tokens) || 0,
7308
+ tokensOut: Number(result.usage.output_tokens) || 0,
7309
+ });
7310
+ }
7311
+ }
7312
+ }
7313
+ });
7314
+ child.stderr.on('data', (chunk) => {
7315
+ const text = String(chunk).trim();
7316
+ if (text) {
7317
+ log(` agent stderr: ${text.slice(0, 400)}`);
7318
+ }
7319
+ err += chunk;
7320
+ });
7321
+
7322
+ child.on('error', async (failure) => {
7323
+ running.delete(run.id);
7324
+ const summary = `Could not spawn Antigravity: ${failure.message}`;
7325
+ await transcript.flush();
7326
+ await finish(config, run, 'FAILED', summary);
7327
+ resolvePromise(
7328
+ stage ? { code: -1, signal: null, text: summary, turnEnded: false } : undefined);
7329
+ });
7330
+
7331
+ child.on('close', async (code) => {
7332
+ running.delete(run.id);
7333
+ await transcript.flush();
7334
+ const text = result?.response
7335
+ || (result?.denied_actions?.length
7336
+ ? `Antigravity denied an action it needed and stopped: `
7337
+ + result.denied_actions.map((each) => each.display_name ?? each.action).join(', ')
7338
+ : null)
7339
+ || err.trim() || 'Antigravity produced no output.';
7340
+ // Whether the CLI ever gave back a parseable turn — this agent has no
7341
+ // separate "the turn ended" signal the way Claude Code's stream does;
7342
+ // getting the `result` event at all IS the turn ending, one way or
7343
+ // the other.
7344
+ const turnEnded = Boolean(result);
7345
+
7346
+ // The agent normally ends the run itself with report(done|blocked),
7347
+ // through the same cawdev MCP server Claude Code calls — this only
7348
+ // catches a call that produced nothing report could act on.
7349
+ const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
7350
+ .catch(() => null);
7351
+ if (current?.live && !stage) {
7352
+ await finish(config, run, code === 0 && result?.status === 'SUCCESS' ? 'FINISHED' : 'FAILED',
7353
+ text);
7354
+ }
7355
+ // R112: not between stages — see the same line on the Claude Code path.
7356
+ if (!stage) {
7357
+ await settleActions(config, run, cwd);
7358
+ }
7359
+ resolvePromise(stage ? { code, signal: null, text, turnEnded } : undefined);
7360
+ });
7361
+ });
7362
+ }
7363
+
6754
7364
  /**
6755
7365
  * Tells the platform what the CLI calls this session — R69.
6756
7366
  *
@@ -6853,7 +7463,7 @@ function capabilities(config) {
6853
7463
  // can read this can say which side refused without waiting for a run to
6854
7464
  // say it in a transcript.
6855
7465
  skills: config.skills ?? [],
6856
- agent: config.agentCommand,
7466
+ agents: config.agentCommands,
6857
7467
  // R260. This daemon knows a claim can carry `baseBranch` — a sprint's
6858
7468
  // branch to cut from, open the pull request against and merge into. It
6859
7469
  // is the WHOLE of how the platform tells an old daemon from a new one:
@@ -6878,56 +7488,59 @@ function capabilities(config) {
6878
7488
  * message from a CLI it does not own is worse than one that says so and runs.
6879
7489
  */
6880
7490
  async function probePermissionPrompt(config) {
6881
- const said = await new Promise((done) => {
6882
- let text = '';
6883
- let child;
6884
- try {
6885
- child = spawn(config.agentCommand, ['-p', '--permission-prompt-tool', 'mcp__cawdev__approve'], {
6886
- stdio: ['pipe', 'pipe', 'pipe'],
6887
- });
6888
- } catch (failure) {
6889
- return done(`could not be run: ${failure.message}`);
6890
- }
6891
- const give_up = setTimeout(() => {
7491
+ let allGood = true;
7492
+ for (const agentCommand of config.agentCommands) {
7493
+ const said = await new Promise((done) => {
7494
+ let text = '';
7495
+ let child;
6892
7496
  try {
6893
- child.kill('SIGKILL');
6894
- } catch {
6895
- // Already gone.
7497
+ child = spawn(agentCommand, ['-p', '--permission-prompt-tool', 'mcp__cawdev__approve'], {
7498
+ stdio: ['pipe', 'pipe', 'pipe'],
7499
+ });
7500
+ } catch (failure) {
7501
+ return done(`could not be run: ${failure.message}`);
6896
7502
  }
6897
- done(text);
6898
- }, 10_000);
6899
- give_up.unref?.();
6900
-
6901
- child.stdout.on('data', (chunk) => (text += chunk));
6902
- child.stderr.on('data', (chunk) => (text += chunk));
6903
- child.on('error', (failure) => {
6904
- clearTimeout(give_up);
6905
- done(`could not be run: ${failure.message}`);
6906
- });
6907
- // `close`, not `exit`: this reads the CLI's own help to decide whether a
6908
- // flag exists, and a truncated read would answer "no" for a flag that is
6909
- // there. See git() above.
6910
- child.on('close', () => {
6911
- clearTimeout(give_up);
6912
- done(text);
7503
+ const give_up = setTimeout(() => {
7504
+ try {
7505
+ child.kill('SIGKILL');
7506
+ } catch {
7507
+ // Already gone.
7508
+ }
7509
+ done(text);
7510
+ }, 10_000);
7511
+ give_up.unref?.();
7512
+
7513
+ child.stdout.on('data', (chunk) => (text += chunk));
7514
+ child.stderr.on('data', (chunk) => (text += chunk));
7515
+ child.on('error', (failure) => {
7516
+ clearTimeout(give_up);
7517
+ done(`could not be run: ${failure.message}`);
7518
+ });
7519
+ // `close`, not `exit`: this reads the CLI's own help to decide whether a
7520
+ // flag exists, and a truncated read would answer "no" for a flag that is
7521
+ // there. See git() above.
7522
+ child.on('close', () => {
7523
+ clearTimeout(give_up);
7524
+ done(text);
7525
+ });
7526
+ // Nothing to say: the missing prompt is what we want it to complain about.
7527
+ child.stdin.end();
6913
7528
  });
6914
- // Nothing to say: the missing prompt is what we want it to complain about.
6915
- child.stdin.end();
6916
- });
6917
7529
 
6918
- if (/unknown option.*permission-prompt-tool/i.test(said)) {
6919
- log(
6920
- `WARNING: ${config.agentCommand} does not accept --permission-prompt-tool. Sessions that ` +
6921
- `need a command nobody allowed in advance will be denied outright rather than asking ` +
6922
- `you. See R51 and tools/runner/README.md.`,
6923
- );
6924
- return false;
6925
- }
6926
- if (said.startsWith('could not be run:')) {
6927
- log(`WARNING: ${config.agentCommand} ${said}`);
6928
- return false;
7530
+ if (/unknown option.*permission-prompt-tool/i.test(said)) {
7531
+ log(
7532
+ `WARNING: ${agentCommand} does not accept --permission-prompt-tool. Sessions that ` +
7533
+ `need a command nobody allowed in advance will be denied outright rather than asking ` +
7534
+ `you. See R51 and tools/runner/README.md.`,
7535
+ );
7536
+ allGood = false;
7537
+ }
7538
+ if (said.startsWith('could not be run:')) {
7539
+ log(`WARNING: ${agentCommand} ${said}`);
7540
+ allGood = false;
7541
+ }
6929
7542
  }
6930
- return true;
7543
+ return allGood;
6931
7544
  }
6932
7545
 
6933
7546
  async function main() {
@@ -6936,6 +7549,31 @@ async function main() {
6936
7549
  quiet = attaching;
6937
7550
 
6938
7551
  const config = await readConfig();
7552
+ const myVersion = await cliVersion();
7553
+
7554
+ // R283. Checked before registering — a daemon too old to be safe should not
7555
+ // get as far as claiming a run. `/api/health` is unauthenticated, so this
7556
+ // works with the very token `readConfig` just resolved, before it has been
7557
+ // proven good for anything else. Anything BUT "the platform said refuse"
7558
+ // fails open: a health check that cannot be reached is not evidence the
7559
+ // version is wrong, and refusing to boot over a network hiccup would be R2's
7560
+ // failure mode pointed at the wrong target.
7561
+ try {
7562
+ const health = await api(config, '/api/health');
7563
+ if (isBelow(myVersion, health?.minimumCliVersion)) {
7564
+ throw new Error(
7565
+ `This CLI is ${myVersion}; ${config.url} requires at least `
7566
+ + `${health.minimumCliVersion}. Update it (npm i -g ./tools from an `
7567
+ + 'updated checkout, or however this machine installs cawdev) and run '
7568
+ + 'cawdev again.',
7569
+ );
7570
+ }
7571
+ } catch (failure) {
7572
+ if (failure.message.startsWith('This CLI is')) {
7573
+ throw failure;
7574
+ }
7575
+ log(`could not check the minimum CLI version (${failure.message}); continuing`);
7576
+ }
6939
7577
 
6940
7578
  const runner = await api(config, '/api/runners', {
6941
7579
  method: 'POST',
@@ -6948,6 +7586,8 @@ async function main() {
6948
7586
  // would not be a ceiling.
6949
7587
  acceptsConsoleRules: config.acceptsRulesFromConsole === true,
6950
7588
  grantable: JSON.stringify(declaredCeiling(config)),
7589
+ // R283. Own version, on every registration and heartbeat — see cliVersion below.
7590
+ cliVersion: myVersion,
6951
7591
  },
6952
7592
  });
6953
7593
  config.runnerId = runner.id;
@@ -6960,7 +7600,7 @@ async function main() {
6960
7600
  // Skipped when attaching, where the UI takes the screen a moment later and
6961
7601
  // a banner would only flash.
6962
7602
  if (!quiet) {
6963
- console.log(bannerLines(config, ink).join('\n'));
7603
+ console.log(bannerLines(config, ink, myVersion).join('\n'));
6964
7604
  }
6965
7605
 
6966
7606
  log(`registered as "${runner.name}" (${runner.id})`);
@@ -6990,36 +7630,90 @@ async function main() {
6990
7630
  // than buried under a session that then fails for a reason it explains.
6991
7631
  await probePermissionPrompt(config);
6992
7632
 
7633
+ // What this daemon SAYS it is, on every hello — R52's socket, R288's rule
7634
+ // that it describes the current state. One object, sent as-is by
7635
+ // `serveControl` and mutated by the SIGHUP handler below, so a terminal that
7636
+ // attaches after a toggle reads what is true now.
7637
+ const advertised = {
7638
+ id: config.runnerId,
7639
+ name: config.name,
7640
+ url: config.url,
7641
+ // R126. Whether this machine takes rules from the console — so the
7642
+ // terminal only offers `M` when pressing it would do something. A key
7643
+ // that takes an answer the platform then refuses reads as cawdev being
7644
+ // broken, which is R58's rule about `a` applied to this one.
7645
+ acceptsConsoleRules: config.acceptsRulesFromConsole === true,
7646
+ projects: Object.keys(config.projects),
7647
+ // How many checkouts each has — R62. The per-project half of R47's
7648
+ // gate, which the bar shows as `cawdev 1/2`. Added rather than
7649
+ // replacing `projects`: an older attach ignores it and still works,
7650
+ // and a newer one against an older daemon simply shows a count with
7651
+ // nothing to compare it against.
7652
+ workspaces: Object.fromEntries(
7653
+ Object.entries(config.projects).map(([slug, p]) => [slug, p.workspaces.length]),
7654
+ ),
7655
+ // R288: which agents this machine spawns, and the file it booted from,
7656
+ // so the attached terminal's config screen can show the state of each
7657
+ // toggle and write to the right file. `configPath` is null for a daemon
7658
+ // run from the environment alone, and the screen says so rather than
7659
+ // inventing a file.
7660
+ agents: [...config.agentCommands],
7661
+ configPath: config.configPath,
7662
+ // R81. `cawdev` starts a daemon for you when it finds none, and
7663
+ // quitting the UI leaves it running — it is driving sessions. A
7664
+ // background process you did not know you started is the cost of that
7665
+ // choice, so the goodbye has to name it precisely enough to stop, and
7666
+ // this is the only place the number is known.
7667
+ pid: process.pid,
7668
+ };
7669
+
7670
+ // R283. `cawdev config agent enable/disable` and `cawdev config rules
7671
+ // on/off` write the file and, if a daemon is running on it, send this — NOT
7672
+ // a control-socket command. `control.mjs` is deliberately read-only ("no
7673
+ // command can" change anything, so a person acting through it never gets
7674
+ // more than their own HTTPS session already grants); SIGHUP is a unix
7675
+ // convention for "re-read your config", asked of a process this operator
7676
+ // already owns, so it does not open that door. Only the settings that are
7677
+ // genuinely safe to hot-swap: `agentCommands`, `grantable` and
7678
+ // `acceptsRulesFromConsole` are read fresh from `config` on every use
7679
+ // (spawn, or the next heartbeat), never captured once at boot — so mutating
7680
+ // them here is enough, with no further plumbing.
7681
+ process.on('SIGHUP', async () => {
7682
+ const path = configPathFromArgv();
7683
+ try {
7684
+ const file = JSON.parse(await readFile(path, 'utf8'));
7685
+ const before = {
7686
+ acceptsRulesFromConsole: config.acceptsRulesFromConsole,
7687
+ grantable: config.grantable,
7688
+ agentCommands: config.agentCommands,
7689
+ };
7690
+ config.acceptsRulesFromConsole =
7691
+ file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole;
7692
+ config.grantable = file.grantable ?? DEFAULTS.grantable;
7693
+ config.agentCommands = file.agentCommands
7694
+ ?? (file.agentCommand ? [file.agentCommand] : null)
7695
+ ?? DEFAULTS.agentCommands;
7696
+ // R288: the hello describes the current state, not the booted one — an
7697
+ // attach opened after a toggle would otherwise offer to turn on what is
7698
+ // already on. The same object `serveControl` sends, mutated in place.
7699
+ advertised.acceptsConsoleRules = config.acceptsRulesFromConsole === true;
7700
+ advertised.agents = [...config.agentCommands];
7701
+ const changed = Object.keys(before)
7702
+ .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(config[key]));
7703
+ log(changed.length
7704
+ ? `re-read ${path} on SIGHUP — changed: ${changed.join(', ')}`
7705
+ : `re-read ${path} on SIGHUP — nothing changed`);
7706
+ } catch (failure) {
7707
+ log(`SIGHUP re-read of ${path} failed: ${failure.message}`);
7708
+ }
7709
+ });
7710
+
6993
7711
  // R52. Nothing here is load-bearing for running an agent, so a daemon that
6994
7712
  // cannot open a socket says so and carries on: trading the ability to run
6995
7713
  // work for the ability to watch it would be the wrong way round.
6996
7714
  try {
6997
7715
  control = await serveControl({
6998
- runner: {
6999
- id: config.runnerId,
7000
- name: config.name,
7001
- url: config.url,
7002
- // R126. Whether this machine takes rules from the console — so the
7003
- // terminal only offers `M` when pressing it would do something. A key
7004
- // that takes an answer the platform then refuses reads as cawdev being
7005
- // broken, which is R58's rule about `a` applied to this one.
7006
- acceptsConsoleRules: config.acceptsRulesFromConsole === true,
7007
- projects: Object.keys(config.projects),
7008
- // How many checkouts each has — R62. The per-project half of R47's
7009
- // gate, which the bar shows as `cawdev 1/2`. Added rather than
7010
- // replacing `projects`: an older attach ignores it and still works,
7011
- // and a newer one against an older daemon simply shows a count with
7012
- // nothing to compare it against.
7013
- workspaces: Object.fromEntries(
7014
- Object.entries(config.projects).map(([slug, p]) => [slug, p.workspaces.length]),
7015
- ),
7016
- // R81. `cawdev` starts a daemon for you when it finds none, and
7017
- // quitting the UI leaves it running — it is driving sessions. A
7018
- // background process you did not know you started is the cost of that
7019
- // choice, so the goodbye has to name it precisely enough to stop, and
7020
- // this is the only place the number is known.
7021
- pid: process.pid,
7022
- },
7716
+ runner: advertised,
7023
7717
  snapshot: snapshotRuns,
7024
7718
  });
7025
7719
  log(`watchable at ${control.path} — attach with: node runner.mjs attach`);
@@ -7099,6 +7793,10 @@ async function main() {
7099
7793
  // without anybody having to revoke anything.
7100
7794
  acceptsConsoleRules: config.acceptsRulesFromConsole === true,
7101
7795
  grantable: JSON.stringify(declaredCeiling(config)),
7796
+ // R283. Sent on every beat, not only at registration, for
7797
+ // `acceptsConsoleRules`' own reason: a daemon reconfigured or
7798
+ // reinstalled reports what is true NOW.
7799
+ cliVersion: myVersion,
7102
7800
  },
7103
7801
  }).then((me) => {
7104
7802
  // R73/R80. The heartbeat's answer is what the platform decided about