@ucsandman/legcli 0.10.0 → 0.11.0
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/CHANGELOG.md +70 -0
- package/README.md +70 -9
- package/bin/leg.mjs +55 -14
- package/docs/ERRORS.md +53 -0
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/adapters.md +93 -11
- package/docs/cli-contracts.md +36 -17
- package/docs/configuration.md +55 -5
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +35 -9
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +15 -0
- package/src/board/index.html +22 -0
- package/src/board/sessions.js +29 -1
- package/src/server.mjs +89 -23
- package/src/share.mjs +66 -6
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +21 -5
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Audit trail: who did what, across every terminal and every card on this
|
|
2
|
+
// board. Data: /api/audit, which is owner-only — the trail names repositories
|
|
3
|
+
// and the people on the roster.
|
|
4
|
+
//
|
|
5
|
+
// It loads when the reader asks, never on a board render: the trail reads every
|
|
6
|
+
// session's events and every card's ledger, and that is not work to do on a
|
|
7
|
+
// timer behind a panel nobody has opened. Like the history cell, nothing here
|
|
8
|
+
// arrives over SSE (DESIGN.md rule 3: the live terminals lane is the only live
|
|
9
|
+
// region).
|
|
10
|
+
//
|
|
11
|
+
// `el`, `api`, `getToken`, `ago` and `whenAgo` are copied from sessions.js,
|
|
12
|
+
// which cannot export from its IIFE. The time grammar is sessions.js's: a
|
|
13
|
+
// change to ago() there is a change here in the same commit.
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict'
|
|
16
|
+
|
|
17
|
+
function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
|
|
18
|
+
async function api(path, opts = {}) {
|
|
19
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
20
|
+
const token = getToken()
|
|
21
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
22
|
+
const r = await fetch(path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
23
|
+
const data = await r.json().catch(() => ({}))
|
|
24
|
+
if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`)
|
|
25
|
+
return data
|
|
26
|
+
}
|
|
27
|
+
function el(tag, attrs, children) {
|
|
28
|
+
const n = document.createElement(tag)
|
|
29
|
+
for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined) continue; if (k === 'class') n.className = v; else n.setAttribute(k, v) }
|
|
30
|
+
for (const c of children || []) { if (c === null || c === undefined) continue; n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c) }
|
|
31
|
+
return n
|
|
32
|
+
}
|
|
33
|
+
function ago(ms) {
|
|
34
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
35
|
+
if (s < 60) return `${s}s`
|
|
36
|
+
const m = Math.floor(s / 60)
|
|
37
|
+
if (m < 60) return `${m}m`
|
|
38
|
+
const h = Math.floor(m / 60)
|
|
39
|
+
if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
|
|
40
|
+
const d = Math.floor(h / 24)
|
|
41
|
+
return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
|
|
42
|
+
}
|
|
43
|
+
function whenAgo(ts) {
|
|
44
|
+
const t = Date.parse(ts)
|
|
45
|
+
return Number.isFinite(t) ? `${ago(Date.now() - t)} ago` : ''
|
|
46
|
+
}
|
|
47
|
+
function clockAt(ms) {
|
|
48
|
+
const d = new Date(ms)
|
|
49
|
+
if (!Number.isFinite(d.getTime())) return ''
|
|
50
|
+
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const root = document.getElementById('audit')
|
|
54
|
+
if (!root) return
|
|
55
|
+
const whoSel = document.getElementById('audit-who')
|
|
56
|
+
const kindSel = document.getElementById('audit-kind')
|
|
57
|
+
const loadBtn = document.getElementById('audit-load')
|
|
58
|
+
const meta = document.getElementById('audit-meta')
|
|
59
|
+
const list = document.getElementById('audit-list')
|
|
60
|
+
let knownPeople = null
|
|
61
|
+
|
|
62
|
+
// A guest or an operator never sees this panel; the fieldset stays hidden
|
|
63
|
+
// until /api/audit answers, so a 403 leaves nothing on the page to click.
|
|
64
|
+
async function reveal() {
|
|
65
|
+
try {
|
|
66
|
+
const r = await api('/api/audit?limit=1')
|
|
67
|
+
root.hidden = false
|
|
68
|
+
knownPeople = r.people || []
|
|
69
|
+
fillPeople()
|
|
70
|
+
} catch { root.hidden = true }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fillPeople() {
|
|
74
|
+
if (!knownPeople) return
|
|
75
|
+
const current = whoSel.value
|
|
76
|
+
while (whoSel.options.length > 1) whoSel.remove(1)
|
|
77
|
+
for (const p of knownPeople) whoSel.appendChild(el('option', { value: p }, [p]))
|
|
78
|
+
whoSel.value = current
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function row(e) {
|
|
82
|
+
const line = el('div', { class: 'audit-row' })
|
|
83
|
+
line.appendChild(el('span', { class: 'audit-when', title: e.at }, [clockAt(Date.parse(e.at))]))
|
|
84
|
+
line.appendChild(el('span', { class: `chip chip-id-${e.kind === 'human' ? 'human' : (e.agent || 'leg')}` }, [e.who]))
|
|
85
|
+
line.appendChild(el('span', { class: 'audit-what' }, [e.what.replace(/_/g, ' ')]))
|
|
86
|
+
line.appendChild(el('span', { class: 'audit-summary' }, [e.summary || '']))
|
|
87
|
+
const where = e.where === 'card' ? `card ${e.id}` : `terminal ${String(e.id).split('-').pop()}`
|
|
88
|
+
line.appendChild(el('span', { class: 'chip' }, [where]))
|
|
89
|
+
if (e.repo) line.appendChild(el('span', { class: 'chip' }, [String(e.repo).split(/[\\/]/).pop() + (e.branch ? `@${e.branch}` : '')]))
|
|
90
|
+
line.appendChild(el('span', { class: 'audit-ago' }, [whenAgo(e.at)]))
|
|
91
|
+
return line
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function load() {
|
|
95
|
+
loadBtn.disabled = true
|
|
96
|
+
meta.textContent = 'reading every terminal and every card…'
|
|
97
|
+
list.replaceChildren()
|
|
98
|
+
try {
|
|
99
|
+
const q = new URLSearchParams({ limit: '200' })
|
|
100
|
+
if (whoSel.value) q.set('who', whoSel.value)
|
|
101
|
+
if (kindSel.value) q.set('kind', kindSel.value)
|
|
102
|
+
const r = await api(`/api/audit?${q}`)
|
|
103
|
+
knownPeople = r.people || knownPeople
|
|
104
|
+
fillPeople()
|
|
105
|
+
// L2: the verdict carries the volume it processed, so an empty trail from
|
|
106
|
+
// a board that looked at nothing does not read like a quiet week.
|
|
107
|
+
const scanned = `${r.scanned.sessions} terminal${r.scanned.sessions === 1 ? '' : 's'} and ${r.scanned.cards} card${r.scanned.cards === 1 ? '' : 's'}, ${r.scanned.events} events read`
|
|
108
|
+
meta.textContent = r.entries.length
|
|
109
|
+
? `${r.matched} action${r.matched === 1 ? '' : 's'}${r.truncated ? `, newest ${r.entries.length} shown` : ''} — ${scanned}`
|
|
110
|
+
: `nothing matched — ${scanned}`
|
|
111
|
+
for (const e of r.entries) list.appendChild(row(e))
|
|
112
|
+
} catch (err) {
|
|
113
|
+
meta.textContent = err.message
|
|
114
|
+
} finally {
|
|
115
|
+
loadBtn.disabled = false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
loadBtn.addEventListener('click', load)
|
|
120
|
+
whoSel.addEventListener('change', load)
|
|
121
|
+
kindSel.addEventListener('change', load)
|
|
122
|
+
reveal()
|
|
123
|
+
})()
|
package/src/board/board.css
CHANGED
|
@@ -391,6 +391,21 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
391
391
|
.history-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; margin: 4px 0 18px; max-width: 64ch; }
|
|
392
392
|
.history-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
393
393
|
.history-filters label:has(input[type="checkbox"]) { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font-weight: var(--w-text); }
|
|
394
|
+
/* Audit trail (Settings). Same grammar as the history rows: a flat list with
|
|
395
|
+
hairline separators, the time on the left, the actor as a chip. */
|
|
396
|
+
.audit { border: 0; padding: 0; margin: 22px 0 0; }
|
|
397
|
+
.audit legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding: 0; }
|
|
398
|
+
.audit-filters { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin: 10px 0 14px; }
|
|
399
|
+
.audit-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
400
|
+
.audit-list { display: block; }
|
|
401
|
+
.audit-row { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; padding: 10px 0; font-size: var(--t-0); }
|
|
402
|
+
.audit-row + .audit-row { border-top: 1px solid var(--line); }
|
|
403
|
+
.audit-when { color: var(--text-3); font-variant-numeric: tabular-nums; min-width: 12ch; }
|
|
404
|
+
.audit-what { font-weight: var(--w-head); }
|
|
405
|
+
.audit-summary { color: var(--text-2); flex: 1 1 46ch; min-width: 0; overflow-wrap: anywhere; }
|
|
406
|
+
.audit-ago { color: var(--text-3); margin-left: auto; }
|
|
407
|
+
@media (max-width: 640px) { .audit-ago { margin-left: 0; } }
|
|
408
|
+
|
|
394
409
|
.history-list { display: block; }
|
|
395
410
|
.history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 12px 0; font-size: var(--t-0); }
|
|
396
411
|
.history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
|
package/src/board/index.html
CHANGED
|
@@ -158,6 +158,27 @@
|
|
|
158
158
|
<button type="button" class="btn btn-secondary" id="default-order-save">Save default</button>
|
|
159
159
|
<span class="field-status" id="default-order-status" aria-live="polite"></span>
|
|
160
160
|
</fieldset>
|
|
161
|
+
<!-- Who did what, across every terminal and every card. The owner's, like
|
|
162
|
+
the rest of this panel: the trail names repositories and people. It
|
|
163
|
+
loads when asked rather than on every board render. -->
|
|
164
|
+
<fieldset class="audit" id="audit" hidden>
|
|
165
|
+
<legend>Audit trail</legend>
|
|
166
|
+
<p class="field-help">Every hand-off, landing, approval, reassignment and kill on this board, newest first, with the person or the agent that did it.</p>
|
|
167
|
+
<div class="audit-filters">
|
|
168
|
+
<label for="audit-who">Who</label>
|
|
169
|
+
<select id="audit-who"><option value="">anyone</option></select>
|
|
170
|
+
<label for="audit-kind">Kind</label>
|
|
171
|
+
<select id="audit-kind">
|
|
172
|
+
<option value="">people and agents</option>
|
|
173
|
+
<option value="human">people</option>
|
|
174
|
+
<option value="agent">agents</option>
|
|
175
|
+
<option value="leg">Leg itself</option>
|
|
176
|
+
</select>
|
|
177
|
+
<button type="button" class="btn btn-secondary" id="audit-load">Load</button>
|
|
178
|
+
</div>
|
|
179
|
+
<p class="region-meta" id="audit-meta" role="status" aria-atomic="true"></p>
|
|
180
|
+
<div class="audit-list" id="audit-list"></div>
|
|
181
|
+
</fieldset>
|
|
161
182
|
<div class="field board-facts"></div>
|
|
162
183
|
</div>
|
|
163
184
|
</section>
|
|
@@ -280,5 +301,6 @@
|
|
|
280
301
|
<script src="board.js" defer></script>
|
|
281
302
|
<script src="sessions.js" defer></script>
|
|
282
303
|
<script src="history.js" defer></script>
|
|
304
|
+
<script src="audit.js" defer></script>
|
|
283
305
|
</body>
|
|
284
306
|
</html>
|
package/src/board/sessions.js
CHANGED
|
@@ -661,7 +661,7 @@
|
|
|
661
661
|
sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
|
|
662
662
|
} else {
|
|
663
663
|
await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
|
|
664
|
-
if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'hand-off requested; this terminal switches agents in a few seconds' })
|
|
664
|
+
if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: body && body.agent ? `hand-off to ${optionLabel(body)} requested; this terminal switches agents in a few seconds` : 'hand-off requested; this terminal switches agents in a few seconds' })
|
|
665
665
|
else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
|
|
666
666
|
else if (action === 'land/fix') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'applied fix' })
|
|
667
667
|
}
|
|
@@ -693,6 +693,34 @@
|
|
|
693
693
|
else wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`first eligible now: ${eligible}`]))
|
|
694
694
|
wrap.appendChild(el('p', { class: 'blocker' }, ['Used after a usage limit or Hand off now. A normal exit ends this terminal.']))
|
|
695
695
|
|
|
696
|
+
// The picker. The Hand off now button on the panel stays the one-click
|
|
697
|
+
// path (it takes the order); this names a destination instead. An option
|
|
698
|
+
// that cannot be picked carries the reason in its own label, so nothing is
|
|
699
|
+
// greyed out without saying why.
|
|
700
|
+
const targets = Array.isArray(s.handoff_targets) ? s.handoff_targets : []
|
|
701
|
+
if (s.active && targets.length) {
|
|
702
|
+
const pick = el('div', { class: 'form-row' })
|
|
703
|
+
const selectId = `handoff-to-${s.session_id}`
|
|
704
|
+
pick.appendChild(el('label', { for: selectId }, ['Hand off now to']))
|
|
705
|
+
const select = el('select', { id: selectId })
|
|
706
|
+
select.appendChild(el('option', { value: '' }, ['the next option in the order']))
|
|
707
|
+
targets.forEach((t, i) => {
|
|
708
|
+
const note = t.available ? '' : ` — ${t.reason}${Number.isFinite(t.resets_at) ? `, back ${until(t.resets_at)}` : ''}`
|
|
709
|
+
// the index is the value: an account name is not ours to parse
|
|
710
|
+
select.appendChild(el('option', { value: String(i), disabled: t.available ? null : 'disabled' }, [optionLabel(t) + note]))
|
|
711
|
+
})
|
|
712
|
+
const go = el('button', { type: 'button', class: 'btn btn-secondary' }, ['Hand off'])
|
|
713
|
+
go.addEventListener('click', () => {
|
|
714
|
+
const t = select.value === '' ? null : targets[Number(select.value)]
|
|
715
|
+
act(s.session_id, 'handoff', go, t ? { agent: t.agent, account: t.account } : null)
|
|
716
|
+
})
|
|
717
|
+
pick.appendChild(el('div', { class: 'chain-rail' }, [select, go]))
|
|
718
|
+
if (!targets.some((t) => t.available)) {
|
|
719
|
+
pick.appendChild(el('p', { class: 'field-help' }, ['Every destination is at its limit or not installed; a hand-off now waits for the first reset.']))
|
|
720
|
+
}
|
|
721
|
+
wrap.appendChild(pick)
|
|
722
|
+
}
|
|
723
|
+
|
|
696
724
|
const editableNow = ['starting', 'running', 'warning', 'limit', 'waiting'].includes(s.status)
|
|
697
725
|
if (!s.hidden && editableNow) {
|
|
698
726
|
let state = sessionEditors.get(s.session_id)
|
package/src/server.mjs
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
|
|
6
6
|
// multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
|
|
7
7
|
import http from 'node:http'
|
|
8
|
+
import https from 'node:https'
|
|
8
9
|
import { spawnSync, execFile } from 'node:child_process'
|
|
9
10
|
import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
10
11
|
import { join, dirname, resolve, extname, sep } from 'node:path'
|
|
11
12
|
import { fileURLToPath } from 'node:url'
|
|
12
13
|
import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
|
|
13
|
-
import { readShare, isOn as shareIsOn, sharePath, identify, personNamed } from './share.mjs'
|
|
14
|
+
import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseCards, mayUseMachine, readTls } from './share.mjs'
|
|
15
|
+
import { auditTrail, ACTOR_KINDS } from './audit.mjs'
|
|
14
16
|
import { createLimiter } from './ratelimit.mjs'
|
|
15
17
|
import { realPath, canonPath } from './fsx.mjs'
|
|
16
18
|
import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
|
|
@@ -29,7 +31,7 @@ import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs
|
|
|
29
31
|
import { hasRecentSynthesis } from './synthesis.mjs'
|
|
30
32
|
import { refreshPointers } from './resume.mjs'
|
|
31
33
|
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
32
|
-
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
34
|
+
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset } from './usage.mjs'
|
|
33
35
|
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
34
36
|
import { readCodexUsage } from './taps/codex.mjs'
|
|
35
37
|
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
|
|
@@ -343,6 +345,11 @@ function visibleSessionFile(file) {
|
|
|
343
345
|
|
|
344
346
|
export function sessionsView({ viewer = null, share = null } = {}) {
|
|
345
347
|
const shared = Boolean(share && shareIsOn(share))
|
|
348
|
+
// Decided before the map below, because the per-session payload has to know
|
|
349
|
+
// it: a guest owns their own terminal and may hand it off, so they get its
|
|
350
|
+
// list of destinations — but a reset time is this machine's usage data and
|
|
351
|
+
// belongs to nobody else, even on a terminal that is theirs.
|
|
352
|
+
const guest = shared && viewer && viewer.role !== 'owner'
|
|
346
353
|
const list = reapLost(listSessions())
|
|
347
354
|
const ov = overlaps(list)
|
|
348
355
|
const configuredAccounts = readAccounts()
|
|
@@ -360,6 +367,21 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
360
367
|
chain,
|
|
361
368
|
preferred_next: preferredNext,
|
|
362
369
|
eligible_next: eligibleNext,
|
|
370
|
+
// every destination this terminal could be handed to, each with the
|
|
371
|
+
// reason it cannot be picked right now. The board's picker renders this
|
|
372
|
+
// list directly, so a greyed option always carries its own explanation.
|
|
373
|
+
handoff_targets: chain.map((c) => {
|
|
374
|
+
const u = readUsage(c.agent, c.account)
|
|
375
|
+
const missing = availabilityKnown && s.installed[c.agent] === false
|
|
376
|
+
const walled = !isAvailable(u)
|
|
377
|
+
return {
|
|
378
|
+
agent: c.agent,
|
|
379
|
+
account: c.account,
|
|
380
|
+
available: !missing && !walled,
|
|
381
|
+
reason: missing ? 'not installed on this machine' : walled ? 'at its usage limit' : null,
|
|
382
|
+
resets_at: walled && !guest ? (u.limited_until ?? null) : null,
|
|
383
|
+
}
|
|
384
|
+
}),
|
|
363
385
|
handoff_availability_known: availabilityKnown,
|
|
364
386
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
365
387
|
active: isActive(s),
|
|
@@ -387,7 +409,6 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
387
409
|
const canon = new Map()
|
|
388
410
|
const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
|
|
389
411
|
const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
|
|
390
|
-
const guest = shared && viewer && viewer.role !== 'owner'
|
|
391
412
|
const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
392
413
|
const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
|
|
393
414
|
return {
|
|
@@ -461,14 +482,15 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
|
|
|
461
482
|
// resets the count the first is reading from.
|
|
462
483
|
const refreshCard = (id) => {
|
|
463
484
|
const card = readCard(id)
|
|
464
|
-
// pipeline cards and their events
|
|
465
|
-
|
|
485
|
+
// pipeline cards and their events belong to the people who may run them:
|
|
486
|
+
// the owner and any operator. A guest never gets them.
|
|
487
|
+
const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
|
|
466
488
|
if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
|
|
467
489
|
const events = readEvents(id)
|
|
468
490
|
// broadcast() refreshes each client's viewer (and drops revoked ones) first
|
|
469
491
|
broadcast('card', forOwner(summarize(card)))
|
|
470
492
|
for (const c of [...clients]) {
|
|
471
|
-
if (!c.viewer || c.viewer.role
|
|
493
|
+
if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
|
|
472
494
|
const from = c.sig.get(id) ?? 0
|
|
473
495
|
c.sig.set(id, events.length)
|
|
474
496
|
for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
|
|
@@ -613,7 +635,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
613
635
|
}
|
|
614
636
|
const limiter = createLimiter()
|
|
615
637
|
const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
|
|
616
|
-
const forOwner = (payload) => (viewer) => (viewer && viewer.role
|
|
638
|
+
const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
|
|
617
639
|
// SSE re-identifies each client from the live roster on every push
|
|
618
640
|
const reauthClient = (c) => {
|
|
619
641
|
const sh = currentShare()
|
|
@@ -688,19 +710,23 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
688
710
|
const rl = limiter.request(auth.person ? viewer.name : ip)
|
|
689
711
|
if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
|
|
690
712
|
const actor = { type: 'human', id: viewer.name }
|
|
691
|
-
//
|
|
692
|
-
|
|
713
|
+
// What this viewer may reach, decided once from their role (src/share.mjs).
|
|
714
|
+
// `canCards` is the pipeline board: cards, the floor, the adapters and the
|
|
715
|
+
// leases, which an operator runs. `canMachine` is everything that describes
|
|
716
|
+
// this computer rather than the work — the settings, the trunk's repo
|
|
717
|
+
// paths, the history index, the worktree map — and stays the owner's.
|
|
718
|
+
const canCards = !shared || mayUseCards(viewer.role)
|
|
719
|
+
const canMachine = !shared || mayUseMachine(viewer.role)
|
|
693
720
|
const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
694
721
|
const parts = path.split('/').filter(Boolean) // ['api', ...]
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk', 'history', 'worktrees'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
|
|
722
|
+
if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
|
|
723
|
+
if (!canMachine && ['trunk', 'history', 'worktrees', 'audit'].includes(parts[1])) return send(res, 403, { error: 'this is the map of the machine itself: every repository path and every conversation on it. It belongs to the owner of this machine.' })
|
|
698
724
|
try {
|
|
699
725
|
if (req.method === 'GET' && path === '/api/health') {
|
|
700
726
|
const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
|
|
701
|
-
if (
|
|
727
|
+
if (!canCards) return send(res, 200, { ok: true, version: VERSION, you })
|
|
702
728
|
const cards = listCards()
|
|
703
|
-
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
729
|
+
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: canMachine ? home() : null, you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
704
730
|
}
|
|
705
731
|
if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
|
|
706
732
|
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
@@ -721,14 +747,14 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
721
747
|
}
|
|
722
748
|
if (req.method === 'GET' && path === '/api/events') {
|
|
723
749
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
724
|
-
const cards =
|
|
750
|
+
const cards = canCards ? listCards() : []
|
|
725
751
|
res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
|
|
726
752
|
sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
|
|
727
753
|
return
|
|
728
754
|
}
|
|
729
755
|
if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
|
|
730
756
|
if (path === '/api/settings') {
|
|
731
|
-
if (
|
|
757
|
+
if (!canMachine) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
|
|
732
758
|
if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
|
|
733
759
|
if (req.method === 'POST' || req.method === 'PATCH') {
|
|
734
760
|
const body = await readBody(req)
|
|
@@ -865,9 +891,30 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
865
891
|
}
|
|
866
892
|
if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
|
|
867
893
|
if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
894
|
+
if (parts[3] === 'end') {
|
|
895
|
+
requestControl(id, { end: true, by: actor.id })
|
|
896
|
+
log(`end requested for ${id} by ${actor.id}`)
|
|
897
|
+
return send(res, 200, { ok: true, requested: 'end' })
|
|
898
|
+
}
|
|
899
|
+
// Hand off now, optionally to a named destination. With no body the
|
|
900
|
+
// chain decides, exactly as it did before the picker existed.
|
|
901
|
+
const body = await readBody(req)
|
|
902
|
+
let target = null
|
|
903
|
+
if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
|
|
904
|
+
const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
|
|
905
|
+
const order = normalizeHandoffOrder(sess.handoff_order)
|
|
906
|
+
const chain = candidates({ agent: sess.agent, account: sess.account, accounts: readAccounts(), order })
|
|
907
|
+
const hit = chain.find((c) => c.agent === want.agent && c.account === want.account)
|
|
908
|
+
const label = `${want.agent}${want.account !== 'default' ? '/' + want.account : ''}`
|
|
909
|
+
if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(', ') || 'none'})` })
|
|
910
|
+
if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
|
|
911
|
+
const u = readUsage(want.agent, want.account)
|
|
912
|
+
if (!isAvailable(u)) return send(res, 409, { error: `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another or use Hand off now without a destination` })
|
|
913
|
+
target = hit
|
|
914
|
+
}
|
|
915
|
+
requestControl(id, target ? { handoff: true, target, by: actor.id } : { handoff: true, by: actor.id })
|
|
916
|
+
log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${target.agent}/${target.account}` : ''}`)
|
|
917
|
+
return send(res, 200, { ok: true, requested: 'handoff', target })
|
|
871
918
|
}
|
|
872
919
|
if (req.method === 'DELETE' && parts.length === 3) {
|
|
873
920
|
if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
|
|
@@ -944,6 +991,18 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
944
991
|
const q = url.searchParams
|
|
945
992
|
return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
|
|
946
993
|
}
|
|
994
|
+
if (req.method === 'GET' && path === '/api/audit') {
|
|
995
|
+
const q = url.searchParams
|
|
996
|
+
const kind = q.get('kind')
|
|
997
|
+
if (kind && !ACTOR_KINDS.includes(kind)) return send(res, 400, { error: `kind is one of ${ACTOR_KINDS.join(', ')}` })
|
|
998
|
+
const limit = parseInt(q.get('limit') ?? '200', 10)
|
|
999
|
+
return send(res, 200, auditTrail({
|
|
1000
|
+
limit: Number.isFinite(limit) ? limit : 200,
|
|
1001
|
+
since: q.get('since'),
|
|
1002
|
+
who: q.get('who'),
|
|
1003
|
+
kind,
|
|
1004
|
+
}))
|
|
1005
|
+
}
|
|
947
1006
|
if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
|
|
948
1007
|
if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
|
|
949
1008
|
if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
|
|
@@ -1004,21 +1063,28 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
1004
1063
|
}
|
|
1005
1064
|
|
|
1006
1065
|
const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
|
|
1007
|
-
|
|
1066
|
+
// TLS when a certificate pair is configured (leg share on --tls-cert/--tls-key,
|
|
1067
|
+
// or LEG_TLS_CERT/LEG_TLS_KEY). readTls throws rather than fall back to
|
|
1068
|
+
// plaintext: a board told to use TLS and quietly serving http would be the
|
|
1069
|
+
// worst outcome of the three.
|
|
1070
|
+
const tls = readTls(initialShare)
|
|
1071
|
+
const server = tls ? https.createServer({ cert: tls.cert, key: tls.key }, onReq) : http.createServer(onReq)
|
|
1008
1072
|
// When the board is bound to a non-loopback address (share on), also listen on
|
|
1009
1073
|
// 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
|
|
1010
|
-
// remote peer's address is never loopback, so it still needs a token.
|
|
1074
|
+
// remote peer's address is never loopback, so it still needs a token. That one
|
|
1075
|
+
// stays plain http even under TLS: the certificate is for the shared name, and
|
|
1076
|
+
// loopback traffic never leaves this machine.
|
|
1011
1077
|
const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
|
|
1012
1078
|
|
|
1013
1079
|
return {
|
|
1014
1080
|
server,
|
|
1015
|
-
bind, port,
|
|
1081
|
+
bind, port, tls: tls ? { cert_path: tls.cert_path, key_path: tls.key_path } : null,
|
|
1016
1082
|
start() {
|
|
1017
1083
|
return new Promise((resolvePromise, reject) => {
|
|
1018
1084
|
server.once('error', reject)
|
|
1019
1085
|
server.listen(port, bind, () => {
|
|
1020
1086
|
const addr = server.address()
|
|
1021
|
-
log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
|
|
1087
|
+
log(`listening on ${tls ? 'https' : 'http'}://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'}${tls ? `, TLS from ${tls.cert_path}` : ''})`)
|
|
1022
1088
|
// A terminal that crashed instead of exiting left its hand-off in
|
|
1023
1089
|
// .leg/RESUME.md looking live. The board is the thing that starts
|
|
1024
1090
|
// after a crash, so it is where that gets corrected.
|
package/src/share.mjs
CHANGED
|
@@ -6,15 +6,29 @@
|
|
|
6
6
|
// (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
|
|
7
7
|
// open and `BATON_TOKEN` is the only token.
|
|
8
8
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
9
|
-
import { existsSync, readFileSync, mkdirSync } from 'node:fs'
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync, statSync } from 'node:fs'
|
|
10
10
|
import { networkInterfaces, userInfo } from 'node:os'
|
|
11
11
|
import { createSocket } from 'node:dgram'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
13
|
import { home } from './store.mjs'
|
|
14
14
|
import { writeJsonAtomic } from './fsx.mjs'
|
|
15
15
|
|
|
16
|
-
export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [] }
|
|
17
|
-
|
|
16
|
+
export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [], tls: null }
|
|
17
|
+
|
|
18
|
+
// Three roles, because two were not enough to describe a second human who runs
|
|
19
|
+
// cards on this machine but has no business in its settings or its project map.
|
|
20
|
+
// owner everything: machine settings, the harness, every terminal, cards
|
|
21
|
+
// operator the pipeline board and their own terminals; not the settings,
|
|
22
|
+
// not the history index, not anyone else's terminal
|
|
23
|
+
// guest the terminals lane, read-only and redacted; may ask for a hand-off
|
|
24
|
+
export const ROLES = ['owner', 'operator', 'guest']
|
|
25
|
+
|
|
26
|
+
// One place that says what a role may reach, so no endpoint decides for itself.
|
|
27
|
+
// `cards` is the pipeline side of the board. `machine` is everything that
|
|
28
|
+
// describes this computer rather than the work: the settings, the harness
|
|
29
|
+
// policy, the history index and the worktree map.
|
|
30
|
+
export function mayUseCards(role) { return role === 'owner' || role === 'operator' }
|
|
31
|
+
export function mayUseMachine(role) { return role === 'owner' }
|
|
18
32
|
|
|
19
33
|
export function sharePath() { return join(home(), 'share.json') }
|
|
20
34
|
|
|
@@ -55,9 +69,49 @@ export function identify(share, presented) {
|
|
|
55
69
|
export function personNamed(share, name) { return share.people.find((p) => p.name.toLowerCase() === String(name ?? '').toLowerCase()) ?? null }
|
|
56
70
|
export function isOwner(person) { return person?.role === 'owner' }
|
|
57
71
|
|
|
72
|
+
// ---- TLS ----
|
|
73
|
+
// Leg does not make certificates. It uses a pair you already have, which on a
|
|
74
|
+
// Tailscale network is one command (`tailscale cert <machine>.<tailnet>.ts.net`)
|
|
75
|
+
// and gives a certificate browsers already trust. A self-signed pair would
|
|
76
|
+
// teach everyone on the board to click through a warning, which is worse than
|
|
77
|
+
// no TLS at all on a network that is already private.
|
|
78
|
+
export class TlsRefused extends Error {
|
|
79
|
+
constructor(msg) { super(msg); this.name = 'TlsRefused'; this.exitCode = 3 }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tlsPaths(share, env) {
|
|
83
|
+
return {
|
|
84
|
+
cert: env.LEG_TLS_CERT || env.BATON_TLS_CERT || share?.tls?.cert || null,
|
|
85
|
+
key: env.LEG_TLS_KEY || env.BATON_TLS_KEY || share?.tls?.key || null,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// → { cert, key, cert_path, key_path } | null. Throws TlsRefused when a pair is
|
|
90
|
+
// configured but unusable: a board that quietly fell back to plaintext after
|
|
91
|
+
// being told to use TLS is the one failure this must not have.
|
|
92
|
+
export function readTls(share = readShare(), env = process.env) {
|
|
93
|
+
const { cert: certPath, key: keyPath } = tlsPaths(share, env)
|
|
94
|
+
if (!certPath && !keyPath) return null
|
|
95
|
+
if (!certPath || !keyPath) throw new TlsRefused('TLS needs both a certificate and a key (--tls-cert and --tls-key, or LEG_TLS_CERT and LEG_TLS_KEY)')
|
|
96
|
+
for (const [label, file] of [['certificate', certPath], ['key', keyPath]]) {
|
|
97
|
+
if (!existsSync(file)) throw new TlsRefused(`TLS ${label} not found: ${file}`)
|
|
98
|
+
try { statSync(file) } catch (err) { throw new TlsRefused(`TLS ${label} ${file}: ${err.message}`) }
|
|
99
|
+
}
|
|
100
|
+
let cert
|
|
101
|
+
let key
|
|
102
|
+
try { cert = readFileSync(certPath) } catch (err) { throw new TlsRefused(`TLS certificate ${certPath}: ${err.message}`) }
|
|
103
|
+
try { key = readFileSync(keyPath) } catch (err) { throw new TlsRefused(`TLS key ${keyPath}: ${err.message}`) }
|
|
104
|
+
if (!cert.length || !key.length) throw new TlsRefused('the TLS certificate or key is empty')
|
|
105
|
+
return { cert, key, cert_path: certPath, key_path: keyPath }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function tlsConfigured(share = readShare(), env = process.env) { return Boolean(tlsPaths(share, env).cert) }
|
|
109
|
+
|
|
110
|
+
export function scheme(share = readShare(), env = process.env) { return tlsConfigured(share, env) ? 'https' : 'http' }
|
|
111
|
+
|
|
58
112
|
export function addPerson(name, { role = 'guest', share = readShare() } = {}) {
|
|
59
113
|
if (!validName(name)) throw new Error(`bad name "${name}": letters, digits, dash and underscore, up to 32 characters`)
|
|
60
|
-
if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (
|
|
114
|
+
if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (${ROLES.join('|')})`)
|
|
61
115
|
if (personNamed(share, name)) throw new Error(`"${name}" is already on the board; baton share rotate ${name} issues a new link`)
|
|
62
116
|
const token = newToken()
|
|
63
117
|
const person = { name, role, token_sha256: hashToken(token), created_at: new Date().toISOString(), last_seen: null }
|
|
@@ -122,7 +176,7 @@ export async function resolveBind(kind = 'tailscale') {
|
|
|
122
176
|
return lan.address
|
|
123
177
|
}
|
|
124
178
|
|
|
125
|
-
export function linkFor(share, token) { return
|
|
179
|
+
export function linkFor(share, token) { return `${scheme(share)}://${share.bind}:${share.port}/?token=${token}` }
|
|
126
180
|
|
|
127
181
|
// Whose terminal this is: BATON_PERSON, else the board's owner, else 'local'.
|
|
128
182
|
export function whoami(share = readShare()) {
|
|
@@ -131,9 +185,15 @@ export function whoami(share = readShare()) {
|
|
|
131
185
|
return share.owner || 'local'
|
|
132
186
|
}
|
|
133
187
|
|
|
134
|
-
export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner } = {}) {
|
|
188
|
+
export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner, tlsCert = null, tlsKey = null } = {}) {
|
|
135
189
|
const share = readShare()
|
|
136
190
|
const address = await resolveBind(bind)
|
|
191
|
+
if (tlsCert || tlsKey) {
|
|
192
|
+
if (!tlsCert || !tlsKey) throw new TlsRefused('TLS needs both --tls-cert and --tls-key')
|
|
193
|
+
share.tls = { cert: tlsCert, key: tlsKey }
|
|
194
|
+
// read the pair now, so a bad one fails here and not at the next board start
|
|
195
|
+
readTls(share, {})
|
|
196
|
+
}
|
|
137
197
|
share.on = true
|
|
138
198
|
share.bind = address
|
|
139
199
|
share.bind_kind = ['tailscale', 'lan'].includes(String(bind).toLowerCase()) ? String(bind).toLowerCase() : 'address'
|
package/src/taps/grok.mjs
CHANGED
|
@@ -183,6 +183,10 @@ const LIMIT_RES = [
|
|
|
183
183
|
['grok-rate-limit-event', /"rate_limit"/i],
|
|
184
184
|
['grok-free-usage-exhausted', /subscription:free-usage-exhausted|You've used all of your free queries/i],
|
|
185
185
|
['grok-too-many-requests', /TOO_MANY_REQUESTS/],
|
|
186
|
+
// Observed live 2026-09-17 on grok 1.0.34: an account with no balance left
|
|
187
|
+
// answers 402, never 429, and none of the strings above appear. Without this
|
|
188
|
+
// the terminal sat on an exhausted login instead of handing off.
|
|
189
|
+
['grok-balance-exhausted', /usage balance exhausted|status 402 Payment Required/i],
|
|
186
190
|
]
|
|
187
191
|
|
|
188
192
|
// Scans log or stream text for Grok rate limit signals.
|
package/src/usage.mjs
CHANGED
|
@@ -157,21 +157,37 @@ export function candidates({ agent, account = 'default', accounts, order = AGENT
|
|
|
157
157
|
return out
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
// → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted
|
|
160
|
+
// → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted
|
|
161
|
+
// by reset, preferred_taken: bool }
|
|
161
162
|
// `exclude` names (agent, account) pairs this choice must skip: a destination
|
|
162
163
|
// the strict harness policy refused is neither available nor out, it is off
|
|
163
164
|
// the list for this hand-off.
|
|
164
|
-
|
|
165
|
+
// `prefer` is a human's pick from the board ("Hand off now to codex"). It wins
|
|
166
|
+
// over the saved order when it is installed, available and not excluded. When
|
|
167
|
+
// it is none of those the order decides instead and `preferred_taken` is false,
|
|
168
|
+
// which is what the session event says: a pick made a minute ago must not leave
|
|
169
|
+
// a terminal stopped because that account walled in the meantime.
|
|
170
|
+
export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null }) {
|
|
165
171
|
const out = []
|
|
166
|
-
|
|
172
|
+
const list = candidates({ agent, account, accounts, order })
|
|
173
|
+
const eligible = (c) => {
|
|
174
|
+
if (installed && installed[c.agent] === false) return false
|
|
175
|
+
if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) return false
|
|
176
|
+
return isAvailable(readUsage(c.agent, c.account), nowS)
|
|
177
|
+
}
|
|
178
|
+
if (prefer) {
|
|
179
|
+
const hit = list.find((c) => c.agent === prefer.agent && c.account === (prefer.account ?? 'default'))
|
|
180
|
+
if (hit && eligible(hit)) return { next: hit, out, preferred_taken: true }
|
|
181
|
+
}
|
|
182
|
+
for (const c of list) {
|
|
167
183
|
if (installed && installed[c.agent] === false) continue
|
|
168
184
|
if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) continue
|
|
169
185
|
const u = readUsage(c.agent, c.account)
|
|
170
|
-
if (isAvailable(u, nowS)) return { next: c, out }
|
|
186
|
+
if (isAvailable(u, nowS)) return { next: c, out, preferred_taken: false }
|
|
171
187
|
out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
|
|
172
188
|
}
|
|
173
189
|
out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
|
|
174
|
-
return { next: null, out }
|
|
190
|
+
return { next: null, out, preferred_taken: false }
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
export function fmtReset(epochS) {
|