nearly-cli 0.1.5 → 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/server/index.mjs CHANGED
@@ -16,14 +16,17 @@ import { paths } from './paths.mjs';
16
16
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
17
  const PORT = Number(process.env.NEARLY_PORT || 47653);
18
18
  const HOST = '127.0.0.1';
19
- const WORKSPACE = path.join(ROOT, 'workspace');
20
- const WORKTREES = path.join(WORKSPACE, '.worktrees');
19
+ const WORKTREES = path.join(paths.workspace(), '.worktrees');
21
20
  const RECORDINGS = paths.recordings();
22
21
  const UI = path.join(ROOT, 'ui', 'index.html');
23
22
  const MAX_SESSIONS = 3; // 8 GB machine
24
23
  const ASK_TIMEOUT_MS = Number(process.env.NEARLY_ASK_TIMEOUT_MS || 120_000); // UI must answer before this; then we fail CLOSED (deny)
25
24
  const HOOK_TIMEOUT_S = 180; // Claude Code's own hook timeout; must be > ASK_TIMEOUT
26
25
  const MODEL = 'sonnet';
26
+ // Overridable so the tests can exercise everything around starting an agent
27
+ // without starting one, and so anyone whose binary is not called `claude` can
28
+ // say so.
29
+ const AGENT_CMD = process.env.NEARLY_AGENT_CMD || 'claude';
27
30
  const MAX_TURNS = '12';
28
31
 
29
32
  fs.mkdirSync(RECORDINGS, { recursive: true });
@@ -64,7 +67,7 @@ function summary(s) {
64
67
  };
65
68
  }
66
69
  function pendingView(p) {
67
- 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 };
68
71
  }
69
72
 
