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 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.7",
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
  }
@@ -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 c = AbortSignal.timeout(ms);
55
- const r = await fetch(`${BASE}/health`, { signal: c });
56
- return r.ok;
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
- if (!(await up()) && !(await start())) process.exit(0); // fail open, silently
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
@@ -342,12 +342,17 @@ export const ADAPTERS = [
342
342
  // -------------------------------------------------------------------------
343
343
  {
344
344
  id: 'copilot',
345
- name: 'GitHub Copilot CLI',
345
+ name: 'GitHub Copilot',
346
346
  verified: null,
347
347
  config: '.github/hooks/nearly.json',
348
- // Copilot accepts PascalCase event names as a Claude Code compatibility
349
- // mode, and in that mode it sends snake_case fields and Claude's own tool
350
- // names. So this adapter is mostly a different file path.
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
- cfg.hooks[their] = [{ type: 'command', command: cmdFor(ours), timeoutSec: holdFor(ours) }];
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') return json(res, 200, { ok: true, sessions: sessions.size });
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 recaps and the replay out of ui/, plus a local preview of the
479
- // docs/ folder that GitHub Pages will serve, so you can check it before pushing.
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 docs = url.pathname === '/docs' || url.pathname.startsWith('/docs/');
482
- const base = path.join(ROOT, docs ? 'docs' : 'ui');
483
- let rel = url.pathname.slice(1).split('/').filter((p) => p && p !== '..').join('/');
484
- if (docs) rel = rel.replace(/^docs\/?/, '') || 'index.html';
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
+ }