nearly-cli 0.1.6 → 0.1.8
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 +47 -2
- package/bin/nearly.mjs +6 -2
- package/package.json +2 -2
- package/scripts/attach.mjs +62 -8
- package/scripts/hook.mjs +29 -5
- package/scripts/push-record.mjs +8 -4
- package/server/adapters.mjs +22 -5
- package/server/index.mjs +63 -10
- package/server/paths.mjs +16 -1
- package/server/reclaim.mjs +104 -0
- package/ui/index.html +28 -5
package/README.md
CHANGED
|
@@ -61,11 +61,16 @@ the hook. Seven are supported:
|
|
|
61
61
|
| Claude Code | `.claude/settings.local.json` | yes | full |
|
|
62
62
|
| Cursor | `.cursor/hooks.json` | yes | full |
|
|
63
63
|
| Antigravity | `.agents/hooks.json` | yes | full |
|
|
64
|
-
| GitHub Copilot CLI | `.github/hooks/nearly.json` | yes | full |
|
|
64
|
+
| GitHub Copilot (CLI **and VS Code agent mode**) | `.github/hooks/nearly.json` | yes | full |
|
|
65
65
|
| Gemini CLI | `.gemini/settings.json` | yes | full |
|
|
66
66
|
| Codex CLI | `.codex/hooks.json` | yes | no prompts, one turn |
|
|
67
67
|
| Windsurf | `.windsurf/hooks.json` | until Cascade gives up | shell and file tools only |
|
|
68
68
|
|
|
69
|
+
VS Code agent mode loads every `.json` in `.github/hooks/` with no further
|
|
70
|
+
setup, so `nearly --agent=copilot` is the whole of it there — and it is the only
|
|
71
|
+
route for someone on Windows who does not have Claude Code, since Cline's hooks
|
|
72
|
+
are macOS and Linux only and Windsurf's cannot be given a deadline.
|
|
73
|
+
|
|
69
74
|
`nearly` turns on whichever of these the repo shows signs of, and Claude Code
|
|
70
75
|
either way. `nearly --agent=cursor` forces one, `--agent=all` forces all of them,
|
|
71
76
|
and `nearly agents` prints what is actually wired here.
|
|
@@ -113,6 +118,11 @@ Zed's built-in agent, Aider, Kilo Code, Warp and the hosted builders (Replit,
|
|
|
113
118
|
Lovable, Bolt, v0) expose no blocking pre-tool hook. There is nothing to attach
|
|
114
119
|
to, and no adapter can change that.
|
|
115
120
|
|
|
121
|
+
**Cline** has one, and it is macOS and Linux only — so on Windows there is
|
|
122
|
+
nothing to attach to there either. Cline does keep its own consent trail in
|
|
123
|
+
`ui_messages.json` under its task history, which Nearly could read after the
|
|
124
|
+
fact; that would be a reader rather than a gate, and it does not exist yet.
|
|
125
|
+
|
|
116
126
|
## Use it on your own repo
|
|
117
127
|
|
|
118
128
|
One command, in the repo you want recorded.
|
|
@@ -210,6 +220,11 @@ Every tool call passes through an HTTP `PreToolUse` hook to this server, which s
|
|
|
210
220
|
|
|
211
221
|
"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
222
|
|
|
223
|
+
Lab mode is off by default. `nearly open` shows your own sessions and what
|
|
224
|
+
needs you — the gate, which is what you installed this for. `nearly lab` adds
|
|
225
|
+
the panel for starting agents from the dashboard, which is a different job and
|
|
226
|
+
no longer the first thing a new user is asked about.
|
|
227
|
+
|
|
213
228
|
Agents in lab mode are real Claude Code sessions (`claude -p`) on your Claude
|
|
214
229
|
subscription, each on its own branch in its own git worktree. You pick which
|
|
215
230
|
repo to branch from — the dashboard offers the ones you have turned Nearly on
|
|
@@ -312,6 +327,36 @@ node scripts/publish-pages.mjs --base https://<user>.github.io/<repo>
|
|
|
312
327
|
|
|
313
328
|
That copies every built recap into `docs/records/` and writes `docs/index.html`, an index of the sessions on record. Commit `docs/`, then set **Settings → Pages → branch `main`, folder `/docs`**. Preview it locally first at http://127.0.0.1:47653/docs/ while the server is running.
|
|
314
329
|
|
|
330
|
+
## The server, and why you never start it
|
|
331
|
+
|
|
332
|
+
The first hook that needs the server starts it, and it stays up for the rest of
|
|
333
|
+
the day rather than paying the startup cost on every tool call. Two consequences
|
|
334
|
+
had to be designed for, because both were found the hard way on other people's
|
|
335
|
+
machines.
|
|
336
|
+
|
|
337
|
+
A server outlives the run that started it, so one `npx nearly-cli` — or any
|
|
338
|
+
upgrade — can leave the previous build holding the port. It keeps answering,
|
|
339
|
+
from a directory npm has since replaced, which is why its record pages 404 and
|
|
340
|
+
why upgrading appears to do nothing at all. Every server now says which build it
|
|
341
|
+
is and where it lives, and a hook from a different install takes the port back
|
|
342
|
+
before doing anything else — without being asked, because nobody reads a hook's
|
|
343
|
+
output, and a fix only the people who happen to re-read a message ever get is
|
|
344
|
+
not a fix.
|
|
345
|
+
|
|
346
|
+
Builds from 0.1.8 stand down when asked. Older ones have no way to be asked, so
|
|
347
|
+
an idle one is ended outright. Two rules make that defensible. It must prove it
|
|
348
|
+
is ours twice, on `/health` and on `/state` — the second carries the consent
|
|
349
|
+
gradient itself, which nothing else on your machine is going to return by
|
|
350
|
+
chance, so a plain web server that happens to sit on 47653 is never touched. And
|
|
351
|
+
it is never ended while anybody is using it: dropping a held request would hand
|
|
352
|
+
it back to the agent's own prompt, which is the one outcome this project exists
|
|
353
|
+
to prevent. If a server cannot be ended, `nearly` says so and gives you the
|
|
354
|
+
command for your platform rather than leaving you to work it out.
|
|
355
|
+
|
|
356
|
+
And a server with no sessions that nobody has asked anything of for thirty
|
|
357
|
+
minutes exits on its own. There is nothing to remember to shut down, and nothing
|
|
358
|
+
squats on a port for days.
|
|
359
|
+
|
|
315
360
|
## Why the hook fails open
|
|
316
361
|
|
|
317
362
|
Claude Code treats a hook that times out, errors, or returns anything other than `200` with JSON as a non-blocking error and lets the tool call proceed. So this server always answers with JSON, holds "ask" calls for at most `ASK_TIMEOUT_MS`, and denies when nobody decides. The hook's own timeout is set longer than that.
|
|
@@ -368,6 +413,6 @@ The claim this project makes is testable: a reviewer who sees the session record
|
|
|
368
413
|
- `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
|
|
369
414
|
- `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
|
|
370
415
|
- `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
|
|
416
|
+
- `~/.nearly/`, where recordings, records, settings and agent worktrees are kept — outside the package, so an upgrade cannot destroy them
|
|
372
417
|
- `recordings/<session>.jsonl`, every event and decision; `recordings/demo/` is committed so the records can be rebuilt from source
|
|
373
418
|
- `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.8",
|
|
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": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://anujpatel06.github.io/nearly/",
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs"
|
|
37
|
+
"test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs test/stale-server.test.mjs"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/scripts/attach.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { ADAPTERS } from '../server/adapters.mjs';
|
|
|
28
28
|
|
|
29
29
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
30
30
|
const HOOK = join(root, 'scripts', 'hook.mjs');
|
|
31
|
-
const PORT = 47653;
|
|
31
|
+
const PORT = Number(process.env.NEARLY_PORT || 47653);
|
|
32
32
|
|
|
33
33
|
// What the hooks should invoke, in order of preference. This choice decides
|
|
34
34
|
// whether upgrading the tool ever reaches the repos it was turned on for.
|
|
@@ -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,31 @@ 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
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// A server from somewhere else, already holding the port
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// The server outlives the run that starts it. So a single `npx nearly-cli`, or
|
|
184
|
+
// any upgrade, can leave the previous build squatting — answering from a
|
|
185
|
+
// directory npm has since replaced, 404ing its own record pages, and making
|
|
186
|
+
// every fix since invisible. A hook cannot say any of this out loud; this
|
|
187
|
+
// command can, because you are here reading it.
|
|
188
|
+
//
|
|
189
|
+
// Builds from 0.1.8 stand down when asked. Older ones have no way to be asked,
|
|
190
|
+
// so the honest thing is to name the problem and the exact command.
|
|
191
|
+
async function checkPort() {
|
|
192
|
+
let mine = root;
|
|
193
|
+
try { mine = realpathSync(root); } catch { /* compare the literal path */ }
|
|
194
|
+
try {
|
|
195
|
+
const { reclaim } = await import('../server/reclaim.mjs');
|
|
196
|
+
return await reclaim({ port: PORT, base: `http://127.0.0.1:${PORT}`, root: mine });
|
|
197
|
+
} catch { return null; }
|
|
198
|
+
}
|
|
199
|
+
const port = off ? null : await checkPort();
|
|
171
200
|
|
|
172
201
|
// ---------------------------------------------------------------------------
|
|
173
202
|
// git pre-push hook
|
|
@@ -199,20 +228,26 @@ function pagesUrl() {
|
|
|
199
228
|
// An address already configured wins: it was either set deliberately or worked
|
|
200
229
|
// out here before, and it survives the project being renamed.
|
|
201
230
|
function configured() {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
231
|
+
// The legacy path is read, never written: an upgrade destroys it, so the
|
|
232
|
+
// first run after this change is the last chance to carry it forward.
|
|
233
|
+
for (const f of [paths.config(), join(root, '.nearly.json')]) {
|
|
234
|
+
try {
|
|
235
|
+
if (existsSync(f)) {
|
|
236
|
+
const u = JSON.parse(readFileSync(f, 'utf8')).urlBase;
|
|
237
|
+
if (u) return u;
|
|
238
|
+
}
|
|
239
|
+
} catch { /* try the next one */ }
|
|
240
|
+
}
|
|
206
241
|
return null;
|
|
207
242
|
}
|
|
208
243
|
const derived = pagesUrl();
|
|
209
244
|
const base = process.env.NEARLY_URL_BASE || configured() || derived;
|
|
210
245
|
if (base && !off) {
|
|
211
246
|
try {
|
|
212
|
-
const cfg =
|
|
247
|
+
const cfg = paths.config();
|
|
213
248
|
const prev = existsSync(cfg) ? JSON.parse(readFileSync(cfg, 'utf8')) : {};
|
|
214
249
|
writeFileSync(cfg, JSON.stringify({ ...prev, urlBase: base }, null, 2) + '\n');
|
|
215
|
-
} catch {
|
|
250
|
+
} catch (e) { notes.push(`could not save where records publish: ${e.message}`); }
|
|
216
251
|
}
|
|
217
252
|
|
|
218
253
|
// ---------------------------------------------------------------------------
|
|
@@ -246,6 +281,25 @@ if (base) {
|
|
|
246
281
|
}
|
|
247
282
|
for (const n of notes) console.log(` ${dim('·')} ${dim(n)}`);
|
|
248
283
|
|
|
284
|
+
if (port?.outcome === 'stood-down' || port?.outcome === 'ended') {
|
|
285
|
+
const from = port.who?.root || 'an older build';
|
|
286
|
+
console.log(` ${ok('·')} ${dim(`closed an older Nearly server that was holding port ${PORT}`)}`);
|
|
287
|
+
console.log(` ${dim(from)}`);
|
|
288
|
+
} else if (port?.outcome === 'busy') {
|
|
289
|
+
console.log('');
|
|
290
|
+
console.log(` ${bold('Another Nearly server is on this port and is in use.')}`);
|
|
291
|
+
console.log(dim(' Left it alone. Run this again once it is idle and this build will take over.'));
|
|
292
|
+
} else if (port?.outcome === 'stuck') {
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log(` ${bold(`An older Nearly server is holding port ${PORT} and would not close.`)}`);
|
|
295
|
+
console.log(dim(' Until it goes it answers instead of this one, which is why its record pages'));
|
|
296
|
+
console.log(dim(' 404 and why upgrading appears to do nothing.'));
|
|
297
|
+
console.log('');
|
|
298
|
+
console.log(` ${dim('End it with:')} ${process.platform === 'win32'
|
|
299
|
+
? `netstat -ano | findstr :${PORT} then taskkill /PID <pid> /F`
|
|
300
|
+
: `lsof -ti:${PORT} -sTCP:LISTEN | xargs kill`}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
249
303
|
// An agent you have on this machine but have not used here is worth a word, and
|
|
250
304
|
// nothing more: having it installed is no reason to write files into this repo.
|
|
251
305
|
const elsewhere = agentsOnMachine().filter((i) => !wired.some((w) => w.id === i.id));
|
package/scripts/hook.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { spawn } from 'node:child_process';
|
|
26
26
|
import { join, dirname } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { realpathSync } from 'node:fs';
|
|
28
29
|
import { byId } from '../server/adapters.mjs';
|
|
29
30
|
|
|
30
31
|
const HOST = '127.0.0.1';
|
|
@@ -49,11 +50,21 @@ const body = await new Promise((r) => {
|
|
|
49
50
|
process.stdin.on('error', () => r(''));
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
const realRoot = (() => { try { return realpathSync(root); } catch { return root; } })();
|
|
54
|
+
|
|
55
|
+
// Is the thing on this port *us*?
|
|
56
|
+
//
|
|
57
|
+
// A hook starts the server and the server outlives the run. So after an upgrade
|
|
58
|
+
// — or after a one-off `npx nearly-cli` — the old build keeps the port and keeps
|
|
59
|
+
// answering, from a directory that may not exist any more. Its record pages 404
|
|
60
|
+
// and every fix since is invisible, with nothing anywhere saying why.
|
|
52
61
|
async function up(ms = 400) {
|
|
53
62
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
const r = await fetch(`${BASE}/health`, { signal: AbortSignal.timeout(ms) });
|
|
64
|
+
if (!r.ok) return false;
|
|
65
|
+
const h = await r.json().catch(() => ({}));
|
|
66
|
+
if (h.root !== realRoot) return 'stale'; // no root at all means older than this check
|
|
67
|
+
return true;
|
|
57
68
|
} catch { return false; }
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -70,7 +81,19 @@ async function start() {
|
|
|
70
81
|
return false;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
let health = await up();
|
|
85
|
+
if (health === 'stale') {
|
|
86
|
+
// Take the port back rather than run whatever is already there. Nobody reads
|
|
87
|
+
// a hook's output, so this has to happen without being asked — otherwise the
|
|
88
|
+
// only people who ever get the fix are the ones who happen to re-run `nearly`
|
|
89
|
+
// and read the message.
|
|
90
|
+
const { reclaim } = await import('../server/reclaim.mjs');
|
|
91
|
+
const { outcome } = await reclaim({ port: PORT, base: BASE, root: realRoot });
|
|
92
|
+
// 'busy' and 'stuck' both mean it is still there. Talking to an old server
|
|
93
|
+
// still gates the call, which is better than not gating it.
|
|
94
|
+
health = (outcome === 'stood-down' || outcome === 'ended' || outcome === 'free') ? false : true;
|
|
95
|
+
}
|
|
96
|
+
if (!health && !(await start())) process.exit(0); // fail open, silently
|
|
74
97
|
|
|
75
98
|
// PreToolUse can hold for as long as the server is willing to wait for a human.
|
|
76
99
|
// Everything else should be quick; keep it short so a wedged endpoint cannot
|
|
@@ -87,7 +110,8 @@ if (adapter && adapter.normalize) {
|
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
try {
|
|
90
|
-
const
|
|
113
|
+
const hold = adapter?.holdMs ? `&hold=${adapter.holdMs}` : '';
|
|
114
|
+
const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}`, {
|
|
91
115
|
method: 'POST',
|
|
92
116
|
headers: { 'content-type': 'application/json' },
|
|
93
117
|
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
|
@@ -342,12 +342,17 @@ export const ADAPTERS = [
|
|
|
342
342
|
// -------------------------------------------------------------------------
|
|
343
343
|
{
|
|
344
344
|
id: 'copilot',
|
|
345
|
-
name: 'GitHub Copilot
|
|
345
|
+
name: 'GitHub Copilot',
|
|
346
346
|
verified: null,
|
|
347
347
|
config: '.github/hooks/nearly.json',
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// names
|
|
348
|
+
// Both the CLI and VS Code's agent mode, which loads every .json in
|
|
349
|
+
// .github/hooks/ with no further setup. Copilot accepts PascalCase event
|
|
350
|
+
// names as a Claude Code compatibility mode, and in that mode it sends
|
|
351
|
+
// snake_case fields and Claude's own tool names, so this adapter is mostly
|
|
352
|
+
// a different file path.
|
|
353
|
+
//
|
|
354
|
+
// It also fails closed where Claude Code fails open: a crash or a non-zero
|
|
355
|
+
// exit in a preToolUse hook denies the call rather than waving it through.
|
|
351
356
|
events: {
|
|
352
357
|
SessionStart: 'session-start', UserPromptSubmit: 'prompt', PreToolUse: 'pre-tool',
|
|
353
358
|
PostToolUse: 'post-tool', Stop: 'stop', SessionEnd: 'session-end',
|
|
@@ -356,7 +361,12 @@ export const ADAPTERS = [
|
|
|
356
361
|
const file = join(repo, '.github', 'hooks', 'nearly.json');
|
|
357
362
|
const cfg = { version: 1, hooks: {} };
|
|
358
363
|
for (const [their, ours] of Object.entries(this.events)) {
|
|
359
|
-
|
|
364
|
+
const run = cmdFor(ours);
|
|
365
|
+
// `command` is the cross-platform fallback; `bash` and `powershell` are
|
|
366
|
+
// what the runtime picks per OS. Writing all three means a Windows
|
|
367
|
+
// machine finds one whichever property it prefers — and Windows is
|
|
368
|
+
// exactly where somebody with no other option is running this.
|
|
369
|
+
cfg.hooks[their] = [{ type: 'command', command: run, bash: run, powershell: run, timeoutSec: holdFor(ours) }];
|
|
360
370
|
}
|
|
361
371
|
writeJson(file, cfg); // our own file; nobody else's entries to keep
|
|
362
372
|
return { file };
|
|
@@ -534,6 +544,13 @@ export const ADAPTERS = [
|
|
|
534
544
|
name: 'Windsurf',
|
|
535
545
|
verified: null,
|
|
536
546
|
config: '.windsurf/hooks.json',
|
|
547
|
+
// Windsurf is the only harness with no way to say how long a hook may take,
|
|
548
|
+
// and an abandoned pre-hook does not block — Cascade treats anything but
|
|
549
|
+
// exit 2 as "proceed". So a request held past whatever Cascade's own limit
|
|
550
|
+
// is would be allowed, by Cascade, silently. Decide well inside any
|
|
551
|
+
// plausible limit instead: a deny we issue is recorded and explains itself,
|
|
552
|
+
// where a timeout we lose is an allow nobody chose.
|
|
553
|
+
holdMs: 20_000,
|
|
537
554
|
// The odd one out twice over. Windsurf has no JSON answer at all — a pre
|
|
538
555
|
// hook blocks by exiting 2 with the reason on stderr — and it has no single
|
|
539
556
|
// pre-tool event, so the gate is spread across three.
|
package/server/index.mjs
CHANGED
|
@@ -20,6 +20,19 @@ const WORKTREES = path.join(paths.workspace(), '.worktrees');
|
|
|
20
20
|
const RECORDINGS = paths.recordings();
|
|
21
21
|
const UI = path.join(ROOT, 'ui', 'index.html');
|
|
22
22
|
const MAX_SESSIONS = 3; // 8 GB machine
|
|
23
|
+
// Which build is actually answering on this port. A server started by a hook
|
|
24
|
+
// outlives the run that started it, so after an upgrade the old one keeps the
|
|
25
|
+
// port and keeps serving its own code — and every fix stays invisible. Say who
|
|
26
|
+
// we are so the launcher can tell.
|
|
27
|
+
const VERSION = (() => {
|
|
28
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; }
|
|
29
|
+
catch { return '0.0.0'; }
|
|
30
|
+
})();
|
|
31
|
+
// Idle since the last request. A server nobody is using should not hold a port
|
|
32
|
+
// for the rest of the week, least of all one running from a cache directory
|
|
33
|
+
// that npm may already have deleted.
|
|
34
|
+
const IDLE_EXIT_MS = Number(process.env.NEARLY_IDLE_EXIT_MS || 30 * 60_000);
|
|
35
|
+
let lastSeen = Date.now();
|
|
23
36
|
const ASK_TIMEOUT_MS = Number(process.env.NEARLY_ASK_TIMEOUT_MS || 120_000); // UI must answer before this; then we fail CLOSED (deny)
|
|
24
37
|
const HOOK_TIMEOUT_S = 180; // Claude Code's own hook timeout; must be > ASK_TIMEOUT
|
|
25
38
|
const MODEL = 'sonnet';
|
|
@@ -67,7 +80,7 @@ function summary(s) {
|
|
|
67
80
|
};
|
|
68
81
|
}
|
|
69
82
|
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 };
|
|
83
|
+
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
84
|
}
|
|
72
85
|
|
|
73
86
|
function hooksSettings(sid) {
|
|
@@ -358,7 +371,10 @@ function readBody(req) {
|
|
|
358
371
|
return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
|
|
359
372
|
}
|
|
360
373
|
|
|
374
|
+
const realRoot = (() => { try { return fs.realpathSync(ROOT); } catch { return ROOT; } })();
|
|
375
|
+
|
|
361
376
|
const server = http.createServer(async (req, res) => {
|
|
377
|
+
lastSeen = Date.now();
|
|
362
378
|
const url = new URL(req.url, `http://${HOST}:${PORT}`);
|
|
363
379
|
const sidParam = url.searchParams.get('s');
|
|
364
380
|
|
|
@@ -419,8 +435,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
419
435
|
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
436
|
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
437
|
// ask: hold the response until the UI decides, or fail closed
|
|
422
|
-
|
|
423
|
-
|
|
438
|
+
// A harness may say it will not wait as long as we would. It can shorten
|
|
439
|
+
// the deadline, never lengthen it: the point of the cap is that nobody
|
|
440
|
+
// else gets to decide by not answering.
|
|
441
|
+
const asked = Number(url.searchParams.get('hold')) || 0;
|
|
442
|
+
const holdMs = asked > 0 ? Math.min(asked, ASK_TIMEOUT_MS) : ASK_TIMEOUT_MS;
|
|
443
|
+
const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, respond };
|
|
444
|
+
item.timer = setTimeout(() => decide(sid, id, 'deny',
|
|
445
|
+
`no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
|
|
424
446
|
s.pending.set(id, item);
|
|
425
447
|
s.state = 'waiting';
|
|
426
448
|
record(sid, { type: 'ask', ...pendingView(item) });
|
|
@@ -464,18 +486,40 @@ const server = http.createServer(async (req, res) => {
|
|
|
464
486
|
|
|
465
487
|
// ---- UI API ----
|
|
466
488
|
// Cheap liveness check: the hook launcher calls this before every tool call.
|
|
467
|
-
if (req.method === 'GET' && url.pathname === '/health')
|
|
489
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
490
|
+
return json(res, 200, { ok: true, sessions: sessions.size, version: VERSION, root: realRoot });
|
|
491
|
+
}
|
|
492
|
+
// Stand down so a newer build can take the port. Refused while anybody is
|
|
493
|
+
// waiting on a decision: dropping a held request would hand it back to the
|
|
494
|
+
// agent's own prompt, which is the one outcome this whole project exists to
|
|
495
|
+
// avoid.
|
|
496
|
+
if (req.method === 'POST' && url.pathname === '/exit') {
|
|
497
|
+
const waiting = [...sessions.values()].reduce((n, s) => n + s.pending.size, 0);
|
|
498
|
+
if (waiting) return json(res, 409, { ok: false, waiting });
|
|
499
|
+
json(res, 200, { ok: true, version: VERSION });
|
|
500
|
+
setTimeout(() => process.exit(0), 50);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
468
503
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
469
504
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
470
505
|
return res.end(fs.readFileSync(UI));
|
|
471
506
|
}
|
|
472
|
-
// Static: built
|
|
473
|
-
//
|
|
507
|
+
// Static: built records, the replay page, and a local preview of the docs/
|
|
508
|
+
// folder GitHub Pages serves, so you can check it before pushing.
|
|
509
|
+
//
|
|
510
|
+
// Each of these has to be asked for by name rather than resolved against the
|
|
511
|
+
// package. Records used to live in ui/records/ and moved to the user's own
|
|
512
|
+
// directory when it turned out an upgrade was deleting them — but this route
|
|
513
|
+
// kept serving out of the package, so from an npm install every record 404'd
|
|
514
|
+
// while sitting perfectly well on disk. It only ever worked from a checkout,
|
|
515
|
+
// which is the one place nobody would notice.
|
|
474
516
|
if (req.method === 'GET' && (url.pathname.startsWith('/records/') || url.pathname === '/replay.html' || url.pathname === '/docs' || url.pathname.startsWith('/docs/'))) {
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
517
|
+
const isDocs = url.pathname === '/docs' || url.pathname.startsWith('/docs/');
|
|
518
|
+
const isRecord = url.pathname.startsWith('/records/');
|
|
519
|
+
const base = isRecord ? paths.pages() : isDocs ? paths.docs() : path.join(ROOT, 'ui');
|
|
520
|
+
const strip = isRecord ? '/records/' : isDocs ? '/docs' : '/';
|
|
521
|
+
let rel = url.pathname.slice(strip.length).split('/').filter((p) => p && p !== '..').join('/');
|
|
522
|
+
if (isDocs) rel = rel || 'index.html';
|
|
479
523
|
const file = path.join(base, rel);
|
|
480
524
|
if (!file.startsWith(base) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return json(res, 404, { error: 'not found' });
|
|
481
525
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
@@ -576,6 +620,15 @@ server.on('error', (e) => {
|
|
|
576
620
|
process.exit(1);
|
|
577
621
|
});
|
|
578
622
|
|
|
623
|
+
// Nothing to remember to shut down. A server with no sessions that nobody has
|
|
624
|
+
// asked anything of for half an hour has no reason to still be holding a port.
|
|
625
|
+
if (IDLE_EXIT_MS > 0) {
|
|
626
|
+
const idle = setInterval(() => {
|
|
627
|
+
if (sessions.size === 0 && Date.now() - lastSeen > IDLE_EXIT_MS) process.exit(0);
|
|
628
|
+
}, 60_000);
|
|
629
|
+
idle.unref();
|
|
630
|
+
}
|
|
631
|
+
|
|
579
632
|
server.listen(PORT, HOST, () => {
|
|
580
633
|
console.log(`nearly http://${HOST}:${PORT}`);
|
|
581
634
|
console.log(`worktrees ${WORKTREES}`);
|
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
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Take the port back from a Nearly server that is not this one.
|
|
2
|
+
//
|
|
3
|
+
// The server outlives the run that starts it. So a single `npx nearly-cli`, or
|
|
4
|
+
// any upgrade, leaves the previous build holding 47653 — still answering, from a
|
|
5
|
+
// directory npm has since replaced. Its record pages 404, and worse, every fix
|
|
6
|
+
// shipped after it never runs. Nothing anywhere says why.
|
|
7
|
+
//
|
|
8
|
+
// Builds from 0.1.8 stand down when asked. Everything already installed does
|
|
9
|
+
// not, and that is most people. Telling them to run taskkill is not a fix; it
|
|
10
|
+
// is a fix for whoever reads the message. So when an older build is idle, we
|
|
11
|
+
// end it ourselves.
|
|
12
|
+
//
|
|
13
|
+
// The rule that makes that defensible: never kill anything until it has proved,
|
|
14
|
+
// twice, that it is one of ours, and never kill one that anybody is using.
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const isWin = process.platform === 'win32';
|
|
19
|
+
|
|
20
|
+
async function get(base, path, ms = 700) {
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(base + path, { signal: AbortSignal.timeout(ms) });
|
|
23
|
+
if (!r.ok) return null;
|
|
24
|
+
return await r.json();
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Two independent shapes only this server produces. /health alone is a couple of
|
|
29
|
+
// generic fields that anything could return by chance; /state carries the
|
|
30
|
+
// consent gradient itself — the tier table, the learned rules, the deadline. A
|
|
31
|
+
// process answering both is ours, whatever version wrote it.
|
|
32
|
+
export async function identify(base) {
|
|
33
|
+
const health = await get(base, '/health');
|
|
34
|
+
if (!health || health.ok !== true || typeof health.sessions !== 'number') return null;
|
|
35
|
+
const state = await get(base, '/state');
|
|
36
|
+
if (!state || !state.defaults || !Array.isArray(state.sessions)) return null;
|
|
37
|
+
if (typeof state.defaults.Bash !== 'string' || typeof state.askTimeoutMs !== 'number') return null;
|
|
38
|
+
return {
|
|
39
|
+
version: health.version || null, // absent before 0.1.8
|
|
40
|
+
root: health.root || null, // absent before 0.1.8
|
|
41
|
+
sessions: health.sessions,
|
|
42
|
+
// Anything actually being decided right now. Killing over one of these
|
|
43
|
+
// would drop a held request back to the agent's own prompt.
|
|
44
|
+
waiting: state.sessions.reduce((n, s) => n + (s.pending?.length || 0), 0),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pidsOnPort(port) {
|
|
49
|
+
try {
|
|
50
|
+
if (isWin) {
|
|
51
|
+
const out = execFileSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf8', timeout: 5000 });
|
|
52
|
+
return [...new Set(out.split(/\r?\n/)
|
|
53
|
+
.filter((l) => /LISTENING/i.test(l) && new RegExp(`[:.]${port}\\s`).test(l))
|
|
54
|
+
.map((l) => Number(l.trim().split(/\s+/).pop()))
|
|
55
|
+
.filter((n) => Number.isInteger(n) && n > 0))];
|
|
56
|
+
}
|
|
57
|
+
const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'],
|
|
58
|
+
{ encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
59
|
+
return [...new Set(out.split(/\s+/).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
|
|
60
|
+
} catch { return []; } // lsof or netstat missing, or nothing listening
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const freed = async (base) => (await identify(base)) === null;
|
|
64
|
+
|
|
65
|
+
// Returns what happened, for a caller that wants to say so out loud.
|
|
66
|
+
//
|
|
67
|
+
// 'ours' the server on this port is this build
|
|
68
|
+
// 'free' nothing is listening
|
|
69
|
+
// 'busy' someone else's, but a person is mid-decision — left alone
|
|
70
|
+
// 'stood-down' it exited when asked (0.1.8 and later)
|
|
71
|
+
// 'ended' older build, idle, so we closed it
|
|
72
|
+
// 'stuck' ours by every test, but we could not end it
|
|
73
|
+
export async function reclaim({ port, base, root, kill = process.kill.bind(process) }) {
|
|
74
|
+
const who = await identify(base);
|
|
75
|
+
if (!who) return { outcome: 'free' };
|
|
76
|
+
if (who.root && who.root === root) return { outcome: 'ours', who };
|
|
77
|
+
if (who.waiting > 0) return { outcome: 'busy', who };
|
|
78
|
+
|
|
79
|
+
// The polite path. A build that understands this will refuse if it is busy.
|
|
80
|
+
try {
|
|
81
|
+
const r = await fetch(`${base}/exit`, { method: 'POST', signal: AbortSignal.timeout(2000) });
|
|
82
|
+
if (r.status === 409) return { outcome: 'busy', who };
|
|
83
|
+
if (r.ok) {
|
|
84
|
+
for (let i = 0; i < 20; i++) {
|
|
85
|
+
await new Promise((s) => setTimeout(s, 100));
|
|
86
|
+
if (await freed(base)) return { outcome: 'stood-down', who };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* older build: no such endpoint */ }
|
|
90
|
+
|
|
91
|
+
// Older build. It has already answered as ours on two endpoints and has no
|
|
92
|
+
// sessions, so ending it costs nobody anything and is the only way its
|
|
93
|
+
// replacement ever gets to run.
|
|
94
|
+
if (who.sessions > 0) return { outcome: 'busy', who };
|
|
95
|
+
for (const pid of pidsOnPort(port)) {
|
|
96
|
+
if (pid === process.pid) continue;
|
|
97
|
+
try { kill(pid, 'SIGTERM'); } catch { /* gone, or not ours to signal */ }
|
|
98
|
+
}
|
|
99
|
+
for (let i = 0; i < 25; i++) {
|
|
100
|
+
await new Promise((s) => setTimeout(s, 100));
|
|
101
|
+
if (await freed(base)) return { outcome: 'ended', who };
|
|
102
|
+
}
|
|
103
|
+
return { outcome: 'stuck', who };
|
|
104
|
+
}
|
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));
|