70
73
  function hooksSettings(sid) {
@@ -87,13 +90,54 @@ function git(cwd, args) {
87
90
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
88
91
  }
89
92
 
90
- function createSession({ name, prompt }) {
93
+ // The repo an agent started from the dashboard should branch from. There is no
94
+ // such thing as a default one: this used to assume a `workspace` folder beside
95
+ // the code, which exists in a checkout and never exists when the tool is
96
+ // installed from npm, so the button could only fail.
97
+ function knownRepos() {
98
+ let listed = [];
99
+ try { listed = JSON.parse(fs.readFileSync(paths.repos(), 'utf8')); } catch { /* none yet */ }
100
+ const live = [...sessions.values()].filter((s) => s.attached && s.worktree).map((s) => s.worktree);
101
+ // A repo can be moved or deleted after it was turned on; offering one that is
102
+ // no longer there would just move the failure later.
103
+ return [...new Set([...live, ...listed])].filter((r) => fs.existsSync(path.join(r, '.git')));
104
+ }
105
+
106
+ function resolveRepo(given) {
107
+ // Nothing given: the repos you have turned Nearly on for are the ones you work
108
+ // in, so they are the only sensible guess. One is a default; several are a
109
+ // question, and guessing between them would branch the wrong project.
110
+ const candidates = given ? [path.resolve(given)] : knownRepos();
111
+
112
+ if (!candidates.length) {
113
+ throw new Error('No repo to start from. Run `nearly` in the repo you want, then try again — or give a path.');
114
+ }
115
+ if (!given && candidates.length > 1) {
116
+ throw new Error(`Several repos are attached. Say which one: ${candidates.join(', ')}`);
117
+ }
118
+ const repo = candidates[0];
119
+ if (!fs.existsSync(path.join(repo, '.git'))) throw new Error(`${repo} is not a git repository.`);
120
+ try {
121
+ // An unborn HEAD is the other way this failed: git cannot branch from a repo
122
+ // with no commits, and "invalid reference: main" explained none of that.
123
+ git(repo, ['rev-parse', 'HEAD']);
124
+ } catch {
125
+ throw new Error(`${repo} has no commits yet. Make one, then start an agent from it.`);
126
+ }
127
+ return repo;
128
+ }
129
+
130
+ function createSession({ name, prompt, repo: repoArg }) {
91
131
  if (sessions.size >= MAX_SESSIONS) throw new Error(`max ${MAX_SESSIONS} sessions on this machine`);
132
+ const repo = resolveRepo(repoArg);
92
133
  const id = randomUUID();
93
134
  const safe = String(name || 'agent').replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'agent';
94
- const branch = `cr/${safe}-${id.slice(0, 4)}`;
135
+ const branch = `nearly/${safe}-${id.slice(0, 4)}`;
95
136
  const worktree = path.join(WORKTREES, `${safe}-${id.slice(0, 4)}`);
96
- git(WORKSPACE, ['worktree', 'add', '-B', branch, worktree, 'main']);
137
+ // HEAD, not `main`: branch from where the person actually is. Hardcoding the
138
+ // branch name broke every repo on master, every repo mid-feature, and every
139
+ // repo that had simply never been called main.
140
+ git(repo, ['worktree', 'add', '-B', branch, worktree, 'HEAD']);
97
141
 
98
142
  const settingsPath = path.join(worktree, '.nearly-hooks.json');
99
143
  fs.writeFileSync(settingsPath, JSON.stringify(hooksSettings(id)));
@@ -111,12 +155,12 @@ function createSession({ name, prompt }) {
111
155
  '--max-turns', MAX_TURNS,
112
156
  '--name', safe,
113
157
  ];
114
- const proc = spawn('claude', args, { cwd: worktree, stdio: ['pipe', 'pipe', 'pipe'] });
158
+ const proc = spawn(AGENT_CMD, args, { cwd: worktree, stdio: ['pipe', 'pipe', 'pipe'] });
115
159
  // An unhandled spawn error would take the whole server down and every other
116
160
  // session with it. The usual cause is Claude Code not being on PATH.
117
161
  proc.on('error', (e) => {
118
162
  const why = e.code === 'ENOENT'
119
- ? 'Claude Code is not on PATH. Install it, or check `which claude`.'
163
+ ? `${AGENT_CMD} is not on PATH. Install Claude Code, or check \`which ${AGENT_CMD}\`.`
120
164
  : e.message;
121
165
  record(id, { type: 'stderr', text: `could not start the agent: ${why}` });
122
166
  s.state = 'exited';
@@ -127,8 +171,9 @@ function createSession({ name, prompt }) {
127
171
  id, name: safe, branch, worktree, proc, state: 'starting', turns: 0, lastText: '', currentTool: null,
128
172
  usage: null, rateLimit: null, pending: new Map(), events: [], startedAt: Date.now(), buf: '',
129
173
  };
174
+ s.repo = repo;
130
175
  sessions.set(id, s);
131
- record(id, { type: 'session', subtype: 'created', name: safe, branch, worktree, prompt });
176
+ record(id, { type: 'session', subtype: 'created', name: safe, branch, worktree, repo, prompt });
132
177
 
133
178
  proc.stderr.on('data', (d) => record(id, { type: 'stderr', text: String(d).slice(0, 2000) }));
134
179
  proc.stdout.on('data', (d) => {
@@ -363,15 +408,25 @@ const server = http.createServer(async (req, res) => {
363
408
  if (ev === 'pre-tool') {
364
409
  const { tier, reason } = classifyWith(hook, rules);
365
410
  const id = hook.tool_use_id || randomUUID();
411
+ // Policy keys on the canonical name so a rule means the same thing in every
412
+ // harness; the record shows the harness's own name so it stays truthful
413
+ // about what actually ran.
414
+ const shown = hook.tool_label || hook.tool_name;
366
415
  const respond = (decision, why) => hookOk(res, {
367
416
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `nearly: ${why}` },
368
417
  });
369
418
  if (!s) return respond('deny', 'unknown session');
370
- if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: hook.tool_name, input: hook.tool_input, tier }); return respond('deny', `never (${reason})`); }
371
- if (tier === 'log') { record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: 'policy', tool: hook.tool_name, input: hook.tool_input, tier }); return respond('allow', `do and log (${reason})`); }
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
+ 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})`); }
372
421
  // ask: hold the response until the UI decides, or fail closed
373
- const item = { id, sid, tool: hook.tool_name, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), respond };
374
- 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);
375
430
  s.pending.set(id, item);
376
431
  s.state = 'waiting';
377
432
  record(sid, { type: 'ask', ...pendingView(item) });
@@ -380,7 +435,7 @@ const server = http.createServer(async (req, res) => {
380
435
  }
381
436
 
382
437
  if (ev === 'post-tool') {
383
- if (s) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
438
+ if (s) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_label || hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
384
439
  return hookOk(res);
385
440
  }
386
441
  if (ev === 'stop') {
@@ -434,13 +489,13 @@ const server = http.createServer(async (req, res) => {
434
489
  }
435
490
  if (req.method === 'GET' && url.pathname === '/events') {
436
491
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
437
- res.write(`data: ${JSON.stringify({ type: 'snapshot', sessions: [...sessions.values()].map(summary), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS })}\n\n`);
492
+ res.write(`data: ${JSON.stringify({ type: 'snapshot', sessions: [...sessions.values()].map(summary), repos: knownRepos(), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS })}\n\n`);
438
493
  clients.add(res);
439
494
  req.on('close', () => clients.delete(res));
440
495
  return;
441
496
  }
442
497
  if (req.method === 'GET' && url.pathname === '/state') {
443
- return json(res, 200, { sessions: [...sessions.values()].map(summary), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS });
498
+ return json(res, 200, { sessions: [...sessions.values()].map(summary), repos: knownRepos(), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS });
444
499
  }
445
500
  if (req.method === 'GET' && url.pathname.startsWith('/recordings/')) {
446
501
  const id = url.pathname.split('/')[2];
@@ -451,7 +506,7 @@ const server = http.createServer(async (req, res) => {
451
506
  try {
452
507
  const b = JSON.parse(await readBody(req) || '{}');
453
508
  if (!b.prompt) return json(res, 400, { error: 'prompt required' });
454
- const s = createSession({ name: b.name, prompt: b.prompt });
509
+ const s = createSession({ name: b.name, prompt: b.prompt, repo: b.repo });
455
510
  return json(res, 200, summary(s));
456
511
  } catch (e) { return json(res, 400, { error: e.message }); }
457
512
  }
@@ -529,7 +584,7 @@ server.on('error', (e) => {
529
584
 
530
585
  server.listen(PORT, HOST, () => {
531
586
  console.log(`nearly http://${HOST}:${PORT}`);
532
- console.log(`workspace ${WORKSPACE}`);
587
+ console.log(`worktrees ${WORKTREES}`);
533
588
  console.log(`recordings ${RECORDINGS}`);
534
589
  });
