nubos-pilot 1.2.3 → 1.2.4

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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to nubos-pilot are documented in this file. Format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning
5
5
  follows [SemVer](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.2.4] - 2026-06-15
8
+
9
+ Fixed a recursion fault in the in-session hooks that could spawn an unbounded cascade of headless `claude -p` processes.
10
+
11
+ - The Stop-hook security review and continuous-learning capture each spawn a headless `claude -p` to do their work. That headless run re-fires the same SessionStart/Stop hooks, which spawned another headless run, and so on — a fork bomb of `claude`, `np-tools` and duplicated MCP servers that survived closing the terminal. nubos-pilot now marks every headless spawn with `NUBOS_PILOT_HEADLESS=1` and a `NUBOS_PILOT_HOOK_DEPTH` counter; the hooks no-op immediately inside a headless run, so the chain stops at exactly one level.
12
+ - Three independent guards back this up: the hook scripts and the `security`/`learnings` backends exit early when `NUBOS_PILOT_HEADLESS` is set; `spawn-headless` refuses to start a nested headless run (reentrancy + depth cap, default one level); and a per-agent lockfile under `.nubos-pilot/run/` bounds concurrent headless runs to one per agent even if the environment is not inherited. Headless runs already carry a hard timeout with SIGKILL, so a hung review cannot linger.
13
+ - Escape hatch: the guard keys off `NUBOS_PILOT_HEADLESS`, set automatically on the spawned `claude` — do not set it in your own shell or the in-session hooks will silently no-op. Raise the depth cap with `NUBOS_PILOT_MAX_HOOK_DEPTH` only if you understand the recursion risk.
14
+
7
15
  ## [1.2.3] — 2026-06-14
8
16
 
9
17
  Three opt-in layers that make execution cheaper, more reliable, and self-improving.
package/README.md CHANGED
@@ -169,6 +169,22 @@ load-bearing ones for users and contributors:
169
169
  See [`SECURITY.md`](./SECURITY.md) for the vulnerability disclosure policy
170
170
  and threat model.
171
171
 
172
+ ### Headless recursion guard
173
+
174
+ The in-session security review and continuous-learning hooks do their work in
175
+ a headless `claude -p` subprocess. To stop that subprocess from re-firing the
176
+ same hooks (which would cascade into an unbounded fork of `claude`/`np-tools`
177
+ processes), nubos-pilot sets `NUBOS_PILOT_HEADLESS=1` and a
178
+ `NUBOS_PILOT_HOOK_DEPTH` counter on every headless spawn. The hooks no-op when
179
+ `NUBOS_PILOT_HEADLESS` is set, `spawn-headless` refuses a nested or
180
+ depth-exceeded spawn, and a per-agent lockfile under `.nubos-pilot/run/` bounds
181
+ concurrent headless runs to one per agent.
182
+
183
+ The guard is automatic — do not export `NUBOS_PILOT_HEADLESS` in your own
184
+ shell, or the in-session hooks will silently do nothing. The depth cap is one
185
+ level; override it with `NUBOS_PILOT_MAX_HOOK_DEPTH` only if you understand the
186
+ recursion risk.
187
+
172
188
  ## Support
173
189
 
