cawdev-cli 1.0.2-beta → 1.0.3-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.
@@ -0,0 +1,81 @@
1
+ // R294 — which parts of a runner's config a running daemon can take without a
2
+ // restart, and which it cannot.
3
+ //
4
+ // The daemon reads its config ONCE at boot and threads most of it by value —
5
+ // `projects` and their checkouts, `url`, `name`, the token, the MCP servers.
6
+ // Three keys are the exception: they are read fresh off the live `config`
7
+ // object on every use (a spawn, or the next heartbeat), so a SIGHUP that
8
+ // re-reads the file is enough to apply them. That list was spelled in three
9
+ // places — configure.mjs's header, attach.mjs's `reloadDaemon`, runner.mjs's
10
+ // handler — and nothing pinned that the three agreed. It is spelled HERE and
11
+ // nowhere else now, and the one question everything asks of it is
12
+ // `restartNeededFor`: given what the daemon booted with and what the file says
13
+ // now, which keys would only a restart change?
14
+ //
15
+ // Pure, because the daemon answers it and both config doors repeat the answer,
16
+ // and a function all three share is the only way they cannot disagree.
17
+ //
18
+ // Zero dependencies, like everything in tools/.
19
+
20
+ /**
21
+ * The keys runner.mjs's SIGHUP handler applies without a restart.
22
+ *
23
+ * Only settings that are genuinely safe to hot-swap belong here: each one
24
+ * must be read off `config` at the moment it is used, never captured at boot.
25
+ * Adding a key that IS captured would make the daemon claim it applied
26
+ * something it did not, which is worse than saying restart.
27
+ */
28
+ export const HOT_SWAPPABLE = Object.freeze(['agentCommands', 'grantable', 'acceptsRulesFromConsole']);
29
+
30
+ /**
31
+ * The legacy spelling of one key, read as the current one — so a config that
32
+ * says `agentCommand: "claude"` is not reported as differing from one that
33
+ * says `agentCommands: ["claude"]`. runner.mjs's `readConfig` makes the same
34
+ * move; this is it applied to a raw file rather than a booted config.
35
+ */
36
+ export function normaliseConfigFile(file) {
37
+ const { agentCommand, ...rest } = file ?? {};
38
+ if (agentCommand !== undefined && rest.agentCommands === undefined) {
39
+ rest.agentCommands = [agentCommand];
40
+ }
41
+ return rest;
42
+ }
43
+
44
+ /**
45
+ * The keys a restart would change: everything that differs between what the
46
+ * daemon booted with and what the file says now, minus {@link HOT_SWAPPABLE}.
47
+ *
48
+ * Compared against the BOOTED file and not the last one read, so an edit
49
+ * somebody reverts by hand clears the notice by itself. Values are compared
50
+ * with a key-sorted stringify, because `{workspaces: [...], name}` and
51
+ * `{name, workspaces: [...]}` are one config. Names only, never values: the
52
+ * answer is shown in a footer and logged, and the token is one of the keys.
53
+ *
54
+ * @returns {string[]} sorted, empty when nothing needs a restart
55
+ */
56
+ export function restartNeededFor(booted, file) {
57
+ const before = normaliseConfigFile(booted);
58
+ const after = normaliseConfigFile(file);
59
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
60
+ return [...keys]
61
+ .filter((key) => !HOT_SWAPPABLE.includes(key))
62
+ .filter((key) => canonical(before[key]) !== canonical(after[key]))
63
+ .sort();
64
+ }
65
+
66
+ /** Applied, or would be on a SIGHUP: the hot-swappable keys that differ. */
67
+ export function reloadableFor(booted, file) {
68
+ const before = normaliseConfigFile(booted);
69
+ const after = normaliseConfigFile(file);
70
+ return HOT_SWAPPABLE.filter((key) => canonical(before[key]) !== canonical(after[key]));
71
+ }
72
+
73
+ /** JSON with object keys in one order, so two spellings of one value agree. */
74
+ function canonical(value) {
75
+ if (value === undefined) return 'undefined';
76
+ return JSON.stringify(value, (_, inner) => (
77
+ inner && typeof inner === 'object' && !Array.isArray(inner)
78
+ ? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]]))
79
+ : inner
80
+ ));
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.2-beta",
3
+ "version": "1.0.3-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
package/runner/README.md CHANGED
@@ -138,15 +138,31 @@ travels beside the secret since this card, and a machine set up before it
138
138
  falls back to a lookup by label the first time this runs, after which it
139
139
  remembers.
140
140
 
