nearly-cli 0.1.7 → 0.1.8
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 +41 -1
- package/package.json +2 -2
- package/scripts/attach.mjs +41 -1
- package/scripts/hook.mjs +27 -4
- package/server/adapters.mjs +15 -5
- package/server/index.mjs +54 -7
- package/server/reclaim.mjs +104 -0
package/README.md
CHANGED
|
@@ -61,11 +61,16 @@ the hook. Seven are supported:
|
|
|
61
61
|
| Claude Code | `.claude/settings.local.json` | yes | full |
|
|
62
62
|
| Cursor | `.cursor/hooks.json` | yes | full |
|
|
63
63
|
| Antigravity | `.agents/hooks.json` | yes | full |
|
|
64
|
-
| GitHub Copilot CLI | `.github/hooks/nearly.json` | yes | full |
|
|
64
|
+
| GitHub Copilot (CLI **and VS Code agent mode**) | `.github/hooks/nearly.json` | yes | full |
|
|
65
65
|
| Gemini CLI | `.gemini/settings.json` | yes | full |
|
|
66
66
|
| Codex CLI | `.codex/hooks.json` | yes | no prompts, one turn |
|
|
67
67
|
| Windsurf | `.windsurf/hooks.json` | until Cascade gives up | shell and file tools only |
|
|
68
68
|
|
|
69
|
+
VS Code agent mode loads every `.json` in `.github/hooks/` with no further
|
|
70
|
+
setup, so `nearly --agent=copilot` is the whole of it there — and it is the only
|
|
71
|
+
route for someone on Windows who does not have Claude Code, since Cline's hooks
|
|
72
|
+
are macOS and Linux only and Windsurf's cannot be given a deadline.
|
|
73
|
+
|
|
69
74
|
`nearly` turns on whichever of these the repo shows signs of, and Claude Code
|
|
70
75
|
either way. `nearly --agent=cursor` forces one, `--agent=all` forces all of them,
|
|
71
76
|
and `nearly agents` prints what is actually wired here.
|
|
@@ -113,6 +118,11 @@ Zed's built-in agent, Aider, Kilo Code, Warp and the hosted builders (Replit,
|
|
|
113
118
|
Lovable, Bolt, v0) expose no blocking pre-tool hook. There is nothing to attach
|
|
114
119
|
to, and no adapter can change that.
|
|
115
120
|
|
|
121
|
+
**Cline** has one, and it is macOS and Linux only — so on Windows there is
|
|
122
|
+
nothing to attach to there either. Cline does keep its own consent trail in
|
|
123
|
+
`ui_messages.json` under its task history, which Nearly could read after the
|
|
124
|
+
fact; that would be a reader rather than a gate, and it does not exist yet.
|
|
125
|
+
|
|
116
126
|
## Use it on your own repo
|
|
117
127
|
|
|
118
128
|
One command, in the repo you want recorded.
|
|
@@ -317,6 +327,36 @@ node scripts/publish-pages.mjs --base https://<user>.github.io/<repo>
|
|
|
317
327
|
|
|
318
328
|
That copies every built recap into `docs/records/` and writes `docs/index.html`, an index of the sessions on record. Commit `docs/`, then set **Settings → Pages → branch `main`, folder `/docs`**. Preview it locally first at http://127.0.0.1:47653/docs/ while the server is running.
|
|
319
329
|
|
|
330
|
+
## The server, and why you never start it
|
|
331
|
+
|
|
332
|
+
The first hook that needs the server starts it, and it stays up for the rest of
|
|
333
|
+
the day rather than paying the startup cost on every tool call. Two consequences
|
|
334
|
+
had to be designed for, because both were found the hard way on other people's
|
|
335
|
+
machines.
|
|
336
|
+
|
|
337
|
+
A server outlives the run that started it, so one `npx nearly-cli` — or any
|
|
338
|
+
upgrade — can leave the previous build holding the port. It keeps answering,
|
|
339
|
+
from a directory npm has since replaced, which is why its record pages 404 and
|
|
340
|
+
why upgrading appears to do nothing at all. Every server now says which build it
|
|
341
|
+
is and where it lives, and a hook from a different install takes the port back
|
|
342
|
+
before doing anything else — without being asked, because nobody reads a hook's
|
|
343
|
+
output, and a fix only the people who happen to re-read a message ever get is
|
|
344
|
+
not a fix.
|
|
345
|
+
|
|
346
|
+
Builds from 0.1.8 stand down when asked. Older ones have no way to be asked, so
|
|
347
|
+
an idle one is ended outright. Two rules make that defensible. It must prove it
|
|
348
|
+
is ours twice, on `/health` and on `/state` — the second carries the consent
|
|
349
|
+
gradient itself, which nothing else on your machine is going to return by
|
|
350
|
+
chance, so a plain web server that happens to sit on 47653 is never touched. And
|
|
351
|
+
it is never ended while anybody is using it: dropping a held request would hand
|
|
352
|
+
it back to the agent's own prompt, which is the one outcome this project exists
|
|
353
|
+
to prevent. If a server cannot be ended, `nearly` says so and gives you the
|
|
354
|
+
command for your platform rather than leaving you to work it out.
|
|
355
|
+
|
|
356
|
+
And a server with no sessions that nobody has asked anything of for thirty
|
|
357
|
+
minutes exits on its own. There is nothing to remember to shut down, and nothing
|
|
358
|
+
squats on a port for days.
|
|
359
|
+
|
|
320
360
|
## Why the hook fails open
|
|
321
361
|
|
|
322
362
|
Claude Code treats a hook that times out, errors, or returns anything other than `200` with JSON as a non-blocking error and lets the tool call proceed. So this server always answers with JSON, holds "ask" calls for at most `ASK_TIMEOUT_MS`, and denies when nobody decides. The hook's own timeout is set longer than that.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "A pull request tells you what changed. Nearly tells you what nearly happened: the commands a human refused, the pushes policy blocked, the turns rolled back.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://anujpatel06.github.io/nearly/",
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs"
|
|
37
|
+
"test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs test/stale-server.test.mjs"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/scripts/attach.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { ADAPTERS } from '../server/adapters.mjs';
|
|
|
28
28
|
|
|
29
29
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
30
30
|
const HOOK = join(root, 'scripts', 'hook.mjs');
|
|
31
|
-
const PORT = 47653;
|
|
31
|
+
const PORT = Number(process.env.NEARLY_PORT || 47653);
|
|
32
32
|
|
|
33
33
|
// What the hooks should invoke, in order of preference. This choice decides
|
|
34
34
|
// whether upgrading the tool ever reaches the repos it was turned on for.
|
|
@@ -177,6 +177,27 @@ try {
|
|
|
177
177
|
notes.push(`could not remember this repo for the dashboard: ${e.message}`);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// A server from somewhere else, already holding the port
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// The server outlives the run that starts it. So a single `npx nearly-cli`, or
|
|
184
|
+
// any upgrade, can leave the previous build squatting — answering from a
|
|
185
|
+
// directory npm has since replaced, 404ing its own record pages, and making
|
|
186
|
+
// every fix since invisible. A hook cannot say any of this out loud; this
|
|
187
|
+
// command can, because you are here reading it.
|
|
188
|
+
//
|
|
189
|
+
// Builds from 0.1.8 stand down when asked. Older ones have no way to be asked,
|
|
190
|
+
// so the honest thing is to name the problem and the exact command.
|
|
191
|
+
async function checkPort() {
|
|
192
|
+
let mine = root;
|
|
193
|
+
try { mine = realpathSync(root); } catch { /* compare the literal path */ }
|
|
194
|
+
try {
|
|
195
|
+
const { reclaim } = await import('../server/reclaim.mjs');
|
|
196
|
+
return await reclaim({ port: PORT, base: `http://127.0.0.1:${PORT}`, root: mine });
|
|
197
|
+
} catch { return null; }
|
|
198
|
+
}
|
|
199
|
+
const port = off ? null : await checkPort();
|
|
200
|
+
|
|
180
201
|
// ---------------------------------------------------------------------------
|
|
181
202
|
// git pre-push hook
|
|
182
203
|
// ---------------------------------------------------------------------------
|
|
@@ -260,6 +281,25 @@ if (base) {
|
|
|
260
281
|
}
|
|
261
282
|
for (const n of notes) console.log(` ${dim('·')} ${dim(n)}`);
|
|
262
283
|
|
|
284
|
+
if (port?.outcome === 'stood-down' || port?.outcome === 'ended') {
|
|
285
|
+
const from = port.who?.root || 'an older build';
|
|
286
|
+
console.log(` ${ok('·')} ${dim(`closed an older Nearly server that was holding port ${PORT}`)}`);
|
|
287
|
+
console.log(` ${dim(from)}`);
|
|
288
|
+
} else if (port?.outcome === 'busy') {
|
|
289
|
+
console.log('');
|
|
290
|
+
console.log(` ${bold('Another Nearly server is on this port and is in use.')}`);
|
|
291
|
+
console.log(dim(' Left it alone. Run this again once it is idle and this build will take over.'));
|
|
292
|
+
} else if (port?.outcome === 'stuck') {
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log(` ${bold(`An older Nearly server is holding port ${PORT} and would not close.`)}`);
|
|
295
|
+
console.log(dim(' Until it goes it answers instead of this one, which is why its record pages'));
|
|
296
|
+
console.log(dim(' 404 and why upgrading appears to do nothing.'));
|
|
297
|
+
console.log('');
|
|
298
|
+
console.log(` ${dim('End it with:')} ${process.platform === 'win32'
|
|
299
|
+
? `netstat -ano | findstr :${PORT} then taskkill /PID <pid> /F`
|
|
300
|
+
: `lsof -ti:${PORT} -sTCP:LISTEN | xargs kill`}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
263
303
|
// An agent you have on this machine but have not used here is worth a word, and
|
|
264
304
|
// nothing more: having it installed is no reason to write files into this repo.
|
|
265
305
|
const elsewhere = agentsOnMachine().filter((i) => !wired.some((w) => w.id === i.id));
|
package/scripts/hook.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { spawn } from 'node:child_process';
|
|
26
26
|
import { join, dirname } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { realpathSync } from 'node:fs';
|
|
28
29
|
import { byId } from '../server/adapters.mjs';
|
|
29
30
|
|
|
30
31
|
const HOST = '127.0.0.1';
|
|
@@ -49,11 +50,21 @@ const body = await new Promise((r) => {
|
|
|
49
50
|
process.stdin.on('error', () => r(''));
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
const realRoot = (() => { try { return realpathSync(root); } catch { return root; } })();
|
|
54
|
+
|
|
55
|
+
// Is the thing on this port *us*?
|
|
56
|
+
//
|
|
57
|
+
// A hook starts the server and the server outlives the run. So after an upgrade
|
|
58
|
+
// — or after a one-off `npx nearly-cli` — the old build keeps the port and keeps
|
|
59
|
+
// answering, from a directory that may not exist any more. Its record pages 404
|
|
60
|
+
// and every fix since is invisible, with nothing anywhere saying why.
|
|
52
61
|
async function up(ms = 400) {
|
|
53
62
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
const r = await fetch(`${BASE}/health`, { signal: AbortSignal.timeout(ms) });
|
|
64
|
+
if (!r.ok) return false;
|
|
65
|
+
const h = await r.json().catch(() => ({}));
|
|
66
|
+
if (h.root !== realRoot) return 'stale'; // no root at all means older than this check
|
|
67
|
+
return true;
|
|
57
68
|
} catch { return false; }
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -70,7 +81,19 @@ async function start() {
|
|
|
70
81
|
return false;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
let health = await up();
|
|
85
|
+
if (health === 'stale') {
|
|
86
|
+
// Take the port back rather than run whatever is already there. Nobody reads
|
|
87
|
+
// a hook's output, so this has to happen without being asked — otherwise the
|
|
88
|
+
// only people who ever get the fix are the ones who happen to re-run `nearly`
|
|
89
|
+
// and read the message.
|
|
90
|
+
const { reclaim } = await import('../server/reclaim.mjs');
|
|
91
|
+
const { outcome } = await reclaim({ port: PORT, base: BASE, root: realRoot });
|
|
92
|
+
// 'busy' and 'stuck' both mean it is still there. Talking to an old server
|
|
93
|
+
// still gates the call, which is better than not gating it.
|
|
94
|
+
health = (outcome === 'stood-down' || outcome === 'ended' || outcome === 'free') ? false : true;
|
|
95
|
+
}
|
|
96
|
+
if (!health && !(await start())) process.exit(0); // fail open, silently
|
|
74
97
|
|
|
75
98
|
// PreToolUse can hold for as long as the server is willing to wait for a human.
|
|
76
99
|
// Everything else should be quick; keep it short so a wedged endpoint cannot
|
package/server/adapters.mjs
CHANGED
|
@@ -342,12 +342,17 @@ export const ADAPTERS = [
|
|
|
342
342
|
// -------------------------------------------------------------------------
|
|
343
343
|
{
|
|
344
344
|
id: 'copilot',
|
|
345
|
-
name: 'GitHub Copilot
|
|
345
|
+
name: 'GitHub Copilot',
|
|
346
346
|
verified: null,
|
|
347
347
|
config: '.github/hooks/nearly.json',
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// names
|
|
348
|
+
// Both the CLI and VS Code's agent mode, which loads every .json in
|
|
349
|
+
// .github/hooks/ with no further setup. Copilot accepts PascalCase event
|
|
350
|
+
// names as a Claude Code compatibility mode, and in that mode it sends
|
|
351
|
+
// snake_case fields and Claude's own tool names, so this adapter is mostly
|
|
352
|
+
// a different file path.
|
|
353
|
+
//
|
|
354
|
+
// It also fails closed where Claude Code fails open: a crash or a non-zero
|
|
355
|
+
// exit in a preToolUse hook denies the call rather than waving it through.
|
|
351
356
|
events: {
|
|
352
357
|
SessionStart: 'session-start', UserPromptSubmit: 'prompt', PreToolUse: 'pre-tool',
|
|
353
358
|
PostToolUse: 'post-tool', Stop: 'stop', SessionEnd: 'session-end',
|
|
@@ -356,7 +361,12 @@ export const ADAPTERS = [
|
|
|
356
361
|
const file = join(repo, '.github', 'hooks', 'nearly.json');
|
|
357
362
|
const cfg = { version: 1, hooks: {} };
|
|
358
363
|
for (const [their, ours] of Object.entries(this.events)) {
|
|
359
|
-
|
|
364
|
+
const run = cmdFor(ours);
|
|
365
|
+
// `command` is the cross-platform fallback; `bash` and `powershell` are
|
|
366
|
+
// what the runtime picks per OS. Writing all three means a Windows
|
|
367
|
+
// machine finds one whichever property it prefers — and Windows is
|
|
368
|
+
// exactly where somebody with no other option is running this.
|
|
369
|
+
cfg.hooks[their] = [{ type: 'command', command: run, bash: run, powershell: run, timeoutSec: holdFor(ours) }];
|
|
360
370
|
}
|
|
361
371
|
writeJson(file, cfg); // our own file; nobody else's entries to keep
|
|
362
372
|
return { file };
|
package/server/index.mjs
CHANGED
|
@@ -20,6 +20,19 @@ const WORKTREES = path.join(paths.workspace(), '.worktrees');
|
|
|
20
20
|
const RECORDINGS = paths.recordings();
|
|
21
21
|
const UI = path.join(ROOT, 'ui', 'index.html');
|
|
22
22
|
const MAX_SESSIONS = 3; // 8 GB machine
|
|
23
|
+
// Which build is actually answering on this port. A server started by a hook
|
|
24
|
+
// outlives the run that started it, so after an upgrade the old one keeps the
|
|
25
|
+
// port and keeps serving its own code — and every fix stays invisible. Say who
|
|
26
|
+
// we are so the launcher can tell.
|
|
27
|
+
const VERSION = (() => {
|
|
28
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; }
|
|
29
|
+
catch { return '0.0.0'; }
|
|
30
|
+
})();
|
|
31
|
+
// Idle since the last request. A server nobody is using should not hold a port
|
|
32
|
+
// for the rest of the week, least of all one running from a cache directory
|
|
33
|
+
// that npm may already have deleted.
|
|
34
|
+
const IDLE_EXIT_MS = Number(process.env.NEARLY_IDLE_EXIT_MS || 30 * 60_000);
|
|
35
|
+
let lastSeen = Date.now();
|
|
23
36
|
const ASK_TIMEOUT_MS = Number(process.env.NEARLY_ASK_TIMEOUT_MS || 120_000); // UI must answer before this; then we fail CLOSED (deny)
|
|
24
37
|
const HOOK_TIMEOUT_S = 180; // Claude Code's own hook timeout; must be > ASK_TIMEOUT
|
|
25
38
|
const MODEL = 'sonnet';
|
|
@@ -358,7 +371,10 @@ function readBody(req) {
|
|
|
358
371
|
return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
|
|
359
372
|
}
|
|
360
373
|
|
|
374
|
+
const realRoot = (() => { try { return fs.realpathSync(ROOT); } catch { return ROOT; } })();
|
|
375
|
+
|
|
361
376
|
const server = http.createServer(async (req, res) => {
|
|
377
|
+
lastSeen = Date.now();
|
|
362
378
|
const url = new URL(req.url, `http://${HOST}:${PORT}`);
|
|
363
379
|
const sidParam = url.searchParams.get('s');
|
|
364
380
|
|
|
@@ -470,18 +486,40 @@ const server = http.createServer(async (req, res) => {
|
|
|
470
486
|
|
|
471
487
|
// ---- UI API ----
|
|
472
488
|
// Cheap liveness check: the hook launcher calls this before every tool call.
|
|
473
|
-
if (req.method === 'GET' && url.pathname === '/health')
|
|
489
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
490
|
+
return json(res, 200, { ok: true, sessions: sessions.size, version: VERSION, root: realRoot });
|
|
491
|
+
}
|
|
492
|
+
// Stand down so a newer build can take the port. Refused while anybody is
|
|
493
|
+
// waiting on a decision: dropping a held request would hand it back to the
|
|
494
|
+
// agent's own prompt, which is the one outcome this whole project exists to
|
|
495
|
+
// avoid.
|
|
496
|
+
if (req.method === 'POST' && url.pathname === '/exit') {
|
|
497
|
+
const waiting = [...sessions.values()].reduce((n, s) => n + s.pending.size, 0);
|
|
498
|
+
if (waiting) return json(res, 409, { ok: false, waiting });
|
|
499
|
+
json(res, 200, { ok: true, version: VERSION });
|
|
500
|
+
setTimeout(() => process.exit(0), 50);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
474
503
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
475
504
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
476
505
|
return res.end(fs.readFileSync(UI));
|
|
477
506
|
}
|
|
478
|
-
// Static: built
|
|
479
|
-
//
|
|
507
|
+
// Static: built records, the replay page, and a local preview of the docs/
|
|
508
|
+
// folder GitHub Pages serves, so you can check it before pushing.
|
|
509
|
+
//
|
|
510
|
+
// Each of these has to be asked for by name rather than resolved against the
|
|
511
|
+
// package. Records used to live in ui/records/ and moved to the user's own
|
|
512
|
+
// directory when it turned out an upgrade was deleting them — but this route
|
|
513
|
+
// kept serving out of the package, so from an npm install every record 404'd
|
|
514
|
+
// while sitting perfectly well on disk. It only ever worked from a checkout,
|
|
515
|
+
// which is the one place nobody would notice.
|
|
480
516
|
if (req.method === 'GET' && (url.pathname.startsWith('/records/') || url.pathname === '/replay.html' || url.pathname === '/docs' || url.pathname.startsWith('/docs/'))) {
|
|
481
|
-
const
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
517
|
+
const isDocs = url.pathname === '/docs' || url.pathname.startsWith('/docs/');
|
|
518
|
+
const isRecord = url.pathname.startsWith('/records/');
|
|
519
|
+
const base = isRecord ? paths.pages() : isDocs ? paths.docs() : path.join(ROOT, 'ui');
|
|
520
|
+
const strip = isRecord ? '/records/' : isDocs ? '/docs' : '/';
|
|
521
|
+
let rel = url.pathname.slice(strip.length).split('/').filter((p) => p && p !== '..').join('/');
|
|
522
|
+
if (isDocs) rel = rel || 'index.html';
|
|
485
523
|
const file = path.join(base, rel);
|
|
486
524
|
if (!file.startsWith(base) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return json(res, 404, { error: 'not found' });
|
|
487
525
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
@@ -582,6 +620,15 @@ server.on('error', (e) => {
|
|
|
582
620
|
process.exit(1);
|
|
583
621
|
});
|
|
584
622
|
|
|
623
|
+
// Nothing to remember to shut down. A server with no sessions that nobody has
|
|
624
|
+
// asked anything of for half an hour has no reason to still be holding a port.
|
|
625
|
+
if (IDLE_EXIT_MS > 0) {
|
|
626
|
+
const idle = setInterval(() => {
|
|
627
|
+
if (sessions.size === 0 && Date.now() - lastSeen > IDLE_EXIT_MS) process.exit(0);
|
|
628
|
+
}, 60_000);
|
|
629
|
+
idle.unref();
|
|
630
|
+
}
|
|
631
|
+
|
|
585
632
|
server.listen(PORT, HOST, () => {
|
|
586
633
|
console.log(`nearly http://${HOST}:${PORT}`);
|
|
587
634
|
console.log(`worktrees ${WORKTREES}`);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Take the port back from a Nearly server that is not this one.
|
|
2
|
+
//
|
|
3
|
+
// The server outlives the run that starts it. So a single `npx nearly-cli`, or
|
|
4
|
+
// any upgrade, leaves the previous build holding 47653 — still answering, from a
|
|
5
|
+
// directory npm has since replaced. Its record pages 404, and worse, every fix
|
|
6
|
+
// shipped after it never runs. Nothing anywhere says why.
|
|
7
|
+
//
|
|
8
|
+
// Builds from 0.1.8 stand down when asked. Everything already installed does
|
|
9
|
+
// not, and that is most people. Telling them to run taskkill is not a fix; it
|
|
10
|
+
// is a fix for whoever reads the message. So when an older build is idle, we
|
|
11
|
+
// end it ourselves.
|
|
12
|
+
//
|
|
13
|
+
// The rule that makes that defensible: never kill anything until it has proved,
|
|
14
|
+
// twice, that it is one of ours, and never kill one that anybody is using.
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const isWin = process.platform === 'win32';
|
|
19
|
+
|
|
20
|
+
async function get(base, path, ms = 700) {
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(base + path, { signal: AbortSignal.timeout(ms) });
|
|
23
|
+
if (!r.ok) return null;
|
|
24
|
+
return await r.json();
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Two independent shapes only this server produces. /health alone is a couple of
|
|
29
|
+
// generic fields that anything could return by chance; /state carries the
|
|
30
|
+
// consent gradient itself — the tier table, the learned rules, the deadline. A
|
|
31
|
+
// process answering both is ours, whatever version wrote it.
|
|
32
|
+
export async function identify(base) {
|
|
33
|
+
const health = await get(base, '/health');
|
|
34
|
+
if (!health || health.ok !== true || typeof health.sessions !== 'number') return null;
|
|
35
|
+
const state = await get(base, '/state');
|
|
36
|
+
if (!state || !state.defaults || !Array.isArray(state.sessions)) return null;
|
|
37
|
+
if (typeof state.defaults.Bash !== 'string' || typeof state.askTimeoutMs !== 'number') return null;
|
|
38
|
+
return {
|
|
39
|
+
version: health.version || null, // absent before 0.1.8
|
|
40
|
+
root: health.root || null, // absent before 0.1.8
|
|
41
|
+
sessions: health.sessions,
|
|
42
|
+
// Anything actually being decided right now. Killing over one of these
|
|
43
|
+
// would drop a held request back to the agent's own prompt.
|
|
44
|
+
waiting: state.sessions.reduce((n, s) => n + (s.pending?.length || 0), 0),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pidsOnPort(port) {
|
|
49
|
+
try {
|
|
50
|
+
if (isWin) {
|
|
51
|
+
const out = execFileSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf8', timeout: 5000 });
|
|
52
|
+
return [...new Set(out.split(/\r?\n/)
|
|
53
|
+
.filter((l) => /LISTENING/i.test(l) && new RegExp(`[:.]${port}\\s`).test(l))
|
|
54
|
+
.map((l) => Number(l.trim().split(/\s+/).pop()))
|
|
55
|
+
.filter((n) => Number.isInteger(n) && n > 0))];
|
|
56
|
+
}
|
|
57
|
+
const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'],
|
|
58
|
+
{ encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
59
|
+
return [...new Set(out.split(/\s+/).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
|
|
60
|
+
} catch { return []; } // lsof or netstat missing, or nothing listening
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const freed = async (base) => (await identify(base)) === null;
|
|
64
|
+
|
|
65
|
+
// Returns what happened, for a caller that wants to say so out loud.
|
|
66
|
+
//
|
|
67
|
+
// 'ours' the server on this port is this build
|
|
68
|
+
// 'free' nothing is listening
|
|
69
|
+
// 'busy' someone else's, but a person is mid-decision — left alone
|
|
70
|
+
// 'stood-down' it exited when asked (0.1.8 and later)
|
|
71
|
+
// 'ended' older build, idle, so we closed it
|
|
72
|
+
// 'stuck' ours by every test, but we could not end it
|
|
73
|
+
export async function reclaim({ port, base, root, kill = process.kill.bind(process) }) {
|
|
74
|
+
const who = await identify(base);
|
|
75
|
+
if (!who) return { outcome: 'free' };
|
|
76
|
+
if (who.root && who.root === root) return { outcome: 'ours', who };
|
|
77
|
+
if (who.waiting > 0) return { outcome: 'busy', who };
|
|
78
|
+
|
|
79
|
+
// The polite path. A build that understands this will refuse if it is busy.
|
|
80
|
+
try {
|
|
81
|
+
const r = await fetch(`${base}/exit`, { method: 'POST', signal: AbortSignal.timeout(2000) });
|
|
82
|
+
if (r.status === 409) return { outcome: 'busy', who };
|
|
83
|
+
if (r.ok) {
|
|
84
|
+
for (let i = 0; i < 20; i++) {
|
|
85
|
+
await new Promise((s) => setTimeout(s, 100));
|
|
86
|
+
if (await freed(base)) return { outcome: 'stood-down', who };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* older build: no such endpoint */ }
|
|
90
|
+
|
|
91
|
+
// Older build. It has already answered as ours on two endpoints and has no
|
|
92
|
+
// sessions, so ending it costs nobody anything and is the only way its
|
|
93
|
+
// replacement ever gets to run.
|
|
94
|
+
if (who.sessions > 0) return { outcome: 'busy', who };
|
|
95
|
+
for (const pid of pidsOnPort(port)) {
|
|
96
|
+
if (pid === process.pid) continue;
|
|
97
|
+
try { kill(pid, 'SIGTERM'); } catch { /* gone, or not ours to signal */ }
|
|
98
|
+
}
|
|
99
|
+
for (let i = 0; i < 25; i++) {
|
|
100
|
+
await new Promise((s) => setTimeout(s, 100));
|
|
101
|
+
if (await freed(base)) return { outcome: 'ended', who };
|
|
102
|
+
}
|
|
103
|
+
return { outcome: 'stuck', who };
|
|
104
|
+
}
|