nearly-cli 0.1.6 → 0.1.7

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
@@ -210,6 +210,11 @@ Every tool call passes through an HTTP `PreToolUse` hook to this server, which s
210
210
 
211
211
  "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`.
212
212
 
213
+ Lab mode is off by default. `nearly open` shows your own sessions and what
214
+ needs you — the gate, which is what you installed this for. `nearly lab` adds
215
+ the panel for starting agents from the dashboard, which is a different job and
216
+ no longer the first thing a new user is asked about.
217
+
213
218
  Agents in lab mode are real Claude Code sessions (`claude -p`) on your Claude
214
219
  subscription, each on its own branch in its own git worktree. You pick which
215
220
  repo to branch from — the dashboard offers the ones you have turned Nearly on
@@ -368,6 +373,6 @@ The claim this project makes is testable: a reviewer who sees the session record
368
373
  - `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
369
374
  - `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
370
375
  - `scripts/install-push-hook.mjs` + `scripts/push-record.mjs`, hand the branch record over at `git push`
371
- - `~/.nearly/`, where recordings, records and agent worktrees are kept
376
+ - `~/.nearly/`, where recordings, records, settings and agent worktrees are kept — outside the package, so an upgrade cannot destroy them
372
377
  - `recordings/<session>.jsonl`, every event and decision; `recordings/demo/` is committed so the records can be rebuilt from source
373
378
  - `STUDY.md`, the protocol for testing whether any of this helps a reviewer
package/bin/nearly.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // nearly turn it on for the repo you are in
5
5
  // nearly off turn it off again
6
6
  // nearly open open the dashboard
7
+ // nearly lab open it with the panel for starting agents
7
8
  // nearly record build the record for the current branch
8
9
  // nearly post put that record on the pull request
9
10
  // nearly agents which agents this repo is gated for
@@ -86,8 +87,10 @@ switch (cmd) {
86
87
  return run(join(root, 'server', 'index.mjs'), rest);
87
88
  }
88
89
 
