nearly-cli 0.1.6 → 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.
@@ -210,6 +220,11 @@ Every tool call passes through an HTTP `PreToolUse` hook to this server, which s
210
220
 
211
221
  "Allow always" and "Never" turn a decision into a rule for the rest of the run, keyed by tool and first word of the command, or file extension for edits. In lab mode every turn is committed in the agent's worktree by the `Stop` hook, so **Undo turn** is a `git reset --hard HEAD~1`.
212
222
 
223
+ Lab mode is off by default. `nearly open` shows your own sessions and what
224
+ needs you — the gate, which is what you installed this for. `nearly lab` adds
225
+ the panel for starting agents from the dashboard, which is a different job and
226
+ no longer the first thing a new user is asked about.
227
+
213
228
  Agents in lab mode are real Claude Code sessions (`claude -p`) on your Claude
214
229
  subscription, each on its own branch in its own git worktree. You pick which
215
230
  repo to branch from — the dashboard offers the ones you have turned Nearly on
@@ -312,6 +327,36 @@ node scripts/publish-pages.mjs --base https://<user>.github.io/<repo>
312
327
 
313
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.
314
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
+
315
360
  ## Why the hook fails open
316
361
 
317
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.
@@ -368,6 +413,6 @@ The claim this project makes is testable: a reviewer who sees the session record
368
413
  - `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
369
414
  - `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
370
415
  - `scripts/install-push-hook.mjs` + `scripts/push-record.mjs`, hand the branch record over at `git push`
371
- - `~/.nearly/`, where recordings, records and agent worktrees are kept
416
+ - `~/.nearly/`, where recordings, records, settings and agent worktrees are kept — outside the package, so an upgrade cannot destroy them
372
417
  - `recordings/<session>.jsonl`, every event and decision; `recordings/demo/` is committed so the records can be rebuilt from source
373
418
  - `STUDY.md`, the protocol for testing whether any of this helps a reviewer
package/bin/nearly.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // nearly turn it on for the repo you are in
5
5
  // nearly off turn it off again
6
6
  // nearly open open the dashboard
7
+ // nearly lab open it with the panel for starting agents
7
8
  // nearly record build the record for the current branch
8
9
  // nearly post put that record on the pull request
9
10
  // nearly agents which agents this repo is gated for
@@ -86,8 +87,10 @@ switch (cmd) {
86
87
  return run(join(root, 'server', 'index.mjs'), rest);
87
88
  }
88
89
 
