nearly-cli 0.1.11 → 0.1.16

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
@@ -159,6 +159,19 @@ Nothing to configure, no server to start, and `nearly off` removes all of it.
159
159
  Run it again in any other repo you want recorded. After the first time the
160
160
  command is just `nearly`.
161
161
 
162
+ ### Where it installs
163
+
164
+ `npx nearly-cli` installs a copy into `~/.nearly/runtime`, and the hooks call
165
+ that copy directly. Not `npm install -g`, which it used to use and which failed
166
+ silently in three ordinary situations: on any up-to-date Windows machine, where
167
+ Node will not start `npm.cmd` without a shell; on a Mac whose Node came from the
168
+ official installer, where the global folder belongs to root; and whenever npx
169
+ itself was running it, because npx passes its own settings down to the npm it
170
+ starts. Each failure fell back to a pinned `npx -y nearly-cli@<version>` in every
171
+ hook — slower on every tool call, and stuck on that release for good. A folder
172
+ you always own avoids all three, and if the install still fails, `nearly` now
173
+ prints npm's actual reason instead of a pointer to a log file.
174
+
162
175
  ### Upgrading
163
176
 
164
177
  It updates itself. When you run a command and a newer version exists, Nearly
@@ -175,7 +188,7 @@ Four rules keep that from being something you regret installing:
175
188
  before you trust it.
176
189
  - **Never silent.** An update that happened without being mentioned is
177
190
  indistinguishable from a compromise, so it always says what it did.
178
- - **Never fatal.** No network, a locked global directory, a slow registry: you
191
+ - **Never fatal.** No network, a locked folder, a slow registry: you
179
192
  keep the version you have, the command you ran still works, and it tells you
180
193
  rather than leaving you to assume you are current.
181
194
 
@@ -212,18 +225,63 @@ All of them capture the moment a human refuses something. None of them passes it
212
225
 
213
226
  **The gate here exists to produce the recording.** It is the instrument, not the product. If you already run Prempti, its audit trail is richer than ours and reading it as an input is the obvious next step; see *Roadmap*.
214
227
 
228
+ ## Unattended by default
229
+
230
+ Turning Nearly on does not make your agent wait for anyone. Destructive commands
231
+ are refused outright — `rm -rf`, `git push`, `sudo`, anything touching `.env`,
232
+ `curl … | sh` — and everything else runs and is written down.
233
+
234
+ It used to hold every edit for a person to approve, which only works if a person
235
+ is watching the dashboard, and nothing told a new user it existed. In practice
236
+ that meant a Cursor session unable to write a single file, each edit waiting two
237
+ minutes and then being refused. Holding for approval is now something you turn
238
+ on while you are actually watching:
239
+
240
+ ```bash
241
+ nearly --supervise
242
+ ```
243
+
244
+ This is not weaker than it sounds. The refusals that matter never needed a
245
+ person: they are rules, and they fire with nobody at the keyboard — including
246
+ under Claude Code's `bypassPermissions`, which skips its own prompts but not
247
+ the hooks Nearly runs in. With no one watching, the record is also the only
248
+ account of what the agent did, and it says so on the first line.
249
+
215
250
  ## The consent gradient
216
251
 
217
- Every tool call passes through an HTTP `PreToolUse` hook to this server, which sorts it into a tier:
252
+ Every tool call passes through a `PreToolUse` hook to this server, which sorts it into a tier:
218
253
 
219
- | Tier | What happens | Default for |
254
+ | Tier | What happens | Covers |
220
255
  |---|---|---|
221
- | never | denied, no prompt, logged | `rm -rf`, `git push`, `sudo`, `.env`, `curl | sh` |
222
- | ask | held until a human decides in the UI; denied if nobody answers in 2 minutes (fails closed) | Bash, Edit, Write, WebFetch, Task |
223
- | log | allowed, receipt recorded | Read, Glob, Grep |
256
+ | never | refused, nobody asked, recorded with the reason | anything that cannot be undone see below |
257
+ | ask | only with `--supervise`: held until you decide; refused if nobody answers in 2 minutes | Bash, Edit, Write, WebFetch, Task |
258
+ | log | allowed and recorded | everything else — and, unattended, everything that would have been asked |
224
259
 
225
260
  "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`.
226
261
 