89
- case 'open': {
90
- const url = 'http://127.0.0.1:47653';
90
+ case 'lab': case 'open': {
91
+ // `open` is the gate: your sessions and what needs you. `lab` adds the
92
+ // panel for starting agents from here, which is a different job.
93
+ const url = 'http://127.0.0.1:47653' + (cmd === 'lab' ? '/?lab=1' : '');
91
94
  spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open',
92
95
  [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
93
96
  console.log(url);
@@ -99,6 +102,7 @@ switch (cmd) {
99
102
  nearly turn it on for the repo you are in
100
103
  nearly off turn it off again
101
104
  nearly open open the dashboard
105
+ nearly lab open it with the panel for starting agents
102
106
  nearly record build the record for the current branch
103
107
  nearly post put that record on the pull request
104
108
  nearly agents which agents this repo is gated for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nearly-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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": {
@@ -160,6 +160,11 @@ for (const a of chosen) {
160
160
  // by simply turning Nearly on again.
161
161
  try {
162
162
  const f = paths.repos();
163
+ // On a machine that has never run Nearly, ~/.nearly does not exist yet and
164
+ // this write fails with ENOENT. It used to fail into a bare catch, so the
165
+ // list was silently never created and the dashboard could never offer a repo
166
+ // — invisible on every machine except a genuinely fresh one.
167
+ mkdirSync(dirname(f), { recursive: true });
163
168
  let list = [];
164
169
  try { list = JSON.parse(readFileSync(f, 'utf8')); } catch { /* first one */ }
165
170
  // Prune as we go: a repo that has been moved or deleted is noise in a list
@@ -167,7 +172,10 @@ try {
167
172
  list = list.filter((r) => r !== repo && existsSync(join(r, '.git')));
168
173
  if (!off) list.unshift(repo);
169
174
  writeFileSync(f, JSON.stringify(list.slice(0, 50), null, 2) + '\n');
170
- } catch { /* the dashboard still works without it */ }
175
+ } catch (e) {
176
+ // Not fatal — the gate does not depend on it — but not silent either.
177
+ notes.push(`could not remember this repo for the dashboard: ${e.message}`);
178
+ }
171
179
 
172
180
  // ---------------------------------------------------------------------------
173
181
  // git pre-push hook
@@ -199,20 +207,26 @@ function pagesUrl() {
199
207
  // An address already configured wins: it was either set deliberately or worked
200
208
  // out here before, and it survives the project being renamed.
201
209
  function configured() {
202
- try {
203
- const f = join(root, '.nearly.json');
204
- if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).urlBase || null;
205
- } catch { /* fall through */ }
210
+ // The legacy path is read, never written: an upgrade destroys it, so the
211
+ // first run after this change is the last chance to carry it forward.
212
+ for (const f of [paths.config(), join(root, '.nearly.json')]) {
213
+ try {
214
+ if (existsSync(f)) {
215
+ const u = JSON.parse(readFileSync(f, 'utf8')).urlBase;
216
+ if (u) return u;
217
+ }
218
+ } catch { /* try the next one */ }
219
+ }
206
220
  return null;
207
221
  }
208
222
  const derived = pagesUrl();
209
223
  const base = process.env.NEARLY_URL_BASE || configured() || derived;
210
224
  if (base && !off) {
211
225
  try {
212
- const cfg = join(root, '.nearly.json');
226
+ const cfg = paths.config();
213
227
  const prev = existsSync(cfg) ? JSON.parse(readFileSync(cfg, 'utf8')) : {};
214
228
  writeFileSync(cfg, JSON.stringify({ ...prev, urlBase: base }, null, 2) + '\n');
215
- } catch { /* the env var still works */ }
229
+ } catch (e) { notes.push(`could not save where records publish: ${e.message}`); }
216
230
  }
217
231
 
218
232
  // ---------------------------------------------------------------------------
package/scripts/hook.mjs CHANGED
@@ -87,7 +87,8 @@ if (adapter && adapter.normalize) {
87
87
  }
88
88
 
89
89
  try {
90
- const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}`, {
90
+ const hold = adapter?.holdMs ? `&hold=${adapter.holdMs}` : '';
91
+ const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}`, {
91
92
  method: 'POST',
92
93
  headers: { 'content-type': 'application/json' },
93
94
  body: payload,
@@ -15,10 +15,14 @@ import { paths } from '../server/paths.mjs';
15
15
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
16
16
  const repo = resolve(process.argv[2] || '.');
17
17
  function configured() {
18
- try {
19
- const f = join(root, '.nearly.json');
20
- if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).urlBase || '';
21
- } catch { /* fall through to the env var */ }
18
+ for (const f of [paths.config(), join(root, '.nearly.json')]) {
19
+ try {
20
+ if (existsSync(f)) {
21
+ const u = JSON.parse(readFileSync(f, 'utf8')).urlBase;
22
+ if (u) return u;
23
+ }
24
+ } catch { /* try the next one */ }
25
+ }
22
26
  return '';
23
27
  }
24
28
  const URL_BASE = (process.env.NEARLY_URL_BASE || configured() || '').replace(/\/$/, '');
@@ -534,6 +534,13 @@ export const ADAPTERS = [
534
534
  name: 'Windsurf',
535
535
  verified: null,
536
536
  config: '.windsurf/hooks.json',
537
+ // Windsurf is the only harness with no way to say how long a hook may take,
538
+ // and an abandoned pre-hook does not block — Cascade treats anything but
539
+ // exit 2 as "proceed". So a request held past whatever Cascade's own limit
540
+ // is would be allowed, by Cascade, silently. Decide well inside any
541
+ // plausible limit instead: a deny we issue is recorded and explains itself,
542
+ // where a timeout we lose is an allow nobody chose.
543
+ holdMs: 20_000,
537
544
  // The odd one out twice over. Windsurf has no JSON answer at all — a pre
538
545
  // hook blocks by exiting 2 with the reason on stderr — and it has no single
539
546
  // pre-tool event, so the gate is spread across three.
package/server/index.mjs CHANGED
@@ -67,7 +67,7 @@ function summary(s) {
67
67
  };
68
68
  }
69
69
  function pendingView(p) {
70
- return { id: p.id, session: p.sid, tool: p.tool, input: p.input, tier: p.tier, reason: p.reason, key: p.key, at: p.at };
70
+ return { id: p.id, session: p.sid, tool: p.tool, input: p.input, tier: p.tier, reason: p.reason, key: p.key, at: p.at, holdMs: p.holdMs };
71
71
  }
72
72
 
73
73
  function hooksSettings(sid) {
@@ -419,8 +419,14 @@ const server = http.createServer(async (req, res) => {
419
419
  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})`); }
420
420
  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})`); }
421
421
  // ask: hold the response until the UI decides, or fail closed
422
- const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), respond };
423
- item.timer = setTimeout(() => decide(sid, id, 'deny', 'no human answer; nearly fails closed'), ASK_TIMEOUT_MS);
422
+ // A harness may say it will not wait as long as we would. It can shorten
423
+ // the deadline, never lengthen it: the point of the cap is that nobody
424
+ // else gets to decide by not answering.
425
+ const asked = Number(url.searchParams.get('hold')) || 0;
426
+ const holdMs = asked > 0 ? Math.min(asked, ASK_TIMEOUT_MS) : ASK_TIMEOUT_MS;
427
+ const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, respond };
428
+ item.timer = setTimeout(() => decide(sid, id, 'deny',
429
+ `no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
424
430
  s.pending.set(id, item);
425
431
  s.state = 'waiting';
426
432
  record(sid, { type: 'ask', ...pendingView(item) });
package/server/paths.mjs CHANGED
@@ -24,6 +24,15 @@ export const dataRoot = fromCheckout
24
24
  ? pkgRoot
25
25
  : join(process.env.NEARLY_HOME || join(homedir(), '.nearly'));
26
26
 
27
+ // A file, with its directory guaranteed to exist — including when the path came
28
+ // from an environment variable, which is where this went wrong the first time:
29
+ // the default path was fixed and the override was left to fail on its own.
30
+ function dataFile(envVar, name) {
31
+ const f = process.env[envVar] || join(dataRoot, name);
32
+ try { mkdirSync(dirname(f), { recursive: true }); } catch { /* caller will report */ }
33
+ return f;
34
+ }
35
+
27
36
  export function dataDir(...parts) {
28
37
  const p = join(dataRoot, ...parts);
29
38
  try { mkdirSync(p, { recursive: true }); } catch { /* caller will report */ }
@@ -44,5 +53,11 @@ export const paths = {
44
53
  // you work in before any session has run in it — otherwise the only repos it
45
54
  // can offer are ones that are already going, which is no help when you are
46
55
  // trying to start the first one.
47
- repos: () => process.env.NEARLY_REPOS || join(dataRoot, 'repos.json'),
56
+ repos: () => dataFile('NEARLY_REPOS', 'repos.json'),
57
+ // Where records are published, so a pull-request comment can link them. This
58
+ // lived in the package directory, which `npm install -g` replaces wholesale:
59
+ // the address was quietly lost on every upgrade and the next record went out
60
+ // with no link. Same lesson as recordings — anything a person configured
61
+ // belongs in their space, not in ours.
62
+ config: () => dataFile('NEARLY_CONFIG', 'config.json'),
48
63
  };
package/ui/index.html CHANGED
@@ -121,6 +121,7 @@
121
121
 
122
122
  /* ---------- fleet ---------- */
123
123
  .new { margin: 0 12px 14px; display: grid; gap: 7px; }
124
+ .new[hidden] { display: none; }
124
125
  .formErr { margin: 0; font-size: 12px; line-height: 1.45; color: var(--deny); }
125
126
  .fleet { padding: 0 12px 18px; display: grid; gap: 8px; }
126
127
  .agent {
@@ -239,7 +240,7 @@
239
240
  <main>
240
241
  <div class="col">
241
242
  <div class="col-h"><span class="lbl">Agents</span><span class="count" id="fleetCount">0</span></div>
242
- <form class="new" id="newForm">
243
+ <form class="new" id="newForm" hidden>
243
244
  <input name="name" placeholder="Name, e.g. docs" required autocomplete="off">
244
245
  <input name="repo" id="repoField" placeholder="Repo to branch from" list="repoList" autocomplete="off">
245
246
  <datalist id="repoList"></datalist>
@@ -264,6 +265,10 @@
264
265
  </main>
265
266
 
266
267
  <script>
268
+ // Lab mode — starting agents from the dashboard — is a different job from
269
+ // watching your own sessions be gated. It is the demo, not the product, so it
270
+ // is off unless asked for.
271
+ const LAB = new URLSearchParams(location.search).has('lab');
267
272
  const S = { sessions: new Map(), rules: {}, defaults: {}, askTimeoutMs: 120000 };
268
273
  const $ = (id) => document.getElementById(id);
269
274
  const fmtT = (ms) => new Date(ms).toLocaleTimeString([], { hour12: false });
@@ -317,7 +322,19 @@
317
322
  </div>`;
318
323
  el.appendChild(d);
319
324
  }
320
- if (!S.sessions.size) el.innerHTML = '<div class="empty">No agents yet. Start one above and it runs on your Claude subscription, in its own git worktree.</div>';
325
+ if (!S.sessions.size) {
326
+ // What a person who just installed this is actually waiting for is their
327
+ // own next session, not a button. Saying otherwise taught the wrong
328
+ // product on the first screen anybody sees.
329
+ el.innerHTML = LAB
330
+ ? '<div class="empty">No agents yet. Start one above and it runs on your Claude subscription, on its own branch.</div>'
331
+ : `<div class="empty"><b>No sessions yet.</b>
332
+ Work as you normally would. Every session you run in a repo you have turned
333
+ Nearly on for appears here, and anything that needs you shows up alongside.
334
+ <div class="keys"><span><kbd>nearly agents</kbd><span>what is gated in a repo</span></span>
335
+ <span><kbd>nearly lab</kbd><span>start an agent from here instead</span></span></div>
336
+ </div>`;
337
+ }
321
338
  }
