nearly-cli 0.1.7 → 0.1.10
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 +46 -2
- package/bin/nearly.mjs +5 -0
- package/package.json +2 -2
- package/scripts/attach.mjs +41 -1
- package/scripts/doctor.mjs +165 -0
- package/scripts/hook.mjs +27 -4
- package/scripts/push-record.mjs +17 -4
- package/server/adapters.mjs +15 -5
- package/server/index.mjs +54 -7
- package/server/reclaim.mjs +104 -0
package/README.md
CHANGED
|
@@ -22,7 +22,11 @@ Watch the first thirty seconds. The cover says what the diff cannot: an action t
|
|
|
22
22
|
|
|
23
23
|
## What it needs
|
|
24
24
|
|
|
25
|
-
Node 18 or newer, git, and one of the seven coding agents below signed in.
|
|
25
|
+
Node 18 or newer, git, and one of the seven coding agents below signed in.
|
|
26
|
+
Posting the record to a pull request also needs the [GitHub CLI](https://cli.github.com)
|
|
27
|
+
signed in (`gh auth login`); everything else works without it. If nothing is
|
|
28
|
+
showing up, `nearly doctor` walks the whole chain — gate, recording, record,
|
|
29
|
+
pull request, link — and names what is in the way. No dependencies and no API key of its own: agents run on whatever subscription you already have.
|
|
26
30
|
|
|
27
31
|
### Platforms
|
|
28
32
|
|
|
@@ -61,11 +65,16 @@ the hook. Seven are supported:
|
|
|
61
65
|
| Claude Code | `.claude/settings.local.json` | yes | full |
|
|
62
66
|
| Cursor | `.cursor/hooks.json` | yes | full |
|
|
63
67
|
| Antigravity | `.agents/hooks.json` | yes | full |
|
|
64
|
-
| GitHub Copilot CLI | `.github/hooks/nearly.json` | yes | full |
|
|
68
|
+
| GitHub Copilot (CLI **and VS Code agent mode**) | `.github/hooks/nearly.json` | yes | full |
|
|
65
69
|
| Gemini CLI | `.gemini/settings.json` | yes | full |
|
|
66
70
|
| Codex CLI | `.codex/hooks.json` | yes | no prompts, one turn |
|
|
67
71
|
| Windsurf | `.windsurf/hooks.json` | until Cascade gives up | shell and file tools only |
|
|
68
72
|
|
|
73
|
+
VS Code agent mode loads every `.json` in `.github/hooks/` with no further
|
|
74
|
+
setup, so `nearly --agent=copilot` is the whole of it there — and it is the only
|
|
75
|
+
route for someone on Windows who does not have Claude Code, since Cline's hooks
|
|
76
|
+
are macOS and Linux only and Windsurf's cannot be given a deadline.
|
|
77
|
+
|
|
69
78
|
`nearly` turns on whichever of these the repo shows signs of, and Claude Code
|
|
70
79
|
either way. `nearly --agent=cursor` forces one, `--agent=all` forces all of them,
|
|
71
80
|
and `nearly agents` prints what is actually wired here.
|
|
@@ -113,6 +122,11 @@ Zed's built-in agent, Aider, Kilo Code, Warp and the hosted builders (Replit,
|
|
|
113
122
|
Lovable, Bolt, v0) expose no blocking pre-tool hook. There is nothing to attach
|
|
114
123
|
to, and no adapter can change that.
|
|
115
124
|
|
|
125
|
+
**Cline** has one, and it is macOS and Linux only — so on Windows there is
|
|
126
|
+
nothing to attach to there either. Cline does keep its own consent trail in
|
|
127
|
+
`ui_messages.json` under its task history, which Nearly could read after the
|
|
128
|
+
fact; that would be a reader rather than a gate, and it does not exist yet.
|
|
129
|
+
|
|
116
130
|
## Use it on your own repo
|
|
117
131
|
|
|
118
132
|
One command, in the repo you want recorded.
|
|
@@ -317,6 +331,36 @@ node scripts/publish-pages.mjs --base https://<user>.github.io/<repo>
|
|
|
317
331
|
|
|
318
332
|
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
333
|
|
|
334
|
+
## The server, and why you never start it
|
|
335
|
+
|
|
336
|
+
The first hook that needs the server starts it, and it stays up for the rest of
|
|
337
|
+
the day rather than paying the startup cost on every tool call. Two consequences
|
|
338
|
+
had to be designed for, because both were found the hard way on other people's
|
|
339
|
+
machines.
|
|
340
|
+
|
|
341
|
+
A server outlives the run that started it, so one `npx nearly-cli` — or any
|
|
342
|
+
upgrade — can leave the previous build holding the port. It keeps answering,
|
|
343
|
+
from a directory npm has since replaced, which is why its record pages 404 and
|
|
344
|
+
why upgrading appears to do nothing at all. Every server now says which build it
|
|
345
|
+
is and where it lives, and a hook from a different install takes the port back
|
|
346
|
+
before doing anything else — without being asked, because nobody reads a hook's
|
|
347
|
+
output, and a fix only the people who happen to re-read a message ever get is
|
|
348
|
+
not a fix.
|
|
349
|
+
|
|
350
|
+
Builds from 0.1.8 stand down when asked. Older ones have no way to be asked, so
|
|
351
|
+
an idle one is ended outright. Two rules make that defensible. It must prove it
|
|
352
|
+
is ours twice, on `/health` and on `/state` — the second carries the consent
|
|
353
|
+
gradient itself, which nothing else on your machine is going to return by
|
|
354
|
+
chance, so a plain web server that happens to sit on 47653 is never touched. And
|
|
355
|
+
it is never ended while anybody is using it: dropping a held request would hand
|
|
356
|
+
it back to the agent's own prompt, which is the one outcome this project exists
|
|
357
|
+
to prevent. If a server cannot be ended, `nearly` says so and gives you the
|
|
358
|
+
command for your platform rather than leaving you to work it out.
|
|
359
|
+
|
|
360
|
+
And a server with no sessions that nobody has asked anything of for thirty
|
|
361
|
+
minutes exits on its own. There is nothing to remember to shut down, and nothing
|
|
362
|
+
squats on a port for days.
|
|
363
|
+
|
|
320
364
|
## Why the hook fails open
|
|
321
365
|
|
|
322
366
|
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/bin/nearly.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// nearly record build the record for the current branch
|
|
9
9
|
// nearly post put that record on the pull request
|
|
10
10
|
// nearly agents which agents this repo is gated for
|
|
11
|
+
// nearly doctor why nothing is showing up
|
|
11
12
|
// nearly voices list the narration voices you have
|
|
12
13
|
// nearly server run the server in the foreground (it self-starts otherwise)
|
|
13
14
|
// nearly hook <ev> internal: what the Claude Code hooks call
|
|
@@ -78,6 +79,9 @@ switch (cmd) {
|
|
|
78
79
|
case 'agents':
|
|
79
80
|
return run(s('agents.mjs'), rest);
|
|
80
81
|
|
|
82
|
+
case 'doctor': case 'why':
|
|
83
|
+
return run(s('doctor.mjs'), rest);
|
|
84
|
+
|
|
81
85
|
case 'voices':
|
|
82
86
|
return run(s('build-recap.mjs'), ['--voices']);
|
|
83
87
|
|
|
@@ -106,6 +110,7 @@ switch (cmd) {
|
|
|
106
110
|
nearly record build the record for the current branch
|
|
107
111
|
nearly post put that record on the pull request
|
|
108
112
|
nearly agents which agents this repo is gated for
|
|
113
|
+
nearly doctor why nothing is showing up
|
|
109
114
|
nearly voices list the narration voices you have
|
|
110
115
|
nearly server run the server in the foreground
|
|
111
116
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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));
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Why is nothing showing up?
|
|
2
|
+
//
|
|
3
|
+
// nearly doctor
|
|
4
|
+
//
|
|
5
|
+
// Everything this project does is a chain: gate the session, record it, build
|
|
6
|
+
// the record, find the pull request, post the link. A break anywhere means
|
|
7
|
+
// nothing appears, and every step is quiet by design — hooks must never
|
|
8
|
+
// interrupt an agent, and a push must never fail over a recap. Quiet is right
|
|
9
|
+
// and it is also how somebody ends up staring at an empty pull request with no
|
|
10
|
+
// idea which link came apart.
|
|
11
|
+
//
|
|
12
|
+
// So this walks the chain in order and stops being polite about it.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { join, resolve, basename, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
18
|
+
import { paths, dataRoot } from '../server/paths.mjs';
|
|
19
|
+
import { ADAPTERS } from '../server/adapters.mjs';
|
|
20
|
+
|
|
21
|
+
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
22
|
+
const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
|
|
23
|
+
const PORT = Number(process.env.NEARLY_PORT || 47653);
|
|
24
|
+
|
|
25
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
26
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
27
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
28
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
29
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
30
|
+
|
|
31
|
+
const blockers = [];
|
|
32
|
+
function say(ok, label, detail, fix) {
|
|
33
|
+
const mark = ok === true ? green('✓') : ok === false ? red('✗') : yellow('!');
|
|
34
|
+
console.log(` ${mark} ${label}${detail ? dim(` ${detail}`) : ''}`);
|
|
35
|
+
if (ok === false && fix) blockers.push(fix);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const git = (args) => {
|
|
39
|
+
try { return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
|
|
40
|
+
catch { return null; }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log(` ${bold('Nearly')} ${dim(repo)}`);
|
|
45
|
+
console.log('');
|
|
46
|
+
|
|
47
|
+
// 1 — a repo at all
|
|
48
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
49
|
+
if (!existsSync(join(repo, '.git'))) {
|
|
50
|
+
say(false, 'a git repository', 'this is not one', `cd into the repo you work in, then run nearly`);
|
|
51
|
+
} else {
|
|
52
|
+
say(true, 'a git repository', `on ${branch}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2 — which agents are gated here. An agent Nearly cannot see records nothing,
|
|
56
|
+
// and that is the single most common reason for an empty pull request.
|
|
57
|
+
const gated = ADAPTERS.filter((a) => {
|
|
58
|
+
const f = join(repo, a.config);
|
|
59
|
+
try { return existsSync(f) && /nearly/i.test(readFileSync(f, 'utf8')); } catch { return false; }
|
|
60
|
+
});
|
|
61
|
+
if (gated.length) say(true, 'agents gated here', gated.map((a) => a.name).join(', '));
|
|
62
|
+
else say(false, 'agents gated here', 'none', 'run `nearly` in this repo to turn it on');
|
|
63
|
+
|
|
64
|
+
// 3 — the server, and whether it is this build
|
|
65
|
+
let health = null;
|
|
66
|
+
try {
|
|
67
|
+
const r = await fetch(`http://127.0.0.1:${PORT}/health`, { signal: AbortSignal.timeout(900) });
|
|
68
|
+
if (r.ok) health = await r.json();
|
|
69
|
+
} catch { /* not running is normal: hooks start it */ }
|
|
70
|
+
if (!health) {
|
|
71
|
+
say(null, 'server', 'not running — a hook starts it when one fires');
|
|
72
|
+
} else {
|
|
73
|
+
let mine = root;
|
|
74
|
+
try { mine = realpathSync(root); } catch { /* compare literally */ }
|
|
75
|
+
const same = health.root === mine;
|
|
76
|
+
say(same ? true : false, 'server', same ? `v${health.version}` : `a different install is answering: ${health.root || 'an older build'}`,
|
|
77
|
+
'run `nearly` here — it closes an older server that is holding the port');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4 — recordings for this branch, matched the way the record builder matches
|
|
81
|
+
// them, so this cannot disagree with it.
|
|
82
|
+
const recDir = paths.recordings();
|
|
83
|
+
let runs = 0, otherBranches = new Set();
|
|
84
|
+
const real = (p) => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
|
|
85
|
+
try {
|
|
86
|
+
for (const f of readdirSync(recDir).filter((f) => f.endsWith('.jsonl'))) {
|
|
87
|
+
let created = null;
|
|
88
|
+
for (const line of readFileSync(join(recDir, f), 'utf8').split('\n')) {
|
|
89
|
+
if (!line.trim()) continue;
|
|
90
|
+
try {
|
|
91
|
+
const e = JSON.parse(line);
|
|
92
|
+
if (e.type === 'session' && e.subtype === 'created') { created = e; break; }
|
|
93
|
+
} catch { /* a torn line */ }
|
|
94
|
+
}
|
|
95
|
+
if (!created) continue;
|
|
96
|
+
if (created.worktree && real(created.worktree) !== real(repo)) continue;
|
|
97
|
+
if (created.branch === branch) runs += 1;
|
|
98
|
+
else if (created.branch) otherBranches.add(created.branch);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* no recordings directory yet */ }
|
|
101
|
+
|
|
102
|
+
if (runs) {
|
|
103
|
+
say(true, `sessions recorded on ${branch}`, `${runs}`);
|
|
104
|
+
} else {
|
|
105
|
+
say(false, `sessions recorded on ${branch}`, 'none',
|
|
106
|
+
otherBranches.size
|
|
107
|
+
? `this branch has no recorded sessions. Others do: ${[...otherBranches].join(', ')}. A record only exists for work an agent Nearly gates actually did.`
|
|
108
|
+
: `nothing here has been recorded yet. Nearly only sees the agents it gates — run \`nearly agents\` to see which those are.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 5 — the hook that offers the record at push time
|
|
112
|
+
const pushHook = join(repo, '.git', 'hooks', 'pre-push');
|
|
113
|
+
const hasPush = existsSync(pushHook) && /x-session-record-hook|nearly/i.test((() => {
|
|
114
|
+
try { return readFileSync(pushHook, 'utf8'); } catch { return ''; }
|
|
115
|
+
})());
|
|
116
|
+
say(hasPush, 'pre-push hook', hasPush ? 'installed' : 'missing', 'run `nearly` here to install it');
|
|
117
|
+
|
|
118
|
+
// 6 — gh, which is how a pull request is found and commented on. Its absence
|
|
119
|
+
// used to be reported as "no pull request yet", which sent people off to raise
|
|
120
|
+
// one they already had.
|
|
121
|
+
const ghOk = spawnSync('gh', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
122
|
+
if (!ghOk) {
|
|
123
|
+
say(false, 'GitHub CLI (gh)', 'not on PATH',
|
|
124
|
+
'install it from cli.github.com and run `gh auth login` — without it the record cannot be posted to a pull request');
|
|
125
|
+
} else {
|
|
126
|
+
const auth = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8' }).status === 0;
|
|
127
|
+
say(auth, 'GitHub CLI (gh)', auth ? 'installed and signed in' : 'installed but not signed in', 'run `gh auth login`');
|
|
128
|
+
if (auth) {
|
|
129
|
+
const pr = spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' });
|
|
130
|
+
if (pr.status === 0) {
|
|
131
|
+
let url = '';
|
|
132
|
+
try { url = JSON.parse(pr.stdout).url; } catch { /* keep it blank */ }
|
|
133
|
+
say(true, 'open pull request', url);
|
|
134
|
+
} else {
|
|
135
|
+
say(null, 'open pull request', `none for ${branch} — raise one, then push again`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 7 — where a posted record would point
|
|
141
|
+
const urlBase = (() => {
|
|
142
|
+
if (process.env.NEARLY_URL_BASE) return process.env.NEARLY_URL_BASE;
|
|
143
|
+
for (const f of [paths.config(), join(root, '.nearly.json')]) {
|
|
144
|
+
try { if (existsSync(f)) { const u = JSON.parse(readFileSync(f, 'utf8')).urlBase; if (u) return u; } }
|
|
145
|
+
catch { /* try the next */ }
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
})();
|
|
149
|
+
say(urlBase ? true : null, 'somewhere to publish records',
|
|
150
|
+
urlBase || `kept in ${dataRoot.replace(process.env.HOME || '~', '~')}, so a comment would have no link to give`);
|
|
151
|
+
|
|
152
|
+
// 8 — whether a record for this branch exists right now
|
|
153
|
+
const slug = `${String(basename(repo)).replace(/[^a-z0-9._-]+/gi, '-').toLowerCase()}--${String(branch).replace(/[^a-z0-9._-]+/gi, '-').toLowerCase()}`;
|
|
154
|
+
const built = join(paths.records(), `${slug}.json`);
|
|
155
|
+
say(existsSync(built) ? true : null, 'record built for this branch',
|
|
156
|
+
existsSync(built) ? built : 'not yet — it is built at push time, or by `nearly record`');
|
|
157
|
+
|
|
158
|
+
console.log('');
|
|
159
|
+
if (!blockers.length) {
|
|
160
|
+
console.log(` ${green('Nothing is in the way.')} ${dim('Work, push, and the record is offered.')}`);
|
|
161
|
+
} else {
|
|
162
|
+
console.log(` ${bold(blockers.length === 1 ? 'One thing is in the way:' : `${blockers.length} things are in the way:`)}`);
|
|
163
|
+
for (const b of blockers) console.log(` · ${b}`);
|
|
164
|
+
}
|
|
165
|
+
console.log('');
|
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/scripts/push-record.mjs
CHANGED
|
@@ -94,11 +94,24 @@ try {
|
|
|
94
94
|
applyUpdate(await checkForUpdate());
|
|
95
95
|
} catch { /* never worth failing a push over */ }
|
|
96
96
|
|
|
97
|
-
//
|
|
97
|
+
// Finding the pull request needs gh. Not having it and not having a pull
|
|
98
|
+
// request are different problems with the same silence, and they were reported
|
|
99
|
+
// as the same sentence — which sent people off to raise a pull request they
|
|
100
|
+
// were already looking at.
|
|
98
101
|
const hasGh = spawnSync('gh', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
console.log(dim('
|
|
102
|
+
if (!hasGh) {
|
|
103
|
+
console.log(dim(' The record is built, but posting it to a pull request needs the GitHub CLI,'));
|
|
104
|
+
console.log(dim(' and `gh` is not on PATH. Install it from cli.github.com, then `gh auth login`.'));
|
|
105
|
+
console.log(dim(' `nearly doctor` checks the whole chain.'));
|
|
106
|
+
console.log('');
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
const pr = spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' });
|
|
110
|
+
if (pr.status !== 0) {
|
|
111
|
+
const why = /not logged|authentication|gh auth/i.test(pr.stderr || '')
|
|
112
|
+
? 'gh is installed but not signed in. Run `gh auth login`, then push again.'
|
|
113
|
+
: 'No open pull request for this branch yet. Raise one, then push again to attach the record.';
|
|
114
|
+
console.log(dim(` ${why}`));
|
|
102
115
|
console.log('');
|
|
103
116
|
process.exit(0);
|
|
104
117
|
}
|
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
|
+
}
|