535
590
 
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 */ }
@@ -35,4 +44,20 @@ export const paths = {
35
44
  records: () => process.env.NEARLY_STORY || dataDir('records'),
36
45
  pages: () => process.env.NEARLY_OUT || (fromCheckout ? join(pkgRoot, 'ui', 'records') : dataDir('pages', 'records')),
37
46
  docs: () => (fromCheckout ? join(pkgRoot, 'docs') : dataDir('pages')),
47
+ // Worktrees for agents started from the dashboard. Same reasoning as
48
+ // recordings: installed from npm this used to land inside the package — under
49
+ // the npx cache, even — where git has no repository to branch from and an
50
+ // upgrade deletes whatever survived.
51
+ workspace: () => process.env.NEARLY_WORKSPACE || dataDir('workspace'),
52
+ // The repos `nearly` has been turned on for. Kept so the dashboard knows what
53
+ // you work in before any session has run in it — otherwise the only repos it
54
+ // can offer are ones that are already going, which is no help when you are
55
+ // trying to start the first one.
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'),
38
63
  };
package/ui/index.html CHANGED
@@ -121,6 +121,8 @@
121
121
 
122
122
  /* ---------- fleet ---------- */
123
123
  .new { margin: 0 12px 14px; display: grid; gap: 7px; }
124
+ .new[hidden] { display: none; }
125
+ .formErr { margin: 0; font-size: 12px; line-height: 1.45; color: var(--deny); }
124
126
  .fleet { padding: 0 12px 18px; display: grid; gap: 8px; }
