nearly-cli 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -6
- package/bin/nearly.mjs +17 -3
- package/package.json +2 -2
- package/scripts/agents.mjs +61 -0
- package/scripts/attach.mjs +80 -43
- package/scripts/detect.mjs +75 -0
- package/scripts/hook.mjs +40 -5
- package/scripts/push-record.mjs +8 -4
- package/server/adapters.mjs +602 -0
- package/server/index.mjs +73 -18
- package/server/paths.mjs +25 -0
- package/ui/index.html +54 -11
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 });
|
|
@@ -64,7 +67,7 @@ function summary(s) {
|
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
function pendingView(p) {
|
|
67
|
-
return { id: p.id, session: p.sid, tool: p.tool, input: p.input, tier: p.tier, reason: p.reason, key: p.key, at: p.at };
|
|
70
|
+
return { id: p.id, session: p.sid, tool: p.tool, input: p.input, tier: p.tier, reason: p.reason, key: p.key, at: p.at, holdMs: p.holdMs };
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
function hooksSettings(sid) {
|
|
@@ -87,13 +90,54 @@ function git(cwd, args) {
|
|
|
87
90
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
|
|
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,15 +408,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
363
408
|
if (ev === 'pre-tool') {
|
|
364
409
|
const { tier, reason } = classifyWith(hook, rules);
|
|
365
410
|
const id = hook.tool_use_id || randomUUID();
|
|
411
|
+
// Policy keys on the canonical name so a rule means the same thing in every
|
|
412
|
+
// harness; the record shows the harness's own name so it stays truthful
|
|
413
|
+
// about what actually ran.
|
|
414
|
+
const shown = hook.tool_label || hook.tool_name;
|
|
366
415
|
const respond = (decision, why) => hookOk(res, {
|
|
367
416
|
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `nearly: ${why}` },
|
|
368
417
|
});
|
|
369
418
|
if (!s) return respond('deny', 'unknown session');
|
|
370
|
-
if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool:
|
|
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
|
-
|
|
374
|
-
|
|
422
|
+
// A harness may say it will not wait as long as we would. It can shorten
|
|
423
|
+
// the deadline, never lengthen it: the point of the cap is that nobody
|
|
424
|
+
// else gets to decide by not answering.
|
|
425
|
+
const asked = Number(url.searchParams.get('hold')) || 0;
|
|
426
|
+
const holdMs = asked > 0 ? Math.min(asked, ASK_TIMEOUT_MS) : ASK_TIMEOUT_MS;
|
|
427
|
+
const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, respond };
|
|
428
|
+
item.timer = setTimeout(() => decide(sid, id, 'deny',
|
|
429
|
+
`no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
|
|
375
430
|
s.pending.set(id, item);
|
|
376
431
|
s.state = 'waiting';
|
|
377
432
|
record(sid, { type: 'ask', ...pendingView(item) });
|
|
@@ -380,7 +435,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
380
435
|
}
|
|
381
436
|
|
|
382
437
|
if (ev === 'post-tool') {
|
|
383
|
-
if (s) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
|
|
438
|
+
if (s) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_label || hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
|
|
384
439
|
return hookOk(res);
|
|
385
440
|
}
|
|
386
441
|
if (ev === 'stop') {
|
|
@@ -434,13 +489,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
434
489
|
}
|
|
435
490
|
if (req.method === 'GET' && url.pathname === '/events') {
|
|
436
491
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
437
|
-
res.write(`data: ${JSON.stringify({ type: 'snapshot', sessions: [...sessions.values()].map(summary), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS })}\n\n`);
|
|
492
|
+
res.write(`data: ${JSON.stringify({ type: 'snapshot', sessions: [...sessions.values()].map(summary), repos: knownRepos(), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS })}\n\n`);
|
|
438
493
|
clients.add(res);
|
|
439
494
|
req.on('close', () => clients.delete(res));
|
|
440
495
|
return;
|
|
441
496
|
}
|
|
442
497
|
if (req.method === 'GET' && url.pathname === '/state') {
|
|
443
|
-
return json(res, 200, { sessions: [...sessions.values()].map(summary), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS });
|
|
498
|
+
return json(res, 200, { sessions: [...sessions.values()].map(summary), repos: knownRepos(), rules: Object.fromEntries(rules), defaults: DEFAULT_TIER, askTimeoutMs: ASK_TIMEOUT_MS });
|
|
444
499
|
}
|
|
445
500
|
if (req.method === 'GET' && url.pathname.startsWith('/recordings/')) {
|
|
446
501
|
const id = url.pathname.split('/')[2];
|
|
@@ -451,7 +506,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
451
506
|
try {
|
|
452
507
|
const b = JSON.parse(await readBody(req) || '{}');
|
|
453
508
|
if (!b.prompt) return json(res, 400, { error: 'prompt required' });
|
|
454
|
-
const s = createSession({ name: b.name, prompt: b.prompt });
|
|
509
|
+
const s = createSession({ name: b.name, prompt: b.prompt, repo: b.repo });
|
|
455
510
|
return json(res, 200, summary(s));
|
|
456
511
|
} catch (e) { return json(res, 400, { error: e.message }); }
|
|
457
512
|
}
|
|
@@ -529,7 +584,7 @@ server.on('error', (e) => {
|
|
|
529
584
|
|
|
530
585
|
server.listen(PORT, HOST, () => {
|
|
531
586
|
console.log(`nearly http://${HOST}:${PORT}`);
|
|
532
|
-
console.log(`
|
|
587
|
+
console.log(`worktrees ${WORKTREES}`);
|
|
533
588
|
console.log(`recordings ${RECORDINGS}`);
|
|
534
589
|
});
|
|
535
590
|
|
package/server/paths.mjs
CHANGED
|
@@ -24,6 +24,15 @@ export const dataRoot = fromCheckout
|
|
|
24
24
|
? pkgRoot
|
|
25
25
|
: join(process.env.NEARLY_HOME || join(homedir(), '.nearly'));
|
|
26
26
|
|
|
27
|
+
// A file, with its directory guaranteed to exist — including when the path came
|
|
28
|
+
// from an environment variable, which is where this went wrong the first time:
|
|
29
|
+
// the default path was fixed and the override was left to fail on its own.
|
|
30
|
+
function dataFile(envVar, name) {
|
|
31
|
+
const f = process.env[envVar] || join(dataRoot, name);
|
|
32
|
+
try { mkdirSync(dirname(f), { recursive: true }); } catch { /* caller will report */ }
|
|
33
|
+
return f;
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
export function dataDir(...parts) {
|
|
28
37
|
const p = join(dataRoot, ...parts);
|
|
29
38
|
try { mkdirSync(p, { recursive: true }); } catch { /* caller will report */ }
|
|
@@ -35,4 +44,20 @@ export const paths = {
|
|
|
35
44
|
records: () => process.env.NEARLY_STORY || dataDir('records'),
|
|
36
45
|
pages: () => process.env.NEARLY_OUT || (fromCheckout ? join(pkgRoot, 'ui', 'records') : dataDir('pages', 'records')),
|
|
37
46
|
docs: () => (fromCheckout ? join(pkgRoot, 'docs') : dataDir('pages')),
|
|
47
|
+
// Worktrees for agents started from the dashboard. Same reasoning as
|
|
48
|
+
// recordings: installed from npm this used to land inside the package — under
|
|
49
|
+
// the npx cache, even — where git has no repository to branch from and an
|
|
50
|
+
// upgrade deletes whatever survived.
|
|
51
|
+
workspace: () => process.env.NEARLY_WORKSPACE || dataDir('workspace'),
|
|
52
|
+
// The repos `nearly` has been turned on for. Kept so the dashboard knows what
|
|
53
|
+
// you work in before any session has run in it — otherwise the only repos it
|
|
54
|
+
// can offer are ones that are already going, which is no help when you are
|
|
55
|
+
// trying to start the first one.
|
|
56
|
+
repos: () => dataFile('NEARLY_REPOS', 'repos.json'),
|
|
57
|
+
// Where records are published, so a pull-request comment can link them. This
|
|
58
|
+
// lived in the package directory, which `npm install -g` replaces wholesale:
|
|
59
|
+
// the address was quietly lost on every upgrade and the next record went out
|
|
60
|
+
// with no link. Same lesson as recordings — anything a person configured
|
|
61
|
+
// belongs in their space, not in ours.
|
|
62
|
+
config: () => dataFile('NEARLY_CONFIG', 'config.json'),
|
|
38
63
|
};
|
package/ui/index.html
CHANGED
|
@@ -121,6 +121,8 @@
|
|
|
121
121
|
|
|
122
122
|
/* ---------- fleet ---------- */
|
|
123
123
|
.new { margin: 0 12px 14px; display: grid; gap: 7px; }
|
|
124
|
+
.new[hidden] { display: none; }
|
|
125
|
+
.formErr { margin: 0; font-size: 12px; line-height: 1.45; color: var(--deny); }
|
|
124
126
|
.fleet { padding: 0 12px 18px; display: grid; gap: 8px; }
|
|
125
127
|
.agent {
|
|
126
128
|
background: var(--panel); border: 1px solid var(--rule); border-radius: var(--r);
|
|
@@ -238,10 +240,13 @@
|
|
|
238
240
|
<main>
|
|
239
241
|
<div class="col">
|
|
240
242
|
<div class="col-h"><span class="lbl">Agents</span><span class="count" id="fleetCount">0</span></div>
|
|
241
|
-
<form class="new" id="newForm">
|
|
243
|
+
<form class="new" id="newForm" hidden>
|
|
242
244
|
<input name="name" placeholder="Name, e.g. docs" required autocomplete="off">
|
|
243
|
-
<
|
|
245
|
+
<input name="repo" id="repoField" placeholder="Repo to branch from" list="repoList" autocomplete="off">
|
|
246
|
+
<datalist id="repoList"></datalist>
|
|
247
|
+
<textarea name="prompt" placeholder="What should this agent do on that branch?" required></textarea>
|
|
244
248
|
<button type="submit" data-v="primary">Start agent</button>
|
|
249
|
+
<p class="formErr" id="newErr" hidden></p>
|
|
245
250
|
</form>
|
|
246
251
|
<div class="fleet" id="sessions"></div>
|
|
247
252
|
</div>
|
|
@@ -260,6 +265,10 @@
|
|
|
260
265
|
</main>
|
|
261
266
|
|
|
262
267
|
<script>
|
|
268
|
+
// Lab mode — starting agents from the dashboard — is a different job from
|
|
269
|
+
// watching your own sessions be gated. It is the demo, not the product, so it
|
|
270
|
+
// is off unless asked for.
|
|
271
|
+
const LAB = new URLSearchParams(location.search).has('lab');
|
|
263
272
|
const S = { sessions: new Map(), rules: {}, defaults: {}, askTimeoutMs: 120000 };
|
|
264
273
|
const $ = (id) => document.getElementById(id);
|
|
265
274
|
const fmtT = (ms) => new Date(ms).toLocaleTimeString([], { hour12: false });
|
|
@@ -313,7 +322,19 @@
|
|
|
313
322
|
</div>`;
|
|
314
323
|
el.appendChild(d);
|
|
315
324
|
}
|
|
316
|
-
if (!S.sessions.size)
|
|
325
|
+
if (!S.sessions.size) {
|
|
326
|
+
// What a person who just installed this is actually waiting for is their
|
|
327
|
+
// own next session, not a button. Saying otherwise taught the wrong
|
|
328
|
+
// product on the first screen anybody sees.
|
|
329
|
+
el.innerHTML = LAB
|
|
330
|
+
? '<div class="empty">No agents yet. Start one above and it runs on your Claude subscription, on its own branch.</div>'
|
|
331
|
+
: `<div class="empty"><b>No sessions yet.</b>
|
|
332
|
+
Work as you normally would. Every session you run in a repo you have turned
|
|
333
|
+
Nearly on for appears here, and anything that needs you shows up alongside.
|
|
334
|
+
<div class="keys"><span><kbd>nearly agents</kbd><span>what is gated in a repo</span></span>
|
|
335
|
+
<span><kbd>nearly lab</kbd><span>start an agent from here instead</span></span></div>
|
|
336
|
+
</div>`;
|
|
337
|
+
}
|
|
317
338
|
}
|
|
318
339
|
|
|
319
340
|
function renderAsks() {
|
|
@@ -349,7 +370,7 @@
|
|
|
349
370
|
<span class="tool">${esc(p.tool)}</span>
|
|
350
371
|
<span class="who">${esc(p.sname)}</span>
|
|
351
372
|
<span class="tag" title="Always and Never attach to this key">${esc(p.key || p.tool)}</span>
|
|
352
|
-
<span class="clock" data-wait="${p.at}"></span>
|
|
373
|
+
<span class="clock" data-wait="${p.at}" data-limit="${p.holdMs || ''}"></span>
|
|
353
374
|
</div>
|
|
354
375
|
<pre>${esc(prettyInput(p.tool, p.input))}</pre>
|
|
355
376
|
<div class="blast"><span class="ic">▲</span><span>${blast(p.tool)}</span></div>
|
|
@@ -359,7 +380,7 @@
|
|
|
359
380
|
<button data-v="deny" data-decide="deny" data-scope="once">Deny<kbd>D</kbd></button>
|
|
360
381
|
<button data-v="deny" data-decide="deny" data-scope="always" title="Never allow ${esc(p.key || p.tool)} again this run">Never<kbd>⇧D</kbd></button>
|
|
361
382
|
</div>
|
|
362
|
-
<div class="deadline" data-wait-bar="${p.at}">
|
|
383
|
+
<div class="deadline" data-wait-bar="${p.at}" data-limit="${p.holdMs || ''}">
|
|
363
384
|
<div class="bar"><i></i></div>
|
|
364
385
|
<div class="cap"></div>
|
|
365
386
|
</div>
|
|
@@ -427,9 +448,9 @@
|
|
|
427
448
|
if (ev.type === 'snapshot') {
|
|
428
449
|
S.sessions.clear();
|
|
429
450
|
for (const s of ev.sessions) S.sessions.set(s.id, s);
|
|
430
|
-
S.rules = ev.rules; S.defaults = ev.defaults;
|
|
451
|
+
S.rules = ev.rules; S.defaults = ev.defaults; S.repos = ev.repos || [];
|
|
431
452
|
if (ev.askTimeoutMs) S.askTimeoutMs = ev.askTimeoutMs;
|
|
432
|
-
renderSessions(); renderAsks(); renderRules(); renderQuota();
|
|
453
|
+
renderSessions(); renderAsks(); renderRules(); renderQuota(); fillRepos();
|
|
433
454
|
return;
|
|
434
455
|
}
|
|
435
456
|
if (ev.type === 'rules') { S.rules = ev.rules; renderRules(); return; }
|
|
@@ -440,7 +461,7 @@
|
|
|
440
461
|
if (ev.type === 'decision') { s.pending = (s.pending || []).filter((p) => p.id !== ev.id); }
|
|
441
462
|
if (ev.type === 'result') { s.turns = (s.turns || 0) + 1; s.state = 'idle'; }
|
|
442
463
|
if (ev.type === 'session' && ev.subtype === 'exited') { s.state = 'exited'; s.pending = []; }
|
|
443
|
-
renderSessions(); renderAsks(); renderQuota(); logEvent(ev);
|
|
464
|
+
renderSessions(); renderAsks(); renderQuota(); fillRepos(); logEvent(ev);
|
|
444
465
|
}
|
|
445
466
|
|
|
446
467
|
function connect() {
|
|
@@ -451,13 +472,31 @@
|
|
|
451
472
|
}
|
|
452
473
|
connect();
|
|
453
474
|
|
|
475
|
+
if (LAB) $('newForm').hidden = false;
|
|
476
|
+
|
|
454
477
|
$('newForm').onsubmit = async (e) => {
|
|
455
478
|
e.preventDefault();
|
|
456
479
|
const f = new FormData(e.target);
|
|
457
|
-
const
|
|
458
|
-
|
|
480
|
+
const err = $('newErr');
|
|
481
|
+
err.hidden = true;
|
|
482
|
+
const r = await post('/sessions', { name: f.get('name'), prompt: f.get('prompt'), repo: f.get('repo')?.trim() || undefined });
|
|
483
|
+
// Inline, not an alert. The reason a start failed is almost always something
|
|
484
|
+
// you fix in the form right here, and a modal you have to dismiss first puts
|
|
485
|
+
// the answer behind a click.
|
|
486
|
+
if (r.error) { err.textContent = r.error; err.hidden = false; }
|
|
487
|
+
else { e.target.reset(); fillRepos(); }
|
|
459
488
|
};
|
|
460
489
|
|
|
490
|
+
// The repos you have attached are the ones worth offering, and if there is
|
|
491
|
+
// exactly one it is the answer, so fill it in rather than asking.
|
|
492
|
+
function fillRepos() {
|
|
493
|
+
const repos = S.repos || [];
|
|
494
|
+
$('repoList').innerHTML = repos.map((r) => `<option value="${esc(r)}">`).join('');
|
|
495
|
+
const field = $('repoField');
|
|
496
|
+
if (repos.length === 1 && !field.value) field.value = repos[0];
|
|
497
|
+
field.placeholder = repos.length ? 'Repo to branch from' : 'Run `nearly` in a repo first';
|
|
498
|
+
}
|
|
499
|
+
|
|
461
500
|
document.body.addEventListener('click', async (e) => {
|
|
462
501
|
const b = e.target.closest('button');
|
|
463
502
|
if (!b) return;
|
|
@@ -498,13 +537,17 @@
|
|
|
498
537
|
|
|
499
538
|
// One ticker for every countdown on the page.
|
|
500
539
|
function tickWaits() {
|
|
501
|
-
|
|
540
|
+
// Each request carries its own deadline: some harnesses will not wait as
|
|
541
|
+
// long as the rest, and a bar that drains at the wrong rate is worse than
|
|
542
|
+
// no bar.
|
|
502
543
|
document.querySelectorAll('[data-wait]').forEach((el) => {
|
|
544
|
+
const limit = +el.dataset.limit || S.askTimeoutMs;
|
|
503
545
|
const held = Date.now() - +el.dataset.wait;
|
|
504
546
|
el.textContent = `held ${Math.round(held / 1000)}s`;
|
|
505
547
|
el.dataset.urgent = held > limit * 0.75 ? '2' : held > limit * 0.4 ? '1' : '0';
|
|
506
548
|
});
|
|
507
549
|
document.querySelectorAll('[data-wait-bar]').forEach((el) => {
|
|
550
|
+
const limit = +el.dataset.limit || S.askTimeoutMs;
|
|
508
551
|
const held = Date.now() - +el.dataset.waitBar;
|
|
509
552
|
const left = Math.max(0, limit - held);
|
|
510
553
|
const frac = Math.max(0, Math.min(1, left / limit));
|