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.
@@ -16,17 +16,19 @@ import { join, resolve, basename, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { execFileSync, spawnSync } from 'node:child_process';
18
18
  import { paths, dataRoot } from '../server/paths.mjs';
19
- import { ADAPTERS } from '../server/adapters.mjs';
19
+ import { ADAPTERS, OURS_RE } from '../server/adapters.mjs';
20
20
 
21
21
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
22
22
  const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
23
23
  const PORT = Number(process.env.NEARLY_PORT || 47653);
24
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`;
25
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
26
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
27
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
28
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
29
+ const green = (s) => (COLOR ? `\x1b[32m${s}\x1b[0m` : String(s));
30
+ const red = (s) => (COLOR ? `\x1b[31m${s}\x1b[0m` : String(s));
31
+ const yellow = (s) => (COLOR ? `\x1b[33m${s}\x1b[0m` : String(s));
30
32
 
31
33
  const blockers = [];
32
34
  function say(ok, label, detail, fix) {
@@ -45,7 +47,8 @@ console.log(` ${bold('Nearly')} ${dim(repo)}`);
45
47
  console.log('');
46
48
 
47
49
  // 1 — a repo at all
48
- const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
50
+ // A repo with no commits has no HEAD to name, but it does have a branch.
51
+ const branch = git(['symbolic-ref', '--short', 'HEAD']) || git(['rev-parse', '--abbrev-ref', 'HEAD']) || '(no branch)';
49
52
  if (!existsSync(join(repo, '.git'))) {
50
53
  say(false, 'a git repository', 'this is not one', `cd into the repo you work in, then run nearly`);
51
54
  } else {
@@ -56,11 +59,69 @@ if (!existsSync(join(repo, '.git'))) {
56
59
  // and that is the single most common reason for an empty pull request.
57
60
  const gated = ADAPTERS.filter((a) => {
58
61
  const f = join(repo, a.config);
59
- try { return existsSync(f) && /nearly/i.test(readFileSync(f, 'utf8')); } catch { return false; }
62
+ try { return existsSync(f) && OURS_RE.test(readFileSync(f, 'utf8')); } catch { return false; }
60
63
  });
61
64
  if (gated.length) say(true, 'agents gated here', gated.map((a) => a.name).join(', '));
62
65
  else say(false, 'agents gated here', 'none', 'run `nearly` in this repo to turn it on');
63
66
 
67
+ // Configured is not the same as working, and the difference is invisible.
68
+ // Hooks fail open on purpose — a broken one must never wedge an agent — so a
69
+ // command that cannot be found produces silence, and silence looks exactly like
70
+ // a session nobody ran. Everything above can be green while nothing is gated.
71
+ //
72
+ // So run the hook this repo actually has, the way the agent runs it, and see
73
+ // whether an answer comes back.
74
+ if (gated.length) {
75
+ const cc = gated.find((a) => a.id === 'claude-code') || gated[0];
76
+ let cmd = null;
77
+ try {
78
+ const cfg = JSON.parse(readFileSync(join(repo, cc.config), 'utf8'));
79
+ const walk = (o) => {
80
+ if (!o || typeof o !== 'object') return;
81
+ if (typeof o.command === 'string' && OURS_RE.test(o.command) && /pre-tool/.test(o.command)) cmd = o.command;
82
+ for (const v of Object.values(o)) walk(v);
83
+ };
84
+ walk(cfg);
85
+ } catch { /* unreadable config */ }
86
+
87
+ if (!cmd) {
88
+ say(null, 'hooks actually fire', 'could not find the pre-tool hook to try');
89
+ } else {
90
+ const probe = JSON.stringify({
91
+ session_id: `nearly-doctor-${Date.now()}`, cwd: repo,
92
+ hook_event_name: 'PreToolUse', tool_name: 'Read',
93
+ tool_input: { file_path: join(repo, 'nearly-doctor-probe') }, tool_use_id: 'doctor',
94
+ });
95
+ // shell: true because the agent runs these through a shell, and on Windows
96
+ // the installed command is a .cmd that will not spawn any other way.
97
+ // Run it with the PATH the agent will have, not this process's. Launched
98
+ // through npx, this process has npx's temporary bin first on PATH, holding a
99
+ // `nearly` that vanishes when npx exits — so a hook calling `nearly` passed
100
+ // here and could never run for the agent. npm scripts add node_modules/.bin
101
+ // the same way.
102
+ const sep = process.platform === 'win32' ? ';' : ':';
103
+ const agentPath = String(process.env.PATH || process.env.Path || '').split(sep)
104
+ .filter((d) => d && !/[\\/]_npx[\\/]/.test(d) && !/[\\/]node_modules[\\/]\.bin$/.test(d)).join(sep);
105
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^npm_/i.test(k)));
106
+ env.PATH = agentPath;
107
+ if (process.platform === 'win32') env.Path = agentPath;
108
+ const r = spawnSync(cmd, { input: probe, shell: true, encoding: 'utf8', timeout: 30_000, env });
109
+ const decided = /permissionDecision|"decision"|"permission"/.test(r.stdout || '');
110
+ if (decided) {
111
+ say(true, 'hooks actually fire', 'the gate answered a test call');
112
+ } else {
113
+ const lines = String(r.stderr || '').split('\n').map((l) => l.trim()).filter(Boolean)
114
+ .filter((l) => !/^(at |node:internal|npm (warn|notice)|\^+$|Node\.js v)/.test(l));
115
+ const pick = lines.find((l) => /not found|no such file|cannot find|ENOENT|EACCES|permission denied|is not recognized|Error:/i.test(l))
116
+ || lines[lines.length - 1];
117
+ const why = (r.error && r.error.message) || pick
118
+ || (r.status !== 0 ? `the hook command exited ${r.status}` : 'the hook ran but answered nothing');
119
+ say(false, 'hooks actually fire', why,
120
+ `the hook command in ${cc.config} does not work here, so nothing is gated and nothing is recorded — check that \`nearly\` runs in a plain shell, then re-run \`nearly\` to rewrite the hooks`);
121
+ }
122
+ }
123
+ }
124
+
64
125
  // 3 — the server, and whether it is this build
65
126
  let health = null;
66
127
  try {
@@ -93,7 +154,11 @@ if (!health) {
93
154
  // them, so this cannot disagree with it.
94
155
  const recDir = paths.recordings();
95
156
  let runs = 0, otherBranches = new Set();
96
- const real = (p) => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
157
+ const real = (p) => {
158
+ let r;
159
+ try { r = realpathSync.native(resolve(p)); } catch { r = resolve(p); }
160
+ return process.platform === 'win32' ? r.toLowerCase() : r; // same place, spelled differently
161
+ };
97
162
  try {
98
163
  for (const f of readdirSync(recDir).filter((f) => f.endsWith('.jsonl'))) {
99
164
  let created = null;
package/scripts/hook.mjs CHANGED
@@ -25,8 +25,9 @@
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
+ import { realpathSync, readFileSync } from 'node:fs';
29
29
  import { byId } from '../server/adapters.mjs';
30
+ import { keep } from '../server/reclaim.mjs';
30
31
 
31
32
  const HOST = '127.0.0.1';
32
33
  const PORT = Number(process.env.NEARLY_PORT || 47653);
@@ -41,6 +42,10 @@ if (!event) process.exit(0);
41
42
 
42
43
  // An unknown id is a typo in a config file, not a reason to wedge the agent.
43
44
  const adapter = flag ? byId(flag.slice('--adapter='.length)) : null;
45
+ // Nobody is at the keyboard. Set by attach --auto, carried per repo rather than
46
+ // as machine-wide state, because supervising one project and not another is the
47
+ // normal case.
48
+ const unattended = args.includes('--auto');
44
49
 
45
50
  const body = await new Promise((r) => {
46
51
  let s = '';
@@ -51,6 +56,7 @@ const body = await new Promise((r) => {
51
56
  });
52
57
 
53
58
  const realRoot = (() => { try { return realpathSync(root); } catch { return root; } })();
59
+ const VERSION = (() => { try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; } catch { return null; } })();
54
60
 
55
61
  // Is the thing on this port *us*?
56
62
  //
@@ -63,8 +69,8 @@ async function up(ms = 400) {
63
69
  const r = await fetch(`${BASE}/health`, { signal: AbortSignal.timeout(ms) });
64
70
  if (!r.ok) return false;
65
71
  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;
72
+ if (h.root === realRoot || keep(h.version, VERSION)) return true;
73
+ return 'stale'; // older, or from before servers said what they were
68
74
  } catch { return false; }
69
75
  }
70
76
 
@@ -88,7 +94,7 @@ if (health === 'stale') {
88
94
  // only people who ever get the fix are the ones who happen to re-run `nearly`
89
95
  // and read the message.
90
96
  const { reclaim } = await import('../server/reclaim.mjs');
91
- const { outcome } = await reclaim({ port: PORT, base: BASE, root: realRoot });
97
+ const { outcome } = await reclaim({ port: PORT, base: BASE, root: realRoot, version: VERSION });
92
98
  // 'busy' and 'stuck' both mean it is still there. Talking to an old server
93
99
  // still gates the call, which is better than not gating it.
94
100
  health = (outcome === 'stood-down' || outcome === 'ended' || outcome === 'free') ? false : true;
@@ -111,7 +117,8 @@ if (adapter && adapter.normalize) {
111
117
 
112
118
  try {
113
119
  const hold = adapter?.holdMs ? `&hold=${adapter.holdMs}` : '';
114
- const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}`, {
120
+ const auto = unattended ? '&auto=1' : '';
121
+ const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}${auto}`, {
115
122
  method: 'POST',
116
123
  headers: { 'content-type': 'application/json' },
117
124
  body: payload,
@@ -1,7 +1,7 @@
1
1
  // Install a git pre-push hook that builds the branch's session record and
2
2
  // offers to post it to the pull request.
3
3
  //
4
- // node scripts/install-push-hook.mjs <repo-path> [--remove]
4
+ // node scripts/install-push-hook.mjs <repo-path> [--cmd "<how to run nearly>"] [--remove]
5
5
  //
6
6
  // Pushing is the moment your work stops being yours and becomes someone else's
7
7
  // to review, so it is the right moment to hand over the record. The hook:
@@ -22,7 +22,14 @@ import { fileURLToPath } from 'node:url';
22
22
 
23
23
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
24
24
  const argv = process.argv.slice(2);
25
- const repo = resolve(argv.find((a) => !a.startsWith('--')) || '.');
25
+ const cmdAt = argv.indexOf('--cmd');
26
+ // How the hook runs Nearly. attach passes the same command it wrote into the
27
+ // agent hooks, so both survive the same things. The default — this file's own
28
+ // folder — is only right for a checkout: run through npx it was the npx cache,
29
+ // and once npm cleared that every push printed a stack trace while doctor went
30
+ // on reporting the hook as installed.
31
+ const RUN = cmdAt !== -1 && argv[cmdAt + 1] ? argv[cmdAt + 1] : `node ${JSON.stringify(join(root, 'bin', 'nearly.mjs'))}`;
32
+ const repo = resolve(argv.find((a, i) => !a.startsWith('--') && i !== cmdAt + 1) || '.');
26
33
  const remove = argv.includes('--remove');
27
34
 
28
35
  if (!existsSync(join(repo, '.git'))) {
@@ -35,25 +42,37 @@ const MARKER = 'x-session-record-hook';
35
42
  const hooksDir = join(repo, '.git', 'hooks');
36
43
  const hookPath = join(hooksDir, 'pre-push');
37
44
 
45
+ // Ours by the marker, or by the exact comment older versions wrote. Matching the
46
+ // word "nearly" anywhere claimed any hook that happened to mention it.
47
+ const ours = (text) => text.includes(MARKER) || /^# (control-room|nearly): hand the session record/m.test(text);
48
+
38
49
  if (remove) {
39
- if (existsSync(hookPath)) { rmSync(hookPath); console.log(`Removed ${hookPath}`); }
40
- else console.log('No pre-push hook to remove.');
50
+ if (!existsSync(hookPath)) { console.log('No pre-push hook to remove.'); process.exit(0); }
51
+ // Installing refused to overwrite someone else's hook; removing used to delete
52
+ // it anyway, and said "removed" while doing so.
53
+ if (!ours(readFileSync(hookPath, 'utf8'))) {
54
+ console.error(`${hookPath} was not written by Nearly, so it was left alone.`);
55
+ process.exit(1);
56
+ }
57
+ rmSync(hookPath);
58
+ console.log(`Removed ${hookPath}`);
41
59
  process.exit(0);
42
60
  }
43
61
 
44
62
  if (existsSync(hookPath)) {
45
63
  const existing = readFileSync(hookPath, 'utf8');
46
- // Recognise it by a marker that does not change when the product is renamed,
47
- // and still recognise hooks written before this file existed.
48
- const mine = existing.includes(MARKER) || /control-room|nearly/.test(existing);
64
+ const mine = ours(existing);
49
65
  if (!mine) {
50
66
  console.error(`${hookPath} already exists and was not written by the Nearly.`);
51
67
  console.error('Refusing to overwrite it. Move it aside, or add this line to it yourself:');
52
- console.error(` node ${join(root, 'scripts', 'push-record.mjs')} "${repo}" || true`);
68
+ console.error(` ${RUN} push-record "${repo}" || true`);
53
69
  process.exit(1);
54
70
  }
55
71
  }
56
72
 
73
+ // Safe inside the double quotes of a sh script.
74
+ const shq = (s) => String(s).replace(/[\\"$`]/g, '\\$&');
75
+
57
76
  mkdirSync(hooksDir, { recursive: true });
58
77
  writeFileSync(hookPath, `#!/bin/sh
59
78
  # ${MARKER}
@@ -69,11 +88,10 @@ writeFileSync(hookPath, `#!/bin/sh
69
88
  # ENXIO, when no terminal is attached. The open has to happen inside a subshell
70
89
  # too: a failed redirection is reported by the shell itself, so redirecting the
71
90
  # command's stderr does not silence it, but redirecting the subshell's does.
72
- CR="${join(root, 'scripts', 'push-record.mjs')}"
73
91
  if (: >/dev/tty) 2>/dev/null; then
74
- node "$CR" "${repo}" </dev/tty >/dev/tty 2>&1 || true
92
+ ${RUN} push-record "${shq(repo)}" </dev/tty >/dev/tty 2>&1 || true
75
93
  else
76
- NEARLY_NO_TTY=1 node "$CR" "${repo}" || true
94
+ NEARLY_NO_TTY=1 ${RUN} push-record "${shq(repo)}" || true
77
95
  fi
78
96
  exit 0
79
97
  `);
@@ -59,7 +59,7 @@ lines.push('');
59
59
  if (outcome?.notDone?.length) {
60
60
  lines.push(`> **${outcome.notDone.length} thing${outcome.notDone.length > 1 ? 's' : ''} the agent wanted to do did not happen.** The diff cannot show you this.`);
61
61
  lines.push('>');
62
- for (const n of outcome.notDone) lines.push(`> - \`${n.tool}\` · \`${n.what}\` — ${n.by === 'policy' ? 'blocked by policy' : 'refused by the supervisor'}`);
62
+ for (const n of outcome.notDone) lines.push(`> - \`${n.tool}\` · \`${n.what}\` — ${n.by === 'policy' ? 'blocked by policy' : n.by === 'timeout' ? 'nobody answered, so it was refused' : 'refused by the supervisor'}`);
63
63
  lines.push('');
64
64
  }
65
65
  lines.push('| ' + cover.stats.map(([k]) => k).join(' | ') + ' |');
@@ -30,9 +30,11 @@ const URL_BASE = (process.env.NEARLY_URL_BASE || configured() || '').replace(/\/
30
30
  // wait at a push. Opt in with NEARLY_AUDIO=1 when you are making the good one.
31
31
  const WANT_AUDIO = process.env.NEARLY_AUDIO === '1';
32
32
 
33
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
34
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
35
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
33
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
34
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
35
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
36
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
37
+ const red = (s) => (COLOR ? `\x1b[31m${s}\x1b[0m` : String(s));
36
38
 
37
39
  function bail(msg) { if (msg) console.error(dim(`nearly: ${msg}`)); process.exit(0); }
38
40
 
@@ -76,7 +78,7 @@ if (notDone.length) {
76
78
  console.log('');
77
79
  console.log(` ${red('These never happened, and the diff will not show them:')}`);
78
80
  for (const n of notDone) {
79
- const by = n.by === 'policy' ? 'blocked by policy' : 'you refused it';
81
+ const by = n.by === 'policy' ? 'blocked by policy' : n.by === 'timeout' ? 'nobody answered' : 'you refused it';
80
82
  console.log(` · ${n.tool} ${n.what} ${dim(`(${by})`)}`);
81
83
  }
82
84
  }
@@ -91,7 +93,7 @@ console.log('');
91
93
  // hear about a fix.
92
94
  try {
93
95
  const { checkForUpdate, applyUpdate } = await import('./update-check.mjs');
94
- applyUpdate(await checkForUpdate());
96
+ applyUpdate(await checkForUpdate(), { background: true });
95
97
  } catch { /* never worth failing a push over */ }
96
98
 
97
99
  // Finding the pull request needs gh. Not having it and not having a pull
@@ -0,0 +1,84 @@
1
+ // Where Nearly installs itself so hooks have something stable to call.
2
+ //
3
+ // Hooks run on every tool call, so they need a command that is fast, will still
4
+ // exist tomorrow, and picks up upgrades. `npm install -g` was supposed to give
5
+ // that and failed in two common ways, both silently:
6
+ //
7
+ // · Windows: spawning npm.cmd without a shell is refused by every current
8
+ // Node release (the 2024 fix for CVE-2024-27980). So the install failed on
9
+ // every up-to-date Windows machine, every time.
10
+ // · macOS with Node from the official installer: the global prefix is
11
+ // /usr/local, owned by root, so the install fails without sudo.
12
+ //
13
+ // Either way attach fell back to `npx -y nearly-cli@<version>` in every hook —
14
+ // slower on each call, and pinned, so no fix ever reached that repo again. A
15
+ // Cursor user was stuck on the exact release that was breaking his editor.
16
+ //
17
+ // So install into a directory the user always owns. No root, no PATH, no
18
+ // global prefix to be wrong about.
19
+
20
+ import { existsSync, mkdirSync } from 'node:fs';
21
+ import { join, resolve } from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { homedir } from 'node:os';
24
+
25
+ // Not paths.mjs: from a checkout that resolves to the checkout, and a runtime
26
+ // install must live in the user's own space whatever ran it.
27
+ export const RUNTIME = join(process.env.NEARLY_HOME || join(homedir(), '.nearly'), 'runtime');
28
+ export const ENTRY = join(RUNTIME, 'node_modules', 'nearly-cli', 'bin', 'nearly.mjs');
29
+
30
+ export const hasRuntime = () => existsSync(ENTRY);
31
+
32
+ export function isRuntime(root) {
33
+ const rel = resolve(root);
34
+ return rel === resolve(RUNTIME) || rel.startsWith(resolve(RUNTIME) + (process.platform === 'win32' ? '\\' : '/'));
35
+ }
36
+
37
+ // The command a hook runs. Quoted, because a home directory with a space in it
38
+ // is ordinary on Windows and would otherwise split into two arguments.
39
+ export const runtimeCommand = () => `node ${JSON.stringify(ENTRY)}`;
40
+
41
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
42
+
43
+ // Everything that installs Nearly runs inside `npx nearly-cli`, and npx hands its
44
+ // own settings to its children as npm_config_* variables. A nested npm install
45
+ // inherits them and obeys them — on one machine `allow_scripts` turned a plain
46
+ // install into EALLOWSCRIPTS, and on another it will be whatever that person's
47
+ // npm config happens to hold. The identical command succeeds with them removed.
48
+ export function cleanEnv() {
49
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^npm_/i.test(k)));
50
+ }
51
+
52
+ // npm reports a failure as key/value lines (`code`, `syscall`, `path`, `errno`)
53
+ // followed by a sentence, and ends almost every one with "A complete log of this
54
+ // run can be found in". Showing the last line pointed people at a log file;
55
+ // showing the first two showed `code ENOENT — syscall open` and dropped the part
56
+ // that says which file. The error code plus npm's own sentence is the answer.
57
+ const KEYS = /^(code|syscall|path|errno|dest|spawnargs|signal|cmd|cwd|file|type|stack|A complete log)\b/i;
58
+ export function npmError(r) {
59
+ const lines = String(r.stderr || r.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
60
+ const body = (l) => l.replace(/^npm (error|ERR!)\s*/i, '');
61
+ const errs = lines.filter((l) => /^npm (error|ERR!)/i.test(l)).map(body);
62
+ const code = errs.find((l) => /^code\b/i.test(l))?.replace(/^code\s+/i, '');
63
+ const sentence = errs.filter((l) => !KEYS.test(l)).map((l) => l.replace(/^[a-z]+ (?=[A-Z])/, ''))[0];
64
+ const said = [code, sentence].filter(Boolean);
65
+ // Drop the code when the sentence already starts with it.
66
+ if (said.length === 2 && said[1].startsWith(said[0])) said.shift();
67
+ return said.join(' — ') || lines.filter((l) => !/complete log of this run/i.test(l)).pop() || `npm exited ${r.status}`;
68
+ }
69
+
70
+ export function installRuntime(version) {
71
+ try { mkdirSync(RUNTIME, { recursive: true }); } catch (e) { return { ok: false, error: e.message }; }
72
+ // A tarball or path can stand in for the registry — how the tests install a
73
+ // build that has not been published yet.
74
+ const spec = process.env.NEARLY_INSTALL_SPEC || `nearly-cli@${version}`;
75
+ const r = spawnSync(NPM, ['install', '--prefix', RUNTIME, spec, '--no-save', '--no-fund', '--no-audit', '--loglevel=error'], {
76
+ encoding: 'utf8', timeout: 180_000,
77
+ // Windows will not spawn a .cmd any other way.
78
+ shell: process.platform === 'win32',
79
+ env: cleanEnv(),
80
+ });
81
+ if (r.error) return { ok: false, error: r.error.message };
82
+ if (r.status !== 0 || !hasRuntime()) return { ok: false, error: npmError(r) };
83
+ return { ok: true };
84
+ }
@@ -19,7 +19,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from
19
19
  import { join, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { homedir, tmpdir } from 'node:os';
22
- import { spawnSync, execFileSync } from 'node:child_process';
22
+ import { spawn, spawnSync, execFileSync } from 'node:child_process';
23
+ import { installRuntime, isRuntime, cleanEnv } from './runtime.mjs';
23
24
 
24
25
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
25
26
  const DAY = 24 * 60 * 60 * 1000;
@@ -27,8 +28,10 @@ const DAY = 24 * 60 * 60 * 1000;
27
28
  // npm is a .cmd shim on Windows and Node will not run one through spawn unless
28
29
  // it is named exactly. Without this, installing and upgrading both fail there.
29
30
  const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
30
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
31
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
31
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
32
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
33
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
34
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
32
35
 
33
36
  function stampPath() {
34
37
  const dir = process.env.XDG_CACHE_HOME || join(homedir() || tmpdir(), '.cache');
@@ -46,6 +49,9 @@ function compare(a, b) {
46
49
  // Updating in place only makes sense for a global install. An npx run is
47
50
  // ephemeral and a checkout belongs to whoever cloned it.
48
51
  function installKind() {
52
+ // Installed by attach into ~/.nearly/runtime. It upgrades itself in place, so
53
+ // every repo whose hooks point there moves with it.
54
+ if (isRuntime(root)) return 'runtime';
49
55
  if (/[\\/]_npx[\\/]/.test(root)) return 'npx';
50
56
  try {
51
57
  const bin = execFileSync(process.platform === 'win32' ? 'where' : 'which', ['nearly'],
@@ -84,7 +90,12 @@ export async function checkForUpdate() {
84
90
  } catch { return null; }
85
91
  }
86
92
 
87
- export function applyUpdate(u) {
93
+ // `background` is for the push path. An update installing in the foreground
94
+ // held `git push` for up to two minutes, and a push that hangs reads as a push
95
+ // that is broken — which is a strange thing for a tool that must never block a
96
+ // push to do. Commands a person typed and is watching still install in front
97
+ // of them, because there they want to know it happened.
98
+ export function applyUpdate(u, { background = false } = {}) {
88
99
  if (!u) return;
89
100
 
90
101
  if (u.major) {
@@ -95,6 +106,27 @@ export function applyUpdate(u) {
95
106
  return;
96
107
  }
97
108
 
109
+ if (u.kind === 'runtime') {
110
+ if (background) {
111
+ try {
112
+ const child = spawn(process.execPath, [join(root, 'scripts', 'update-check.mjs'), '--install-runtime', u.to],
113
+ { detached: true, stdio: 'ignore' });
114
+ child.on('error', () => { /* next time */ });
115
+ child.unref();
116
+ console.log('');
117
+ console.log(dim(` Nearly ${u.to} is out (you have ${u.from}) — updating in the background.`));
118
+ } catch { /* never worth failing a push over */ }
119
+ return;
120
+ }
121
+ process.stdout.write(dim(` Updating Nearly ${u.from} → ${u.to}… `));
122
+ const r = installRuntime(u.to);
123
+ console.log(r.ok ? 'done' : 'could not');
124
+ if (!r.ok) console.log(` ${r.error}`);
125
+ else console.log(dim(' Every repo you turned it on for is now on the new version.'));
126
+ console.log('');
127
+ return;
128
+ }
129
+
98
130
  if (u.kind !== 'global') {
99
131
  console.log('');
100
132
  console.log(` ${bold(`Nearly ${u.to} is out`)} ${dim(`(you have ${u.from})`)}`);
@@ -102,9 +134,24 @@ export function applyUpdate(u) {
102
134
  return;
103
135
  }
104
136
 
137
+ if (background) {
138
+ try {
139
+ // Detached and forgotten: the push goes ahead now and the new version is
140
+ // in place by the next command. A .cmd on Windows only spawns through a
141
+ // shell, which is the same trap npm itself fell into here once already.
142
+ const child = spawn(NPM, ['install', '-g', `${u.name}@${u.to}`, '--silent', '--no-fund', '--no-audit'],
143
+ { detached: true, stdio: 'ignore', shell: process.platform === 'win32', env: cleanEnv() });
144
+ child.on('error', () => { /* offline, or no permission: next time */ });
145
+ child.unref();
146
+ console.log('');
147
+ console.log(dim(` Nearly ${u.to} is out (you have ${u.from}) — updating in the background.`));
148
+ } catch { /* never worth failing a push over */ }
149
+ return;
150
+ }
151
+
105
152
  process.stdout.write(dim(` Updating Nearly ${u.from} → ${u.to}… `));
106
153
  const r = spawnSync(NPM, ['install', '-g', `${u.name}@${u.to}`, '--silent', '--no-fund', '--no-audit'],
107
- { encoding: 'utf8', timeout: 120_000 });
154
+ { encoding: 'utf8', timeout: 120_000, shell: process.platform === 'win32', env: cleanEnv() });
108
155
 
109
156
  if (r.status === 0) {
110
157
  console.log('done');
@@ -117,3 +164,9 @@ export function applyUpdate(u) {
117
164
  }
118
165
  console.log('');
119
166
  }
167
+
168
+ // `node update-check.mjs --install-runtime <version>` — what the background
169
+ // updater runs, detached, so a push never waits on npm.
170
+ if (process.argv[2] === '--install-runtime' && process.argv[3]) {
171
+ installRuntime(process.argv[3]);
172
+ }
@@ -126,10 +126,20 @@ function writeJson(file, obj) {
126
126
  writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
127
127
  }
128
128
 
129
- // Ours is anything that runs nearly. Matching on that rather than on a version
130
- // or a path is what makes attach safe to re-run, and what stopped a rename from
131
- // orphaning hooks the last time.
132
- const isOurs = (h) => /nearly/i.test(JSON.stringify(h ?? ''));
129
+ // Ours: a hook command that runs Nearly's hook, in any form attach has ever
130
+ // written `nearly hook <event>`, `node "…/nearly.mjs" hook <event>`,
131
+ // `npx -y nearly-cli@x hook <event>`, `node "…/scripts/hook.mjs" <event>`, or
132
+ // the old HTTP hooks. Matching the word "nearly" anywhere claimed any hook that
133
+ // mentioned it, and attach deleted a user's `nearly-finished-notifier/notify.sh`.
134
+ // The quote-and-backslash run tolerates the same command read out of raw JSON.
135
+ export const OURS_RE = /(?:^|[\s"'/\\])(?:nearly(?:\.mjs)?|nearly-cli@\S+?|hook\.mjs)[\\"']*\s+(?:hook\s+)?(?:session-start|prompt|pre-tool|post-tool|stop|session-end|subagent-stop)\b|:\d+\/hooks\/(?:session-start|prompt|pre-tool|post-tool|stop|session-end|subagent-stop)\b/;
136
+
137
+ export function isOurs(value) {
138
+ if (value == null) return false;
139
+ if (typeof value === 'string') return OURS_RE.test(value);
140
+ if (typeof value === 'object') return Object.values(value).some(isOurs);
141
+ return false;
142
+ }
133
143
 
134
144
  // Strip our entries out of an event map shaped { event: [entry, ...] }, and drop
135
145
  // events we emptied so the file does not fill with husks.
@@ -226,7 +236,13 @@ export const ADAPTERS = [
226
236
  cfg.hooks = stripEvents(cfg.hooks || {});
227
237
  for (const [their, ours] of Object.entries(this.events)) {
228
238
  cfg.hooks[their] = [...(cfg.hooks[their] || []),
229
- { command: cmdFor(ours), timeout: holdFor(ours), failClosed: ours === 'pre-tool' }];
239
+ // Fail open, as every other harness does. failClosed only ever
240
+ // applied when the gate did not answer — a slow npx, a server that
241
+ // had not started — and then it denied every write in the editor.
242
+ // A reported Cursor session could not edit a single file. The
243
+ // never-rules do not depend on this: the server enforces them whenever
244
+ // it is reachable.
245
+ { command: cmdFor(ours), timeout: holdFor(ours), failClosed: false }];
230
246
  }
231
247
  writeJson(file, cfg);
232
248
  return { file };
@@ -362,11 +378,18 @@ export const ADAPTERS = [
362
378
  const cfg = { version: 1, hooks: {} };
363
379
  for (const [their, ours] of Object.entries(this.events)) {
364
380
  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) }];
381
+ // Two products read this file and disagree about the key. Copilot CLI
382
+ // takes `bash` and `powershell`; VS Code's agent mode takes `windows`,
383
+ // `linux` and `osx` as per-platform overrides and does not document the
384
+ // other two at all. Writing both sets costs a few bytes. Guessing wrong
385
+ // means the hook never runs, and a hook that never runs looks exactly
386
+ // like a week in which nobody did any work.
387
+ cfg.hooks[their] = [{
388
+ type: 'command', command: run,
389
+ bash: run, powershell: run,
390
+ windows: run, linux: run, osx: run,
391
+ timeoutSec: holdFor(ours), timeout: holdFor(ours),
392
+ }];
370
393
  }
371
394
  writeJson(file, cfg); // our own file; nobody else's entries to keep
372
395
  return { file };
package/server/index.mjs CHANGED
@@ -351,7 +351,12 @@ function decide(sid, id, decision, why, scope = 'once') {
351
351
  clearTimeout(p.timer);
352
352
  s.pending.delete(id);
353
353
  if (scope === 'always') rules.set(p.key, decision === 'allow' ? 'log' : 'never');
354
- p.respond(decision, why);
354
+ // One call can arrive down more than one hook — VS Code reads both
355
+ // .claude/settings.local.json and .github/hooks/*.json, so a repo wired for
356
+ // Claude Code and Copilot fires twice for the same tool_use_id. Everyone who
357
+ // asked gets the same answer; answering only the last one left the first hook
358
+ // hanging until the agent's own timeout, which looks like the agent freezing.
359
+ for (const r of p.responders) r(decision, why);
355
360
  record(sid, { type: 'decision', id, decision, why, scope, tool: p.tool, key: p.key, waitedMs: Date.now() - p.at });
356
361
  if (s.pending.size === 0 && s.state === 'waiting') s.state = 'working';
357
362
  broadcast({ type: 'session-state', session: sid, state: s.state });
@@ -422,7 +427,14 @@ const server = http.createServer(async (req, res) => {
422
427
  }
423
428
 
424
429
  if (ev === 'pre-tool') {
425
- const { tier, reason } = classifyWith(hook, rules);
430
+ // Unattended: nobody is going to answer, so holding a call for two
431
+ // minutes and then failing closed does not protect anything — it just
432
+ // stalls the run and teaches people to turn the gate off. The never-rules
433
+ // still bite, because those never needed a person. Everything that would
434
+ // have been asked is done and written down instead.
435
+ const unattended = url.searchParams.get('auto') === '1';
436
+ let { tier, reason } = classifyWith(hook, rules);
437
+ if (unattended && tier === 'ask') { tier = 'log'; reason = 'allowed unattended — nobody was asked'; }
426
438
  const id = hook.tool_use_id || randomUUID();
427
439
  // Policy keys on the canonical name so a rule means the same thing in every
428
440
  // harness; the record shows the harness's own name so it stays truthful
@@ -432,17 +444,24 @@ const server = http.createServer(async (req, res) => {
432
444
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `nearly: ${why}` },
433
445
  });
434
446
  if (!s) return respond('deny', 'unknown session');
435
- if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier }); return respond('deny', `never (${reason})`); }
436
- if (tier === 'log') { record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier }); return respond('allow', `do and log (${reason})`); }
447
+ if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier, unattended }); return respond('deny', `never (${reason})`); }
448
+ if (tier === 'log') {
449
+ record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: unattended ? 'auto' : 'policy', tool: shown, input: hook.tool_input, tier });
450
+ return respond('allow', unattended ? reason : `do and log (${reason})`);
451
+ }
437
452
  // ask: hold the response until the UI decides, or fail closed
438
453
  // A harness may say it will not wait as long as we would. It can shorten
439
454
  // the deadline, never lengthen it: the point of the cap is that nobody
440
455
  // else gets to decide by not answering.
441
456
  const asked = Number(url.searchParams.get('hold')) || 0;
442
457
  const holdMs = asked > 0 ? Math.min(asked, ASK_TIMEOUT_MS) : ASK_TIMEOUT_MS;
443
- const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, respond };
458
+ // Same call, second hook: join the question already being asked rather
459
+ // than replacing it, so the person is not asked twice about one thing.
460
+ const already = s.pending.get(id);
461
+ if (already) { already.responders.push(respond); return; }
462
+ const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, responders: [respond] };
444
463
  item.timer = setTimeout(() => decide(sid, id, 'deny',
445
- `no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
464
+ `no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`, 'timeout'), holdMs);
446
465
  s.pending.set(id, item);
447
466
  s.state = 'waiting';
448
467
  record(sid, { type: 'ask', ...pendingView(item) });