cawdev-cli 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env node
2
+ // `cawdev` — one word, and you are in. R81.
3
+ //
4
+ // This is the whole interface from a machine. Not `node runner.mjs`, not
5
+ // `node runner.mjs attach`, not argument number two of a script: a command you
6
+ // type by name, which is what R52's terminal client became once it stopped
7
+ // being an accessory to the daemon and started being the way people use cawdev.
8
+ //
9
+ // **Finding no daemon, it starts one.** That was decided by the person who
10
+ // asked for this entry, and it has a cost worth naming: a background process
11
+ // somebody did not know they started. R81 paid that cost out loud and left the
12
+ // process running; R123 pays it by ENDING it — one word started the window and
13
+ // the machine, and `q` stops both, asking twice while sessions are live.
14
+ // `--leave-running` is the old answer, for a machine that should outlive the
15
+ // window, and there the goodbye still names the runner and the `kill`.
16
+ //
17
+ // R52's discovery is unchanged where it still applies: one daemon is the answer
18
+ // without asking, and more than one means naming which.
19
+ //
20
+ // Zero dependencies, like everything in tools/.
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { realpathSync } from 'node:fs';
24
+ import { access, mkdir, open, readFile } from 'node:fs/promises';
25
+ import { homedir } from 'node:os';
26
+ import { dirname, join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { attach, urlFrom, valueOf } from './attach.mjs';
29
+ import { asker, mintForThisMachine, setUpThisMachine } from './bootstrap.mjs';
30
+ import { liveSockets, probeSocket, socketPathFor } from './control.mjs';
31
+ import { loadToken } from './token-store.mjs';
32
+ import { painter } from '../lib/ansi.mjs';
33
+ import { mark } from './brand.mjs';
34
+
35
+ const HERE = dirname(new URL(import.meta.url).pathname);
36
+ const RUNNER = join(HERE, 'runner.mjs');
37
+
38
+ const USAGE = `
39
+ cawdev — this machine's sessions, in your terminal
40
+
41
+ cawdev attach to the runner here, starting one if there is none
42
+ cawdev --runner <name> when this machine runs more than one
43
+ cawdev --url <url> which cawdev to sign in to (or CAWDEV_URL)
44
+ cawdev --config <path> the runner config to start a daemon from
45
+ cawdev --setup set this machine up again: projects, checkouts, token
46
+ cawdev --no-start attach only; never launch a daemon
47
+ cawdev --watch-only do not sign in; watch without being able to act
48
+ cawdev --leave-running leave the daemon running when you quit
49
+ cawdev --help
50
+
51
+ On a machine with no config, cawdev sets one up: it signs you in through the
52
+ browser, asks which projects this machine should run agents for, clones them,
53
+ and mints its own runner token. No token is ever typed.
54
+
55
+ Inside: enter prompts the session you are watching, / takes a command
56
+ (/help lists them), L lists the runs, and q stops the machine and leaves.
57
+ It asks twice while sessions are running. --leave-running keeps it up.
58
+ `;
59
+
60
+ /**
61
+ * The config a daemon would be started from.
62
+ *
63
+ * Four places, in the order somebody would expect: what they just typed, what
64
+ * their shell says, the directory they are standing in, and the one that
65
+ * follows them between directories. Nothing is invented — if none of the four
66
+ * is there, the daemon is started without one and `readConfig` decides whether
67
+ * the environment supplied enough, which is the answer it already gives.
68
+ */
69
+ export async function findConfig(argv, env = process.env, cwd = process.cwd()) {
70
+ const named = valueOf(argv, '--config');
71
+ if (named) {
72
+ // Not checked for existence: a file somebody named and got wrong should
73
+ // fail saying so, not quietly fall back to a different one.
74
+ return resolve(cwd, named);
75
+ }
76
+ const candidates = [
77
+ env.CAWDEV_RUNNER_CONFIG,
78
+ join(cwd, 'runner.config.json'),
79
+ join(env.HOME ?? homedir(), '.cawdev', 'runner.config.json'),
80
+ ].filter(Boolean);
81
+ for (const path of candidates) {
82
+ try {
83
+ await access(path);
84
+ return path;
85
+ } catch {
86
+ // Next.
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /** Where a daemon this command started writes what it could not put on a socket. */
93
+ export function daemonLogPath() {
94
+ return join(homedir(), '.cawdev', 'runner.log');
95
+ }
96
+
97
+ /**
98
+ * Start a daemon in the background and wait for its socket.
99
+ *
100
+ * Detached with its output on a file, because this terminal belongs to the UI
101
+ * a second later — a daemon writing its banner into the middle of a transcript
102
+ * is the thing `--attach` exists to avoid, and the same reasoning applies
103
+ * harder when the two are separate processes.
104
+ *
105
+ * The wait is for the SOCKET rather than for a timer: a daemon that boots is
106
+ * ready when it is offering one, and a daemon that refuses to boot — no token,
107
+ * no projects — never will. That is why the failure path prints the log rather
108
+ * than a timeout, since the log is where `readConfig` said what was missing.
109
+ */
110
+ export async function startDaemon(configPath, ink = painter()) {
111
+ await mkdir(dirname(daemonLogPath()), { recursive: true, mode: 0o700 });
112
+ const log = await open(daemonLogPath(), 'a');
113
+
114
+ const args = [RUNNER, ...(configPath ? ['--config', configPath] : [])];
115
+ const child = spawn(process.execPath, args, {
116
+ detached: true,
117
+ stdio: ['ignore', log.fd, log.fd],
118
+ // From the config's own directory, so relative workspace paths in it mean
119
+ // what they meant when it was written.
120
+ cwd: configPath ? dirname(configPath) : process.cwd(),
121
+ });
122
+ child.unref();
123
+ await log.close();
124
+
125
+ const deadline = Date.now() + 20_000;
126
+ while (Date.now() < deadline) {
127
+ await new Promise((done) => setTimeout(done, 250));
128
+ const alive = await liveSockets();
129
+ if (alive.length === 1) {
130
+ return alive[0].path;
131
+ }
132
+ if (alive.length > 1) {
133
+ // Somebody else's daemon was already here and ours arrived beside it.
134
+ const named = alive.map((each) => each.name).join(', ');
135
+ throw new Error(`More than one runner here (${named}). Choose one: cawdev --runner <name>`);
136
+ }
137
+ if (child.exitCode !== null) {
138
+ break;
139
+ }
140
+ }
141
+
142
+ const tail = await lastLines(daemonLogPath(), 12);
143
+ throw new Error(
144
+ `The runner did not start.${configPath ? `\n Config: ${configPath}` : '\n No config file found.'}\n`
145
+ + ` ${ink.muted(daemonLogPath())}\n\n`
146
+ + (tail.length ? tail.map((line) => ` ${line}`).join('\n') : ' (nothing in the log)'),
147
+ );
148
+ }
149
+
150
+ async function lastLines(path, count) {
151
+ try {
152
+ const text = await readFile(path, 'utf8');
153
+ return text.trimEnd().split('\n').slice(-count);
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Which socket to attach to, starting a daemon if that is what "none" means.
161
+ *
162
+ * A named runner is never started for you: naming one is a claim that it is
163
+ * there, and launching a *different* daemon under that name because the first
164
+ * was not answering is not the request.
165
+ */
166
+ export async function socketToAttach(argv, ink = painter()) {
167
+ const named = valueOf(argv, '--runner');
168
+ if (named) {
169
+ const path = socketPathFor(named);
170
+ if (await probeSocket(path)) {
171
+ return path;
172
+ }
173
+ throw new Error(`No runner called "${named}" is answering on this machine.`);
174
+ }
175
+
176
+ const alive = await liveSockets();
177
+ if (alive.length === 1) {
178
+ return alive[0].path;
179
+ }
180
+ if (alive.length > 1) {
181
+ const names = alive.map((each) => each.name).join(', ');
182
+ throw new Error(`More than one runner here (${names}). Choose one: cawdev --runner <name>`);
183
+ }
184
+
185
+ if (argv.includes('--no-start')) {
186
+ throw new Error('No runner is answering on this machine, and --no-start was given.');
187
+ }
188
+
189
+ let configPath = await findConfig(argv);
190
+ const file = configPath ? await readConfigFile(configPath) : null;
191
+
192
+ // R93. A machine that cannot produce a token is one nobody has finished
193
+ // setting up, and it is the ONLY case that acts by itself: a token that is
194
+ // exported, or one written into the config, is somebody having said which
195
+ // credential to use, and asking them again would be ignoring it.
196
+ //
197
+ // The old behaviour here was to start a daemon that could not boot and then
198
+ // print `readConfig`'s complaint out of a log file — accurate, and useless on
199
+ // a laptop where the answer was "you have not set this up yet".
200
+ //
201
+ // **The guard used to ask whether a config EXISTED, and that was the bug.**
202
+ // A config with no token in it is the commonest shape there is: the file
203
+ // names working copies and permissions, so it is written by hand, copied
204
+ // between machines and committed — and it must not carry a credential. Such
205
+ // a machine skipped the walk, started a daemon that refused to boot, and was
206
+ // told to go and mint a token in the console by hand. Which is precisely the
207
+ // errand R93 exists to abolish, reached through a different door.
208
+ if (!process.env.CAWDEV_TOKEN && !file?.token) {
209
+ if (!configPath) {
210
+ // Nothing here at all: the walk, which asks what this machine serves.
211
+ configPath = await runSetup(argv, ink);
212
+ } else if (file && !(await loadToken(daemonUrl(file)))) {
213
+ // Configured but uncredentialed, and nothing minted here before. The
214
+ // config answers every question the walk would ask, so only the token is
215
+ // fetched. A file we could not parse is left alone deliberately: the
216
+ // daemon's own complaint about it says more than a walk would.
217
+ //
218
+ // Asked under `daemonUrl`, not the one being signed in to: the question
219
+ // is "will the daemon find a token", and the daemon has never heard of
220
+ // `--url`.
221
+ await runMint(configPath, file, argv, ink);
222
+ }
223
+ }
224
+
225
+ console.log(` ${ink.muted('No runner here yet — starting one')}`
226
+ + `${configPath ? ` ${ink.muted('from')} ${ink.accent(configPath)}` : ''}${ink.muted('…')}`);
227
+ return startDaemon(configPath, ink);
228
+ }
229
+
230
+ /**
231
+ * The URL a daemon assumes when nothing says otherwise.
232
+ *
233
+ * A third copy of a string that already exists in `runner.mjs`'s `DEFAULTS` and
234
+ * in `attach.mjs`'s `urlFrom`, and importing either would be worse: `DEFAULTS`
235
+ * lives in a module whose top level starts a daemon, and `urlFrom` folds in
236
+ * `--url`, which is the very thing this must not see. `cawdev-command.test.mjs`
237
+ * pins the three in step instead — it reads the three files and fails when they
238
+ * hold more than one value, which is what makes moving the default a
239
+ * three-line change rather than a two-line bug.
240
+ */
241
+ const DEFAULT_URL = 'http://localhost:4200';
242
+
243
+ /**
244
+ * Two URLs, because they answer two different questions.
245
+ *
246
+ * **Where a person signs in** is a browser's question. **What this machine is
247
+ * called** is the daemon's, and it is the key the token is stored under. An
248
+ * earlier version of this file had one function for both, with a comment
249
+ * claiming they were kept in step — they are not, and the cost of the claim was
250
+ * a token minted at one URL, filed under it, and looked for under another. A
251
+ * live credential on the tokens page that nothing would ever read.
252
+ *
253
+ * They differ for an ordinary reason rather than a broken one: a config may
254
+ * name the API directly — in development it is on `:8091` while the console
255
+ * proxying to it is on `:4200` — and a browser sent to the API gets no sign-in
256
+ * page, because the console is what serves one. So `--url` is how somebody says
257
+ * which door *they* are going through, and it has no business renaming the
258
+ * machine.
259
+ *
260
+ * <p>The DEFAULT is now the console's origin, which makes the two agree when
261
+ * nothing has been configured — but they are still two questions, and the split
262
+ * is what keeps a config naming `:8091` working with a browser sent to `:4200`.
263
+ */
264
+ export function signInUrl(file, argv, env = process.env) {
265
+ const typed = valueOf(argv, '--url') ?? env.CAWDEV_URL;
266
+ return String(typed ?? file?.url ?? urlFrom(argv)).replace(/\/+$/, '');
267
+ }
268
+
269
+ /**
270
+ * What `readConfig` will call this machine — and therefore the storage key.
271
+ *
272
+ * Deliberately a copy of the daemon's own precedence (`CAWDEV_URL`, the config,
273
+ * the default) rather than a call into it: `readConfig` is not exported, reads
274
+ * `process.argv` for its own `--config`, and throws when there is no token,
275
+ * which is the state this is deciding about. `--url` is absent because the
276
+ * daemon is never passed one, and a key the daemon cannot compute is a token it
277
+ * cannot find.
278
+ */
279
+ export function daemonUrl(file, env = process.env) {
280
+ return String(env.CAWDEV_URL ?? file?.url ?? DEFAULT_URL).replace(/\/+$/, '');
281
+ }
282
+
283
+ /**
284
+ * The config as an object, or null if it will not parse.
285
+ *
286
+ * Null rather than a throw: an unreadable config is the daemon's complaint to
287
+ * make — it names the file and the parse error — and swallowing it here to
288
+ * offer a setup walk would replace a precise message with a wrong guess about
289
+ * what somebody wants.
290
+ */
291
+ export async function readConfigFile(path) {
292
+ try {
293
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
294
+ return parsed && typeof parsed === 'object' ? parsed : null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * The setup walk, with the terminal handed to it and taken back.
302
+ *
303
+ * `readline` owns stdin while it is open and the client's raw mode wants it
304
+ * afterwards, so the interface is closed on every path out — including the one
305
+ * where somebody answered "no, Claude Code is not signed in", which throws.
306
+ */
307
+ async function runSetup(argv, ink) {
308
+ const ask = asker();
309
+ try {
310
+ const { configPath } = await setUpThisMachine({
311
+ url: urlFrom(argv),
312
+ ask,
313
+ say: (line) => console.log(line),
314
+ ink,
315
+ });
316
+ return configPath;
317
+ } finally {
318
+ ask.close();
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Minting for a machine that is already configured.
324
+ *
325
+ * No `asker` here, and that is the point rather than an omission: this asks
326
+ * nothing. The config named the projects, the checkouts are on disk, and the
327
+ * browser handles the one interaction there is.
328
+ */
329
+ async function runMint(configPath, file, argv, ink) {
330
+ await mintForThisMachine({
331
+ url: signInUrl(file, argv),
332
+ storeUrl: daemonUrl(file),
333
+ config: file,
334
+ configPath,
335
+ say: (line) => console.log(line),
336
+ ink,
337
+ });
338
+ }
339
+
340
+ async function main() {
341
+ const argv = process.argv.slice(2);
342
+ const ink = painter();
343
+
344
+ if (argv.includes('--help') || argv.includes('-h')) {
345
+ console.log(USAGE);
346
+ return;
347
+ }
348
+
349
+ // The mark, once, before anything else happens. It is the only decoration in
350
+ // the program and it is here because this is the moment somebody is waiting:
351
+ // a browser about to open, or a daemon about to boot.
352
+ for (const line of mark(ink, { tagline: urlFrom(argv) })) {
353
+ console.log(`\n${line}\n`);
354
+ }
355
+
356
+ // Asked for by name, the walk runs even where one has been done before —
357
+ // that is what "again" means, and adding a project to this machine is the
358
+ // ordinary reason. A daemon already running keeps the config it booted with,
359
+ // so it is told to restart rather than left to look like it took the change.
360
+ if (argv.includes('--setup')) {
361
+ await runSetup(argv, ink);
362
+ const alive = await liveSockets();
363
+ if (alive.length) {
364
+ console.log(` ${ink.warn('!')} ${ink.muted('A runner is already running here, on the config it booted with.')}`);
365
+ console.log(` ${ink.muted('Attach and quit to stop it, and the next cawdev starts one')}`);
366
+ console.log(` ${ink.muted('on what you just set up.')}`);
367
+ }
368
+ }
369
+
370
+ const socketPath = await socketToAttach(argv, ink);
371
+ await attach(argv, { socketPath });
372
+ }
373
+
374
+ /**
375
+ * Is this file the command being run, rather than a module somebody imported?
376
+ *
377
+ * **Through the SYMLINK, and that is the whole point.** `npm i -g` installs a
378
+ * bin as a link — `…/bin/cawdev` → `…/lib/node_modules/cawdev/runner/cawdev.mjs`
379
+ * — so `process.argv[1]` is the link and `import.meta.url` is its target. A
380
+ * lexical comparison of the two is never equal, `main()` never ran, and the
381
+ * installed command exited 0 having printed nothing. It worked only when the
382
+ * file was named directly, which is how it passed every test and every hand
383
+ * check: those all typed the path.
384
+ *
385
+ * So both sides are resolved through the filesystem, which is what makes a
386
+ * link and its target the same file. `fileURLToPath` rather than
387
+ * `URL.pathname`, because a path containing a space arrives percent-encoded
388
+ * and would miss for a second reason.
389
+ *
390
+ * Pure enough to test: give it the two strings and it answers.
391
+ */
392
+ export function isTheCommand(argv1, moduleUrl) {
393
+ if (!argv1) return false;
394
+ const real = (path) => {
395
+ try {
396
+ return realpathSync(path);
397
+ } catch {
398
+ // A path that is not there cannot be this file; the lexical form is
399
+ // still worth comparing, since that is the case where both are absent
400
+ // from disk (a bundler, a test harness) and equality still means yes.
401
+ return resolve(path);
402
+ }
403
+ };
404
+ return real(argv1) === real(fileURLToPath(moduleUrl));
405
+ }
406
+
407
+ // Only when this file IS the command. Its helpers are imported by the tests,
408
+ // and a module that starts a daemon on import is one nothing can test.
409
+ if (isTheCommand(process.argv[1], import.meta.url)) {
410
+ main().catch((failure) => {
411
+ console.error(`\n ${failure.message}\n`);
412
+ process.exit(1);
413
+ });
414
+ }
@@ -0,0 +1,225 @@
1
+ // The daemon's own view of itself, offered on a local socket — R52.
2
+ //
3
+ // Not an API. The platform already has one, and everything a browser needs
4
+ // goes there. What lives ONLY here is the machine's own knowledge:
5
+ //
6
+ // - what it is driving right now, before any of it reaches the database;
7
+ // - what it has claimed but not yet spawned;
8
+ // - what it is leaving queued, AND WHY — "cawdev already has a run here",
9
+ // "at 4 sessions". That reason exists nowhere else. The console can show
10
+ // you four runs and no explanation for the fifth.
11
+ // - the daemon's own log, and the agent's stderr.
12
+ //
13
+ // READ ONLY, deliberately. Nothing here changes anything, and no command can.
14
+ // Prompting, cancelling and deciding a permission request all refuse a token
15
+ // (R51), so a socket that could do them would either need the daemon's own
16
+ // credential — making the daemon a way around a guard that exists on purpose —
17
+ // or a person's, which does not belong in a daemon. The client signs in itself
18
+ // and acts over HTTPS like any other person.
19
+ //
20
+ // The consequence is worth stating plainly: **permission to read this socket is
21
+ // permission to read this machine's transcripts.** That is why it lives in a
22
+ // 0700 directory under the operator's home, and why it discloses only this
23
+ // machine's own work.
24
+
25
+ import { connect as connectTo, createServer } from 'node:net';
26
+ import { mkdir, readdir, unlink } from 'node:fs/promises';
27
+ import { homedir } from 'node:os';
28
+ import { join } from 'node:path';
29
+
30
+ /**
31
+ * Where a daemon puts its socket. 0700: the transcripts are in here.
32
+ *
33
+ * `CAWDEV_RUN_DIR` overrides it, and exists because R81 made "how many daemons
34
+ * are on this machine" a question the `cawdev` command ANSWERS rather than one
35
+ * a person answers for it — so a test of that answer has to be able to stand
36
+ * somewhere the operator's own daemon is not.
37
+ */
38
+ export function socketDirectory() {
39
+ return process.env.CAWDEV_RUN_DIR ?? join(homedir(), '.cawdev', 'run');
40
+ }
41
+
42
+ /**
43
+ * One socket per runner NAME, not per process.
44
+ *
45
+ * A name is what the operator chose and what the console shows, so
46
+ * `attach --runner macbook-laptop` means the machine they are looking at. Two
47
+ * daemons under one name would be a configuration mistake either way, and this
48
+ * makes it a visible one: the second finds the first's socket alive.
49
+ */
50
+ export function socketPathFor(name) {
51
+ const safe = String(name).replace(/[^a-zA-Z0-9._-]/g, '-');
52
+ return join(socketDirectory(), `${safe}.sock`);
53
+ }
54
+
55
+ /** Every daemon socket on this machine, live or left behind. */
56
+ export async function listSockets() {
57
+ try {
58
+ const names = await readdir(socketDirectory());
59
+ return names
60
+ .filter((name) => name.endsWith('.sock'))
61
+ .map((name) => ({ name: name.slice(0, -'.sock'.length), path: join(socketDirectory(), name) }));
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Whether anything is actually listening on a socket — R81.
69
+ *
70
+ * A killed daemon leaves its file behind, and a stale socket is
71
+ * indistinguishable from a live one until you try it. That did not matter while
72
+ * attaching was something you did after starting a daemon by hand; it matters
73
+ * now that `cawdev` decides whether to START one from what it finds here, and
74
+ * a leftover file would make it attach to nothing for ever instead.
75
+ */
76
+ export function probeSocket(path, timeoutMs = 750) {
77
+ return new Promise((done) => {
78
+ const client = connectTo(path);
79
+ const settle = (alive) => {
80
+ clearTimeout(timer);
81
+ try {
82
+ client.destroy();
83
+ } catch {
84
+ // Already gone.
85
+ }
86
+ done(alive);
87
+ };
88
+ const timer = setTimeout(() => settle(false), timeoutMs);
89
+ client.on('connect', () => settle(true));
90
+ client.on('error', () => settle(false));
91
+ });
92
+ }
93
+
94
+ /** Every socket on this machine that something is answering on. */
95
+ export async function liveSockets() {
96
+ const found = await listSockets();
97
+ const alive = [];
98
+ for (const socket of found) {
99
+ if (await probeSocket(socket.path)) {
100
+ alive.push(socket);
101
+ }
102
+ }
103
+ return alive;
104
+ }
105
+
106
+ /**
107
+ * How many lines of each session's transcript are kept for somebody who
108
+ * attaches later.
109
+ *
110
+ * Attaching to a run that started an hour ago and seeing a blank pane until the
111
+ * agent next speaks is the whole reason this exists. Reading the history back
112
+ * from the platform would work and would defeat the point of a local socket —
113
+ * it has to be readable when the platform is not.
114
+ */
115
+ const KEPT_LINES = 4000;
116
+
117
+ /**
118
+ * Serves the socket, and returns the handle the daemon publishes through.
119
+ *
120
+ * `snapshot()` is called rather than passed, because the answer changes every
121
+ * few seconds and a value captured at startup would be a lie by the first run.
122
+ */
123
+ export async function serveControl({ runner, snapshot }) {
124
+ await mkdir(socketDirectory(), { recursive: true, mode: 0o700 });
125
+
126
+ const path = socketPathFor(runner.name);
127
+ // A killed daemon leaves its socket behind, and a stale file is
128
+ // indistinguishable from a live one until you try it. Connecting first would
129
+ // be more correct and much slower to write; unlinking is what every daemon
130
+ // that has ever done this does, and the failure it risks — stealing a live
131
+ // daemon's socket — is already a misconfiguration (two daemons, one name).
132
+ await unlink(path).catch(() => undefined);
133
+
134
+ const clients = new Set();
135
+ /** runId -> the last KEPT_LINES of its transcript. */
136
+ const history = new Map();
137
+
138
+ const write = (client, message) => {
139
+ try {
140
+ client.write(`${JSON.stringify(message)}\n`);
141
+ } catch {
142
+ // A client that has gone away is not this daemon's problem.
143
+ }
144
+ };
145
+
146
+ const server = createServer((client) => {
147
+ client.setEncoding('utf8');
148
+ clients.add(client);
149
+ client.on('error', () => clients.delete(client));
150
+ client.on('close', () => clients.delete(client));
151
+
152
+ write(client, { type: 'hello', runner, runs: snapshot() });
153
+
154
+ let buffer = '';
155
+ client.on('data', (chunk) => {
156
+ buffer += chunk;
157
+ let newline;
158
+ while ((newline = buffer.indexOf('\n')) !== -1) {
159
+ const line = buffer.slice(0, newline).trim();
160
+ buffer = buffer.slice(newline + 1);
161
+ if (!line) continue;
162
+ let message;
163
+ try {
164
+ message = JSON.parse(line);
165
+ } catch {
166
+ continue; // Not ours to interpret.
167
+ }
168
+ // The only two things a client may ask for, and neither changes
169
+ // anything: the backlog of one run, and proof we are still here.
170
+ if (message.type === 'backlog') {
171
+ write(client, {
172
+ type: 'backlog',
173
+ runId: message.runId,
174
+ lines: history.get(message.runId) ?? [],
175
+ });
176
+ } else if (message.type === 'ping') {
177
+ write(client, { type: 'pong' });
178
+ }
179
+ }
180
+ });
181
+ });
182
+
183
+ server.on('error', () => {
184
+ // A daemon that cannot offer a socket is still a daemon. Nothing here is
185
+ // load-bearing for running an agent, and refusing to start over it would
186
+ // trade the whole feature for one of its conveniences.
187
+ });
188
+
189
+ await new Promise((done) => server.listen(path, done));
190
+
191
+ return {
192
+ path,
193
+
194
+ /** Broadcast, and keep what is worth keeping for whoever attaches next. */
195
+ publish(event) {
196
+ if (event.type === 'output' && event.runId) {
197
+ const kept = history.get(event.runId) ?? [];
198
+ kept.push(event.line);
199
+ if (kept.length > KEPT_LINES) {
200
+ kept.splice(0, kept.length - KEPT_LINES);
201
+ }
202
+ history.set(event.runId, kept);
203
+ }
204
+ for (const client of clients) {
205
+ write(client, event);
206
+ }
207
+ },
208
+
209
+ /** A run that is over stops costing memory. */
210
+ forget(runId) {
211
+ history.delete(runId);
212
+ },
213
+
214
+ async close() {
215
+ for (const client of clients) {
216
+ client.destroy();
217
+ }
218
+ clients.clear();
219
+ await new Promise((done) => server.close(done));
220
+ // Leaving this behind is what makes the NEXT attach report a daemon that
221
+ // is not there.
222
+ await unlink(path).catch(() => undefined);
223
+ },
224
+ };
225
+ }