322
339
 
323
340
  function renderAsks() {
@@ -353,7 +370,7 @@
353
370
  <span class="tool">${esc(p.tool)}</span>
354
371
  <span class="who">${esc(p.sname)}</span>
355
372
  <span class="tag" title="Always and Never attach to this key">${esc(p.key || p.tool)}</span>
356
- <span class="clock" data-wait="${p.at}"></span>
373
+ <span class="clock" data-wait="${p.at}" data-limit="${p.holdMs || ''}"></span>
357
374
  </div>
358
375
  <pre>${esc(prettyInput(p.tool, p.input))}</pre>
359
376
  <div class="blast"><span class="ic">▲</span><span>${blast(p.tool)}</span></div>
@@ -363,7 +380,7 @@
363
380
  <button data-v="deny" data-decide="deny" data-scope="once">Deny<kbd>D</kbd></button>
364
381
  <button data-v="deny" data-decide="deny" data-scope="always" title="Never allow ${esc(p.key || p.tool)} again this run">Never<kbd>⇧D</kbd></button>
365
382
  </div>
366
- <div class="deadline" data-wait-bar="${p.at}">
383
+ <div class="deadline" data-wait-bar="${p.at}" data-limit="${p.holdMs || ''}">
367
384
  <div class="bar"><i></i></div>
368
385
  <div class="cap"></div>
369
386
  </div>
@@ -455,6 +472,8 @@
455
472
  }