262
+ A call nobody answered is refused, but nobody refused it, and the record says so
263
+ — "nobody answered" — rather than crediting you with a decision you never made.
264
+
265
+ ### What "never" means
266
+
267
+ One rule: **refuse what cannot be undone.**
268
+
269
+ - **Deleting.** Inside the repo, git gives it back, so `rm -rf node_modules` runs. Outside the repo, in your home directory, at the root, `.git` itself, or a path only known at run time (`$SOME_VAR`) is refused. Scratch space under the system temp folder is allowed, except for the temp folder itself, a folder that holds this repo, or another git repository.
270
+ - **Pushing.** A feature branch is how work reaches review, so it runs. A force-push, deleting a remote branch, `--mirror`, or pushing straight to `main`, `master` or the remote's default branch is refused — a bare `git push` asks git which branch you are on.
271
+ - **Local history git never had.** `git clean -fd`, `git reset --hard` over uncommitted work, `reflog expire`, `gc --prune=now` and `stash clear` are refused. `git clean -fdX`, which only removes ignored build output, is not.
272
+ - **Secrets.** Refused when a real secret would be shown to the agent, sent somewhere, or carried out of the repo: `cat .env`, `curl -F file=@.env`, `cp .env /tmp`, the Read tool on `.env`, `~/.aws/credentials`, `~/.ssh`. Not refused: `.env.example`, `--env-file .env`, `source .env`, `test -f .env`, `cp .env .env.bak`, and any file git already tracks, since that is already readable by anyone with the repo.
273
+ - **The rest.** `sudo` and its relatives, a download piped into anything that runs it, world-writable `chmod`, disk and filesystem tools, deleting a GitHub repository.
274
+
275
+ It reads a command the way a shell runs it: `cd ~ && rm -rf Documents` is refused because the `cd` moves where the delete lands; `bash -c`, `eval`, `$(…)`, `xargs`, `find -exec` and `npx rimraf` are unwrapped and checked; `\rm` and `/bin/rm` are `rm`; a path is followed through symlinks; `git -c x=y push --force` is still a force-push; `git checkout main && git push` is still a push to main. Windows `Remove-Item`, `rd /s` and `del /s` are covered. Any tool carrying a command is checked, not only one named `Bash`.
276
+
277
+ ### What it is not
278
+
279
+ **It is not a sandbox.** It catches what a well-meaning agent actually types, and it was built by collecting every bypass two independent test runs could find — 126 destructive commands, all refused, and 97 ordinary ones, none refused, all kept as tests. But a determined or compromised agent can still get past a rule that reads text: code handed to an interpreter (`python -c`, `node -e`) is only inspected for the obvious, and a script the agent writes to a file and then runs is not inspected at all.
280
+
281
+ For isolation, run the agent in a container, a VM, or Claude Code's own sandbox, and use Nearly for what it is for — a record of what happened, including what was stopped, that reaches the person reviewing the code.
282
+
283
+ ### Lab mode
284
+
227
285
  Lab mode is off by default. `nearly open` shows your own sessions and what
228
286
  needs you — the gate, which is what you installed this for. `nearly lab` adds
229
287
  the panel for starting agents from the dashboard, which is a different job and
package/bin/nearly.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  // because the useful thing is what people type first.
18
18
 
19
19
  import { spawn, spawnSync, execFileSync } from 'node:child_process';
20
+ import { existsSync, statSync } from 'node:fs';
20
21
  import { join, dirname, resolve } from 'node:path';
21
22
  import { fileURLToPath } from 'node:url';
22
23
 