141
- Adding a project or a workspace needs a restart to take effect, same as any
142
- other change to `"projects"` a live daemon says so. Enabling or disabling an
143
- agent and turning `rules` on or off do **not**: `cawdev config` and the `c`
144
- screen, through the pid the hello names sends the running daemon a `SIGHUP`,
145
- which re-reads just those three settings from the file and updates what the
146
- hello advertises, so the next `c` shows the new state. That is a unix convention asked of a process this operator already
147
- owns, not a command sent over the control socket in `attach.mjs` that socket
148
- is deliberately read-only (see its own header), and a config change is not an
149
- exception carved into it.
141
+ **What takes effect when** (R294). Three settings a running daemon takes
142
+ without a restart`agentCommands`, `grantable`, `acceptsRulesFromConsole`,
143
+ the `HOT_SWAPPABLE` list in `tools/lib/runner-config.mjs`, spelled once
144
+ because the daemon reads them fresh on every use. Everything else in the file
145
+ (`projects` and their workspaces, `url`, `name`, the token, `browser`, the
146
+ tuning) is read once at boot. Every change made by either door ends the same
147
+ way: the running daemon is sent a `SIGHUP` (through the pid the hello names
148
+ a unix convention asked of a process this operator already owns, not a command
149
+ over the control socket in `attach.mjs`, which is deliberately read-only), it
150
+ re-reads the file, applies what it can, and **says what it could not**: the
151
+ hello carries `restartNeeded`, the keys the file has moved on from since boot
152
+ that only a restart applies, and `configReadAt`. The door waits for that
153
+ re-read and repeats the daemon's own verdict — "applied, no restart needed",
154
+ or "needs a restart to pick up `projects` — q, then cawdev". A daemon too old
155
+ to answer gets the door's own reading of the same function.
156
+
157
+ While `restartNeeded` is non-empty the attached terminal pins a row in its
158
+ footer — `! config changed: projects — restart to apply (q, then cawdev)` — and
159
+ the `c` screen names what is in the file and not yet served ("serves cawdev ·
160
+ dycrypt after a restart", "cawdev: 1, 2 after a restart"). It is compared
161
+ against the file the daemon **booted** with, not the last one it read, so an
162
+ edit reverted by hand clears it. And it is off the file's own mtime, checked
163
+ on every heartbeat: a change made in an editor with nobody signalling anything
164
+ reaches the footer within thirty seconds. A notice, never a restart — the
165
+ daemon says, a person decides.
150
166
 
151
167
  ### What it shows you
152
168
 
package/runner/attach.mjs CHANGED
@@ -50,13 +50,14 @@ import { connect } from 'node:net';
50
50
  import { basename, dirname } from 'node:path';
51
51
  import { listSockets, socketPathFor } from './control.mjs';
52
52
  import {
53
- addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
53
+ RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
54
54
  setAcceptsRulesFromConsole, setAgentEnabled, slugsOf,
55
55
  } from './configure.mjs';
56
56
  import {
57
57
  TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
58
58
  } from './paths.mjs';
59
59
  import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
60
+ import { restartNeededFor } from '../lib/runner-config.mjs';
60
61
  import { oneLine } from './brand.mjs';
61
62
  import { Scrollback } from './scrollback.mjs';
62
63
  import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
@@ -490,6 +491,20 @@ export function footerLines(state, width, ink = painter(3)) {
490
491
 
491
492
  lines.push(settingsBar(runner, runs, width, ink));
492
493
 
494
+ // R294: the config file has moved on from what this daemon booted with, in
495
+ // ways only a restart applies. A row while it is true and nothing when it
496
+ // is not — the daemon decides, off the file's own mtime, so an edit by hand
497
+ // shows here too and an edit reverted clears it. Pinned, because the one
498
+ // sentence R288 printed about this scrolled off with the next transcript
499
+ // line, and a person who added a project here believed it was being served.
500
+ const stale = Array.isArray(runner?.restartNeeded) ? runner.restartNeeded : [];
501
+ if (stale.length) {
502
+ lines.push(padVisible(clip(
503
+ ` ${ink.warn('!')} ${ink.muted('config changed:')} ${ink.text(stale.join(', '))} ${ink.muted(`— restart to apply (${RESTART_FROM_ATTACH})`)}`,
504
+ width,
505
+ ), width));
506
+ }
507
+
493
508
  // Who and where. The runner's NAME is what the console shows and what
494
509
  // `--runner` takes, so it is the word somebody would type; the email is what
495
510
  // every action here is done as, and "watching only" is not a lesser state,
@@ -694,12 +709,27 @@ export function configMenu(file, runner = {}) {
694
709
  const slugs = slugsOf(file);
695
710
  const agents = agentsOf(file);
696
711
  const rulesOn = file.acceptsRulesFromConsole === true;
697
- const checkouts = slugs.map((slug) => `${slug}: ${pathsOf(file.projects[slug]).length}`).join(', ');
712
+ // R294: the file and the daemon can disagree, and the row says so rather
713
+ // than claiming the file is what runs. `served` is what the hello says the
714
+ // daemon booted with; a slug in the file and not there is "restart".
715
+ const stale = Array.isArray(runner.restartNeeded) ? runner.restartNeeded : [];
716
+ const served = Array.isArray(runner.projects) ? runner.projects : null;
717
+ const notServed = served && stale.includes('projects') ? slugs.filter((slug) => !served.includes(slug)) : [];
718
+ const serving = slugs.length
719
+ ? `serves ${slugs.filter((slug) => !notServed.includes(slug)).join(', ') || 'nothing yet'}`
720
+ : 'serves nothing yet';
721
+ const pending = notServed.length ? ` · ${notServed.join(', ')} after a restart` : '';
722
+ const checkouts = slugs.map((slug) => {
723
+ const count = pathsOf(file.projects[slug]).length;
724
+ const booted = runner.workspaces?.[slug];
725
+ const moved = stale.includes('projects') && typeof booted === 'number' && booted !== count;
726
+ return `${slug}: ${moved ? `${booted}, ${count} after a restart` : count}`;
727
+ }).join(', ');
698
728
  return new Select({
699
729
  kind: 'config',
700
730
  title: `this machine — ${runner.name ?? file.name ?? 'cawdev'}`,
701
731
  rows: [
702
- { id: 'add-project', label: 'Add a project', hint: slugs.length ? `serves ${slugs.join(', ')}` : 'serves nothing yet' },
732
+ { id: 'add-project', label: 'Add a project', hint: `${serving}${pending}` },
703
733
  { id: 'add-workspace', label: 'Add a workspace to a project', hint: checkouts || 'no projects to add one to' },
704
734
  ...[...new Set([...KNOWN_AGENTS, ...agents])].map((command) => (agents.includes(command)
705
735
  ? { id: `agent:${command}`, label: `Disable ${command}`, hint: 'spawned here now' }
@@ -959,6 +989,13 @@ export class Attached {
959
989
  this.runner = event.runner;
960
990
  this.runs = event.runs;
961
991
  this.chooseWatched();
992
+ } else if (event.type === 'runner') {
993
+ // R294: the daemon re-read its config — after a signal, or because the
994
+ // file's mtime moved — and says what it applied and what it could not.
995
+ // The hello was sent once, when this terminal attached; this is the
996
+ // same object again, so the footer and the `c` screen read what is
997
+ // true now.
998
+ this.runner = event.runner;
962
999
  } else if (event.type === 'runs') {
963
1000
  this.runs = event.runs;
964
1001
  this.chooseWatched();
@@ -2125,22 +2162,22 @@ export class Attached {
2125
2162
  if (!this.requireSignIn('add a project to this machine')) {
2126
2163
  return undefined;
2127
2164
  }
2128
- await addProject({
2165
+ const next = await addProject({
2129
2166
  configPath, file, url, session: this.session, ask, say, ink, pick,
2130
2167
  askPath: (spec) => this.askPath(spec),
2131
2168
  inspect: this.options.inspect ?? inspectPath,
2132
2169
  clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
2133
2170
  });
2134
- return this.afterProjectsChanged();
2171
+ return this.reloadDaemon(file, next);
2135
2172
  }
2136
2173
  if (chosen.id === 'add-workspace') {
2137
- await addWorkspace({
2174
+ const next = await addWorkspace({
2138
2175
  configPath, file, url, session: this.session, ask, say, ink, pick,
2139
2176
  askPath: (spec) => this.askPath(spec),
2140
2177
  inspect: this.options.inspect ?? inspectPath,
2141
2178
  clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
2142
2179
  });
2143
- return this.afterProjectsChanged();
2180
+ return this.reloadDaemon(file, next);
2144
2181
  }
2145
2182
  if (chosen.id.startsWith('agent:')) {
2146
2183
  const command = chosen.id.slice('agent:'.length);
@@ -2148,44 +2185,46 @@ export class Attached {
2148
2185
  file, configPath, ask, say, ink, command, enabled: !agentsOf(file).includes(command),
2149
2186
  });
2150
2187
  this.runner.agents = agentsOf(next);
2151
- return this.reloadDaemon(next);
2188
+ return this.reloadDaemon(file, next);
2152
2189
  }
2153
2190
  if (chosen.id === 'rules') {
2154
2191
  const next = await setAcceptsRulesFromConsole({
2155
2192
  file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
2156
2193
  });
2157
2194
  this.runner.acceptsConsoleRules = next.acceptsRulesFromConsole === true;
2158
- return this.reloadDaemon(next);
2195
+ return this.reloadDaemon(file, next);
2159
2196
  }
2160
2197
  return this.note('');
2161
2198
  }
2162
2199
 
2163
2200
  /**
2164
- * The two settings the daemon re-reads on SIGHUP, applied to the one this
2165
- * terminal is attached to R283's `reloadIfRunning` with the pid handed in,
2166
- * since the hello already says which process this is.
2201
+ * Every change ends here — R294. The daemon this terminal is attached to is
2202
+ * signalled through the pid the hello carries (R283's `reloadIfRunning`),
2203
+ * and what is printed is the DAEMON's verdict: the keys in
2204
+ * lib/runner-config.mjs's `HOT_SWAPPABLE` were applied, or the file has
2205
+ * moved on in ways only a restart picks up. The daemon pushes its re-read
2206
+ * hello to this terminal as a `runner` event, so `hello` here reads
2207
+ * `this.runner` and waits for `configReadAt` to move — no second socket.
2208
+ *
2209
+ * The footer then carries `restartNeeded` for as long as it is true, which
2210
+ * is the change from R288: a muted line that scrolled away was the only
2211
+ * thing that said a project added here was not being served yet.
2167
2212
  */
2168
- async reloadDaemon(next) {
2169
- await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
2170
- pidOf: async () => this.runner?.pid ?? null,
2213
+ async reloadDaemon(file, next) {
2214
+ const verdict = await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
2215
+ hello: async () => this.runner,
2216
+ fallback: restartNeededFor(file, next),
2171
2217
  // Injectable so a test can watch the signal without sending one.
2172
2218
  kill: this.options.kill ?? ((pid, signal) => process.kill(pid, signal)),
2219
+ restartHint: RESTART_FROM_ATTACH,
2220
+ waitMs: this.options.reloadWaitMs ?? 3000,
2173
2221
  });
2174
- return this.note('');
2175
- }
2176
-
2177
- /**
2178
- * Adding a project or a workspace is not hot-swappable — `config.projects`
2179
- * is read once at boot so this says what happens next rather than letting
2180
- * the row above look like it took effect. The same sentence `cawdev --setup`
2181
- * says, because it is the same fact.
2182
- */
2183
- afterProjectsChanged() {
2184
- const ink = this.ink;
2185
- this.say(` ${ink.muted('This daemon is still on the config it booted with.')}`);
2186
- this.say(` ${ink.muted('Press')} ${ink.text('q')} ${ink.muted('to stop it, and the next')} `
2187
- + `${ink.text('cawdev')} ${ink.muted('starts one on what you just set up.')}`);
2188
- return this.note('saved — q, then cawdev, to serve it');
2222
+ if (!verdict.running) {
2223
+ // Reachable when the hello carried no pid — a daemon older than R81.
2224
+ this.say(` ${this.ink.muted('Saved. This daemon is still on the config it booted with — q, then cawdev, to serve it.')}`);
2225
+ return this.note('saved — q, then cawdev, to serve it');
2226
+ }
2227
+ return this.note(verdict.restartNeeded.length ? 'savedrestart to apply' : '');
2189
2228
  }
2190
2229
 
2191
2230
  /**
package/runner/cawdev.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  addProject, addWorkspace, readConfigFile, reloadIfRunning, setAcceptsRulesFromConsole,
33
33
  setAgentEnabled,
34
34
  } from './configure.mjs';
35
+ import { restartNeededFor } from '../lib/runner-config.mjs';
35
36
  import { Select } from './select.mjs';
36
37
  import { storedSession } from './sign-in.mjs';
37
38
  import { loadToken } from './token-store.mjs';
@@ -461,19 +462,25 @@ export async function pickAction(ask, say, pick = askOne) {
461
462
  return [id];
462
463
  }
463
464
 
464
- export async function runAction([verb, ...rest], { configPath, file, url, session, ask, say, ink }) {
465
+ export async function runAction([verb, ...rest], { configPath, file, url, session, ask, say, ink, reloadOptions = {} }) {
465
466
  if (verb === 'show') {
466
467
  printConfig(file, ink, say);
467
468
  return;
468
469
  }
470
+ // R294: every change ends the same way — the daemon on this config, if one
471
+ // is running, is signalled and its own verdict is printed. For a project or
472
+ // a workspace that verdict is "needs a restart", because `config.projects`
473
+ // is read once at boot; but it is the daemon saying so about the file it is
474
+ // actually on, not this command guessing about a daemon it cannot see.
475
+ const reload = (next) => reloadIfRunning(file.name, say, ink, {
476
+ fallback: restartNeededFor(file, next), ...reloadOptions,
477
+ });
469
478
  if (verb === 'add-project') {
470
- await addProject({ configPath, file, url, session, ask, say, ink });
471
- await afterProjectsChanged(file, say, ink);
479
+ await reload(await addProject({ configPath, file, url, session, ask, say, ink }));
472
480
  return;
473
481
  }
474
482
  if (verb === 'add-workspace') {
475
- await addWorkspace({ configPath, file, url, session, ask, say, ink });
476
- await afterProjectsChanged(file, say, ink);
483
+ await reload(await addWorkspace({ configPath, file, url, session, ask, say, ink }));
477
484
  return;
478
485
  }
479
486
  if (verb === 'agent') {
@@ -481,34 +488,19 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
481
488
  if (!['claude', 'agy'].includes(command)) {
482
489
  throw new Error('Name an agent: cawdev config agent enable|disable claude|agy');
483
490
  }
484
- await setAgentEnabled({
491
+ await reload(await setAgentEnabled({
485
492
  file, configPath, ask, say, ink, command, enabled: sub === 'enable',
486
- });
487
- await reloadIfRunning(file.name, say, ink);
493
+ }));
488
494
  return;
489
495
  }
490
496
  if (verb === 'rules') {
491
497
  const [sub] = rest;
492
- await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' });
493
- await reloadIfRunning(file.name, say, ink);
498
+ await reload(await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' }));
494
499
  return;
495
500
  }
496
501
  throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
497
502
  }
498
503
 
499
- /**
500
- * Adding a project or a workspace is NOT hot-swappable — `config.projects` is
501
- * read once at boot and threaded everywhere by value, unlike the three
502
- * settings runner.mjs's SIGHUP handler re-reads. So this only says why
503
- * nothing happened yet, on the daemon this config would actually restart as.
504
- */
505
- export async function afterProjectsChanged(file, say, ink, { probe = probeSocket } = {}) {
506
- if (await probe(socketPathFor(file.name))) {
507
- say(` ${ink.muted('A runner is already running here, on the config it booted with.')}`);
508
- say(` ${ink.muted('Attach and quit to stop it, and the next cawdev starts one on what you just set up.')}`);
509
- }
510
- }
511
-
512
504
  /** The config as somebody would want to read it back — never the secret. */
513
505
  export function printConfig(file, ink, say) {
514
506
  const shown = { ...file };
@@ -9,11 +9,14 @@
9
9
  // **Every change here writes the FILE and nothing else.** A daemon already
10
10
  // running keeps the config it booted with — `cawdev --setup` already says so,
11
11
  // and this repeats it rather than inventing a second story. The one exception
12
- // is `reloadIfRunning`: `agentCommands` and `acceptsRulesFromConsole` are read
13
- // fresh off the live `config` object on every use in runner.mjs (a spawn, or
14
- // the next heartbeat), so a SIGHUP that re-reads the file is enough to apply
15
- // them without a restart — see runner.mjs's own handler for why that is a
16
- // signal and not a command sent over R52's read-only socket.
12
+ // is `reloadIfRunning`: the keys in lib/runner-config.mjs's `HOT_SWAPPABLE`
13
+ // are read fresh off the live `config` object on every use in runner.mjs (a
14
+ // spawn, or the next heartbeat), so a SIGHUP that re-reads the file is enough
15
+ // to apply them without a restart — see runner.mjs's own handler for why that
16
+ // is a signal and not a command sent over R52's read-only socket. R294: every
17
+ // change goes through it, and what it prints is the DAEMON's verdict read off
18
+ // the hello — applied, or these keys need a restart — never this file's guess
19
+ // about which keys the daemon can take.
17
20
  //
18
21
  // R288: the same functions have a SECOND door — the attached terminal's `c`
19
22
  // key — and that is why every question here comes in through a parameter.
@@ -359,23 +362,28 @@ export async function setAcceptsRulesFromConsole({
359
362
  }
360
363
 
361
364
  /**
362
- * The pid of a daemon running on this exact config, or null.
365
+ * The hello of a daemon running on this exact config, or null.
363
366
  *
364
367
  * The socket is keyed by NAME (R52), not by config path, so a machine
365
368
  * renamed between `--setup` and now would miss its own daemon — an edge case
366
369
  * left alone deliberately: naming this wrong points the reload at nobody's
367
370
  * process rather than somebody else's.
371
+ *
372
+ * R294: the whole `runner` object and not only its pid, because the hello is
373
+ * where the daemon says what it could not apply (`restartNeeded`) and when it
374
+ * last read the file (`configReadAt`) — the two fields `reloadIfRunning`
375
+ * phrases its answer from.
368
376
  */
369
- async function livePidFor(name) {
377
+ async function liveHello(name) {
370
378
  const path = socketPathFor(name);
371
379
  if (!(await probeSocket(path))) return null;
372
380
  return new Promise((done) => {
373
381
  const client = connectTo(path);
374
382
  let buffer = '';
375
- const finish = (pid) => {
383
+ const finish = (runner) => {
376
384
  clearTimeout(timer);
377
385
  try { client.destroy(); } catch { /* already gone */ }
378
- done(pid);
386
+ done(runner);
379
387
  };
380
388
  const timer = setTimeout(() => finish(null), 1000);
381
389
  client.setEncoding('utf8');
@@ -384,7 +392,7 @@ async function livePidFor(name) {
384
392
  const newline = buffer.indexOf('\n');
385
393
  if (newline === -1) return;
386
394
  try {
387
- finish(JSON.parse(buffer.slice(0, newline))?.runner?.pid ?? null);
395
+ finish(JSON.parse(buffer.slice(0, newline))?.runner ?? null);
388
396
  } catch {
389
397
  finish(null);
390
398
  }
@@ -393,20 +401,77 @@ async function livePidFor(name) {
393
401
  });
394
402
  }
395
403
 
404
+ /** How to restart, said from the shell: attach, quit, start again. */
405
+ export const RESTART_FROM_SHELL = 'cawdev, then q, then cawdev again';
406
+ /** The same, said from inside the attached terminal. */
407
+ export const RESTART_FROM_ATTACH = 'q, then cawdev';
408
+
396
409
  /**
397
- * SIGHUP a daemon already running on this config, so `agentCommands` and
398
- * `acceptsRulesFromConsole` take effect without a restart. Never a socket
399
- * command — see this file's header and runner.mjs's handler for why.
410
+ * SIGHUP a daemon already running on this config, and say what came of it.
411
+ *
412
+ * Never a socket command — see this file's header and runner.mjs's handler for
413
+ * why. R294: the daemon applies what it can (`HOT_SWAPPABLE`) and advertises
414
+ * the rest as `restartNeeded`, so this WAITS for the re-read — `configReadAt`
415
+ * moving past the one the hello carried before the signal — and repeats the
416
+ * daemon's own verdict rather than guessing at it. Three sentences, and each
417
+ * is a fact the daemon stated: applied and nothing else changed; applied, and
418
+ * these keys need a restart; signalled, and nothing came back.
419
+ *
420
+ * @param hello reads the daemon's current hello — the socket by default; the
421
+ * attached terminal hands in its own copy, which the daemon pushes to it
422
+ * @param restartHint how to restart from where the person is standing
423
+ * @param fallback what THIS door believes needs a restart — `restartNeededFor`
424
+ * over the file before and after its own write — used only for a daemon
425
+ * too old to say (no `configReadAt` on its hello). The daemon's answer wins
426
+ * whenever there is one, because the daemon knows what it booted with and
427
+ * the door only knows what it just changed
428
+ * @returns what happened, for a caller that wants to draw it:
429
+ * `{ running, applied, restartNeeded }`
400
430
  */
401
- export async function reloadIfRunning(name, say, ink = painter(3), { pidOf = livePidFor, kill = process.kill } = {}) {
402
- const pid = await pidOf(name);
403
- if (!pid) return false;
431
+ export async function reloadIfRunning(name, say, ink = painter(3), {
432
+ hello = liveHello,
433
+ kill = process.kill,
434
+ restartHint = RESTART_FROM_SHELL,
435
+ fallback = [],
436
+ waitMs = 3000,
437
+ tick = (ms) => new Promise((done) => setTimeout(done, ms)),
438
+ } = {}) {
439
+ const before = await hello(name);
440
+ if (!before?.pid) return { running: false, applied: false, restartNeeded: [] };
404
441
  try {
405
- kill(pid, 'SIGHUP');
406
- say(` ${ink.success('✓')} ${ink.muted(`Told the running daemon (pid ${pid}) to re-read this — no restart needed.`)}`);
407
- return true;
442
+ kill(before.pid, 'SIGHUP');
408
443
  } catch (failure) {
409
- say(` ${ink.warn('!')} ${ink.muted(`Could not signal the running daemon (${failure.message}) — restart it to pick this up.`)}`);
410
- return false;
444
+ say(` ${ink.warn('!')} ${ink.muted(`Could not signal the running daemon (${failure.message}) — restart it to pick this up: ${restartHint}.`)}`);
445
+ return { running: true, applied: false, restartNeeded: [] };
446
+ }
447
+
448
+ // An older daemon carries no `configReadAt` and cannot be waited for; the
449
+ // most this can say about it is what R283 said.
450
+ let after = null;
451
+ if (before.configReadAt !== undefined) {
452
+ const deadline = Date.now() + waitMs;
453
+ while (Date.now() < deadline) {
454
+ const now = await hello(name);
455
+ if (now?.configReadAt && now.configReadAt !== before.configReadAt) {
456
+ after = now;
457
+ break;
458
+ }
459
+ await tick(100);
460
+ }
461
+ }
462
+
463
+ if (!after && before.configReadAt !== undefined) {
464
+ say(` ${ink.warn('!')} ${ink.muted(`Signalled the daemon (pid ${before.pid}) but it did not re-read the file — restart it: ${restartHint}.`)}`);
465
+ return { running: true, applied: false, restartNeeded: [] };
466
+ }
467
+
468
+ const restartNeeded = after
469
+ ? (Array.isArray(after.restartNeeded) ? after.restartNeeded : [])
470
+ : fallback;
471
+ if (restartNeeded.length) {
472
+ say(` ${ink.warn('!')} ${ink.muted('Saved. The running daemon (pid')} ${ink.text(String(before.pid))}${ink.muted(') needs a restart to pick up')} ${ink.text(restartNeeded.join(', '))} ${ink.muted(`— ${restartHint}.`)}`);
473
+ } else {
474
+ say(` ${ink.success('✓')} ${ink.muted(`Told the running daemon (pid ${before.pid}) to re-read this — applied, no restart needed.`)}`);
411
475
  }
476
+ return { running: true, applied: true, restartNeeded };
412
477
  }
package/runner/runner.mjs CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
39
39
  import { findSecret } from '../lib/secrets.mjs';
40
40
  import { cliVersion, isBelow } from '../lib/version.mjs';
41
+ import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
41
42
 
42
43
  // --- configuration -----------------------------------------------------------
43
44
 
@@ -409,6 +410,14 @@ async function readConfig() {
409
410
  * daemon's cwd is not the terminal's: `cawdev` starts it detached.
410
411
  */
411
412
  configPath: fromFile ? resolve(path) : null,
413
+ /**
414
+ * The file as it was when this daemon booted — R294. What every later
415
+ * reading is compared against, so the daemon can say which of its
416
+ * settings the file has moved on from and only a restart would apply.
417
+ * The RAW file, not `config`: the question is what the operator changed,
418
+ * and defaults filled in here are not something they wrote.
419
+ */
420
+ bootedFile: fromFile ? file : null,
412
421
  };
413
422
 
414
423
  if (!config.token) {
@@ -7659,6 +7668,11 @@ async function main() {
7659
7668
  // inventing a file.
7660
7669
  agents: [...config.agentCommands],
7661
7670
  configPath: config.configPath,
7671
+ // R294: the keys the file has moved on from since boot that only a restart
7672
+ // would apply — nothing, at boot, by definition — and when the file was
7673
+ // last read, so a door that just signalled can wait for a fresh answer.
7674
+ restartNeeded: [],
7675
+ configReadAt: new Date().toISOString(),
7662
7676
  // R81. `cawdev` starts a daemon for you when it finds none, and
7663
7677
  // quitting the UI leaves it running — it is driving sessions. A
7664
7678
  // background process you did not know you started is the cost of that
@@ -7668,45 +7682,88 @@ async function main() {
7668
7682
  };
7669
7683
 
7670
7684
  // 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
7685
+ // on/off` write the file and, if a daemon is running on it, send SIGHUP — NOT
7672
7686
  // a control-socket command. `control.mjs` is deliberately read-only ("no
7673
7687
  // command can" change anything, so a person acting through it never gets
7674
7688
  // more than their own HTTPS session already grants); SIGHUP is a unix
7675
7689
  // convention for "re-read your config", asked of a process this operator
7676
7690
  // 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
7691
+ // genuinely safe to hot-swap `HOT_SWAPPABLE`, in lib/runner-config.mjs,
7692
+ // the ONE spelling of that list are applied: `agentCommands`, `grantable`
7693
+ // and `acceptsRulesFromConsole` are read fresh from `config` on every use
7694
+ // (spawn, or the next heartbeat), never captured once at boot, so mutating
7680
7695
  // them here is enough, with no further plumbing.
7681
- process.on('SIGHUP', async () => {
7682
- const path = configPathFromArgv();
7696
+ //
7697
+ // R294: and the daemon SAYS what it could not apply. Everything else in the
7698
+ // file is read once at boot, and until this a project added to the file was
7699
+ // logged as "nothing changed" — the handler diffed only the three keys it
7700
+ // could swap. `rereadConfig` compares the whole file against the one this
7701
+ // process BOOTED with (not the last one read, so an edit reverted by hand
7702
+ // clears the notice by itself) and publishes the keys only a restart would
7703
+ // apply as `restartNeeded` on the hello, with `configReadAt` so a door that
7704
+ // just signalled can tell a fresh answer from the old one. A notice, never a
7705
+ // restart — R113's rule: the daemon says so, and a person decides. Two
7706
+ // triggers, one function: the signal, and the heartbeat noticing the file's
7707
+ // mtime moved, which is what covers a hand edit with no door at all.
7708
+ let configSeenAt = null;
7709
+ const rereadConfig = async (why) => {
7710
+ const path = config.configPath ?? configPathFromArgv();
7683
7711
  try {
7712
+ // The mtime first, so a re-read on a signal is not followed by a second
7713
+ // one on the next beat for the same write.
7714
+ configSeenAt = (await stat(path).catch(() => null))?.mtimeMs ?? configSeenAt;
7684
7715
  const file = JSON.parse(await readFile(path, 'utf8'));
7685
- const before = {
7686
- acceptsRulesFromConsole: config.acceptsRulesFromConsole,
7687
- grantable: config.grantable,
7688
- agentCommands: config.agentCommands,
7716
+ // What the three keys RESOLVE to, defaults filled in as `readConfig`
7717
+ // fills them, so a key the file omits is not reported as a change.
7718
+ const resolved = {
7719
+ acceptsRulesFromConsole: file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
7720
+ grantable: file.grantable ?? DEFAULTS.grantable,
7721
+ agentCommands: file.agentCommands
7722
+ ?? (file.agentCommand ? [file.agentCommand] : null)
7723
+ ?? DEFAULTS.agentCommands,
7689
7724
  };
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;
7725
+ const applied = reloadableFor({
7726
+ agentCommands: config.agentCommands,
7727
+ grantable: config.grantable,
7728
+ acceptsRulesFromConsole: config.acceptsRulesFromConsole,
7729
+ }, resolved);
7730
+ Object.assign(config, resolved);
7696
7731
  // R288: the hello describes the current state, not the booted one — an
7697
7732
  // attach opened after a toggle would otherwise offer to turn on what is
7698
7733
  // already on. The same object `serveControl` sends, mutated in place.
7699
7734
  advertised.acceptsConsoleRules = config.acceptsRulesFromConsole === true;
7700
7735
  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`);
7736
+ advertised.restartNeeded = config.bootedFile ? restartNeededFor(config.bootedFile, file) : [];
7737
+ advertised.configReadAt = new Date().toISOString();
7738
+ const said = [
7739
+ applied.length ? `applied: ${applied.join(', ')}` : null,
7740
+ advertised.restartNeeded.length ? `needs restart: ${advertised.restartNeeded.join(', ')}` : null,
7741
+ ].filter(Boolean);
7742
+ log(`re-read ${path} on ${why} — ${said.length ? said.join('; ') : 'nothing changed'}`);
7706
7743
  } catch (failure) {
7707
- log(`SIGHUP re-read of ${path} failed: ${failure.message}`);
7744
+ log(`${why} re-read of ${path} failed: ${failure.message}`);
7708
7745
  }
7709
- });
7746
+ // A terminal already attached got its hello before this; the daemon tells
7747
+ // it rather than waiting for the next attach. An older client ignores an
7748
+ // event type it does not know.
7749
+ control?.publish({ type: 'runner', runner: advertised });
7750
+ };
7751
+ process.on('SIGHUP', () => rereadConfig('SIGHUP'));
7752
+
7753
+ // The heartbeat's half of R294: the file's mtime, read once per beat, and a
7754
+ // re-read when it moved. `stat` and nothing more when it did not, which is
7755
+ // what makes this affordable every thirty seconds. Skipped for a daemon
7756
+ // booted from the environment alone — there is no file to have changed.
7757
+ configSeenAt = config.configPath ? (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null : null;
7758
+ const noticeConfigEdits = async () => {
7759
+ if (!config.configPath) return;
7760
+ const seen = (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null;
7761
+ if (seen === null || seen === configSeenAt) return;
7762
+ // `rereadConfig` records the mtime before it reads: a file caught
7763
+ // mid-write fails to parse, and the write that finishes it moves the
7764
+ // mtime again.
7765
+ await rereadConfig('edit');
7766
+ };
7710
7767
 
7711
7768
  // R52. Nothing here is load-bearing for running an agent, so a daemon that
7712
7769
  // cannot open a socket says so and carries on: trading the ability to run
@@ -7814,7 +7871,7 @@ async function main() {
7814
7871
  : [];
7815
7872
  }
7816
7873
  }).catch((failure) => log(`heartbeat failed: ${failure.message}`));
7817
- await Promise.all([workspacesReported, heartbeatAnswered]);
7874
+ await Promise.all([workspacesReported, heartbeatAnswered, noticeConfigEdits()]);
7818
7875
  };
7819
7876
 
7820
7877
  await beat();