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 +6 -1
- package/bin/nearly.mjs +6 -2
- package/package.json +1 -1
- package/scripts/attach.mjs +21 -7
- package/scripts/hook.mjs +2 -1
- package/scripts/push-record.mjs +8 -4
- package/server/adapters.mjs +7 -0
- package/server/index.mjs +9 -3
- package/server/paths.mjs +16 -1
- package/ui/index.html +28 -5
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
|
-
|
|
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.
|
|
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": {
|
package/scripts/attach.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
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,
|
package/scripts/push-record.mjs
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(/\/$/, '');
|
package/server/adapters.mjs
CHANGED
|
@@ -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
|
-
|
|
423
|
-
|
|
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: () =>
|
|
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)
|
|
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
|
-
|
|
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));
|