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.
- package/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
|
@@ -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
|
+
}
|