@@ -61,6 +62,12 @@ switch (cmd) {
61
62
  case 'hook':
62
63
  return run(s('hook.mjs'), rest);
63
64
 
65
+ // internal: what the git pre-push hook runs. It goes through this command
66
+ // rather than a path to the script, so it survives the same upgrades and
67
+ // cache clears the agent hooks do.
68
+ case 'push-record':
69
+ return run(s('push-record.mjs'), rest);
70
+
64
71
  case 'record': case 'recap': {
65
72
  // Default to the branch you are on, since that is what gets reviewed.
66
73
  if (rest.length) return run(s('build-recap.mjs'), rest);
@@ -86,7 +93,7 @@ switch (cmd) {
86
93
  return run(s('build-recap.mjs'), ['--voices']);
87
94
 
88
95
  case 'server': {
89
- console.log('Nearly on http://127.0.0.1:47653');
96
+ console.log(`Nearly on http://127.0.0.1:${process.env.NEARLY_PORT || 47653}`);
90
97
  console.log('You do not normally need this: the hooks start it when they need it.');
91
98
  return run(join(root, 'server', 'index.mjs'), rest);
92
99
  }
@@ -94,7 +101,7 @@ switch (cmd) {
94
101
  case 'lab': case 'open': {
95
102
  // `open` is the gate: your sessions and what needs you. `lab` adds the
96
103
  // panel for starting agents from here, which is a different job.
97
- const url = 'http://127.0.0.1:47653' + (cmd === 'lab' ? '/?lab=1' : '');
104
+ const url = `http://127.0.0.1:${process.env.NEARLY_PORT || 47653}` + (cmd === 'lab' ? '/?lab=1' : '');
98
105
  spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open',
99
106
  [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
100
107
  console.log(url);
@@ -118,6 +125,9 @@ switch (cmd) {
118
125
  }
119
126
 
120
127
  default:
128
+ // `nearly ~/code/app` — a path, not a command. The "not a git repository"
129
+ // message told people to pass one, and then this said "Unknown command".
130
+ if (existsSync(cmd) && statSync(cmd).isDirectory()) return run(s('attach.mjs'), [cmd, ...rest]);
121
131
  console.error(`Unknown command: ${cmd}`);
122
132
  console.error('Try: nearly help');
123
133
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nearly-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.16",
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 test/stale-server.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 test/runtime.test.mjs"
38
38
  }
39
39
  }
@@ -11,12 +11,14 @@
11
11
 
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
13
  import { join, resolve } from 'node:path';
14
- import { ADAPTERS } from '../server/adapters.mjs';
14
+ import { ADAPTERS, OURS_RE } from '../server/adapters.mjs';
15
15
  import { detect, installed } from './detect.mjs';
16
16
 
17
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
18
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
19
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
17
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
18
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
19
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
20
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
21
+ const ok = (s) => (COLOR ? `\x1b[32m${s}\x1b[0m` : String(s));
20
22
 
21
23
  const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
22
24
  const here = new Set(detect(repo).map((d) => d.id));
@@ -28,7 +30,7 @@ const machine = new Set(installed().map((d) => d.id));
28
30
  const gated = (a) => {
29
31
  const f = join(repo, a.config);
30
32
  if (!existsSync(f)) return false;
31
- try { return /nearly/i.test(readFileSync(f, 'utf8')); } catch { return false; }
33
+ try { return OURS_RE.test(readFileSync(f, 'utf8')); } catch { return false; }
32
34
  };
33
35
 
34
36
  const width = Math.max(...ADAPTERS.map((a) => a.name.length));
@@ -40,7 +42,9 @@ console.log('');
40
42
  for (const a of ADAPTERS) {
41
43
  const on = gated(a);
42
44
  const mark = on ? ok('●') : dim('○');
43
- const state = on ? 'gated here' : here.has(a.id) ? 'used here, not gated' : machine.has(a.id) ? 'installed, unused here' : 'not in use here';
45
+ // "Wired", not "gated": a hook being in the config says nothing about whether
46
+ // it runs. `nearly doctor` fires one to find out.
47
+ const state = on ? 'wired here' : here.has(a.id) ? 'used here, not wired' : machine.has(a.id) ? 'installed, unused here' : 'not in use here';
44
48
  console.log(` ${mark} ${bold(pad(a.name))} ${pad2(state)} ${dim(a.config)}`);
45
49
  }
46
50
  function pad2(s) { return s + ' '.repeat(Math.max(0, 22 - s.length)); }
@@ -18,12 +18,13 @@
18
18
  // it, and if it cannot start, Claude Code falls back to its own prompts and
19
19
  // nothing breaks.
20
20
 
21
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
21
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, readdirSync, rmdirSync, rmSync } from 'node:fs';
22
22
  import { join, resolve, dirname, basename } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { execFileSync, spawnSync } from 'node:child_process';
25
25
  import { dataRoot, paths } from '../server/paths.mjs';
26
26
  import { choose, installed as agentsOnMachine } from './detect.mjs';
27
+ import { installRuntime, hasRuntime, isRuntime, runtimeCommand } from './runtime.mjs';
27
28
  import { ADAPTERS } from '../server/adapters.mjs';
28
29
 
29
30
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
@@ -76,57 +77,112 @@ const viaNpx = /[\\/]_npx[\\/]/.test(root);
76
77
 
77
78
  // `npx nearly-cli` should be the whole of it. An npx run is a temporary
78
79
  // download, so hooks pointing at it would pin a version in a directory npm
79
- // clears, and no fix would ever reach this repo. Install it properly instead,
80
- // once, out loud, so the one command someone types actually leaves them with a
81
- // working tool. --no-install skips it.
82
- function installGlobally() {
83
- if (!viaNpx || onPath() || argv.includes('--no-install') || process.env.NEARLY_NO_INSTALL === '1') return false;
84
- process.stdout.write(dim(` Installing nearly so upgrades reach you… `));
85
- const r = spawnSync(NPM, ['install', '-g', `nearly-cli@${pkgVersion()}`, '--silent', '--no-fund', '--no-audit'],
86
- { encoding: 'utf8', timeout: 180_000 });
87
- if (r.status === 0 && onPath()) { console.log('done'); return true; }
88
- console.log(dim('skipped'));
89
- console.log(dim(' Running from npx instead. Upgrades will not reach this repo automatically;'));
90
- console.log(dim(' npm install -g nearly-cli when you want that.'));
80
+ // clears, and no fix would ever reach this repo.
81
+ //
82
+ // This used to `npm install -g`, which failed silently on every up-to-date
83
+ // Windows machine (Node will not spawn npm.cmd without a shell) and on any Mac
84
+ // whose Node came from the official installer (a root-owned prefix). Each fell
85
+ // back to a pinned npx call in every hook: slower on every tool call, and stuck
86
+ // on that release for good. So it installs into ~/.nearly/runtime instead
87
+ // somewhere the user always owns. --no-install skips it.
88
+ function installRuntimeOnce() {
89
+ if (!fromPackage || onPath() || hasRuntime() || isRuntime(root)) return hasRuntime();
90
+ if (argv.includes('--no-install') || process.env.NEARLY_NO_INSTALL === '1') return false;
91
+ process.stdout.write(dim(' Installing nearly so upgrades reach you '));
92
+ const r = installRuntime(pkgVersion());
93
+ if (r.ok) { console.log('done'); return true; }
94
+ console.log(dim('could not'));
95
+ // The reason, in full. "skipped" in grey is how the last version of this
96
+ // left someone pinned to a broken release without ever knowing why.
97
+ console.log(` ${r.error}`);
98
+ console.log(dim(' Falling back to npx, which is slower on every tool call and will not'));
99
+ console.log(dim(' pick up fixes. Run nearly again here once the problem above is solved.'));
91
100
  return false;
92
101
  }
102
+
103
+ // Preference, fastest and most durable first:
104
+ // 1. a `nearly` on PATH that is really this tool
105
+ // 2. the runtime install in ~/.nearly/runtime
106
+ // 3. a pinned npx call — works, but slow and never upgrades
107
+ // 4. the checkout's own script, for someone developing this
93
108
  let installed = onPath();
109
+ let runtime = hasRuntime();
94
110
  const hookCmd = (ev) => installed
95
- ? `nearly hook ${ev}` // verified above to survive this process
96
- : fromPackage
97
- ? `npx -y nearly-cli@${pkgVersion()} hook ${ev}`
98
- : `node ${JSON.stringify(HOOK)} ${ev}`;
99
- // A function, not a value: the install below changes the answer, and computing
100
- // it early told a fresh install it was pinned when it was not.
101
- const updateNote = () => installed
111
+ ? `nearly hook ${ev}`
112
+ : runtime
113
+ ? `${runtimeCommand()} hook ${ev}`
114
+ : fromPackage
115
+ ? `npx -y nearly-cli@${pkgVersion()} hook ${ev}`
116
+ : `node ${JSON.stringify(HOOK)} ${ev}`;
117
+ // A function, not a value: the install below changes the answer.
118
+ const updateNote = () => (installed || runtime)
102
119
  ? 'upgrades reach this repo automatically'
103
120
  : fromPackage
104
- ? `pinned to v${pkgVersion()} — run nearly again here after upgrading`
121
+ ? `pinned to v${pkgVersion()} — slow, and fixes will not reach it until you run nearly again`
105
122
  : 'running from a checkout — git pull updates it';
106
123
 
107
- const argv = process.argv.slice(2);
124
+ // Flags take a value either way: `--agent=cursor` or `--agent cursor`. Only the
125
+ // first worked, so `nearly --agent cursor` read "cursor" as the repo path and
126
+ // said it was not a git repository.
127
+ const VALUED = new Set(['--agent', '--name']);
128
+ const argv = (() => {
129
+ const raw = process.argv.slice(2), out = [];
130
+ for (let i = 0; i < raw.length; i++) {
131
+ if (VALUED.has(raw[i]) && raw[i + 1] !== undefined && !raw[i + 1].startsWith('--')) { out.push(`${raw[i]}=${raw[i + 1]}`); i++; }
132
+ else out.push(raw[i]);
133
+ }
134
+ return out;
135
+ })();
136
+ const flagValue = (f) => argv.find((a) => a.startsWith(`${f}=`))?.slice(f.length + 1);
108
137
  const off = argv.includes('--off') || argv.includes('--detach');
109
- const repo = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
110
- const nameIdx = argv.indexOf('--name');
111
- const name = (nameIdx !== -1 ? argv[nameIdx + 1] : basename(repo))
138
+ // Unattended unless you ask to supervise.
139
+ //
140
+ // Holding a call for a human only works if a human is looking at the dashboard,
141
+ // and nothing tells a new user it exists. So the default used to be: every edit
142
+ // silently waits two minutes and is then refused. That made a Cursor session
143
+ // unable to write a single file, and turned a fifteen-second task into five
144
+ // minutes under auto mode. Two real failures of the same default.
145
+ //
146
+ // Now the never-rules block with nobody present, everything else is done and
147
+ // recorded, and holding for approval is something you turn on while watching.
148
+ const supervise = argv.includes('--supervise');
149
+ const auto = !supervise;
150
+ // The repo is wherever git says its top is. Looking only for a .git folder in
151
+ // the current directory rejected every subfolder, which is where people usually
152
+ // are when they first try something.
153
+ const asked = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
154
+ const repo = (() => {
155
+ try {
156
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'],
157
+ { cwd: asked, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
158
+ if (!top) return asked;
159
+ // Keep the path as it was typed when it already is the top; git hands back
160
+ // the resolved one (/private/var for /var on macOS), which is the same place
161
+ // spelled in a way nobody wrote.
162
+ return realpathSync.native(asked) === realpathSync.native(top) ? asked : resolve(top);
163
+ } catch { return asked; }
164
+ })();
165
+ const name = (flagValue('--name') ?? basename(repo))
112
166
  .replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo';
113
167
 
114
168
  // npm is a .cmd shim on Windows and Node will not run one through spawn unless
115
169
  // it is named exactly. Without this, installing and upgrading both fail there.
116
170
  const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
117
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
118
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
119
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
171
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
172
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
173
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
174
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
175
+ const ok = (s) => (COLOR ? `\x1b[32m${s}\x1b[0m` : String(s));
120
176
 
121
177
  if (!existsSync(join(repo, '.git'))) {
122
- console.error(`${repo} is not a git repository.`);
178
+ console.error(`${asked} is not inside a git repository.`);
123
179
  console.error('Run this inside the repo you want recorded, or pass its path.');
124
180
  process.exit(1);
125
181
  }
126
182
 
127
183
  // Do this before the hooks are written, so they can point at the installed
128
184
  // command rather than a temporary npx download.
129
- if (!off && installGlobally()) installed = onPath();
185
+ if (!off) runtime = installRuntimeOnce() || hasRuntime();
130
186
 
131
187
  // ---------------------------------------------------------------------------
132
188
  // Agent hooks
@@ -143,7 +199,9 @@ if (unknown.length) {
143
199
  const wired = [];
144
200
  const notes = [];
145
201
  for (const a of chosen) {
146
- const cmdFor = (ev) => `${hookCmd(ev)} ${name}` + (a.id === 'claude-code' ? '' : ` --adapter=${a.id}`);
202
+ const cmdFor = (ev) => `${hookCmd(ev)} ${name}`
203
+ + (a.id === 'claude-code' ? '' : ` --adapter=${a.id}`)
204
+ + (auto ? ' --auto' : '');
147
205
  try {
148
206
  const r = off ? a.uninstall({ repo }) : a.install({ repo, cmdFor, name });
149
207
  if (r?.error) { notes.push(`${a.name}: ${r.error}`); continue; }
@@ -155,6 +213,30 @@ for (const a of chosen) {
155
213
  }
156
214
  }
157
215
 
216
+ // Leave nothing behind. Turning off used to strip the hooks and keep an empty
217
+ // `{}` file and empty `.cursor/ .agents/ .codex/ .gemini/ .windsurf/` folders —
218
+ // and the next `nearly` read those folders as signs of use and wired all seven
219
+ // agents. Only what is empty goes; nothing of anyone else's is touched, and
220
+ // nothing above the repo.
221
+ if (off) {
222
+ for (const a of ADAPTERS) {
223
+ const file = join(repo, a.config);
224
+ try {
225
+ if (existsSync(file)) {
226
+ const v = JSON.parse(readFileSync(file, 'utf8'));
227
+ const hollow = v && typeof v === 'object' && Object.entries(v)
228
+ .filter(([k]) => k !== 'version')
229
+ .every(([, x]) => x == null || (typeof x === 'object' && !Object.keys(x).length));
230
+ if (hollow) rmSync(file);
231
+ }
232
+ for (let dir = dirname(file); dir !== repo && dir.startsWith(repo); dir = dirname(dir)) {
233
+ if (!existsSync(dir) || readdirSync(dir).length) break;
234
+ rmdirSync(dir);
235
+ }
236
+ } catch { /* unreadable or busy: leave it */ }
237
+ }
238
+ }
239
+
158
240
  // Remember this repo, so the dashboard can offer it before anything has run in
159
241
  // it. A list of paths and nothing else: it is a convenience, and it is rebuilt
160
242
  // by simply turning Nearly on again.
@@ -193,7 +275,7 @@ async function checkPort() {
193
275
  try { mine = realpathSync(root); } catch { /* compare the literal path */ }
194
276
  try {
195
277
  const { reclaim } = await import('../server/reclaim.mjs');
196
- return await reclaim({ port: PORT, base: `http://127.0.0.1:${PORT}`, root: mine });
278
+ return await reclaim({ port: PORT, base: `http://127.0.0.1:${PORT}`, root: mine, version: pkgVersion() });
197
279
  } catch { return null; }
198
280
  }
199
281
  const port = off ? null : await checkPort();
@@ -201,8 +283,15 @@ const port = off ? null : await checkPort();
201
283
  // ---------------------------------------------------------------------------
202
284
  // git pre-push hook
203
285
  // ---------------------------------------------------------------------------
286
+ const baseCmd = () => installed
287
+ ? 'nearly'
288
+ : runtime
289
+ ? runtimeCommand()
290
+ : fromPackage
291
+ ? `npx -y nearly-cli@${pkgVersion()}`
292
+ : `node ${JSON.stringify(join(root, 'bin', 'nearly.mjs'))}`;
204
293
  const push = spawnSync(process.execPath,
205
- [join(root, 'scripts', 'install-push-hook.mjs'), repo, ...(off ? ['--remove'] : [])],
294
+ [join(root, 'scripts', 'install-push-hook.mjs'), repo, '--cmd', baseCmd(), ...(off ? ['--remove'] : [])],
206
295
  { encoding: 'utf8' });
207
296
 
208
297
  // ---------------------------------------------------------------------------
@@ -264,12 +353,27 @@ if (off) {
264
353
 
265
354
  console.log(`${ok('✓')} ${bold('Nearly is on')} for ${bold(name)} ${dim(repo)}`);
266
355
  console.log('');
356
+ if (supervise) {
357
+ console.log(` ${ok('·')} ${bold('supervised')} ${dim(`— risky calls wait for you at http://127.0.0.1:${PORT}`)}`);
358
+ console.log(` ${dim('keep that page open, or every one of them is refused after two minutes')}`);
359
+ } else {
360
+ console.log(` ${ok('·')} ${dim('destructive commands are blocked; everything else runs and is recorded')}`);
361
+ console.log(` ${dim('to approve risky calls yourself: nearly --supervise')}`);
362
+ }
267
363
  for (const a of wired) {
268
364
  const how = a.verified ? dim(`(${a.verified})`) : dim('(built to their published hook spec, not yet run against a live agent)');
269
365
  console.log(` ${ok('·')} ${a.name} sessions here are gated and recorded ${how}`);
270
366
  }
271
367
  console.log(` ${ok('·')} ${dim(updateNote())}`);
272
- console.log(` ${ok('·')} ${push.status === 0 ? 'the record is offered when you push' : dim('pre-push hook skipped: ' + (push.stderr || '').trim().split('\n')[0])}`);
368
+ if (push.status === 0) {
369
+ console.log(` ${ok('·')} the record is offered when you push`);
370
+ } else {
371
+ // All of it: when a pre-push hook of yours is already there, the lines after
372
+ // the first are the ones that say how to add Nearly to it by hand.
373
+ const said = (push.stderr || '').trim().split('\n').filter(Boolean);
374
+ console.log(` ${ok('·')} ${dim(`pre-push hook skipped: ${said[0] || 'unknown reason'}`)}`);
375
+ for (const line of said.slice(1)) console.log(` ${dim(line)}`);
376
+ }
273
377
  if (base) {
274
378
  console.log(` ${ok('·')} records publish to ${base}`);
275
379
  } else {
@@ -311,6 +415,8 @@ if (elsewhere.length) {
311
415
  }
312
416
 
313
417
  console.log('');
314
- console.log(` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`);
418
+ console.log(supervise
419
+ ? ` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`
420
+ : ` Now just work. Nothing will wait for you.`);
315
421
  console.log(dim(' Nothing to leave running. Turn it off again with --off.'));
316
422
  console.log('');
@@ -98,7 +98,14 @@ function parseRecording(path) {
98
98
  // belongs to nobody.
99
99
  function samePath(a, b) {
100
100
  if (!a || !b) return false;
101
- const real = (p) => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
101
+ const real = (p) => {
102
+ let r;
103
+ try { r = realpathSync.native(resolve(p)); } catch { r = resolve(p); }
104
+ // Windows spells the same directory more than one way and means the same
105
+ // place. Comparing those as strings loses every session on that machine,
106
+ // the same way /var against /private/var did on this one.
107
+ return process.platform === 'win32' ? r.toLowerCase() : r;
108
+ };
102
109
  return real(a) === real(b);
103
110
  }
104
111
 
@@ -213,10 +220,17 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
213
220
  const undos = events.filter((e) => e.type === 'undo');
214
221
  const finals = events.filter((e) => e.type === 'result');
215
222
  const texts = events.filter((e) => e.type === 'text');
216
- const humanDecisions = decisions.filter((d) => d.waitedMs != null);
223
+ // A held call nobody answered is denied, but nobody decided it. Counting it as
224
+ // a decision made the headline claim "7 decisions by Anuj" and the comment say
225
+ // "refused by the supervisor" about calls no person ever looked at — the one
226
+ // kind of claim a reviewer has no way to check. Recordings from before the
227
+ // server marked timeouts are recognised by their reason.
228
+ const timedOut = (d) => d.scope === 'timeout' || /no human answer/.test(d.why || '');
229
+ const humanDecisions = decisions.filter((d) => d.waitedMs != null && !timedOut(d));
217
230
  const denied = decisions.filter((d) => d.decision === 'deny');
218
231
  const blocked = decisions.filter((d) => d.tier === 'never');
219
- const humanWaitMs = humanDecisions.reduce((n, d) => n + d.waitedMs, 0);
232
+ // The wait still happened, answered or not.
233
+ const humanWaitMs = decisions.filter((d) => d.waitedMs != null).reduce((n, d) => n + d.waitedMs, 0);
220
234
  const lastResult = finals.at(-1);
221
235
 
222
236
  const name = created?.name ?? id.slice(0, 8);
@@ -234,13 +248,23 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
234
248
  if (undos.length) headlineBits.push(`${plural(undos.length, 'turn')} rolled back`);
235
249
  if (humanDecisions.length) headlineBits.push(plural(humanDecisions.length, 'decision') + V(` by ${AUTHOR}`, ' from you'));
236
250
  if (!denied.length && !undos.length && checkpoints.length) headlineBits.push(plural(checkpoints.length, 'turn') + (attached ? '' : ' committed'));
251
+
252
+ // An unattended run is the one case where the absence of human decisions is
253
+ // itself the fact. Leaving it implied reads as "nothing needed approving"
254
+ // when what happened is that nobody was asked.
255
+ const unattended = decisions.some((d) => d.scope === 'auto');
237
256
  scenes.push({
238
257
  kind: 'cover',
239
258
  title: headlineBits.length ? headlineBits.join(', ') : 'A session with nothing to flag',
240
259
  runs: sbRuns,
260
+ unattended,
241
261
  orient: forReviewer
242
- ? `An agent wrote the branch you are about to review${sbRuns > 1 ? `, across ${plural(sbRuns, 'session')}` : ''}. This is what happened while it was writing it — including the things it was stopped from doing, which the diff cannot show you.`
243
- : `Everything your agent did in this session, including what you stopped it from doing.`,
262
+ ? (unattended
263
+ ? `An agent wrote the branch you are about to review${sbRuns > 1 ? `, across ${plural(sbRuns, 'session')}` : ''}, with nobody watching it work. Nothing here was approved by a person — the rules did the stopping. This is the only account of what it did.`
264
+ : `An agent wrote the branch you are about to review${sbRuns > 1 ? `, across ${plural(sbRuns, 'session')}` : ''}. This is what happened while it was writing it — including the things it was stopped from doing, which the diff cannot show you.`)
265
+ : (unattended
266
+ ? `Everything your agent did while you were not watching, including what the rules stopped it from doing.`
267
+ : `Everything your agent did in this session, including what you stopped it from doing.`),
244
268
  repo: worktree ? basename(worktree) : null,
245
269
  branch: created?.branch || null,
246
270
  stats: [
@@ -304,6 +328,8 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
304
328
  let narration;
305
329
  if (e.tier === 'never') {
306
330
  narration = `${asked.replace('It asked', 'It tried')}. A never rule blocked it before ${V('anyone', 'you')} saw it.`;
331
+ } else if (timedOut(e)) {
332
+ narration = `${asked}. Nobody answered in ${w} seconds, so it was refused rather than allowed. The agent carried on without it.`;
307
333
  } else if (e.decision === 'allow') {
308
334
  narration = `${asked}. ${SUP} allowed it${e.scope === 'always' ? ' as a rule' : ''} after ${w} seconds.`;
309
335
  } else {
@@ -375,7 +401,7 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
375
401
  const notDone = denied.map((d) => {
376
402
  const ask = asks.find((a) => a.id === d.id);
377
403
  const input = ask?.input ?? d.input ?? {};
378
- return { tool: d.tool, what: short(describeInput(d.tool, input), 90), by: d.tier === 'never' ? 'policy' : 'you' };
404
+ return { tool: d.tool, what: short(describeInput(d.tool, input), 90), by: d.tier === 'never' ? 'policy' : timedOut(d) ? 'timeout' : 'you' };
379
405
  });
380
406
  const head = canGit && !created?.attached ? git(worktree, ['rev-parse', '--short', 'HEAD']) : null;
381
407
  scenes.push({
@@ -384,11 +410,13 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
384
410
  narration: (() => {
385
411
  let n = `The agent reported: ${short(finalText, 150)}`;
386
412
  if (!notDone.length) return n;
387
- const byHuman = notDone.filter((x) => x.by !== 'policy').length;
388
- const byPolicy = notDone.length - byHuman;
413
+ const byHuman = notDone.filter((x) => x.by === 'you').length;
414
+ const byPolicy = notDone.filter((x) => x.by === 'policy').length;
415
+ const byTimeout = notDone.filter((x) => x.by === 'timeout').length;
389
416
  const parts = [];
390
417
  if (byHuman) parts.push(`${byHuman} ${byHuman === 1 ? 'was' : 'were'} refused by ${sup}`);
391
418
  if (byPolicy) parts.push(`${byPolicy} ${byPolicy === 1 ? 'was' : 'were'} blocked by policy before anyone saw ${byPolicy === 1 ? 'it' : 'them'}`);
419
+ if (byTimeout) parts.push(`${byTimeout} ${byTimeout === 1 ? 'was' : 'were'} refused because nobody answered`);
392
420
  return `${n} Read that with a caveat: ${plural(notDone.length, 'requested step')} never ran. ${parts.join(', and ')}.`;
393
421
  })(),
394
422
  });
@@ -643,10 +671,28 @@ const slug = BRANCH ? `${safe(sb.name)}--${safe(BRANCH)}` : `${sb.name}-${sb.id.
643
671
  const jsonPath = join(storyDirOut, `${slug}.json`);
644
672
  writeFileSync(jsonPath, JSON.stringify({ ...sb, scenes: sb.scenes.map(({ audio, ...s }) => s) }, null, 2));
645
673
 
674
+ // Everything in a record came from an agent, so everything is hostile until
675
+ // proven otherwise.
676
+ //
677
+ // This used to be `.replace('__RECAP__', json)`. With a string as the
678
+ // replacement, JavaScript reads `$&`, `$\``, `$'` in it as instructions — so a
679
+ // command containing `$\`` pasted the page's whole <head> into the record's
680
+ // data, broke out of the script block, and ran text from the agent's command as
681
+ // code in the reviewer's browser. Reproduced: the tab retitled itself. A
682
+ // function as the replacement is taken literally.
683
+ //
684
+ // And inside a <script>, escaping `</script>` is not enough: `<!--` and
685
+ // `<script` change how the HTML parser reads the block. Encoding every `<`, `>`
686
+ // and `&` as \u escapes leaves nothing for it to act on, and JSON.parse reads
687
+ // them back unchanged. U+2028/2029 are line terminators to older JavaScript.
688
+ const scriptSafe = (value) => JSON.stringify(value)
689
+ .replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
690
+ .replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
691
+ const htmlSafe = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
646
692
  const html = readFileSync(templatePath, 'utf8')
647
- .replace('__RECAP__', JSON.stringify(sb).replace(/<\/script>/gi, '<\\/script>'))
648
- .replaceAll('__TITLE__', `${sb.name} · ${sb.scenes[0].title}`)
649
- .replaceAll('__DESC__', `Recap of a Nearly session: ${sb.scenes[0].narration}`);
693
+ .replace('__RECAP__', () => scriptSafe(sb))
694
+ .replaceAll('__TITLE__', () => htmlSafe(`${sb.name} · ${sb.scenes[0].title}`))
695
+ .replaceAll('__DESC__', () => htmlSafe(`Recap of a Nearly session: ${sb.scenes[0].narration}`));
650
696
  const outPath = join(outDir, `${slug}.html`);
651
697
  writeFileSync(outPath, html);
652
698
 
@@ -63,7 +63,8 @@ export function installed() {
63
63
  // --agent=cursor,gemini exactly these
64
64
  // --agent=all every adapter there is
65
65
  export function choose(repo, argv = []) {
66
- const flag = argv.find((a) => a.startsWith('--agent='));
66
+ const at = argv.indexOf('--agent');
67
+ const flag = argv.find((a) => a.startsWith('--agent=')) ?? (at !== -1 && argv[at + 1] ? `--agent=${argv[at + 1]}` : undefined);
67
68
  if (flag) {
68
69
  const want = flag.slice('--agent='.length).split(',').map((s) => s.trim()).filter(Boolean);
69
70
  if (want.includes('all')) return { chosen: ADAPTERS, unknown: [] };