nearly-cli 0.1.5 → 0.1.6

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 });
@@ -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,14 +408,18 @@ 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 };
422
+ const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), respond };
374
423
  item.timer = setTimeout(() => decide(sid, id, 'deny', 'no human answer; nearly fails closed'), ASK_TIMEOUT_MS);
375
424
  s.pending.set(id, item);
376
425
  s.state = 'waiting';
@@ -380,7 +429,7 @@ const server = http.createServer(async (req, res) => {
380
429
  }
381
430
 
382
431
  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 ?? '') });
432
+ 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
433
  return hookOk(res);
385
434
  }
386
435
  if (ev === 'stop') {
@@ -434,13 +483,13 @@ const server = http.createServer(async (req, res) => {
434
483
  }
435
484
  if (req.method === 'GET' && url.pathname === '/events') {
436
485
  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`);
486
+ 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
487
  clients.add(res);
439
488
  req.on('close', () => clients.delete(res));
440
489
  return;
441
490
  }
442
491
  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 });
492
+ return json(res, 200, { sessions: [...sessions.values()].map(summary), repos: knownRepos(), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS });
444
493
  }
445
494
  if (req.method === 'GET' && url.pathname.startsWith('/recordings/')) {
446
495
  const id = url.pathname.split('/')[2];
@@ -451,7 +500,7 @@ const server = http.createServer(async (req, res) => {
451
500
  try {
452
501
  const b = JSON.parse(await readBody(req) || '{}');
453
502
  if (!b.prompt) return json(res, 400, { error: 'prompt required' });
454
- const s = createSession({ name: b.name, prompt: b.prompt });
503
+ const s = createSession({ name: b.name, prompt: b.prompt, repo: b.repo });
455
504
  return json(res, 200, summary(s));
456
505
  } catch (e) { return json(res, 400, { error: e.message }); }
457
506
  }
@@ -529,7 +578,7 @@ server.on('error', (e) => {
529
578
 
530
579
  server.listen(PORT, HOST, () => {
531
580
  console.log(`nearly http://${HOST}:${PORT}`);
532
- console.log(`workspace ${WORKSPACE}`);
581
+ console.log(`worktrees ${WORKTREES}`);
533
582
  console.log(`recordings ${RECORDINGS}`);
534
583
  });
535
584
 
package/server/paths.mjs CHANGED
@@ -35,4 +35,14 @@ export const paths = {
35
35
  records: () => process.env.NEARLY_STORY || dataDir('records'),
36
36
  pages: () => process.env.NEARLY_OUT || (fromCheckout ? join(pkgRoot, 'ui', 'records') : dataDir('pages', 'records')),
37
37
  docs: () => (fromCheckout ? join(pkgRoot, 'docs') : dataDir('pages')),
38
+ // Worktrees for agents started from the dashboard. Same reasoning as
39
+ // recordings: installed from npm this used to land inside the package — under
40
+ // the npx cache, even — where git has no repository to branch from and an
41
+ // upgrade deletes whatever survived.
42
+ workspace: () => process.env.NEARLY_WORKSPACE || dataDir('workspace'),
43
+ // The repos `nearly` has been turned on for. Kept so the dashboard knows what
44
+ // you work in before any session has run in it — otherwise the only repos it
45
+ // can offer are ones that are already going, which is no help when you are
46
+ // trying to start the first one.
47
+ repos: () => process.env.NEARLY_REPOS || join(dataRoot, 'repos.json'),
38
48
  };
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
+ .formErr { margin: 0; font-size: 12px; line-height: 1.45; color: var(--deny); }
124
125
  .fleet { padding: 0 12px 18px; display: grid; gap: 8px; }
125
126
  .agent {
126
127
  background: var(--panel); border: 1px solid var(--rule); border-radius: var(--r);
@@ -240,8 +241,11 @@
240
241
  <div class="col-h"><span class="lbl">Agents</span><span class="count" id="fleetCount">0</span></div>
241
242
  <form class="new" id="newForm">
242
243
  <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>
244
+ <input name="repo" id="repoField" placeholder="Repo to branch from" list="repoList" autocomplete="off">
245
+ <datalist id="repoList"></datalist>
246
+ <textarea name="prompt" placeholder="What should this agent do on that branch?" required></textarea>
244
247
  <button type="submit" data-v="primary">Start agent</button>
248
+ <p class="formErr" id="newErr" hidden></p>
245
249
  </form>
246
250
  <div class="fleet" id="sessions"></div>
247
251
  </div>
@@ -427,9 +431,9 @@
427
431
  if (ev.type === 'snapshot') {
428
432
  S.sessions.clear();
429
433
  for (const s of ev.sessions) S.sessions.set(s.id, s);
430
- S.rules = ev.rules; S.defaults = ev.defaults;
434
+ S.rules = ev.rules; S.defaults = ev.defaults; S.repos = ev.repos || [];
431
435
  if (ev.askTimeoutMs) S.askTimeoutMs = ev.askTimeoutMs;
432
- renderSessions(); renderAsks(); renderRules(); renderQuota();
436
+ renderSessions(); renderAsks(); renderRules(); renderQuota(); fillRepos();
433
437
  return;
434
438
  }
435
439
  if (ev.type === 'rules') { S.rules = ev.rules; renderRules(); return; }
@@ -440,7 +444,7 @@
440
444
  if (ev.type === 'decision') { s.pending = (s.pending || []).filter((p) => p.id !== ev.id); }
441
445
  if (ev.type === 'result') { s.turns = (s.turns || 0) + 1; s.state = 'idle'; }
442
446
  if (ev.type === 'session' && ev.subtype === 'exited') { s.state = 'exited'; s.pending = []; }
443
- renderSessions(); renderAsks(); renderQuota(); logEvent(ev);
447
+ renderSessions(); renderAsks(); renderQuota(); fillRepos(); logEvent(ev);
444
448
  }
445
449
 
446
450
  function connect() {
@@ -454,10 +458,26 @@
454
458
  $('newForm').onsubmit = async (e) => {
455
459
  e.preventDefault();
456
460
  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();
461
+ const err = $('newErr');
462
+ err.hidden = true;
463
+ const r = await post('/sessions', { name: f.get('name'), prompt: f.get('prompt'), repo: f.get('repo')?.trim() || undefined });
464
+ // Inline, not an alert. The reason a start failed is almost always something
465
+ // you fix in the form right here, and a modal you have to dismiss first puts
466
+ // the answer behind a click.
467
+ if (r.error) { err.textContent = r.error; err.hidden = false; }
468
+ else { e.target.reset(); fillRepos(); }
459
469
  };
460
470
 
471
+ // The repos you have attached are the ones worth offering, and if there is
472
+ // exactly one it is the answer, so fill it in rather than asking.
473
+ function fillRepos() {
474
+ const repos = S.repos || [];
475
+ $('repoList').innerHTML = repos.map((r) => `<option value="${esc(r)}">`).join('');
476
+ const field = $('repoField');
477
+ if (repos.length === 1 && !field.value) field.value = repos[0];
478
+ field.placeholder = repos.length ? 'Repo to branch from' : 'Run `nearly` in a repo first';
479
+ }
480
+
461
481
  document.body.addEventListener('click', async (e) => {
462
482
  const b = e.target.closest('button');
463
483
  if (!b) return;