89
- case 'open': {
90
- const url = 'http://127.0.0.1:47653';
90
+ case 'lab': case 'open': {
91
+ // `open` is the gate: your sessions and what needs you. `lab` adds the
92
+ // panel for starting agents from here, which is a different job.
93
+ const url = 'http://127.0.0.1:47653' + (cmd === 'lab' ? '/?lab=1' : '');
91
94
  spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open',
92
95
  [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
93
96
  console.log(url);
@@ -99,6 +102,7 @@ switch (cmd) {
99
102
  nearly turn it on for the repo you are in
100
103
  nearly off turn it off again
101
104
  nearly open open the dashboard
105
+ nearly lab open it with the panel for starting agents
102
106
  nearly record build the record for the current branch
103
107
  nearly post put that record on the pull request
104
108
  nearly agents which agents this repo is gated for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nearly-cli",
3
- "version": "0.1.6",
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.
@@ -160,6 +160,11 @@ for (const a of chosen) {
160
160
  // by simply turning Nearly on again.
161
161
  try {
162
162
  const f = paths.repos();
163
+ // On a machine that has never run Nearly, ~/.nearly does not exist yet and
164
+ // this write fails with ENOENT. It used to fail into a bare catch, so the
165
+ // list was silently never created and the dashboard could never offer a repo
166
+ // — invisible on every machine except a genuinely fresh one.
167
+ mkdirSync(dirname(f), { recursive: true });
163
168
  let list = [];
164
169
  try { list = JSON.parse(readFileSync(f, 'utf8')); } catch { /* first one */ }
165
170
  // Prune as we go: a repo that has been moved or deleted is noise in a list
@@ -167,7 +172,31 @@ try {
167
172
  list = list.filter((r) => r !== repo && existsSync(join(r, '.git')));
168
173
  if (!off) list.unshift(repo);
169
174
  writeFileSync(f, JSON.stringify(list.slice(0, 50), null, 2) + '\n');
170
- } catch { /* the dashboard still works without it */ }
175
+ } catch (e) {
176
+ // Not fatal — the gate does not depend on it — but not silent either.
177
+ notes.push(`could not remember this repo for the dashboard: ${e.message}`);
178
+ }
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();
171
200
 
172
201
  // ---------------------------------------------------------------------------
173
202
  // git pre-push hook
@@ -199,20 +228,26 @@ function pagesUrl() {
199
228
  // An address already configured wins: it was either set deliberately or worked
200
229
  // out here before, and it survives the project being renamed.
201
230
  function configured() {
202
- try {
203
- const f = join(root, '.nearly.json');
204
- if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).urlBase || null;
205
- } catch { /* fall through */ }
231
+ // The legacy path is read, never written: an upgrade destroys it, so the
232
+ // first run after this change is the last chance to carry it forward.
233
+ for (const f of [paths.config(), join(root, '.nearly.json')]) {
234
+ try {
235
+ if (existsSync(f)) {
236
+ const u = JSON.parse(readFileSync(f, 'utf8')).urlBase;
237
+ if (u) return u;
238
+ }
239
+ } catch { /* try the next one */ }
240
+ }
206
241
  return null;
207
242
  }
208
243
  const derived = pagesUrl();
209
244
  const base = process.env.NEARLY_URL_BASE || configured() || derived;
210
245
  if (base && !off) {
211
246
  try {
212
- const cfg = join(root, '.nearly.json');
247
+ const cfg = paths.config();
213
248
  const prev = existsSync(cfg) ? JSON.parse(readFileSync(cfg, 'utf8')) : {};
214
249
  writeFileSync(cfg, JSON.stringify({ ...prev, urlBase: base }, null, 2) + '\n');
215
- } catch { /* the env var still works */ }
250
+ } catch (e) { notes.push(`could not save where records publish: ${e.message}`); }
216
251
  }
217
252
 
218
253
  // ---------------------------------------------------------------------------
@@ -246,6 +281,25 @@ if (base) {
246
281
  }
247
282
  for (const n of notes) console.log(` ${dim('·')} ${dim(n)}`);
248
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
+
249
303
  // An agent you have on this machine but have not used here is worth a word, and
250
304
  // nothing more: having it installed is no reason to write files into this repo.
251
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
@@ -87,7 +110,8 @@ if (adapter && adapter.normalize) {
87
110
  }
88
111
 
89
112
  try {
90
- const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}`, {
113
+ const hold = adapter?.holdMs ? `&hold=${adapter.holdMs}` : '';
114
+ const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}`, {
91
115
  method: 'POST',
92
116
  headers: { 'content-type': 'application/json' },
93
117
  body: payload,
@@ -15,10 +15,14 @@ import { paths } from '../server/paths.mjs';
15
15
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
16
16
  const repo = resolve(process.argv[2] || '.');
17
17
  function configured() {
18
- try {
19
- const f = join(root, '.nearly.json');
20
- if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).urlBase || '';
21
- } catch { /* fall through to the env var */ }
18
+ for (const f of [paths.config(), join(root, '.nearly.json')]) {
19
+ try {
20
+ if (existsSync(f)) {
21
+ const u = JSON.parse(readFileSync(f, 'utf8')).urlBase;
22
+ if (u) return u;
23
+ }
24
+ } catch { /* try the next one */ }
25
+ }
22
26
  return '';
23
27
  }
24
28
  const URL_BASE = (process.env.NEARLY_URL_BASE || configured() || '').replace(/\/$/, '');
@@ -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 };
@@ -534,6 +544,13 @@ export const ADAPTERS = [
534
544
  name: 'Windsurf',
535
545
  verified: null,
536
546
  config: '.windsurf/hooks.json',
547
+ // Windsurf is the only harness with no way to say how long a hook may take,
548
+ // and an abandoned pre-hook does not block — Cascade treats anything but
549
+ // exit 2 as "proceed". So a request held past whatever Cascade's own limit
550
+ // is would be allowed, by Cascade, silently. Decide well inside any
551
+ // plausible limit instead: a deny we issue is recorded and explains itself,
552
+ // where a timeout we lose is an allow nobody chose.
553
+ holdMs: 20_000,
537
554
  // The odd one out twice over. Windsurf has no JSON answer at all — a pre
538
555
  // hook blocks by exiting 2 with the reason on stderr — and it has no single
539
556
  // pre-tool event, so the gate is spread across three.
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';
@@ -67,7 +80,7 @@ function summary(s) {
67
80
  };
68
81
  }
69
82
  function pendingView(p) {
70
- return { id: p.id, session: p.sid, tool: p.tool, input: p.input, tier: p.tier, reason: p.reason, key: p.key, at: p.at };
83
+ return { id: p.id, session: p.sid, tool: p.tool, input: p.input, tier: p.tier, reason: p.reason, key: p.key, at: p.at, holdMs: p.holdMs };
71
84
  }
72
85
 
73
86
  function hooksSettings(sid) {
@@ -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
 
@@ -419,8 +435,14 @@ const server = http.createServer(async (req, res) => {
419
435
  if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier }); return respond('deny', `never (${reason})`); }
420
436
  if (tier === 'log') { record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier }); return respond('allow', `do and log (${reason})`); }
421
437
  // ask: hold the response until the UI decides, or fail closed
422
- const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), respond };
423
- item.timer = setTimeout(() => decide(sid, id, 'deny', 'no human answer; nearly fails closed'), ASK_TIMEOUT_MS);
438
+ // A harness may say it will not wait as long as we would. It can shorten
439
+ // the deadline, never lengthen it: the point of the cap is that nobody
440
+ // else gets to decide by not answering.
441
+ const asked = Number(url.searchParams.get('hold')) || 0;
442
+ const holdMs = asked > 0 ? Math.min(asked, ASK_TIMEOUT_MS) : ASK_TIMEOUT_MS;
443
+ const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, respond };
444
+ item.timer = setTimeout(() => decide(sid, id, 'deny',
445
+ `no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
424
446
  s.pending.set(id, item);
425
447
  s.state = 'waiting';
426
448
  record(sid, { type: 'ask', ...pendingView(item) });
@@ -464,18 +486,40 @@ const server = http.createServer(async (req, res) => {
464
486
 
465
487
  // ---- UI API ----
466
488
  // Cheap liveness check: the hook launcher calls this before every tool call.
467
- 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
+ }
468
503
  if (req.method === 'GET' && url.pathname === '/') {
469
504
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
470
505
  return res.end(fs.readFileSync(UI));
471
506
  }
472
- // Static: built recaps and the replay out of ui/, plus a local preview of the
473
- // 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.
474
516
  if (req.method === 'GET' && (url.pathname.startsWith('/records/') || url.pathname === '/replay.html' || url.pathname === '/docs' || url.pathname.startsWith('/docs/'))) {
475
- const docs = url.pathname === '/docs' || url.pathname.startsWith('/docs/');
476
- const base = path.join(ROOT, docs ? 'docs' : 'ui');
477
- let rel = url.pathname.slice(1).split('/').filter((p) => p && p !== '..').join('/');
478
- 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';
479
523
  const file = path.join(base, rel);
480
524
  if (!file.startsWith(base) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return json(res, 404, { error: 'not found' });
481
525
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
@@ -576,6 +620,15 @@ server.on('error', (e) => {
576
620
  process.exit(1);
577
621
  });
578
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
+
579
632
  server.listen(PORT, HOST, () => {
580
633
  console.log(`nearly http://${HOST}:${PORT}`);
581
634
  console.log(`worktrees ${WORKTREES}`);
package/server/paths.mjs CHANGED
@@ -24,6 +24,15 @@ export const dataRoot = fromCheckout
24
24
  ? pkgRoot
25
25
  : join(process.env.NEARLY_HOME || join(homedir(), '.nearly'));
26
26
 
27
+ // A file, with its directory guaranteed to exist — including when the path came
28
+ // from an environment variable, which is where this went wrong the first time:
29
+ // the default path was fixed and the override was left to fail on its own.
30
+ function dataFile(envVar, name) {
31
+ const f = process.env[envVar] || join(dataRoot, name);
32
+ try { mkdirSync(dirname(f), { recursive: true }); } catch { /* caller will report */ }
33
+ return f;
34
+ }
35
+
27
36
  export function dataDir(...parts) {
28
37
  const p = join(dataRoot, ...parts);
29
38
  try { mkdirSync(p, { recursive: true }); } catch { /* caller will report */ }
@@ -44,5 +53,11 @@ export const paths = {
44
53
  // you work in before any session has run in it — otherwise the only repos it
45
54
  // can offer are ones that are already going, which is no help when you are
46
55
  // trying to start the first one.
47
- repos: () => process.env.NEARLY_REPOS || join(dataRoot, 'repos.json'),
56
+ repos: () => dataFile('NEARLY_REPOS', 'repos.json'),
57
+ // Where records are published, so a pull-request comment can link them. This
58
+ // lived in the package directory, which `npm install -g` replaces wholesale:
59
+ // the address was quietly lost on every upgrade and the next record went out
60
+ // with no link. Same lesson as recordings — anything a person configured
61
+ // belongs in their space, not in ours.
62
+ config: () => dataFile('NEARLY_CONFIG', 'config.json'),
48
63
  };
@@ -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
+ }
package/ui/index.html CHANGED
@@ -121,6 +121,7 @@
121
121
 
122
122
  /* ---------- fleet ---------- */
123
123
  .new { margin: 0 12px 14px; display: grid; gap: 7px; }
124
+ .new[hidden] { display: none; }
124
125
  .formErr { margin: 0; font-size: 12px; line-height: 1.45; color: var(--deny); }
125
126
  .fleet { padding: 0 12px 18px; display: grid; gap: 8px; }
126
127
  .agent {
@@ -239,7 +240,7 @@
239
240
  <main>
240
241
  <div class="col">
241
242
  <div class="col-h"><span class="lbl">Agents</span><span class="count" id="fleetCount">0</span></div>
242
- <form class="new" id="newForm">
243
+ <form class="new" id="newForm" hidden>
243
244
  <input name="name" placeholder="Name, e.g. docs" required autocomplete="off">
244
245
  <input name="repo" id="repoField" placeholder="Repo to branch from" list="repoList" autocomplete="off">
245
246
  <datalist id="repoList"></datalist>
@@ -264,6 +265,10 @@
264
265
  </main>
265
266
 
266
267
  <script>
268
+ // Lab mode — starting agents from the dashboard — is a different job from
269
+ // watching your own sessions be gated. It is the demo, not the product, so it
270
+ // is off unless asked for.
271
+ const LAB = new URLSearchParams(location.search).has('lab');
267
272
  const S = { sessions: new Map(), rules: {}, defaults: {}, askTimeoutMs: 120000 };
268
273
  const $ = (id) => document.getElementById(id);
269
274
  const fmtT = (ms) => new Date(ms).toLocaleTimeString([], { hour12: false });
@@ -317,7 +322,19 @@
317
322
  </div>`;
318
323
  el.appendChild(d);
319
324
  }
320
- if (!S.sessions.size) el.innerHTML = '<div class="empty">No agents yet. Start one above and it runs on your Claude subscription, in its own git worktree.</div>';
325
+ if (!S.sessions.size) {
326
+ // What a person who just installed this is actually waiting for is their
327
+ // own next session, not a button. Saying otherwise taught the wrong
328
+ // product on the first screen anybody sees.
329
+ el.innerHTML = LAB
330
+ ? '<div class="empty">No agents yet. Start one above and it runs on your Claude subscription, on its own branch.</div>'
331
+ : `<div class="empty"><b>No sessions yet.</b>
332
+ Work as you normally would. Every session you run in a repo you have turned
333
+ Nearly on for appears here, and anything that needs you shows up alongside.
334
+ <div class="keys"><span><kbd>nearly agents</kbd><span>what is gated in a repo</span></span>
335
+ <span><kbd>nearly lab</kbd><span>start an agent from here instead</span></span></div>
336
+ </div>`;
337
+ }
321
338
  }
322
339
 
323
340
  function renderAsks() {
@@ -353,7 +370,7 @@
353
370
  <span class="tool">${esc(p.tool)}</span>
354
371
  <span class="who">${esc(p.sname)}</span>
355
372
  <span class="tag" title="Always and Never attach to this key">${esc(p.key || p.tool)}</span>
356
- <span class="clock" data-wait="${p.at}"></span>
373
+ <span class="clock" data-wait="${p.at}" data-limit="${p.holdMs || ''}"></span>
357
374
  </div>
358
375
  <pre>${esc(prettyInput(p.tool, p.input))}</pre>
359
376
  <div class="blast"><span class="ic">▲</span><span>${blast(p.tool)}</span></div>
@@ -363,7 +380,7 @@
363
380
  <button data-v="deny" data-decide="deny" data-scope="once">Deny<kbd>D</kbd></button>
364
381
  <button data-v="deny" data-decide="deny" data-scope="always" title="Never allow ${esc(p.key || p.tool)} again this run">Never<kbd>⇧D</kbd></button>
365
382
  </div>
366
- <div class="deadline" data-wait-bar="${p.at}">
383
+ <div class="deadline" data-wait-bar="${p.at}" data-limit="${p.holdMs || ''}">
367
384
  <div class="bar"><i></i></div>
368
385
  <div class="cap"></div>
369
386
  </div>
@@ -455,6 +472,8 @@
455
472
  }
456
473
  connect();
457
474
 
475
+ if (LAB) $('newForm').hidden = false;
476
+
458
477
  $('newForm').onsubmit = async (e) => {
459
478
  e.preventDefault();
460
479
  const f = new FormData(e.target);
@@ -518,13 +537,17 @@
518
537
 
519
538
  // One ticker for every countdown on the page.
520
539
  function tickWaits() {
521
- const limit = S.askTimeoutMs;
540
+ // Each request carries its own deadline: some harnesses will not wait as
541
+ // long as the rest, and a bar that drains at the wrong rate is worse than
542
+ // no bar.
522
543
  document.querySelectorAll('[data-wait]').forEach((el) => {
544
+ const limit = +el.dataset.limit || S.askTimeoutMs;
523
545
  const held = Date.now() - +el.dataset.wait;
524
546
  el.textContent = `held ${Math.round(held / 1000)}s`;
525
547
  el.dataset.urgent = held > limit * 0.75 ? '2' : held > limit * 0.4 ? '1' : '0';
526
548
  });
527
549
  document.querySelectorAll('[data-wait-bar]').forEach((el) => {
550
+ const limit = +el.dataset.limit || S.askTimeoutMs;
528
551
  const held = Date.now() - +el.dataset.waitBar;
529
552
  const left = Math.max(0, limit - held);
530
553
  const frac = Math.max(0, Math.min(1, left / limit));