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/README.md +85 -6
- package/bin/nearly.mjs +11 -1
- package/package.json +2 -2
- package/scripts/agents.mjs +61 -0
- package/scripts/attach.mjs +61 -38
- package/scripts/detect.mjs +75 -0
- package/scripts/hook.mjs +38 -4
- package/server/adapters.mjs +595 -0
- package/server/index.mjs +65 -16
- package/server/paths.mjs +10 -0
- package/ui/index.html +26 -6
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
|
|
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
|
-
|
|
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 = `
|
|
135
|
+
const branch = `nearly/${safe}-${id.slice(0, 4)}`;
|
|
95
136
|
const worktree = path.join(WORKTREES, `${safe}-${id.slice(0, 4)}`);
|
|
96
|
-
|
|
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(
|
|
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
|
-
?
|
|
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:
|
|
371
|
-
if (tier === 'log') { record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: 'policy', tool:
|
|
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:
|
|
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(`
|
|
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
|
-
<
|
|
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
|
|
458
|
-
|
|
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;
|