125
127
  .agent {
126
128
  background: var(--panel); border: 1px solid var(--rule); border-radius: var(--r);
@@ -238,10 +240,13 @@
238
240
  <main>
239
241
  <div class="col">
240
242
  <div class="col-h"><span class="lbl">Agents</span><span class="count" id="fleetCount">0</span></div>
241
- <form class="new" id="newForm">
243
+ <form class="new" id="newForm" hidden>
242
244
  <input name="name" placeholder="Name, e.g. docs" required autocomplete="off">
243
- <textarea name="prompt" placeholder="What should this agent do in the workspace?" required></textarea>
245
+ <input name="repo" id="repoField" placeholder="Repo to branch from" list="repoList" autocomplete="off">
246
+ <datalist id="repoList"></datalist>
247
+ <textarea name="prompt" placeholder="What should this agent do on that branch?" required></textarea>
244
248
  <button type="submit" data-v="primary">Start agent</button>
249
+ <p class="formErr" id="newErr" hidden></p>
245
250
  </form>
246
251
  <div class="fleet" id="sessions"></div>
247
252
  </div>
@@ -260,6 +265,10 @@
260
265
  </main>
261
266
 
262
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');
263
272
  const S = { sessions: new Map(), rules: {}, defaults: {}, askTimeoutMs: 120000 };
264
273
  const $ = (id) => document.getElementById(id);
265
274
  const fmtT = (ms) => new Date(ms).toLocaleTimeString([], { hour12: false });
@@ -313,7 +322,19 @@
313
322
  </div>`;
314
323
  el.appendChild(d);
315
324
  }
316
- 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
+ }
317
338
  }
318
339
 
319
340
  function renderAsks() {
@@ -349,7 +370,7 @@
349
370
  <span class="tool">${esc(p.tool)}</span>
350
371
  <span class="who">${esc(p.sname)}</span>
351
372
  <span class="tag" title="Always and Never attach to this key">${esc(p.key || p.tool)}</span>
352
- <span class="clock" data-wait="${p.at}"></span>
373
+ <span class="clock" data-wait="${p.at}" data-limit="${p.holdMs || ''}"></span>
353
374
  </div>
354
375
  <pre>${esc(prettyInput(p.tool, p.input))}</pre>
355
376
  <div class="blast"><span class="ic">▲</span><span>${blast(p.tool)}</span></div>
@@ -359,7 +380,7 @@
359
380
  <button data-v="deny" data-decide="deny" data-scope="once">Deny<kbd>D</kbd></button>
360
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>
361
382
  </div>
362
- <div class="deadline" data-wait-bar="${p.at}">
383
+ <div class="deadline" data-wait-bar="${p.at}" data-limit="${p.holdMs || ''}">
363
384
  <div class="bar"><i></i></div>
364
385
  <div class="cap"></div>
365
386
  </div>
@@ -427,9 +448,9 @@
427
448
  if (ev.type === 'snapshot') {
428
449
  S.sessions.clear();
429
450
  for (const s of ev.sessions) S.sessions.set(s.id, s);
430
- S.rules = ev.rules; S.defaults = ev.defaults;
451
+ S.rules = ev.rules; S.defaults = ev.defaults; S.repos = ev.repos || [];
431
452
  if (ev.askTimeoutMs) S.askTimeoutMs = ev.askTimeoutMs;
432
- renderSessions(); renderAsks(); renderRules(); renderQuota();
453
+ renderSessions(); renderAsks(); renderRules(); renderQuota(); fillRepos();
433
454
  return;
434
455
  }
435
456
  if (ev.type === 'rules') { S.rules = ev.rules; renderRules(); return; }
@@ -440,7 +461,7 @@
440
461
  if (ev.type === 'decision') { s.pending = (s.pending || []).filter((p) => p.id !== ev.id); }
441
462
  if (ev.type === 'result') { s.turns = (s.turns || 0) + 1; s.state = 'idle'; }
442
463
  if (ev.type === 'session' && ev.subtype === 'exited') { s.state = 'exited'; s.pending = []; }
443
- renderSessions(); renderAsks(); renderQuota(); logEvent(ev);
464
+ renderSessions(); renderAsks(); renderQuota(); fillRepos(); logEvent(ev);
444
465
  }
445
466
 
446
467
  function connect() {
@@ -451,13 +472,31 @@
451
472
  }
452
473
  connect();
453
474
 
475
+ if (LAB) $('newForm').hidden = false;
476
+
454
477
  $('newForm').onsubmit = async (e) => {
455
478
  e.preventDefault();
456
479
  const f = new FormData(e.target);
457
- const r = await post('/sessions', { name: f.get('name'), prompt: f.get('prompt') });
458
- if (r.error) alert(r.error); else e.target.reset();
480
+ const err = $('newErr');
481
+ err.hidden = true;
482
+ const r = await post('/sessions', { name: f.get('name'), prompt: f.get('prompt'), repo: f.get('repo')?.trim() || undefined });
483
+ // Inline, not an alert. The reason a start failed is almost always something
484
+ // you fix in the form right here, and a modal you have to dismiss first puts
485
+ // the answer behind a click.
486
+ if (r.error) { err.textContent = r.error; err.hidden = false; }
487
+ else { e.target.reset(); fillRepos(); }
459
488
  };
460
489
 
490
+ // The repos you have attached are the ones worth offering, and if there is
491
+ // exactly one it is the answer, so fill it in rather than asking.
492
+ function fillRepos() {
493
+ const repos = S.repos || [];
494
+ $('repoList').innerHTML = repos.map((r) => `<option value="${esc(r)}">`).join('');
495
+ const field = $('repoField');
496
+ if (repos.length === 1 && !field.value) field.value = repos[0];
497
+ field.placeholder = repos.length ? 'Repo to branch from' : 'Run `nearly` in a repo first';
498
+ }
499
+
461
500
  document.body.addEventListener('click', async (e) => {
462
501
  const b = e.target.closest('button');
463
502
  if (!b) return;
@@ -498,13 +537,17 @@
498
537
 
499
538
  // One ticker for every countdown on the page.
500
539
  function tickWaits() {
501
- 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.
502
543
  document.querySelectorAll('[data-wait]').forEach((el) => {
544
+ const limit = +el.dataset.limit || S.askTimeoutMs;
503
545
  const held = Date.now() - +el.dataset.wait;
504
546
  el.textContent = `held ${Math.round(held / 1000)}s`;
505
547
  el.dataset.urgent = held > limit * 0.75 ? '2' : held > limit * 0.4 ? '1' : '0';
506
548
  });
507
549
  document.querySelectorAll('[data-wait-bar]').forEach((el) => {
550
+ const limit = +el.dataset.limit || S.askTimeoutMs;
508
551
  const held = Date.now() - +el.dataset.waitBar;
509
552
  const left = Math.max(0, limit - held);
510
553
  const frac = Math.max(0, Math.min(1, left / limit));