174
190
  - Bugs / features: [GitHub issues](https://github.com/Nubos-AI/nubos-pilot/issues)
@@ -6,6 +6,7 @@ const child_process = require('node:child_process');
6
6
  const { tryReadConfigPath } = require('../../lib/config.cjs');
7
7
  const ledger = require('../../lib/learnings/capture-ledger.cjs');
8
8
  const extract = require('../../lib/learnings/extract.cjs');
9
+ const headlessGuard = require('../../lib/headless-guard.cjs');
9
10
  const args = require('./_args.cjs');
10
11
 
11
12
  function _readStdin() {
@@ -60,6 +61,9 @@ async function run(argv, ctx) {
60
61
  const stdout = context.stdout || process.stdout;
61
62
  const list = Array.isArray(argv) ? argv : [];
62
63
  const verb = list[0];
64
+
65
+ if (headlessGuard.isHeadless(process.env)) return 0;
66
+
63
67
  const cfg = _cfg(cwd);
64
68
 
65
69
  // 'reset' (UserPromptSubmit) and 'run-extract' (background worker) are not
@@ -8,6 +8,7 @@ const { tryReadConfigPath } = require('../../lib/config.cjs');
8
8
  const scan = require('../../lib/security/scan.cjs');
9
9
  const ledger = require('../../lib/security/ledger.cjs');
10
10
  const review = require('../../lib/security/review.cjs');
11
+ const headlessGuard = require('../../lib/headless-guard.cjs');
11
12
  const args = require('./_args.cjs');
12
13
 
13
14
  const COMMIT_RE = /\bgit\b[\s\S]*\b(commit|push)\b/;
@@ -93,6 +94,8 @@ async function run(argv, ctx) {
93
94
  const list = Array.isArray(argv) ? argv : [];
94
95
  const verb = list[0];
95
96
 
97
+ if (headlessGuard.isHeadless(process.env)) return 0;
98
+
96
99
  const cfg = _cfg(cwd);
97
100
  if (!cfg.enabled && verb !== 'run-review') return 0;
98
101
 
@@ -8,6 +8,7 @@ const child_process = require('node:child_process');
8
8
  const { NubosPilotError, atomicWriteFileSync, appendJsonl, findProjectRoot } = require('../../lib/core.cjs');
9
9
  const runContext = require('../../lib/run-context.cjs');
10
10
  const safePath = require('../../lib/safe-path.cjs');
11
+ const headlessGuard = require('../../lib/headless-guard.cjs');
11
12
  const args = require('./_args.cjs');
12
13
 
13
14
  function _sha256(s) {
@@ -171,6 +172,22 @@ function run(argv, ctx) {
171
172
  const stdout = context.stdout || process.stdout;
172
173
  const list = Array.isArray(argv) ? argv : [];
173
174
 
175
+ if (headlessGuard.isHeadless(process.env)) {
176
+ throw new NubosPilotError(
177
+ 'spawn-headless-reentrant',
178
+ 'refusing to spawn a nested headless `claude` (NUBOS_PILOT_HEADLESS is set) — recursion guard',
179
+ { depth: headlessGuard.currentDepth(process.env) },
180
+ );
181
+ }
182
+ if (headlessGuard.depthExceeded(process.env)) {
183
+ throw new NubosPilotError(
184
+ 'spawn-headless-depth-exceeded',
185
+ 'refusing to spawn headless `claude`: hook depth ' + headlessGuard.currentDepth(process.env)
186
+ + ' has reached the cap ' + headlessGuard.maxDepth(process.env) + ' (recursion guard)',
187
+ { depth: headlessGuard.currentDepth(process.env), max: headlessGuard.maxDepth(process.env) },
188
+ );
189
+ }
190
+
174
191
  const agent = args.getFlag(list, '--agent');
175
192
  if (!agent) {
176
193
  throw new NubosPilotError(
@@ -213,6 +230,21 @@ function run(argv, ctx) {
213
230
 
214
231
  const runId = runContext.getRunId();
215
232
 
233
+ let lockRoot;
234
+ try { lockRoot = findProjectRoot(cwd); }
235
+ catch { lockRoot = cwd; }
236
+ const lock = headlessGuard.tryAcquireSpawnLock(lockRoot, agent);
237
+ if (!lock.acquired) {
238
+ throw new NubosPilotError(
239
+ 'spawn-headless-locked',
240
+ 'another headless run for agent `' + agent + '` is already active in this project (concurrency guard)',
241
+ { agent, holder: lock.holder || null },
242
+ );
243
+ }
244
+
245
+ const childEnv = _filterSpawnEnv(process.env);
246
+ Object.assign(childEnv, headlessGuard.childSpawnEnv(process.env));
247
+
216
248
  const bin = _claudeBinary();
217
249
  const claudeArgs = ['-p', '--output-format', 'json'];
218
250
  const startedAt = new Date().toISOString();
@@ -224,7 +256,7 @@ function run(argv, ctx) {
224
256
  timeout: timeoutMs,
225
257
  maxBuffer: 64 * 1024 * 1024,
226
258
  encoding: 'utf-8',
227
- env: _filterSpawnEnv(process.env),
259
+ env: childEnv,
228
260
  killSignal: 'SIGKILL',
229
261
  });
230
262
  } catch (err) {
@@ -233,6 +265,8 @@ function run(argv, ctx) {
233
265
  'failed to spawn `' + bin + '`: ' + (err && err.message),
234
266
  { bin, cause: err && err.code },
235
267
  );
268
+ } finally {
269
+ lock.release();
236
270
  }
237
271
  if (result.error && result.error.code === 'ENOENT') {
238
272
  throw new NubosPilotError(
@@ -8,6 +8,14 @@ const assert = require('node:assert/strict');
8
8
 
9
9
  const spawnHeadless = require('./spawn-headless.cjs');
10
10
  const runContext = require('../../lib/run-context.cjs');
11
+ const headlessGuard = require('../../lib/headless-guard.cjs');
12
+
13
+ function _mockClaude(r, name, body) {
14
+ const p = path.join(r, name);
15
+ fs.writeFileSync(p, body, 'utf-8');
16
+ fs.chmodSync(p, 0o755);
17
+ return p;
18
+ }
11
19
 
12
20
  const _sandboxes = [];
13
21
  const _envBackup = {};
@@ -488,6 +496,133 @@ test('SH-TRAIL-2 two sequential spawns append two parseable trail lines (jsonl i
488
496
  for (const l of lines) JSON.parse(l);
489
497
  });
490
498
 
499
+ test('SH-GUARD-1 refuses to spawn when NUBOS_PILOT_HEADLESS=1 (reentrancy guard)', () => {
500
+ const r = _mkRoot();
501
+ fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
502
+ const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
503
+ _setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
504
+ _setEnv('NUBOS_PILOT_HEADLESS', '1');
505
+ const cap = _cap();
506
+ assert.throws(
507
+ () => spawnHeadless.run(
508
+ ['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
509
+ { cwd: r, stdout: cap.stub },
510
+ ),
511
+ (err) => err && err.code === 'spawn-headless-reentrant',
512
+ );
513
+ assert.equal(fs.existsSync(path.join(r, 'out.json')), false, 'no claude must be spawned inside a headless run');
514
+ });
515
+
516
+ test('SH-GUARD-2 refuses to spawn when hook depth has reached the cap (depth guard)', () => {
517
+ const r = _mkRoot();
518
+ fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
519
+ const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
520
+ _setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
521
+ _setEnv('NUBOS_PILOT_HOOK_DEPTH', '1');
522
+ const cap = _cap();
523
+ assert.throws(
524
+ () => spawnHeadless.run(
525
+ ['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
526
+ { cwd: r, stdout: cap.stub },
527
+ ),
528
+ (err) => err && err.code === 'spawn-headless-depth-exceeded',
529
+ );
530
+ });
531
+
532
+ test('SH-GUARD-3 child env carries NUBOS_PILOT_HEADLESS=1 and depth=1 (one level deep only)', () => {
533
+ const r = _mkRoot();
534
+ fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
535
+ const mockBin = _mockClaude(r, 'mock.sh',
536
+ '#!/bin/sh\ncat > /dev/null\nprintf \'{"hl":"\'$NUBOS_PILOT_HEADLESS\'","depth":"\'$NUBOS_PILOT_HOOK_DEPTH\'"}\\n\'\n');
537
+ _setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
538
+ const cap = _cap();
539
+ const rc = spawnHeadless.run(
540
+ ['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
541
+ { cwd: r, stdout: cap.stub },
542
+ );
543
+ assert.equal(rc, 0);
544
+ const child = JSON.parse(fs.readFileSync(path.join(r, 'out.json'), 'utf-8'));
545
+ assert.equal(child.hl, '1', 'child claude must run with NUBOS_PILOT_HEADLESS=1');
546
+ assert.equal(child.depth, '1', 'child claude must run at hook depth 1');
547
+ });
548
+
549
+ test('SH-GUARD-4 refuses to spawn while a live lock for the same agent is held (concurrency guard)', () => {
550
+ const r = _mkRoot();
551
+ fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
552
+ const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
553
+ _setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
554
+ const held = headlessGuard.tryAcquireSpawnLock(r, 'np-test-critic');
555
+ assert.equal(held.acquired, true);
556
+ const cap = _cap();
557
+ try {
558
+ assert.throws(
559
+ () => spawnHeadless.run(
560
+ ['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
561
+ { cwd: r, stdout: cap.stub },
562
+ ),
563
+ (err) => err && err.code === 'spawn-headless-locked',
564
+ );
565
+ } finally {
566
+ held.release();
567
+ }
568
+ });
569
+
570
+ test('SH-GUARD-5 lock is released after a successful spawn (re-spawnable)', () => {
571
+ const r = _mkRoot();
572
+ fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
573
+ const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
574
+ _setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
575
+ const cap = _cap();
576
+ for (let i = 0; i < 2; i++) {
577
+ const rc = spawnHeadless.run(
578
+ ['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out' + i + '.json'],
579
+ { cwd: r, stdout: cap.stub },
580
+ );
581
+ assert.equal(rc, 0, 'sequential spawns must each acquire and release the lock');
582
+ }
583
+ assert.equal(fs.existsSync(headlessGuard._lockPath(r, 'np-test-critic')), false, 'no lock residue after spawns');
584
+ });
585
+
586
+ test('SH-GUARD-6 a held lock for one agent does NOT block a different agent (per-agent scope)', () => {
587
+ const r = _mkRoot();
588
+ fs.writeFileSync(
589
+ path.join(r, '.nubos-pilot', 'agents', 'np-other-critic.md'),
590
+ '---\nname: np-other-critic\n---\n\n# Role\n',
591
+ 'utf-8',
592
+ );
593
+ fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
594
+ const mockBin = _mockClaude(r, 'mock.sh', '#!/bin/sh\ncat > /dev/null\necho "{}"\n');
595
+ _setEnv('NUBOS_PILOT_CLAUDE_BIN', mockBin);
596
+ const held = headlessGuard.tryAcquireSpawnLock(r, 'np-test-critic');
597
+ assert.equal(held.acquired, true);
598
+ const cap = _cap();
599
+ try {
600
+ const rc = spawnHeadless.run(
601
+ ['--agent', 'np-other-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
602
+ { cwd: r, stdout: cap.stub },
603
+ );
604
+ assert.equal(rc, 0, 'a different agent must spawn while np-test-critic is locked');
605
+ } finally {
606
+ held.release();
607
+ }
608
+ });
609
+
610
+ test('SH-GUARD-7 lock is released even when the spawn errors (claude-not-found)', () => {
611
+ const r = _mkRoot();
612
+ fs.writeFileSync(path.join(r, 'p.md'), 'audit', 'utf-8');
613
+ _setEnv('NUBOS_PILOT_CLAUDE_BIN', path.join(r, 'no-such-binary'));
614
+ const cap = _cap();
615
+ assert.throws(
616
+ () => spawnHeadless.run(
617
+ ['--agent', 'np-test-critic', '--prompt-path', 'p.md', '--output-path', 'out.json'],
618
+ { cwd: r, stdout: cap.stub },
619
+ ),
620
+ (err) => err && err.code === 'spawn-headless-claude-not-found',
621
+ );
622
+ assert.equal(fs.existsSync(headlessGuard._lockPath(r, 'np-test-critic')), false,
623
+ 'the per-agent lock must not leak when the spawn fails');
624
+ });
625
+
491
626
  test('SH-ENV-4 NUBOS_PILOT_/CLAUDE_/ANTHROPIC_ prefixed vars pass through (whitelisted prefix)', () => {
492
627
  const parent = {
493
628
  PATH: '/usr/bin',
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+
8
+ const { atomicCreateExclusiveSync } = require('./core.cjs');
9
+
10
+ const HEADLESS_ENV = 'NUBOS_PILOT_HEADLESS';
11
+ const DEPTH_ENV = 'NUBOS_PILOT_HOOK_DEPTH';
12
+ const MAX_DEPTH_ENV = 'NUBOS_PILOT_MAX_HOOK_DEPTH';
13
+ const DEFAULT_MAX_DEPTH = 1;
14
+ const DEFAULT_LOCK_STALE_MS = 15 * 60 * 1000;
15
+
16
+ function isHeadless(env) {
17
+ const e = env || process.env;
18
+ return e[HEADLESS_ENV] === '1';
19
+ }
20
+
21
+ function currentDepth(env) {
22
+ const e = env || process.env;
23
+ const n = parseInt(e[DEPTH_ENV], 10);
24
+ return Number.isFinite(n) && n > 0 ? n : 0;
25
+ }
26
+
27
+ function maxDepth(env) {
28
+ const e = env || process.env;
29
+ const n = parseInt(e[MAX_DEPTH_ENV], 10);
30
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MAX_DEPTH;
31
+ }
32
+
33
+ function depthExceeded(env) {
34
+ return currentDepth(env) >= maxDepth(env);
35
+ }
36
+
37
+ function childSpawnEnv(env) {
38
+ const out = Object.create(null);
39
+ out[HEADLESS_ENV] = '1';
40
+ out[DEPTH_ENV] = String(currentDepth(env) + 1);
41
+ return out;
42
+ }
43
+
44
+ function _isPidAlive(pid) {
45
+ if (!Number.isInteger(pid) || pid <= 0) return false;
46
+ try { process.kill(pid, 0); return true; }
47
+ catch (err) {
48
+ if (err && err.code === 'ESRCH') return false;
49
+ if (err && err.code === 'EPERM') return true;
50
+ return true;
51
+ }
52
+ }
53
+
54
+ function _lockPath(root, agent) {
55
+ return path.join(root, '.nubos-pilot', 'run', 'headless-' + agent + '.lock');
56
+ }
57
+
58
+ function _reclaimStaleLock(lockPath) {
59
+ const aside = lockPath + '.stale.' + process.pid + '.' + crypto.randomBytes(4).toString('hex');
60
+ try { fs.renameSync(lockPath, aside); }
61
+ catch { return; }
62
+ try { fs.unlinkSync(aside); } catch {}
63
+ }
64
+
65
+ function tryAcquireSpawnLock(root, agent, opts) {
66
+ const o = opts || {};
67
+ const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : DEFAULT_LOCK_STALE_MS;
68
+ const lockPath = _lockPath(root, agent);
69
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch {}
70
+ const payload = JSON.stringify({
71
+ pid: process.pid,
72
+ agent,
73
+ hostname: os.hostname(),
74
+ acquiredAt: new Date().toISOString(),
75
+ });
76
+
77
+ for (let attempt = 0; attempt < 2; attempt++) {
78
+ try {
79
+ atomicCreateExclusiveSync(lockPath, payload);
80
+ let released = false;
81
+ return {
82
+ acquired: true,
83
+ lockPath,
84
+ release() {
85
+ if (released) return;
86
+ released = true;
87
+ let meta = null;
88
+ try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
89
+ if (meta && meta.pid !== process.pid) return;
90
+ try { fs.unlinkSync(lockPath); } catch {}
91
+ },
92
+ };
93
+ } catch (err) {
94
+ if (!err || err.code !== 'EEXIST') {
95
+ return { acquired: false, error: (err && err.code) || 'unknown' };
96
+ }
97
+ let meta = null;
98
+ let stat = null;
99
+ try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
100
+ try { stat = fs.statSync(lockPath); } catch {}
101
+ const ageStale = !!stat && (Date.now() - stat.mtimeMs > staleMs);
102
+ const pidDead = !!meta && _isPidAlive(meta.pid) === false;
103
+ if (ageStale || pidDead) {
104
+ _reclaimStaleLock(lockPath);
105
+ continue;
106
+ }
107
+ return { acquired: false, holder: meta };
108
+ }
109
+ }
110
+ return { acquired: false };
111
+ }
112
+
113
+ module.exports = {
114
+ HEADLESS_ENV,
115
+ DEPTH_ENV,
116
+ MAX_DEPTH_ENV,
117
+ DEFAULT_MAX_DEPTH,
118
+ DEFAULT_LOCK_STALE_MS,
119
+ isHeadless,
120
+ currentDepth,
121
+ maxDepth,
122
+ depthExceeded,
123
+ childSpawnEnv,
124
+ tryAcquireSpawnLock,
125
+ _isPidAlive,
126
+ _lockPath,
127
+ };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { test, afterEach } = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+
9
+ const guard = require('./headless-guard.cjs');
10
+
11
+ const _sandboxes = [];
12
+
13
+ function _mkRoot() {
14
+ const r = fs.mkdtempSync(path.join(os.tmpdir(), 'np-headless-guard-'));
15
+ fs.mkdirSync(path.join(r, '.nubos-pilot'), { recursive: true });
16
+ _sandboxes.push(r);
17
+ return r;
18
+ }
19
+
20
+ afterEach(() => {
21
+ while (_sandboxes.length) {
22
+ const r = _sandboxes.pop();
23
+ try { fs.rmSync(r, { recursive: true, force: true }); } catch {}
24
+ }
25
+ });
26
+
27
+ test('HG-1: isHeadless is true only when NUBOS_PILOT_HEADLESS=1', () => {
28
+ assert.equal(guard.isHeadless({}), false);
29
+ assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '0' }), false);
30
+ assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: 'yes' }), false);
31
+ assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '1' }), true);
32
+ });
33
+
34
+ test('HG-2: currentDepth parses NUBOS_PILOT_HOOK_DEPTH, defaults to 0', () => {
35
+ assert.equal(guard.currentDepth({}), 0);
36
+ assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: 'x' }), 0);
37
+ assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '0' }), 0);
38
+ assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '2' }), 2);
39
+ });
40
+
41
+ test('HG-3: depthExceeded honours default cap of 1 and the env override', () => {
42
+ assert.equal(guard.depthExceeded({}), false);
43
+ assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1' }), true);
44
+ assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), false);
45
+ assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '2', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), true);
46
+ });
47
+
48
+ test('HG-4: childSpawnEnv marks headless and increments depth', () => {
49
+ assert.deepEqual({ ...guard.childSpawnEnv({}) }, { NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '1' });
50
+ assert.deepEqual(
51
+ { ...guard.childSpawnEnv({ NUBOS_PILOT_HOOK_DEPTH: '1' }) },
52
+ { NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '2' },
53
+ );
54
+ });
55
+
56
+ test('HG-5: tryAcquireSpawnLock acquires, then refuses a live concurrent holder', () => {
57
+ const r = _mkRoot();
58
+ const first = guard.tryAcquireSpawnLock(r, 'np-test-critic');
59
+ assert.equal(first.acquired, true);
60
+ assert.ok(fs.existsSync(first.lockPath));
61
+
62
+ const second = guard.tryAcquireSpawnLock(r, 'np-test-critic');
63
+ assert.equal(second.acquired, false, 'second concurrent acquire must be refused');
64
+ assert.ok(second.holder && second.holder.pid === process.pid);
65
+
66
+ first.release();
67
+ assert.equal(fs.existsSync(first.lockPath), false, 'release removes the lock');
68
+
69
+ const third = guard.tryAcquireSpawnLock(r, 'np-test-critic');
70
+ assert.equal(third.acquired, true, 'lock is re-acquirable after release');
71
+ third.release();
72
+ });
73
+
74
+ test('HG-6: different agents get independent locks', () => {
75
+ const r = _mkRoot();
76
+ const a = guard.tryAcquireSpawnLock(r, 'np-security-reviewer');
77
+ const b = guard.tryAcquireSpawnLock(r, 'np-learnings-extractor');
78
+ assert.equal(a.acquired, true);
79
+ assert.equal(b.acquired, true, 'a second agent must not be blocked by the first');
80
+ a.release();
81
+ b.release();
82
+ });
83
+
84
+ test('HG-7: a stale lock (old mtime) is reclaimed', () => {
85
+ const r = _mkRoot();
86
+ const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
87
+ assert.equal(held.acquired, true);
88
+ const past = new Date(Date.now() - 60 * 60 * 1000);
89
+ fs.utimesSync(held.lockPath, past, past);
90
+
91
+ const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
92
+ assert.equal(next.acquired, true, 'a lock older than staleMs must be reclaimed');
93
+ next.release();
94
+ });
95
+
96
+ test('HG-8: a dead-pid lock is reclaimed even when fresh', () => {
97
+ const r = _mkRoot();
98
+ const lockPath = guard._lockPath(r, 'np-test-critic');
99
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
100
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: 2147483646, hostname: os.hostname(), acquiredAt: new Date().toISOString() }), 'utf-8');
101
+ assert.equal(guard._isPidAlive(2147483646), false);
102
+
103
+ const next = guard.tryAcquireSpawnLock(r, 'np-test-critic');
104
+ assert.equal(next.acquired, true, 'a lock owned by a dead pid must be reclaimed');
105
+ next.release();
106
+ });
107
+
108
+ test('HG-9: stale reclaim leaves no .stale residue behind', () => {
109
+ const r = _mkRoot();
110
+ const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
111
+ const past = new Date(Date.now() - 60 * 60 * 1000);
112
+ fs.utimesSync(held.lockPath, past, past);
113
+ const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
114
+ assert.equal(next.acquired, true);
115
+ next.release();
116
+ const runDir = path.join(r, '.nubos-pilot', 'run');
117
+ const residue = fs.readdirSync(runDir).filter((n) => n.includes('.stale.'));
118
+ assert.deepEqual(residue, [], 'rename-aside reclaim must clean up its temp file');
119
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Self-hosted AI pilot for any codebase. Researcher and critic agents plan, execute and verify each change.",
5
5
  "homepage": "https://pilot.nubos.cloud",
6
6
  "repository": {
@@ -37,6 +37,7 @@ function readStdin() {
37
37
  }
38
38
 
39
39
  (async () => {
40
+ if (process.env.NUBOS_PILOT_HEADLESS === '1') { process.exit(0); return; }
40
41
  const verb = process.argv[2];
41
42
  if (!ALLOWED_VERBS.has(verb)) { process.exit(0); return; }
42
43
  const npTools = resolveNpTools();
@@ -31,6 +31,7 @@ function readStdin() {
31
31
  }
32
32
 
33
33
  (async () => {
34
+ if (process.env.NUBOS_PILOT_HEADLESS === '1') { process.exit(0); return; }
34
35
  const verb = process.argv[2];
35
36
  if (!ALLOWED_VERBS.has(verb)) { process.exit(0); return; }
36
37
  const npTools = resolveNpTools();