nearly-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ // Keeping people current, without ever being the reason something broke.
2
+ //
3
+ // Nearly decides whether `rm -rf` runs. That makes replacing its own code a
4
+ // different act from updating a linter, and the design follows from that:
5
+ //
6
+ // 1. Never in the hook path. Nothing here may sit in front of an action an
7
+ // agent is waiting on. Only commands a person typed reach this file.
8
+ // 2. Never across a major version. Same major, same promises: a gate whose
9
+ // rules changed should be read before it is trusted, so a major bump is
10
+ // announced and left for the person to do.
11
+ // 3. Never silent about itself. An update that happened without being
12
+ // mentioned is indistinguishable from a compromise.
13
+ // 4. Never fatal. No network, a locked global directory, a slow registry:
14
+ // you keep the version you have and lose nothing.
15
+ //
16
+ // Off with NEARLY_NO_UPDATE=1.
17
+
18
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { homedir, tmpdir } from 'node:os';
22
+ import { spawnSync, execFileSync } from 'node:child_process';
23
+
24
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
25
+ const DAY = 24 * 60 * 60 * 1000;
26
+
27
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
28
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
29
+
30
+ function stampPath() {
31
+ const dir = process.env.XDG_CACHE_HOME || join(homedir() || tmpdir(), '.cache');
32
+ return join(dir, 'nearly', 'last-update-check');
33
+ }
34
+
35
+ const parts = (v) => String(v).split('-')[0].split('.').map(Number);
36
+ function compare(a, b) {
37
+ const [x, y] = [parts(a), parts(b)];
38
+ if (x.some(Number.isNaN) || y.some(Number.isNaN)) return null;
39
+ for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0) ? 1 : -1;
40
+ return 0;
41
+ }
42
+
43
+ // Updating in place only makes sense for a global install. An npx run is
44
+ // ephemeral and a checkout belongs to whoever cloned it.
45
+ function installKind() {
46
+ if (/[\\/]_npx[\\/]/.test(root)) return 'npx';
47
+ try {
48
+ const bin = execFileSync(process.platform === 'win32' ? 'where' : 'which', ['nearly'],
49
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n')[0];
50
+ if (bin && realpathSync(bin).includes(`${root}/bin/`)) {
51
+ return existsSync(join(root, '.git')) ? 'clone' : 'global';
52
+ }
53
+ } catch { /* fall through */ }
54
+ return existsSync(join(root, '.git')) ? 'clone' : 'global';
55
+ }
56
+
57
+ export async function checkForUpdate() {
58
+ if (process.env.NEARLY_NO_UPDATE === '1' || !process.stdout.isTTY) return null;
59
+ let pkg;
60
+ try { pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return null; }
61
+
62
+ const stamp = stampPath();
63
+ try {
64
+ if (existsSync(stamp) && Date.now() - Number(readFileSync(stamp, 'utf8')) < DAY) return null;
65
+ } catch { /* unreadable: check anyway */ }
66
+
67
+ try {
68
+ const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
69
+ signal: AbortSignal.timeout(1500),
70
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
71
+ });
72
+ if (!res.ok) return null;
73
+ const { version } = await res.json();
74
+ try { mkdirSync(dirname(stamp), { recursive: true }); writeFileSync(stamp, String(Date.now())); } catch { /* fine */ }
75
+ if (compare(version, pkg.version) !== 1) return null;
76
+ return {
77
+ name: pkg.name, from: pkg.version, to: version,
78
+ major: parts(version)[0] !== parts(pkg.version)[0],
79
+ kind: installKind(),
80
+ };
81
+ } catch { return null; }
82
+ }
83
+
84
+ export function applyUpdate(u) {
85
+ if (!u) return;
86
+
87
+ if (u.major) {
88
+ console.log('');
89
+ console.log(` ${bold(`Nearly ${u.to} is out`)} ${dim(`(you have ${u.from})`)}`);
90
+ console.log(dim(' A major version, so the rules this gate enforces may have changed.'));
91
+ console.log(dim(` Read what changed, then: npm install -g ${u.name}@latest`));
92
+ return;
93
+ }
94
+
95
+ if (u.kind !== 'global') {
96
+ console.log('');
97
+ console.log(` ${bold(`Nearly ${u.to} is out`)} ${dim(`(you have ${u.from})`)}`);
98
+ console.log(dim(u.kind === 'clone' ? ' You are running from a checkout: git pull' : ` npm install -g ${u.name}@latest`));
99
+ return;
100
+ }
101
+
102
+ process.stdout.write(dim(` Updating Nearly ${u.from} → ${u.to}… `));
103
+ const r = spawnSync('npm', ['install', '-g', `${u.name}@${u.to}`, '--silent', '--no-fund', '--no-audit'],
104
+ { encoding: 'utf8', timeout: 120_000 });
105
+
106
+ if (r.status === 0) {
107
+ console.log('done');
108
+ console.log(dim(' Every repo you turned it on for is now on the new version.'));
109
+ } else {
110
+ // Usually a global directory this user cannot write to. Say so rather than
111
+ // leaving them stale while believing they are current.
112
+ console.log('could not');
113
+ console.log(dim(` Run it yourself: npm install -g ${u.name}@latest`));
114
+ }
115
+ console.log('');
116
+ }
@@ -0,0 +1,538 @@
1
+ // Agent Nearly — spike server.
2
+ // Zero dependencies. Spawns `claude -p` sessions (your Claude subscription, no API key),
3
+ // gates every tool call through an HTTP PreToolUse hook, and streams everything to the UI.
4
+ //
5
+ // node server/index.mjs then open http://127.0.0.1:47653
6
+ //
7
+ import http from 'node:http';
8
+ import { spawn, execFileSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { DEFAULT_TIER, ruleKey, classify as classifyWith } from './policy.mjs';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const PORT = Number(process.env.NEARLY_PORT || 47653);
17
+ const HOST = '127.0.0.1';
18
+ const WORKSPACE = path.join(ROOT, 'workspace');
19
+ const WORKTREES = path.join(WORKSPACE, '.worktrees');
20
+ const RECORDINGS = process.env.NEARLY_RECORDINGS || path.join(ROOT, 'recordings');
21
+ const UI = path.join(ROOT, 'ui', 'index.html');
22
+ const MAX_SESSIONS = 3; // 8 GB machine
23
+ const ASK_TIMEOUT_MS = Number(process.env.NEARLY_ASK_TIMEOUT_MS || 120_000); // UI must answer before this; then we fail CLOSED (deny)
24
+ const HOOK_TIMEOUT_S = 180; // Claude Code's own hook timeout; must be > ASK_TIMEOUT
25
+ const MODEL = 'sonnet';
26
+ const MAX_TURNS = '12';
27
+
28
+ fs.mkdirSync(RECORDINGS, { recursive: true });
29
+ fs.mkdirSync(WORKTREES, { recursive: true });
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Consent gradient. The rules learned during this run: "allow always" and
33
+ // "never" write here, keyed by ruleKey().
34
+ // ---------------------------------------------------------------------------
35
+ const rules = new Map();
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Sessions
39
+ // ---------------------------------------------------------------------------
40
+ const sessions = new Map(); // id -> session
41
+ const clients = new Set(); // SSE responses
42
+
43
+ function broadcast(ev) {
44
+ const line = `data: ${JSON.stringify(ev)}\n\n`;
45
+ for (const c of clients) c.write(line);
46
+ }
47
+
48
+ function record(sid, ev) {
49
+ const s = sessions.get(sid);
50
+ const full = { ...ev, session: sid, at: ev.at || Date.now() };
51
+ if (s) {
52
+ s.events.push(full);
53
+ fs.appendFileSync(path.join(RECORDINGS, `${s.id}.jsonl`), JSON.stringify(full) + '\n');
54
+ }
55
+ broadcast(full);
56
+ }
57
+
58
+ function summary(s) {
59
+ return {
60
+ id: s.id, name: s.name, state: s.state, worktree: s.worktree, branch: s.branch, attached: !!s.attached,
61
+ turns: s.turns, lastText: s.lastText, currentTool: s.currentTool, usage: s.usage,
62
+ rateLimit: s.rateLimit, pending: [...s.pending.values()].map(pendingView), startedAt: s.startedAt,
63
+ };
64
+ }
65
+ function pendingView(p) {
66
+ 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 };
67
+ }
68
+
69
+ function hooksSettings(sid) {
70
+ const url = (ev) => `http://${HOST}:${PORT}/hooks/${ev}?s=${sid}`;
71
+ const h = (ev, timeout) => [{ hooks: [{ type: 'http', url: url(ev), timeout }] }];
72
+ return {
73
+ hooks: {
74
+ PreToolUse: h('pre-tool', HOOK_TIMEOUT_S),
75
+ PostToolUse: h('post-tool', 10),
76
+ Stop: h('stop', 20),
77
+ Notification: h('notification', 10),
78
+ SubagentStart: h('subagent-start', 10),
79
+ SubagentStop: h('subagent-stop', 10),
80
+ PreCompact: h('pre-compact', 10),
81
+ },
82
+ };
83
+ }
84
+
85
+ function git(cwd, args) {
86
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
87
+ }
88
+
89
+ function createSession({ name, prompt }) {
90
+ if (sessions.size >= MAX_SESSIONS) throw new Error(`max ${MAX_SESSIONS} sessions on this machine`);
91
+ const id = randomUUID();
92
+ const safe = String(name || 'agent').replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'agent';
93
+ const branch = `cr/${safe}-${id.slice(0, 4)}`;
94
+ const worktree = path.join(WORKTREES, `${safe}-${id.slice(0, 4)}`);
95
+ git(WORKSPACE, ['worktree', 'add', '-B', branch, worktree, 'main']);
96
+
97
+ const settingsPath = path.join(worktree, '.nearly-hooks.json');
98
+ fs.writeFileSync(settingsPath, JSON.stringify(hooksSettings(id)));
99
+
100
+ const args = [
101
+ '-p',
102
+ '--input-format', 'stream-json',
103
+ '--output-format', 'stream-json',
104
+ '--verbose',
105
+ '--include-hook-events',
106
+ '--settings', settingsPath,
107
+ '--permission-mode', 'default',
108
+ '--permission-prompts', 'none',
109
+ '--model', MODEL,
110
+ '--max-turns', MAX_TURNS,
111
+ '--name', safe,
112
+ ];
113
+ const proc = spawn('claude', args, { cwd: worktree, stdio: ['pipe', 'pipe', 'pipe'] });
114
+ // An unhandled spawn error would take the whole server down and every other
115
+ // session with it. The usual cause is Claude Code not being on PATH.
116
+ proc.on('error', (e) => {
117
+ const why = e.code === 'ENOENT'
118
+ ? 'Claude Code is not on PATH. Install it, or check `which claude`.'
119
+ : e.message;
120
+ record(id, { type: 'stderr', text: `could not start the agent: ${why}` });
121
+ s.state = 'exited';
122
+ broadcast({ type: 'session-state', session: id, state: s.state });
123
+ });
124
+
125
+ const s = {
126
+ id, name: safe, branch, worktree, proc, state: 'starting', turns: 0, lastText: '', currentTool: null,
127
+ usage: null, rateLimit: null, pending: new Map(), events: [], startedAt: Date.now(), buf: '',
128
+ };
129
+ sessions.set(id, s);
130
+ record(id, { type: 'session', subtype: 'created', name: safe, branch, worktree, prompt });
131
+
132
+ proc.stderr.on('data', (d) => record(id, { type: 'stderr', text: String(d).slice(0, 2000) }));
133
+ proc.stdout.on('data', (d) => {
134
+ s.buf += d;
135
+ const lines = s.buf.split('\n');
136
+ s.buf = lines.pop();
137
+ for (const l of lines) {
138
+ if (!l.trim()) continue;
139
+ let j;
140
+ try { j = JSON.parse(l); } catch { continue; }
141
+ onStream(s, j);
142
+ }
143
+ });
144
+ proc.on('exit', (code) => {
145
+ s.state = 'exited';
146
+ record(id, { type: 'session', subtype: 'exited', code });
147
+ broadcast({ type: 'session-state', session: id, state: s.state });
148
+ });
149
+
150
+ send(s, prompt);
151
+ return s;
152
+ }
153
+
154
+ // A session started by Claude Code itself (terminal or VS Code) in a repo where
155
+ // scripts/attach.mjs installed our hooks. We do not own the process, so there is
156
+ // no stdin, no auto-commit and no undo; every turn's diff is recorded instead.
157
+ function attachSession({ id, name, cwd }) {
158
+ let branch = null, base = null;
159
+ try {
160
+ branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
161
+ base = git(cwd, ['rev-parse', 'HEAD']); // everything after this is the agent's work
162
+ } catch { /* not a repo */ }
163
+ const s = {
164
+ id, name, branch, base, worktree: cwd, proc: null, attached: true, state: 'working', turns: 0, lastText: '',
165
+ currentTool: null, usage: null, rateLimit: null, pending: new Map(), events: [], startedAt: Date.now(), buf: '',
166
+ };
167
+ sessions.set(id, s);
168
+ record(id, { type: 'session', subtype: 'created', name, branch, worktree: cwd, attached: true });
169
+ return s;
170
+ }
171
+
172
+ // `claude -p` does not fire SessionStart, so an attached headless session never
173
+ // tells us its model. Every hook payload carries transcript_path; read it there.
174
+ function modelFromTranscript(p) {
175
+ if (!p || !fs.existsSync(p)) return null;
176
+ try {
177
+ for (const l of fs.readFileSync(p, 'utf8').split('\n').filter(Boolean)) {
178
+ let j; try { j = JSON.parse(l); } catch { continue; }
179
+ const m = j.message?.model || j.model;
180
+ if (m) return m;
181
+ }
182
+ } catch { /* transcript unreadable; the recap just says "claude" */ }
183
+ return null;
184
+ }
185
+
186
+ function turnDiff(cwd, base, maxLines = 200) {
187
+ const from = base || 'HEAD';
188
+ const numstat = git(cwd, ['diff', '--numstat', from]);
189
+ const stat = numstat.split('\n').filter(Boolean).map((l) => {
190
+ const [add, del, file] = l.split('\t');
191
+ return { file, add: add === '-' ? 0 : +add, del: del === '-' ? 0 : +del };
192
+ });
193
+ const untracked = git(cwd, ['ls-files', '--others', '--exclude-standard']).split('\n').filter((f) => f && f !== '.claude/settings.local.json');
194
+ for (const f of untracked) stat.push({ file: f, add: 0, del: 0, untracked: true });
195
+ const full = git(cwd, ['diff', '--no-color', '--unified=2', from]);
196
+ const lines = full.split('\n');
197
+ return { stat, patch: { text: lines.slice(0, maxLines).join('\n'), truncated: lines.length > maxLines, total: lines.length } };
198
+ }
199
+
200
+ function buildRecap(s, extraArgs = [], cb) {
201
+ const args = [path.join(ROOT, 'scripts', 'build-recap.mjs'), s.id, ...extraArgs];
202
+ const child = spawn(process.execPath, args, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
203
+ let out = '', err = '';
204
+ child.stdout.on('data', (d) => (out += d));
205
+ child.stderr.on('data', (d) => (err += d));
206
+ child.on('exit', (code) => {
207
+ if (code !== 0) return cb(new Error(err.trim().split('\n').at(-1) || `exit ${code}`));
208
+ // The builder prints its own path. Parse the current name, and keep the old
209
+ // one working, because a scraped string is exactly what a rename breaks.
210
+ const built = out.match(/Built ui(\/(?:records|recaps)\/[^\s]+\.html)/);
211
+ const href = built ? built[1] : null;
212
+ record(s.id, { type: 'recap', href, log: out.trim().split('\n')[0] });
213
+ cb(null, href);
214
+ });
215
+ }
216
+
217
+ function send(s, text) {
218
+ const m = { type: 'user', message: { role: 'user', content: text } };
219
+ s.proc.stdin.write(JSON.stringify(m) + '\n');
220
+ s.state = 'working';
221
+ record(s.id, { type: 'prompt', text });
222
+ broadcast({ type: 'session-state', session: s.id, state: s.state });
223
+ }
224
+
225
+ function onStream(s, j) {
226
+ // Keep the recording compact: store what the UI needs, not raw payloads.
227
+ switch (j.type) {
228
+ case 'system':
229
+ if (j.subtype === 'init') {
230
+ s.state = 'working';
231
+ record(s.id, { type: 'init', model: j.model, claudeSession: j.session_id, apiKeySource: j.apiKeySource });
232
+ } else if (j.subtype === 'hook_started' || j.subtype === 'hook_response') {
233
+ // hook traffic is already recorded by our endpoints; skip to reduce noise
234
+ } else if (j.subtype === 'api_error') {
235
+ record(s.id, { type: 'api_error', attempt: j.retryAttempt, max: j.maxRetries, in: j.retryInMs });
236
+ } else {
237
+ record(s.id, { type: 'system', subtype: j.subtype, detail: trim(j) });
238
+ }
239
+ break;
240
+ case 'assistant': {
241
+ const content = j.message?.content || [];
242
+ for (const b of content) {
243
+ if (b.type === 'text' && b.text) { s.lastText = b.text.slice(0, 400); record(s.id, { type: 'text', text: b.text }); }
244
+ if (b.type === 'tool_use') { s.currentTool = b.name; record(s.id, { type: 'tool_use', id: b.id, tool: b.name, input: b.input }); }
245
+ }
246
+ if (j.message?.usage) s.usage = pickUsage(j.message.usage);
247
+ break;
248
+ }
249
+ case 'user': {
250
+ const content = j.message?.content || [];
251
+ for (const b of content) {
252
+ if (b.type === 'tool_result') {
253
+ s.currentTool = null;
254
+ record(s.id, { type: 'tool_result', id: b.tool_use_id, is_error: !!b.is_error, content: String(typeof b.content === 'string' ? b.content : JSON.stringify(b.content)).slice(0, 1500) });
255
+ }
256
+ }
257
+ break;
258
+ }
259
+ case 'rate_limit_event':
260
+ s.rateLimit = j.rate_limit_info?.unifiedWindows || null;
261
+ record(s.id, { type: 'rate_limit', windows: s.rateLimit });
262
+ break;
263
+ case 'result':
264
+ s.turns += 1;
265
+ s.state = 'idle';
266
+ s.currentTool = null;
267
+ record(s.id, {
268
+ type: 'result', subtype: j.subtype, num_turns: j.num_turns, duration_ms: j.duration_ms,
269
+ cost_usd: j.total_cost_usd, denials: (j.permission_denials || []).map((p) => ({ tool: p.tool_name, input: p.tool_input })),
270
+ text: String(j.result || '').slice(0, 2000),
271
+ });
272
+ break;
273
+ default:
274
+ break;
275
+ }
276
+ broadcast({ type: 'session-state', session: s.id, state: s.state, currentTool: s.currentTool, turns: s.turns, usage: s.usage, rateLimit: s.rateLimit, lastText: s.lastText });
277
+ }
278
+
279
+ function pickUsage(u) {
280
+ return { in: u.input_tokens, out: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens };
281
+ }
282
+ function trim(o) { const s = JSON.stringify(o); return s.length > 600 ? s.slice(0, 600) + '…' : s; }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Decisions
286
+ // ---------------------------------------------------------------------------
287
+ function decide(sid, id, decision, why, scope = 'once') {
288
+ const s = sessions.get(sid);
289
+ if (!s) return false;
290
+ const p = s.pending.get(id);
291
+ if (!p) return false;
292
+ clearTimeout(p.timer);
293
+ s.pending.delete(id);
294
+ if (scope === 'always') rules.set(p.key, decision === 'allow' ? 'log' : 'never');
295
+ p.respond(decision, why);
296
+ record(sid, { type: 'decision', id, decision, why, scope, tool: p.tool, key: p.key, waitedMs: Date.now() - p.at });
297
+ if (s.pending.size === 0 && s.state === 'waiting') s.state = 'working';
298
+ broadcast({ type: 'session-state', session: sid, state: s.state });
299
+ broadcast({ type: 'rules', rules: Object.fromEntries(rules) });
300
+ return true;
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // HTTP
305
+ // ---------------------------------------------------------------------------
306
+ function json(res, code, body) {
307
+ res.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
308
+ res.end(JSON.stringify(body));
309
+ }
310
+ function hookOk(res, extra = {}) { json(res, 200, extra); }
311
+ function readBody(req) {
312
+ return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
313
+ }
314
+
315
+ const server = http.createServer(async (req, res) => {
316
+ const url = new URL(req.url, `http://${HOST}:${PORT}`);
317
+ const sidParam = url.searchParams.get('s');
318
+
319
+ // ---- hooks from Claude Code (always answer 200 + JSON; anything else fails open) ----
320
+ if (url.pathname.startsWith('/hooks/')) {
321
+ let hook = {};
322
+ try { hook = JSON.parse(await readBody(req) || '{}'); } catch { /* keep {} */ }
323
+ const ev = url.pathname.slice('/hooks/'.length);
324
+ const attach = url.searchParams.get('attach');
325
+ let sidResolved = sidParam;
326
+ if (!sidResolved && attach && hook.session_id) {
327
+ sidResolved = hook.session_id;
328
+ if (!sessions.has(sidResolved)) attachSession({ id: sidResolved, name: attach.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo', cwd: hook.cwd || process.cwd() });
329
+ }
330
+ const s = sessions.get(sidResolved);
331
+ const sid = sidResolved;
332
+
333
+ if (ev === 'session-start') {
334
+ if (s) {
335
+ s.state = 'working';
336
+ s.model = hook.model || modelFromTranscript(hook.transcript_path) || null;
337
+ // Only claim a model once we actually know one. The transcript does not
338
+ // exist yet on the very first event, and a placeholder here would be the
339
+ // name the record ends up showing.
340
+ if (s.model) record(sid, { type: 'init', model: s.model, claudeSession: hook.session_id, apiKeySource: 'attached' });
341
+ broadcast({ type: 'session-state', session: sid, state: s.state });
342
+ }
343
+ return hookOk(res);
344
+ }
345
+ if (ev === 'prompt') {
346
+ if (s) { s.state = 'working'; record(sid, { type: 'prompt', text: String(hook.prompt || '').slice(0, 4000) }); broadcast({ type: 'session-state', session: sid, state: s.state }); }
347
+ return hookOk(res);
348
+ }
349
+ if (ev === 'session-end') {
350
+ if (s && !s.ended) {
351
+ s.ended = true; // SessionEnd can fire more than once
352
+ s.state = 'exited';
353
+ if (!s.model) s.model = modelFromTranscript(hook.transcript_path);
354
+ record(sid, { type: 'session', subtype: 'exited', reason: hook.reason });
355
+ if (s.model) record(sid, { type: 'init', model: s.model, claudeSession: hook.session_id, apiKeySource: 'attached' });
356
+ broadcast({ type: 'session-state', session: sid, state: s.state });
357
+ buildRecap(s, [], (e, href) => { if (e) record(sid, { type: 'recap_error', error: e.message }); else broadcast({ type: 'recap', session: sid, href }); });
358
+ }
359
+ return hookOk(res);
360
+ }
361
+
362
+ if (ev === 'pre-tool') {
363
+ const { tier, reason } = classifyWith(hook, rules);
364
+ const id = hook.tool_use_id || randomUUID();
365
+ const respond = (decision, why) => hookOk(res, {
366
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `nearly: ${why}` },
367
+ });
368
+ if (!s) return respond('deny', 'unknown session');
369
+ if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: hook.tool_name, input: hook.tool_input, tier }); return respond('deny', `never (${reason})`); }
370
+ if (tier === 'log') { record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: 'policy', tool: hook.tool_name, input: hook.tool_input, tier }); return respond('allow', `do and log (${reason})`); }
371
+ // ask: hold the response until the UI decides, or fail closed
372
+ const item = { id, sid, tool: hook.tool_name, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), respond };
373
+ item.timer = setTimeout(() => decide(sid, id, 'deny', 'no human answer; nearly fails closed'), ASK_TIMEOUT_MS);
374
+ s.pending.set(id, item);
375
+ s.state = 'waiting';
376
+ record(sid, { type: 'ask', ...pendingView(item) });
377
+ broadcast({ type: 'session-state', session: sid, state: s.state });
378
+ return; // response is sent by decide()
379
+ }
380
+
381
+ if (ev === 'post-tool') {
382
+ if (s) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
383
+ return hookOk(res);
384
+ }
385
+ if (ev === 'stop') {
386
+ if (s && s.attached) {
387
+ s.turns += 1;
388
+ s.state = 'idle';
389
+ s.lastText = String(hook.last_assistant_message || '').slice(0, 400);
390
+ if (hook.last_assistant_message) record(sid, { type: 'text', text: String(hook.last_assistant_message).slice(0, 4000) });
391
+ try {
392
+ const d = turnDiff(s.worktree, s.base);
393
+ const commits = s.base ? (git(s.worktree, ['log', '--oneline', `${s.base}..HEAD`]) || '').split('\n').filter(Boolean) : [];
394
+ record(sid, { type: 'turn_diff', turn: s.turns, msg: `turn ${s.turns}: ${s.lastText.replace(/\s+/g, ' ').slice(0, 60)}`, commits, ...d });
395
+ } catch (e) { record(sid, { type: 'checkpoint_error', error: String(e.message).slice(0, 300) }); }
396
+ broadcast({ type: 'session-state', session: sid, state: s.state, turns: s.turns, lastText: s.lastText });
397
+ return hookOk(res);
398
+ }
399
+ if (s) {
400
+ // commit per turn so "undo" is a git revert
401
+ try {
402
+ git(s.worktree, ['add', '-A']);
403
+ const msg = `turn ${s.turns + 1}: ${(hook.last_assistant_message || '').replace(/\s+/g, ' ').slice(0, 60)}`;
404
+ git(s.worktree, ['-c', 'user.name=Nearly', '-c', 'user.email=nearly-cli@local', 'commit', '-qm', msg, '--allow-empty']);
405
+ const sha = git(s.worktree, ['rev-parse', '--short', 'HEAD']);
406
+ record(sid, { type: 'checkpoint', sha, msg });
407
+ } catch (e) { record(sid, { type: 'checkpoint_error', error: String(e.message).slice(0, 300) }); }
408
+ }
409
+ return hookOk(res);
410
+ }
411
+ if (s) record(sid, { type: 'hook', event: ev, detail: trim(hook) });
412
+ return hookOk(res);
413
+ }
414
+
415
+ // ---- UI API ----
416
+ // Cheap liveness check: the hook launcher calls this before every tool call.
417
+ if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true, sessions: sessions.size });
418
+ if (req.method === 'GET' && url.pathname === '/') {
419
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
420
+ return res.end(fs.readFileSync(UI));
421
+ }
422
+ // Static: built recaps and the replay out of ui/, plus a local preview of the
423
+ // docs/ folder that GitHub Pages will serve, so you can check it before pushing.
424
+ if (req.method === 'GET' && (url.pathname.startsWith('/records/') || url.pathname === '/replay.html' || url.pathname === '/docs' || url.pathname.startsWith('/docs/'))) {
425
+ const docs = url.pathname === '/docs' || url.pathname.startsWith('/docs/');
426
+ const base = path.join(ROOT, docs ? 'docs' : 'ui');
427
+ let rel = url.pathname.slice(1).split('/').filter((p) => p && p !== '..').join('/');
428
+ if (docs) rel = rel.replace(/^docs\/?/, '') || 'index.html';
429
+ const file = path.join(base, rel);
430
+ if (!file.startsWith(base) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return json(res, 404, { error: 'not found' });
431
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
432
+ return res.end(fs.readFileSync(file));
433
+ }
434
+ if (req.method === 'GET' && url.pathname === '/events') {
435
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
436
+ res.write(`data: ${JSON.stringify({ type: 'snapshot', sessions: [...sessions.values()].map(summary), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS })}\n\n`);
437
+ clients.add(res);
438
+ req.on('close', () => clients.delete(res));
439
+ return;
440
+ }
441
+ if (req.method === 'GET' && url.pathname === '/state') {
442
+ return json(res, 200, { sessions: [...sessions.values()].map(summary), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS });
443
+ }
444
+ if (req.method === 'GET' && url.pathname.startsWith('/recordings/')) {
445
+ const id = url.pathname.split('/')[2];
446
+ const s = sessions.get(id);
447
+ return json(res, 200, s ? s.events : []);
448
+ }
449
+ if (req.method === 'POST' && url.pathname === '/sessions') {
450
+ try {
451
+ const b = JSON.parse(await readBody(req) || '{}');
452
+ if (!b.prompt) return json(res, 400, { error: 'prompt required' });
453
+ const s = createSession({ name: b.name, prompt: b.prompt });
454
+ return json(res, 200, summary(s));
455
+ } catch (e) { return json(res, 400, { error: e.message }); }
456
+ }
457
+ if (req.method === 'POST' && url.pathname === '/decide') {
458
+ const b = JSON.parse(await readBody(req) || '{}');
459
+ const ok = decide(b.session, b.id, b.decision === 'allow' ? 'allow' : 'deny', b.why || `human ${b.decision} (${b.scope || 'once'})`, b.scope || 'once');
460
+ return json(res, ok ? 200 : 404, { ok });
461
+ }
462
+ if (req.method === 'POST' && url.pathname === '/message') {
463
+ const b = JSON.parse(await readBody(req) || '{}');
464
+ const s = sessions.get(b.session);
465
+ if (!s || s.state === 'exited' || !s.proc) return json(res, 404, { error: 'no such live session (attached sessions take input in their own terminal)' });
466
+ send(s, b.text);
467
+ return json(res, 200, { ok: true });
468
+ }
469
+ if (req.method === 'POST' && url.pathname === '/stop') {
470
+ const b = JSON.parse(await readBody(req) || '{}');
471
+ const s = sessions.get(b.session);
472
+ if (!s) return json(res, 404, { error: 'no such session' });
473
+ for (const id of [...s.pending.keys()]) decide(s.id, id, 'deny', 'session stopped');
474
+ if (!s.proc) { s.state = 'exited'; record(s.id, { type: 'session', subtype: 'detached' }); broadcast({ type: 'session-state', session: s.id, state: s.state }); return json(res, 200, { ok: true }); }
475
+ if (b.hard) s.proc.kill('SIGTERM'); else s.proc.stdin.end();
476
+ record(s.id, { type: 'session', subtype: b.hard ? 'killed' : 'stopping' });
477
+ return json(res, 200, { ok: true });
478
+ }
479
+ if (req.method === 'POST' && url.pathname === '/recap') {
480
+ // Build a narrated recap page from this session's recording. Runs the
481
+ // build script as a child so a slow `say` never blocks a hook response.
482
+ const b = JSON.parse(await readBody(req) || '{}');
483
+ const sid = String(b.session || '').replace(/[^0-9a-f-]/gi, '');
484
+ const s = sessions.get(sid);
485
+ if (!s && !(sid && fs.existsSync(path.join(RECORDINGS, `${sid}.jsonl`)))) return json(res, 404, { error: 'no such session or recording' });
486
+ const extra = [];
487
+ if (b.llm) extra.push('--llm');
488
+ if (b.noAudio) extra.push('--no-audio');
489
+ buildRecap(s || { id: sid }, extra, (e, href) => (e ? json(res, 500, { error: e.message }) : json(res, 200, { ok: true, href })));
490
+ return;
491
+ }
492
+ if (req.method === 'POST' && url.pathname === '/post-recap') {
493
+ // Deliberate, user-initiated: comments on the PR for the session's branch via `gh`.
494
+ const b = JSON.parse(await readBody(req) || '{}');
495
+ const sid = String(b.session || '').replace(/[^0-9a-f-]/gi, '');
496
+ const args = [path.join(ROOT, 'scripts', 'post-recap.mjs'), sid];
497
+ if (b.urlBase) args.push('--url-base', String(b.urlBase));
498
+ const child = spawn(process.execPath, args, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
499
+ let out = '', err = '';
500
+ child.stdout.on('data', (d) => (out += d));
501
+ child.stderr.on('data', (d) => (err += d));
502
+ child.on('exit', (code) => (code === 0 ? json(res, 200, { ok: true, url: out.trim() }) : json(res, 500, { error: err.trim() || `exit ${code}` })));
503
+ return;
504
+ }
505
+ if (req.method === 'POST' && url.pathname === '/undo') {
506
+ const b = JSON.parse(await readBody(req) || '{}');
507
+ const s = sessions.get(b.session);
508
+ if (!s) return json(res, 404, { error: 'no such session' });
509
+ if (s.attached) return json(res, 400, { error: 'undo is not offered for attached sessions: it is your branch, use git' });
510
+ try {
511
+ const before = git(s.worktree, ['rev-parse', '--short', 'HEAD']);
512
+ git(s.worktree, ['reset', '--hard', 'HEAD~1']);
513
+ const after = git(s.worktree, ['rev-parse', '--short', 'HEAD']);
514
+ record(s.id, { type: 'undo', from: before, to: after });
515
+ return json(res, 200, { ok: true, from: before, to: after });
516
+ } catch (e) { return json(res, 400, { error: String(e.message).slice(0, 300) }); }
517
+ }
518
+ json(res, 404, { error: 'not found' });
519
+ });
520
+
521
+ // Hooks start this on demand, so two tool calls arriving together can both try.
522
+ // The loser is not an error: the winner is already serving.
523
+ server.on('error', (e) => {
524
+ if (e.code === 'EADDRINUSE') process.exit(0);
525
+ console.error(`nearly: ${e.message}`);
526
+ process.exit(1);
527
+ });
528
+
529
+ server.listen(PORT, HOST, () => {
530
+ console.log(`nearly http://${HOST}:${PORT}`);
531
+ console.log(`workspace ${WORKSPACE}`);
532
+ console.log(`recordings ${RECORDINGS}`);
533
+ });
534
+
535
+ process.on('SIGINT', () => {
536
+ for (const s of sessions.values()) { try { s.proc.kill('SIGTERM'); } catch { /* ignore */ } }
537
+ process.exit(0);
538
+ });