456
473
  connect();
457
474
 
475
+ if (LAB) $('newForm').hidden = false;
476
+
458
477
  $('newForm').onsubmit = async (e) => {
459
478
  e.preventDefault();
460
479
  const f = new FormData(e.target);
@@ -518,13 +537,17 @@
518
537
 
519
538
  // One ticker for every countdown on the page.
520
539
  function tickWaits() {
521
- const limit = S.askTimeoutMs;
540
+ // Each request carries its own deadline: some harnesses will not wait as
541
+ // long as the rest, and a bar that drains at the wrong rate is worse than
542
+ // no bar.
522
543
  document.querySelectorAll('[data-wait]').forEach((el) => {
544
+ const limit = +el.dataset.limit || S.askTimeoutMs;
523
545
  const held = Date.now() - +el.dataset.wait;
524
546
  el.textContent = `held ${Math.round(held / 1000)}s`;
525
547
  el.dataset.urgent = held > limit * 0.75 ? '2' : held > limit * 0.4 ? '1' : '0';
526
548
  });
527
549
  document.querySelectorAll('[data-wait-bar]').forEach((el) => {
550
+ const limit = +el.dataset.limit || S.askTimeoutMs;
528
551
  const held = Date.now() - +el.dataset.waitBar;
529
552
  const left = Math.max(0, limit - held);
530
553
  const frac = Math.max(0, Math.min(1, left / limit));